passkey-crypto.ts

9.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}234235export async function signPasskeyAssertion(236  privateKeyJwk: JsonWebKey,237  algorithm: -7 | -8,238  authenticatorData: Uint8Array,239  clientDataHash: Uint8Array240): Promise<Uint8Array> {241  const signInput = concatBytes(authenticatorData, clientDataHash);242243  if (algorithm === -7) {244    const key = await crypto.subtle.importKey(245      "jwk",246      privateKeyJwk,247      { name: "ECDSA", namedCurve: "P-256" },248      false,249      ["sign"]250    );251    const signature = await crypto.subtle.sign(252      { name: "ECDSA", hash: "SHA-256" },253      key,254      toArrayBuffer(signInput)255    );256    return new Uint8Array(signature);257  }258259  const key = await crypto.subtle.importKey(260    "jwk",261    privateKeyJwk,262    { name: "Ed25519" },263    false,264    ["sign"]265  );266  const signature = await crypto.subtle.sign("Ed25519", key, toArrayBuffer(signInput));267  return new Uint8Array(signature);268}269270export async function buildAuthenticatorData(opts: {271  rpId: string;272  flags: number;273  signCount: number;274  credentialId?: Uint8Array;275  publicKeyCose?: Uint8Array;276}): Promise<Uint8Array> {277  const rpIdHash = new Uint8Array(278    await crypto.subtle.digest("SHA-256", toArrayBuffer(textEncoder.encode(opts.rpId)))279  );280  const signCount = new Uint8Array(4);281  new DataView(signCount.buffer).setUint32(0, opts.signCount, false);282283  const base = concatBytes(rpIdHash, Uint8Array.of(opts.flags), signCount);284  if (!opts.credentialId || !opts.publicKeyCose) {285    return base;286  }287288  const aaguid = new Uint8Array(16);289  const credLength = new Uint8Array(2);290  new DataView(credLength.buffer).setUint16(0, opts.credentialId.length, false);291292  return concatBytes(base, aaguid, credLength, opts.credentialId, opts.publicKeyCose);293}294295export function buildClientDataJSON(296  type: "webauthn.create" | "webauthn.get",297  challenge: string,298  origin: string299): Uint8Array {300  return textEncoder.encode(301    JSON.stringify({302      type,303      challenge,304      origin,305      crossOrigin: false,306    })307  );308}309310export function createAttestationObject(authData: Uint8Array): Uint8Array {311  return encodeMap([312    ["fmt", "none"],313    ["attStmt", new Map()],314    ["authData", authData],315  ]);316}317318export function isPasskeyEntry(entry: PasskeyEntry | { type?: string }): entry is PasskeyEntry {319  return entry.type === "passkey";320}