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}234235236237238239240241function 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 259 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}