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

252 lines (251 loc) 10.4 kB
import { hmac } from "@noble/hashes/hmac.js"; import { hkdf } from "@noble/hashes/hkdf.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { randomBytes } from "@noble/hashes/utils.js"; import { HybridIdentity, HybridRecipient } from "./recipients.js"; import { ScryptIdentity, ScryptRecipient } from "./recipients.js"; import { X25519Identity, X25519Recipient } from "./recipients.js"; import { TagRecipient, HybridTagRecipient } from "./recipients.js"; import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js"; import { ciphertextSize, decryptSTREAM, encryptSTREAM, plaintextSize } from "./stream.js"; import { readAll, stream, read, readAllString, prepend } from "./io.js"; export * as armor from "./armor.js"; export * as webauthn from "./webauthn.js"; export { Stanza }; export { generateIdentity, generateHybridIdentity, generateX25519Identity, identityToRecipient } from "./recipients.js"; /** * Encrypts a file using the given passphrase or recipients. * * First, call {@link Encrypter.setPassphrase} to set a passphrase for symmetric * encryption, or {@link Encrypter.addRecipient} to specify one or more * recipients. Then, call {@link Encrypter.encrypt} one or more times to encrypt * files using the configured passphrase or recipients. */ export class Encrypter { passphrase = null; scryptWorkFactor = 18; recipients = []; /** * Set the passphrase to encrypt the file(s) with. This method can only be * called once, and can't be called if {@link Encrypter.addRecipient} has * been called. * * The passphrase is passed through the scrypt key derivation function, but * it needs to have enough entropy to resist offline brute-force attacks. * You should use at least 8-10 random alphanumeric characters, or 4-5 * random words from a list of at least 2000 words. * * @param s - The passphrase to encrypt the file with. */ setPassphrase(s) { if (this.passphrase !== null) { throw new Error("can encrypt to at most one passphrase"); } if (this.recipients.length !== 0) { throw new Error("can't encrypt to both recipients and passphrases"); } this.passphrase = s; } /** * Set the scrypt work factor to use when encrypting the file(s) with a * passphrase. The default is 18. Using a lower value will require stronger * passphrases to resist offline brute-force attacks. * * @param logN - The base-2 logarithm of the scrypt work factor. */ setScryptWorkFactor(logN) { this.scryptWorkFactor = logN; } /** * Add a recipient to encrypt the file(s) for. This method can be called * multiple times to encrypt the file(s) for multiple recipients. * * This version supports native X25519 recipients (`age1...`), hybrid * post-quantum recipients (`age1pq1...`), tag recipients (`age1tag1...`), * and hybrid tag recipients (`age1tagpq1...`). * * @param s - The recipient to encrypt the file for. Either a string * beginning with `age1...` or an object implementing the {@link Recipient} * interface. */ addRecipient(s) { if (this.passphrase !== null) { throw new Error("can't encrypt to both recipients and passphrases"); } if (typeof s === "string") { if (s.startsWith("age1pq1")) { this.recipients.push(new HybridRecipient(s)); } else if (s.startsWith("age1tag1")) { this.recipients.push(new TagRecipient(s)); } else if (s.startsWith("age1tagpq1")) { this.recipients.push(new HybridTagRecipient(s)); } else if (s.startsWith("age1")) { this.recipients.push(new X25519Recipient(s)); } else { throw new Error("unrecognized recipient type"); } } else { this.recipients.push(s); } } async encrypt(file) { const fileKey = randomBytes(16); const stanzas = []; let recipients = this.recipients; if (this.passphrase !== null) { recipients = [new ScryptRecipient(this.passphrase, this.scryptWorkFactor)]; } for (const recipient of recipients) { stanzas.push(...await recipient.wrapFileKey(fileKey)); } const labelHeader = new TextEncoder().encode("header"); const hmacKey = hkdf(sha256, fileKey, undefined, labelHeader, 32); const mac = hmac(sha256, hmacKey, encodeHeaderNoMAC(stanzas)); const header = encodeHeader(stanzas, mac); const nonce = randomBytes(16); const labelPayload = new TextEncoder().encode("payload"); const streamKey = hkdf(sha256, fileKey, nonce, labelPayload, 32); const encrypter = encryptSTREAM(streamKey); if (!(file instanceof ReadableStream)) { if (typeof file === "string") file = new TextEncoder().encode(file); return await readAll(prepend(stream(file).pipeThrough(encrypter), header, nonce)); } return Object.assign(prepend(file.pipeThrough(encrypter), header, nonce), { size: (size) => ciphertextSize(size) + header.length + nonce.length }); } } /** * Decrypts a file using the given identities. * * First, call {@link Decrypter.addPassphrase} to set a passphrase for symmetric * decryption, and/or {@link Decrypter.addIdentity} to specify one or more * identities. All passphrases and/or identities are tried in parallel for each * file. Then, call {@link Decrypter.decrypt} one or more times to decrypt files * using the configured passphrase and/or identities. */ export class Decrypter { identities = []; /** * Add a passphrase to decrypt password-encrypted file(s) with. This method * can be called multiple times to try multiple passphrases. * * @param s - The passphrase to decrypt the file with. */ addPassphrase(s) { this.identities.push(new ScryptIdentity(s)); } /** * Add an identity to decrypt file(s) with. This method can be called * multiple times to try multiple identities. * * @param s - The identity to decrypt the file with. Either a string * beginning with `AGE-SECRET-KEY-PQ-1...` or `AGE-SECRET-KEY-1...`, an * X25519 private * {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey | CryptoKey} * object, or an object implementing the {@link Identity} interface. * * A CryptoKey object must have * {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey/type | type} * `private`, * {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey/algorithm | algorithm} * `{name: 'X25519'}`, and * {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey/usages | usages} * `["deriveBits"]`. For example: * ```js * const keyPair = await crypto.subtle.generateKey({ name: "X25519" }, false, ["deriveBits"]) * decrypter.addIdentity(key.privateKey) * ``` */ addIdentity(s) { if (isCryptoKey(s)) { this.identities.push(new X25519Identity(s)); } else if (typeof s === "string") { if (s.startsWith("AGE-SECRET-KEY-1")) { this.identities.push(new X25519Identity(s)); } else if (s.startsWith("AGE-SECRET-KEY-PQ-1")) { this.identities.push(new HybridIdentity(s)); } else { throw new Error("unrecognized identity type"); } } else { this.identities.push(s); } } async decrypt(file, outputFormat) { const s = file instanceof ReadableStream ? file : stream(file); const { fileKey, headerSize, rest } = await this.decryptHeaderInternal(s); const { data: nonce, rest: payload } = await read(rest, 16); const label = new TextEncoder().encode("payload"); const streamKey = hkdf(sha256, fileKey, nonce, label, 32); const decrypter = decryptSTREAM(streamKey); const out = payload.pipeThrough(decrypter); const outWithSize = Object.assign(out, { size: (size) => plaintextSize(size - headerSize - nonce.length) }); if (file instanceof ReadableStream) return outWithSize; if (outputFormat === "text") return await readAllString(out); return await readAll(out); } /** * Decrypt the file key from a detached header. This is a low-level * function that can be used to implement delegated decryption logic. * Most users won't need this. * * It is the caller's responsibility to keep track of what file the * returned file key decrypts, and to ensure the file key is not used * for any other purpose. * * @param header - The file's textual header, including the MAC. * * @returns The file key used to encrypt the file. */ async decryptHeader(header) { return (await this.decryptHeaderInternal(stream(header))).fileKey; } async decryptHeaderInternal(file) { const h = await parseHeader(file); const fileKey = await this.unwrapFileKey(h.stanzas); if (fileKey === null) throw Error("no identity matched any of the file's recipients"); const label = new TextEncoder().encode("header"); const hmacKey = hkdf(sha256, fileKey, undefined, label, 32); const mac = hmac(sha256, hmacKey, h.headerNoMAC); if (!compareBytes(h.MAC, mac)) throw Error("invalid header HMAC"); return { fileKey, headerSize: h.headerSize, rest: h.rest }; } async unwrapFileKey(stanzas) { for (const identity of this.identities) { const fileKey = await identity.unwrapFileKey(stanzas); if (fileKey !== null) return fileKey; } return null; } } function compareBytes(a, b) { if (a.length !== b.length) { return false; } let acc = 0; for (let i = 0; i < a.length; i++) { acc |= a[i] ^ b[i]; } return acc === 0; } function isCryptoKey(key) { return typeof CryptoKey !== "undefined" && key instanceof CryptoKey; }