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

197 lines (196 loc) 7.69 kB
import { hmac } from "@noble/hashes/hmac"; import { hkdf } from "@noble/hashes/hkdf"; import { sha256 } from "@noble/hashes/sha256"; import { randomBytes } from "@noble/hashes/utils"; import { ScryptIdentity, ScryptRecipient, X25519Identity, X25519Recipient } from "./recipients.js"; import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js"; import { decryptSTREAM, encryptSTREAM } from "./stream.js"; export * as armor from "./armor.js"; export { Stanza }; export { generateIdentity, 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. * * @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") { this.recipients.push(new X25519Recipient(s)); } else { this.recipients.push(s); } } /** * Encrypt a file using the configured passphrase or recipients. * * @param file - The file to encrypt. If a string is passed, it will be * encoded as UTF-8. * * @returns A promise that resolves to the encrypted file as a Uint8Array. */ async encrypt(file) { if (typeof file === "string") { file = new TextEncoder().encode(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 hmacKey = hkdf(sha256, fileKey, undefined, "header", 32); const mac = hmac(sha256, hmacKey, encodeHeaderNoMAC(stanzas)); const header = encodeHeader(stanzas, mac); const nonce = randomBytes(16); const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32); const payload = encryptSTREAM(streamKey, file); const out = new Uint8Array(header.length + nonce.length + payload.length); out.set(header); out.set(nonce, header.length); out.set(payload, header.length + nonce.length); return out; } } /** * 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-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 (typeof s === "string" || isCryptoKey(s)) { this.identities.push(new X25519Identity(s)); } else { this.identities.push(s); } } async decrypt(file, outputFormat) { const h = parseHeader(file); const fileKey = await this.unwrapFileKey(h.stanzas); if (fileKey === null) { throw Error("no identity matched any of the file's recipients"); } const hmacKey = hkdf(sha256, fileKey, undefined, "header", 32); const mac = hmac(sha256, hmacKey, h.headerNoMAC); if (!compareBytes(h.MAC, mac)) { throw Error("invalid header HMAC"); } const nonce = h.rest.subarray(0, 16); const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32); const payload = h.rest.subarray(16); const out = decryptSTREAM(streamKey, payload); if (outputFormat === "text") return new TextDecoder().decode(out); return out; } 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; }