diff --git a/README.md b/README.md index 3711294..80dfe98 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ npm install age-encryption ## Usage `age-encryption` is a modern ES Module with built-in types. + It's compiled for ES2022, compatible with Node.js 18+, Bun, and all recent browsers. #### Encrypt and decrypt a file with a new recipient / identity pair diff --git a/lib/cbor.ts b/lib/cbor.ts new file mode 100644 index 0000000..830cc03 --- /dev/null +++ b/lib/cbor.ts @@ -0,0 +1,131 @@ +// This file implements a tiny subset of CTAP2's subset of CBOR, in order to +// encode and decode WebAuthn identities. +// +// Only major types 0 (unsigned integer), 2 (byte strings), 3 (text strings), +// and 4 (arrays, only containing text strings) are supported. Arguments are +// limited to 16-bit values. +// +// See https://www.imperialviolet.org/tourofwebauthn/tourofwebauthn.html#cbor. + +function readTypeAndArgument(b: Uint8Array): [number, number, Uint8Array] { + if (b.length === 0) { + throw Error("cbor: unexpected EOF") + } + const major = b[0] >> 5 + const minor = b[0] & 0x1f + if (minor <= 23) { + return [major, minor, b.subarray(1)] + } + if (minor === 24) { + if (b.length < 2) { + throw Error("cbor: unexpected EOF") + } + return [major, b[1], b.subarray(2)] + } + if (minor === 25) { + if (b.length < 3) { + throw Error("cbor: unexpected EOF") + } + return [major, (b[1] << 8) | b[2], b.subarray(3)] + } + throw Error("cbor: unsupported argument encoding") +} + +export function readUint(b: Uint8Array): [number, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 0) { + throw Error("cbor: expected unsigned integer") + } + return [minor, rest] +} + +export function readByteString(b: Uint8Array): [Uint8Array, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 2) { + throw Error("cbor: expected byte string") + } + if (minor > rest.length) { + throw Error("cbor: unexpected EOF") + } + return [rest.subarray(0, minor), rest.subarray(minor)] +} + +export function readTextString(b: Uint8Array): [string, Uint8Array] { + const [major, minor, rest] = readTypeAndArgument(b) + if (major !== 3) { + throw Error("cbor: expected text string") + } + if (minor > rest.length) { + throw Error("cbor: unexpected EOF") + } + return [new TextDecoder().decode(rest.subarray(0, minor)), rest.subarray(minor)] +} + +export function readArray(b: Uint8Array): [string[], Uint8Array] { + const [major, minor, r] = readTypeAndArgument(b) + if (major !== 4) { + throw Error("cbor: expected array") + } + let rest = r + const args = [] + for (let i = 0; i < minor; i++) { + let arg + [arg, rest] = readTextString(rest) + args.push(arg) + } + return [args, rest] +} + +export function encodeUint(n: number): Uint8Array { + if (n <= 23) { + return new Uint8Array([n]) + } + if (n <= 0xff) { + return new Uint8Array([24, n]) + } + if (n <= 0xffff) { + return new Uint8Array([25, n >> 8, n & 0xff]) + } + throw Error("cbor: unsigned integer too large") +} + +export function encodeByteString(b: Uint8Array): Uint8Array { + if (b.length <= 23) { + return new Uint8Array([2 << 5 | b.length, ...b]) + } + if (b.length <= 0xff) { + return new Uint8Array([2 << 5 | 24, b.length, ...b]) + } + if (b.length <= 0xffff) { + return new Uint8Array([2 << 5 | 25, b.length >> 8, b.length & 0xff, ...b]) + } + throw Error("cbor: byte string too long") +} + +export function encodeTextString(s: string): Uint8Array { + const b = new TextEncoder().encode(s) + if (b.length <= 23) { + return new Uint8Array([3 << 5 | b.length, ...b]) + } + if (b.length <= 0xff) { + return new Uint8Array([3 << 5 | 24, b.length, ...b]) + } + if (b.length <= 0xffff) { + return new Uint8Array([3 << 5 | 25, b.length >> 8, b.length & 0xff, ...b]) + } + throw Error("cbor: text string too long") +} + +export function encodeArray(args: string[]): Uint8Array { + const body = args.flatMap(x => [...encodeTextString(x)]) + if (args.length <= 23) { + return new Uint8Array([4 << 5 | args.length, ...body]) + } + if (args.length <= 0xff) { + return new Uint8Array([4 << 5 | 24, args.length, ...body]) + } + if (args.length <= 0xffff) { + return new Uint8Array([4 << 5 | 25, args.length >> 8, args.length & 0xff, ...body]) + } + throw Error("cbor: array too long") +} diff --git a/lib/index.ts b/lib/index.ts index ddc6046..935151a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,6 +8,8 @@ import { decryptSTREAM, encryptSTREAM } from "./stream.js" export * as armor from "./armor.js" +export * as webauthn from "./webauthn.js" + export { Stanza } /** diff --git a/lib/recipients.ts b/lib/recipients.ts index c840067..7115fab 100644 --- a/lib/recipients.ts +++ b/lib/recipients.ts @@ -18,7 +18,7 @@ import { Identity, Recipient } from "./index.js" */ export function generateIdentity(): Promise { const scalar = randomBytes(32) - const identity = bech32.encode("AGE-SECRET-KEY-", bech32.toWords(scalar)).toUpperCase() + const identity = bech32.encodeFromBytes("AGE-SECRET-KEY-", scalar).toUpperCase() return Promise.resolve(identity) } @@ -49,7 +49,7 @@ export async function identityToRecipient(identity: string | CryptoKey): Promise } const recipient = await x25519.scalarMultBase(scalar) - return bech32.encode("age", bech32.toWords(recipient)) + return bech32.encodeFromBytes("age", recipient) } export class X25519Recipient implements Recipient { @@ -188,12 +188,12 @@ export class ScryptIdentity implements Identity { } } -function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array { +export function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array { const nonce = new Uint8Array(12) return chacha20poly1305(key, nonce).encrypt(fileKey) } -function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null { +export function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null { if (body.length !== 32) { throw Error("invalid stanza") } diff --git a/lib/webauthn.ts b/lib/webauthn.ts new file mode 100644 index 0000000..9a03c29 --- /dev/null +++ b/lib/webauthn.ts @@ -0,0 +1,298 @@ +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 { Identity, Recipient } from "./index.js" +import { Stanza } from "./format.js" +import { decryptFileKey, encryptFileKey } from "./recipients.js" +import * as cbor from "./cbor.js" + +/** + * Options for {@link createCredential}. + */ +export interface CreationOptions { + /** + * The name of the key. This will be shown in various platform UIs. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#name_2 | PublicKeyCredentialCreationOptions.user.name} + */ + keyName: string; + + /** + * The type of credential to create. + * + * If the default `passkey` is used, the credential will be required to be + * discoverable. This means that the user will be able to select it from a + * list of credentials even if {@link Options.identity} is not set. + * + * If `security-key` is used, the `security-key` hint and the `discouraged` + * residentKey option will be passed to the authenticator. The returned + * identity string MUST be passed with {@link Options.identity} to encrypt + * and decrypt files, and CAN'T be regenerated if lost. The UI will prompt + * the user to use a hardware token. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#residentkey | PublicKeyCredentialCreationOptions.authenticatorSelection.residentKey} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#hints | PublicKeyCredentialCreationOptions.hints} + */ + type?: "passkey" | "security-key"; + + /** + * The relying party ID to use for the WebAuthn credential. + * + * This must be the origin's domain (e.g. `app.example.com`), or a parent + * (e.g. `example.com`). Note that credentials are available to subdomains + * of the RP ID, but not to parents, so it's important to choose the right + * RP ID. + * + * @see {@link https://www.imperialviolet.org/tourofwebauthn/tourofwebauthn.html#relying-party-ids | A Tour of WebAuthn ยง Relying party IDs} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#id_2 | PublicKeyCredentialCreationOptions.rp.id} + */ + rpId?: string; +} + +// We don't actually use the public key, so declare support for all default +// algorithms that might be supported by authenticators. +const defaultAlgorithms: PublicKeyCredentialParameters[] = [ + { type: "public-key", alg: -8 }, + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, +] + +declare global { + interface PublicKeyCredentialCreationOptions { + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#hints + hints?: ("security-key" | "client-device" | "hybrid")[]; + } + interface PublicKeyCredentialRequestOptions { + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#hints + hints?: ("security-key" | "client-device" | "hybrid")[]; + } +} + +/** + * 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: CreationOptions): Promise { + 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 + }, + }) as PublicKeyCredential + 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: PublicKeyCredential, rpId: string): string { + const res = credential.response as AuthenticatorAttestationResponse + 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: string): [Uint8Array, string, string[]] { + const res = bech32.decodeToBytes(identity) + if (!identity.startsWith(prefix + "1") || res.bytes.length < 2 || res.bytes[0] !== 0x01) { + 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] +} + +/** + * Options for {@link WebAuthnRecipient} and {@link WebAuthnIdentity}. + */ +export interface Options { + /** + * The identity string to use for encryption or decryption. + * + * If set, the file will be encrypted or decrypted with this specific + * credential. Otherwise, the user will be prompted to select a discoverable + * credential from those available for the RP (which might include login + * credentials, which won't work). + * + * @see {@link createCredential} + */ + identity?: string; + + /** + * The relying party ID for discoverable credentials. Ignored if + * {@link identity} is set, as the RP ID is parsed from the identity. + * + * @see {@link CreationOptions.rpId} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#rpid | PublicKeyCredentialRequestOptions.rpId} + */ + rpId?: string; +} + +const label = "age-encryption.org/fido2prf" + +class WebAuthnInternal { + private credId: Uint8Array | undefined + private transports: string[] | undefined + private rpId: string | undefined + + constructor(options?: 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 + } + } + + protected async getCredential(nonce: Uint8Array): Promise { + const assertion = await navigator.credentials.get({ + publicKey: { + allowCredentials: this.credId ? [{ + id: this.credId, + transports: this.transports as AuthenticatorTransport[], + type: "public-key" + }] : [], + challenge: randomBytes(16), + extensions: { prf: { eval: prfInputs(nonce) } }, + userVerification: "required", // prf requires UV + rpId: this.rpId, + }, + }) as PublicKeyCredential + const results = assertion.getClientExtensionResults().prf?.results + if (results === undefined) { + throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") + } + return results + } +} + +// For the WebAuthnRecipient and WebAuthnIdentity TSDoc links. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { type Encrypter, type Decrypter } from "./index.js" + +/** + * 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 Recipient { + /** + * Implements {@link Recipient.wrapFileKey}. + */ + async wrapFileKey(fileKey: Uint8Array): Promise { + const nonce = randomBytes(16) + const results = await this.getCredential(nonce) + const key = extract(sha256, deriveKey(results), label) + 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 Identity { + /** + * Implements {@link Identity.unwrapFileKey}. + */ + async unwrapFileKey(stanzas: Stanza[]): Promise { + 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 = extract(sha256, deriveKey(results), label) + 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: Uint8Array): AuthenticationExtensionsPRFValues { + 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: AuthenticationExtensionsPRFValues): Uint8Array { + 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 as ArrayBuffer), 0) + prf.set(new Uint8Array(results.second as ArrayBuffer), results.first.byteLength) + return extract(sha256, prf, label) +} diff --git a/package.json b/package.json index 8bf0fd5..dc34bce 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "examples:esbuild": "cd tests/examples && npm update && npm run test:esbuild", "bench": "vitest bench --run", "lint": "eslint .", + "serve": "esbuild wwwdev/index.ts --bundle --outdir=wwwdev/js --servedir=wwwdev --sourcemap", "build": "tsc -p tsconfig.build.json", "docs": "typedoc", "prepublishOnly": "npm run build" diff --git a/typedoc.json b/typedoc.json index 413ea6b..eea9d81 100644 --- a/typedoc.json +++ b/typedoc.json @@ -5,5 +5,6 @@ "out": "docs", "readme": "none", "tsconfig": "tsconfig.build.json", + "excludeProtected": true, "validation": true } diff --git a/wwwdev/index.html b/wwwdev/index.html new file mode 100644 index 0000000..c739c40 --- /dev/null +++ b/wwwdev/index.html @@ -0,0 +1,28 @@ + + + + + typage dev + + + + +

typage dev

+

Open the JavaScript console to access age.

+ + + diff --git a/wwwdev/index.ts b/wwwdev/index.ts new file mode 100644 index 0000000..9d06542 --- /dev/null +++ b/wwwdev/index.ts @@ -0,0 +1,4 @@ +// Not using "age-encryption" to load the TS files directly. +import * as age from "../lib/index.js" +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any +(globalThis as any).age = age