passkey-crypto.ts

10.0 KB · raw

1import type { PasskeyEntry } from "../shared/types";23const textEncoder = new TextEncoder();45function toArrayBuffer(data: Uint8Array): ArrayBuffer {6  const out = new Uint8Array(data.byteLength);7  out.set(data);8  return out.buffer;9}1011function encodeLength(majorType: number, value: number): number[] {12  if (value < 24) return [(majorType << 5) | value];13  if (value < 0x100) return [(majorType << 5) | 24, value];14  if (value < 0x1_0000) return [(majorType << 5) | 25, value >> 8, value & 0xff];15  if (value < 0x1_0000_0000) {16    return [17      (majorType << 5) | 26,18      (value >>> 24) & 0xff,19      (value >>> 16) & 0xff,20      (value >>> 8) & 0xff,21      value & 0xff,22    ];23  }2425  throw new Error("CBOR length too large");26}2728function encodeInteger(value: number): Uint8Array {29  if (value >= 0) {30    return Uint8Array.from(encodeLength(0, value));31  }32  return Uint8Array.from(encodeLength(1, -1 - value));33}3435function encodeText(value: string): Uint8Array {36  const bytes = textEncoder.encode(value);37  return concatBytes(Uint8Array.from(encodeLength(3, bytes.length)), bytes);38}3940function encodeBytes(value: Uint8Array): Uint8Array {41  return concatBytes(Uint8Array.from(encodeLength(2, value.length)), value);42}4344function encodeMap(entries: Array<[number | string, unknown]>): Uint8Array {45  const parts: Uint8Array[] = [Uint8Array.from(encodeLength(5, entries.length))];46  for (const [key, value] of entries) {47    parts.push(typeof key === "number" ? encodeInteger(key) : encodeText(key));48    parts.push(encodeCbor(value));49  }50  return concatBytes(...parts);51}5253export function encodeCbor(value: unknown): Uint8Array {54  if (value instanceof Uint8Array) return encodeBytes(value);55  if (typeof value === "string") return encodeText(value);56  if (typeof value === "number" && Number.isInteger(value)) return encodeInteger(value);57  if (value instanceof Map) return encodeMap(Array.from(value.entries()));58  if (Array.isArray(value)) {59    return concatBytes(60      Uint8Array.from(encodeLength(4, value.length)),61      ...value.map((item) => encodeCbor(item))62    );63  }64  if (value && typeof value === "object") {65    return encodeMap(Object.entries(value as Record<string, unknown>));66  }67  if (value === false) return Uint8Array.of(0xf4);68  if (value === true) return Uint8Array.of(0xf5);69  if (value === null) return Uint8Array.of(0xf6);7071  throw new Error("Unsupported CBOR value");72}7374export function concatBytes(...parts: Uint8Array[]): Uint8Array {75  const total = parts.reduce((sum, part) => sum + part.length, 0);76  const out = new Uint8Array(total);77  let offset = 0;78  for (const part of parts) {79    out.set(part, offset);80    offset += part.length;81  }82  return out;83}8485export function base64urlEncode(data: Uint8Array): string {86  let binary = "";87  for (const byte of data) binary += String.fromCharCode(byte);88  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");89}9091export function base64urlDecode(input: string): Uint8Array {92  const padded = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "=");93  const binary = atob(padded);94  const out = new Uint8Array(binary.length);95  for (let i = 0; i < binary.length; i += 1) {96    out[i] = binary.charCodeAt(i);97  }98  return out;99}100101export function pickPasskeyAlgorithm(102  pubKeyCredParams: Array<{ alg: number }>103): -7 | -8 | null {104  const offered = new Set(pubKeyCredParams.map((param) => param.alg));105  if (offered.has(-8)) return -8;106  if (offered.has(-7)) return -7;107  return null;108}109110export function p256RawToCose(raw: Uint8Array): Uint8Array {111  if (raw.length !== 65 || raw[0] !== 0x04) {112    throw new Error("Invalid P-256 public key");113  }114  const x = raw.slice(1, 33);115  const y = raw.slice(33, 65);116  return encodeMap([117    [1, 2],118    [3, -7],119    [-1, 1],120    [-2, x],121    [-3, y],122  ]);123}124125export function ed25519RawToCose(raw: Uint8Array): Uint8Array {126  if (raw.length !== 32) {127    throw new Error("Invalid Ed25519 public key");128  }129  return encodeMap([130    [1, 1],131    [3, -8],132    [-1, 6],133    [-2, raw],134  ]);135}136137export function publicKeyJwkToCose(jwk: JsonWebKey, algorithm: -7 | -8): Uint8Array {138  if (algorithm === -7) {139    if (!jwk.x || !jwk.y) throw new Error("Missing EC coordinates");140    return encodeMap([141      [1, 2],142      [3, -7],143      [-1, 1],144      [-2, base64urlDecode(jwk.x)],145      [-3, base64urlDecode(jwk.y)],146    ]);147  }148149  if (!jwk.x) throw new Error("Missing Ed25519 coordinate");150  return encodeMap([151    [1, 1],152    [3, -8],153    [-1, 6],154    [-2, base64urlDecode(jwk.x)],155  ]);156}157158export async function generatePasskeyKeypair(159  algorithm: -7 | -8160): Promise<{161  publicKeyCose: Uint8Array;162  publicKeySpki: Uint8Array;163  privateKeyJwk: JsonWebKey;164}> {165  if (algorithm === -7) {166    const keyPair = await crypto.subtle.generateKey(167      { name: "ECDSA", namedCurve: "P-256" },168      true,169      ["sign", "verify"]170    );171    const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);172    const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);173    const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);174    return {175      privateKeyJwk,176      publicKeyCose: p256RawToCose(new Uint8Array(publicKeyRaw)),177      publicKeySpki: new Uint8Array(publicKeySpki),178    };179  }180181  const keyPair = await crypto.subtle.generateKey(182    { name: "Ed25519" },183    true,184    ["sign", "verify"]185  );186  const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);187  const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);188  const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);189  return {190    privateKeyJwk,191    publicKeyCose: ed25519RawToCose(new Uint8Array(publicKeyRaw)),192    publicKeySpki: new Uint8Array(publicKeySpki),193  };194}195196async function importPkcs8AsAlgorithm(197  pkcs8: Uint8Array,198  algorithm: -7 | -8199): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {200  const imported =201    algorithm === -7202      ? await crypto.subtle.importKey(203          "pkcs8",204          toArrayBuffer(pkcs8),205          { name: "ECDSA", namedCurve: "P-256" },206          true,207          ["sign"]208        )209      : await crypto.subtle.importKey(210          "pkcs8",211          toArrayBuffer(pkcs8),212          { name: "Ed25519" },213          true,214          ["sign"]215        );216217  const privateKeyJwk = await crypto.subtle.exportKey("jwk", imported);218  return {219    privateKeyJwk,220    publicKeyCose: publicKeyJwkToCose(privateKeyJwk, algorithm),221    algorithm,222  };223}224225export async function importPasskeyPrivateKey(226  pkcs8: Uint8Array227): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {228  try {229    return await importPkcs8AsAlgorithm(pkcs8, -8);230  } catch {231    return importPkcs8AsAlgorithm(pkcs8, -7);232  }233}234235/**236 * SubtleCrypto.sign("ECDSA") returns IEEE P1363 raw `r||s`, but WebAuthn237 * requires ASN.1 DER-encoded ECDSA signatures. Without this conversion238 * the relying party rejects the assertion ("Unable to sign in with your239 * passkey"). Ed25519 signatures need no conversion.240 */241function p1363ToDer(p1363: Uint8Array): Uint8Array {242  const half = p1363.length / 2;243  const r = trimAndPadAsn1Integer(p1363.slice(0, half));244  const s = trimAndPadAsn1Integer(p1363.slice(half));245  const body = concatBytes(246    Uint8Array.of(0x02, r.length),247    r,248    Uint8Array.of(0x02, s.length),249    s250  );251  return concatBytes(Uint8Array.of(0x30, body.length), body);252}253254function trimAndPadAsn1Integer(integer: Uint8Array): Uint8Array {255  let start = 0;256  while (start < integer.length - 1 && integer[start] === 0) start += 1;257  const trimmed = integer.slice(start);258  // ASN.1 INTEGER is signed two's-complement; if the high bit is set,259  // prefix a zero byte so the value reads as positive.260  if ((trimmed[0]! & 0x80) !== 0) {261    return concatBytes(Uint8Array.of(0x00), trimmed);262  }263  return trimmed;264}265266export async function signPasskeyAssertion(267  privateKeyJwk: JsonWebKey,268  algorithm: -7 | -8,269  authenticatorData: Uint8Array,270  clientDataHash: Uint8Array271): Promise<Uint8Array> {272  const signInput = concatBytes(authenticatorData, clientDataHash);273274  if (algorithm === -7) {275    const key = await crypto.subtle.importKey(276      "jwk",277      privateKeyJwk,278      { name: "ECDSA", namedCurve: "P-256" },279      false,280      ["sign"]281    );282    const raw = await crypto.subtle.sign(283      { name: "ECDSA", hash: "SHA-256" },284      key,285      toArrayBuffer(signInput)286    );287    return p1363ToDer(new Uint8Array(raw));288  }289290  const key = await crypto.subtle.importKey(291    "jwk",292    privateKeyJwk,293    { name: "Ed25519" },294    false,295    ["sign"]296  );297  const signature = await crypto.subtle.sign("Ed25519", key, toArrayBuffer(signInput));298  return new Uint8Array(signature);299}300301export async function buildAuthenticatorData(opts: {302  rpId: string;303  flags: number;304  signCount: number;305  credentialId?: Uint8Array;306  publicKeyCose?: Uint8Array;307}): Promise<Uint8Array> {308  const rpIdHash = new Uint8Array(309    await crypto.subtle.digest("SHA-256", toArrayBuffer(textEncoder.encode(opts.rpId)))310  );311  const signCount = new Uint8Array(4);312  new DataView(signCount.buffer).setUint32(0, opts.signCount, false);313314  const base = concatBytes(rpIdHash, Uint8Array.of(opts.flags), signCount);315  if (!opts.credentialId || !opts.publicKeyCose) {316    return base;317  }318319  const aaguid = new Uint8Array(16);320  const credLength = new Uint8Array(2);321  new DataView(credLength.buffer).setUint16(0, opts.credentialId.length, false);322323  return concatBytes(base, aaguid, credLength, opts.credentialId, opts.publicKeyCose);324}325326export function buildClientDataJSON(327  type: "webauthn.create" | "webauthn.get",328  challenge: string,329  origin: string330): Uint8Array {331  return textEncoder.encode(332    JSON.stringify({333      type,334      challenge,335      origin,336      crossOrigin: false,337    })338  );339}340341export function createAttestationObject(authData: Uint8Array): Uint8Array {342  return encodeMap([343    ["fmt", "none"],344    ["attStmt", new Map()],345    ["authData", authData],346  ]);347}348349export function isPasskeyEntry(entry: PasskeyEntry | { type?: string }): entry is PasskeyEntry {350  return entry.type === "passkey";351}