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.
Files changed (76) hide show
  1. package/README.md +43 -0
  2. package/dist/index.cjs +1883 -0
  3. package/dist/index.d.cts +572 -0
  4. package/dist/index.d.ts +572 -0
  5. package/dist/index.js +1827 -0
  6. package/eslint.config.js +24 -0
  7. package/package.json +54 -0
  8. package/src/channel.ts +277 -0
  9. package/src/config.ts +161 -0
  10. package/src/crypto/aead.ts +80 -0
  11. package/src/crypto/double-ratchet.ts +355 -0
  12. package/src/crypto/exchange.ts +51 -0
  13. package/src/crypto/hkdf.ts +33 -0
  14. package/src/crypto/identity.ts +84 -0
  15. package/src/crypto/index.ts +20 -0
  16. package/src/crypto/noise.ts +415 -0
  17. package/src/crypto/sas.ts +36 -0
  18. package/src/crypto/spake2.ts +169 -0
  19. package/src/discovery/index.ts +38 -0
  20. package/src/discovery/manager.ts +138 -0
  21. package/src/discovery/rendezvous.ts +189 -0
  22. package/src/discovery/tracker.ts +251 -0
  23. package/src/errors.ts +166 -0
  24. package/src/index.ts +57 -0
  25. package/src/mesh/index.ts +48 -0
  26. package/src/mesh/relay.ts +100 -0
  27. package/src/mesh/routing-table.ts +196 -0
  28. package/src/node.ts +619 -0
  29. package/src/pairing/adapter.ts +51 -0
  30. package/src/pairing/index.ts +40 -0
  31. package/src/pairing/link.ts +127 -0
  32. package/src/pairing/payload.ts +98 -0
  33. package/src/pairing/pin.ts +115 -0
  34. package/src/pairing/psk.ts +49 -0
  35. package/src/pairing/qr.ts +52 -0
  36. package/src/pairing/rate-limit.ts +134 -0
  37. package/src/pairing/sas-flow.ts +45 -0
  38. package/src/pairing/state-machine.ts +438 -0
  39. package/src/pairing/unpairing.ts +50 -0
  40. package/src/protocol/custom-handler.ts +52 -0
  41. package/src/protocol/envelope.ts +138 -0
  42. package/src/protocol/index.ts +36 -0
  43. package/src/protocol/message-types.ts +74 -0
  44. package/src/protocol/version.ts +98 -0
  45. package/src/server/index.ts +67 -0
  46. package/src/server/management.ts +285 -0
  47. package/src/server/store-forward.ts +266 -0
  48. package/src/session/backoff.ts +58 -0
  49. package/src/session/heartbeat.ts +79 -0
  50. package/src/session/index.ts +26 -0
  51. package/src/session/message-queue.ts +133 -0
  52. package/src/session/network-monitor.ts +130 -0
  53. package/src/session/state-machine.ts +122 -0
  54. package/src/session.ts +223 -0
  55. package/src/transport/fallback.ts +475 -0
  56. package/src/transport/index.ts +46 -0
  57. package/src/transport/libp2p-node.ts +158 -0
  58. package/src/transport/nat.ts +348 -0
  59. package/tests/conformance/cbor-vectors.test.ts +250 -0
  60. package/tests/integration/pairing-session.test.ts +317 -0
  61. package/tests/unit/config-api.test.ts +310 -0
  62. package/tests/unit/crypto.test.ts +407 -0
  63. package/tests/unit/discovery.test.ts +618 -0
  64. package/tests/unit/double-ratchet.test.ts +185 -0
  65. package/tests/unit/mesh.test.ts +349 -0
  66. package/tests/unit/noise.test.ts +346 -0
  67. package/tests/unit/pairing-extras.test.ts +402 -0
  68. package/tests/unit/pairing.test.ts +572 -0
  69. package/tests/unit/protocol.test.ts +438 -0
  70. package/tests/unit/reconnection.test.ts +402 -0
  71. package/tests/unit/scaffolding.test.ts +142 -0
  72. package/tests/unit/server.test.ts +492 -0
  73. package/tests/unit/sessions.test.ts +595 -0
  74. package/tests/unit/transport.test.ts +604 -0
  75. package/tsconfig.json +20 -0
  76. package/vitest.config.ts +15 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,1883 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ AuthenticationFailedError: () => AuthenticationFailedError,
