diff options
Diffstat (limited to 'webAO/ext_packet.ts')
| -rw-r--r-- | webAO/ext_packet.ts | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/webAO/ext_packet.ts b/webAO/ext_packet.ts new file mode 100644 index 0000000..bdf2238 --- /dev/null +++ b/webAO/ext_packet.ts @@ -0,0 +1,510 @@ +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<ArrayBuffer>, + pubKeyCredParams: Array.from(cred.algorithms, (alg) => ({ + alg, + type: "public-key", + })), + rp: { + id: cred.rpId, + name: "", + }, + user: { + displayName: "", + id: cred.userId as Uint8Array<ArrayBuffer>, + 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<ArrayBuffer>, + 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); +} |
