podcast manager

wip on default realm stuff

+448 -213
+1
eslint.config.js
··· 50 50 '@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '(?:^_)'}], 51 51 '@typescript-eslint/no-unnecessary-condition': 'off', 52 52 '@typescript-eslint/restrict-template-expressions': 'off', 53 + '@typescript-eslint/only-throw-error': 'off', 53 54 '@typescript-eslint/no-unnecessary-type-parameters': 'off', 54 55 '@typescript-eslint/no-unnecessary-type-constraint': 'off', 55 56 'tsdoc/syntax': 'warn',
+27
package-lock.json
··· 8 8 "name": "skypod", 9 9 "version": "0.0.0", 10 10 "dependencies": { 11 + "@preact/signals": "^2.2.0", 11 12 "express": "^5.1.0", 12 13 "isomorphic-ws": "^5.0.0", 13 14 "jose": "^6.0.11", ··· 2399 2400 "peerDependencies": { 2400 2401 "@babel/core": "7.x", 2401 2402 "vite": "2.x || 3.x || 4.x || 5.x || 6.x" 2403 + } 2404 + }, 2405 + "node_modules/@preact/signals": { 2406 + "version": "2.2.0", 2407 + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.2.0.tgz", 2408 + "integrity": "sha512-P3KPcEYyVk9Wiwfw68QQzRpPkt0H+zjfH3X4AaGCDlc86GuRBYFGiAxT1nC5F5qlsVIEmjNJ9yVYe7C91z3L+g==", 2409 + "license": "MIT", 2410 + "dependencies": { 2411 + "@preact/signals-core": "^1.9.0" 2412 + }, 2413 + "funding": { 2414 + "type": "opencollective", 2415 + "url": "https://opencollective.com/preact" 2416 + }, 2417 + "peerDependencies": { 2418 + "preact": ">= 10.25.0" 2419 + } 2420 + }, 2421 + "node_modules/@preact/signals-core": { 2422 + "version": "1.10.0", 2423 + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.10.0.tgz", 2424 + "integrity": "sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA==", 2425 + "license": "MIT", 2426 + "funding": { 2427 + "type": "opencollective", 2428 + "url": "https://opencollective.com/preact" 2402 2429 } 2403 2430 }, 2404 2431 "node_modules/@prefresh/babel-plugin": {
+1
package.json
··· 20 20 "#server/*": "./src/server/*" 21 21 }, 22 22 "dependencies": { 23 + "@preact/signals": "^2.2.0", 23 24 "express": "^5.1.0", 24 25 "isomorphic-ws": "^5.0.0", 25 26 "jose": "^6.0.11",
+1 -6
src/client/components/peer-list.tsx
··· 13 13 14 14 const updatePeers = () => { 15 15 queueMicrotask(() => { 16 - const states = webrtcManager.getPeerStates() 17 - console.debug('updating peers', states) 18 - setPeers(states) 16 + setPeers(webrtcManager.peers) 19 17 }) 20 18 } 21 19 ··· 65 63 return ( 66 64 <div className="connection-status"> 67 65 <span className="status-icon">{getStatusIcon()}</span> 68 - <code className="initiator"> 69 - {state.address.family}-{state.address.address}:{state.address.port} 70 - </code> 71 66 </div> 72 67 ) 73 68 }
+12 -4
src/client/page-app.tsx
··· 1 - import {RealmConnectionProvider} from './realm/context' 1 + import {RealmConnectionManager} from './realm-connection-manager' 2 + import {RealmConnectionProvider} from './realm/context-connection' 3 + import {RealmIdentityFallbackProps, RealmIdentityProvider} from './realm/context-identity' 2 4 import {WebRTCDemo} from './webrtc-demo' 3 5 4 6 export const App: preact.FunctionComponent = () => { 7 + const identityFallback = (p: RealmIdentityFallbackProps) => 8 + p.loading ? <h1>Loading</h1> : <h1>Error! {p.error?.message}</h1> 9 + 5 10 return ( 6 - <RealmConnectionProvider url="ws://localhost:3001/stream"> 7 - <WebRTCDemo /> 8 - </RealmConnectionProvider> 11 + <RealmIdentityProvider fallback={identityFallback}> 12 + <RealmConnectionProvider url="ws://localhost:3001/stream"> 13 + <RealmConnectionManager /> 14 + <WebRTCDemo /> 15 + </RealmConnectionProvider> 16 + </RealmIdentityProvider> 9 17 ) 10 18 }
+54
src/client/realm-connection-manager.tsx
··· 1 + import {RealmBrand} from '#common/protocol' 2 + import {useSignal} from '@preact/signals' 3 + import {useCallback, useContext} from 'preact/hooks' 4 + import {RealmConnectionContext} from './realm/context-connection' 5 + import {RealmIdentityContext} from './realm/context-identity' 6 + 7 + export const RealmConnectionManager: preact.FunctionComponent = () => { 8 + const identityContext = useContext(RealmIdentityContext) 9 + if (!identityContext) throw new Error('expected to be called inside realm identity context!') 10 + 11 + const connectionContext = useContext(RealmConnectionContext) 12 + if (!connectionContext) throw new Error('expected to be called inside realm connection context!') 13 + 14 + const invitation = useSignal<string>() 15 + 16 + const register = useCallback(() => { 17 + console.log('register') 18 + connectionContext.realmid.value = RealmBrand.generate() 19 + }, [connectionContext.realmid]) 20 + 21 + const exchange = useCallback(() => { 22 + console.log('exchange', invitation.value) 23 + }, [invitation]) 24 + 25 + return connectionContext?.realmid.value ? ( 26 + <h1> 27 + Connection: 28 + {connectionContext.realmid.value} 29 + {connectionContext.connected.value ? '🟢 Connected' : '🔴 Disconnected'} 30 + </h1> 31 + ) : ( 32 + <div> 33 + <h1> 34 + Connection: <code>NONE</code> 35 + </h1> 36 + <p> 37 + Generate a new realm:{' '} 38 + <button type="button" onClick={register}> 39 + Register 40 + </button> 41 + </p> 42 + <p> 43 + Exchange an invite: 44 + <textarea 45 + value={invitation.value} 46 + onInput={(e) => (invitation.value = e.currentTarget.value)} 47 + /> 48 + <button type="button" onClick={exchange}> 49 + Exchange 50 + </button> 51 + </p> 52 + </div> 53 + ) 54 + }
+101 -84
src/client/realm/connection.ts
··· 3 3 import SimplePeer, {SimplePeerData} from 'simple-peer' 4 4 import {z} from 'zod/v4' 5 5 6 - import {generateSignableJwt, jwkExport} from '#common/crypto/jwks' 6 + import {generateSignableJwt} from '#common/crypto/jwks' 7 7 import {normalizeError, normalizeProtocolError, ProtocolError} from '#common/errors' 8 8 import { 9 9 IdentID, 10 10 PreauthRegisterRequest, 11 11 preauthRespSchema, 12 + RealmID, 12 13 realmRtcPeerJoinedEventSchema, 13 14 realmRtcPeerLeftEventSchema, 14 15 realmRtcPingRequestSchema, ··· 18 19 realmRtcSignalEventSchema, 19 20 } from '#common/protocol' 20 21 import {streamSocketJson, takeSocketJson} from '#common/socket' 22 + import {RealmIdentity} from './identity' 21 23 import {RealmPeer} from './peer' 22 - import {ConnectionIdentity, PeerState} from './types' 24 + import {PeerState} from './types' 23 25 24 26 const realmRtcMessagesSchema = z.union([ 25 27 realmRtcSignalEventSchema, ··· 31 33 /** manages websocket and webrtc connections for a realm */ 32 34 export class RealmConnection extends EventTarget { 33 35 #url: string 34 - #identity: ConnectionIdentity 36 + #realmid: RealmID 37 + #identity: RealmIdentity 35 38 36 39 #socket: WebSocket 37 40 #peers: Map<IdentID, RealmPeer> 38 41 #nonces: Map<IdentID, string> 39 42 40 - constructor(url: string, identity: ConnectionIdentity) { 43 + constructor(url: string, realmid: RealmID, identity: RealmIdentity) { 41 44 super() 42 45 43 46 this.#url = url 47 + this.#realmid = realmid 44 48 this.#identity = identity 45 49 46 50 this.#peers = new Map() 47 51 this.#nonces = new Map() 48 52 53 + console.debug('realm connection starting!') 49 54 this.#socket = new WebSocket(this.#url) 50 55 this.#socket.onopen = this.#handleSocketOpen 51 56 this.#socket.onclose = this.#handleSocketClose ··· 56 61 return this.#socket.readyState === this.#socket.OPEN 57 62 } 58 63 64 + get peers(): Record<IdentID, PeerState> { 65 + const states: Record<IdentID, PeerState> = {} 66 + for (const [identid, peer] of this.#peers) { 67 + states[identid] = { 68 + identid, 69 + connected: peer.connected, 70 + destroyed: peer.destroyed, 71 + } 72 + } 73 + 74 + return states 75 + } 76 + 59 77 send<T extends unknown>(identid: IdentID, data: T) { 60 - const peer = this.#peers.get(identid) 61 - if (!peer?.connected) throw new Error(`Not connected to peer: ${identid}`) 62 - 63 - peer.send(JSON.stringify(data)) 78 + this.sendRaw(identid, JSON.stringify(data)) 64 79 } 65 80 66 81 sendRaw(identid: IdentID, data: SimplePeerData) { ··· 71 86 } 72 87 73 88 broadcast(data: unknown, self = false) { 89 + this.broadcastRaw(JSON.stringify(data), self) 90 + } 91 + 92 + broadcastRaw(data: SimplePeerData, self = false) { 74 93 this.#peers.forEach((peer, identid) => { 75 - if (self || identid !== this.#identity.identid) peer.send(JSON.stringify(data)) 94 + if (self || identid !== this.#identity.identid) peer.send(data) 76 95 }) 77 96 } 78 97 79 - getPeerStates(): Record<IdentID, PeerState> { 80 - const states: Record<IdentID, PeerState> = {} 81 - for (const [identid, peer] of this.#peers) { 82 - states[identid] = { 83 - identid, 84 - address: peer.address(), 85 - connected: peer.connected, 86 - destroyed: peer.destroyed, 87 - } 98 + destroy() { 99 + console.debug('realm connection destroy!') 100 + 101 + // disconnect from socket 102 + if (this.connected) { 103 + this.#socket.close() 104 + } 105 + 106 + // disconnect from peers 107 + for (const peer of this.#peers.values()) { 108 + peer.destroy() 88 109 } 89 110 90 - return states 111 + this.#peers.clear() 112 + this.#nonces.clear() 91 113 } 92 114 93 - destroy() { 94 - console.debug('realm connection destroy!') 115 + #dispatchCustomEvent(type: string, detail?: object) { 116 + this.dispatchEvent(new CustomEvent(type, {detail})) 117 + } 95 118 96 - if (this.connected) this.#socket.close() 97 - for (const peer of this.#peers.values()) peer.destroy() 119 + // typed helpers 120 + // @example 121 + // this.#socketWrite<YourType>({ 122 + // ... // <- get type errors here 123 + // }) 124 + 125 + async #socketSignedWrite<T extends object>(payload: T) { 126 + const token = await this.#identity.sign( 127 + generateSignableJwt({ 128 + aud: this.#realmid, 129 + iss: this.#identity.identid, 130 + payload, 131 + }), 132 + ) 133 + 134 + this.#socket.send(token) 135 + } 136 + 137 + /// event handlers 138 + 139 + // handle socket open is the main loop for the sendSocket 140 + #handleSocketOpen: WebSocket['onopen'] = async () => { 141 + if (this.#socket == undefined) throw new Error('socket open handler called with no socket?') 142 + 143 + console.debug('realm connection, socket loop open') 144 + this.#dispatchCustomEvent('wsopen') 145 + 146 + try { 147 + await this.#handleOpenAuthenticate() 148 + for await (const data of streamSocketJson(this.#socket)) { 149 + this.#handleOpenMessage(data) 150 + } 151 + } catch (exc) { 152 + const err = normalizeProtocolError(exc) 98 153 99 - this.#peers.clear() 100 - this.#nonces.clear() 154 + console.error('realm connection, socket loop error', err) 155 + this.#dispatchCustomEvent('wserror', {error: err}) 156 + } finally { 157 + this.destroy() 158 + } 101 159 } 102 160 103 161 // do the auth dance 104 162 // TODO: this should be a state machine 105 - #connectionAuthenticate = async () => { 163 + #handleOpenAuthenticate = async () => { 106 164 try { 107 - const pubkey = await jwkExport.parseAsync(this.#identity.keypair.publicKey) 108 165 await this.#socketSignedWrite<PreauthRegisterRequest>({ 109 166 typ: 'req', 110 167 msg: 'preauth.register', 111 - dat: {pubkey}, 168 + dat: { 169 + pubkey: await this.#identity.pubjwk, 170 + }, 112 171 }) 113 172 114 173 const resp = await takeSocketJson(this.#socket, preauthRespSchema) ··· 127 186 } 128 187 129 188 // handle some message coming in on the _websocket_ 130 - #connectionMessage = (data: unknown) => { 189 + #handleOpenMessage = (data: unknown) => { 131 190 const parse = realmRtcMessagesSchema.safeParse(data) 132 191 if (!parse.success) { 133 192 // publish non-handled data to the listeners ··· 165 224 } 166 225 } 167 226 227 + #handleSocketError: WebSocket['onerror'] = (exc) => { 228 + this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)}) 229 + this.destroy() 230 + } 231 + 232 + #handleSocketClose: WebSocket['onclose'] = () => { 233 + this.#dispatchCustomEvent('wsclose') 234 + this.destroy() 235 + } 236 + 237 + // peers 238 + 168 239 #connectPeer(remoteid: IdentID, initiator: boolean): RealmPeer { 169 240 let peer = this.#peers.get(remoteid) 170 241 if (!peer) { ··· 190 261 } 191 262 } 192 263 193 - // handle socket open is the main loop for the sendSocket 194 - #handleSocketOpen: WebSocket['onopen'] = async () => { 195 - if (this.#socket == undefined) throw new Error('socket open handler called with no socket?') 196 - 197 - console.debug('realm connection, socket loop open') 198 - this.#dispatchCustomEvent('wsopen') 199 - 200 - try { 201 - await this.#connectionAuthenticate() 202 - for await (const data of streamSocketJson(this.#socket)) { 203 - this.#connectionMessage(data) 204 - } 205 - } catch (exc) { 206 - const err = normalizeProtocolError(exc) 207 - 208 - console.error('realm connection, socket loop error', err) 209 - this.#dispatchCustomEvent('wserror', {error: err}) 210 - } finally { 211 - this.destroy() 212 - } 213 - } 214 - 215 - #handleSocketError: WebSocket['onerror'] = (exc) => { 216 - this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)}) 217 - this.destroy() 218 - } 219 - 220 - #handleSocketClose: WebSocket['onclose'] = () => { 221 - this.#dispatchCustomEvent('wsclose') 222 - this.destroy() 223 - } 224 - 225 264 #handlePeerSignal = (peer: RealmPeer, payload: SimplePeer.SignalData) => { 226 265 const msg: RealmRtcSignalEvent = { 227 266 typ: 'evt', ··· 257 296 258 297 #handlePeerData = (peer: RealmPeer, chunk: string | Uint8Array) => { 259 298 const data = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk) 260 - const parsed = realmRtcPingRequestSchema.safeParse(chunk) 299 + const parsed = realmRtcPingRequestSchema.safeParse(data) 261 300 if (parsed.success) { 262 301 // reply to pings with pongs 263 302 this.send<RealmRtcPongResponse>(peer.identid, { ··· 268 307 } else { 269 308 this.#dispatchCustomEvent('peerdata', {identid: peer.identid, data}) 270 309 } 271 - } 272 - 273 - // helpers that are just too damn long 274 - 275 - #dispatchCustomEvent(type: string, detail?: object) { 276 - this.dispatchEvent(new CustomEvent(type, {detail})) 277 - } 278 - 279 - // typed helpers 280 - // @example 281 - // this.#socketWrite<YourType>({ 282 - // ... // <- get type errors here 283 - // }) 284 - 285 - #socketSignedWrite = async <T extends object>(payload: T) => { 286 - const token = await generateSignableJwt({ 287 - aud: this.#identity.realmid, 288 - iss: this.#identity.identid, 289 - payload, 290 - }).sign(this.#identity.keypair.privateKey) 291 - 292 - this.#socket.send(token) 293 310 } 294 311 }
+61
src/client/realm/context-connection.tsx
··· 1 + import {Signal, useSignal, useSignalEffect} from '@preact/signals' 2 + import {createContext} from 'preact' 3 + import {useCallback, useContext} from 'preact/hooks' 4 + 5 + import {RealmConnection} from '#client/realm/connection' 6 + import {RealmID} from '#common/protocol.js' 7 + import {RealmIdentityContext} from './context-identity' 8 + 9 + export interface RealmConnectionValues { 10 + realm: Signal<RealmConnection | undefined> 11 + realmid: Signal<RealmID | undefined> 12 + connected: Signal<boolean> 13 + } 14 + 15 + export const RealmConnectionContext = createContext<RealmConnectionValues | null>(null) 16 + 17 + export const RealmConnectionProvider: preact.FunctionComponent<{ 18 + url: string 19 + realmid?: RealmID 20 + children: preact.ComponentChildren 21 + }> = (props) => { 22 + const context = useContext(RealmIdentityContext) 23 + if (!context) throw new Error('expected to be called inside realm identity context!') 24 + 25 + const connected$ = useSignal<boolean>(false) 26 + const connection$ = useSignal<RealmConnection>() 27 + const realmid$ = useSignal(props.realmid) 28 + 29 + const onChange = useCallback(() => { 30 + console.log('on change called!', connection$.value?.connected) 31 + connected$.value = connection$.value?.connected || false 32 + }, [connected$, connection$]) 33 + 34 + useSignalEffect(() => { 35 + if (!realmid$.value) return 36 + 37 + const connection = new RealmConnection(props.url, realmid$.value, context.identity) 38 + connection.addEventListener('wsopen', onChange) 39 + connection.addEventListener('wsclose', onChange) 40 + connection.addEventListener('wserror', onChange) 41 + connection$.value = connection 42 + 43 + return () => { 44 + connection.removeEventListener('wsopen', onChange) 45 + connection.removeEventListener('wsclose', onChange) 46 + connection.removeEventListener('wserror', onChange) 47 + connection.destroy() 48 + } 49 + }) 50 + 51 + return ( 52 + <RealmConnectionContext.Provider 53 + children={props.children} 54 + value={{ 55 + realm: connection$, 56 + realmid: realmid$, 57 + connected: connected$, 58 + }} 59 + /> 60 + ) 61 + }
+49
src/client/realm/context-identity.tsx
··· 1 + import {generateSigningJwkPair} from '#common/crypto/jwks' 2 + import {normalizeProtocolError, ProtocolError} from '#common/errors' 3 + import {IdentBrand} from '#common/protocol' 4 + import {createContext} from 'preact' 5 + import {useEffect, useState} from 'preact/hooks' 6 + import {RealmIdentity} from './identity' 7 + 8 + export interface RealmIdentityValues { 9 + identity: RealmIdentity 10 + } 11 + 12 + export interface RealmIdentityFallbackProps { 13 + loading: boolean 14 + error?: ProtocolError 15 + } 16 + 17 + export const RealmIdentityContext = createContext<RealmIdentityValues | null>(null) 18 + 19 + export const RealmIdentityProvider: preact.FunctionComponent<{ 20 + fallback: (props: RealmIdentityFallbackProps) => preact.ComponentChild 21 + children: preact.ComponentChildren 22 + }> = (props) => { 23 + const [identity$, setIdentity$] = useState<RealmIdentity>() 24 + const [error$, setError$] = useState<ProtocolError>() 25 + 26 + useEffect(() => { 27 + if (identity$ || error$) return 28 + 29 + generateSigningJwkPair() 30 + .then((keypair) => { 31 + const identid = IdentBrand.generate() 32 + 33 + setError$(undefined) 34 + setIdentity$(new RealmIdentity(identid, keypair)) 35 + }) 36 + .catch((exc: unknown) => { 37 + setIdentity$(undefined) 38 + setError$(normalizeProtocolError(exc)) 39 + }) 40 + }, [identity$, error$]) 41 + 42 + return identity$ ? ( 43 + <RealmIdentityContext.Provider value={{identity: identity$}}> 44 + {props.children} 45 + </RealmIdentityContext.Provider> 46 + ) : ( 47 + props.fallback({loading: !identity$, error: error$}) 48 + ) 49 + }
-51
src/client/realm/context.tsx
··· 1 - import {createContext} from 'preact' 2 - import {useCallback, useEffect, useState} from 'preact/hooks' 3 - 4 - import {RealmConnection} from '#client/realm/connection' 5 - import {ConnectionIdentity} from './types' 6 - 7 - interface RealmConnectionContext { 8 - realm?: RealmConnection 9 - identity?: ConnectionIdentity 10 - setIdentity: (ident: ConnectionIdentity) => void 11 - } 12 - 13 - export const RealmConnectionContext = createContext<RealmConnectionContext | null>(null) 14 - 15 - export const RealmConnectionProvider: preact.FunctionComponent<{ 16 - url: string 17 - children: preact.ComponentChildren 18 - }> = (props) => { 19 - const [identity$, setIdentity$] = useState<ConnectionIdentity>() 20 - const [connection$, setConnection$] = useState<RealmConnection>() 21 - 22 - const connect = useCallback(() => { 23 - if (connection$) return 24 - if (!identity$) return 25 - 26 - setConnection$(new RealmConnection(props.url, identity$)) 27 - }, [connection$, identity$, props.url]) 28 - 29 - const disconnect = useCallback(() => { 30 - connection$?.destroy() 31 - }, [connection$]) 32 - 33 - // connect on mount, or identity change 34 - useEffect(() => { 35 - if (identity$) connect() 36 - 37 - // disconnect on unsubscribe 38 - return disconnect 39 - }, [connect, disconnect, identity$]) 40 - 41 - return ( 42 - <RealmConnectionContext.Provider 43 - children={props.children} 44 - value={{ 45 - realm: connection$, 46 - identity: identity$, 47 - setIdentity: setIdentity$, 48 - }} 49 - /> 50 - ) 51 - }
+34
src/client/realm/identity.ts
··· 1 + import {JWK, jwkExport} from '#common/crypto/jwks.js' 2 + import {IdentID} from '#common/protocol' 3 + 4 + export interface Signable { 5 + sign(key: CryptoKey): Promise<string> 6 + } 7 + 8 + /** manages websocket and webrtc connections for a realm */ 9 + export class RealmIdentity { 10 + #identid: IdentID 11 + #keypair: CryptoKeyPair 12 + #pubjwk: Promise<JWK> | undefined 13 + 14 + constructor(identid: IdentID, keypair: CryptoKeyPair) { 15 + this.#identid = identid 16 + this.#keypair = keypair 17 + } 18 + 19 + get identid() { 20 + return this.#identid 21 + } 22 + 23 + get pubkey() { 24 + return this.#keypair.publicKey 25 + } 26 + 27 + get pubjwk() { 28 + return (this.#pubjwk ??= jwkExport.parseAsync(this.#keypair.publicKey)) 29 + } 30 + 31 + sign(token: Signable) { 32 + return token.sign(this.#keypair.privateKey) 33 + } 34 + }
-2
src/client/realm/types.ts
··· 1 1 import {IdentID, RealmID} from '#common/protocol' 2 - import SimplePeer from 'simple-peer' 3 2 4 3 /** identity info for connecting to a realm */ 5 4 export interface ConnectionIdentity { ··· 13 12 identid: IdentID 14 13 connected: boolean 15 14 destroyed: boolean 16 - address: ReturnType<SimplePeer.Instance['address']> 17 15 }
+34 -58
src/client/webrtc-demo.tsx
··· 1 - import {useCallback, useContext, useEffect} from 'preact/hooks' 1 + import {useSignalEffect} from '@preact/signals' 2 + import {useContext} from 'preact/hooks' 2 3 3 - import {Messenger} from '#client/components/messenger' 4 - import {PeerList} from '#client/components/peer-list' 5 - import {RealmConnectionContext} from '#client/realm/context' 6 - 7 - import {generateSigningJwkPair} from '#common/crypto/jwks' 8 - import * as protocol from '#common/protocol' 4 + import {Messenger} from './components/messenger' 5 + import {PeerList} from './components/peer-list' 6 + import {RealmConnectionContext} from './realm/context-connection' 7 + import {RealmIdentityContext} from './realm/context-identity' 9 8 10 9 function attachEventListener<F extends EventListenerOrEventListenerObject>( 11 10 target: EventTarget, ··· 17 16 } 18 17 19 18 export const WebRTCDemo: preact.FunctionComponent = () => { 20 - const context = useContext(RealmConnectionContext) 21 - if (!context) throw new Error('expected to be called inside realm connection context!') 19 + const identityContext = useContext(RealmIdentityContext) 20 + if (!identityContext) throw new Error('expected to be called inside realm identity context!') 22 21 23 - useEffect(() => { 24 - if (!context.realm) return 22 + const connectionContext = useContext(RealmConnectionContext) 23 + if (!connectionContext) throw new Error('expected to be called inside realm connection context!') 25 24 26 - /* setup event listeners on the socket */ 25 + useSignalEffect(() => { 26 + if (!connectionContext.realm.value) return 27 27 28 - const wsopen = attachEventListener(context.realm, 'wsopen', () => { 28 + const realm = connectionContext.realm.value 29 + 30 + const wsopen = attachEventListener(realm, 'wsopen', () => { 29 31 console.log('connection socket open!') 30 32 }) 31 33 32 - const wsdata = attachEventListener(context.realm, 'wsdata', (e) => { 34 + const wsdata = attachEventListener(realm, 'wsdata', (e) => { 33 35 console.log('connection socket data!', e) 34 36 }) 35 37 36 - const wserror = attachEventListener(context.realm, 'wserror', (e) => { 38 + const wserror = attachEventListener(realm, 'wserror', (e) => { 37 39 console.log('connection socket error!', e) 38 40 }) 39 41 40 - const wsclose = attachEventListener(context.realm, 'wsclose', (e) => { 42 + const wsclose = attachEventListener(realm, 'wsclose', (e) => { 41 43 console.log('connection socket closed!', e) 42 44 }) 43 45 44 - const peeropen = attachEventListener(context.realm, 'peeropen', (p) => { 46 + const peeropen = attachEventListener(realm, 'peeropen', (p) => { 45 47 console.log('peer connected', p) 46 48 }) 47 49 48 - const peerdata = attachEventListener(context.realm, 'peerdata', (p) => { 50 + const peerdata = attachEventListener(realm, 'peerdata', (p) => { 49 51 console.log('peer data', p) 50 52 }) 51 53 52 - const peerclose = attachEventListener(context.realm, 'peerclose', (p) => { 54 + const peerclose = attachEventListener(realm, 'peerclose', (p) => { 53 55 console.log('peer disconnected', p) 54 56 }) 55 57 56 - const peererror = attachEventListener(context.realm, 'peererror', (p) => { 58 + const peererror = attachEventListener(realm, 'peererror', (p) => { 57 59 console.log('peer error', p) 58 60 }) 59 61 60 62 return () => { 61 - context.realm?.removeEventListener('wsopen', wsopen) 62 - context.realm?.removeEventListener('wsdata', wsdata) 63 - context.realm?.removeEventListener('wserror', wserror) 64 - context.realm?.removeEventListener('wsclose', wsclose) 63 + realm.removeEventListener('wsopen', wsopen) 64 + realm.removeEventListener('wsdata', wsdata) 65 + realm.removeEventListener('wserror', wserror) 66 + realm.removeEventListener('wsclose', wsclose) 65 67 66 - context.realm?.removeEventListener('peeropen', peeropen) 67 - context.realm?.removeEventListener('peerdata', peerdata) 68 - context.realm?.removeEventListener('peererror', peererror) 69 - context.realm?.removeEventListener('peerclose', peerclose) 68 + realm.removeEventListener('peeropen', peeropen) 69 + realm.removeEventListener('peerdata', peerdata) 70 + realm.removeEventListener('peererror', peererror) 71 + realm.removeEventListener('peerclose', peerclose) 70 72 } 71 73 }) 72 74 73 - const connect = useCallback(() => { 74 - const go = async () => { 75 - const realmid = protocol.RealmBrand.parse('realm-n7-qM0rOzsJ8N-iF') // hard code for now 76 - const identid = protocol.IdentBrand.generate() 77 - const keypair = await generateSigningJwkPair() 78 - 79 - context.setIdentity({realmid, identid, keypair}) 80 - } 81 - 82 - go().catch((e: unknown) => { 83 - console.error('couldnt create identity', e) 84 - }) 85 - }, [context]) 86 - 87 75 return ( 88 76 <div className="webrtc-demo"> 89 77 <h1>WebRTC Demo</h1> 90 78 91 - <button onClick={connect}>Connect</button> 92 - 93 79 <div> 94 80 Identity: 95 - <pre>{JSON.stringify(context.identity, null, 2)}</pre> 81 + <pre>{identityContext.identity?.identid}</pre> 96 82 </div> 97 83 98 - <div className="connection-info"> 99 - <p> 100 - Status: 101 - {context.realm?.connected ? '🟢 Connected' : '🔴 Disconnected'} 102 - </p> 103 - <pre> 104 - <code>{JSON.stringify(context.realm, null, 2)}</code> 105 - </pre> 106 - </div> 107 - 108 - {context.realm && ( 84 + {connectionContext.realm.value && ( 109 85 <div className="demo-layout"> 110 86 <div className="demo-section"> 111 - <PeerList webrtcManager={context.realm} /> 112 - <Messenger realm={context.realm} /> 87 + <PeerList webrtcManager={connectionContext.realm.value} /> 88 + <Messenger realm={connectionContext.realm.value} /> 113 89 </div> 114 90 </div> 115 91 )}
+2
src/common/crypto/jwks.ts
··· 2 2 import {z} from 'zod/v4' 3 3 import {CryptoError} from './errors' 4 4 5 + export type JWK = jose.JWK 6 + 5 7 const subtleSignAlgo = {name: 'ECDSA', namedCurve: 'P-256'} 6 8 const joseSignAlgo = {name: 'ES256'} 7 9
+2 -2
src/common/crypto/jwts.ts
··· 5 5 6 6 const signAlgo = {name: 'ES256'} 7 7 8 - interface JWTToken { 8 + export interface JWTToken { 9 9 token: string 10 10 claims: jose.JWTPayload 11 11 } 12 12 13 - interface JWTTokenPayload<T> { 13 + export interface JWTTokenPayload<T> { 14 14 token: string 15 15 claims: jose.JWTPayload 16 16 payload: T
+14 -1
src/common/protocol/messages.ts
··· 19 19 20 20 export const preauthAuthnReqSchema = makeEmptyRequestSchema('preauth.authn') 21 21 22 + export const preauthExchangeInviteReqSchema = makeRequestSchema( 23 + 'preauth.exchange', 24 + z.object({ 25 + pubkey: jwkSchema, 26 + inviteJwt: z.jwt(), 27 + }), 28 + ) 29 + 22 30 export const preauthRespSchema = makeResponseSchema( 23 31 'preauth.authn', 24 32 z.object({ ··· 26 34 }), 27 35 ) 28 36 29 - export const preauthReqSchema = z.union([preauthAuthnReqSchema, preauthRegisterReqSchema]) 37 + export const preauthReqSchema = z.union([ 38 + preauthAuthnReqSchema, 39 + preauthExchangeInviteReqSchema, 40 + preauthRegisterReqSchema, 41 + ]) 30 42 43 + export type PreauthExchangeInviteRequest = z.infer<typeof preauthExchangeInviteReqSchema> 31 44 export type PreauthRegisterRequest = z.infer<typeof preauthRegisterReqSchema> 32 45 export type PreauthAuthnRequest = z.infer<typeof preauthAuthnReqSchema> 33 46 export type PreauthResponse = z.infer<typeof preauthRespSchema>
+38 -5
src/server/routes-socket/handler-preauth.ts
··· 2 2 3 3 import {combineSignals, timeoutSignal} from '#common/async/aborts' 4 4 import {jwkImport} from '#common/crypto/jwks' 5 - import {jwtPayload, verifyJwtToken} from '#common/crypto/jwts' 5 + import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts' 6 6 import {normalizeError, ProtocolError} from '#common/errors' 7 7 import { 8 8 IdentBrand, ··· 36 36 const identid = IdentBrand.parse(jwt.claims.iss) 37 37 const realmid = RealmBrand.parse(jwt.claims.aud) 38 38 39 - // if we're registering, make sure the realm exists 40 - if (jwt.payload.msg === 'preauth.register') { 41 - const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey) 42 - realms.ensureRegisteredRealm(realmid, identid, registrantkey) 39 + switch (jwt.payload.msg) { 40 + case 'preauth.register': { 41 + // if we're registering, make sure the realm exists 42 + const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey) 43 + realms.ensureRegisteredRealm(realmid, identid, registrantkey) 44 + 45 + break 46 + } 47 + 48 + case 'preauth.exchange': { 49 + // validate and then insert (validation throws) 50 + const token = jwtSchema.parse(jwt.payload.dat.inviteJwt) 51 + await preauthValidateInvitation(realmid, token) 52 + 53 + const inviteeid = IdentBrand.parse(jwt.claims.iss) 54 + const inviteekey = await jwkImport.parseAsync(jwt.payload.dat.pubkey) 55 + realms.admitToRealm(realmid, inviteeid, inviteekey) 56 + 57 + break 58 + } 43 59 } 44 60 61 + // everything is in place, fall through to authentication 45 62 const auth = await authenticatePreauth(realmid, identid, jwt.token) 46 63 const msg = preauthResponse(auth, jwt.payload.seq) 47 64 ws.send(JSON.stringify(msg)) ··· 49 66 return auth 50 67 } finally { 51 68 timeout.cancel() 69 + } 70 + } 71 + 72 + async function preauthValidateInvitation(realmid: RealmID, invitation: JWTToken): Promise<void> { 73 + try { 74 + const realm = realms.realmMap.require(realmid) 75 + 76 + if (!invitation.claims.jti) throw 'invitation requires nonce!' 77 + if (!realms.validateNonce(realmid, invitation.claims.jti)) throw 'invitation already used!' 78 + 79 + const inviterid = IdentBrand.parse(invitation.claims.iss) 80 + const inviterkey = realm.identities.require(inviterid) 81 + await verifyJwtToken(invitation.token, inviterkey, {subject: 'invitation'}) 82 + } catch (exc) { 83 + const err = normalizeError(exc) 84 + throw new ProtocolError('invitation verification failed', 401, {cause: err}) 52 85 } 53 86 } 54 87
+17
src/server/routes-socket/state.ts
··· 13 13 14 14 export interface Realm { 15 15 realmid: RealmID 16 + nonces: Set<string> 16 17 sockets: StrictMap<IdentID, WebSocket[]> 17 18 identities: StrictMap<IdentID, CryptoKey> 18 19 } ··· 36 37 ): Realm { 37 38 const realm = realmMap.ensure(realmid, () => ({ 38 39 realmid, 40 + nonces: new Set(), 39 41 sockets: new StrictMap(), 40 42 identities: new StrictMap([[registrantid, registrantkey]]), 41 43 })) ··· 43 45 // hack for now, allow any registration to work 44 46 realm.identities.ensure(registrantid, () => registrantkey) 45 47 return realm 48 + } 49 + 50 + export function validateNonce(realmid: RealmID, nonce: string): boolean { 51 + const realm = realmMap.require(realmid) 52 + if (realm.nonces.has(nonce)) { 53 + return false 54 + } 55 + 56 + realm.nonces.add(nonce) 57 + return true 58 + } 59 + 60 + export function admitToRealm(realmid: RealmID, inviteeid: IdentID, inviteekey: CryptoKey) { 61 + const realm = realmMap.require(realmid) 62 + realm.identities.set(inviteeid, inviteekey) 46 63 } 47 64 48 65 export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {