diff --git a/src/client/realm-connection-manager.tsx b/src/client/realm-connection-manager.tsx
index e46f51d..c03ee14 100644
--- a/src/client/realm-connection-manager.tsx
+++ b/src/client/realm-connection-manager.tsx
@@ -1,5 +1,8 @@
+import {generateSignableJwt} from '#common/crypto/jwks.js'
+import {jwtSchema} from '#common/crypto/jwts.js'
import {RealmBrand} from '#common/protocol'
import {useSignal} from '@preact/signals'
+import {nanoid} from 'nanoid'
import {useCallback, useContext} from 'preact/hooks'
import {RealmConnectionContext} from './realm/context-connection'
import {RealmIdentityContext} from './realm/context-identity'
@@ -15,19 +18,62 @@ export const RealmConnectionManager: preact.FunctionComponent = () => {
const register = useCallback(() => {
console.log('register')
- connectionContext.realmid.value = RealmBrand.generate()
- }, [connectionContext.realmid])
+ connectionContext.connectopts.value = {
+ realmid: RealmBrand.generate(),
+ register: true,
+ }
+ }, [connectionContext.connectopts])
const exchange = useCallback(() => {
console.log('exchange', invitation.value)
- }, [invitation])
+ jwtSchema
+ .parseAsync(invitation.value)
+ .then((token) => {
+ connectionContext.connectopts.value = {
+ realmid: RealmBrand.parse(token.claims.aud),
+ invitation: invitation.value,
+ }
+ })
+ .catch((exc: unknown) => {
+ console.error('couldnt exchange', exc)
+ })
+ }, [invitation, connectionContext.connectopts])
- return connectionContext?.realmid.value ? (
-
- Connection:
- {connectionContext.realmid.value}
- {connectionContext.connected.value ? '🟢 Connected' : '🔴 Disconnected'}
-
+ const generate = useCallback(() => {
+ if (!connectionContext.realm.value) return
+ identityContext.identity
+ .sign(
+ generateSignableJwt({
+ aud: connectionContext.realm.value.realmid,
+ iss: connectionContext.realm.value.identid,
+ sub: 'invitation',
+ jti: nanoid(),
+ exp: Date.now() + 5 * 60,
+ }),
+ )
+ .then((token) => (invitation.value = token))
+ .catch((exc: unknown) => {
+ console.error('error generating invite', exc)
+ })
+ }, [invitation, connectionContext.realm, identityContext.identity])
+
+ return connectionContext?.realm.value ? (
+
+
+ Connection:
+ {connectionContext.realm.value.realmid}
+ {connectionContext.connected.value ? '🟢 Connected' : '🔴 Disconnected'}
+
+
+
+
+
Identities:
+
{JSON.stringify(connectionContext.realm.value?.knownIdentities, null, 2)}
+
Peers:
+
{JSON.stringify(connectionContext.realm.value?.knownPeers, null, 2)}
+
) : (
diff --git a/src/client/realm/connection.ts b/src/client/realm/connection.ts
index e4a4db7..8078063 100644
--- a/src/client/realm/connection.ts
+++ b/src/client/realm/connection.ts
@@ -3,50 +3,50 @@ import {nanoid} from 'nanoid'
import SimplePeer, {SimplePeerData} from 'simple-peer'
import {z} from 'zod/v4'
-import {generateSignableJwt} from '#common/crypto/jwks'
+import {timeoutSignal} from '#common/async/aborts.js'
+import {generateSignableJwt, jwkImport} from '#common/crypto/jwks'
+import {jwtPayload, verifyJwtToken} from '#common/crypto/jwts.js'
import {normalizeError, normalizeProtocolError, ProtocolError} from '#common/errors'
-import {
- IdentID,
- PreauthRegisterRequest,
- preauthRespSchema,
- RealmID,
- realmRtcPeerJoinedEventSchema,
- realmRtcPeerLeftEventSchema,
- realmRtcPingRequestSchema,
- RealmRtcPongResponse,
- realmRtcPongResponseSchema,
- RealmRtcSignalEvent,
- realmRtcSignalEventSchema,
-} from '#common/protocol'
+import * as protocol from '#common/protocol'
+import {IdentID, RealmID} from '#common/protocol'
import {streamSocketJson, takeSocketJson} from '#common/socket'
import {RealmIdentity} from './identity'
import {RealmPeer} from './peer'
import {PeerState} from './types'
const realmRtcMessagesSchema = z.union([
- realmRtcSignalEventSchema,
- realmRtcPeerJoinedEventSchema,
- realmRtcPeerLeftEventSchema,
- realmRtcPongResponseSchema,
+ protocol.realmRtcSignalEventSchema,
+ protocol.realmRtcPeerJoinedEventSchema,
+ protocol.realmRtcPeerLeftEventSchema,
+ protocol.realmRtcPongResponseSchema,
])
+export interface ConnectionOptions {
+ realmid?: RealmID
+ register?: boolean
+ invitation?: string
+}
+
/** manages websocket and webrtc connections for a realm */
export class RealmConnection extends EventTarget {
#url: string
- #realmid: RealmID
#identity: RealmIdentity
+ #connectopts: ConnectionOptions
- #socket: WebSocket
+ #identities: Map
#peers: Map
#nonces: Map
- constructor(url: string, realmid: RealmID, identity: RealmIdentity) {
+ #socket: WebSocket
+
+ constructor(url: string, identity: RealmIdentity, options: ConnectionOptions) {
super()
this.#url = url
- this.#realmid = realmid
this.#identity = identity
+ this.#connectopts = options
+ this.#identities = new Map()
this.#peers = new Map()
this.#nonces = new Map()
@@ -57,6 +57,22 @@ export class RealmConnection extends EventTarget {
this.#socket.onerror = this.#handleSocketError
}
+ get realmid() {
+ return this.#connectopts.realmid
+ }
+
+ get identid() {
+ return this.#identity.identid
+ }
+
+ get knownIdentities() {
+ return this.#identities
+ }
+
+ get knownPeers() {
+ return this.#peers.keys()
+ }
+
get connected() {
return this.#socket.readyState === this.#socket.OPEN
}
@@ -122,10 +138,14 @@ export class RealmConnection extends EventTarget {
// ... // <- get type errors here
// })
- async #socketSignedWrite(payload: T) {
+ #socketSend(payload: T) {
+ this.#socket.send(JSON.stringify(payload))
+ }
+
+ async #socketSignedSend(payload: T) {
const token = await this.#identity.sign(
- generateSignableJwt({
- aud: this.#realmid,
+ generateSignableJwt({
+ aud: this.#connectopts.realmid,
iss: this.#identity.identid,
payload,
}),
@@ -146,7 +166,7 @@ export class RealmConnection extends EventTarget {
try {
await this.#handleOpenAuthenticate()
for await (const data of streamSocketJson(this.#socket)) {
- this.#handleOpenMessage(data)
+ await this.#handleOpenMessage(data)
}
} catch (exc) {
const err = normalizeProtocolError(exc)
@@ -161,32 +181,68 @@ export class RealmConnection extends EventTarget {
// do the auth dance
// TODO: this should be a state machine
#handleOpenAuthenticate = async () => {
+ const timeout = timeoutSignal(5000)
try {
- await this.#socketSignedWrite({
- typ: 'req',
- msg: 'preauth.register',
- dat: {
- pubkey: await this.#identity.pubjwk,
- },
- })
+ // first we send the appropriate authentication message
+ if (this.#connectopts.invitation) {
+ await this.#socketSignedSend({
+ typ: 'req',
+ msg: 'preauth.exchange',
+ dat: {
+ inviteJwt: this.#connectopts.invitation,
+ pubkey: await this.#identity.pubjwk,
+ },
+ })
+ } else if (this.#connectopts.register) {
+ await this.#socketSignedSend({
+ typ: 'req',
+ msg: 'preauth.register',
+ dat: {
+ pubkey: await this.#identity.pubjwk,
+ },
+ })
+ } else {
+ await this.#socketSignedSend({
+ typ: 'req',
+ msg: 'preauth.authn',
+ })
+ }
- const resp = await takeSocketJson(this.#socket, preauthRespSchema)
- this.#dispatchCustomEvent('wsauth', resp)
+ // we're expecting an authenticated response
+ // if this times out, gets a different response or otherwise fails, we throw
+ const resp = await takeSocketJson(this.#socket, protocol.preauthRespSchema, timeout.signal)
+
+ // we store publickeys from the server for p2p message authentication
+ for (const [identid_, pubkey_] of Object.entries(resp.dat.identities)) {
+ try {
+ const identid = protocol.IdentBrand.parse(identid_)
+ const pubkey = await jwkImport.parseAsync(pubkey_)
+
+ this.#identities.set(identid, pubkey)
+ } catch (exc: unknown) {
+ console.error(`couldnt import realm identity: ${identid_}`, exc)
+ }
+ }
// we initiate connections outbound when starting up
- // we never initiate connections to other peers otherwise
- for (const peerid of resp.dat.activePeers) {
+ for (const peerid of resp.dat.peers) {
if (peerid === this.#identity.identid) continue
+
this.#connectPeer(peerid, true)
}
+
+ // finally, we're connected
+ this.#dispatchCustomEvent('wsauth', resp)
} catch (exc) {
const err = normalizeError(exc)
throw new ProtocolError('failure on authentication', 401, {cause: err})
+ } finally {
+ timeout.cancel()
}
}
// handle some message coming in on the _websocket_
- #handleOpenMessage = (data: unknown) => {
+ #handleOpenMessage = async (data: unknown) => {
const parse = realmRtcMessagesSchema.safeParse(data)
if (!parse.success) {
// publish non-handled data to the listeners
@@ -200,21 +256,48 @@ export class RealmConnection extends EventTarget {
return
case 'realm.rtc.signal': {
+ const jwt = await jwtPayload(protocol.realmRtcSignalPayloadSchema).parseAsync(
+ parse.data.dat.signed,
+ )
+
+ // backwards from our perspective
+ const remoteid = parse.data.dat.localid
+ const remotekey = this.#identities.get(remoteid)
+
+ // if we don't have a key for this peer, something is up
+ if (!remotekey) {
+ console.error('signal from remote peer with mismatched key!', parse.data)
+ return
+ }
+
+ // validate the token against the key we already have
+ await verifyJwtToken(jwt.token, remotekey)
+ if (jwt.claims.aud !== this.#identity.identid) {
+ console.error('signal received, but not intended for us', parse.data)
+ return
+ }
+
// only connect if _they_ are the initiator - our initiating conns were above
let peer = this.#peers.get(parse.data.dat.localid)
- if (!peer && parse.data.dat.initiator) {
+ if (!peer && jwt.payload.initiator) {
peer = this.#connectPeer(parse.data.dat.localid, false)
}
// may not have a connection yet if we're waiting for them to answer
- peer?.signal(parse.data.dat.payload)
+ peer?.signal(jwt.payload.signal)
return
}
- case 'realm.rtc.peer-joined':
- // new peers connect to us, we don't connect to them
+ case 'realm.rtc.peer-joined': {
+ const remoteid = parse.data.dat.identid
+ const remotekey = await jwkImport.parseAsync(parse.data.dat.pubkey)
+ if (!this.#identities.has(remoteid)) {
+ this.#identities.set(remoteid, remotekey)
+ }
+
this.#dispatchCustomEvent('peerjoined', {identid: parse.data.dat.identid})
return
+ }
case 'realm.rtc.peer-left':
// peer is gone, disconnect
@@ -262,18 +345,33 @@ export class RealmConnection extends EventTarget {
}
#handlePeerSignal = (peer: RealmPeer, payload: SimplePeer.SignalData) => {
- const msg: RealmRtcSignalEvent = {
- typ: 'evt',
- msg: 'realm.rtc.signal',
- dat: {
- initiator: peer.initiator,
- payload: JSON.stringify(payload),
- localid: this.#identity.identid,
- remoteid: peer.identid,
- },
+ const go = async () => {
+ const signed = await this.#identity.sign(
+ generateSignableJwt({
+ aud: peer.identid,
+ iss: this.#identity.identid,
+ exp: Date.now() + 60,
+ payload: {
+ signal: JSON.stringify(payload),
+ initiator: peer.initiator,
+ },
+ }),
+ )
+
+ this.#socketSend({
+ typ: 'evt',
+ msg: 'realm.rtc.signal',
+ dat: {
+ signed,
+ localid: this.#identity.identid,
+ remoteid: peer.identid,
+ },
+ })
}
- this.#socket.send(JSON.stringify(msg))
+ go().catch((exc: unknown) => {
+ console.error('error in peer signal!', exc)
+ })
}
#handlePeerConnect = (peer: RealmPeer) => {
@@ -296,10 +394,10 @@ export class RealmConnection extends EventTarget {
#handlePeerData = (peer: RealmPeer, chunk: string | Uint8Array) => {
const data = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
- const parsed = realmRtcPingRequestSchema.safeParse(data)
+ const parsed = protocol.realmRtcPingRequestSchema.safeParse(data)
if (parsed.success) {
// reply to pings with pongs
- this.send(peer.identid, {
+ this.send(peer.identid, {
typ: 'res',
msg: 'realm.rtc.pong',
seq: parsed.data.seq,
diff --git a/src/client/realm/context-connection.tsx b/src/client/realm/context-connection.tsx
index 964695b..1329613 100644
--- a/src/client/realm/context-connection.tsx
+++ b/src/client/realm/context-connection.tsx
@@ -2,21 +2,19 @@ import {Signal, useSignal, useSignalEffect} from '@preact/signals'
import {createContext} from 'preact'
import {useCallback, useContext} from 'preact/hooks'
-import {RealmConnection} from '#client/realm/connection'
-import {RealmID} from '#common/protocol.js'
+import {ConnectionOptions, RealmConnection} from '#client/realm/connection'
import {RealmIdentityContext} from './context-identity'
export interface RealmConnectionValues {
realm: Signal
- realmid: Signal
connected: Signal
+ connectopts: Signal
}
export const RealmConnectionContext = createContext(null)
export const RealmConnectionProvider: preact.FunctionComponent<{
url: string
- realmid?: RealmID
children: preact.ComponentChildren
}> = (props) => {
const context = useContext(RealmIdentityContext)
@@ -24,17 +22,16 @@ export const RealmConnectionProvider: preact.FunctionComponent<{
const connected$ = useSignal(false)
const connection$ = useSignal()
- const realmid$ = useSignal(props.realmid)
+ const connectopts$ = useSignal({})
const onChange = useCallback(() => {
- console.log('on change called!', connection$.value?.connected)
connected$.value = connection$.value?.connected || false
}, [connected$, connection$])
useSignalEffect(() => {
- if (!realmid$.value) return
+ if (!connectopts$.value?.realmid) return
- const connection = new RealmConnection(props.url, realmid$.value, context.identity)
+ const connection = new RealmConnection(props.url, context.identity, connectopts$.value)
connection.addEventListener('wsopen', onChange)
connection.addEventListener('wsclose', onChange)
connection.addEventListener('wserror', onChange)
@@ -53,8 +50,8 @@ export const RealmConnectionProvider: preact.FunctionComponent<{
children={props.children}
value={{
realm: connection$,
- realmid: realmid$,
connected: connected$,
+ connectopts: connectopts$,
}}
/>
)
diff --git a/src/common/crypto/jwks.ts b/src/common/crypto/jwks.ts
index e89f8b0..f0e84ac 100644
--- a/src/common/crypto/jwks.ts
+++ b/src/common/crypto/jwks.ts
@@ -99,6 +99,13 @@ export async function generateSigningJwkPair(): Promise {
* @param payload - the payload to sign
* @returns a properly configured jwt signer, with the payload provided
*/
-export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT {
+declare const _INFERRED: unique symbol
+type Inferred = typeof _INFERRED
+
+export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT
+export function generateSignableJwt(payload: jose.JWTPayload & {payload: T}): jose.SignJWT
+export function generateSignableJwt(
+ payload: T extends Inferred ? jose.JWTPayload : jose.JWTPayload & {payload: T},
+): jose.SignJWT {
return new jose.SignJWT(payload).setProtectedHeader({alg: joseSignAlgo.name})
}
diff --git a/src/common/crypto/jwts.ts b/src/common/crypto/jwts.ts
index 5f87cd9..846f3a1 100644
--- a/src/common/crypto/jwts.ts
+++ b/src/common/crypto/jwts.ts
@@ -10,7 +10,7 @@ export interface JWTToken {
claims: jose.JWTPayload
}
-export interface JWTTokenPayload {
+export type JWTTokenPayload = {
token: string
claims: jose.JWTPayload
payload: T
@@ -43,7 +43,6 @@ export const jwtSchema: z.ZodType = z
*/
export const jwtPayload = (schema: z.ZodType): z.ZodType> => {
const parser = z.looseObject({payload: schema})
-
return jwtSchema.transform(async (payload, ctx) => {
const result = await parser.safeParseAsync(payload.claims)
if (result.success) return {...payload, payload: result.data.payload}
diff --git a/src/common/protocol/messages.ts b/src/common/protocol/messages.ts
index b933906..816cf4d 100644
--- a/src/common/protocol/messages.ts
+++ b/src/common/protocol/messages.ts
@@ -30,7 +30,8 @@ export const preauthExchangeInviteReqSchema = makeRequestSchema(
export const preauthRespSchema = makeResponseSchema(
'preauth.authn',
z.object({
- activePeers: z.array(IdentBrand.schema),
+ peers: z.array(IdentBrand.schema),
+ identities: z.record(z.string(), jwkSchema),
}),
)
@@ -65,16 +66,23 @@ export const realmRtcPingPongMessageSchema = z.union([
export const realmRtcSignalEventSchema = makeEventSchema(
'realm.rtc.signal',
z.object({
- payload: z.string(),
- initiator: z.boolean(),
+ signed: z.jwt(),
localid: IdentBrand.schema,
remoteid: IdentBrand.schema,
}),
)
+export const realmRtcSignalPayloadSchema = z.object({
+ signal: z.string(),
+ initiator: z.boolean(),
+})
+
export const realmRtcPeerJoinedEventSchema = makeEventSchema(
'realm.rtc.peer-joined',
- z.object({identid: IdentBrand.schema}),
+ z.object({
+ identid: IdentBrand.schema,
+ pubkey: jwkSchema,
+ }),
)
export const realmRtcPeerLeftEventSchema = makeEventSchema(
@@ -86,5 +94,6 @@ export type RealmBroadcastEvent = z.infer
export type RealmRtcPingRequest = z.infer
export type RealmRtcPongResponse = z.infer
export type RealmRtcSignalEvent = z.infer
+export type RealmRtcSignalPayload = z.infer
export type RealmRtcPeerJoinedEvent = z.infer
export type RealmRtcPeerLeftEvent = z.infer
diff --git a/src/server/routes-socket/handler-preauth.ts b/src/server/routes-socket/handler-preauth.ts
index 0f2ff47..74e7e1e 100644
--- a/src/server/routes-socket/handler-preauth.ts
+++ b/src/server/routes-socket/handler-preauth.ts
@@ -1,7 +1,7 @@
import WebSocket from 'isomorphic-ws'
import {combineSignals, timeoutSignal} from '#common/async/aborts'
-import {jwkImport} from '#common/crypto/jwks'
+import {JWK, jwkExport, jwkImport} from '#common/crypto/jwks'
import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts'
import {normalizeError, ProtocolError} from '#common/errors'
import {
@@ -60,7 +60,7 @@ export async function preauthHandler(
// everything is in place, fall through to authentication
const auth = await authenticatePreauth(realmid, identid, jwt.token)
- const msg = preauthResponse(auth, jwt.payload.seq)
+ const msg = await preauthResponse(auth, jwt.payload.seq)
ws.send(JSON.stringify(msg))
return auth
@@ -104,13 +104,22 @@ async function authenticatePreauth(
}
}
-function preauthResponse(auth: realms.AuthenticatedIdentity, seq?: number): PreauthResponse {
+async function preauthResponse(
+ auth: realms.AuthenticatedIdentity,
+ seq?: number,
+): Promise {
+ const peers = Array.from(auth.realm.sockets.keys())
+ const identities: Record = {}
+
+ for (const identid of auth.realm.identities.keys()) {
+ const pubkey = auth.realm.identities.require(identid)
+ identities[identid] = await jwkExport.parseAsync(pubkey)
+ }
+
return {
typ: 'res',
msg: 'preauth.authn',
- dat: {
- activePeers: Array.from(auth.realm.sockets.keys()),
- },
+ dat: {peers, identities},
seq,
}
}
diff --git a/src/server/routes-socket/handler-realm.ts b/src/server/routes-socket/handler-realm.ts
index 1010656..42f95fa 100644
--- a/src/server/routes-socket/handler-realm.ts
+++ b/src/server/routes-socket/handler-realm.ts
@@ -1,6 +1,7 @@
import {WebSocket} from 'isomorphic-ws'
import {z} from 'zod/v4'
+import {jwkExport} from '#common/crypto/jwks.js'
import {normalizeProtocolError, ProtocolError} from '#common/errors'
import * as protocol from '#common/protocol'
import {streamSocket} from '#common/socket'
@@ -21,7 +22,7 @@ export async function realmHandler(
auth: realm.AuthenticatedIdentity,
signal?: AbortSignal,
) {
- realmBroadcast(auth, buildRtcPeerJoined(auth))
+ realmBroadcast(auth, await buildRtcPeerJoined(auth))
try {
const incomingParser = protocol.parseJson.pipe(incomingMessageSchema)
@@ -56,11 +57,16 @@ export async function realmHandler(
}
}
-function buildRtcPeerJoined(auth: realm.AuthenticatedIdentity): protocol.RealmRtcPeerJoinedEvent {
+async function buildRtcPeerJoined(
+ auth: realm.AuthenticatedIdentity,
+): Promise {
return {
typ: 'evt',
msg: 'realm.rtc.peer-joined',
- dat: {identid: auth.identid},
+ dat: {
+ identid: auth.identid,
+ pubkey: await jwkExport.parseAsync(auth.pubkey),
+ },
}
}