const textEncoder = new TextEncoder(); function toArrayBuffer(data: Uint8Array): ArrayBuffer { const out = new Uint8Array(data.byteLength); out.set(data); return out.buffer; } function encodeLength(majorType: number, value: number): number[] { if (value < 24) return [(majorType << 5) | value]; if (value < 0x100) return [(majorType << 5) | 24, value]; if (value < 0x1_0000) return [(majorType << 5) | 25, value >> 8, value & 0xff]; if (value < 0x1_0000_0000) { return [ (majorType << 5) | 26, (value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff, ]; } throw new Error("CBOR length too large"); } function encodeInteger(value: number): Uint8Array { if (value >= 0) { return Uint8Array.from(encodeLength(0, value)); } return Uint8Array.from(encodeLength(1, -1 - value)); } function encodeText(value: string): Uint8Array { const bytes = textEncoder.encode(value); return concatBytes(Uint8Array.from(encodeLength(3, bytes.length)), bytes); } function encodeBytes(value: Uint8Array): Uint8Array { return concatBytes(Uint8Array.from(encodeLength(2, value.length)), value); } function encodeMap(entries: Array<[number | string, unknown]>): Uint8Array { const parts: Uint8Array[] = [Uint8Array.from(encodeLength(5, entries.length))]; for (const [key, value] of entries) { parts.push(typeof key === "number" ? encodeInteger(key) : encodeText(key)); parts.push(encodeCbor(value)); } return concatBytes(...parts); } export function encodeCbor(value: unknown): Uint8Array { if (value instanceof Uint8Array) return encodeBytes(value); if (typeof value === "string") return encodeText(value); if (typeof value === "number" && Number.isInteger(value)) return encodeInteger(value); if (value instanceof Map) return encodeMap(Array.from(value.entries())); if (Array.isArray(value)) { return concatBytes( Uint8Array.from(encodeLength(4, value.length)), ...value.map((item) => encodeCbor(item)) ); } if (value && typeof value === "object") { return encodeMap(Object.entries(value as Record)); } if (value === false) return Uint8Array.of(0xf4); if (value === true) return Uint8Array.of(0xf5); if (value === null) return Uint8Array.of(0xf6); throw new Error("Unsupported CBOR value"); } export function concatBytes(...parts: Uint8Array[]): Uint8Array { const total = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } export function base64urlEncode(data: Uint8Array): string { let binary = ""; for (const byte of data) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } export function base64urlDecode(input: string): Uint8Array { const padded = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "="); const binary = atob(padded); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { out[i] = binary.charCodeAt(i); } return out; } export function pickPasskeyAlgorithm( pubKeyCredParams: Array<{ alg: number }> ): -7 | -8 | null { const offered = new Set(pubKeyCredParams.map((param) => param.alg)); if (offered.has(-8)) return -8; if (offered.has(-7)) return -7; return null; } export function p256RawToCose(raw: Uint8Array): Uint8Array { if (raw.length !== 65 || raw[0] !== 0x04) { throw new Error("Invalid P-256 public key"); } const x = raw.slice(1, 33); const y = raw.slice(33, 65); return encodeMap([ [1, 2], [3, -7], [-1, 1], [-2, x], [-3, y], ]); } export function ed25519RawToCose(raw: Uint8Array): Uint8Array { if (raw.length !== 32) { throw new Error("Invalid Ed25519 public key"); } return encodeMap([ [1, 1], [3, -8], [-1, 6], [-2, raw], ]); } export function publicKeyJwkToCose(jwk: JsonWebKey, algorithm: -7 | -8): Uint8Array { if (algorithm === -7) { if (!jwk.x || !jwk.y) throw new Error("Missing EC coordinates"); return encodeMap([ [1, 2], [3, -7], [-1, 1], [-2, base64urlDecode(jwk.x)], [-3, base64urlDecode(jwk.y)], ]); } if (!jwk.x) throw new Error("Missing Ed25519 coordinate"); return encodeMap([ [1, 1], [3, -8], [-1, 6], [-2, base64urlDecode(jwk.x)], ]); } // AUD-005: Passkey private keys are generated with `extractable: true`. This is // an intentional architectural tradeoff — Peach stores the JWK inside the // encrypted vault blob to enable PeachScript backup, Codex restore, and cross- // device sync. A stronger design would use non-extractable platform keys, but // that would break portability. The private key never leaves the background // process: content scripts receive only signed assertions, never the key // material itself. If non-extractable platform keys ever become compatible // with Peach's backup and sync goals, prefer them for new credentials. export async function generatePasskeyKeypair( algorithm: -7 | -8 ): Promise<{ publicKeyCose: Uint8Array; publicKeySpki: Uint8Array; privateKeyJwk: JsonWebKey; }> { if (algorithm === -7) { const keyPair = await crypto.subtle.generateKey( { name: "ECDSA", namedCurve: "P-256" }, true, // AUD-005: extractable — required for vault-stored portability ["sign", "verify"] ); const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey); return { privateKeyJwk, publicKeyCose: p256RawToCose(new Uint8Array(publicKeyRaw)), publicKeySpki: new Uint8Array(publicKeySpki), }; } const keyPair = await crypto.subtle.generateKey( { name: "Ed25519" }, true, // AUD-005: extractable — required for vault-stored portability ["sign", "verify"] ); const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey); return { privateKeyJwk, publicKeyCose: ed25519RawToCose(new Uint8Array(publicKeyRaw)), publicKeySpki: new Uint8Array(publicKeySpki), }; } async function importPkcs8AsAlgorithm( pkcs8: Uint8Array, algorithm: -7 | -8 ): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> { const imported = algorithm === -7 ? await crypto.subtle.importKey( "pkcs8", toArrayBuffer(pkcs8), { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"] ) : await crypto.subtle.importKey( "pkcs8", toArrayBuffer(pkcs8), { name: "Ed25519" }, true, ["sign"] ); const privateKeyJwk = await crypto.subtle.exportKey("jwk", imported); return { privateKeyJwk, publicKeyCose: publicKeyJwkToCose(privateKeyJwk, algorithm), algorithm, }; } export async function importPasskeyPrivateKey( pkcs8: Uint8Array ): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> { try { return await importPkcs8AsAlgorithm(pkcs8, -8); } catch { return importPkcs8AsAlgorithm(pkcs8, -7); } } /** * SubtleCrypto.sign("ECDSA") returns IEEE P1363 raw `r||s`, but WebAuthn * requires ASN.1 DER-encoded ECDSA signatures. Without this conversion * the relying party rejects the assertion ("Unable to sign in with your * passkey"). Ed25519 signatures need no conversion. */ function p1363ToDer(p1363: Uint8Array): Uint8Array { const half = p1363.length / 2; const r = trimAndPadAsn1Integer(p1363.slice(0, half)); const s = trimAndPadAsn1Integer(p1363.slice(half)); const body = concatBytes( Uint8Array.of(0x02, r.length), r, Uint8Array.of(0x02, s.length), s ); return concatBytes(Uint8Array.of(0x30, body.length), body); } function trimAndPadAsn1Integer(integer: Uint8Array): Uint8Array { let start = 0; while (start < integer.length - 1 && integer[start] === 0) start += 1; const trimmed = integer.slice(start); // ASN.1 INTEGER is signed two's-complement; if the high bit is set, // prefix a zero byte so the value reads as positive. if ((trimmed[0]! & 0x80) !== 0) { return concatBytes(Uint8Array.of(0x00), trimmed); } return trimmed; } export async function signPasskeyAssertion( privateKeyJwk: JsonWebKey, algorithm: -7 | -8, authenticatorData: Uint8Array, clientDataHash: Uint8Array ): Promise { const signInput = concatBytes(authenticatorData, clientDataHash); if (algorithm === -7) { const key = await crypto.subtle.importKey( "jwk", privateKeyJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"] ); const raw = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, key, toArrayBuffer(signInput) ); return p1363ToDer(new Uint8Array(raw)); } const key = await crypto.subtle.importKey( "jwk", privateKeyJwk, { name: "Ed25519" }, false, ["sign"] ); const signature = await crypto.subtle.sign("Ed25519", key, toArrayBuffer(signInput)); return new Uint8Array(signature); } export async function buildAuthenticatorData(opts: { rpId: string; flags: number; signCount: number; credentialId?: Uint8Array; publicKeyCose?: Uint8Array; }): Promise { const rpIdHash = new Uint8Array( await crypto.subtle.digest("SHA-256", toArrayBuffer(textEncoder.encode(opts.rpId))) ); const signCount = new Uint8Array(4); new DataView(signCount.buffer).setUint32(0, opts.signCount, false); const base = concatBytes(rpIdHash, Uint8Array.of(opts.flags), signCount); if (!opts.credentialId || !opts.publicKeyCose) { return base; } const aaguid = new Uint8Array(16); const credLength = new Uint8Array(2); new DataView(credLength.buffer).setUint16(0, opts.credentialId.length, false); return concatBytes(base, aaguid, credLength, opts.credentialId, opts.publicKeyCose); } export function buildClientDataJSON( type: "webauthn.create" | "webauthn.get", challenge: string, origin: string ): Uint8Array { return textEncoder.encode( JSON.stringify({ type, challenge, origin, crossOrigin: false, }) ); } export function createAttestationObject(authData: Uint8Array): Uint8Array { return encodeMap([ ["fmt", "none"], ["attStmt", new Map()], ["authData", authData], ]); } /** * Generate a fresh credential ID. WebAuthn requires the value be unguessable * and unique per credential; 32 bytes from the CSPRNG satisfies both. */ export function generateCredentialId(): { bytes: Uint8Array; base64url: string; } { const bytes = crypto.getRandomValues(new Uint8Array(32)); return { bytes, base64url: base64urlEncode(bytes) }; }