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
JavaScript
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;
}