passkey-crypto.ts

10.4 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}155156export async function generatePasskeyKeypair(157  algorithm: -7 | -8158): Promise<{159  publicKeyCose: Uint8Array;160  publicKeySpki: Uint8Array;161  privateKeyJwk: JsonWebKey;162}> {163  if (algorithm === -7) {164    const keyPair = await crypto.subtle.generateKey(165      { name: "ECDSA", namedCurve: "P-256" },166      true,167      ["sign", "verify"]168    );169    const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);170    const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);171    const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);172    return {173      privateKeyJwk,174      publicKeyCose: p256RawToCose(new Uint8Array(publicKeyRaw)),175      publicKeySpki: new Uint8Array(publicKeySpki),176    };177  }178179  const keyPair = await crypto.subtle.generateKey(180    { name: "Ed25519" },181    true,182    ["sign", "verify"]183  );184  const privateKeyJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);185  const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);186  const publicKeySpki = await crypto.subtle.exportKey("spki", keyPair.publicKey);187  return {188    privateKeyJwk,189    publicKeyCose: ed25519RawToCose(new Uint8Array(publicKeyRaw)),190    publicKeySpki: new Uint8Array(publicKeySpki),191  };192}193194async function importPkcs8AsAlgorithm(195  pkcs8: Uint8Array,196  algorithm: -7 | -8197): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {198  const imported =199    algorithm === -7200      ? await crypto.subtle.importKey(201          "pkcs8",202          toArrayBuffer(pkcs8),203          { name: "ECDSA", namedCurve: "P-256" },204          true,205          ["sign"]206        )207      : await crypto.subtle.importKey(208          "pkcs8",209          toArrayBuffer(pkcs8),210          { name: "Ed25519" },211          true,212          ["sign"]213        );214215  const privateKeyJwk = await crypto.subtle.exportKey("jwk", imported);216  return {217    privateKeyJwk,218    publicKeyCose: publicKeyJwkToCose(privateKeyJwk, algorithm),219    algorithm,220  };221}222223export async function importPasskeyPrivateKey(224  pkcs8: Uint8Array225): Promise<{ privateKeyJwk: JsonWebKey; publicKeyCose: Uint8Array; algorithm: -7 | -8 }> {226  try {227    return await importPkcs8AsAlgorithm(pkcs8, -8);228  } catch {229    return importPkcs8AsAlgorithm(pkcs8, -7);230  }231}232233/**234 * SubtleCrypto.sign("ECDSA") returns IEEE P1363 raw `r||s`, but WebAuthn235 * requires ASN.1 DER-encoded ECDSA signatures. Without this conversion236 * the relying party rejects the assertion ("Unable to sign in with your237 * passkey"). Ed25519 signatures need no conversion.238 */239function p1363ToDer(p1363: Uint8Array): Uint8Array {240  const half = p1363.length / 2;241  const r = trimAndPadAsn1Integer(p1363.slice(0, half));242  const s = trimAndPadAsn1Integer(p1363.slice(half));243  const body = concatBytes(244    Uint8Array.of(0x02, r.length),245    r,246    Uint8Array.of(0x02, s.length),247    s248  );249  return concatBytes(Uint8Array.of(0x30, body.length), body);250}251252function trimAndPadAsn1Integer(integer: Uint8Array): Uint8Array {253  let start = 0;254  while (start < integer.length - 1 && integer[start] === 0) start += 1;255  const trimmed = integer.slice(start);256  // ASN.1 INTEGER is signed two's-complement; if the high bit is set,257  // prefix a zero byte so the value reads as positive.258  if ((trimmed[0]! & 0x80) !== 0) {259    return concatBytes(Uint8Array.of(0x00), trimmed);260  }261  return trimmed;262}263264export async function signPasskeyAssertion(265  privateKeyJwk: JsonWebKey,266  algorithm: -7 | -8,267  authenticatorData: Uint8Array,268  clientDataHash: Uint8Array269): Promise<Uint8Array> {270  const signInput = concatBytes(authenticatorData, clientDataHash);271272  if (algorithm === -7) {273    const key = await crypto.subtle.importKey(274      "jwk",275      privateKeyJwk,276      { name: "ECDSA", namedCurve: "P-256" },277      false,278      ["sign"]279    );280    const raw = await crypto.subtle.sign(281      { name: "ECDSA", hash: "SHA-256" },282      key,283      toArrayBuffer(signInput)284    );285    return p1363ToDer(new Uint8Array(raw));286  }287288  const key = await crypto.subtle.importKey(289    "jwk",290    privateKeyJwk,291    { name: "Ed25519" },292    false,293    ["sign"]294  );295  const signature = await crypto.subtle.sign("Ed25519", key, toArrayBuffer(signInput));296  return new Uint8Array(signature);297}298299export async function buildAuthenticatorData(opts: {300  rpId: string;301  flags: number;302  signCount: number;303  credentialId?: Uint8Array;304  publicKeyCose?: Uint8Array;305}): Promise<Uint8Array> {306  const rpIdHash = new Uint8Array(307    await crypto.subtle.digest("SHA-256", toArrayBuffer(textEncoder.encode(opts.rpId)))308  );309  const signCount = new Uint8Array(4);310  new DataView(signCount.buffer).setUint32(0, opts.signCount, false);311312  const base = concatBytes(rpIdHash, Uint8Array.of(opts.flags), signCount);313  if (!opts.credentialId || !opts.publicKeyCose) {314    return base;315  }316317  const aaguid = new Uint8Array(16);318  const credLength = new Uint8Array(2);319  new DataView(credLength.buffer).setUint16(0, opts.credentialId.length, false);320321  return concatBytes(base, aaguid, credLength, opts.credentialId, opts.publicKeyCose);322}323324export function buildClientDataJSON(325  type: "webauthn.create" | "webauthn.get",326  challenge: string,327  origin: string328): Uint8Array {329  return textEncoder.encode(330    JSON.stringify({331      type,332      challenge,333      origin,334      crossOrigin: false,335    })336  );337}338339export function createAttestationObject(authData: Uint8Array): Uint8Array {340  return encodeMap([341    ["fmt", "none"],342    ["attStmt", new Map()],343    ["authData", authData],344  ]);345}346347export async function sha256(data: Uint8Array): Promise<Uint8Array> {348  return new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));349}350351/**352 * Generate a fresh credential ID. WebAuthn requires the value be unguessable353 * and unique per credential; 32 bytes from the CSPRNG satisfies both.354 */355export function generateCredentialId(): {356  bytes: Uint8Array;357  base64url: string;358} {359  const bytes = crypto.getRandomValues(new Uint8Array(32));360  return { bytes, base64url: base64urlEncode(bytes) };361}362