import { client } from "./client"; const VLI32_MAXBYTES = 5; function encodeVli32(u: number): Uint8Array { if (u >> 7 === 0) { return new Uint8Array([(u << 1) | 0x1]); } let shift = u >> 7; for (let bytes = 2; bytes < 5; ++bytes) { shift >>= 7; if (shift === 0) { const result = new Uint8Array(bytes); const encoded = ((u << 1) | 0x1) << (bytes - 1); for (let i = 0; i < bytes; ++i) { result[i] = (encoded >> (i * 8)) & 0xff; } return result; } } const result = new Uint8Array(5); result[0] = 0; for (let i = 1; i < 5; ++i) { result[i] = (u >> (i * 8)) & 0xff; } } function decodeVli32(p: Uint8Array): [number, number] { const u = p[0]; if (u & 1) { return [u >> 1, 1]; } let result = 0; if ((u & 0xf) === 0) { for (let i = 1; i < 5; ++i) { result |= p[i] << (i * 8); } return [result, 5]; } // ctz in terms of clz const bytes = 31 - Math.clz32(u & -u); result = u >> (bytes + 1); for (let i = 1; i < bytes + 1; ++i) { result |= p[i] << (i * 8); } return [result, 1 + bytes]; } // Wrappers around buffers for parsing and serialization. class Cursor { buf: Uint8Array; offset: number; constructor(buf: Uint8Array, offset = 0) { this.buf = buf; this.offset = offset; } remaining(): number { return this.buf.length - this.offset; } skip(i: number) { if (this.remaining() < i) { throw new Error(); } this.offset += i; } readExact(i: number): Uint8Array { if (this.remaining() < i) { throw new Error(); } const out = this.buf.subarray(this.offset, this.offset + i); this.offset += i; return out; } readVli32(): number { const result = decodeVli32(this.buf.subarray(this.offset)); if (result === null) { throw new Error(); } const [u, bytes] = result; this.offset += bytes; return u; } readVli32s(): number { const result = this.readVli32(); return (result >> 1) ^ -(result & 1); } readArrayVli32s(): Int32Array { const count = this.readVli32(); const result = new Int32Array(count); for (let i = 0; i < count; ++i) { result[i] = this.readVli32s(); } return result; } readBytes(): Uint8Array { const len = this.readVli32(); return this.readExact(len); } readText(): string { const len = this.readVli32(); const utf8bytes = this.readExact(len); return new TextDecoder().decode(utf8bytes); } } class Writer { buf: Uint8Array; offset: number; constructor(buf: Uint8Array) { this.buf = buf; this.offset = 0; } writeByte(b: number) { this.buf[this.offset] = b; this.offset += 1; } writeVli32(u: number) { const result = encodeVli32(u); this.buf.set(result, this.offset); this.offset += result.length; } writeFixed(bs: Uint8Array) { this.buf.set(bs, this.offset); this.offset += bs.length; } writeBytes(bs: Uint8Array) { this.writeVli32(bs.length); this.writeFixed(bs); } out(): Uint8Array { return this.buf.subarray(0, this.offset); } } export enum ExMsgType { AuthRequest = 2, RegisterCredential = 5, RegistrationFinish = 6, AssertCredential = 7, AssertionFinish = 8, } function serializeMessage(type: ExMsgType, body: Uint8Array): Uint8Array { const buf = new Uint8Array(1 + body.length); buf[0] = type; buf.set(body, 1); return buf; } const CHALLENGE_BYTES = 32; const USERID_BYTES = 16; const enum CredentialType { Passkey = 1, } interface PasskeyCredential { challenge: Uint8Array; rpId: string; userId: Uint8Array; userName: string; algorithms: Int32Array; } function parsePasskeyCredential(buf: Uint8Array): PasskeyCredential | null { const cur = new Cursor(buf); try { // Skip the presence bitmap. cur.skip(1); const challenge = cur.readExact(CHALLENGE_BYTES); const rpId = cur.readText(); const userId = cur.readExact(USERID_BYTES); const userName = cur.readText(); const algorithms = cur.readArrayVli32s(); return { challenge, rpId, userId, userName, algorithms }; } catch { return null; } } // The default RPID is the origin's domain name, but the server also provides // one. // From spec: "Relying Parties SHOULD set [requireResidentKey] to true if, and // only if, residentKey is set to required." // We require a discoverable (resident) credential for a single-factor auth. function extractCreationOptions( cred: PasskeyCredential, ): PublicKeyCredentialCreationOptions { return { authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required", }, challenge: cred.challenge as Uint8Array, pubKeyCredParams: Array.from(cred.algorithms, (alg) => ({ alg, type: "public-key", })), rp: { id: cred.rpId, name: "", }, user: { displayName: "", id: cred.userId as Uint8Array, name: cred.userName, }, timeout: 300000, }; } // The previous one was for registration, this one is for authentication. // Lots of duplication. One is a subset of another. interface PasskeyCredentialRequest { challenge: Uint8Array; rpId: string; } function parsePasskeyCredentialRequest( buf: Uint8Array, ): PasskeyCredentialRequest | null { const cur = new Cursor(buf); try { cur.skip(1); const challenge = cur.readExact(CHALLENGE_BYTES); const rpId = cur.readText(); return { challenge, rpId }; } catch { return null; } } function extractRequestOptions( cred: PasskeyCredentialRequest, ): PublicKeyCredentialRequestOptions { return { challenge: cred.challenge as Uint8Array, rpId: cred.rpId, timeout: 300000, userVerification: "required", }; } // clientData happens to be a UTF-8 encoded JSON string, but we explicitly treat // it as opaque bytes because it's canonically signed as such. Avoid // unnecessary reencodings, let the server validate. interface PasskeyRecord { publicKey: Uint8Array; authData: Uint8Array; clientData: Uint8Array; } function serializePasskeyRecord(rec: PasskeyRecord): Uint8Array { const capacity = VLI32_MAXBYTES + rec.publicKey.length + VLI32_MAXBYTES + rec.authData.length + VLI32_MAXBYTES + rec.clientData.length; const buf = new Uint8Array(capacity); const w = new Writer(buf); w.writeByte(0); w.writeBytes(rec.publicKey); w.writeBytes(rec.authData); w.writeBytes(rec.clientData); return w.out(); } // userHandle must always be present for discoverable credentials. interface PasskeyAssertion { credId: Uint8Array; authData: Uint8Array; clientData: Uint8Array; signature: Uint8Array; userHandle: Uint8Array; } function serializePasskeyAssertion(rec: PasskeyAssertion): Uint8Array { const capacity = VLI32_MAXBYTES + rec.credId.length + VLI32_MAXBYTES + rec.authData.length + VLI32_MAXBYTES + rec.clientData.length + VLI32_MAXBYTES + rec.signature.length + rec.userHandle.length; const buf = new Uint8Array(capacity); const w = new Writer(buf); w.writeByte(0); w.writeBytes(rec.credId); w.writeBytes(rec.authData); w.writeBytes(rec.clientData); w.writeBytes(rec.signature); w.writeFixed(rec.userHandle); return w.out(); } export enum AuthMethod { Certificate = 1, Envelope = 2, Passkey = 3, } type AuthRequest = | { method: AuthMethod.Certificate; username: string } | { method: AuthMethod.Envelope; username: string; record: Uint8Array } | { method: AuthMethod.Passkey }; function authRequestMaxSize(req: AuthRequest): number { // Presence bitmap. let cap = 1; switch (req.method) { case AuthMethod.Certificate: cap += new TextEncoder().encode(req.username).length; break; case AuthMethod.Passkey: // Passkeys don't involve usernames or other credentials, but because I // was stupid, I made the username field mandatory in all AuthRequest // messages. So, send an empty string, which is always encoded as a // one-byte VLI 0. cap += 1; break; case AuthMethod.Envelope: cap += new TextEncoder().encode(req.username).length; cap += req.record.length; break; } cap += VLI32_MAXBYTES; return cap; } // Top-level serialization functions function serializeAuthRequest(req: AuthRequest): Uint8Array { const buf = new Uint8Array(authRequestMaxSize(req)); const w = new Writer(buf); w.writeByte(0); switch (req.method) { // Empty username. Other methods not implemented. case AuthMethod.Passkey: w.writeVli32(0); } w.writeVli32(req.method); return w.out(); } // Only expect the passkey credential. export function parseRegisterCredential( buf: Uint8Array, ): PasskeyCredential | null { // At least the presence bitmap and the enumerant. if (buf.length < 2) { return null; } const choice = decodeVli32(buf.subarray(1)); if (choice === null) { return null; } const [credType, credTypeN] = choice; switch (credType) { case CredentialType.Passkey: // We only care about the passkey now, so break out and continue in the // function body. break; default: // This one shall be a different error. return null; } const cred = parsePasskeyCredential(buf.subarray(1 + credTypeN)); return cred; } // Similar to parsing registration data, only serialize the passkey as nothing // else exists at the moment. function serializeRegistrationFinish(msg: PasskeyRecord): Uint8Array { const record = serializePasskeyRecord(msg); const capacity = 1 + VLI32_MAXBYTES + record.length; const buf = new Uint8Array(capacity); const w = new Writer(buf); w.writeByte(0); w.writeVli32(CredentialType.Passkey); w.writeFixed(record); return w.out(); } export function parseAssertCredential( buf: Uint8Array, ): PasskeyCredentialRequest | null { if (buf.length < 2) { return null; } const choice = decodeVli32(buf.subarray(1)); if (!choice) { return null; } const [credType, credTypeN] = choice; switch (credType) { case CredentialType.Passkey: break; default: return null; } const cred = parsePasskeyCredentialRequest(buf.subarray(1 + credTypeN)); return cred; } function serializeAssertionFinish(msg: PasskeyAssertion): Uint8Array { const record = serializePasskeyAssertion(msg); const capacity = 1 + VLI32_MAXBYTES + record.length; const buf = new Uint8Array(capacity); const w = new Writer(buf); w.writeByte(0); w.writeVli32(CredentialType.Passkey); w.writeFixed(record); return w.out(); } // Handlers export enum AuthState { None, Pending, } export async function handleRegisterCredential(buf: Uint8Array) { const registerCred = parseRegisterCredential(buf); const publicKey = extractCreationOptions(registerCred); let credential; try { credential = (await navigator.credentials.create({ publicKey, })) as PublicKeyCredential; } catch { alert("Registration failed."); return; } const response = credential.response; if (!(response instanceof AuthenticatorAttestationResponse)) { alert("Invalid attestation response."); return; } const reply = serializeRegistrationFinish({ publicKey: new Uint8Array(response.getPublicKey()), authData: new Uint8Array(response.getAuthenticatorData()), clientData: new Uint8Array(response.clientDataJSON), }); const msg = serializeMessage(ExMsgType.RegistrationFinish, reply); client.serv.send(msg); } export function sendPasskeyLoginRequest() { const reply = serializeAuthRequest({ method: AuthMethod.Passkey, }); const msg = serializeMessage(ExMsgType.AuthRequest, reply); client.authState = AuthState.Pending; client.serv.send(msg); } export async function handleAssertCredential(buf: Uint8Array) { if (client.authState !== AuthState.Pending) { return; } const credOptions = parseAssertCredential(buf); const publicKey = extractRequestOptions(credOptions); let credential; try { credential = (await navigator.credentials.get({ publicKey, })) as PublicKeyCredential; } catch { alert("Authentication failed."); return; } const response = credential.response; if (!(response instanceof AuthenticatorAssertionResponse)) { alert("Invalid assertion response."); return; } const reply = serializeAssertionFinish({ credId: new Uint8Array(credential.rawId), authData: new Uint8Array(response.authenticatorData), clientData: new Uint8Array(response.clientDataJSON), signature: new Uint8Array(response.signature), userHandle: new Uint8Array(response.userHandle), }); const msg = serializeMessage(ExMsgType.AssertionFinish, reply); client.authState = AuthState.None; client.serv.send(msg); }