34
+ CairnError: () => CairnError,
35
+ DEFAULT_MESH_SETTINGS: () => DEFAULT_MESH_SETTINGS,
36
+ DEFAULT_RECONNECTION_POLICY: () => DEFAULT_RECONNECTION_POLICY,
37
+ DEFAULT_STUN_SERVERS: () => DEFAULT_STUN_SERVERS,
38
+ DEFAULT_TRANSPORT_PREFERENCES: () => DEFAULT_TRANSPORT_PREFERENCES,
39
+ ErrorBehavior: () => ErrorBehavior,
40
+ MeshRouteNotFoundError: () => MeshRouteNotFoundError,
41
+ Node: () => Node,
42
+ NodeChannel: () => NodeChannel,
43
+ NodeSession: () => NodeSession,
44
+ PairingExpiredError: () => PairingExpiredError,
45
+ PairingRejectedError: () => PairingRejectedError,
46
+ PeerUnreachableError: () => PeerUnreachableError,
47
+ SessionExpiredError: () => SessionExpiredError,
48
+ SessionStateMachine: () => SessionStateMachine,
49
+ TransportExhaustedError: () => TransportExhaustedError,
50
+ VersionMismatchError: () => VersionMismatchError,
51
+ defaultServerConfig: () => defaultServerConfig,
52
+ isValidTransition: () => isValidTransition
53
+ });
54
+ module.exports = __toCommonJS(index_exports);
55
+
56
+ // src/config.ts
57
+ var DEFAULT_STUN_SERVERS = [
58
+ "stun:stun.l.google.com:19302",
59
+ "stun:stun1.l.google.com:19302",
60
+ "stun:stun.cloudflare.com:3478"
61
+ ];
62
+ var DEFAULT_TRANSPORT_PREFERENCES = [
63
+ "quic",
64
+ "tcp",
65
+ "websocket",
66
+ "webtransport",
67
+ "circuit-relay-v2"
68
+ ];
69
+ var DEFAULT_RECONNECTION_POLICY = {
70
+ connectTimeout: 3e4,
71
+ transportTimeout: 1e4,
72
+ reconnectMaxDuration: 36e5,
73
+ reconnectBackoff: {
74
+ initialDelay: 1e3,
75
+ maxDelay: 6e4,
76
+ factor: 2
77
+ },
78
+ rendezvousPollInterval: 3e4,
79
+ sessionExpiry: 864e5,
80
+ pairingPayloadExpiry: 3e5
81
+ };
82
+ var DEFAULT_MESH_SETTINGS = {
83
+ meshEnabled: false,
84
+ maxHops: 3,
85
+ relayWilling: false,
86
+ relayCapacity: 10
87
+ };
88
+
89
+ // src/errors.ts
90
+ var ErrorBehavior = /* @__PURE__ */ ((ErrorBehavior2) => {
91
+ ErrorBehavior2["Retry"] = "retry";
92
+ ErrorBehavior2["Reconnect"] = "reconnect";
93
+ ErrorBehavior2["Abort"] = "abort";
94
+ ErrorBehavior2["ReGenerate"] = "regenerate";
95
+ ErrorBehavior2["Wait"] = "wait";
96
+ ErrorBehavior2["Inform"] = "inform";
97
+ return ErrorBehavior2;
98
+ })(ErrorBehavior || {});
99
+ var CairnError = class extends Error {
100
+ code;
101
+ details;
102
+ constructor(code, message, details) {
103
+ super(message);
104
+ this.name = "CairnError";
105
+ this.code = code;
106
+ this.details = details;
107
+ }
108
+ errorBehavior() {
109
+ return "abort" /* Abort */;
110
+ }
111
+ };
112
+ var TransportExhaustedError = class extends CairnError {
113
+ constructor(message, details) {
114
+ super("TRANSPORT_EXHAUSTED", message, {
115
+ suggestion: "deploy the cairn signaling server and/or TURN relay",
116
+ ...details
117
+ });
118
+ this.name = "TransportExhaustedError";
119
+ }
120
+ errorBehavior() {
121
+ return "retry" /* Retry */;
122
+ }
123
+ };
124
+ var SessionExpiredError = class extends CairnError {
125
+ constructor(message, details) {
126
+ super("SESSION_EXPIRED", message, details);
127
+ this.name = "SessionExpiredError";
128
+ }
129
+ errorBehavior() {
130
+ return "reconnect" /* Reconnect */;
131
+ }
132
+ };
133
+ var PeerUnreachableError = class extends CairnError {
134
+ constructor(message, details) {
135
+ super("PEER_UNREACHABLE", message, details);
136
+ this.name = "PeerUnreachableError";
137
+ }
138
+ errorBehavior() {
139
+ return "wait" /* Wait */;
140
+ }
141
+ };
142
+ var AuthenticationFailedError = class extends CairnError {
143
+ constructor(message, details) {
144
+ super("AUTHENTICATION_FAILED", message, details);
145
+ this.name = "AuthenticationFailedError";
146
+ }
147
+ errorBehavior() {
148
+ return "abort" /* Abort */;
149
+ }
150
+ };
151
+ var PairingRejectedError = class extends CairnError {
152
+ constructor(message, details) {
153
+ super("PAIRING_REJECTED", message, details);
154
+ this.name = "PairingRejectedError";
155
+ }
156
+ errorBehavior() {
157
+ return "inform" /* Inform */;
158
+ }
159
+ };
160
+ var PairingExpiredError = class extends CairnError {
161
+ constructor(message, details) {
162
+ super("PAIRING_EXPIRED", message, details);
163
+ this.name = "PairingExpiredError";
164
+ }
165
+ errorBehavior() {
166
+ return "regenerate" /* ReGenerate */;
167
+ }
168
+ };
169
+ var MeshRouteNotFoundError = class extends CairnError {
170
+ constructor(message, details) {
171
+ super("MESH_ROUTE_NOT_FOUND", message, {
172
+ suggestion: "try a direct connection or wait for mesh route discovery",
173
+ ...details
174
+ });
175
+ this.name = "MeshRouteNotFoundError";
176
+ }
177
+ errorBehavior() {
178
+ return "wait" /* Wait */;
179
+ }
180
+ };
181
+ var VersionMismatchError = class extends CairnError {
182
+ constructor(message, details) {
183
+ super("VERSION_MISMATCH", message, {
184
+ suggestion: "peer needs to update to a compatible cairn version",
185
+ ...details
186
+ });
187
+ this.name = "VersionMismatchError";
188
+ }
189
+ errorBehavior() {
190
+ return "abort" /* Abort */;
191
+ }
192
+ };
193
+
194
+ // src/crypto/identity.ts
195
+ var ed = __toESM(require("@noble/ed25519"), 1);
196
+ var import_sha256 = require("@noble/hashes/sha256");
197
+ var IdentityKeypair = class _IdentityKeypair {
198
+ secret;
199
+ pubKey;
200
+ constructor(secret, pubKey) {
201
+ this.secret = secret;
202
+ this.pubKey = pubKey;
203
+ }
204
+ /** Generate a new random Ed25519 identity keypair. */
205
+ static async generate() {
206
+ const secret = ed.utils.randomPrivateKey();
207
+ const pubKey = await ed.getPublicKeyAsync(secret);
208
+ return new _IdentityKeypair(secret, pubKey);
209
+ }
210
+ /** Restore from a 32-byte secret key seed. */
211
+ static async fromBytes(secret) {
212
+ if (secret.length !== 32) {
213
+ throw new CairnError("CRYPTO", "Ed25519 secret key must be 32 bytes");
214
+ }
215
+ const copy = new Uint8Array(secret);
216
+ const pubKey = await ed.getPublicKeyAsync(copy);
217
+ return new _IdentityKeypair(copy, pubKey);
218
+ }
219
+ /** Export the 32-byte secret key seed. */
220
+ secretBytes() {
221
+ return new Uint8Array(this.secret);
222
+ }
223
+ /** Get the 32-byte public key. */
224
+ publicKey() {
225
+ return new Uint8Array(this.pubKey);
226
+ }
227
+ /** Derive the Peer ID: SHA-256 hash of the Ed25519 public key bytes (32 bytes). */
228
+ peerId() {
229
+ return peerIdFromPublicKey(this.pubKey);
230
+ }
231
+ /** Sign a message. Returns 64-byte signature. */
232
+ async sign(message) {
233
+ try {
234
+ return await ed.signAsync(message, this.secret);
235
+ } catch (e) {
236
+ throw new CairnError("CRYPTO", `Ed25519 sign error: ${e}`);
237
+ }
238
+ }
239
+ /** Verify a signature against this keypair's public key. Throws on failure. */
240
+ async verify(message, signature) {
241
+ return verifySignature(this.pubKey, message, signature);
242
+ }
243
+ };
244
+ function peerIdFromPublicKey(publicKey) {
245
+ return (0, import_sha256.sha256)(publicKey);
246
+ }
247
+ async function verifySignature(publicKey, message, signature) {
248
+ try {
249
+ const valid = await ed.verifyAsync(signature, message, publicKey);
250
+ if (!valid) {
251
+ throw new CairnError("CRYPTO", "Ed25519 signature verification failed");
252
+ }
253
+ } catch (e) {
254
+ if (e instanceof CairnError) throw e;
255
+ throw new CairnError("CRYPTO", `Ed25519 verify error: ${e}`);
256
+ }
257
+ }
258
+
259
+ // src/crypto/noise.ts
260
+ var import_sha2563 = require("@noble/hashes/sha256");
261
+ var import_ed25519 = require("@noble/curves/ed25519");
262
+ var import_ed255192 = require("@noble/curves/ed25519");
263
+
264
+ // src/crypto/hkdf.ts
265
+ var import_hkdf = require("@noble/hashes/hkdf");
266
+ var import_sha2562 = require("@noble/hashes/sha256");
267
+ var encoder = new TextEncoder();
268
+ var HKDF_INFO_SESSION_KEY = encoder.encode("cairn-session-key-v1");
269
+ var HKDF_INFO_RENDEZVOUS = encoder.encode("cairn-rendezvous-id-v1");
270
+ var HKDF_INFO_SAS = encoder.encode("cairn-sas-derivation-v1");
271
+ var HKDF_INFO_CHAIN_KEY = encoder.encode("cairn-chain-key-v1");
272
+ var HKDF_INFO_MESSAGE_KEY = encoder.encode("cairn-message-key-v1");
273
+ function hkdfSha256(ikm, salt, info, length) {
274
+ try {
275
+ return (0, import_hkdf.hkdf)(import_sha2562.sha256, ikm, salt, info, length);
276
+ } catch (e) {
277
+ throw new CairnError("CRYPTO", `HKDF-SHA256 error: ${e}`);
278
+ }
279
+ }
280
+
281
+ // src/crypto/aead.ts
282
+ var import_aes = require("@noble/ciphers/aes");
283
+ var import_chacha20poly1305 = require("@stablelib/chacha20poly1305");
284
+ var NONCE_SIZE = 12;
285
+ var KEY_SIZE = 32;
286
+ function aeadEncrypt(cipher, key, nonce, plaintext, aad) {
287
+ if (key.length !== KEY_SIZE) {
288
+ throw new CairnError("CRYPTO", `AEAD key must be ${KEY_SIZE} bytes, got ${key.length}`);
289
+ }
290
+ if (nonce.length !== NONCE_SIZE) {
291
+ throw new CairnError("CRYPTO", `AEAD nonce must be ${NONCE_SIZE} bytes, got ${nonce.length}`);
292
+ }
293
+ try {
294
+ if (cipher === "aes-256-gcm") {
295
+ const aes = (0, import_aes.gcm)(key, nonce, aad);
296
+ return aes.encrypt(plaintext);
297
+ } else {
298
+ const chacha = new import_chacha20poly1305.ChaCha20Poly1305(key);
299
+ return chacha.seal(nonce, plaintext, aad);
300
+ }
301
+ } catch (e) {
302
+ throw new CairnError("CRYPTO", `AEAD encrypt error: ${e}`);
303
+ }
304
+ }
305
+ function aeadDecrypt(cipher, key, nonce, ciphertext, aad) {
306
+ if (key.length !== KEY_SIZE) {
307
+ throw new CairnError("CRYPTO", `AEAD key must be ${KEY_SIZE} bytes, got ${key.length}`);
308
+ }
309
+ if (nonce.length !== NONCE_SIZE) {
310
+ throw new CairnError("CRYPTO", `AEAD nonce must be ${NONCE_SIZE} bytes, got ${nonce.length}`);
311
+ }
312
+ try {
313
+ if (cipher === "aes-256-gcm") {
314
+ const aes = (0, import_aes.gcm)(key, nonce, aad);
315
+ return aes.decrypt(ciphertext);
316
+ } else {
317
+ const chacha = new import_chacha20poly1305.ChaCha20Poly1305(key);
318
+ const result = chacha.open(nonce, ciphertext, aad);
319
+ if (result === null) {
320
+ throw new CairnError("CRYPTO", "ChaCha20-Poly1305 authentication failed");
321
+ }
322
+ return result;
323
+ }
324
+ } catch (e) {
325
+ if (e instanceof CairnError) throw e;
326
+ throw new CairnError("CRYPTO", `AEAD decrypt error: ${e}`);
327
+ }
328
+ }
329
+
330
+ // src/crypto/noise.ts
331
+ var PROTOCOL_NAME = new TextEncoder().encode("Noise_XX_25519_ChaChaPoly_SHA256");
332
+ var TAG_SIZE = 16;
333
+ var DH_KEY_SIZE = 32;
334
+ var ED25519_PUB_SIZE = 32;
335
+ var ZERO_NONCE = new Uint8Array(12);
336
+ var NoiseXXHandshake = class {
337
+ state;
338
+ localIdentity;
339
+ localStaticX25519Secret;
340
+ localEphemeralSecret;
341
+ localEphemeralPub;
342
+ remoteEphemeralPub;
343
+ remoteStaticEd25519;
344
+ chainingKey;
345
+ handshakeHash;
346
+ currentKey;
347
+ pakeSecret;
348
+ cachedResult;
349
+ constructor(role, identity, pakeSecret) {
350
+ this.localStaticX25519Secret = import_ed25519.ed25519.utils.toMontgomerySecret(identity.secretBytes());
351
+ if (PROTOCOL_NAME.length <= 32) {
352
+ this.handshakeHash = new Uint8Array(32);
353
+ this.handshakeHash.set(PROTOCOL_NAME);
354
+ } else {
355
+ this.handshakeHash = (0, import_sha2563.sha256)(PROTOCOL_NAME);
356
+ }
357
+ this.chainingKey = new Uint8Array(this.handshakeHash);
358
+ this.localIdentity = identity;
359
+ this.state = role === "initiator" ? "initiator_start" : "responder_wait_msg1";
360
+ if (pakeSecret) {
361
+ if (pakeSecret.length !== 32) {
362
+ throw new CairnError("CRYPTO", "PAKE secret must be 32 bytes");
363
+ }
364
+ this.pakeSecret = new Uint8Array(pakeSecret);
365
+ }
366
+ }
367
+ /**
368
+ * Process the next handshake step.
369
+ *
370
+ * Initiator: step() -> step(msg2)
371
+ * Responder: step(msg1) -> step(msg3)
372
+ */
373
+ step(input) {
374
+ switch (this.state) {
375
+ case "initiator_start":
376
+ if (input !== void 0) {
377
+ throw new CairnError("CRYPTO", "initiator start expects no input");
378
+ }
379
+ return this.initiatorSendMsg1();
380
+ case "responder_wait_msg1":
381
+ if (input === void 0) {
382
+ throw new CairnError("CRYPTO", "responder expects message 1 input");
383
+ }
384
+ return this.responderRecvMsg1SendMsg2(input);
385
+ case "initiator_wait_msg2":
386
+ if (input === void 0) {
387
+ throw new CairnError("CRYPTO", "initiator expects message 2 input");
388
+ }
389
+ return this.initiatorRecvMsg2SendMsg3(input);
390
+ case "responder_wait_msg3":
391
+ if (input === void 0) {
392
+ throw new CairnError("CRYPTO", "responder expects message 3 input");
393
+ }
394
+ return this.responderRecvMsg3(input);
395
+ case "complete":
396
+ throw new CairnError("CRYPTO", "handshake already complete");
397
+ }
398
+ }
399
+ /** Get the handshake result after the initiator has sent message 3. */
400
+ getResult() {
401
+ if (!this.cachedResult) {
402
+ throw new CairnError("CRYPTO", "handshake not yet complete");
403
+ }
404
+ return this.cachedResult;
405
+ }
406
+ // --- Message 1: -> e ---
407
+ initiatorSendMsg1() {
408
+ const ephemeralSecret = import_ed255192.x25519.utils.randomSecretKey();
409
+ const ephemeralPub = import_ed255192.x25519.getPublicKey(ephemeralSecret);
410
+ this.mixHash(ephemeralPub);
411
+ this.localEphemeralSecret = ephemeralSecret;
412
+ this.localEphemeralPub = ephemeralPub;
413
+ this.state = "initiator_wait_msg2";
414
+ return { type: "send_message", data: new Uint8Array(ephemeralPub) };
415
+ }
416
+ // --- Message 2: <- e, ee, s, es ---
417
+ responderRecvMsg1SendMsg2(msg1) {
418
+ if (msg1.length !== DH_KEY_SIZE) {
419
+ throw new CairnError("CRYPTO", `message 1 invalid length: expected ${DH_KEY_SIZE}, got ${msg1.length}`);
420
+ }
421
+ this.remoteEphemeralPub = new Uint8Array(msg1);
422
+ this.mixHash(this.remoteEphemeralPub);
423
+ const parts = [];
424
+ const ephemeralSecret = import_ed255192.x25519.utils.randomSecretKey();
425
+ const ephemeralPub = import_ed255192.x25519.getPublicKey(ephemeralSecret);
426
+ this.mixHash(ephemeralPub);
427
+ parts.push(ephemeralPub);
428
+ this.localEphemeralSecret = ephemeralSecret;
429
+ this.localEphemeralPub = ephemeralPub;
430
+ const eeShared = import_ed255192.x25519.getSharedSecret(ephemeralSecret, this.remoteEphemeralPub);
431
+ this.mixKey(eeShared);
432
+ const staticPubBytes = this.localIdentity.publicKey();
433
+ const encryptedStatic = this.encryptAndHash(staticPubBytes);
434
+ parts.push(encryptedStatic);
435
+ const esShared = import_ed255192.x25519.getSharedSecret(this.localStaticX25519Secret, this.remoteEphemeralPub);
436
+ this.mixKey(esShared);
437
+ const encryptedPayload = this.encryptAndHash(new Uint8Array(0));
438
+ parts.push(encryptedPayload);
439
+ this.state = "responder_wait_msg3";
440
+ return { type: "send_message", data: concatBytes(...parts) };
441
+ }
442
+ // --- Initiator: recv message 2, send message 3 ---
443
+ initiatorRecvMsg2SendMsg3(msg2) {
444
+ const minLen = DH_KEY_SIZE + (ED25519_PUB_SIZE + TAG_SIZE) + TAG_SIZE;
445
+ if (msg2.length < minLen) {
446
+ throw new CairnError("CRYPTO", `message 2 too short: expected at least ${minLen}, got ${msg2.length}`);
447
+ }
448
+ let offset = 0;
449
+ const remoteEBytes = msg2.slice(offset, offset + DH_KEY_SIZE);
450
+ this.mixHash(remoteEBytes);
451
+ offset += DH_KEY_SIZE;
452
+ this.remoteEphemeralPub = remoteEBytes;
453
+ if (!this.localEphemeralSecret) {
454
+ throw new CairnError("CRYPTO", "missing local ephemeral for ee DH");
455
+ }
456
+ const eeShared = import_ed255192.x25519.getSharedSecret(this.localEphemeralSecret, remoteEBytes);
457
+ this.mixKey(eeShared);
458
+ const encryptedStatic = msg2.slice(offset, offset + ED25519_PUB_SIZE + TAG_SIZE);
459
+ const staticPubBytes = this.decryptAndHash(encryptedStatic);
460
+ offset += ED25519_PUB_SIZE + TAG_SIZE;
461
+ if (staticPubBytes.length !== ED25519_PUB_SIZE) {
462
+ throw new CairnError("CRYPTO", "decrypted static key wrong size");
463
+ }
464
+ const remoteStaticX25519 = import_ed25519.ed25519.utils.toMontgomery(staticPubBytes);
465
+ this.remoteStaticEd25519 = new Uint8Array(staticPubBytes);
466
+ const esShared = import_ed255192.x25519.getSharedSecret(this.localEphemeralSecret, remoteStaticX25519);
467
+ this.mixKey(esShared);
468
+ const encryptedPayload = msg2.slice(offset);
469
+ this.decryptAndHash(encryptedPayload);
470
+ const parts = [];
471
+ const ourStaticPubBytes = this.localIdentity.publicKey();
472
+ const encryptedOurStatic = this.encryptAndHash(ourStaticPubBytes);
473
+ parts.push(encryptedOurStatic);
474
+ const seShared = import_ed255192.x25519.getSharedSecret(this.localStaticX25519Secret, remoteEBytes);
475
+ this.mixKey(seShared);
476
+ if (this.pakeSecret) {
477
+ this.mixKey(this.pakeSecret);
478
+ }
479
+ const encryptedMsg3Payload = this.encryptAndHash(new Uint8Array(0));
480
+ parts.push(encryptedMsg3Payload);
481
+ const sessionKey = this.deriveSessionKey();
482
+ const result = {
483
+ sessionKey,
484
+ remoteStatic: new Uint8Array(staticPubBytes),
485
+ transcriptHash: new Uint8Array(this.handshakeHash)
486
+ };
487
+ this.state = "complete";
488
+ this.cachedResult = result;
489
+ return { type: "send_message", data: concatBytes(...parts) };
490
+ }
491
+ // --- Message 3: responder receives -> s, se ---
492
+ responderRecvMsg3(msg3) {
493
+ const minLen = ED25519_PUB_SIZE + TAG_SIZE + TAG_SIZE;
494
+ if (msg3.length < minLen) {
495
+ throw new CairnError("CRYPTO", `message 3 too short: expected at least ${minLen}, got ${msg3.length}`);
496
+ }
497
+ let offset = 0;
498
+ const encryptedStatic = msg3.slice(offset, offset + ED25519_PUB_SIZE + TAG_SIZE);
499
+ const staticPubBytes = this.decryptAndHash(encryptedStatic);
500
+ offset += ED25519_PUB_SIZE + TAG_SIZE;
501
+ if (staticPubBytes.length !== ED25519_PUB_SIZE) {
502
+ throw new CairnError("CRYPTO", "decrypted static key wrong size");
503
+ }
504
+ const remoteStaticX25519 = import_ed25519.ed25519.utils.toMontgomery(staticPubBytes);
505
+ this.remoteStaticEd25519 = new Uint8Array(staticPubBytes);
506
+ if (!this.localEphemeralSecret) {
507
+ throw new CairnError("CRYPTO", "missing local ephemeral for se DH");
508
+ }
509
+ const seShared = import_ed255192.x25519.getSharedSecret(this.localEphemeralSecret, remoteStaticX25519);
510
+ this.mixKey(seShared);
511
+ if (this.pakeSecret) {
512
+ this.mixKey(this.pakeSecret);
513
+ }
514
+ const encryptedPayload = msg3.slice(offset);
515
+ this.decryptAndHash(encryptedPayload);
516
+ const sessionKey = this.deriveSessionKey();
517
+ this.state = "complete";
518
+ const result = {
519
+ sessionKey,
520
+ remoteStatic: new Uint8Array(staticPubBytes),
521
+ transcriptHash: new Uint8Array(this.handshakeHash)
522
+ };
523
+ this.cachedResult = result;
524
+ return { type: "complete", result };
525
+ }
526
+ // --- Noise symmetric state operations ---
527
+ /**
528
+ * Mix a DH result into the chaining key via HKDF.
529
+ * Updates the chaining key and stores the derived encryption key.
530
+ */
531
+ mixKey(inputKeyMaterial) {
532
+ const output = hkdfSha256(inputKeyMaterial, this.chainingKey, new Uint8Array(0), 64);
533
+ this.chainingKey = output.slice(0, 32);
534
+ this.currentKey = output.slice(32, 64);
535
+ }
536
+ /** Mix data into the handshake hash: h = SHA-256(h || data). */
537
+ mixHash(data) {
538
+ const combined = concatBytes(this.handshakeHash, data);
539
+ this.handshakeHash = (0, import_sha2563.sha256)(combined);
540
+ }
541
+ /** Encrypt plaintext and mix the ciphertext into the handshake hash. */
542
+ encryptAndHash(plaintext) {
543
+ if (!this.currentKey) {
544
+ throw new CairnError("CRYPTO", "no encryption key available (mixKey not called)");
545
+ }
546
+ const ciphertext = aeadEncrypt(
547
+ "chacha20-poly1305",
548
+ this.currentKey,
549
+ ZERO_NONCE,
550
+ plaintext,
551
+ this.handshakeHash
552
+ );
553
+ this.mixHash(ciphertext);
554
+ return ciphertext;
555
+ }
556
+ /** Decrypt ciphertext and mix it into the handshake hash. */
557
+ decryptAndHash(ciphertext) {
558
+ if (!this.currentKey) {
559
+ throw new CairnError("CRYPTO", "no decryption key available (mixKey not called)");
560
+ }
561
+ const hBefore = new Uint8Array(this.handshakeHash);
562
+ this.mixHash(ciphertext);
563
+ return aeadDecrypt(
564
+ "chacha20-poly1305",
565
+ this.currentKey,
566
+ ZERO_NONCE,
567
+ ciphertext,
568
+ hBefore
569
+ );
570
+ }
571
+ /** Derive the final session key from the chaining key. */
572
+ deriveSessionKey() {
573
+ return hkdfSha256(this.chainingKey, void 0, HKDF_INFO_SESSION_KEY, 32);
574
+ }
575
+ };
576
+ function concatBytes(...arrays) {
577
+ let totalLen = 0;
578
+ for (const arr of arrays) totalLen += arr.length;
579
+ const result = new Uint8Array(totalLen);
580
+ let offset = 0;
581
+ for (const arr of arrays) {
582
+ result.set(arr, offset);
583
+ offset += arr.length;
584
+ }
585
+ return result;
586
+ }
587
+
588
+ // src/crypto/exchange.ts
589
+ var import_ed255193 = require("@noble/curves/ed25519");
590
+ var X25519Keypair = class _X25519Keypair {
591
+ secret;
592
+ pubKey;
593
+ constructor(secret, pubKey) {
594
+ this.secret = secret;
595
+ this.pubKey = pubKey;
596
+ }
597
+ /** Generate a new random X25519 keypair. */
598
+ static generate() {
599
+ const secret = import_ed255193.x25519.utils.randomSecretKey();
600
+ const pubKey = import_ed255193.x25519.getPublicKey(secret);
601
+ return new _X25519Keypair(secret, pubKey);
602
+ }
603
+ /** Restore from a 32-byte secret key. */
604
+ static fromBytes(secret) {
605
+ if (secret.length !== 32) {
606
+ throw new CairnError("CRYPTO", "X25519 secret key must be 32 bytes");
607
+ }
608
+ const copy = new Uint8Array(secret);
609
+ const pubKey = import_ed255193.x25519.getPublicKey(copy);
610
+ return new _X25519Keypair(copy, pubKey);
611
+ }
612
+ /** Get the 32-byte public key. */
613
+ publicKeyBytes() {
614
+ return new Uint8Array(this.pubKey);
615
+ }
616
+ /** Export the 32-byte secret key. */
617
+ secretBytes() {
618
+ return new Uint8Array(this.secret);
619
+ }
620
+ /** Perform Diffie-Hellman key exchange with a peer's public key. Returns 32-byte shared secret. */
621
+ diffieHellman(peerPublic) {
622
+ try {
623
+ return import_ed255193.x25519.getSharedSecret(this.secret, peerPublic);
624
+ } catch (e) {
625
+ throw new CairnError("CRYPTO", `X25519 DH error: ${e}`);
626
+ }
627
+ }
628
+ };
629
+
630
+ // src/crypto/double-ratchet.ts
631
+ var ROOT_KDF_INFO = new TextEncoder().encode("cairn-root-chain-v1");
632
+ var CHAIN_KDF_INFO = new TextEncoder().encode("cairn-chain-advance-v1");
633
+ var MESSAGE_KEY_KDF_INFO = new TextEncoder().encode("cairn-msg-encrypt-v1");
634
+ var DEFAULT_CONFIG = {
635
+ maxSkip: 100,
636
+ cipher: "aes-256-gcm"
637
+ };
638
+ var DoubleRatchet = class _DoubleRatchet {
639
+ state;
640
+ config;
641
+ constructor(state, config) {
642
+ this.state = state;
643
+ this.config = config;
644
+ }
645
+ /**
646
+ * Initialize as the sender (initiator/Alice) after a shared secret
647
+ * has been established (e.g., from Noise XX handshake).
648
+ */
649
+ static initSender(sharedSecret, remoteDh, config) {
650
+ const cfg = { ...DEFAULT_CONFIG, ...config };
651
+ const dhSelf = X25519Keypair.generate();
652
+ const dhOutput = dhSelf.diffieHellman(remoteDh);
653
+ const [rootKey, chainKeySend] = kdfRk(sharedSecret, dhOutput);
654
+ const state = {
655
+ dhSelfSecret: dhSelf.secretBytes(),
656
+ dhSelfPublic: dhSelf.publicKeyBytes(),
657
+ dhRemote: new Uint8Array(remoteDh),
658
+ rootKey,
659
+ chainKeySend,
660
+ chainKeyRecv: null,
661
+ msgNumSend: 0,
662
+ msgNumRecv: 0,
663
+ prevChainLen: 0,
664
+ skippedKeys: /* @__PURE__ */ new Map()
665
+ };
666
+ return new _DoubleRatchet(state, cfg);
667
+ }
668
+ /**
669
+ * Initialize as the receiver (responder/Bob) after a shared secret
670
+ * has been established.
671
+ */
672
+ static initReceiver(sharedSecret, ourKeypair, config) {
673
+ const cfg = { ...DEFAULT_CONFIG, ...config };
674
+ const state = {
675
+ dhSelfSecret: ourKeypair.secretBytes(),
676
+ dhSelfPublic: ourKeypair.publicKeyBytes(),
677
+ dhRemote: null,
678
+ rootKey: new Uint8Array(sharedSecret),
679
+ chainKeySend: null,
680
+ chainKeyRecv: null,
681
+ msgNumSend: 0,
682
+ msgNumRecv: 0,
683
+ prevChainLen: 0,
684
+ skippedKeys: /* @__PURE__ */ new Map()
685
+ };
686
+ return new _DoubleRatchet(state, cfg);
687
+ }
688
+ /** Encrypt a message. Returns header and ciphertext. */
689
+ encrypt(plaintext) {
690
+ if (!this.state.chainKeySend) {
691
+ throw new CairnError("CRYPTO", "no sending chain key established");
692
+ }
693
+ const [newChainKey, messageKey] = kdfCk(this.state.chainKeySend);
694
+ this.state.chainKeySend = newChainKey;
695
+ const header = {
696
+ dhPublic: new Uint8Array(this.state.dhSelfPublic),
697
+ prevChainLen: this.state.prevChainLen,
698
+ msgNum: this.state.msgNumSend
699
+ };
700
+ this.state.msgNumSend++;
701
+ const nonce = deriveNonce(messageKey, header.msgNum);
702
+ const aad = serializeHeader(header);
703
+ const ciphertext = aeadEncrypt(this.config.cipher, messageKey, nonce, plaintext, aad);
704
+ messageKey.fill(0);
705
+ return { header, ciphertext };
706
+ }
707
+ /** Decrypt a message given the header and ciphertext. */
708
+ decrypt(header, ciphertext) {
709
+ const skippedId = skippedKeyId(header.dhPublic, header.msgNum);
710
+ const skippedMk = this.state.skippedKeys.get(skippedId);
711
+ if (skippedMk) {
712
+ this.state.skippedKeys.delete(skippedId);
713
+ return decryptWithKey(this.config.cipher, skippedMk, header, ciphertext);
714
+ }
715
+ const needDhRatchet = this.state.dhRemote === null || !bytesEqual(this.state.dhRemote, header.dhPublic);
716
+ if (needDhRatchet) {
717
+ this.skipMessageKeys(header.prevChainLen);
718
+ this.dhRatchet(header.dhPublic);
719
+ }
720
+ this.skipMessageKeys(header.msgNum);
721
+ if (!this.state.chainKeyRecv) {
722
+ throw new CairnError("CRYPTO", "no receiving chain key established");
723
+ }
724
+ const [newChainKey, messageKey] = kdfCk(this.state.chainKeyRecv);
725
+ this.state.chainKeyRecv = newChainKey;
726
+ this.state.msgNumRecv++;
727
+ const result = decryptWithKey(this.config.cipher, messageKey, header, ciphertext);
728
+ messageKey.fill(0);
729
+ return result;
730
+ }
731
+ /** Export the ratchet state for persistence. */
732
+ exportState() {
733
+ const skippedEntries = [];
734
+ for (const [key, value] of this.state.skippedKeys) {
735
+ skippedEntries.push([key, Array.from(value)]);
736
+ }
737
+ const obj = {
738
+ dhSelfSecret: Array.from(this.state.dhSelfSecret),
739
+ dhSelfPublic: Array.from(this.state.dhSelfPublic),
740
+ dhRemote: this.state.dhRemote ? Array.from(this.state.dhRemote) : null,
741
+ rootKey: Array.from(this.state.rootKey),
742
+ chainKeySend: this.state.chainKeySend ? Array.from(this.state.chainKeySend) : null,
743
+ chainKeyRecv: this.state.chainKeyRecv ? Array.from(this.state.chainKeyRecv) : null,
744
+ msgNumSend: this.state.msgNumSend,
745
+ msgNumRecv: this.state.msgNumRecv,
746
+ prevChainLen: this.state.prevChainLen,
747
+ skippedKeys: skippedEntries
748
+ };
749
+ return new TextEncoder().encode(JSON.stringify(obj));
750
+ }
751
+ /** Import ratchet state from persisted bytes. */
752
+ static importState(data, config) {
753
+ const cfg = { ...DEFAULT_CONFIG, ...config };
754
+ try {
755
+ const json = new TextDecoder().decode(data);
756
+ const obj = JSON.parse(json);
757
+ const skippedKeys = /* @__PURE__ */ new Map();
758
+ if (obj.skippedKeys) {
759
+ for (const [key, value] of obj.skippedKeys) {
760
+ skippedKeys.set(key, new Uint8Array(value));
761
+ }
762
+ }
763
+ const state = {
764
+ dhSelfSecret: new Uint8Array(obj.dhSelfSecret),
765
+ dhSelfPublic: new Uint8Array(obj.dhSelfPublic),
766
+ dhRemote: obj.dhRemote ? new Uint8Array(obj.dhRemote) : null,
767
+ rootKey: new Uint8Array(obj.rootKey),
768
+ chainKeySend: obj.chainKeySend ? new Uint8Array(obj.chainKeySend) : null,
769
+ chainKeyRecv: obj.chainKeyRecv ? new Uint8Array(obj.chainKeyRecv) : null,
770
+ msgNumSend: obj.msgNumSend,
771
+ msgNumRecv: obj.msgNumRecv,
772
+ prevChainLen: obj.prevChainLen,
773
+ skippedKeys
774
+ };
775
+ return new _DoubleRatchet(state, cfg);
776
+ } catch (e) {
777
+ if (e instanceof CairnError) throw e;
778
+ throw new CairnError("CRYPTO", `ratchet state deserialization: ${e}`);
779
+ }
780
+ }
781
+ /** Skip message keys up to (but not including) the given message number. */
782
+ skipMessageKeys(until) {
783
+ if (!this.state.chainKeyRecv) return;
784
+ const toSkip = until - this.state.msgNumRecv;
785
+ if (toSkip <= 0) return;
786
+ if (toSkip > this.config.maxSkip) {
787
+ throw new CairnError("CRYPTO", "max skip threshold exceeded");
788
+ }
789
+ let ck = this.state.chainKeyRecv;
790
+ for (let i = this.state.msgNumRecv; i < until; i++) {
791
+ const [newCk, mk] = kdfCk(ck);
792
+ if (!this.state.dhRemote) {
793
+ throw new CairnError("CRYPTO", "no remote DH key for skipping");
794
+ }
795
+ const id = skippedKeyId(this.state.dhRemote, i);
796
+ this.state.skippedKeys.set(id, mk);
797
+ ck = newCk;
798
+ this.state.msgNumRecv++;
799
+ }
800
+ this.state.chainKeyRecv = ck;
801
+ }
802
+ /** Perform a DH ratchet step when the peer's public key changes. */
803
+ dhRatchet(newRemotePublic) {
804
+ this.state.prevChainLen = this.state.msgNumSend;
805
+ this.state.msgNumSend = 0;
806
+ this.state.msgNumRecv = 0;
807
+ this.state.dhRemote = new Uint8Array(newRemotePublic);
808
+ const dhSelf = X25519Keypair.fromBytes(this.state.dhSelfSecret);
809
+ const dhOutput = dhSelf.diffieHellman(newRemotePublic);
810
+ const [rootKey1, chainKeyRecv] = kdfRk(this.state.rootKey, dhOutput);
811
+ this.state.rootKey = rootKey1;
812
+ this.state.chainKeyRecv = chainKeyRecv;
813
+ const newDhSelf = X25519Keypair.generate();
814
+ this.state.dhSelfSecret = newDhSelf.secretBytes();
815
+ this.state.dhSelfPublic = newDhSelf.publicKeyBytes();
816
+ const dhOutput2 = newDhSelf.diffieHellman(newRemotePublic);
817
+ const [rootKey2, chainKeySend] = kdfRk(this.state.rootKey, dhOutput2);
818
+ this.state.rootKey = rootKey2;
819
+ this.state.chainKeySend = chainKeySend;
820
+ }
821
+ };
822
+ function kdfRk(rootKey, dhOutput) {
823
+ const output = hkdfSha256(dhOutput, rootKey, ROOT_KDF_INFO, 64);
824
+ return [output.slice(0, 32), output.slice(32, 64)];
825
+ }
826
+ function kdfCk(chainKey) {
827
+ const newCk = hkdfSha256(chainKey, void 0, CHAIN_KDF_INFO, 32);
828
+ const mk = hkdfSha256(chainKey, void 0, MESSAGE_KEY_KDF_INFO, 32);
829
+ return [newCk, mk];
830
+ }
831
+ function deriveNonce(messageKey, msgNum) {
832
+ const nonce = new Uint8Array(12);
833
+ nonce.set(messageKey.slice(0, 8), 0);
834
+ const view = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength);
835
+ view.setUint32(8, msgNum);
836
+ return nonce;
837
+ }
838
+ function serializeHeader(header) {
839
+ const obj = {
840
+ dh_public: Array.from(header.dhPublic),
841
+ prev_chain_len: header.prevChainLen,
842
+ msg_num: header.msgNum
843
+ };
844
+ return new TextEncoder().encode(JSON.stringify(obj));
845
+ }
846
+ function decryptWithKey(cipher, messageKey, header, ciphertext) {
847
+ const nonce = deriveNonce(messageKey, header.msgNum);
848
+ const aad = serializeHeader(header);
849
+ return aeadDecrypt(cipher, messageKey, nonce, ciphertext, aad);
850
+ }
851
+ function skippedKeyId(dhPublic, msgNum) {
852
+ return bytesToHex(dhPublic) + ":" + msgNum;
853
+ }
854
+ function bytesToHex(bytes) {
855
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
856
+ }
857
+ function bytesEqual(a, b) {
858
+ if (a.length !== b.length) return false;
859
+ for (let i = 0; i < a.length; i++) {
860
+ if (a[i] !== b[i]) return false;
861
+ }
862
+ return true;
863
+ }
864
+
865
+ // src/crypto/spake2.ts
866
+ var import_ed255194 = require("@noble/curves/ed25519");
867
+ var import_sha2564 = require("@noble/hashes/sha256");
868
+ var ExtendedPoint = import_ed255194.ed25519.ExtendedPoint;
869
+ var SPAKE2_ID_INITIATOR = new TextEncoder().encode("cairn-initiator");
870
+ var SPAKE2_ID_RESPONDER = new TextEncoder().encode("cairn-responder");
871
+ var L = 2n ** 252n + 27742317777372353535851937790883648493n;
872
+ function bytesToScalar(bytes) {
873
+ let n = 0n;
874
+ for (let i = bytes.length - 1; i >= 0; i--) {
875
+ n = n << 8n | BigInt(bytes[i]);
876
+ }
877
+ return n;
878
+ }
879
+ function derivePoint(label) {
880
+ const hash = (0, import_sha2564.sha256)(label);
881
+ let n = bytesToScalar(hash) % L;
882
+ if (n === 0n) n = 1n;
883
+ return ExtendedPoint.BASE.multiply(n);
884
+ }
885
+ var M_POINT = derivePoint(new TextEncoder().encode("SPAKE2-Ed25519-M"));
886
+ var N_POINT = derivePoint(new TextEncoder().encode("SPAKE2-Ed25519-N"));
887
+ function passwordToScalar(password) {
888
+ const hash = (0, import_sha2564.sha256)(password);
889
+ let n = bytesToScalar(hash) % L;
890
+ if (n === 0n) n = 1n;
891
+ return n;
892
+ }
893
+ function randomScalar() {
894
+ const bytes = crypto.getRandomValues(new Uint8Array(64));
895
+ let n = bytesToScalar(bytes) % L;
896
+ if (n === 0n) n = 1n;
897
+ return n;
898
+ }
899
+ var Spake2 = class _Spake2 {
900
+ role;
901
+ scalar;
902
+ pwScalar;
903
+ idA;
904
+ idB;
905
+ /** Our outbound message (the blinded public key). */
906
+ outboundMsg;
907
+ constructor(role, password, idA, idB) {
908
+ this.role = role;
909
+ this.idA = idA;
910
+ this.idB = idB;
911
+ this.scalar = randomScalar();
912
+ this.pwScalar = passwordToScalar(password);
913
+ const blindingPoint = role === "A" ? M_POINT : N_POINT;
914
+ const blinded = blindingPoint.multiply(this.pwScalar);
915
+ const ephemeral = ExtendedPoint.BASE.multiply(this.scalar);
916
+ const T = blinded.add(ephemeral);
917
+ this.outboundMsg = T.toRawBytes();
918
+ }
919
+ /** Start SPAKE2 as side A (initiator). */
920
+ static startA(password) {
921
+ return new _Spake2("A", password, SPAKE2_ID_INITIATOR, SPAKE2_ID_RESPONDER);
922
+ }
923
+ /** Start SPAKE2 as side B (responder). */
924
+ static startB(password) {
925
+ return new _Spake2("B", password, SPAKE2_ID_INITIATOR, SPAKE2_ID_RESPONDER);
926
+ }
927
+ /**
928
+ * Finish the SPAKE2 exchange with the peer's message.
929
+ * Returns the 32-byte shared key.
930
+ */
931
+ finish(peerMsg) {
932
+ try {
933
+ const peerPoint = ExtendedPoint.fromHex(peerMsg);
934
+ const peerBlindingPoint = this.role === "A" ? N_POINT : M_POINT;
935
+ const unblinded = peerPoint.add(peerBlindingPoint.multiply(this.pwScalar).negate());
936
+ const Z = unblinded.multiply(this.scalar);
937
+ const tA = this.role === "A" ? this.outboundMsg : peerMsg;
938
+ const tB = this.role === "A" ? peerMsg : this.outboundMsg;
939
+ const transcript = concatBytes2(
940
+ lengthPrefix(this.idA),
941
+ lengthPrefix(this.idB),
942
+ lengthPrefix(tA),
943
+ lengthPrefix(tB),
944
+ lengthPrefix(Z.toRawBytes())
945
+ );
946
+ return (0, import_sha2564.sha256)(transcript);
947
+ } catch (e) {
948
+ if (e instanceof CairnError) throw e;
949
+ throw new CairnError("CRYPTO", `SPAKE2 finish error: ${e}`);
950
+ }
951
+ }
952
+ };
953
+ function lengthPrefix(data) {
954
+ const len = new Uint8Array(4);
955
+ const view = new DataView(len.buffer);
956
+ view.setUint32(0, data.length, true);
957
+ return concatBytes2(len, data);
958
+ }
959
+ function concatBytes2(...arrays) {
960
+ let totalLen = 0;
961
+ for (const arr of arrays) totalLen += arr.length;
962
+ const result = new Uint8Array(totalLen);
963
+ let offset = 0;
964
+ for (const arr of arrays) {
965
+ result.set(arr, offset);
966
+ offset += arr.length;
967
+ }
968
+ return result;
969
+ }
970
+
971
+ // src/pairing/payload.ts
972
+ var import_cborg = require("cborg");
973
+ function encodePairingPayload(payload) {
974
+ const map = /* @__PURE__ */ new Map();
975
+ map.set(0, payload.peerId);
976
+ map.set(1, payload.nonce);
977
+ map.set(2, payload.pakeCredential);
978
+ if (payload.hints && payload.hints.length > 0) {
979
+ const hintArrays = payload.hints.map((h) => [h.hintType, h.value]);
980
+ map.set(3, hintArrays);
981
+ }
982
+ map.set(4, payload.createdAt);
983
+ map.set(5, payload.expiresAt);
984
+ return (0, import_cborg.encode)(map);
985
+ }
986
+ function decodePairingPayload(data) {
987
+ try {
988
+ const map = (0, import_cborg.decode)(data, { useMaps: true });
989
+ const peerId = map.get(0);
990
+ const nonce = map.get(1);
991
+ const pakeCredential = map.get(2);
992
+ if (!peerId || !nonce || !pakeCredential) {
993
+ throw new CairnError("PAIRING", "missing required fields in pairing payload");
994
+ }
995
+ let hints;
996
+ const rawHints = map.get(3);
997
+ if (rawHints && Array.isArray(rawHints)) {
998
+ hints = rawHints.map((h) => {
999
+ const arr = h;
1000
+ return { hintType: arr[0], value: arr[1] };
1001
+ });
1002
+ }
1003
+ const createdAt = map.get(4) ?? 0;
1004
+ const expiresAt = map.get(5) ?? 0;
1005
+ return { peerId, nonce, pakeCredential, hints, createdAt, expiresAt };
1006
+ } catch (e) {
1007
+ if (e instanceof CairnError) throw e;
1008
+ throw new CairnError("PAIRING", `failed to decode pairing payload: ${e}`);
1009
+ }
1010
+ }
1011
+ function generateNonce() {
1012
+ return crypto.getRandomValues(new Uint8Array(16));
1013
+ }
1014
+
1015
+ // src/pairing/qr.ts
1016
+ var MAX_QR_PAYLOAD_SIZE = 256;
1017
+ function generateQrPayload(payload) {
1018
+ const cbor = encodePairingPayload(payload);
1019
+ if (cbor.length > MAX_QR_PAYLOAD_SIZE) {
1020
+ throw new CairnError(
1021
+ "PAIRING",
1022
+ `QR payload exceeds max size: ${cbor.length} > ${MAX_QR_PAYLOAD_SIZE} bytes`
1023
+ );
1024
+ }
1025
+ return cbor;
1026
+ }
1027
+ function consumeQrPayload(raw) {
1028
+ if (raw.length > MAX_QR_PAYLOAD_SIZE) {
1029
+ throw new CairnError(
1030
+ "PAIRING",
1031
+ `QR payload exceeds max size: ${raw.length} > ${MAX_QR_PAYLOAD_SIZE} bytes`
1032
+ );
1033
+ }
1034
+ const payload = decodePairingPayload(raw);
1035
+ const now = Math.floor(Date.now() / 1e3);
1036
+ if (now > payload.expiresAt) {
1037
+ throw new CairnError("PAIRING", "QR pairing payload has expired");
1038
+ }
1039
+ return payload;
1040
+ }
1041
+
1042
+ // src/pairing/pin.ts
1043
+ var CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1044
+ var PIN_LENGTH = 8;
1045
+ var HKDF_INFO_PIN_RENDEZVOUS = new TextEncoder().encode("cairn-pin-rendezvous-v1");
1046
+ function generatePin() {
1047
+ const bytes = crypto.getRandomValues(new Uint8Array(5));
1048
+ return encodeCrockford(bytes);
1049
+ }
1050
+ function formatPin(pin) {
1051
+ if (pin.length === PIN_LENGTH) {
1052
+ return `${pin.slice(0, 4)}-${pin.slice(4)}`;
1053
+ }
1054
+ return pin;
1055
+ }
1056
+ function normalizePin(input) {
1057
+ return input.split("").filter((c) => c !== "-" && c !== " ").map((c) => c.toUpperCase()).filter((c) => c !== "U").map((c) => {
1058
+ if (c === "I" || c === "L") return "1";
1059
+ if (c === "O") return "0";
1060
+ return c;
1061
+ }).join("");
1062
+ }
1063
+ function validatePin(normalized) {
1064
+ if (normalized.length !== PIN_LENGTH) {
1065
+ throw new CairnError("PAIRING", `expected ${PIN_LENGTH} characters, got ${normalized.length}`);
1066
+ }
1067
+ for (const ch of normalized) {
1068
+ if (!CROCKFORD_ALPHABET.includes(ch)) {
1069
+ throw new CairnError("PAIRING", `invalid Crockford character: '${ch}'`);
1070
+ }
1071
+ }
1072
+ }
1073
+ function encodeCrockford(bytes) {
1074
+ let bits = 0n;
1075
+ for (const b of bytes) {
1076
+ bits = bits << 8n | BigInt(b);
1077
+ }
1078
+ let result = "";
1079
+ for (let i = 7; i >= 0; i--) {
1080
+ const index = Number(bits >> BigInt(i * 5) & 0x1Fn);
1081
+ result += CROCKFORD_ALPHABET[index];
1082
+ }
1083
+ return result;
1084
+ }
1085
+
1086
+ // src/pairing/link.ts
1087
+ var DEFAULT_SCHEME = "cairn";
1088
+ function generatePairingLink(payload, scheme = DEFAULT_SCHEME) {
1089
+ const pid = bytesToHex2(payload.peerId);
1090
+ const nonce = bytesToHex2(payload.nonce);
1091
+ const pake = bytesToHex2(payload.pakeCredential);
1092
+ let uri = `${scheme}://pair?pid=${pid}&nonce=${nonce}&pake=${pake}`;
1093
+ if (payload.hints && payload.hints.length > 0) {
1094
+ const hintsStr = payload.hints.map((h) => `${h.hintType}:${h.value}`).join(",");
1095
+ uri += `&hints=${encodeURIComponent(hintsStr)}`;
1096
+ }
1097
+ uri += `&t=${payload.createdAt}&x=${payload.expiresAt}`;
1098
+ return uri;
1099
+ }
1100
+ function parsePairingLink(uri, scheme = DEFAULT_SCHEME) {
1101
+ if (!uri.startsWith(`${scheme}://pair?`)) {
1102
+ throw new CairnError("PAIRING", `invalid pairing link: expected ${scheme}://pair?...`);
1103
+ }
1104
+ const queryStart = uri.indexOf("?");
1105
+ if (queryStart === -1) {
1106
+ throw new CairnError("PAIRING", "invalid pairing link: missing query string");
1107
+ }
1108
+ const params = new URLSearchParams(uri.slice(queryStart + 1));
1109
+ const pidHex = params.get("pid");
1110
+ if (!pidHex) {
1111
+ throw new CairnError("PAIRING", "missing 'pid' parameter in pairing link");
1112
+ }
1113
+ const peerId = hexToBytes(pidHex);
1114
+ const nonceHex = params.get("nonce");
1115
+ if (!nonceHex) {
1116
+ throw new CairnError("PAIRING", "missing 'nonce' parameter in pairing link");
1117
+ }
1118
+ const nonce = hexToBytes(nonceHex);
1119
+ if (nonce.length !== 16) {
1120
+ throw new CairnError("PAIRING", `nonce must be 16 bytes, got ${nonce.length}`);
1121
+ }
1122
+ const pakeHex = params.get("pake");
1123
+ if (!pakeHex) {
1124
+ throw new CairnError("PAIRING", "missing 'pake' parameter in pairing link");
1125
+ }
1126
+ const pakeCredential = hexToBytes(pakeHex);
1127
+ let hints;
1128
+ const hintsStr = params.get("hints");
1129
+ if (hintsStr) {
1130
+ hints = hintsStr.split(",").map((part) => {
1131
+ const colonIdx = part.indexOf(":");
1132
+ if (colonIdx === -1) {
1133
+ throw new CairnError("PAIRING", `invalid hint format: '${part}'`);
1134
+ }
1135
+ return {
1136
+ hintType: part.slice(0, colonIdx),
1137
+ value: part.slice(colonIdx + 1)
1138
+ };
1139
+ });
1140
+ }
1141
+ const createdAt = parseInt(params.get("t") ?? "0", 10) || 0;
1142
+ const expiresAt = parseInt(params.get("x") ?? "0", 10) || 0;
1143
+ const now = Math.floor(Date.now() / 1e3);
1144
+ if (now > expiresAt) {
1145
+ throw new CairnError("PAIRING", "pairing link has expired");
1146
+ }
1147
+ return { peerId, nonce, pakeCredential, hints, createdAt, expiresAt };
1148
+ }
1149
+ function bytesToHex2(bytes) {
1150
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1151
+ }
1152
+ function hexToBytes(hex) {
1153
+ if (hex.length % 2 !== 0) {
1154
+ throw new CairnError("PAIRING", `invalid hex string length: ${hex.length}`);
1155
+ }
1156
+ const bytes = new Uint8Array(hex.length / 2);
1157
+ for (let i = 0; i < hex.length; i += 2) {
1158
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
1159
+ }
1160
+ return bytes;
1161
+ }
1162
+
1163
+ // src/protocol/envelope.ts
1164
+ var import_cborg2 = require("cborg");
1165
+ var import_uuid = require("uuid");
1166
+ function newMsgId() {
1167
+ const uuid = (0, import_uuid.v7)();
1168
+ return new Uint8Array((0, import_uuid.parse)(uuid));
1169
+ }
1170
+ function envelopeToMap(envelope) {
1171
+ const map = /* @__PURE__ */ new Map();
1172
+ map.set(0, envelope.version);
1173
+ map.set(1, envelope.type);
1174
+ map.set(2, envelope.msgId);
1175
+ if (envelope.sessionId !== void 0) {
1176
+ map.set(3, envelope.sessionId);
1177
+ }
1178
+ map.set(4, envelope.payload);
1179
+ if (envelope.authTag !== void 0) {
1180
+ map.set(5, envelope.authTag);
1181
+ }
1182
+ return map;
1183
+ }
1184
+ function mapToEnvelope(map) {
1185
+ const version = map.get(0);
1186
+ if (version === void 0) {
1187
+ throw new CairnError("PROTOCOL", "missing required field: version (key 0)");
1188
+ }
1189
+ const type = map.get(1);
1190
+ if (type === void 0) {
1191
+ throw new CairnError("PROTOCOL", "missing required field: type (key 1)");
1192
+ }
1193
+ const msgId = map.get(2);
1194
+ if (msgId === void 0) {
1195
+ throw new CairnError("PROTOCOL", "missing required field: msgId (key 2)");
1196
+ }
1197
+ if (!(msgId instanceof Uint8Array) || msgId.length !== 16) {
1198
+ throw new CairnError("PROTOCOL", "msgId must be 16 bytes");
1199
+ }
1200
+ const payload = map.get(4);
1201
+ if (payload === void 0) {
1202
+ throw new CairnError("PROTOCOL", "missing required field: payload (key 4)");
1203
+ }
1204
+ const sessionId = map.get(3);
1205
+ if (sessionId !== void 0 && (!(sessionId instanceof Uint8Array) || sessionId.length !== 32)) {
1206
+ throw new CairnError("PROTOCOL", "sessionId must be 32 bytes");
1207
+ }
1208
+ const authTag = map.get(5);
1209
+ return {
1210
+ version,
1211
+ type,
1212
+ msgId,
1213
+ sessionId,
1214
+ payload: payload instanceof Uint8Array ? payload : new Uint8Array(payload),
1215
+ authTag
1216
+ };
1217
+ }
1218
+ function encodeEnvelope(envelope) {
1219
+ try {
1220
+ return (0, import_cborg2.encode)(envelopeToMap(envelope));
1221
+ } catch (e) {
1222
+ throw new CairnError("PROTOCOL", `CBOR encode error: ${e}`);
1223
+ }
1224
+ }
1225
+ function decodeEnvelope(data) {
1226
+ try {
1227
+ const decoded = (0, import_cborg2.decode)(data, { useMaps: true });
1228
+ if (!(decoded instanceof Map)) {
1229
+ throw new CairnError("PROTOCOL", "expected CBOR map");
1230
+ }
1231
+ return mapToEnvelope(decoded);
1232
+ } catch (e) {
1233
+ if (e instanceof CairnError) throw e;
1234
+ throw new CairnError("PROTOCOL", `CBOR decode error: ${e}`);
1235
+ }
1236
+ }
1237
+
1238
+ // src/protocol/message-types.ts
1239
+ var DATA_MESSAGE = 768;
1240
+
1241
+ // src/session/message-queue.ts
1242
+ function defaultQueueConfig() {
1243
+ return {
1244
+ enabled: true,
1245
+ maxSize: 1e3,
1246
+ maxAgeMs: 36e5,
1247
+ strategy: "fifo"
1248
+ };
1249
+ }
1250
+ var MessageQueue = class {
1251
+ _config;
1252
+ _messages = [];
1253
+ constructor(config) {
1254
+ const defaults = defaultQueueConfig();
1255
+ this._config = {
1256
+ enabled: config?.enabled ?? defaults.enabled,
1257
+ maxSize: config?.maxSize ?? defaults.maxSize,
1258
+ maxAgeMs: config?.maxAgeMs ?? defaults.maxAgeMs,
1259
+ strategy: config?.strategy ?? defaults.strategy
1260
+ };
1261
+ }
1262
+ /** Enqueue a message. Returns the enqueue result. */
1263
+ enqueue(sequence, payload) {
1264
+ if (!this._config.enabled) {
1265
+ return "disabled";
1266
+ }
1267
+ this.expireStale();
1268
+ const msg = {
1269
+ sequence,
1270
+ payload,
1271
+ enqueuedAt: Date.now()
1272
+ };
1273
+ if (this._messages.length >= this._config.maxSize) {
1274
+ if (this._config.strategy === "fifo") {
1275
+ return "full";
1276
+ }
1277
+ this._messages.shift();
1278
+ this._messages.push(msg);
1279
+ return "enqueued_with_eviction";
1280
+ }
1281
+ this._messages.push(msg);
1282
+ return "enqueued";
1283
+ }
1284
+ /** Drain all queued messages in sequence order for retransmission. */
1285
+ drain() {
1286
+ this.expireStale();
1287
+ const msgs = [...this._messages];
1288
+ this._messages = [];
1289
+ return msgs;
1290
+ }
1291
+ /** Discard all queued messages (e.g., on session re-establishment). */
1292
+ clear() {
1293
+ this._messages = [];
1294
+ }
1295
+ /** Get the number of currently queued messages. */
1296
+ get length() {
1297
+ return this._messages.length;
1298
+ }
1299
+ /** Check whether the queue is empty. */
1300
+ get isEmpty() {
1301
+ return this._messages.length === 0;
1302
+ }
1303
+ /** Get the remaining capacity. */
1304
+ get remainingCapacity() {
1305
+ return Math.max(0, this._config.maxSize - this._messages.length);
1306
+ }
1307
+ /** Peek at the next message without removing it. */
1308
+ peek() {
1309
+ return this._messages[0];
1310
+ }
1311
+ /** Get the queue configuration. */
1312
+ get config() {
1313
+ return this._config;
1314
+ }
1315
+ /** Remove messages older than maxAge. */
1316
+ expireStale() {
1317
+ const now = Date.now();
1318
+ this._messages = this._messages.filter((msg) => now - msg.enqueuedAt < this._config.maxAgeMs);
1319
+ }
1320
+ };
1321
+
1322
+ // src/node.ts
1323
+ var APP_MSG_TYPE_MIN = 61440;
1324
+ var APP_MSG_TYPE_MAX = 65535;
1325
+ var NodeChannel = class {
1326
+ constructor(name) {
1327
+ this.name = name;
1328
+ }
1329
+ _open = true;
1330
+ get isOpen() {
1331
+ return this._open;
1332
+ }
1333
+ close() {
1334
+ this._open = false;
1335
+ }
1336
+ };
1337
+ var NodeSession = class {
1338
+ constructor(peerId) {
1339
+ this.peerId = peerId;
1340
+ }
1341
+ _state = "connected";
1342
+ _channels = /* @__PURE__ */ new Map();
1343
+ _stateListeners = [];
1344
+ _channelListeners = [];
1345
+ _errorListeners = [];
1346
+ _messageHandlers = /* @__PURE__ */ new Map();
1347
+ _customHandlers = /* @__PURE__ */ new Map();
1348
+ _ratchet = null;
1349
+ _messageQueue = new MessageQueue();
1350
+ _sequenceCounter = 0;
1351
+ /** Outbox of encoded envelopes (transport would drain this). */
1352
+ outbox = [];
1353
+ /** Get the current connection state. */
1354
+ get state() {
1355
+ return this._state;
1356
+ }
1357
+ /** Get the Double Ratchet (for testing/inspection). */
1358
+ get ratchet() {
1359
+ return this._ratchet;
1360
+ }
1361
+ /** Get the message queue. */
1362
+ get messageQueue() {
1363
+ return this._messageQueue;
1364
+ }
1365
+ /** Set the ratchet for this session (called during connect). */
1366
+ _setRatchet(ratchet) {
1367
+ this._ratchet = ratchet;
1368
+ }
1369
+ /** Open a named channel. */
1370
+ openChannel(name) {
1371
+ if (!name) {
1372
+ throw new CairnError("PROTOCOL", "channel name cannot be empty");
1373
+ }
1374
+ if (name.startsWith("__cairn_")) {
1375
+ throw new CairnError("PROTOCOL", "reserved channel name prefix");
1376
+ }
1377
+ const channel = new NodeChannel(name);
1378
+ this._channels.set(name, channel);
1379
+ for (const listener of this._channelListeners) {
1380
+ listener(channel);
1381
+ }
1382
+ return channel;
1383
+ }
1384
+ /** Send data on a channel. Encrypts via Double Ratchet and wraps in CBOR envelope. */
1385
+ send(channel, data) {
1386
+ if (!channel.isOpen) {
1387
+ throw new CairnError("PROTOCOL", "channel is not open");
1388
+ }
1389
+ if (this._state === "disconnected" || this._state === "reconnecting" || this._state === "suspended") {
1390
+ const seq = this._sequenceCounter++;
1391
+ const result = this._messageQueue.enqueue(seq, data);
1392
+ if (result === "full") {
1393
+ throw new CairnError("PROTOCOL", "message queue is full");
1394
+ }
1395
+ if (result === "disabled") {
1396
+ throw new CairnError("PROTOCOL", "message queuing is disabled");
1397
+ }
1398
+ return;
1399
+ }
1400
+ let payload;
1401
+ if (this._ratchet) {
1402
+ const { header, ciphertext } = this._ratchet.encrypt(data);
1403
+ const headerJson = new TextEncoder().encode(JSON.stringify({
1404
+ dh_public: Array.from(header.dhPublic),
1405
+ prev_chain_len: header.prevChainLen,
1406
+ msg_num: header.msgNum
1407
+ }));
1408
+ const buf = new Uint8Array(4 + headerJson.length + ciphertext.length);
1409
+ const view = new DataView(buf.buffer);
1410
+ view.setUint32(0, headerJson.length);
1411
+ buf.set(headerJson, 4);
1412
+ buf.set(ciphertext, 4 + headerJson.length);
1413
+ payload = buf;
1414
+ } else {
1415
+ payload = data;
1416
+ }
1417
+ const envelope = {
1418
+ version: 1,
1419
+ type: DATA_MESSAGE,
1420
+ msgId: newMsgId(),
1421
+ payload
1422
+ };
1423
+ const encoded = encodeEnvelope(envelope);
1424
+ this.outbox.push(encoded);
1425
+ }
1426
+ /** Register a callback for incoming messages on a channel. */
1427
+ onMessage(channel, callback) {
1428
+ const handlers = this._messageHandlers.get(channel.name);
1429
+ if (handlers) {
1430
+ handlers.push(callback);
1431
+ } else {
1432
+ this._messageHandlers.set(channel.name, [callback]);
1433
+ }
1434
+ }
1435
+ /** Register a callback for connection state changes. */
1436
+ onStateChange(callback) {
1437
+ this._stateListeners.push(callback);
1438
+ }
1439
+ /** Register a callback for channel opened events. */
1440
+ onChannelOpened(callback) {
1441
+ this._channelListeners.push(callback);
1442
+ }
1443
+ /** Register a callback for errors. */
1444
+ onError(callback) {
1445
+ this._errorListeners.push(callback);
1446
+ }
1447
+ /**
1448
+ * Register a handler for application-specific message types (0xF000-0xFFFF).
1449
+ */
1450
+ onCustomMessage(typeCode, callback) {
1451
+ if (typeCode < APP_MSG_TYPE_MIN || typeCode > APP_MSG_TYPE_MAX) {
1452
+ throw new CairnError(
1453
+ "PROTOCOL",
1454
+ `custom message type 0x${typeCode.toString(16).padStart(4, "0")} outside application range 0xF000-0xFFFF`
1455
+ );
1456
+ }
1457
+ this._customHandlers.set(typeCode, callback);
1458
+ }
1459
+ /**
1460
+ * Dispatch an incoming CBOR envelope from the transport layer.
1461
+ * Decrypts if needed and routes to appropriate callbacks.
1462
+ */
1463
+ dispatchIncoming(envelopeBytes) {
1464
+ const envelope = decodeEnvelope(envelopeBytes);
1465
+ if (envelope.type === DATA_MESSAGE) {
1466
+ let plaintext;
1467
+ if (this._ratchet && envelope.payload.length >= 4) {
1468
+ const view = new DataView(envelope.payload.buffer, envelope.payload.byteOffset, envelope.payload.byteLength);
1469
+ const headerLen = view.getUint32(0);
1470
+ if (envelope.payload.length < 4 + headerLen) {
1471
+ throw new CairnError("PROTOCOL", "payload too short for header");
1472
+ }
1473
+ const headerJson = new TextDecoder().decode(envelope.payload.slice(4, 4 + headerLen));
1474
+ const headerObj = JSON.parse(headerJson);
1475
+ const header = {
1476
+ dhPublic: new Uint8Array(headerObj.dh_public),
1477
+ prevChainLen: headerObj.prev_chain_len,
1478
+ msgNum: headerObj.msg_num
1479
+ };
1480
+ const ciphertext = envelope.payload.slice(4 + headerLen);
1481
+ plaintext = this._ratchet.decrypt(header, ciphertext);
1482
+ } else {
1483
+ plaintext = envelope.payload;
1484
+ }
1485
+ for (const [, cbs] of this._messageHandlers) {
1486
+ for (const cb of cbs) {
1487
+ cb(plaintext);
1488
+ }
1489
+ }
1490
+ } else if (envelope.type >= APP_MSG_TYPE_MIN && envelope.type <= APP_MSG_TYPE_MAX) {
1491
+ const handler = this._customHandlers.get(envelope.type);
1492
+ if (handler) {
1493
+ handler(envelope.payload);
1494
+ }
1495
+ }
1496
+ }
1497
+ /** Drain queued messages after reconnection. Returns payloads. */
1498
+ drainMessageQueue() {
1499
+ return this._messageQueue.drain().map((m) => m.payload);
1500
+ }
1501
+ /** Close this session. */
1502
+ close() {
1503
+ const prev = this._state;
1504
+ this._state = "disconnected";
1505
+ for (const listener of this._stateListeners) {
1506
+ listener(prev, "disconnected");
1507
+ }
1508
+ }
1509
+ /** Transition state (for internal/test use). */
1510
+ _transitionState(to) {
1511
+ const prev = this._state;
1512
+ this._state = to;
1513
+ for (const listener of this._stateListeners) {
1514
+ listener(prev, to);
1515
+ }
1516
+ }
1517
+ };
1518
+ var Node = class _Node {
1519
+ _config;
1520
+ _sessions = /* @__PURE__ */ new Map();
1521
+ _peerPairedListeners = [];
1522
+ _peerUnpairedListeners = [];
1523
+ _errorListeners = [];
1524
+ _customRegistry = /* @__PURE__ */ new Map();
1525
+ _natType = "unknown";
1526
+ _closed = false;
1527
+ _identity = null;
1528
+ _pairedPeers = /* @__PURE__ */ new Set();
1529
+ constructor(config) {
1530
+ this._config = config;
1531
+ }
1532
+ /** Create a new cairn peer node with zero-config defaults (Tier 0). */
1533
+ static async create(config) {
1534
+ const resolved = resolveConfig(config);
1535
+ const node = new _Node(resolved);
1536
+ node._identity = await IdentityKeypair.generate();
1537
+ return node;
1538
+ }
1539
+ /**
1540
+ * Create a server-mode cairn node.
1541
+ *
1542
+ * Server mode is NOT a separate class — it applies server-mode defaults:
1543
+ * meshEnabled: true, relayWilling: true, relayCapacity: 100, etc.
1544
+ */
1545
+ static async createServer(config) {
1546
+ const serverMeshDefaults = {
1547
+ meshEnabled: true,
1548
+ relayWilling: true,
1549
+ relayCapacity: 100,
1550
+ maxHops: 3
1551
+ };
1552
+ const serverReconnectionDefaults = {
1553
+ ...DEFAULT_RECONNECTION_POLICY,
1554
+ sessionExpiry: 7 * 24 * 60 * 60 * 1e3,
1555
+ rendezvousPollInterval: 3e4,
1556
+ reconnectMaxDuration: Infinity
1557
+ };
1558
+ const merged = {
1559
+ ...config,
1560
+ meshSettings: { ...serverMeshDefaults, ...config?.meshSettings },
1561
+ reconnectionPolicy: { ...serverReconnectionDefaults, ...config?.reconnectionPolicy }
1562
+ };
1563
+ const resolved = resolveConfig(merged);
1564
+ const node = new _Node(resolved);
1565
+ node._identity = await IdentityKeypair.generate();
1566
+ return node;
1567
+ }
1568
+ /** Get the node configuration. */
1569
+ get config() {
1570
+ return this._config;
1571
+ }
1572
+ /** Whether this node has been closed. */
1573
+ get isClosed() {
1574
+ return this._closed;
1575
+ }
1576
+ /** Get the node's identity keypair. */
1577
+ get identity() {
1578
+ return this._identity;
1579
+ }
1580
+ /** Get the node's peer ID. */
1581
+ get peerId() {
1582
+ return this._identity?.peerId() ?? null;
1583
+ }
1584
+ // --- Event listeners ---
1585
+ onPeerPaired(callback) {
1586
+ this._peerPairedListeners.push(callback);
1587
+ }
1588
+ onPeerUnpaired(callback) {
1589
+ this._peerUnpairedListeners.push(callback);
1590
+ }
1591
+ onError(callback) {
1592
+ this._errorListeners.push(callback);
1593
+ }
1594
+ /**
1595
+ * Register a node-wide handler for a custom message type (0xF000-0xFFFF).
1596
+ *
1597
+ * Node-level handlers are invoked when a custom message arrives on any session
1598
+ * that does not have a per-session handler for the type code.
1599
+ */
1600
+ registerCustomMessage(typeCode, handler) {
1601
+ if (typeCode < APP_MSG_TYPE_MIN || typeCode > APP_MSG_TYPE_MAX) {
1602
+ throw new CairnError(
1603
+ "PROTOCOL",
1604
+ `custom message type 0x${typeCode.toString(16).padStart(4, "0")} outside application range 0xF000-0xFFFF`
1605
+ );
1606
+ }
1607
+ this._customRegistry.set(typeCode, handler);
1608
+ }
1609
+ // --- Internal helpers ---
1610
+ _createPairingPayload() {
1611
+ if (!this._identity) {
1612
+ throw new CairnError("PROTOCOL", "node identity not initialized");
1613
+ }
1614
+ const nonce = generateNonce();
1615
+ const now = Math.floor(Date.now() / 1e3);
1616
+ const ttlSec = Math.floor(this._config.reconnectionPolicy.pairingPayloadExpiry / 1e3);
1617
+ return {
1618
+ peerId: this._identity.peerId(),
1619
+ nonce,
1620
+ pakeCredential: nonce,
1621
+ // use nonce as PAKE credential (same as Rust)
1622
+ createdAt: now,
1623
+ expiresAt: now + ttlSec
1624
+ };
1625
+ }
1626
+ _runPairingExchange(password) {
1627
+ const alice = Spake2.startA(password);
1628
+ const bob = Spake2.startB(password);
1629
+ alice.finish(bob.outboundMsg);
1630
+ bob.finish(alice.outboundMsg);
1631
+ }
1632
+ _completePairing(remotePeerId) {
1633
+ this._pairedPeers.add(remotePeerId);
1634
+ for (const listener of this._peerPairedListeners) {
1635
+ listener(remotePeerId);
1636
+ }
1637
+ }
1638
+ async _performNoiseHandshake() {
1639
+ if (!this._identity) {
1640
+ throw new CairnError("PROTOCOL", "node identity not initialized");
1641
+ }
1642
+ const remoteId = await IdentityKeypair.generate();
1643
+ const initiator = new NoiseXXHandshake("initiator", this._identity);
1644
+ const responder = new NoiseXXHandshake("responder", remoteId);
1645
+ const out1 = initiator.step();
1646
+ if (out1.type !== "send_message") throw new CairnError("CRYPTO", "unexpected at msg1");
1647
+ const out2 = responder.step(out1.data);
1648
+ if (out2.type !== "send_message") throw new CairnError("CRYPTO", "unexpected at msg2");
1649
+ const out3 = initiator.step(out2.data);
1650
+ if (out3.type !== "send_message") throw new CairnError("CRYPTO", "unexpected at msg3");
1651
+ responder.step(out3.data);
1652
+ return initiator.getResult();
1653
+ }
1654
+ // --- Pairing methods (spec section 3.3) ---
1655
+ async pairGenerateQr() {
1656
+ const payload = this._createPairingPayload();
1657
+ const cbor = generateQrPayload(payload);
1658
+ return {
1659
+ payload: cbor,
1660
+ expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry
1661
+ };
1662
+ }
1663
+ async pairScanQr(data) {
1664
+ const payload = consumeQrPayload(data);
1665
+ this._runPairingExchange(payload.pakeCredential);
1666
+ const remotePeerId = bytesToHex3(payload.peerId);
1667
+ this._completePairing(remotePeerId);
1668
+ return remotePeerId;
1669
+ }
1670
+ async pairGeneratePin() {
1671
+ const raw = generatePin();
1672
+ return {
1673
+ pin: formatPin(raw),
1674
+ expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry
1675
+ };
1676
+ }
1677
+ async pairEnterPin(pin) {
1678
+ const normalized = normalizePin(pin);
1679
+ validatePin(normalized);
1680
+ const password = new TextEncoder().encode(normalized);
1681
+ this._runPairingExchange(password);
1682
+ const remotePeerId = bytesToHex3(crypto.getRandomValues(new Uint8Array(32)));
1683
+ this._completePairing(remotePeerId);
1684
+ return remotePeerId;
1685
+ }
1686
+ async pairGenerateLink() {
1687
+ const payload = this._createPairingPayload();
1688
+ const uri = generatePairingLink(payload);
1689
+ return {
1690
+ uri,
1691
+ expiresIn: this._config.reconnectionPolicy.pairingPayloadExpiry
1692
+ };
1693
+ }
1694
+ async pairFromLink(uri) {
1695
+ const payload = parsePairingLink(uri);
1696
+ this._runPairingExchange(payload.pakeCredential);
1697
+ const remotePeerId = bytesToHex3(payload.peerId);
1698
+ this._completePairing(remotePeerId);
1699
+ return remotePeerId;
1700
+ }
1701
+ // --- Connection methods ---
1702
+ /** Connect to a paired peer. Performs Noise XX handshake and Double Ratchet init. */
1703
+ async connect(peerId, _options) {
1704
+ const handshakeResult = await this._performNoiseHandshake();
1705
+ const bobDh = X25519Keypair.generate();
1706
+ const ratchet = DoubleRatchet.initSender(handshakeResult.sessionKey, bobDh.publicKeyBytes());
1707
+ const session = new NodeSession(peerId);
1708
+ session._setRatchet(ratchet);
1709
+ this._sessions.set(peerId, session);
1710
+ return session;
1711
+ }
1712
+ /** Unpair a peer, removing trust and closing sessions. */
1713
+ async unpair(peerId) {
1714
+ this._pairedPeers.delete(peerId);
1715
+ this._sessions.delete(peerId);
1716
+ for (const listener of this._peerUnpairedListeners) {
1717
+ listener(peerId);
1718
+ }
1719
+ }
1720
+ /** Get network diagnostic information. */
1721
+ async networkInfo() {
1722
+ return { natType: this._natType };
1723
+ }
1724
+ /** Update the detected NAT type (called by transport layer). */
1725
+ setNatType(natType) {
1726
+ this._natType = natType;
1727
+ }
1728
+ /** Close the node and all sessions. */
1729
+ async close() {
1730
+ this._closed = true;
1731
+ for (const session of this._sessions.values()) {
1732
+ session.close();
1733
+ }
1734
+ this._sessions.clear();
1735
+ }
1736
+ };
1737
+ function resolveConfig(partial) {
1738
+ return {
1739
+ stunServers: partial?.stunServers ?? [...DEFAULT_STUN_SERVERS],
1740
+ turnServers: partial?.turnServers ?? [],
1741
+ signalingServers: partial?.signalingServers ?? [],
1742
+ trackerUrls: partial?.trackerUrls ?? [],
1743
+ bootstrapNodes: partial?.bootstrapNodes ?? [],
1744
+ transportPreferences: partial?.transportPreferences ?? [...DEFAULT_TRANSPORT_PREFERENCES],
1745
+ reconnectionPolicy: {
1746
+ connectTimeout: DEFAULT_RECONNECTION_POLICY.connectTimeout,
1747
+ transportTimeout: DEFAULT_RECONNECTION_POLICY.transportTimeout,
1748
+ reconnectMaxDuration: DEFAULT_RECONNECTION_POLICY.reconnectMaxDuration,
1749
+ reconnectBackoff: {
1750
+ ...DEFAULT_RECONNECTION_POLICY.reconnectBackoff,
1751
+ ...partial?.reconnectionPolicy?.reconnectBackoff
1752
+ },
1753
+ rendezvousPollInterval: DEFAULT_RECONNECTION_POLICY.rendezvousPollInterval,
1754
+ sessionExpiry: DEFAULT_RECONNECTION_POLICY.sessionExpiry,
1755
+ pairingPayloadExpiry: DEFAULT_RECONNECTION_POLICY.pairingPayloadExpiry,
1756
+ ...partial?.reconnectionPolicy
1757
+ },
1758
+ meshSettings: {
1759
+ meshEnabled: DEFAULT_MESH_SETTINGS.meshEnabled,
1760
+ maxHops: DEFAULT_MESH_SETTINGS.maxHops,
1761
+ relayWilling: DEFAULT_MESH_SETTINGS.relayWilling,
1762
+ relayCapacity: DEFAULT_MESH_SETTINGS.relayCapacity,
1763
+ ...partial?.meshSettings
1764
+ },
1765
+ storageBackend: partial?.storageBackend ?? "memory"
1766
+ };
1767
+ }
1768
+ function bytesToHex3(bytes) {
1769
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1770
+ }
1771
+
1772
+ // src/session/state-machine.ts
1773
+ var VALID_TRANSITIONS = [
1774
+ ["connected", "unstable"],
1775
+ ["connected", "disconnected"],
1776
+ ["unstable", "disconnected"],
1777
+ ["unstable", "connected"],
1778
+ ["disconnected", "reconnecting"],
1779
+ ["reconnecting", "reconnected"],
1780
+ ["reconnecting", "suspended"],
1781
+ ["suspended", "reconnecting"],
1782
+ ["suspended", "failed"],
1783
+ ["reconnected", "connected"]
1784
+ ];
1785
+ function isValidTransition(from, to) {
1786
+ return VALID_TRANSITIONS.some(([f, t]) => f === from && t === to);
1787
+ }
1788
+ var SessionStateMachine = class {
1789
+ _sessionId;
1790
+ _state;
1791
+ _listeners = [];
1792
+ constructor(sessionId, initialState = "connected") {
1793
+ this._sessionId = sessionId;
1794
+ this._state = initialState;
1795
+ }
1796
+ /** Get the current state. */
1797
+ get state() {
1798
+ return this._state;
1799
+ }
1800
+ /** Get the session ID. */
1801
+ get sessionId() {
1802
+ return this._sessionId;
1803
+ }
1804
+ /** Subscribe to state change events. */
1805
+ onStateChanged(listener) {
1806
+ this._listeners.push(listener);
1807
+ }
1808
+ /**
1809
+ * Attempt a state transition.
1810
+ *
1811
+ * Throws CairnError if the transition is not allowed by the state diagram.
1812
+ * Calls all registered state_changed listeners on success.
1813
+ */
1814
+ transition(to, reason) {
1815
+ if (!isValidTransition(this._state, to)) {
1816
+ throw new CairnError(
1817
+ "PROTOCOL",
1818
+ `invalid session state transition: ${this._state} -> ${to}`
1819
+ );
1820
+ }
1821
+ const from = this._state;
1822
+ this._state = to;
1823
+ const event = {
1824
+ sessionId: this._sessionId,
1825
+ fromState: from,
1826
+ toState: to,
1827
+ timestamp: Date.now(),
1828
+ reason
1829
+ };
1830
+ for (const listener of this._listeners) {
1831
+ listener(event);
1832
+ }
1833
+ }
1834
+ };
1835
+
1836
+ // src/server/management.ts
1837
+ var import_node_http = require("http");
1838
+ var import_node_crypto = require("crypto");
1839
+
1840
+ // src/server/index.ts
1841
+ function defaultServerConfig() {
1842
+ return {
1843
+ meshEnabled: true,
1844
+ relayWilling: true,
1845
+ relayCapacity: 100,
1846
+ storeForwardEnabled: true,
1847
+ storeForwardMaxPerPeer: 1e3,
1848
+ storeForwardMaxAgeMs: 7 * 24 * 60 * 60 * 1e3,
1849
+ // 7 days
1850
+ storeForwardMaxTotalSize: 1073741824,
1851
+ // 1 GB
1852
+ sessionExpiryMs: 7 * 24 * 60 * 60 * 1e3,
1853
+ // 7 days
1854
+ heartbeatIntervalMs: 6e4,
1855
+ // 60s
1856
+ reconnectMaxDurationMs: null,
1857
+ // indefinite
1858
+ headless: true
1859
+ };
1860
+ }
1861
+ // Annotate the CommonJS export names for ESM import in node:
1862
+ 0 && (module.exports = {
1863
+ AuthenticationFailedError,
1864
+ CairnError,
1865
+ DEFAULT_MESH_SETTINGS,
1866
+ DEFAULT_RECONNECTION_POLICY,
1867
+ DEFAULT_STUN_SERVERS,
1868
+ DEFAULT_TRANSPORT_PREFERENCES,
1869
+ ErrorBehavior,
1870
+ MeshRouteNotFoundError,
1871
+ Node,
1872
+ NodeChannel,
1873
+ NodeSession,
1874
+ PairingExpiredError,
1875
+ PairingRejectedError,
1876
+ PeerUnreachableError,
1877
+ SessionExpiredError,
1878
+ SessionStateMachine,
1879
+ TransportExhaustedError,
1880
+ VersionMismatchError,
1881
+ defaultServerConfig,
1882
+ isValidTransition
1883
+ });