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