diff --git a/lib/recipients.ts b/lib/recipients.ts index 59aed15..81034e5 100644 --- a/lib/recipients.ts +++ b/lib/recipients.ts @@ -11,7 +11,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) } @@ -28,7 +28,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 { @@ -167,12 +167,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..0eb4a12 --- /dev/null +++ b/lib/webauthn.ts @@ -0,0 +1,183 @@ +import { bech32, base64nopad } from "@scure/base" +import { randomBytes } from "@noble/hashes/utils" +import { Identity, Recipient } from "./index.js" +import { extract } from "@noble/hashes/hkdf" +import { sha256 } from "@noble/hashes/sha256" +import { decryptFileKey, encryptFileKey } from "./recipients.js" +import { Stanza } from "../dist/format.js" + +export interface CreationOptions { + issuerName: string; + keyName: string; + + // If securityKey is true, pass the "security-key" hint to the + // authenticator, which will prompt a UI for hardware tokens only. + securityKey?: boolean; + + // If resident is false, pass the "discouraged" residentKey option to the + // authenticator. This generally has no effect if the user chooses a passkey + // authenticator, so it might be useful to combine with securityKey. + // + // The returned identity string MUST be available to encrypt and decrypt + // files, and CAN'T be regenerated if lost. + // + // resident defaults to true. + resident?: boolean; +} + +// We don't actually use the public key, so declare support for all default +// algorithms that might be supported by stores. +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")[]; + } +} + +export async function createCredential(options: CreationOptions): Promise<{ + identity: string, credential: PublicKeyCredential +}> { + const cred = await navigator.credentials.create({ + publicKey: { + rp: { name: options.issuerName }, + user: { + name: options.keyName, + id: randomBytes(8), + displayName: "", + }, + pubKeyCredParams: defaultAlgorithms, + authenticatorSelection: { + residentKey: options.resident === false ? "discouraged" : "required", + }, + hints: options.securityKey ? ["security-key"] : [], + extensions: { prf: {} }, + challenge: randomBytes(16), // unused without attestation + }, + }) as PublicKeyCredential + if (!cred.getClientExtensionResults().prf?.enabled) { + throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") + } + return { identity: encodeIdentity(cred), credential: cred } +} + +function encodeIdentity(credential: PublicKeyCredential): string { + const credId = new Uint8Array(credential.rawId) + const identityData = new Uint8Array(credId.length + 1) + identityData[0] = 0x01 // version and flags + identityData.set(credId, 1) + return bech32.encode("AGE-PLUGIN-PRF-", bech32.toWords(identityData), false).toUpperCase() +} + +function decodeIdentity(identity: string): Uint8Array { + const res = bech32.decodeToBytes(identity) + if (!identity.startsWith("AGE-PLUGIN-PRF-1") || res.bytes.length < 2 || res.bytes[0] !== 0x01) { + throw Error("invalid identity") + } + return res.bytes.subarray(1) +} + +export interface Options { + // If identity is set, the file will be encrypted with this specific + // credential. Otherwise, the user will be prompted to select one from those + // available for the origin (which might include login credentials, which + // won't work). + identity?: string; + + securityKey?: boolean; +} + +const label = "age-encryption.org/prf" + +class WebAuthn { + private identity: Uint8Array | null + private securityKey: boolean + + constructor(options: Options) { + this.identity = options.identity ? decodeIdentity(options.identity) : null + this.securityKey = options.securityKey ?? false + } + + async getCredential(nonce: Uint8Array): Promise { + const assertion = await navigator.credentials.get({ + publicKey: { + allowCredentials: this.identity ? [{ id: this.identity, type: "public-key" }] : [], + challenge: randomBytes(16), + extensions: { prf: { eval: prfInputs(nonce) } }, + hints: this.securityKey ? ["security-key"] : [], + }, + }) as PublicKeyCredential + const results = assertion.getClientExtensionResults().prf?.results + if (results === undefined) { + throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") + } + return results + } +} + +export class WebAuthnRecipient extends WebAuthn implements Recipient { + 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))] + } +} + +export class WebAuthnIdentity extends WebAuthn implements Identity { + 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 verification. + +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 2381783..4924def 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ }, "type": "module", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./webauthn": "./dist/webauthn.js" }, "types": "./dist/index.d.ts", "keywords": [ @@ -32,6 +33,7 @@ "examples:esbuild": "cd tests/examples && npm update && npm run test:esbuild", "bench": "vitest bench --run", "lint": "eslint .", + "serve": "esbuild www/index.ts --bundle --outdir=www/js --servedir=www --sourcemap", "build": "tsc -p tsconfig.build.json", "prepublishOnly": "npm run build" }, diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..89ee0b3 --- /dev/null +++ b/www/index.html @@ -0,0 +1,28 @@ + + + + + typage dev + + + + +

typage dev

+

Open the JavaScript console to access age and ageWebAuthn.

+ + + diff --git a/www/index.ts b/www/index.ts new file mode 100644 index 0000000..3f0ea22 --- /dev/null +++ b/www/index.ts @@ -0,0 +1,7 @@ +import * as age from "../lib/index.js" +import * as ageWebAuthn from "../lib/webauthn.js" + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +(globalThis as any).age = age; +(globalThis as any).ageWebAuthn = ageWebAuthn