tangled
alpha
login
or
join now
accidental.cc
/
skypod
3
fork
atom
podcast manager
3
fork
atom
overview
issues
pulls
pipelines
wip on default realm stuff
Jonathan Raphaelson
8 months ago
55e3e528
f94158fb
+448
-213
18 changed files
expand all
collapse all
unified
split
eslint.config.js
package-lock.json
package.json
src
client
components
peer-list.tsx
page-app.tsx
realm
connection.ts
context-connection.tsx
context-identity.tsx
context.tsx
identity.ts
types.ts
realm-connection-manager.tsx
webrtc-demo.tsx
common
crypto
jwks.ts
jwts.ts
protocol
messages.ts
server
routes-socket
handler-preauth.ts
state.ts
+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
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
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
2403
+
}
2404
2404
+
},
2405
2405
+
"node_modules/@preact/signals": {
2406
2406
+
"version": "2.2.0",
2407
2407
+
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.2.0.tgz",
2408
2408
+
"integrity": "sha512-P3KPcEYyVk9Wiwfw68QQzRpPkt0H+zjfH3X4AaGCDlc86GuRBYFGiAxT1nC5F5qlsVIEmjNJ9yVYe7C91z3L+g==",
2409
2409
+
"license": "MIT",
2410
2410
+
"dependencies": {
2411
2411
+
"@preact/signals-core": "^1.9.0"
2412
2412
+
},
2413
2413
+
"funding": {
2414
2414
+
"type": "opencollective",
2415
2415
+
"url": "https://opencollective.com/preact"
2416
2416
+
},
2417
2417
+
"peerDependencies": {
2418
2418
+
"preact": ">= 10.25.0"
2419
2419
+
}
2420
2420
+
},
2421
2421
+
"node_modules/@preact/signals-core": {
2422
2422
+
"version": "1.10.0",
2423
2423
+
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.10.0.tgz",
2424
2424
+
"integrity": "sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA==",
2425
2425
+
"license": "MIT",
2426
2426
+
"funding": {
2427
2427
+
"type": "opencollective",
2428
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
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
16
-
const states = webrtcManager.getPeerStates()
17
17
-
console.debug('updating peers', states)
18
18
-
setPeers(states)
16
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
68
-
<code className="initiator">
69
69
-
{state.address.family}-{state.address.address}:{state.address.port}
70
70
-
</code>
71
66
</div>
72
67
)
73
68
}
+12
-4
src/client/page-app.tsx
···
1
1
-
import {RealmConnectionProvider} from './realm/context'
1
1
+
import {RealmConnectionManager} from './realm-connection-manager'
2
2
+
import {RealmConnectionProvider} from './realm/context-connection'
3
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
7
+
const identityFallback = (p: RealmIdentityFallbackProps) =>
8
8
+
p.loading ? <h1>Loading</h1> : <h1>Error! {p.error?.message}</h1>
9
9
+
5
10
return (
6
6
-
<RealmConnectionProvider url="ws://localhost:3001/stream">
7
7
-
<WebRTCDemo />
8
8
-
</RealmConnectionProvider>
11
11
+
<RealmIdentityProvider fallback={identityFallback}>
12
12
+
<RealmConnectionProvider url="ws://localhost:3001/stream">
13
13
+
<RealmConnectionManager />
14
14
+
<WebRTCDemo />
15
15
+
</RealmConnectionProvider>
16
16
+
</RealmIdentityProvider>
9
17
)
10
18
}
+54
src/client/realm-connection-manager.tsx
···
1
1
+
import {RealmBrand} from '#common/protocol'
2
2
+
import {useSignal} from '@preact/signals'
3
3
+
import {useCallback, useContext} from 'preact/hooks'
4
4
+
import {RealmConnectionContext} from './realm/context-connection'
5
5
+
import {RealmIdentityContext} from './realm/context-identity'
6
6
+
7
7
+
export const RealmConnectionManager: preact.FunctionComponent = () => {
8
8
+
const identityContext = useContext(RealmIdentityContext)
9
9
+
if (!identityContext) throw new Error('expected to be called inside realm identity context!')
10
10
+
11
11
+
const connectionContext = useContext(RealmConnectionContext)
12
12
+
if (!connectionContext) throw new Error('expected to be called inside realm connection context!')
13
13
+
14
14
+
const invitation = useSignal<string>()
15
15
+
16
16
+
const register = useCallback(() => {
17
17
+
console.log('register')
18
18
+
connectionContext.realmid.value = RealmBrand.generate()
19
19
+
}, [connectionContext.realmid])
20
20
+
21
21
+
const exchange = useCallback(() => {
22
22
+
console.log('exchange', invitation.value)
23
23
+
}, [invitation])
24
24
+
25
25
+
return connectionContext?.realmid.value ? (
26
26
+
<h1>
27
27
+
Connection:
28
28
+
{connectionContext.realmid.value}
29
29
+
{connectionContext.connected.value ? '🟢 Connected' : '🔴 Disconnected'}
30
30
+
</h1>
31
31
+
) : (
32
32
+
<div>
33
33
+
<h1>
34
34
+
Connection: <code>NONE</code>
35
35
+
</h1>
36
36
+
<p>
37
37
+
Generate a new realm:{' '}
38
38
+
<button type="button" onClick={register}>
39
39
+
Register
40
40
+
</button>
41
41
+
</p>
42
42
+
<p>
43
43
+
Exchange an invite:
44
44
+
<textarea
45
45
+
value={invitation.value}
46
46
+
onInput={(e) => (invitation.value = e.currentTarget.value)}
47
47
+
/>
48
48
+
<button type="button" onClick={exchange}>
49
49
+
Exchange
50
50
+
</button>
51
51
+
</p>
52
52
+
</div>
53
53
+
)
54
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
6
-
import {generateSignableJwt, jwkExport} from '#common/crypto/jwks'
6
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
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
22
+
import {RealmIdentity} from './identity'
21
23
import {RealmPeer} from './peer'
22
22
-
import {ConnectionIdentity, PeerState} from './types'
24
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
34
-
#identity: ConnectionIdentity
36
36
+
#realmid: RealmID
37
37
+
#identity: RealmIdentity
35
38
36
39
#socket: WebSocket
37
40
#peers: Map<IdentID, RealmPeer>
38
41
#nonces: Map<IdentID, string>
39
42
40
40
-
constructor(url: string, identity: ConnectionIdentity) {
43
43
+
constructor(url: string, realmid: RealmID, identity: RealmIdentity) {
41
44
super()
42
45
43
46
this.#url = url
47
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
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
64
+
get peers(): Record<IdentID, PeerState> {
65
65
+
const states: Record<IdentID, PeerState> = {}
66
66
+
for (const [identid, peer] of this.#peers) {
67
67
+
states[identid] = {
68
68
+
identid,
69
69
+
connected: peer.connected,
70
70
+
destroyed: peer.destroyed,
71
71
+
}
72
72
+
}
73
73
+
74
74
+
return states
75
75
+
}
76
76
+
59
77
send<T extends unknown>(identid: IdentID, data: T) {
60
60
-
const peer = this.#peers.get(identid)
61
61
-
if (!peer?.connected) throw new Error(`Not connected to peer: ${identid}`)
62
62
-
63
63
-
peer.send(JSON.stringify(data))
78
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
89
+
this.broadcastRaw(JSON.stringify(data), self)
90
90
+
}
91
91
+
92
92
+
broadcastRaw(data: SimplePeerData, self = false) {
74
93
this.#peers.forEach((peer, identid) => {
75
75
-
if (self || identid !== this.#identity.identid) peer.send(JSON.stringify(data))
94
94
+
if (self || identid !== this.#identity.identid) peer.send(data)
76
95
})
77
96
}
78
97
79
79
-
getPeerStates(): Record<IdentID, PeerState> {
80
80
-
const states: Record<IdentID, PeerState> = {}
81
81
-
for (const [identid, peer] of this.#peers) {
82
82
-
states[identid] = {
83
83
-
identid,
84
84
-
address: peer.address(),
85
85
-
connected: peer.connected,
86
86
-
destroyed: peer.destroyed,
87
87
-
}
98
98
+
destroy() {
99
99
+
console.debug('realm connection destroy!')
100
100
+
101
101
+
// disconnect from socket
102
102
+
if (this.connected) {
103
103
+
this.#socket.close()
104
104
+
}
105
105
+
106
106
+
// disconnect from peers
107
107
+
for (const peer of this.#peers.values()) {
108
108
+
peer.destroy()
88
109
}
89
110
90
90
-
return states
111
111
+
this.#peers.clear()
112
112
+
this.#nonces.clear()
91
113
}
92
114
93
93
-
destroy() {
94
94
-
console.debug('realm connection destroy!')
115
115
+
#dispatchCustomEvent(type: string, detail?: object) {
116
116
+
this.dispatchEvent(new CustomEvent(type, {detail}))
117
117
+
}
95
118
96
96
-
if (this.connected) this.#socket.close()
97
97
-
for (const peer of this.#peers.values()) peer.destroy()
119
119
+
// typed helpers
120
120
+
// @example
121
121
+
// this.#socketWrite<YourType>({
122
122
+
// ... // <- get type errors here
123
123
+
// })
124
124
+
125
125
+
async #socketSignedWrite<T extends object>(payload: T) {
126
126
+
const token = await this.#identity.sign(
127
127
+
generateSignableJwt({
128
128
+
aud: this.#realmid,
129
129
+
iss: this.#identity.identid,
130
130
+
payload,
131
131
+
}),
132
132
+
)
133
133
+
134
134
+
this.#socket.send(token)
135
135
+
}
136
136
+
137
137
+
/// event handlers
138
138
+
139
139
+
// handle socket open is the main loop for the sendSocket
140
140
+
#handleSocketOpen: WebSocket['onopen'] = async () => {
141
141
+
if (this.#socket == undefined) throw new Error('socket open handler called with no socket?')
142
142
+
143
143
+
console.debug('realm connection, socket loop open')
144
144
+
this.#dispatchCustomEvent('wsopen')
145
145
+
146
146
+
try {
147
147
+
await this.#handleOpenAuthenticate()
148
148
+
for await (const data of streamSocketJson(this.#socket)) {
149
149
+
this.#handleOpenMessage(data)
150
150
+
}
151
151
+
} catch (exc) {
152
152
+
const err = normalizeProtocolError(exc)
98
153
99
99
-
this.#peers.clear()
100
100
-
this.#nonces.clear()
154
154
+
console.error('realm connection, socket loop error', err)
155
155
+
this.#dispatchCustomEvent('wserror', {error: err})
156
156
+
} finally {
157
157
+
this.destroy()
158
158
+
}
101
159
}
102
160
103
161
// do the auth dance
104
162
// TODO: this should be a state machine
105
105
-
#connectionAuthenticate = async () => {
163
163
+
#handleOpenAuthenticate = async () => {
106
164
try {
107
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
111
-
dat: {pubkey},
168
168
+
dat: {
169
169
+
pubkey: await this.#identity.pubjwk,
170
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
130
-
#connectionMessage = (data: unknown) => {
189
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
227
+
#handleSocketError: WebSocket['onerror'] = (exc) => {
228
228
+
this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)})
229
229
+
this.destroy()
230
230
+
}
231
231
+
232
232
+
#handleSocketClose: WebSocket['onclose'] = () => {
233
233
+
this.#dispatchCustomEvent('wsclose')
234
234
+
this.destroy()
235
235
+
}
236
236
+
237
237
+
// peers
238
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
193
-
// handle socket open is the main loop for the sendSocket
194
194
-
#handleSocketOpen: WebSocket['onopen'] = async () => {
195
195
-
if (this.#socket == undefined) throw new Error('socket open handler called with no socket?')
196
196
-
197
197
-
console.debug('realm connection, socket loop open')
198
198
-
this.#dispatchCustomEvent('wsopen')
199
199
-
200
200
-
try {
201
201
-
await this.#connectionAuthenticate()
202
202
-
for await (const data of streamSocketJson(this.#socket)) {
203
203
-
this.#connectionMessage(data)
204
204
-
}
205
205
-
} catch (exc) {
206
206
-
const err = normalizeProtocolError(exc)
207
207
-
208
208
-
console.error('realm connection, socket loop error', err)
209
209
-
this.#dispatchCustomEvent('wserror', {error: err})
210
210
-
} finally {
211
211
-
this.destroy()
212
212
-
}
213
213
-
}
214
214
-
215
215
-
#handleSocketError: WebSocket['onerror'] = (exc) => {
216
216
-
this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)})
217
217
-
this.destroy()
218
218
-
}
219
219
-
220
220
-
#handleSocketClose: WebSocket['onclose'] = () => {
221
221
-
this.#dispatchCustomEvent('wsclose')
222
222
-
this.destroy()
223
223
-
}
224
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
260
-
const parsed = realmRtcPingRequestSchema.safeParse(chunk)
299
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
271
-
}
272
272
-
273
273
-
// helpers that are just too damn long
274
274
-
275
275
-
#dispatchCustomEvent(type: string, detail?: object) {
276
276
-
this.dispatchEvent(new CustomEvent(type, {detail}))
277
277
-
}
278
278
-
279
279
-
// typed helpers
280
280
-
// @example
281
281
-
// this.#socketWrite<YourType>({
282
282
-
// ... // <- get type errors here
283
283
-
// })
284
284
-
285
285
-
#socketSignedWrite = async <T extends object>(payload: T) => {
286
286
-
const token = await generateSignableJwt({
287
287
-
aud: this.#identity.realmid,
288
288
-
iss: this.#identity.identid,
289
289
-
payload,
290
290
-
}).sign(this.#identity.keypair.privateKey)
291
291
-
292
292
-
this.#socket.send(token)
293
310
}
294
311
}
+61
src/client/realm/context-connection.tsx
···
1
1
+
import {Signal, useSignal, useSignalEffect} from '@preact/signals'
2
2
+
import {createContext} from 'preact'
3
3
+
import {useCallback, useContext} from 'preact/hooks'
4
4
+
5
5
+
import {RealmConnection} from '#client/realm/connection'
6
6
+
import {RealmID} from '#common/protocol.js'
7
7
+
import {RealmIdentityContext} from './context-identity'
8
8
+
9
9
+
export interface RealmConnectionValues {
10
10
+
realm: Signal<RealmConnection | undefined>
11
11
+
realmid: Signal<RealmID | undefined>
12
12
+
connected: Signal<boolean>
13
13
+
}
14
14
+
15
15
+
export const RealmConnectionContext = createContext<RealmConnectionValues | null>(null)
16
16
+
17
17
+
export const RealmConnectionProvider: preact.FunctionComponent<{
18
18
+
url: string
19
19
+
realmid?: RealmID
20
20
+
children: preact.ComponentChildren
21
21
+
}> = (props) => {
22
22
+
const context = useContext(RealmIdentityContext)
23
23
+
if (!context) throw new Error('expected to be called inside realm identity context!')
24
24
+
25
25
+
const connected$ = useSignal<boolean>(false)
26
26
+
const connection$ = useSignal<RealmConnection>()
27
27
+
const realmid$ = useSignal(props.realmid)
28
28
+
29
29
+
const onChange = useCallback(() => {
30
30
+
console.log('on change called!', connection$.value?.connected)
31
31
+
connected$.value = connection$.value?.connected || false
32
32
+
}, [connected$, connection$])
33
33
+
34
34
+
useSignalEffect(() => {
35
35
+
if (!realmid$.value) return
36
36
+
37
37
+
const connection = new RealmConnection(props.url, realmid$.value, context.identity)
38
38
+
connection.addEventListener('wsopen', onChange)
39
39
+
connection.addEventListener('wsclose', onChange)
40
40
+
connection.addEventListener('wserror', onChange)
41
41
+
connection$.value = connection
42
42
+
43
43
+
return () => {
44
44
+
connection.removeEventListener('wsopen', onChange)
45
45
+
connection.removeEventListener('wsclose', onChange)
46
46
+
connection.removeEventListener('wserror', onChange)
47
47
+
connection.destroy()
48
48
+
}
49
49
+
})
50
50
+
51
51
+
return (
52
52
+
<RealmConnectionContext.Provider
53
53
+
children={props.children}
54
54
+
value={{
55
55
+
realm: connection$,
56
56
+
realmid: realmid$,
57
57
+
connected: connected$,
58
58
+
}}
59
59
+
/>
60
60
+
)
61
61
+
}
+49
src/client/realm/context-identity.tsx
···
1
1
+
import {generateSigningJwkPair} from '#common/crypto/jwks'
2
2
+
import {normalizeProtocolError, ProtocolError} from '#common/errors'
3
3
+
import {IdentBrand} from '#common/protocol'
4
4
+
import {createContext} from 'preact'
5
5
+
import {useEffect, useState} from 'preact/hooks'
6
6
+
import {RealmIdentity} from './identity'
7
7
+
8
8
+
export interface RealmIdentityValues {
9
9
+
identity: RealmIdentity
10
10
+
}
11
11
+
12
12
+
export interface RealmIdentityFallbackProps {
13
13
+
loading: boolean
14
14
+
error?: ProtocolError
15
15
+
}
16
16
+
17
17
+
export const RealmIdentityContext = createContext<RealmIdentityValues | null>(null)
18
18
+
19
19
+
export const RealmIdentityProvider: preact.FunctionComponent<{
20
20
+
fallback: (props: RealmIdentityFallbackProps) => preact.ComponentChild
21
21
+
children: preact.ComponentChildren
22
22
+
}> = (props) => {
23
23
+
const [identity$, setIdentity$] = useState<RealmIdentity>()
24
24
+
const [error$, setError$] = useState<ProtocolError>()
25
25
+
26
26
+
useEffect(() => {
27
27
+
if (identity$ || error$) return
28
28
+
29
29
+
generateSigningJwkPair()
30
30
+
.then((keypair) => {
31
31
+
const identid = IdentBrand.generate()
32
32
+
33
33
+
setError$(undefined)
34
34
+
setIdentity$(new RealmIdentity(identid, keypair))
35
35
+
})
36
36
+
.catch((exc: unknown) => {
37
37
+
setIdentity$(undefined)
38
38
+
setError$(normalizeProtocolError(exc))
39
39
+
})
40
40
+
}, [identity$, error$])
41
41
+
42
42
+
return identity$ ? (
43
43
+
<RealmIdentityContext.Provider value={{identity: identity$}}>
44
44
+
{props.children}
45
45
+
</RealmIdentityContext.Provider>
46
46
+
) : (
47
47
+
props.fallback({loading: !identity$, error: error$})
48
48
+
)
49
49
+
}
-51
src/client/realm/context.tsx
···
1
1
-
import {createContext} from 'preact'
2
2
-
import {useCallback, useEffect, useState} from 'preact/hooks'
3
3
-
4
4
-
import {RealmConnection} from '#client/realm/connection'
5
5
-
import {ConnectionIdentity} from './types'
6
6
-
7
7
-
interface RealmConnectionContext {
8
8
-
realm?: RealmConnection
9
9
-
identity?: ConnectionIdentity
10
10
-
setIdentity: (ident: ConnectionIdentity) => void
11
11
-
}
12
12
-
13
13
-
export const RealmConnectionContext = createContext<RealmConnectionContext | null>(null)
14
14
-
15
15
-
export const RealmConnectionProvider: preact.FunctionComponent<{
16
16
-
url: string
17
17
-
children: preact.ComponentChildren
18
18
-
}> = (props) => {
19
19
-
const [identity$, setIdentity$] = useState<ConnectionIdentity>()
20
20
-
const [connection$, setConnection$] = useState<RealmConnection>()
21
21
-
22
22
-
const connect = useCallback(() => {
23
23
-
if (connection$) return
24
24
-
if (!identity$) return
25
25
-
26
26
-
setConnection$(new RealmConnection(props.url, identity$))
27
27
-
}, [connection$, identity$, props.url])
28
28
-
29
29
-
const disconnect = useCallback(() => {
30
30
-
connection$?.destroy()
31
31
-
}, [connection$])
32
32
-
33
33
-
// connect on mount, or identity change
34
34
-
useEffect(() => {
35
35
-
if (identity$) connect()
36
36
-
37
37
-
// disconnect on unsubscribe
38
38
-
return disconnect
39
39
-
}, [connect, disconnect, identity$])
40
40
-
41
41
-
return (
42
42
-
<RealmConnectionContext.Provider
43
43
-
children={props.children}
44
44
-
value={{
45
45
-
realm: connection$,
46
46
-
identity: identity$,
47
47
-
setIdentity: setIdentity$,
48
48
-
}}
49
49
-
/>
50
50
-
)
51
51
-
}
+34
src/client/realm/identity.ts
···
1
1
+
import {JWK, jwkExport} from '#common/crypto/jwks.js'
2
2
+
import {IdentID} from '#common/protocol'
3
3
+
4
4
+
export interface Signable {
5
5
+
sign(key: CryptoKey): Promise<string>
6
6
+
}
7
7
+
8
8
+
/** manages websocket and webrtc connections for a realm */
9
9
+
export class RealmIdentity {
10
10
+
#identid: IdentID
11
11
+
#keypair: CryptoKeyPair
12
12
+
#pubjwk: Promise<JWK> | undefined
13
13
+
14
14
+
constructor(identid: IdentID, keypair: CryptoKeyPair) {
15
15
+
this.#identid = identid
16
16
+
this.#keypair = keypair
17
17
+
}
18
18
+
19
19
+
get identid() {
20
20
+
return this.#identid
21
21
+
}
22
22
+
23
23
+
get pubkey() {
24
24
+
return this.#keypair.publicKey
25
25
+
}
26
26
+
27
27
+
get pubjwk() {
28
28
+
return (this.#pubjwk ??= jwkExport.parseAsync(this.#keypair.publicKey))
29
29
+
}
30
30
+
31
31
+
sign(token: Signable) {
32
32
+
return token.sign(this.#keypair.privateKey)
33
33
+
}
34
34
+
}
-2
src/client/realm/types.ts
···
1
1
import {IdentID, RealmID} from '#common/protocol'
2
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
16
-
address: ReturnType<SimplePeer.Instance['address']>
17
15
}
+34
-58
src/client/webrtc-demo.tsx
···
1
1
-
import {useCallback, useContext, useEffect} from 'preact/hooks'
1
1
+
import {useSignalEffect} from '@preact/signals'
2
2
+
import {useContext} from 'preact/hooks'
2
3
3
3
-
import {Messenger} from '#client/components/messenger'
4
4
-
import {PeerList} from '#client/components/peer-list'
5
5
-
import {RealmConnectionContext} from '#client/realm/context'
6
6
-
7
7
-
import {generateSigningJwkPair} from '#common/crypto/jwks'
8
8
-
import * as protocol from '#common/protocol'
4
4
+
import {Messenger} from './components/messenger'
5
5
+
import {PeerList} from './components/peer-list'
6
6
+
import {RealmConnectionContext} from './realm/context-connection'
7
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
20
-
const context = useContext(RealmConnectionContext)
21
21
-
if (!context) throw new Error('expected to be called inside realm connection context!')
19
19
+
const identityContext = useContext(RealmIdentityContext)
20
20
+
if (!identityContext) throw new Error('expected to be called inside realm identity context!')
22
21
23
23
-
useEffect(() => {
24
24
-
if (!context.realm) return
22
22
+
const connectionContext = useContext(RealmConnectionContext)
23
23
+
if (!connectionContext) throw new Error('expected to be called inside realm connection context!')
25
24
26
26
-
/* setup event listeners on the socket */
25
25
+
useSignalEffect(() => {
26
26
+
if (!connectionContext.realm.value) return
27
27
28
28
-
const wsopen = attachEventListener(context.realm, 'wsopen', () => {
28
28
+
const realm = connectionContext.realm.value
29
29
+
30
30
+
const wsopen = attachEventListener(realm, 'wsopen', () => {
29
31
console.log('connection socket open!')
30
32
})
31
33
32
32
-
const wsdata = attachEventListener(context.realm, 'wsdata', (e) => {
34
34
+
const wsdata = attachEventListener(realm, 'wsdata', (e) => {
33
35
console.log('connection socket data!', e)
34
36
})
35
37
36
36
-
const wserror = attachEventListener(context.realm, 'wserror', (e) => {
38
38
+
const wserror = attachEventListener(realm, 'wserror', (e) => {
37
39
console.log('connection socket error!', e)
38
40
})
39
41
40
40
-
const wsclose = attachEventListener(context.realm, 'wsclose', (e) => {
42
42
+
const wsclose = attachEventListener(realm, 'wsclose', (e) => {
41
43
console.log('connection socket closed!', e)
42
44
})
43
45
44
44
-
const peeropen = attachEventListener(context.realm, 'peeropen', (p) => {
46
46
+
const peeropen = attachEventListener(realm, 'peeropen', (p) => {
45
47
console.log('peer connected', p)
46
48
})
47
49
48
48
-
const peerdata = attachEventListener(context.realm, 'peerdata', (p) => {
50
50
+
const peerdata = attachEventListener(realm, 'peerdata', (p) => {
49
51
console.log('peer data', p)
50
52
})
51
53
52
52
-
const peerclose = attachEventListener(context.realm, 'peerclose', (p) => {
54
54
+
const peerclose = attachEventListener(realm, 'peerclose', (p) => {
53
55
console.log('peer disconnected', p)
54
56
})
55
57
56
56
-
const peererror = attachEventListener(context.realm, 'peererror', (p) => {
58
58
+
const peererror = attachEventListener(realm, 'peererror', (p) => {
57
59
console.log('peer error', p)
58
60
})
59
61
60
62
return () => {
61
61
-
context.realm?.removeEventListener('wsopen', wsopen)
62
62
-
context.realm?.removeEventListener('wsdata', wsdata)
63
63
-
context.realm?.removeEventListener('wserror', wserror)
64
64
-
context.realm?.removeEventListener('wsclose', wsclose)
63
63
+
realm.removeEventListener('wsopen', wsopen)
64
64
+
realm.removeEventListener('wsdata', wsdata)
65
65
+
realm.removeEventListener('wserror', wserror)
66
66
+
realm.removeEventListener('wsclose', wsclose)
65
67
66
66
-
context.realm?.removeEventListener('peeropen', peeropen)
67
67
-
context.realm?.removeEventListener('peerdata', peerdata)
68
68
-
context.realm?.removeEventListener('peererror', peererror)
69
69
-
context.realm?.removeEventListener('peerclose', peerclose)
68
68
+
realm.removeEventListener('peeropen', peeropen)
69
69
+
realm.removeEventListener('peerdata', peerdata)
70
70
+
realm.removeEventListener('peererror', peererror)
71
71
+
realm.removeEventListener('peerclose', peerclose)
70
72
}
71
73
})
72
74
73
73
-
const connect = useCallback(() => {
74
74
-
const go = async () => {
75
75
-
const realmid = protocol.RealmBrand.parse('realm-n7-qM0rOzsJ8N-iF') // hard code for now
76
76
-
const identid = protocol.IdentBrand.generate()
77
77
-
const keypair = await generateSigningJwkPair()
78
78
-
79
79
-
context.setIdentity({realmid, identid, keypair})
80
80
-
}
81
81
-
82
82
-
go().catch((e: unknown) => {
83
83
-
console.error('couldnt create identity', e)
84
84
-
})
85
85
-
}, [context])
86
86
-
87
75
return (
88
76
<div className="webrtc-demo">
89
77
<h1>WebRTC Demo</h1>
90
78
91
91
-
<button onClick={connect}>Connect</button>
92
92
-
93
79
<div>
94
80
Identity:
95
95
-
<pre>{JSON.stringify(context.identity, null, 2)}</pre>
81
81
+
<pre>{identityContext.identity?.identid}</pre>
96
82
</div>
97
83
98
98
-
<div className="connection-info">
99
99
-
<p>
100
100
-
Status:
101
101
-
{context.realm?.connected ? '🟢 Connected' : '🔴 Disconnected'}
102
102
-
</p>
103
103
-
<pre>
104
104
-
<code>{JSON.stringify(context.realm, null, 2)}</code>
105
105
-
</pre>
106
106
-
</div>
107
107
-
108
108
-
{context.realm && (
84
84
+
{connectionContext.realm.value && (
109
85
<div className="demo-layout">
110
86
<div className="demo-section">
111
111
-
<PeerList webrtcManager={context.realm} />
112
112
-
<Messenger realm={context.realm} />
87
87
+
<PeerList webrtcManager={connectionContext.realm.value} />
88
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
5
+
export type JWK = jose.JWK
6
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
8
-
interface JWTToken {
8
8
+
export interface JWTToken {
9
9
token: string
10
10
claims: jose.JWTPayload
11
11
}
12
12
13
13
-
interface JWTTokenPayload<T> {
13
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
22
+
export const preauthExchangeInviteReqSchema = makeRequestSchema(
23
23
+
'preauth.exchange',
24
24
+
z.object({
25
25
+
pubkey: jwkSchema,
26
26
+
inviteJwt: z.jwt(),
27
27
+
}),
28
28
+
)
29
29
+
22
30
export const preauthRespSchema = makeResponseSchema(
23
31
'preauth.authn',
24
32
z.object({
···
26
34
}),
27
35
)
28
36
29
29
-
export const preauthReqSchema = z.union([preauthAuthnReqSchema, preauthRegisterReqSchema])
37
37
+
export const preauthReqSchema = z.union([
38
38
+
preauthAuthnReqSchema,
39
39
+
preauthExchangeInviteReqSchema,
40
40
+
preauthRegisterReqSchema,
41
41
+
])
30
42
43
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
5
-
import {jwtPayload, verifyJwtToken} from '#common/crypto/jwts'
5
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
39
-
// if we're registering, make sure the realm exists
40
40
-
if (jwt.payload.msg === 'preauth.register') {
41
41
-
const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
42
42
-
realms.ensureRegisteredRealm(realmid, identid, registrantkey)
39
39
+
switch (jwt.payload.msg) {
40
40
+
case 'preauth.register': {
41
41
+
// if we're registering, make sure the realm exists
42
42
+
const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
43
43
+
realms.ensureRegisteredRealm(realmid, identid, registrantkey)
44
44
+
45
45
+
break
46
46
+
}
47
47
+
48
48
+
case 'preauth.exchange': {
49
49
+
// validate and then insert (validation throws)
50
50
+
const token = jwtSchema.parse(jwt.payload.dat.inviteJwt)
51
51
+
await preauthValidateInvitation(realmid, token)
52
52
+
53
53
+
const inviteeid = IdentBrand.parse(jwt.claims.iss)
54
54
+
const inviteekey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
55
55
+
realms.admitToRealm(realmid, inviteeid, inviteekey)
56
56
+
57
57
+
break
58
58
+
}
43
59
}
44
60
61
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
69
+
}
70
70
+
}
71
71
+
72
72
+
async function preauthValidateInvitation(realmid: RealmID, invitation: JWTToken): Promise<void> {
73
73
+
try {
74
74
+
const realm = realms.realmMap.require(realmid)
75
75
+
76
76
+
if (!invitation.claims.jti) throw 'invitation requires nonce!'
77
77
+
if (!realms.validateNonce(realmid, invitation.claims.jti)) throw 'invitation already used!'
78
78
+
79
79
+
const inviterid = IdentBrand.parse(invitation.claims.iss)
80
80
+
const inviterkey = realm.identities.require(inviterid)
81
81
+
await verifyJwtToken(invitation.token, inviterkey, {subject: 'invitation'})
82
82
+
} catch (exc) {
83
83
+
const err = normalizeError(exc)
84
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
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
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
48
+
}
49
49
+
50
50
+
export function validateNonce(realmid: RealmID, nonce: string): boolean {
51
51
+
const realm = realmMap.require(realmid)
52
52
+
if (realm.nonces.has(nonce)) {
53
53
+
return false
54
54
+
}
55
55
+
56
56
+
realm.nonces.add(nonce)
57
57
+
return true
58
58
+
}
59
59
+
60
60
+
export function admitToRealm(realmid: RealmID, inviteeid: IdentID, inviteekey: CryptoKey) {
61
61
+
const realm = realmMap.require(realmid)
62
62
+
realm.identities.set(inviteeid, inviteekey)
46
63
}
47
64
48
65
export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {