diff --git a/deno.json b/deno.json index 09b2da7..a1507c0 100644 --- a/deno.json +++ b/deno.json @@ -20,16 +20,18 @@ }, "imports": { + "@panva/jose": "jsr:@panva/jose@^6.0.11", "@repo/": "./src/", "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.4", "@oak/oak": "jsr:@oak/oak@^17.1.4", - "@std/assert": "jsr:@std/assert@^1.0.12", + "@sitnik/nanoid": "jsr:@sitnik/nanoid@^5.1.5", "@types/react": "npm:@types/react@^19.1.2", "@vitejs/plugin-react": "npm:@vitejs/plugin-react@^4.4.1", "react": "npm:react@^19.1.0", "react-dom": "npm:react-dom@^19.1.0", "react-router-dom": "npm:react-router-dom@^7.5.1", - "vite": "npm:vite@^6.3.2" + "vite": "npm:vite@^6.3.2", + "zod": "npm:zod@3" }, "nodeModulesDir": "auto", diff --git a/deno.lock b/deno.lock index 65778bd..e6fd8c7 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,8 @@ "jsr:@oak/commons@1": "1.0.1", "jsr:@oak/oak@*": "17.1.4", "jsr:@oak/oak@^17.1.4": "17.1.4", + "jsr:@panva/jose@^6.0.11": "6.0.11", + "jsr:@sitnik/nanoid@^5.1.5": "5.1.5", "jsr:@std/assert@1": "1.0.13", "jsr:@std/assert@^1.0.12": "1.0.13", "jsr:@std/bytes@1": "1.0.6", @@ -15,16 +17,20 @@ "jsr:@std/internal@^1.0.6": "1.0.8", "jsr:@std/media-types@1": "1.1.0", "jsr:@std/path@1": "1.1.0", + "jsr:@std/regexp@*": "1.0.1", + "jsr:@std/ulid@*": "1.0.0", "npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2_@types+node@22.15.15", "npm:@types/node@*": "22.15.15", "npm:@types/react@^19.1.2": "19.1.6", "npm:@vitejs/plugin-react@^4.4.1": "4.5.1_vite@6.3.5__picomatch@4.0.2_@babel+core@7.27.4_@types+node@22.15.15", + "npm:lodash-es@*": "4.17.21", "npm:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.1.0": "19.1.0_react@19.1.0", "npm:react-router-dom@^7.5.1": "7.6.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:react@^19.1.0": "19.1.0", "npm:vite@*": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", - "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15" + "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", + "npm:zod@3": "3.25.51" }, "jsr": { "@oak/commons@1.0.1": { @@ -50,6 +56,12 @@ "npm:path-to-regexp" ] }, + "@panva/jose@6.0.11": { + "integrity": "90d3a31fbe29ab3ab64f5c8c7c413b8a962663d7c62eacd51cc7f41d9c42ccbf" + }, + "@sitnik/nanoid@5.1.5": { + "integrity": "55bd5f57087d67b1dcb7c1f4a07efdfe77a3ac57ca0af90f162c1f676ebf8f4b" + }, "@std/assert@1.0.13": { "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", "dependencies": [ @@ -82,6 +94,12 @@ }, "@std/path@1.1.0": { "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" + }, + "@std/regexp@1.0.1": { + "integrity": "5179d823465085c5480dafb44438466e83c424fadc61ba31f744050ecc0f596d" + }, + "@std/ulid@1.0.0": { + "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" } }, "npm": { @@ -655,6 +673,9 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, + "lodash-es@4.17.21": { + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ @@ -819,19 +840,24 @@ }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "zod@3.25.51": { + "integrity": "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg==" } }, "workspace": { "dependencies": [ "jsr:@oak/oak@^17.1.4", - "jsr:@std/assert@^1.0.12", + "jsr:@panva/jose@^6.0.11", + "jsr:@sitnik/nanoid@^5.1.5", "npm:@deno/vite-plugin@^1.0.4", "npm:@types/react@^19.1.2", "npm:@vitejs/plugin-react@^4.4.1", "npm:react-dom@^19.1.0", "npm:react-router-dom@^7.5.1", "npm:react@^19.1.0", - "npm:vite@^6.3.2" + "npm:vite@^6.3.2", + "npm:zod@3" ] } } diff --git a/src/schema/crypto.ts b/src/schema/crypto.ts new file mode 100644 index 0000000..02de700 --- /dev/null +++ b/src/schema/crypto.ts @@ -0,0 +1,55 @@ +import { z } from "zod/v4"; +import { decodeJwt, importJWK, jwtVerify } from "@panva/jose"; +import { JWTPayload, JWTVerifyResult } from "@panva/jose"; + +export const SIGNING_ALGO = "ES256"; + +// tokens + +export const jwtDecodedSchema = z.jwt({ abort: true }).transform((val) => { + return decodeJwt(val); +}); + +export const jwtVerifier = (pubkey: CryptoKey) => + z.transform>(async (val, ctx) => { + try { + return await jwtVerify(val, pubkey, { algorithms: [SIGNING_ALGO] }); + } catch (e) { + ctx.issues.push({ + code: "custom", + message: `could not verify JWT: ${e}`, + input: val, + }); + + return z.NEVER; + } + }); + +// keys + +export const jwkSchema = z.unknown().transform(async (val, ctx) => { + try { + if (typeof val === "object" && val !== null) { + return await importJWK(val, SIGNING_ALGO, { extractable: true }); + } else { + ctx.issues.push({ + code: "custom", + message: "not a valid JWK object", + input: val, + }); + } + } catch (e) { + ctx.issues.push({ + code: "custom", + message: `could not import JWK object: ${e}`, + input: val, + }); + } + + return z.NEVER; +}); + +export const jwkPairSchema = z.object({ + publicKey: jwkSchema, + privateKey: jwkSchema, +}).transform((val) => val as CryptoKeyPair); diff --git a/src/schema/proto.ts b/src/schema/proto.ts new file mode 100644 index 0000000..189dc54 --- /dev/null +++ b/src/schema/proto.ts @@ -0,0 +1,44 @@ +import { z } from "zod/v4"; +import { jwkSchema, jwtSchema } from "./crypto.ts"; +import { identIdSchema, realmIdSchema } from "./state.ts"; + +export const preauthAuthnMessageSchema = z.object({ + msg: z.literal("preauth.authn"), + pubkey: jwkSchema, +}); + +export type PreauthAuthnMessage = z.infer; + +export const preauthMessageSchema = z.discriminatedUnion("msg", [ + preauthAuthnMessageSchema, +]); + +export type PreauthMessage = z.infer; + +export const realmStatusMessageSchema = z.object({ + msg: z.literal("realm.status"), +}); + +export const realmStatusResponseSchema = z.object({ + msg: z.literal("realm.status"), + realm: realmIdSchema, + identities: z.array(identIdSchema), +}); + +export type RealmStatusMessage = z.infer; +export type RealmStatusResponse = z.infer; + +export const realmBroadcastMessageSchema = z.object({ + msg: z.literal("realm.broadcast"), + payload: z.any().nonoptional(), + recipients: z.array(identIdSchema), +}); + +export type RealmBroadcastMessage = z.infer; + +export const realmMessageSchema = z.discriminatedUnion("msg", [ + realmStatusMessageSchema, + realmBroadcastMessageSchema, +]); + +export type RealmMessage = z.infer; diff --git a/src/schema/state.ts b/src/schema/state.ts new file mode 100644 index 0000000..a0c5341 --- /dev/null +++ b/src/schema/state.ts @@ -0,0 +1,28 @@ +import { inferBrandedId, makeBrandedId } from "@repo/types/branded-id.ts"; + +const realmIdBrand = makeBrandedId("realm", 16); +export type RealmId = inferBrandedId; +export const { + generator: generateRealmId, + validator: validateRealmId, + parser: parseRealmId, + schema: realmIdSchema, +} = realmIdBrand; + +const identIdBrand = makeBrandedId("ident", 24); +export type IdentId = inferBrandedId; +export const { + generator: generateIdentId, + validator: validateIdentId, + parser: parseIdentId, + schema: identIdSchema, +} = identIdBrand; + +const peerIdBrand = makeBrandedId("peer", 24); +export type PeerId = inferBrandedId; +export const { + generator: generatePeerId, + validator: validatePeerId, + parser: parsePeerId, + schema: peerIdSchema, +} = peerIdBrand; diff --git a/src/server/socket/handler.ts b/src/server/socket/handler.ts index 7592d8b..691382f 100644 --- a/src/server/socket/handler.ts +++ b/src/server/socket/handler.ts @@ -1,23 +1,54 @@ -import { streamSocket, takeSocket } from "@repo/common/socket.ts"; +import { takeSocket } from "@repo/common/socket.ts"; +import { z } from "zod/v4"; -export async function socketHandler(this: WebSocket) { - try { - this.send("your name?"); +import { StrictMap } from "@repo/common/strict-map.ts"; +import { IdentId, RealmId } from "@repo/schema/state.ts"; +import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; +import { preauthMessageSchema } from "@repo/schema/proto.ts"; - const name = await takeSocket(this, 5000); - if (!name) return; +type Realm = { + id: RealmId; + sockets: StrictMap; + identities: StrictMap; +}; - this.send(`welcome, ${name}`); - for await (const message of streamSocket(this)) { - this.send(`${name} said: ${message}`); - } +/* +const realmMap = new StrictMap(); + +function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? [...ss, socket] : [socket]); +} + +function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []) +} +*/ + +export async function socketHandler(this: WebSocket) { + try { + await preauthHandler(this); } catch (e) { - console.error(e); + if (e instanceof z.ZodError) { + this.send(`400 bad params: ${z.prettifyError(e)}\n`); + } else { + this.send(`500 server error: ${e}\n`); + console.error(e); + } + + this.send(`kthkbye\n`); } finally { if (this.readyState !== this.CLOSED) { this.close(); } - console.log("kthxbye"); } } + +async function preauthHandler(ws: WebSocket) { + const data = await takeSocket(ws, 3000); + const jwt = await jwtDecodedSchema.pipe(preauthMessageSchema).parseAsync( + data, + ); + + console.log(jwt); +} diff --git a/src/types/assertions.ts b/src/types/assertions.ts new file mode 100644 index 0000000..9b019fc --- /dev/null +++ b/src/types/assertions.ts @@ -0,0 +1,5 @@ +export type Compatible = T extends U ? T : never; + +export function assertCompatible(): Compatible { + return undefined as Compatible; +} diff --git a/src/types/branded-id.ts b/src/types/branded-id.ts new file mode 100644 index 0000000..09f7f5f --- /dev/null +++ b/src/types/branded-id.ts @@ -0,0 +1,55 @@ +import { escape } from "jsr:@std/regexp/escape"; +import { nanoid } from "@sitnik/nanoid"; +import { z } from "zod/v4"; + +/** result from a branded id maker invocation */ +export interface BrandedIdResult { + generator: () => T; + validator: (i?: unknown) => i is T; + parser: (i?: unknown) => T; + schema: Z; +} + +/** given a branded id maker, return the branded id type */ +export type inferBrandedId = T extends BrandedIdResult ? U + : never; + +/** + * creates a branded identifier system with prefix and validation + * + * @param prefix - string prefix for the identifier (e.g., "usr", "org") + * @param length - length of the random portion, defaults to 16 + * @returns object with generator, validator, schema functions, and inferred type + */ +export function makeBrandedId(prefix: string, length = 16) { + const brand = escape(prefix); + const pattern = `${brand}-[A-Za-z0-9_-]{${length.toString()}}`; + const regex = new RegExp(pattern); + + const schema = z.string().regex(regex).brand(Symbol(brand)); + type BrandType = z.infer; + + const generator = function (): BrandType { + const id = nanoid(length); + return schema.parse(`${prefix}-${id}`); + }; + + const validator = function (input?: unknown): input is BrandType { + return ( + input != null && + typeof input === "string" && + schema.safeParse(input).success + ); + }; + + const parser = function (input?: unknown): BrandType { + return schema.parse(input); + }; + + return { + generator, + validator, + parser, + schema, + } satisfies BrandedIdResult; +}