@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device mesh's relay of last resort: a TURN server on one of the user's always-on devices, used
|
|
3
|
+
* only by the devices of the same roster, with short-lived credentials each pair derives on its own.
|
|
4
|
+
*
|
|
5
|
+
* A credential follows the TURN REST scheme — the username carries its expiry, the password is a MAC
|
|
6
|
+
* of the username — but the MAC key is the pair's secret rather than one shared by every client: the
|
|
7
|
+
* username is `<expiry>:<tag>`, where the tag is the pair's rotating relay-user tag, so it names no
|
|
8
|
+
* device to whoever sees it, and only the relay device can tell whose it is. A device the lists no
|
|
9
|
+
* longer name, or revoke, derives no tag and no password, and its allocations close.
|
|
10
|
+
*
|
|
11
|
+
* The relay moves datagrams between two ends that run DTLS with each other; it never holds a key of
|
|
12
|
+
* that channel, and a connection through it is admitted by the device handshake exactly as a direct
|
|
13
|
+
* one is.
|
|
14
|
+
*/
|
|
15
|
+
import { rendezvousEpoch, type IPairRendezvous } from '@robota-sdk/agent-remote-pairing';
|
|
16
|
+
|
|
17
|
+
import { TurnServer, type ITurnQuotas } from './turn-server.js';
|
|
18
|
+
|
|
19
|
+
import type { IIceServer } from './webrtc-transport-options.js';
|
|
20
|
+
|
|
21
|
+
/** Where a paired device's relay listens. */
|
|
22
|
+
export interface IMeshRelayEndpoint {
|
|
23
|
+
readonly host: string;
|
|
24
|
+
readonly port: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A device the relay serves, with the pair's rendezvous. */
|
|
28
|
+
export interface IMeshRelayPeer {
|
|
29
|
+
readonly deviceId: string;
|
|
30
|
+
readonly rendezvous: IPairRendezvous;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How long a relay credential is good for, by default. */
|
|
34
|
+
export const DEFAULT_RELAY_CREDENTIAL_TTL_MS = 10 * 60 * 1000;
|
|
35
|
+
/** The longest a relay accepts: a credential cannot outlive the tag epochs its relay looks up. */
|
|
36
|
+
export const MAX_RELAY_CREDENTIAL_TTL_MS = 60 * 60 * 1000;
|
|
37
|
+
/** Clocks of two devices may disagree by this much. */
|
|
38
|
+
const CLOCK_SKEW_MS = 60 * 1000;
|
|
39
|
+
const TAG_BYTES = 16;
|
|
40
|
+
const USERNAME = /^(\d{1,12}):([A-Za-z0-9_-]{22})$/;
|
|
41
|
+
|
|
42
|
+
function base64Url(bytes: Uint8Array): string {
|
|
43
|
+
return Buffer.from(bytes).toString('base64url');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A relay credential this device presents to `rendezvous`'s peer's relay, good until `now + ttlMs`. */
|
|
47
|
+
export async function meshRelayCredential(
|
|
48
|
+
rendezvous: IPairRendezvous,
|
|
49
|
+
now: number,
|
|
50
|
+
ttlMs: number = DEFAULT_RELAY_CREDENTIAL_TTL_MS,
|
|
51
|
+
): Promise<{ readonly username: string; readonly credential: string }> {
|
|
52
|
+
const ttl = Math.min(Math.max(ttlMs, 1000), MAX_RELAY_CREDENTIAL_TTL_MS);
|
|
53
|
+
const tag = (await rendezvous.tag('relay-user', 'outbound', rendezvousEpoch(now))).slice(
|
|
54
|
+
0,
|
|
55
|
+
TAG_BYTES,
|
|
56
|
+
);
|
|
57
|
+
const username = `${Math.floor((now + ttl) / 1000)}:${base64Url(tag)}`;
|
|
58
|
+
return { username, credential: await rendezvous.relayPassword('outbound', username) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function turnUrl(endpoint: IMeshRelayEndpoint): string {
|
|
62
|
+
const host = endpoint.host.includes(':') ? `[${endpoint.host}]` : endpoint.host;
|
|
63
|
+
return `turn:${host}:${endpoint.port}?transport=udp`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** ICE servers for a paired device's relay endpoints, with this pair's credential. */
|
|
67
|
+
export async function meshRelayIceServers(
|
|
68
|
+
rendezvous: IPairRendezvous,
|
|
69
|
+
endpoints: readonly IMeshRelayEndpoint[],
|
|
70
|
+
now: number,
|
|
71
|
+
ttlMs?: number,
|
|
72
|
+
): Promise<IIceServer[]> {
|
|
73
|
+
if (endpoints.length === 0) return [];
|
|
74
|
+
const { username, credential } = await meshRelayCredential(rendezvous, now, ttlMs);
|
|
75
|
+
return endpoints.map((endpoint) => ({ urls: turnUrl(endpoint), username, credential }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Why a relay was needed: only relayed connections are allowed, or a direct attempt got as far as
|
|
80
|
+
* trying paths and found none.
|
|
81
|
+
*/
|
|
82
|
+
export type TMeshRelayNeed = 'relay-only' | 'no-direct-path';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A connection needed a relay and none could be tried. Never a silent degrade — the connection is
|
|
86
|
+
* refused with this error, which carries what the direct attempt went through as its `cause`.
|
|
87
|
+
*/
|
|
88
|
+
export class MeshRelayNeededError extends Error {
|
|
89
|
+
public readonly deviceId: string;
|
|
90
|
+
public readonly need: TMeshRelayNeed;
|
|
91
|
+
|
|
92
|
+
public constructor(
|
|
93
|
+
deviceId: string,
|
|
94
|
+
need: TMeshRelayNeed,
|
|
95
|
+
options?: { readonly cause?: unknown },
|
|
96
|
+
) {
|
|
97
|
+
const why =
|
|
98
|
+
need === 'relay-only'
|
|
99
|
+
? 'only relayed connections are allowed'
|
|
100
|
+
: `no direct path was found (${
|
|
101
|
+
options?.cause instanceof Error ? options.cause.message : String(options?.cause)
|
|
102
|
+
})`;
|
|
103
|
+
super(
|
|
104
|
+
`a relay device is needed to reach device ${deviceId}: ${why}, and no paired device ` +
|
|
105
|
+
'advertises a relay and no TURN server is configured. Run the relay on an always-on ' +
|
|
106
|
+
'device, or configure a TURN server.',
|
|
107
|
+
options?.cause !== undefined ? { cause: options.cause } : undefined,
|
|
108
|
+
);
|
|
109
|
+
this.name = 'MeshRelayNeededError';
|
|
110
|
+
this.deviceId = deviceId;
|
|
111
|
+
this.need = need;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface IMeshTurnRelayOptions {
|
|
116
|
+
/** Default every IPv4 interface. */
|
|
117
|
+
readonly host?: string;
|
|
118
|
+
/** Default 3478; 0 picks a free port. */
|
|
119
|
+
readonly port?: number;
|
|
120
|
+
/** The address relayed transport addresses carry, e.g. this device's public address. */
|
|
121
|
+
readonly relayAddress?: string;
|
|
122
|
+
/** The ports relayed addresses take; behind a NAT, forward them with the relay's own port. */
|
|
123
|
+
readonly relayPorts?: { readonly min: number; readonly max: number };
|
|
124
|
+
readonly quotas?: Partial<ITurnQuotas>;
|
|
125
|
+
/** Default true: see {@link TurnServer}. */
|
|
126
|
+
readonly allowPrivatePeers?: boolean;
|
|
127
|
+
/** Default: see {@link TurnServer}. */
|
|
128
|
+
readonly allowPeer?: (address: string) => boolean;
|
|
129
|
+
readonly now?: () => number;
|
|
130
|
+
readonly onError?: (error: Error) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** This device's relay for its paired devices. */
|
|
134
|
+
export class MeshTurnRelay {
|
|
135
|
+
private peers: readonly IMeshRelayPeer[] = [];
|
|
136
|
+
/** Whose each relay-user tag is, for the peers in force and one epoch: derived once, not per request. */
|
|
137
|
+
private owners?: {
|
|
138
|
+
readonly epoch: number;
|
|
139
|
+
readonly peers: readonly IMeshRelayPeer[];
|
|
140
|
+
readonly byTag: Promise<ReadonlyMap<string, IMeshRelayPeer>>;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
private constructor(
|
|
144
|
+
private readonly server: TurnServer,
|
|
145
|
+
private readonly now: () => number,
|
|
146
|
+
) {}
|
|
147
|
+
|
|
148
|
+
public static async start(options: IMeshTurnRelayOptions = {}): Promise<MeshTurnRelay> {
|
|
149
|
+
const now = options.now ?? Date.now;
|
|
150
|
+
let relay: MeshTurnRelay | undefined;
|
|
151
|
+
const server = await TurnServer.start({
|
|
152
|
+
...(options.host !== undefined ? { host: options.host } : {}),
|
|
153
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
154
|
+
...(options.relayAddress !== undefined ? { relayAddress: options.relayAddress } : {}),
|
|
155
|
+
...(options.relayPorts !== undefined ? { relayPorts: options.relayPorts } : {}),
|
|
156
|
+
...(options.quotas !== undefined ? { quotas: options.quotas } : {}),
|
|
157
|
+
...(options.allowPeer !== undefined ? { allowPeer: options.allowPeer } : {}),
|
|
158
|
+
...(options.allowPrivatePeers !== undefined
|
|
159
|
+
? { allowPrivatePeers: options.allowPrivatePeers }
|
|
160
|
+
: {}),
|
|
161
|
+
...(options.onError !== undefined ? { onError: options.onError } : {}),
|
|
162
|
+
now,
|
|
163
|
+
// A request that arrives before the relay exists is refused, as an unknown user is.
|
|
164
|
+
authorize: (username) =>
|
|
165
|
+
relay === undefined ? Promise.resolve(undefined) : relay.authorize(username),
|
|
166
|
+
});
|
|
167
|
+
relay = new MeshTurnRelay(server, now);
|
|
168
|
+
return relay;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Where the relay listens. */
|
|
172
|
+
public get endpoint(): IMeshRelayEndpoint {
|
|
173
|
+
const { address, port } = this.server.address;
|
|
174
|
+
return { host: address, port };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Allocations held, by one device or in all. */
|
|
178
|
+
public allocationCount(deviceId?: string): number {
|
|
179
|
+
return this.server.allocationCount(deviceId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The devices the relay serves: the current, unrevoked peers of the lists in force. Any other
|
|
184
|
+
* device's credentials stop working, and its allocations close at once.
|
|
185
|
+
*/
|
|
186
|
+
public declarePeers(peers: readonly IMeshRelayPeer[]): void {
|
|
187
|
+
this.peers = [...peers];
|
|
188
|
+
this.server.retain(new Set(peers.map((peer) => peer.deviceId)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async authorize(
|
|
192
|
+
username: string,
|
|
193
|
+
): Promise<{ readonly owner: string; readonly password: string } | undefined> {
|
|
194
|
+
const match = USERNAME.exec(username);
|
|
195
|
+
if (match === null) return undefined;
|
|
196
|
+
const now = this.now();
|
|
197
|
+
const expiresAt = Number(match[1]) * 1000;
|
|
198
|
+
if (expiresAt <= now || expiresAt - now > MAX_RELAY_CREDENTIAL_TTL_MS + CLOCK_SKEW_MS) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const peer = (await this.tagOwners(rendezvousEpoch(now))).get(match[2]!);
|
|
202
|
+
// Still a peer once the tags are derived: a revocation meanwhile wins.
|
|
203
|
+
if (peer === undefined || !this.peers.some((p) => p.deviceId === peer.deviceId)) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
owner: peer.deviceId,
|
|
208
|
+
password: await peer.rendezvous.relayPassword('inbound', username),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The peers' relay-user tags for `epoch` and the adjacent ones, by tag. */
|
|
213
|
+
private tagOwners(epoch: number): Promise<ReadonlyMap<string, IMeshRelayPeer>> {
|
|
214
|
+
const held = this.owners;
|
|
215
|
+
if (held !== undefined && held.epoch === epoch && held.peers === this.peers) return held.byTag;
|
|
216
|
+
const peers = this.peers;
|
|
217
|
+
const byTag = (async () => {
|
|
218
|
+
const out = new Map<string, IMeshRelayPeer>();
|
|
219
|
+
for (const peer of peers) {
|
|
220
|
+
for (const tag of await peer.rendezvous.lookupTags('relay-user', epoch)) {
|
|
221
|
+
out.set(base64Url(tag.slice(0, TAG_BYTES)), peer);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
})();
|
|
226
|
+
// A failed derivation is tried again by the next request.
|
|
227
|
+
byTag.catch(() => {
|
|
228
|
+
if (this.owners?.byTag === byTag) this.owners = undefined;
|
|
229
|
+
});
|
|
230
|
+
this.owners = { epoch, peers, byTag };
|
|
231
|
+
return byTag;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
public close(): Promise<void> {
|
|
235
|
+
this.peers = [];
|
|
236
|
+
return this.server.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote fingerprint a channel binding names: the one of the certificate the DTLS layer accepted,
|
|
3
|
+
* never SDP text.
|
|
4
|
+
*
|
|
5
|
+
* The SDP arrives over an untrusted signaling path. The DTLS layer (OpenSSL, through libdatachannel)
|
|
6
|
+
* accepts the remote certificate only after the peer has signed its handshake with that certificate's
|
|
7
|
+
* key and the certificate matches the fingerprint of the remote description. Callers additionally
|
|
8
|
+
* require the description to advertise exactly one fingerprint, so the accepted certificate and the
|
|
9
|
+
* bound value cannot differ: binding it names the one party that holds the key.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RtcPeer } from './rtc-peer.js';
|
|
13
|
+
|
|
14
|
+
const FINGERPRINT_VALUE = /^[0-9A-F]{2}(?::[0-9A-F]{2})+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Call `onVerified` with the remote certificate's fingerprint once the connection is established, or
|
|
18
|
+
* `onFailed` if it fails or closes first, or the fingerprint is missing or of another algorithm than
|
|
19
|
+
* `algorithm`. Returns an unsubscribe function.
|
|
20
|
+
*
|
|
21
|
+
* The connection reports `connected` only after DTLS has completed, and the data channel runs over that
|
|
22
|
+
* DTLS session.
|
|
23
|
+
*/
|
|
24
|
+
export function whenRemoteCertificateVerified(
|
|
25
|
+
peer: RtcPeer,
|
|
26
|
+
algorithm: string,
|
|
27
|
+
onVerified: (fingerprint: string) => void,
|
|
28
|
+
onFailed: (reason: string) => void,
|
|
29
|
+
): () => void {
|
|
30
|
+
let done = false;
|
|
31
|
+
const settle = (): void => {
|
|
32
|
+
if (done) return;
|
|
33
|
+
done = true;
|
|
34
|
+
const fingerprint = peer.remoteFingerprint();
|
|
35
|
+
if (fingerprint === undefined) {
|
|
36
|
+
onFailed('the DTLS layer exposed no remote certificate');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const value = fingerprint.value.toUpperCase();
|
|
40
|
+
if (fingerprint.algorithm.toLowerCase() !== algorithm.toLowerCase()) {
|
|
41
|
+
onFailed(
|
|
42
|
+
`the remote certificate was checked with ${fingerprint.algorithm}, not ${algorithm}`,
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!FINGERPRINT_VALUE.test(value)) {
|
|
47
|
+
onFailed('the remote certificate fingerprint is malformed');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
onVerified(value);
|
|
51
|
+
};
|
|
52
|
+
const fail = (state: string): void => {
|
|
53
|
+
if (done) return;
|
|
54
|
+
done = true;
|
|
55
|
+
onFailed(`the connection ended ${state} before DTLS completed`);
|
|
56
|
+
};
|
|
57
|
+
if (peer.state === 'connected') {
|
|
58
|
+
settle();
|
|
59
|
+
return () => undefined;
|
|
60
|
+
}
|
|
61
|
+
if (peer.state === 'failed' || peer.state === 'closed') {
|
|
62
|
+
fail(peer.state);
|
|
63
|
+
return () => undefined;
|
|
64
|
+
}
|
|
65
|
+
const unsubscribe = peer.onStateChange((state) => {
|
|
66
|
+
if (state === 'connected') settle();
|
|
67
|
+
else if (state === 'failed' || state === 'closed') fail(state);
|
|
68
|
+
else return;
|
|
69
|
+
unsubscribe();
|
|
70
|
+
});
|
|
71
|
+
return unsubscribe;
|
|
72
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live signaling between two devices over public Nostr relays, as ephemeral events (kinds
|
|
3
|
+
* 20000–29999, which relays forward and do not store).
|
|
4
|
+
*
|
|
5
|
+
* Nostr is only a mailbox run by strangers, so nothing in an event tells them whose it is:
|
|
6
|
+
* - the author key is a one-time key of the pair, the direction and the epoch;
|
|
7
|
+
* - the kind is drawn from the ephemeral range per pair, direction and epoch, so no constant kind
|
|
8
|
+
* marks the product;
|
|
9
|
+
* - there are no tags, and the content is the pair's own AEAD ciphertext of the signal, padded to a
|
|
10
|
+
* step so its length says little about what it carries.
|
|
11
|
+
*
|
|
12
|
+
* Events go to several relays of different operators. What arrives is checked (shape, signature,
|
|
13
|
+
* author key and kind for that pair and epoch, age, duplicates) and opened before it reaches the
|
|
14
|
+
* node, which decodes it as hostile input like any relay-delivered signal and admits a peer by the
|
|
15
|
+
* device handshake alone. The draft WebRTC-signaling NIP is not used: the event format here is ours.
|
|
16
|
+
*/
|
|
17
|
+
import { rendezvousEpoch, RENDEZVOUS_EPOCH_MS } from '@robota-sdk/agent-remote-pairing';
|
|
18
|
+
import { SimplePool } from 'nostr-tools/pool';
|
|
19
|
+
import { finalizeEvent, getPublicKey, verifyEvent, type Event } from 'nostr-tools/pure';
|
|
20
|
+
|
|
21
|
+
import { padJson, unpadJson } from './mesh-records.js';
|
|
22
|
+
|
|
23
|
+
import type { IMeshPeerRoute } from './mesh-discovery.js';
|
|
24
|
+
import type { IMeshRelay } from './mesh-relay.js';
|
|
25
|
+
|
|
26
|
+
/** A Nostr event as relays carry it. */
|
|
27
|
+
export interface INostrEvent {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly pubkey: string;
|
|
30
|
+
readonly created_at: number;
|
|
31
|
+
readonly kind: number;
|
|
32
|
+
readonly tags: readonly (readonly string[])[];
|
|
33
|
+
readonly content: string;
|
|
34
|
+
readonly sig: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface INostrFilter {
|
|
38
|
+
readonly kinds: readonly number[];
|
|
39
|
+
readonly authors: readonly string[];
|
|
40
|
+
readonly since?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The relays a device signals through. */
|
|
44
|
+
export interface INostrRelayPool {
|
|
45
|
+
/** Send `event` to every relay; resolves `true` when at least one took it. Never rejects. */
|
|
46
|
+
publish(event: INostrEvent): Promise<boolean>;
|
|
47
|
+
/** Events matching `filter` from any relay, as received. */
|
|
48
|
+
subscribe(filter: INostrFilter, onEvent: (event: unknown) => void): { close(): void };
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EPHEMERAL_KIND_BASE = 20_000;
|
|
53
|
+
const EPHEMERAL_KIND_SPAN = 10_000;
|
|
54
|
+
/** Plaintext is padded up to a multiple of this. */
|
|
55
|
+
export const NOSTR_SIGNAL_PAD_STEP = 512;
|
|
56
|
+
/** Largest signal plaintext sent; relays refuse large events. */
|
|
57
|
+
const MAX_SIGNAL_BYTES = 32 * 1024;
|
|
58
|
+
/** Largest event content accepted. */
|
|
59
|
+
const MAX_CONTENT_CHARS = 48 * 1024;
|
|
60
|
+
/** An event older or newer than this is not taken: a relay could replay it. */
|
|
61
|
+
const DEFAULT_MAX_EVENT_AGE_S = 120;
|
|
62
|
+
/** Event ids remembered to drop the copies several relays deliver. */
|
|
63
|
+
const SEEN_IDS = 2048;
|
|
64
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
65
|
+
const HEX128 = /^[0-9a-f]{128}$/;
|
|
66
|
+
|
|
67
|
+
/** The ephemeral kind of `tag`: a pair's kind for one direction and epoch. */
|
|
68
|
+
export function ephemeralKindOf(tag: Uint8Array): number {
|
|
69
|
+
return EPHEMERAL_KIND_BASE + ((((tag[0] ?? 0) << 8) | (tag[1] ?? 0)) % EPHEMERAL_KIND_SPAN);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toBase64(bytes: Uint8Array): string {
|
|
73
|
+
let binary = '';
|
|
74
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
75
|
+
return btoa(binary);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fromBase64(value: string): Uint8Array | undefined {
|
|
79
|
+
try {
|
|
80
|
+
const binary = atob(value);
|
|
81
|
+
const out = new Uint8Array(binary.length);
|
|
82
|
+
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
|
83
|
+
return out;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An event's fields, copied out of whatever arrived, or `undefined` when it is not an event. */
|
|
90
|
+
function readEvent(value: unknown): Event | undefined {
|
|
91
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
92
|
+
const r = value as Record<string, unknown>;
|
|
93
|
+
if (typeof r.id !== 'string' || !HEX64.test(r.id)) return undefined;
|
|
94
|
+
if (typeof r.pubkey !== 'string' || !HEX64.test(r.pubkey)) return undefined;
|
|
95
|
+
if (typeof r.sig !== 'string' || !HEX128.test(r.sig)) return undefined;
|
|
96
|
+
if (typeof r.kind !== 'number' || !Number.isInteger(r.kind)) return undefined;
|
|
97
|
+
if (typeof r.created_at !== 'number' || !Number.isInteger(r.created_at)) return undefined;
|
|
98
|
+
if (typeof r.content !== 'string' || r.content.length > MAX_CONTENT_CHARS) return undefined;
|
|
99
|
+
if (!Array.isArray(r.tags) || r.tags.length !== 0) return undefined;
|
|
100
|
+
// A fresh object: nothing the sender attached (such as a cached verdict) comes along.
|
|
101
|
+
return {
|
|
102
|
+
id: r.id,
|
|
103
|
+
pubkey: r.pubkey,
|
|
104
|
+
sig: r.sig,
|
|
105
|
+
kind: r.kind,
|
|
106
|
+
created_at: r.created_at,
|
|
107
|
+
content: r.content,
|
|
108
|
+
tags: [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface IOutbound {
|
|
113
|
+
readonly route: IMeshPeerRoute;
|
|
114
|
+
readonly epoch: number;
|
|
115
|
+
readonly secretKey: Uint8Array;
|
|
116
|
+
readonly kind: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface IInbound {
|
|
120
|
+
readonly route: IMeshPeerRoute;
|
|
121
|
+
readonly epoch: number;
|
|
122
|
+
readonly kind: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface INostrMeshRelayOptions {
|
|
126
|
+
readonly pool: INostrRelayPool;
|
|
127
|
+
readonly maxEventAgeS?: number;
|
|
128
|
+
readonly now?: () => number;
|
|
129
|
+
readonly onError?: (error: Error) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A mesh relay over Nostr. It needs the peers (see {@link IMeshRelay.declarePeers}) to derive their
|
|
134
|
+
* keys, and addresses a pair by its outbound topic. It reports a topic absent only when it knows no
|
|
135
|
+
* pair by it: whether the peer is listening, a relay that forwards ephemeral events cannot tell.
|
|
136
|
+
*/
|
|
137
|
+
export class NostrMeshRelay implements IMeshRelay {
|
|
138
|
+
private readonly messages = new Set<(topic: string, data: unknown) => void>();
|
|
139
|
+
private readonly absents = new Set<(topic: string) => void>();
|
|
140
|
+
private outbound = new Map<string, IOutbound>();
|
|
141
|
+
private inbound = new Map<string, IInbound>();
|
|
142
|
+
private subscription?: { close(): void };
|
|
143
|
+
private chain: Promise<void> = Promise.resolve();
|
|
144
|
+
private readonly seen = new Set<string>();
|
|
145
|
+
private routes: readonly IMeshPeerRoute[] = [];
|
|
146
|
+
private epoch?: number;
|
|
147
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
148
|
+
private closed = false;
|
|
149
|
+
|
|
150
|
+
public constructor(private readonly options: INostrMeshRelayOptions) {}
|
|
151
|
+
|
|
152
|
+
private now(): number {
|
|
153
|
+
return (this.options.now ?? Date.now)();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private report(error: unknown): void {
|
|
157
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Run `task` after everything queued before it, so keys are in place before a send uses them. */
|
|
161
|
+
private enqueue(task: () => Promise<void> | void): void {
|
|
162
|
+
this.chain = this.chain.then(task).catch((error: unknown) => this.report(error));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Resolves once every declaration, send and receipt queued so far has run. */
|
|
166
|
+
public whenIdle(): Promise<void> {
|
|
167
|
+
return this.chain;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public declarePresence(): void {
|
|
171
|
+
// Nostr addresses a pair by its derived keys, not by the relay inbox topics.
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public declarePeers(peers: readonly IMeshPeerRoute[]): void {
|
|
175
|
+
this.enqueue(() => this.derive(peers));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The pairs' keys and kinds for this epoch, and a subscription for the peers' adjacent epochs. */
|
|
179
|
+
private async derive(peers: readonly IMeshPeerRoute[]): Promise<void> {
|
|
180
|
+
if (this.closed) return;
|
|
181
|
+
this.routes = peers;
|
|
182
|
+
const epoch = rendezvousEpoch(this.now());
|
|
183
|
+
const outbound = new Map<string, IOutbound>();
|
|
184
|
+
const inbound = new Map<string, IInbound>();
|
|
185
|
+
for (const route of peers) {
|
|
186
|
+
const secretKey = await route.rendezvous.signingSeed('outbound', epoch, 'signal');
|
|
187
|
+
const kind = ephemeralKindOf(await route.rendezvous.tag('nostr-kind', 'outbound', epoch));
|
|
188
|
+
outbound.set(route.outbound, { route, epoch, secretKey, kind });
|
|
189
|
+
for (const e of [epoch - 1, epoch, epoch + 1]) {
|
|
190
|
+
const seed = await route.rendezvous.signingSeed('inbound', e, 'signal');
|
|
191
|
+
const peerKind = ephemeralKindOf(await route.rendezvous.tag('nostr-kind', 'inbound', e));
|
|
192
|
+
inbound.set(getPublicKey(seed), { route, epoch: e, kind: peerKind });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
this.outbound = outbound;
|
|
197
|
+
this.inbound = inbound;
|
|
198
|
+
this.epoch = epoch;
|
|
199
|
+
this.subscription?.close();
|
|
200
|
+
this.subscription =
|
|
201
|
+
inbound.size === 0
|
|
202
|
+
? undefined
|
|
203
|
+
: this.options.pool.subscribe(
|
|
204
|
+
{
|
|
205
|
+
kinds: [...new Set([...inbound.values()].map((i) => i.kind))],
|
|
206
|
+
authors: [...inbound.keys()],
|
|
207
|
+
since:
|
|
208
|
+
Math.floor(this.now() / 1000) -
|
|
209
|
+
(this.options.maxEventAgeS ?? DEFAULT_MAX_EVENT_AGE_S),
|
|
210
|
+
},
|
|
211
|
+
(event) => this.enqueue(() => this.receive(event)),
|
|
212
|
+
);
|
|
213
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
214
|
+
this.timer = setTimeout(
|
|
215
|
+
() => this.declarePeers(this.routes),
|
|
216
|
+
Math.max(0, (epoch + 1) * RENDEZVOUS_EPOCH_MS - this.now()),
|
|
217
|
+
);
|
|
218
|
+
this.timer.unref?.();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public send(topic: string, data: unknown): void {
|
|
222
|
+
if (this.closed) return;
|
|
223
|
+
this.enqueue(async () => {
|
|
224
|
+
if (this.closed) return;
|
|
225
|
+
// A send that outlived its epoch waits for the new keys.
|
|
226
|
+
if (this.epoch !== undefined && rendezvousEpoch(this.now()) !== this.epoch) {
|
|
227
|
+
await this.derive(this.routes);
|
|
228
|
+
}
|
|
229
|
+
const out = this.outbound.get(topic);
|
|
230
|
+
if (out === undefined) {
|
|
231
|
+
for (const handler of this.absents) handler(topic);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const json = new TextEncoder().encode(JSON.stringify(data)).length;
|
|
235
|
+
if (json > MAX_SIGNAL_BYTES) throw new Error('signal too large for a Nostr event');
|
|
236
|
+
const padded = padJson(data, Math.ceil(json / NOSTR_SIGNAL_PAD_STEP) * NOSTR_SIGNAL_PAD_STEP);
|
|
237
|
+
const sealed = await out.route.rendezvous.sealRecord(padded, out.epoch, 'signal');
|
|
238
|
+
const event = finalizeEvent(
|
|
239
|
+
{
|
|
240
|
+
kind: out.kind,
|
|
241
|
+
created_at: Math.floor(this.now() / 1000),
|
|
242
|
+
tags: [],
|
|
243
|
+
content: toBase64(sealed),
|
|
244
|
+
},
|
|
245
|
+
out.secretKey,
|
|
246
|
+
);
|
|
247
|
+
if (!(await this.options.pool.publish(event))) {
|
|
248
|
+
throw new Error('no Nostr relay took the signal');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async receive(raw: unknown): Promise<void> {
|
|
254
|
+
if (this.closed) return;
|
|
255
|
+
const event = readEvent(raw);
|
|
256
|
+
if (event === undefined || this.seen.has(event.id)) return;
|
|
257
|
+
const from = this.inbound.get(event.pubkey);
|
|
258
|
+
if (from === undefined || event.kind !== from.kind) return;
|
|
259
|
+
const ageS = Math.abs(Math.floor(this.now() / 1000) - event.created_at);
|
|
260
|
+
if (ageS > (this.options.maxEventAgeS ?? DEFAULT_MAX_EVENT_AGE_S)) return;
|
|
261
|
+
if (!verifyEvent(event)) return;
|
|
262
|
+
this.remember(event.id);
|
|
263
|
+
const sealed = fromBase64(event.content);
|
|
264
|
+
if (sealed === undefined) return;
|
|
265
|
+
const opened = await from.route.rendezvous.openRecord(sealed, from.epoch, 'signal');
|
|
266
|
+
if (opened?.epoch !== from.epoch) return;
|
|
267
|
+
const data = unpadJson(opened.hints);
|
|
268
|
+
if (data === undefined || this.closed) return;
|
|
269
|
+
for (const handler of this.messages) handler(from.route.inbound, data);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private remember(id: string): void {
|
|
273
|
+
this.seen.add(id);
|
|
274
|
+
if (this.seen.size <= SEEN_IDS) return;
|
|
275
|
+
const oldest = this.seen.values().next().value;
|
|
276
|
+
if (oldest !== undefined) this.seen.delete(oldest);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
public onMessage(handler: (topic: string, data: unknown) => void): () => void {
|
|
280
|
+
this.messages.add(handler);
|
|
281
|
+
return () => this.messages.delete(handler);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public onAbsent(handler: (topic: string) => void): () => void {
|
|
285
|
+
this.absents.add(handler);
|
|
286
|
+
return () => this.absents.delete(handler);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
public close(): void {
|
|
290
|
+
if (this.closed) return;
|
|
291
|
+
this.closed = true;
|
|
292
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
293
|
+
this.subscription?.close();
|
|
294
|
+
this.messages.clear();
|
|
295
|
+
this.absents.clear();
|
|
296
|
+
this.options.pool.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── In-process relays, for tests and loopback ──────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
export interface IInMemoryNostrHub {
|
|
303
|
+
/** A client of every relay of this hub. */
|
|
304
|
+
pool(): INostrRelayPool;
|
|
305
|
+
/** Every event the hub forwarded, as received. */
|
|
306
|
+
readonly events: readonly INostrEvent[];
|
|
307
|
+
/** Deliver `event` as a relay would, to every matching subscription (a hostile relay's injection). */
|
|
308
|
+
inject(event: unknown): void;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function matches(filter: INostrFilter, event: INostrEvent): boolean {
|
|
312
|
+
return filter.kinds.includes(event.kind) && filter.authors.includes(event.pubkey);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* In-process relays with a relay's rules for ephemeral events: an event whose signature does not
|
|
317
|
+
* verify is refused, and one that passes is forwarded to matching subscriptions and not stored.
|
|
318
|
+
* Deliveries run on a microtask, as a network would never deliver synchronously.
|
|
319
|
+
*/
|
|
320
|
+
export function createInMemoryNostrHub(): IInMemoryNostrHub {
|
|
321
|
+
const subscriptions = new Set<{ filter: INostrFilter; onEvent: (event: unknown) => void }>();
|
|
322
|
+
const events: INostrEvent[] = [];
|
|
323
|
+
const deliver = (event: unknown): void => {
|
|
324
|
+
const copy: unknown = JSON.parse(JSON.stringify(event));
|
|
325
|
+
queueMicrotask(() => {
|
|
326
|
+
for (const sub of [...subscriptions]) {
|
|
327
|
+
if (matches(sub.filter, copy as INostrEvent)) sub.onEvent(copy);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
pool: () => {
|
|
333
|
+
const mine = new Set<{ filter: INostrFilter; onEvent: (event: unknown) => void }>();
|
|
334
|
+
return {
|
|
335
|
+
publish: (event) => {
|
|
336
|
+
const read = readEvent(event);
|
|
337
|
+
if (read === undefined || !verifyEvent(read)) return Promise.resolve(false);
|
|
338
|
+
events.push(JSON.parse(JSON.stringify(event)) as INostrEvent);
|
|
339
|
+
deliver(event);
|
|
340
|
+
return Promise.resolve(true);
|
|
341
|
+
},
|
|
342
|
+
subscribe: (filter, onEvent) => {
|
|
343
|
+
const sub = { filter, onEvent };
|
|
344
|
+
subscriptions.add(sub);
|
|
345
|
+
mine.add(sub);
|
|
346
|
+
return {
|
|
347
|
+
close: () => {
|
|
348
|
+
subscriptions.delete(sub);
|
|
349
|
+
mine.delete(sub);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
close: () => {
|
|
354
|
+
for (const sub of mine) subscriptions.delete(sub);
|
|
355
|
+
mine.clear();
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
events,
|
|
360
|
+
inject: (event) => deliver(event),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Public relays, through nostr-tools ─────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A pool over `relays` (`wss://…`, several operators) with `nostr-tools`, connecting on first use.
|
|
368
|
+
* The relays see one-time keys, rotating kinds and ciphertext; they are trusted with nothing.
|
|
369
|
+
*/
|
|
370
|
+
export function createNostrRelayPool(relays: readonly string[]): INostrRelayPool {
|
|
371
|
+
if (relays.length === 0) throw new Error('a Nostr relay pool needs at least one relay');
|
|
372
|
+
const pool = new SimplePool();
|
|
373
|
+
const urls = [...relays];
|
|
374
|
+
return {
|
|
375
|
+
publish: async (event) => {
|
|
376
|
+
const results = await Promise.allSettled(pool.publish(urls, event as Event));
|
|
377
|
+
return results.some((r) => r.status === 'fulfilled');
|
|
378
|
+
},
|
|
379
|
+
subscribe: (filter, onEvent) =>
|
|
380
|
+
pool.subscribeMany(
|
|
381
|
+
urls,
|
|
382
|
+
{
|
|
383
|
+
kinds: [...filter.kinds],
|
|
384
|
+
authors: [...filter.authors],
|
|
385
|
+
...(filter.since !== undefined ? { since: filter.since } : {}),
|
|
386
|
+
},
|
|
387
|
+
{ onevent: (event) => onEvent(event) },
|
|
388
|
+
),
|
|
389
|
+
close: () => pool.destroy(),
|
|
390
|
+
};
|
|
391
|
+
}
|