aboutsummaryrefslogtreecommitdiff
path: root/webAO
diff options
context:
space:
mode:
authorOsmium Sorcerer <os@sof.beauty>2026-04-07 13:19:40 +0000
committerOsmium Sorcerer <os@sof.beauty>2026-04-18 16:52:23 +0000
commitaa4c30bb6d1e46b5019065fba6c0eb3c08aa1f34 (patch)
tree2b6bf1557122d56eecbdcbc2d063289bc16d1186 /webAO
parentae7ef2c6c76947ea12cbb1592152d9c80fd1a8f3 (diff)
Add passkey authentication (WebAuthn)HEADmaster
Bring in the subprotocol (the same as what's used on the desktop client for public-key authentication) to carry the relevant messages: - AuthRequest: first step in the flow, the client sends it to signal the intent to authenticate to the server. - AssertCredential and AssertionFinish: server's challenge and client's response, respectively, to finalize the flow. - RegisterCredential and RegistrationFinish: same structure as the above. Unlike the simple public-key auth with an out-of-band setup, passkeys require user interaction to register. User must be authorized. Validate all relevant checks on the API side, and hand the data over to the server for it to verify attestations and assertions. Because it's a primary auth mechanism (not a second factor), require user verification. As we don't use any other method on web, add a passkey button as the only sign-in interface. Passkeys are discoverable, we don't even need a username.
Diffstat (limited to 'webAO')
-rw-r--r--webAO/auth.ts7
-rw-r--r--webAO/client.ts35
-rw-r--r--webAO/ext_packet.ts510
3 files changed, 550 insertions, 2 deletions
diff --git a/webAO/auth.ts b/webAO/auth.ts
new file mode 100644
index 0000000..7912e8c
--- /dev/null
+++ b/webAO/auth.ts
@@ -0,0 +1,7 @@
+import { sendPasskeyLoginRequest } from "./ext_packet";
+
+export function initWebAuthn() {
+ document.getElementById("use_passkey").addEventListener("click", () => {
+ sendPasskeyLoginRequest();
+ });
+}
diff --git a/webAO/client.ts b/webAO/client.ts
index 50eb18b..43b04f0 100644
--- a/webAO/client.ts
+++ b/webAO/client.ts
@@ -21,6 +21,8 @@ import {
fetchEvidenceList,
fetchCharacterList,
} from "./client/fetchLists";
+import { ExMsgType, handleRegisterCredential, handleAssertCredential, AuthState } from "./ext_packet"
+import { initWebAuthn } from "./auth"
const { ip: serverIP, connect, mode, theme, serverName, char: autoChar, area: autoArea } = queryParser();
export { autoChar, autoArea };
@@ -156,6 +158,7 @@ class Client extends EventEmitter {
emote_extensions: string[];
emotions_extensions: string[];
background_extensions: string[];
+ authState: AuthState;
constructor(connectionString: string) {
super();
@@ -172,6 +175,7 @@ class Client extends EventEmitter {
this.serv.addEventListener("close", this.emit.bind(this, "close"));
this.serv.addEventListener("message", this.emit.bind(this, "message"));
this.serv.addEventListener("error", this.emit.bind(this, "error"));
+ this.serv.binaryType = "arraybuffer";
// If the client is still not connected 5 seconds after attempting to join
// It's fair to assume that the server is not reachable
@@ -215,7 +219,8 @@ class Client extends EventEmitter {
this.charicon_extensions = [".webp", ".png"];
this.emote_extensions = [".webp", ".png", ".apng", ".gif"];
this.emotions_extensions = [".webp", ".png"];
- this.background_extensions = [".webp", ".png", ".apng", ".gif"];;
+ this.background_extensions = [".webp", ".png", ".apng", ".gif"];
+ this.authState = AuthState.None;
}
/**
@@ -269,6 +274,7 @@ class Client extends EventEmitter {
document.getElementById("client_charselect").style.display = "none";
appendICNotice("Connected");
client.joinServer();
+ initWebAuthn();
}
/**
@@ -304,7 +310,13 @@ class Client extends EventEmitter {
const msg = e.data;
console.debug(`S: ${msg}`);
- this.handle_server_packet(msg);
+ if (typeof msg === "string") {
+ this.handle_server_packet(msg);
+ return;
+ }
+
+ const msgBytes = new Uint8Array(msg);
+ this.handle_ext_packet(msgBytes);
}
/**
@@ -413,6 +425,25 @@ class Client extends EventEmitter {
fetchEvidenceList();
fetchCharacterList();
}
+
+ handle_ext_packet(msg: Uint8Array) {
+ if (msg.length === 0) {
+ return;
+ }
+ const type = msg[0];
+ const body = msg.subarray(1);
+ switch (type) {
+ case ExMsgType.RegisterCredential:
+ handleRegisterCredential(body);
+ break;
+ case ExMsgType.AssertCredential:
+ handleAssertCredential(body);
+ break;
+ default:
+ console.log("Invalid message received: ${type}");
+ break;
+ }
+ }
}
export default Client;
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);
+}