UNPKG

age-encryption

Version:

<p align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://github.com/FiloSottile/age/blob/main/logo/logo_white.svg"> <source media="(prefers-color-scheme: light)" srcset="https://github.com/FiloSottile/a

193 lines (192 loc) 6.93 kB
import { bech32 } from "@scure/base"; import { hkdf } from "@noble/hashes/hkdf"; import { sha256 } from "@noble/hashes/sha256"; import { scrypt } from "@noble/hashes/scrypt"; import { chacha20poly1305 } from "@noble/ciphers/chacha"; import { randomBytes } from "@noble/hashes/utils"; import { base64nopad } from "@scure/base"; import * as x25519 from "./x25519.js"; import { Stanza } from "./format.js"; /** * Generate a new native age identity. * * @returns A promise that resolves to the new identity, a string starting with * `AGE-SECRET-KEY-1...`. Use {@link identityToRecipient} to produce the * corresponding recipient. */ export function generateIdentity() { const scalar = randomBytes(32); const identity = bech32.encode("AGE-SECRET-KEY-", bech32.toWords(scalar)).toUpperCase(); return Promise.resolve(identity); } /** * Convert an age identity to a recipient. * * @param identity - An age identity, a string starting with * `AGE-SECRET-KEY-1...` or an X25519 private * {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey | CryptoKey} * object. * * @returns A promise that resolves to the corresponding recipient, a string * starting with `age1...`. * * @see {@link generateIdentity} * @see {@link Decrypter.addIdentity} */ export async function identityToRecipient(identity) { let scalar; if (isCryptoKey(identity)) { scalar = identity; } else { const res = bech32.decodeToBytes(identity); if (!identity.startsWith("AGE-SECRET-KEY-1") || res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" || res.bytes.length !== 32) { throw Error("invalid identity"); } scalar = res.bytes; } const recipient = await x25519.scalarMultBase(scalar); return bech32.encode("age", bech32.toWords(recipient)); } export class X25519Recipient { recipient; constructor(s) { const res = bech32.decodeToBytes(s); if (!s.startsWith("age1") || res.prefix.toLowerCase() !== "age" || res.bytes.length !== 32) { throw Error("invalid recipient"); } this.recipient = res.bytes; } async wrapFileKey(fileKey) { const ephemeral = randomBytes(32); const share = await x25519.scalarMultBase(ephemeral); const secret = await x25519.scalarMult(ephemeral, this.recipient); const salt = new Uint8Array(share.length + this.recipient.length); salt.set(share); salt.set(this.recipient, share.length); const key = hkdf(sha256, secret, salt, "age-encryption.org/v1/X25519", 32); return [new Stanza(["X25519", base64nopad.encode(share)], encryptFileKey(fileKey, key))]; } } export class X25519Identity { identity; recipient; constructor(s) { if (isCryptoKey(s)) { this.identity = s; this.recipient = x25519.scalarMultBase(s); return; } const res = bech32.decodeToBytes(s); if (!s.startsWith("AGE-SECRET-KEY-1") || res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" || res.bytes.length !== 32) { throw Error("invalid identity"); } this.identity = res.bytes; this.recipient = x25519.scalarMultBase(res.bytes); } async unwrapFileKey(stanzas) { for (const s of stanzas) { if (s.args.length < 1 || s.args[0] !== "X25519") { continue; } if (s.args.length !== 2) { throw Error("invalid X25519 stanza"); } const share = base64nopad.decode(s.args[1]); if (share.length !== 32) { throw Error("invalid X25519 stanza"); } const secret = await x25519.scalarMult(this.identity, share); const recipient = await this.recipient; const salt = new Uint8Array(share.length + recipient.length); salt.set(share); salt.set(recipient, share.length); const key = hkdf(sha256, secret, salt, "age-encryption.org/v1/X25519", 32); const fileKey = decryptFileKey(s.body, key); if (fileKey !== null) return fileKey; } return null; } } export class ScryptRecipient { passphrase; logN; constructor(passphrase, logN) { this.passphrase = passphrase; this.logN = logN; } wrapFileKey(fileKey) { const salt = randomBytes(16); const label = "age-encryption.org/v1/scrypt"; const labelAndSalt = new Uint8Array(label.length + 16); labelAndSalt.set(new TextEncoder().encode(label)); labelAndSalt.set(salt, label.length); const key = scrypt(this.passphrase, labelAndSalt, { N: 2 ** this.logN, r: 8, p: 1, dkLen: 32 }); return [new Stanza(["scrypt", base64nopad.encode(salt), this.logN.toString()], encryptFileKey(fileKey, key))]; } } export class ScryptIdentity { passphrase; constructor(passphrase) { this.passphrase = passphrase; } unwrapFileKey(stanzas) { for (const s of stanzas) { if (s.args.length < 1 || s.args[0] !== "scrypt") { continue; } if (stanzas.length !== 1) { throw Error("scrypt recipient is not the only one in the header"); } if (s.args.length !== 3) { throw Error("invalid scrypt stanza"); } if (!/^[1-9][0-9]*$/.test(s.args[2])) { throw Error("invalid scrypt stanza"); } const salt = base64nopad.decode(s.args[1]); if (salt.length !== 16) { throw Error("invalid scrypt stanza"); } const logN = Number(s.args[2]); if (logN > 20) { throw Error("scrypt work factor is too high"); } const label = "age-encryption.org/v1/scrypt"; const labelAndSalt = new Uint8Array(label.length + 16); labelAndSalt.set(new TextEncoder().encode(label)); labelAndSalt.set(salt, label.length); const key = scrypt(this.passphrase, labelAndSalt, { N: 2 ** logN, r: 8, p: 1, dkLen: 32 }); const fileKey = decryptFileKey(s.body, key); if (fileKey !== null) return fileKey; } return null; } } function encryptFileKey(fileKey, key) { const nonce = new Uint8Array(12); return chacha20poly1305(key, nonce).encrypt(fileKey); } function decryptFileKey(body, key) { if (body.length !== 32) { throw Error("invalid stanza"); } const nonce = new Uint8Array(12); try { return chacha20poly1305(key, nonce).decrypt(body); } catch { return null; } } function isCryptoKey(key) { return typeof CryptoKey !== "undefined" && key instanceof CryptoKey; }