passkey-crypto.ts

11.1 KB · raw

1const textEncoder = new TextEncoder();23function toArrayBuffer(data: Uint8Array): ArrayBuffer {4  const out = new Uint8Array(data.byteLength);5  out.set(data);6  return out.buffer;7}89function encodeLength(majorType: number, value: number): number[] {10  if (value < 24) return [(majorType << 5) | value];11  if (value < 0x100) return [(majorType << 5) | 24, value];12  if (value < 0x1_0000) return [(majorType << 5) | 25, value >> 8, value & 0xff];13  if (value < 0x1_0000_0000) {14    return [15      (majorType << 5) | 26,16      (value >>> 24) & 0xff,17      (value >>> 16) & 0xff,18      (value >>> 8) & 0xff,19      value & 0xff,20    ];21  }2223  throw new Error("CBOR length too large");24}2526function encodeInteger(value: number): Uint8Array {27  if (value >= 0) {28    return Uint8Array.from(encodeLength(0, value));29  }30  return Uint8Array.from(encodeLength(1, -1 - value));31}3233function encodeText(value: string): Uint8Array {34  const bytes = textEncoder.encode(value);35  return concatBytes(Uint8Array.from(encodeLength(3, bytes.length)), bytes);36}3738function encodeBytes(value: Uint8Array): Uint8Array {39  return concatBytes(Uint8Array.from(encodeLength(2, value.length)), value);40}4142function encodeMap(entries: Array<[number | string, unknown]>): Uint8Array {43  const parts: Uint8Array[] = [Uint8Array.from(encodeLength(5, entries.length))];44  for (const [key, value] of entries) {45    parts.push(typeof key === "number" ? encodeInteger(key) : encodeText(key));46    parts.push(encodeCbor(value));47  }48  return concatBytes(...parts);49}5051export function encodeCbor(value: unknown): Uint8Array {52  if (value instanceof Uint8Array) return encodeBytes(value);53  if (typeof value === "string") return encodeText(value);54  if (typeof value === "number" && Number.isInteger(value)) return encodeInteger(value);55  if (value instanceof Map) return encodeMap(Array.from(value.entries()));56  if (Array.isArray(value)) {57    return concatBytes(58      Uint8Array.from(encodeLength(4, value.length)),59      ...value.map((item) => encodeCbor(item))60    );61  }62  if (value && typeof value === "object") {63    return encodeMap(Object.entries(value as Record<string, unknown>));64  }65  if (value === false) return Uint8Array.of(0xf4);66  if (value === true) return Uint8Array.of(0xf5);67  if (value === null) return Uint8Array.of(0xf6);6869  throw new Error("Unsupported CBOR value");70}7172export function concatBytes(...parts: Uint8Array[]): Uint8Array {73  const total = parts.reduce((sum, part) => sum + part.length, 0);74  const out = new Uint8Array(total);75  let offset = 0;76  for (const part of parts) {77    out.set(part, offset);78    offset += part.length;79  }80  return out;81}8283export function base64urlEncode(data: Uint8Array): string {84  let binary = "";85  for (const byte of data) binary += String.fromCharCode(byte);86  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");87}8889export function base64urlDecode(input: string): Uint8Array {90  const padded = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "=");91  const binary = atob(padded);92  const out = new Uint8Array(binary.length);93  for (let i = 0; i < binary.length; i += 1) {94    out[i] = binary.charCodeAt(i);95  }96  return out;97}9899export function pickPasskeyAlgorithm(100  pubKeyCredParams: Array<{ alg: number }>101): -7 | -8 | null {102  const offered = new Set(pubKeyCredParams.map((param) => param.alg));103  if (offered.has(-8)) return -8;104  if (offered.has(-7)) return -7;105  return null;106}107108export function p256RawToCose(raw: Uint8Array): Uint8Array {109  if (raw.length !== 65 || raw[0] !== 0x04) {110    throw new Error("Invalid P-256 public key");111  }112  const x = raw.slice(1, 33);113  const y = raw.slice(33, 65);114  return encodeMap([115    [1, 2],116    [3, -7],117    [-1, 1],118    [-2, x],119    [-3, y],120  ]);121}122123export function ed25519RawToCose(raw: Uint8Array): Uint8Array {124  if (raw.length !== 32) {125    throw new Error("Invalid Ed25519 public key");126  }127  return encodeMap([128    [1, 1],129    [3, -8],130    [-1, 6],131    [-2, raw],132  ]);133}134135export function publicKeyJwkToCose(jwk: JsonWebKey, algorithm: -7 | -8): Uint8Array {136  if (algorithm === -7) {137    if (!jwk.x || !jwk.y) throw new Error("Missing EC coordinates");138    return encodeMap([139      [1, 2],140      [3, -7],141      [-1, 1],142      [-2, base64urlDecode(jwk.x)],143      [-3, base64urlDecode(jwk.y)],144    ]);145  }146147  if (!jwk.x) throw new Error("Missing Ed25519 coordinate");148  return encodeMap([149    [1, 1],150    [3, -8],151    [-1, 6],152    [-2, base64urlDecode(jwk.x)],153  ]);154}155156// AUD-005: Passkey private keys are generated with `extractable: true`. This is157// an intentional architectural tradeoff — Peach stores the JWK inside the158// encrypted vault blob to enable PeachScript backup, Codex restore, and cross-159// device sync. A stronger design would use non-extractable platform keys, but160// that would break portability. The private key never leaves the background161// process: content scripts receive only signed assertions, never the key162// material itself. If non-extractable platform keys ever become compatible163// with Peach's backup and sync goals, prefer them for new credentials.164export async function generatePasskeyKeypair(165  algorithm: -7 | -8166): Promise<{167  publicKeyCose: Uint8Array;168  publicKeySpki: Uint8Array;169  privateKeyJwk: JsonWebKey;170}> {171  if (algorithm === -7) {172    const keyPair = await crypto.subtle.generateKey(173      { name: "ECDSA", namedCurve: "P-256" },174      true, // AUD-005: extractable — required for vault-stored portability175      ["sign", "verify"]176    );177    const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);178    const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);179    const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);180    return {181      privateKeyJwk,182      publicKeyCose: p256RawToCose(new Uint8Array(publicKeyRaw)),183      publicKeySpki: new Uint8Array(publicKeySpki),184    };185  }186187  const keyPair = await crypto.subtle.generateKey(188    { name: "Ed25519" },189    true, // AUD-005: extractable — required for vault-stored portability190    ["sign", "verify"]191  );192  const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);193  const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);194  const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);195  return {196    privateKeyJwk,197    publicKeyCose: ed25519RawToCose(new Uint8Array(publicKeyRaw)),198    publicKeySpki: new Uint8Array(publicKeySpki),199  };200}201202async function importPkcs8AsAlgorithm(203  pkcs8: Uint8Array,204  algorithm: -7 | -8205): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {206  const imported =207    algorithm === -7208      ? await crypto.subtle.importKey(209          "pkcs8",210          toArrayBuffer(pkcs8),211          { name: "ECDSA", namedCurve: "P-256" },212          true,213          ["sign"]214        )215      : await crypto.subtle.importKey(216          "pkcs8",217          toArrayBuffer(pkcs8),218          { name: "Ed25519" },219          true,220          ["sign"]221        );222223  const privateKeyJwk = await crypto.subtle.exportKey("jwk", imported);224  return {225    privateKeyJwk,226    publicKeyCose: publicKeyJwkToCose(privateKeyJwk, algorithm),227    algorithm,228  };229}230231export async function importPasskeyPrivateKey(232  pkcs8: Uint8Array233): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {234  try {235    return await importPkcs8AsAlgorithm(pkcs8, -8);236  } catch {237    return importPkcs8AsAlgorithm(pkcs8, -7);238  }239}240241/**242 * SubtleCrypto.sign("ECDSA") returns IEEE P1363 raw `r||s`, but WebAuthn243 * requires ASN.1 DER-encoded ECDSA signatures. Without this conversion244 * the relying party rejects the assertion ("Unable to sign in with your245 * passkey"). Ed25519 signatures need no conversion.246 */247function p1363ToDer(p1363: Uint8Array): Uint8Array {248  const half = p1363.length / 2;249  const r = trimAndPadAsn1Integer(p1363.slice(0, half));250  const s = trimAndPadAsn1Integer(p1363.slice(half));251  const body = concatBytes(252    Uint8Array.of(0x02, r.length),253    r,254    Uint8Array.of(0x02, s.length),255    s256  );257  return concatBytes(Uint8Array.of(0x30, body.length), body);258}259260function trimAndPadAsn1Integer(integer: Uint8Array): Uint8Array {261  let start = 0;262  while (start < integer.length - 1 && integer[start] === 0) start += 1;263  const trimmed = integer.slice(start);264  // ASN.1 INTEGER is signed two's-complement; if the high bit is set,265  // prefix a zero byte so the value reads as positive.266  if ((trimmed[0]! & 0x80) !== 0) {267    return concatBytes(Uint8Array.of(0x00), trimmed);268  }269  return trimmed;270}271272export async function signPasskeyAssertion(273  privateKeyJwk: JsonWebKey,274  algorithm: -7 | -8,275  authenticatorData: Uint8Array,276  clientDataHash: Uint8Array277): Promise<Uint8Array> {278  const signInput = concatBytes(authenticatorData, clientDataHash);279280  if (algorithm === -7) {281    const key = await crypto.subtle.importKey(282      "jwk",283      privateKeyJwk,284      { name: "ECDSA", namedCurve: "P-256" },285      false,286      ["sign"]287    );288    const raw = await crypto.subtle.sign(289      { name: "ECDSA", hash: "SHA-256" },290      key,291      toArrayBuffer(signInput)292    );293    return p1363ToDer(new Uint8Array(raw));294  }295296  const key = await crypto.subtle.importKey(297    "jwk",298    privateKeyJwk,299    { name: "Ed25519" },300    false,301    ["sign"]302  );303  const signature = await crypto.subtle.sign("Ed25519", key, toArrayBuffer(signInput));304  return new Uint8Array(signature);305}306307export async function buildAuthenticatorData(opts: {308  rpId: string;309  flags: number;310  signCount: number;311  credentialId?: Uint8Array;312  publicKeyCose?: Uint8Array;313}): Promise<Uint8Array> {314  const rpIdHash = new Uint8Array(315    await crypto.subtle.digest("SHA-256", toArrayBuffer(textEncoder.encode(opts.rpId)))316  );317  const signCount = new Uint8Array(4);318  new DataView(signCount.buffer).setUint32(0, opts.signCount, false);319320  const base = concatBytes(rpIdHash, Uint8Array.of(opts.flags), signCount);321  if (!opts.credentialId || !opts.publicKeyCose) {322    return base;323  }324325  const aaguid = new Uint8Array(16);326  const credLength = new Uint8Array(2);327  new DataView(credLength.buffer).setUint16(0, opts.credentialId.length, false);328329  return concatBytes(base, aaguid, credLength, opts.credentialId, opts.publicKeyCose);330}331332export function buildClientDataJSON(333  type: "webauthn.create" | "webauthn.get",334  challenge: string,335  origin: string336): Uint8Array {337  return textEncoder.encode(338    JSON.stringify({339      type,340      challenge,341      origin,342      crossOrigin: false,343    })344  );345}346347export function createAttestationObject(authData: Uint8Array): Uint8Array {348  return encodeMap([349    ["fmt", "none"],350    ["attStmt", new Map()],351    ["authData", authData],352  ]);353}354355export async function sha256(data: Uint8Array): Promise<Uint8Array> {356  return new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));357}358359/**360 * Generate a fresh credential ID. WebAuthn requires the value be unguessable361 * and unique per credential; 32 bytes from the CSPRNG satisfies both.362 */363export function generateCredentialId(): {364  bytes: Uint8Array;365  base64url: string;366} {367  const bytes = crypto.getRandomValues(new Uint8Array(32));368  return { bytes, base64url: base64urlEncode(bytes) };369}370