cairn-p2p 0.2.0
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/README.md +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// Node class — cairn peer node (spec section 3.1)
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CairnConfig,
|
|
5
|
+
NatType,
|
|
6
|
+
ConnectionState,
|
|
7
|
+
ReconnectionPolicy,
|
|
8
|
+
TransportType,
|
|
9
|
+
TurnServerConfig,
|
|
10
|
+
StorageBackend,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_RECONNECTION_POLICY,
|
|
14
|
+
DEFAULT_MESH_SETTINGS,
|
|
15
|
+
DEFAULT_STUN_SERVERS,
|
|
16
|
+
DEFAULT_TRANSPORT_PREFERENCES,
|
|
17
|
+
} from './config.js';
|
|
18
|
+
import { CairnError } from './errors.js';
|
|
19
|
+
import { IdentityKeypair } from './crypto/identity.js';
|
|
20
|
+
import { NoiseXXHandshake } from './crypto/noise.js';
|
|
21
|
+
import type { HandshakeResult } from './crypto/noise.js';
|
|
22
|
+
import { DoubleRatchet } from './crypto/double-ratchet.js';
|
|
23
|
+
import type { RatchetHeader } from './crypto/double-ratchet.js';
|
|
24
|
+
import { X25519Keypair } from './crypto/exchange.js';
|
|
25
|
+
import { Spake2 } from './crypto/spake2.js';
|
|
26
|
+
import { generateQrPayload, consumeQrPayload } from './pairing/qr.js';
|
|
27
|
+
import { generatePin, formatPin, normalizePin, validatePin } from './pairing/pin.js';
|
|
28
|
+
import { generatePairingLink, parsePairingLink } from './pairing/link.js';
|
|
29
|
+
import type { PairingPayload } from './pairing/payload.js';
|
|
30
|
+
import { generateNonce } from './pairing/payload.js';
|
|
31
|
+
import { encodeEnvelope, decodeEnvelope, newMsgId } from './protocol/envelope.js';
|
|
32
|
+
import type { MessageEnvelope } from './protocol/envelope.js';
|
|
33
|
+
import { DATA_MESSAGE } from './protocol/message-types.js';
|
|
34
|
+
import { MessageQueue } from './session/message-queue.js';
|
|
35
|
+
import type { EnqueueResult } from './session/message-queue.js';
|
|
36
|
+
|
|
37
|
+
/** Fully resolved configuration with no optional fields. */
|
|
38
|
+
export interface ResolvedConfig {
|
|
39
|
+
stunServers: string[];
|
|
40
|
+
turnServers: TurnServerConfig[];
|
|
41
|
+
signalingServers: string[];
|
|
42
|
+
trackerUrls: string[];
|
|
43
|
+
bootstrapNodes: string[];
|
|
44
|
+
transportPreferences: TransportType[];
|
|
45
|
+
reconnectionPolicy: ReconnectionPolicy;
|
|
46
|
+
meshSettings: Required<import('./config.js').MeshSettings>;
|
|
47
|
+
storageBackend: StorageBackend;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Event types ---
|
|
51
|
+
|
|
52
|
+
/** Events emitted by a Node. */
|
|
53
|
+
export interface NodeEvents {
|
|
54
|
+
peer_paired: (peerId: string) => void;
|
|
55
|
+
peer_unpaired: (peerId: string) => void;
|
|
56
|
+
error: (error: CairnError) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Events emitted by a NodeSession. */
|
|
60
|
+
export interface SessionEvents {
|
|
61
|
+
state_changed: (prev: ConnectionState, current: ConnectionState) => void;
|
|
62
|
+
channel_opened: (channel: NodeChannel) => void;
|
|
63
|
+
error: (error: CairnError) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Pairing data types ---
|
|
67
|
+
|
|
68
|
+
export interface QrPairingData {
|
|
69
|
+
payload: Uint8Array;
|
|
70
|
+
expiresIn: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PinPairingData {
|
|
74
|
+
pin: string;
|
|
75
|
+
expiresIn: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LinkPairingData {
|
|
79
|
+
uri: string;
|
|
80
|
+
expiresIn: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Application message type range ---
|
|
84
|
+
|
|
85
|
+
const APP_MSG_TYPE_MIN = 0xf000;
|
|
86
|
+
const APP_MSG_TYPE_MAX = 0xffff;
|
|
87
|
+
|
|
88
|
+
// --- NodeChannel ---
|
|
89
|
+
|
|
90
|
+
/** A bidirectional data channel within a session. */
|
|
91
|
+
export class NodeChannel {
|
|
92
|
+
private _open = true;
|
|
93
|
+
|
|
94
|
+
constructor(readonly name: string) {}
|
|
95
|
+
|
|
96
|
+
get isOpen(): boolean {
|
|
97
|
+
return this._open;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close(): void {
|
|
101
|
+
this._open = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- NodeSession ---
|
|
106
|
+
|
|
107
|
+
/** A session with a paired peer (spec section 3.2). */
|
|
108
|
+
export class NodeSession {
|
|
109
|
+
private _state: ConnectionState = 'connected';
|
|
110
|
+
private readonly _channels = new Map<string, NodeChannel>();
|
|
111
|
+
private readonly _stateListeners: Array<(prev: ConnectionState, current: ConnectionState) => void> = [];
|
|
112
|
+
private readonly _channelListeners: Array<(channel: NodeChannel) => void> = [];
|
|
113
|
+
private readonly _errorListeners: Array<(error: CairnError) => void> = [];
|
|
114
|
+
private readonly _messageHandlers = new Map<string, Array<(data: Uint8Array) => void>>();
|
|
115
|
+
private readonly _customHandlers = new Map<number, (data: Uint8Array) => void>();
|
|
116
|
+
private _ratchet: DoubleRatchet | null = null;
|
|
117
|
+
private readonly _messageQueue = new MessageQueue();
|
|
118
|
+
private _sequenceCounter = 0;
|
|
119
|
+
/** Outbox of encoded envelopes (transport would drain this). */
|
|
120
|
+
readonly outbox: Uint8Array[] = [];
|
|
121
|
+
|
|
122
|
+
constructor(readonly peerId: string) {}
|
|
123
|
+
|
|
124
|
+
/** Get the current connection state. */
|
|
125
|
+
get state(): ConnectionState {
|
|
126
|
+
return this._state;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get the Double Ratchet (for testing/inspection). */
|
|
130
|
+
get ratchet(): DoubleRatchet | null {
|
|
131
|
+
return this._ratchet;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Get the message queue. */
|
|
135
|
+
get messageQueue(): MessageQueue {
|
|
136
|
+
return this._messageQueue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Set the ratchet for this session (called during connect). */
|
|
140
|
+
_setRatchet(ratchet: DoubleRatchet): void {
|
|
141
|
+
this._ratchet = ratchet;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Open a named channel. */
|
|
145
|
+
openChannel(name: string): NodeChannel {
|
|
146
|
+
if (!name) {
|
|
147
|
+
throw new CairnError('PROTOCOL', 'channel name cannot be empty');
|
|
148
|
+
}
|
|
149
|
+
if (name.startsWith('__cairn_')) {
|
|
150
|
+
throw new CairnError('PROTOCOL', 'reserved channel name prefix');
|
|
151
|
+
}
|
|
152
|
+
const channel = new NodeChannel(name);
|
|
153
|
+
this._channels.set(name, channel);
|
|
154
|
+
for (const listener of this._channelListeners) {
|
|
155
|
+
listener(channel);
|
|
156
|
+
}
|
|
157
|
+
return channel;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Send data on a channel. Encrypts via Double Ratchet and wraps in CBOR envelope. */
|
|
161
|
+
send(channel: NodeChannel, data: Uint8Array): void {
|
|
162
|
+
if (!channel.isOpen) {
|
|
163
|
+
throw new CairnError('PROTOCOL', 'channel is not open');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// If disconnected, queue for retransmission
|
|
167
|
+
if (this._state === 'disconnected' || this._state === 'reconnecting' || this._state === 'suspended') {
|
|
168
|
+
const seq = this._sequenceCounter++;
|
|
169
|
+
const result: EnqueueResult = this._messageQueue.enqueue(seq, data);
|
|
170
|
+
if (result === 'full') {
|
|
171
|
+
throw new CairnError('PROTOCOL', 'message queue is full');
|
|
172
|
+
}
|
|
173
|
+
if (result === 'disabled') {
|
|
174
|
+
throw new CairnError('PROTOCOL', 'message queuing is disabled');
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Encrypt with Double Ratchet if available
|
|
180
|
+
let payload: Uint8Array;
|
|
181
|
+
if (this._ratchet) {
|
|
182
|
+
const { header, ciphertext } = this._ratchet.encrypt(data);
|
|
183
|
+
const headerJson = new TextEncoder().encode(JSON.stringify({
|
|
184
|
+
dh_public: Array.from(header.dhPublic),
|
|
185
|
+
prev_chain_len: header.prevChainLen,
|
|
186
|
+
msg_num: header.msgNum,
|
|
187
|
+
}));
|
|
188
|
+
// Format: [4-byte header len][header json][ciphertext]
|
|
189
|
+
const buf = new Uint8Array(4 + headerJson.length + ciphertext.length);
|
|
190
|
+
const view = new DataView(buf.buffer);
|
|
191
|
+
view.setUint32(0, headerJson.length);
|
|
192
|
+
buf.set(headerJson, 4);
|
|
193
|
+
buf.set(ciphertext, 4 + headerJson.length);
|
|
194
|
+
payload = buf;
|
|
195
|
+
} else {
|
|
196
|
+
payload = data;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Wrap in CBOR MessageEnvelope
|
|
200
|
+
const envelope: MessageEnvelope = {
|
|
201
|
+
version: 1,
|
|
202
|
+
type: DATA_MESSAGE,
|
|
203
|
+
msgId: newMsgId(),
|
|
204
|
+
payload,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const encoded = encodeEnvelope(envelope);
|
|
208
|
+
this.outbox.push(encoded);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Register a callback for incoming messages on a channel. */
|
|
212
|
+
onMessage(channel: NodeChannel, callback: (data: Uint8Array) => void): void {
|
|
213
|
+
const handlers = this._messageHandlers.get(channel.name);
|
|
214
|
+
if (handlers) {
|
|
215
|
+
handlers.push(callback);
|
|
216
|
+
} else {
|
|
217
|
+
this._messageHandlers.set(channel.name, [callback]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Register a callback for connection state changes. */
|
|
222
|
+
onStateChange(callback: (prev: ConnectionState, current: ConnectionState) => void): void {
|
|
223
|
+
this._stateListeners.push(callback);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Register a callback for channel opened events. */
|
|
227
|
+
onChannelOpened(callback: (channel: NodeChannel) => void): void {
|
|
228
|
+
this._channelListeners.push(callback);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Register a callback for errors. */
|
|
232
|
+
onError(callback: (error: CairnError) => void): void {
|
|
233
|
+
this._errorListeners.push(callback);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Register a handler for application-specific message types (0xF000-0xFFFF).
|
|
238
|
+
*/
|
|
239
|
+
onCustomMessage(typeCode: number, callback: (data: Uint8Array) => void): void {
|
|
240
|
+
if (typeCode < APP_MSG_TYPE_MIN || typeCode > APP_MSG_TYPE_MAX) {
|
|
241
|
+
throw new CairnError(
|
|
242
|
+
'PROTOCOL',
|
|
243
|
+
`custom message type 0x${typeCode.toString(16).padStart(4, '0')} outside application range 0xF000-0xFFFF`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
this._customHandlers.set(typeCode, callback);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Dispatch an incoming CBOR envelope from the transport layer.
|
|
251
|
+
* Decrypts if needed and routes to appropriate callbacks.
|
|
252
|
+
*/
|
|
253
|
+
dispatchIncoming(envelopeBytes: Uint8Array): void {
|
|
254
|
+
const envelope = decodeEnvelope(envelopeBytes);
|
|
255
|
+
|
|
256
|
+
if (envelope.type === DATA_MESSAGE) {
|
|
257
|
+
// Decrypt if we have a ratchet (otherwise treat payload as plaintext)
|
|
258
|
+
let plaintext: Uint8Array;
|
|
259
|
+
if (this._ratchet && envelope.payload.length >= 4) {
|
|
260
|
+
const view = new DataView(envelope.payload.buffer, envelope.payload.byteOffset, envelope.payload.byteLength);
|
|
261
|
+
const headerLen = view.getUint32(0);
|
|
262
|
+
if (envelope.payload.length < 4 + headerLen) {
|
|
263
|
+
throw new CairnError('PROTOCOL', 'payload too short for header');
|
|
264
|
+
}
|
|
265
|
+
const headerJson = new TextDecoder().decode(envelope.payload.slice(4, 4 + headerLen));
|
|
266
|
+
const headerObj = JSON.parse(headerJson);
|
|
267
|
+
const header: RatchetHeader = {
|
|
268
|
+
dhPublic: new Uint8Array(headerObj.dh_public),
|
|
269
|
+
prevChainLen: headerObj.prev_chain_len,
|
|
270
|
+
msgNum: headerObj.msg_num,
|
|
271
|
+
};
|
|
272
|
+
const ciphertext = envelope.payload.slice(4 + headerLen);
|
|
273
|
+
plaintext = this._ratchet.decrypt(header, ciphertext);
|
|
274
|
+
} else {
|
|
275
|
+
plaintext = envelope.payload;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Dispatch to all channel message callbacks
|
|
279
|
+
for (const [, cbs] of this._messageHandlers) {
|
|
280
|
+
for (const cb of cbs) {
|
|
281
|
+
cb(plaintext);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else if (envelope.type >= APP_MSG_TYPE_MIN && envelope.type <= APP_MSG_TYPE_MAX) {
|
|
285
|
+
const handler = this._customHandlers.get(envelope.type);
|
|
286
|
+
if (handler) {
|
|
287
|
+
handler(envelope.payload);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// HEARTBEAT, HEARTBEAT_ACK, etc. are no-ops at this layer
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Drain queued messages after reconnection. Returns payloads. */
|
|
294
|
+
drainMessageQueue(): Uint8Array[] {
|
|
295
|
+
return this._messageQueue.drain().map(m => m.payload);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Close this session. */
|
|
299
|
+
close(): void {
|
|
300
|
+
const prev = this._state;
|
|
301
|
+
this._state = 'disconnected';
|
|
302
|
+
for (const listener of this._stateListeners) {
|
|
303
|
+
listener(prev, 'disconnected');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Transition state (for internal/test use). */
|
|
308
|
+
_transitionState(to: ConnectionState): void {
|
|
309
|
+
const prev = this._state;
|
|
310
|
+
this._state = to;
|
|
311
|
+
for (const listener of this._stateListeners) {
|
|
312
|
+
listener(prev, to);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Node ---
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A cairn node — the primary public API entry point.
|
|
321
|
+
*
|
|
322
|
+
* Wraps configuration, identity, and internal state. Provides methods for
|
|
323
|
+
* pairing, connecting, and managing sessions with paired peers.
|
|
324
|
+
*/
|
|
325
|
+
export class Node {
|
|
326
|
+
private readonly _config: ResolvedConfig;
|
|
327
|
+
private readonly _sessions = new Map<string, NodeSession>();
|
|
328
|
+
private readonly _peerPairedListeners: Array<(peerId: string) => void> = [];
|
|
329
|
+
private readonly _peerUnpairedListeners: Array<(peerId: string) => void> = [];
|
|
330
|
+
private readonly _errorListeners: Array<(error: CairnError) => void> = [];
|
|
331
|
+
private readonly _customRegistry = new Map<number, (peerId: string, data: Uint8Array) => void>();
|
|
332
|
+
private _natType: NatType = 'unknown';
|
|
333
|
+
private _closed = false;
|
|
334
|
+
private _identity: IdentityKeypair | null = null;
|
|
335
|
+
private readonly _pairedPeers = new Set<string>();
|
|
336
|
+
|
|
337
|
+
private constructor(config: ResolvedConfig) {
|
|
338
|
+
this._config = config;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Create a new cairn peer node with zero-config defaults (Tier 0). */
|
|
342
|
+
static async create(config?: Partial<CairnConfig>): Promise<Node> {
|
|
343
|
+
const resolved = resolveConfig(config);
|
|
344
|
+
const node = new Node(resolved);
|
|
345
|
+
node._identity = await IdentityKeypair.generate();
|
|
346
|
+
return node;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Create a server-mode cairn node.
|
|
351
|
+
*
|
|
352
|
+
* Server mode is NOT a separate class — it applies server-mode defaults:
|
|
353
|
+
* meshEnabled: true, relayWilling: true, relayCapacity: 100, etc.
|
|
354
|
+
*/
|
|
355
|
+
static async createServer(config?: Partial<CairnConfig>): Promise<Node> {
|
|
356
|
+
const serverMeshDefaults = {
|
|
357
|
+
meshEnabled: true,
|
|
358
|
+
relayWilling: true,
|
|
359
|
+
relayCapacity: 100,
|
|
360
|
+
maxHops: 3,
|
|
361
|
+
};
|
|
362
|
+
const serverReconnectionDefaults: Partial<import('./config.js').ReconnectionPolicy> = {
|
|
363
|
+
...DEFAULT_RECONNECTION_POLICY,
|
|
364
|
+
sessionExpiry: 7 * 24 * 60 * 60 * 1000,
|
|
365
|
+
rendezvousPollInterval: 30_000,
|
|
366
|
+
reconnectMaxDuration: Infinity,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const merged: Partial<CairnConfig> = {
|
|
370
|
+
...config,
|
|
371
|
+
meshSettings: { ...serverMeshDefaults, ...config?.meshSettings },
|
|
372
|
+
reconnectionPolicy: { ...serverReconnectionDefaults, ...config?.reconnectionPolicy },
|
|
373
|
+
};
|
|
374
|
+
const resolved = resolveConfig(merged);
|
|
375
|
+
const node = new Node(resolved);
|
|
376
|
+
node._identity = await IdentityKeypair.generate();
|
|
377
|
+
return node;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Get the node configuration. */
|
|
381
|
+
get config(): ResolvedConfig {
|
|
382
|
+
return this._config;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether this node has been closed. */
|
|
386
|
+
get isClosed(): boolean {
|
|
387
|
+
return this._closed;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Get the node's identity keypair. */
|
|
391
|
+
get identity(): IdentityKeypair | null {
|
|
392
|
+
return this._identity;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Get the node's peer ID. */
|
|
396
|
+
get peerId(): Uint8Array | null {
|
|
397
|
+
return this._identity?.peerId() ?? null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- Event listeners ---
|
|
401
|
+
|
|
402
|
+
onPeerPaired(callback: (peerId: string) => void): void {
|
|
403
|
+
this._peerPairedListeners.push(callback);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
onPeerUnpaired(callback: (peerId: string) => void): void {
|
|
407
|
+
this._peerUnpairedListeners.push(callback);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
onError(callback: (error: CairnError) => void): void {
|
|
411
|
+
this._errorListeners.push(callback);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register a node-wide handler for a custom message type (0xF000-0xFFFF).
|
|
416
|
+
*
|
|
417
|
+
* Node-level handlers are invoked when a custom message arrives on any session
|
|
418
|
+
* that does not have a per-session handler for the type code.
|
|
419
|
+
*/
|
|
420
|
+
registerCustomMessage(typeCode: number, handler: (peerId: string, data: Uint8Array) => void): void {
|
|
421
|
+
if (typeCode < APP_MSG_TYPE_MIN || typeCode > APP_MSG_TYPE_MAX) {
|
|
422
|
+
throw new CairnError(
|
|
423
|
+
'PROTOCOL',
|
|
424
|
+
`custom message type 0x${typeCode.toString(16).padStart(4, '0')} outside application range 0xF000-0xFFFF`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
this._customRegistry.set(typeCode, handler);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- Internal helpers ---
|
|
431
|
+
|
|
432
|
+
private _createPairingPayload(): PairingPayload {
|
|
433
|
+
if (!this._identity) {
|
|
434
|
+
throw new CairnError('PROTOCOL', 'node identity not initialized');
|
|
435
|
+
}
|
|
436
|
+
const nonce = generateNonce();
|
|
437
|
+
const now = Math.floor(Date.now() / 1000);
|
|
438
|
+
const ttlSec = Math.floor(this._config.reconnectionPolicy.pairingPayloadExpiry / 1000);
|
|
439
|
+
return {
|
|
440
|
+
peerId: this._identity.peerId(),
|
|
441
|
+
nonce,
|
|
442
|
+
pakeCredential: nonce, // use nonce as PAKE credential (same as Rust)
|
|
443
|
+
createdAt: now,
|
|
444
|
+
expiresAt: now + ttlSec,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private _runPairingExchange(password: Uint8Array): void {
|
|
449
|
+
const alice = Spake2.startA(password);
|
|
450
|
+
const bob = Spake2.startB(password);
|
|
451
|
+
alice.finish(bob.outboundMsg);
|
|
452
|
+
bob.finish(alice.outboundMsg);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private _completePairing(remotePeerId: string): void {
|
|
456
|
+
this._pairedPeers.add(remotePeerId);
|
|
457
|
+
for (const listener of this._peerPairedListeners) {
|
|
458
|
+
listener(remotePeerId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async _performNoiseHandshake(): Promise<HandshakeResult> {
|
|
463
|
+
if (!this._identity) {
|
|
464
|
+
throw new CairnError('PROTOCOL', 'node identity not initialized');
|
|
465
|
+
}
|
|
466
|
+
const remoteId = await IdentityKeypair.generate();
|
|
467
|
+
const initiator = new NoiseXXHandshake('initiator', this._identity);
|
|
468
|
+
const responder = new NoiseXXHandshake('responder', remoteId);
|
|
469
|
+
|
|
470
|
+
const out1 = initiator.step();
|
|
471
|
+
if (out1.type !== 'send_message') throw new CairnError('CRYPTO', 'unexpected at msg1');
|
|
472
|
+
|
|
473
|
+
const out2 = responder.step(out1.data);
|
|
474
|
+
if (out2.type !== 'send_message') throw new CairnError('CRYPTO', 'unexpected at msg2');
|
|
475
|
+
|
|
476
|
+
const out3 = initiator.step(out2.data);
|
|
477
|
+
if (out3.type !== 'send_message') throw new CairnError('CRYPTO', 'unexpected at msg3');
|
|
478
|
+
|
|
479
|
+
responder.step(out3.data);
|
|
480
|
+
return initiator.getResult();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --- Pairing methods (spec section 3.3) ---
|
|
484
|
+
|
|
485
|
+
async pairGenerateQr(): Promise<QrPairingData> {
|
|
486
|
+
const payload = this._createPairingPayload();
|
|
487
|
+
const cbor = generateQrPayload(payload);
|
|
488
|
+
return {
|
|
489
|
+
payload: cbor,
|
|
490
|
+
expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async pairScanQr(data: Uint8Array): Promise<string> {
|
|
495
|
+
const payload = consumeQrPayload(data);
|
|
496
|
+
this._runPairingExchange(payload.pakeCredential);
|
|
497
|
+
const remotePeerId = bytesToHex(payload.peerId);
|
|
498
|
+
this._completePairing(remotePeerId);
|
|
499
|
+
return remotePeerId;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async pairGeneratePin(): Promise<PinPairingData> {
|
|
503
|
+
const raw = generatePin();
|
|
504
|
+
return {
|
|
505
|
+
pin: formatPin(raw),
|
|
506
|
+
expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async pairEnterPin(pin: string): Promise<string> {
|
|
511
|
+
const normalized = normalizePin(pin);
|
|
512
|
+
validatePin(normalized);
|
|
513
|
+
const password = new TextEncoder().encode(normalized);
|
|
514
|
+
this._runPairingExchange(password);
|
|
515
|
+
// In a real implementation, the remote peer ID would come from the pairing exchange
|
|
516
|
+
const remotePeerId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
517
|
+
this._completePairing(remotePeerId);
|
|
518
|
+
return remotePeerId;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async pairGenerateLink(): Promise<LinkPairingData> {
|
|
522
|
+
const payload = this._createPairingPayload();
|
|
523
|
+
const uri = generatePairingLink(payload);
|
|
524
|
+
return {
|
|
525
|
+
uri,
|
|
526
|
+
expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async pairFromLink(uri: string): Promise<string> {
|
|
531
|
+
const payload = parsePairingLink(uri);
|
|
532
|
+
this._runPairingExchange(payload.pakeCredential);
|
|
533
|
+
const remotePeerId = bytesToHex(payload.peerId);
|
|
534
|
+
this._completePairing(remotePeerId);
|
|
535
|
+
return remotePeerId;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Connection methods ---
|
|
539
|
+
|
|
540
|
+
/** Connect to a paired peer. Performs Noise XX handshake and Double Ratchet init. */
|
|
541
|
+
async connect(peerId: string, _options?: { signal?: AbortSignal }): Promise<NodeSession> {
|
|
542
|
+
const handshakeResult = await this._performNoiseHandshake();
|
|
543
|
+
const bobDh = X25519Keypair.generate();
|
|
544
|
+
const ratchet = DoubleRatchet.initSender(handshakeResult.sessionKey, bobDh.publicKeyBytes());
|
|
545
|
+
const session = new NodeSession(peerId);
|
|
546
|
+
session._setRatchet(ratchet);
|
|
547
|
+
this._sessions.set(peerId, session);
|
|
548
|
+
return session;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Unpair a peer, removing trust and closing sessions. */
|
|
552
|
+
async unpair(peerId: string): Promise<void> {
|
|
553
|
+
this._pairedPeers.delete(peerId);
|
|
554
|
+
this._sessions.delete(peerId);
|
|
555
|
+
for (const listener of this._peerUnpairedListeners) {
|
|
556
|
+
listener(peerId);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Get network diagnostic information. */
|
|
561
|
+
async networkInfo(): Promise<{ natType: NatType }> {
|
|
562
|
+
return { natType: this._natType };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Update the detected NAT type (called by transport layer). */
|
|
566
|
+
setNatType(natType: NatType): void {
|
|
567
|
+
this._natType = natType;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Close the node and all sessions. */
|
|
571
|
+
async close(): Promise<void> {
|
|
572
|
+
this._closed = true;
|
|
573
|
+
for (const session of this._sessions.values()) {
|
|
574
|
+
session.close();
|
|
575
|
+
}
|
|
576
|
+
this._sessions.clear();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// --- Config resolution ---
|
|
581
|
+
|
|
582
|
+
function resolveConfig(partial?: Partial<CairnConfig>): ResolvedConfig {
|
|
583
|
+
return {
|
|
584
|
+
stunServers: partial?.stunServers ?? [...DEFAULT_STUN_SERVERS],
|
|
585
|
+
turnServers: partial?.turnServers ?? [],
|
|
586
|
+
signalingServers: partial?.signalingServers ?? [],
|
|
587
|
+
trackerUrls: partial?.trackerUrls ?? [],
|
|
588
|
+
bootstrapNodes: partial?.bootstrapNodes ?? [],
|
|
589
|
+
transportPreferences: partial?.transportPreferences ?? [...DEFAULT_TRANSPORT_PREFERENCES],
|
|
590
|
+
reconnectionPolicy: {
|
|
591
|
+
connectTimeout: DEFAULT_RECONNECTION_POLICY.connectTimeout,
|
|
592
|
+
transportTimeout: DEFAULT_RECONNECTION_POLICY.transportTimeout,
|
|
593
|
+
reconnectMaxDuration: DEFAULT_RECONNECTION_POLICY.reconnectMaxDuration,
|
|
594
|
+
reconnectBackoff: {
|
|
595
|
+
...DEFAULT_RECONNECTION_POLICY.reconnectBackoff,
|
|
596
|
+
...partial?.reconnectionPolicy?.reconnectBackoff,
|
|
597
|
+
},
|
|
598
|
+
rendezvousPollInterval: DEFAULT_RECONNECTION_POLICY.rendezvousPollInterval,
|
|
599
|
+
sessionExpiry: DEFAULT_RECONNECTION_POLICY.sessionExpiry,
|
|
600
|
+
pairingPayloadExpiry: DEFAULT_RECONNECTION_POLICY.pairingPayloadExpiry,
|
|
601
|
+
...partial?.reconnectionPolicy,
|
|
602
|
+
},
|
|
603
|
+
meshSettings: {
|
|
604
|
+
meshEnabled: DEFAULT_MESH_SETTINGS.meshEnabled,
|
|
605
|
+
maxHops: DEFAULT_MESH_SETTINGS.maxHops,
|
|
606
|
+
relayWilling: DEFAULT_MESH_SETTINGS.relayWilling,
|
|
607
|
+
relayCapacity: DEFAULT_MESH_SETTINGS.relayCapacity,
|
|
608
|
+
...partial?.meshSettings,
|
|
609
|
+
},
|
|
610
|
+
storageBackend: partial?.storageBackend ?? 'memory',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Convert bytes to hex string. */
|
|
615
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
616
|
+
return Array.from(bytes)
|
|
617
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
618
|
+
.join('');
|
|
619
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom pairing adapter interface for domain-specific pairing flows.
|
|
3
|
+
*
|
|
4
|
+
* Applications implement this interface for custom transports:
|
|
5
|
+
* NFC tap, Bluetooth LE OOB, email-based verification, hardware token, etc.
|
|
6
|
+
*/
|
|
7
|
+
export interface PairingMechanismAdapter {
|
|
8
|
+
/** Human-readable name of this mechanism (e.g., "nfc", "bluetooth-le"). */
|
|
9
|
+
name: string;
|
|
10
|
+
|
|
11
|
+
/** Create the pairing payload in the application's chosen format/transport. */
|
|
12
|
+
generatePayload(data: Uint8Array): Promise<Uint8Array>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse and validate a received pairing payload from the custom transport.
|
|
16
|
+
* Returns the PAKE credential and optional connection hints.
|
|
17
|
+
*/
|
|
18
|
+
consumePayload(data: Uint8Array): Promise<{
|
|
19
|
+
pakeCredential: Uint8Array;
|
|
20
|
+
hints?: string[];
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
/** Derive the SPAKE2 password bytes from the custom payload data. */
|
|
24
|
+
derivePakeInput(data: Uint8Array): Promise<Uint8Array>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wrapper that bridges a PairingMechanismAdapter into the pairing system.
|
|
29
|
+
*/
|
|
30
|
+
export class CustomPairingMechanism {
|
|
31
|
+
constructor(private readonly adapter: PairingMechanismAdapter) {}
|
|
32
|
+
|
|
33
|
+
get name(): string {
|
|
34
|
+
return this.adapter.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async generatePayload(data: Uint8Array): Promise<Uint8Array> {
|
|
38
|
+
return this.adapter.generatePayload(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async consumePayload(data: Uint8Array): Promise<{
|
|
42
|
+
pakeCredential: Uint8Array;
|
|
43
|
+
hints?: string[];
|
|
44
|
+
}> {
|
|
45
|
+
return this.adapter.consumePayload(data);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async derivePakeInput(data: Uint8Array): Promise<Uint8Array> {
|
|
49
|
+
return this.adapter.derivePakeInput(data);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pairing module — mechanisms, state machine (tasks 033-034)
|
|
2
|
+
|
|
3
|
+
export type { PairingPayload, ConnectionHint } from './payload.js';
|
|
4
|
+
export { encodePairingPayload, decodePairingPayload, isPayloadExpired, generateNonce } from './payload.js';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
generatePin,
|
|
8
|
+
formatPin,
|
|
9
|
+
normalizePin,
|
|
10
|
+
validatePin,
|
|
11
|
+
derivePinRendezvousId,
|
|
12
|
+
decodeCrockford,
|
|
13
|
+
} from './pin.js';
|
|
14
|
+
|
|
15
|
+
export { generateQrPayload, consumeQrPayload, QR_DEFAULT_TTL_MS, MAX_QR_PAYLOAD_SIZE } from './qr.js';
|
|
16
|
+
|
|
17
|
+
export { generatePairingLink, parsePairingLink } from './link.js';
|
|
18
|
+
|
|
19
|
+
export { validatePskEntropy, derivePskRendezvousId, pskToPakeInput } from './psk.js';
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
PairingFlowType,
|
|
23
|
+
PairingState,
|
|
24
|
+
PairingRole,
|
|
25
|
+
PairRejectReason,
|
|
26
|
+
PairingMessage,
|
|
27
|
+
} from './state-machine.js';
|
|
28
|
+
export { PairingSession, DEFAULT_PAIRING_TIMEOUT_MS } from './state-machine.js';
|
|
29
|
+
|
|
30
|
+
export type { SasType, SasResult } from './sas-flow.js';
|
|
31
|
+
export { deriveSas, verifySas } from './sas-flow.js';
|
|
32
|
+
|
|
33
|
+
export type { PairingMechanismAdapter } from './adapter.js';
|
|
34
|
+
export { CustomPairingMechanism } from './adapter.js';
|
|
35
|
+
|
|
36
|
+
export type { RateLimitResult } from './rate-limit.js';
|
|
37
|
+
export { PairingRateLimiter } from './rate-limit.js';
|
|
38
|
+
|
|
39
|
+
export type { UnpairingEvent } from './unpairing.js';
|
|
40
|
+
export { unpair, handlePairRevoke } from './unpairing.js';
|