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

418 lines (417 loc) 16.7 kB
import { bech32 } from "@scure/base"; import { hkdf, extract, expand } from "@noble/hashes/hkdf.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { scrypt } from "@noble/hashes/scrypt.js"; import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; import { MLKEM768P256, MLKEM768X25519 } from "@noble/post-quantum/hybrid.js"; import { p256 } from "@noble/curves/nist.js"; import { randomBytes } from "@noble/hashes/utils.js"; import { base64nopad } from "@scure/base"; import * as x25519 from "./x25519.js"; import { Stanza } from "./format.js"; import {} from "./index.js"; /** * Generate a new native age identity. * * Currently, this returns an X25519 identity. In the future, this may return a * post-quantum hybrid identity like {@link generateHybridIdentity}. To * explicitly generate an X25519 identity, use {@link generateX25519Identity}. * * @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() { return generateX25519Identity(); } /** * Generate a new X25519 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 generateX25519Identity() { const scalar = randomBytes(32); const identity = bech32.encodeFromBytes("AGE-SECRET-KEY-", scalar).toUpperCase(); return Promise.resolve(identity); } /** * Generate a new post-quantum hybrid native age identity. * * @returns A promise that resolves to the new identity, a string starting with * `AGE-SECRET-KEY-PQ-1...`. Use {@link identityToRecipient} to produce the * corresponding recipient. */ export function generateHybridIdentity() { const scalar = randomBytes(32); const identity = bech32.encodeFromBytes("AGE-SECRET-KEY-PQ-", 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-PQ-1...` or `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 if (identity.startsWith("AGE-SECRET-KEY-PQ-1")) { const res = bech32.decodeToBytes(identity); if (res.prefix.toUpperCase() !== "AGE-SECRET-KEY-PQ-" || res.bytes.length !== 32) { throw Error("invalid identity"); } const recipient = MLKEM768X25519.getPublicKey(res.bytes); // Use encode directly to disable the 90 character bech32 limit. return bech32.encode("age1pq", bech32.toWords(recipient), false); } 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.encodeFromBytes("age", recipient); } export class HybridRecipient { recipient; constructor(s) { const res = bech32.decodeToBytes(s); if (!s.startsWith("age1pq1") || res.prefix.toLowerCase() !== "age1pq" || res.bytes.length !== 1216) { throw Error("invalid recipient"); } this.recipient = res.bytes; } wrapFileKey(fileKey) { const { cipherText: encapsulatedKey, sharedSecret } = MLKEM768X25519.encapsulate(this.recipient); const label = new TextEncoder().encode("age-encryption.org/mlkem768x25519"); const { key, nonce } = hpkeContext(hpkeMLKEM768X25519, sharedSecret, label); const ciphertext = chacha20poly1305(key, nonce).encrypt(fileKey); return [new Stanza(["mlkem768x25519", base64nopad.encode(encapsulatedKey)], ciphertext)]; } } export class HybridIdentity { identity; constructor(s) { const res = bech32.decodeToBytes(s); if (!s.startsWith("AGE-SECRET-KEY-PQ-1") || res.prefix.toUpperCase() !== "AGE-SECRET-KEY-PQ-" || res.bytes.length !== 32) { throw Error("invalid identity"); } this.identity = res.bytes; } unwrapFileKey(stanzas) { for (const s of stanzas) { if (s.args.length < 1 || s.args[0] !== "mlkem768x25519") { continue; } if (s.args.length !== 2) { throw Error("invalid mlkem768x25519 stanza"); } const share = base64nopad.decode(s.args[1]); if (share.length !== 1120) { throw Error("invalid mlkem768x25519 stanza"); } if (s.body.length !== 32) { throw Error("invalid mlkem768x25519 stanza"); } const sharedSecret = MLKEM768X25519.decapsulate(share, this.identity); const label = new TextEncoder().encode("age-encryption.org/mlkem768x25519"); const { key, nonce } = hpkeContext(hpkeMLKEM768X25519, sharedSecret, label); try { return chacha20poly1305(key, nonce).decrypt(s.body); } catch { continue; } } return null; } } const hpkeMLKEM768X25519 = 0x647a; const hpkeMLKEM768P256 = 0x0050; const hpkeDHKEMP256 = 0x0010; function hpkeContext(kemID, sharedSecret, info) { const suiteID = hpkeSuiteID(kemID); const pskIDHash = hpkeLabeledExtract(suiteID, undefined, "psk_id_hash", new Uint8Array(0)); const infoHash = hpkeLabeledExtract(suiteID, undefined, "info_hash", info); const ksContext = new Uint8Array(1 + pskIDHash.length + infoHash.length); ksContext[0] = 0x00; // mode_base ksContext.set(pskIDHash, 1); ksContext.set(infoHash, 1 + pskIDHash.length); const secret = hpkeLabeledExtract(suiteID, sharedSecret, "secret", new Uint8Array(0)); const key = hpkeLabeledExpand(suiteID, secret, "key", ksContext, 32); const nonce = hpkeLabeledExpand(suiteID, secret, "base_nonce", ksContext, 12); return { key, nonce }; } function hpkeSuiteID(kemID) { const suiteID = new Uint8Array(10); suiteID.set(new TextEncoder().encode("HPKE"), 0); suiteID[4] = (kemID >> 8) & 0xff; suiteID[5] = kemID & 0xff; // KDF ID for HKDF-SHA256 is 0x0001 suiteID[6] = 0x00; suiteID[7] = 0x01; // AEAD ID for ChaCha20Poly1305 is 0x0003 suiteID[8] = 0x00; suiteID[9] = 0x03; return suiteID; } function hpkeLabeledExtract(suiteID, salt, label, ikm) { const labeledIKM = new Uint8Array(7 + suiteID.length + label.length + ikm.length); let offset = 0; labeledIKM.set(new TextEncoder().encode("HPKE-v1"), offset); offset += "HPKE-v1".length; labeledIKM.set(suiteID, offset); offset += suiteID.length; labeledIKM.set(new TextEncoder().encode(label), offset); offset += label.length; labeledIKM.set(ikm, offset); return extract(sha256, labeledIKM, salt); } function hpkeLabeledExpand(suiteID, prk, label, info, length) { const labeledInfo = new Uint8Array(2 + 7 + suiteID.length + label.length + info.length); let offset = 0; labeledInfo[offset] = (length >> 8) & 0xff; labeledInfo[offset + 1] = length & 0xff; offset += 2; labeledInfo.set(new TextEncoder().encode("HPKE-v1"), offset); offset += "HPKE-v1".length; labeledInfo.set(suiteID, offset); offset += suiteID.length; labeledInfo.set(new TextEncoder().encode(label), offset); offset += label.length; labeledInfo.set(info, offset); return expand(sha256, prk, labeledInfo, length); } function hpkeDHKEMP256Encapsulate(recipient) { if (recipient.length !== p256.lengths.publicKeyUncompressed) { recipient = p256.Point.fromBytes(recipient).toBytes(false); } const ephemeral = p256.utils.randomSecretKey(); const encapsulatedKey = p256.getPublicKey(ephemeral, false); const ss = p256.getSharedSecret(ephemeral, recipient, true).subarray(1); const kemContext = new Uint8Array(encapsulatedKey.length + recipient.length); kemContext.set(encapsulatedKey, 0); kemContext.set(recipient, encapsulatedKey.length); const suiteID = new Uint8Array(5); suiteID.set(new TextEncoder().encode("KEM"), 0); suiteID[3] = hpkeDHKEMP256 >> 8; suiteID[4] = hpkeDHKEMP256 & 0xff; const eaePRK = hpkeLabeledExtract(suiteID, undefined, "eae_prk", ss); const sharedSecret = hpkeLabeledExpand(suiteID, eaePRK, "shared_secret", kemContext, 32); return { encapsulatedKey, sharedSecret }; } export class TagRecipient { recipient; constructor(s) { const res = bech32.decodeToBytes(s); if (!s.startsWith("age1tag1") || res.prefix.toLowerCase() !== "age1tag" || res.bytes.length !== 33) { throw Error("invalid recipient"); } this.recipient = res.bytes; } wrapFileKey(fileKey) { const { encapsulatedKey, sharedSecret } = hpkeDHKEMP256Encapsulate(this.recipient); const label = new TextEncoder().encode("age-encryption.org/p256tag"); const tag = (() => { const recipientHash = sha256(this.recipient).subarray(0, 4); const ikm = new Uint8Array(encapsulatedKey.length + recipientHash.length); ikm.set(encapsulatedKey, 0); ikm.set(recipientHash, encapsulatedKey.length); return extract(sha256, ikm, label).subarray(0, 4); })(); const { key, nonce } = hpkeContext(hpkeDHKEMP256, sharedSecret, label); const ciphertext = chacha20poly1305(key, nonce).encrypt(fileKey); return [new Stanza(["p256tag", base64nopad.encode(tag), base64nopad.encode(encapsulatedKey)], ciphertext)]; } } export class HybridTagRecipient { recipient; constructor(s) { const res = bech32.decodeToBytes(s); if (!s.startsWith("age1tagpq1") || res.prefix.toLowerCase() !== "age1tagpq" || res.bytes.length !== 1249) { throw Error("invalid recipient"); } this.recipient = res.bytes; } wrapFileKey(fileKey) { const { cipherText: encapsulatedKey, sharedSecret } = MLKEM768P256.encapsulate(this.recipient); const label = new TextEncoder().encode("age-encryption.org/mlkem768p256tag"); const tag = (() => { const recipientHash = sha256(this.recipient.subarray(1184)).subarray(0, 4); const ikm = new Uint8Array(encapsulatedKey.length + recipientHash.length); ikm.set(encapsulatedKey, 0); ikm.set(recipientHash, encapsulatedKey.length); return extract(sha256, ikm, label).subarray(0, 4); })(); const { key, nonce } = hpkeContext(hpkeMLKEM768P256, sharedSecret, label); const ciphertext = chacha20poly1305(key, nonce).encrypt(fileKey); return [new Stanza(["mlkem768p256tag", base64nopad.encode(tag), base64nopad.encode(encapsulatedKey)], ciphertext)]; } } 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 label = new TextEncoder().encode("age-encryption.org/v1/X25519"); const key = hkdf(sha256, secret, salt, label, 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 label = new TextEncoder().encode("age-encryption.org/v1/X25519"); const key = hkdf(sha256, secret, salt, label, 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; } } export function encryptFileKey(fileKey, key) { const nonce = new Uint8Array(12); return chacha20poly1305(key, nonce).encrypt(fileKey); } export 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; }