aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--public/client.html6
-rw-r--r--webAO/auth.ts7
-rw-r--r--webAO/client.ts35
-rw-r--r--webAO/ext_packet.ts510
4 files changed, 556 insertions, 2 deletions
diff --git a/public/client.html b/public/client.html
index a476915..a77132d 100644
--- a/public/client.html
+++ b/public/client.html
@@ -937,6 +937,12 @@
</span>
<br />
<br />
+ <fieldset style="margin: 1rem auto; padding: 1rem; max-width: 200px;">
+ <legend>Authenticate</legend>
+ <div><button type="button" id="use_passkey" class="client_button hover_button">Use passkey</button></div>
+ </fieldset>
+ <br />
+ <br />
<button id="client_disconnect" onclick="DisconnectButton()">
Disconnect
</button>
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);
+}