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

201 lines (200 loc) 7.78 kB
import { bech32, base64nopad } from "@scure/base"; import { randomBytes } from "@noble/hashes/utils"; import { extract } from "@noble/hashes/hkdf"; import { sha256 } from "@noble/hashes/sha256"; import { Stanza } from "./format.js"; import { decryptFileKey, encryptFileKey } from "./recipients.js"; import * as cbor from "./cbor.js"; // We don't actually use the public key, so declare support for all default // algorithms that might be supported by authenticators. const defaultAlgorithms = [ { type: "public-key", alg: -8 }, // Ed25519 { type: "public-key", alg: -7 }, // ECDSA with P-256 and SHA-256 { type: "public-key", alg: -257 }, // RSA PKCS#1 v1.5 with SHA-256 ]; /** * Creates a new WebAuthn credential which can be used for encryption and * decryption. * * @returns The identity string to use for encryption or decryption. * * This string begins with `AGE-PLUGIN-FIDO2PRF-1...` and encodes the credential ID, * the relying party ID, and the transport hint. * * If the credential was created with {@link CreationOptions."type"} set to the * default `passkey`, this string is mostly a hint to make selecting the * credential easier. If the credential was created with `security-key`, this * string is required to encrypt and decrypt files, and can't be regenerated if * lost. * * @see {@link Options.identity} * @experimental */ export async function createCredential(options) { const cred = await navigator.credentials.create({ publicKey: { rp: { name: "", id: options.rpId }, user: { name: options.keyName, id: randomBytes(8), // avoid overwriting existing keys displayName: "", }, pubKeyCredParams: defaultAlgorithms, authenticatorSelection: { requireResidentKey: options.type !== "security-key", residentKey: options.type !== "security-key" ? "required" : "discouraged", userVerification: "required", // prf requires UV }, hints: options.type === "security-key" ? ["security-key"] : [], extensions: { prf: {} }, challenge: new Uint8Array([0]).buffer, // unused without attestation }, }); if (!cred.getClientExtensionResults().prf?.enabled) { throw Error("PRF extension not available (need macOS 15+, Chrome 132+)"); } // Annoyingly, it doesn't seem possible to get the RP ID from the // credential, so we have to hope we get the default right. const rpId = options.rpId ?? new URL(window.origin).hostname; return encodeIdentity(cred, rpId); } const prefix = "AGE-PLUGIN-FIDO2PRF-"; function encodeIdentity(credential, rpId) { const res = credential.response; const version = cbor.encodeUint(1); const credId = cbor.encodeByteString(new Uint8Array(credential.rawId)); const rp = cbor.encodeTextString(rpId); const transports = cbor.encodeArray(res.getTransports()); const identityData = new Uint8Array([...version, ...credId, ...rp, ...transports]); return bech32.encode(prefix, bech32.toWords(identityData), false).toUpperCase(); } function decodeIdentity(identity) { const res = bech32.decodeToBytes(identity); if (!identity.startsWith(prefix + "1")) { throw Error("invalid identity"); } const [version, rest1] = cbor.readUint(res.bytes); if (version !== 1) { throw Error("unsupported identity version"); } const [credId, rest2] = cbor.readByteString(rest1); const [rpId, rest3] = cbor.readTextString(rest2); const [transports,] = cbor.readArray(rest3); return [credId, rpId, transports]; } const label = "age-encryption.org/fido2prf"; class WebAuthnInternal { credId; transports; rpId; constructor(options) { if (options?.identity) { const [credId, rpId, transports] = decodeIdentity(options.identity); this.credId = credId; this.transports = transports; this.rpId = rpId; } else { this.rpId = options?.rpId; } } async getCredential(nonce) { const assertion = await navigator.credentials.get({ publicKey: { allowCredentials: this.credId ? [{ id: this.credId, transports: this.transports, type: "public-key" }] : [], challenge: randomBytes(16), extensions: { prf: { eval: prfInputs(nonce) } }, userVerification: "required", // prf requires UV rpId: this.rpId, }, }); const results = assertion.getClientExtensionResults().prf?.results; if (results === undefined) { throw Error("PRF extension not available (need macOS 15+, Chrome 132+)"); } return results; } } /** * A {@link Recipient} that symmetrically encrypts file keys using a WebAuthn * credential, such as a passkey or a security key. * * The credential needs to already exist, and support the PRF extension. * Usually, it would have been created with {@link createCredential}. * * @see {@link Encrypter.addRecipient} * @experimental */ export class WebAuthnRecipient extends WebAuthnInternal { /** * Implements {@link Recipient.wrapFileKey}. */ async wrapFileKey(fileKey) { const nonce = randomBytes(16); const results = await this.getCredential(nonce); const key = deriveKey(results); return [new Stanza([label, base64nopad.encode(nonce)], encryptFileKey(fileKey, key))]; } } /** * An {@link Identity} that symmetrically decrypts file keys using a WebAuthn * credential, such as a passkey or a security key. * * The credential needs to already exist, and support the PRF extension. * Usually, it would have been created with {@link createCredential}. * * @see {@link Decrypter.addIdentity} * @experimental */ export class WebAuthnIdentity extends WebAuthnInternal { /** * Implements {@link Identity.unwrapFileKey}. */ async unwrapFileKey(stanzas) { for (const s of stanzas) { if (s.args.length < 1 || s.args[0] !== label) { continue; } if (s.args.length !== 2) { throw Error("invalid prf stanza"); } const nonce = base64nopad.decode(s.args[1]); if (nonce.length !== 16) { throw Error("invalid prf stanza"); } const results = await this.getCredential(nonce); const key = deriveKey(results); const fileKey = decryptFileKey(s.body, key); if (fileKey !== null) return fileKey; } return null; } } // We use both first and second to prevent an attacker from decrypting two files // at once with a single user presence check. function prfInputs(nonce) { const prefix = new TextEncoder().encode(label); const first = new Uint8Array(prefix.length + nonce.length + 1); first.set(prefix, 0); first[prefix.length] = 0x01; first.set(nonce, prefix.length + 1); const second = new Uint8Array(prefix.length + nonce.length + 1); second.set(prefix, 0); second[prefix.length] = 0x02; second.set(nonce, prefix.length + 1); return { first, second }; } function deriveKey(results) { if (results.second === undefined) { throw Error("Missing second PRF result"); } const prf = new Uint8Array(results.first.byteLength + results.second.byteLength); prf.set(new Uint8Array(results.first), 0); prf.set(new Uint8Array(results.second), results.first.byteLength); return extract(sha256, prf, label); }