bitchat-node 0.1.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 (102) hide show
  1. package/README.md +223 -0
  2. package/dist/bin/bitchat.d.ts +7 -0
  3. package/dist/bin/bitchat.d.ts.map +1 -0
  4. package/dist/bin/bitchat.js +69 -0
  5. package/dist/bin/bitchat.js.map +1 -0
  6. package/dist/client.d.ts +77 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +411 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/crypto/index.d.ts +6 -0
  11. package/dist/crypto/index.d.ts.map +1 -0
  12. package/dist/crypto/index.js +6 -0
  13. package/dist/crypto/index.js.map +1 -0
  14. package/dist/crypto/noise.d.ts +72 -0
  15. package/dist/crypto/noise.d.ts.map +1 -0
  16. package/dist/crypto/noise.js +470 -0
  17. package/dist/crypto/noise.js.map +1 -0
  18. package/dist/crypto/signing.d.ts +34 -0
  19. package/dist/crypto/signing.d.ts.map +1 -0
  20. package/dist/crypto/signing.js +56 -0
  21. package/dist/crypto/signing.js.map +1 -0
  22. package/dist/index.d.ts +32 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +48 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/mesh/deduplicator.d.ts +48 -0
  27. package/dist/mesh/deduplicator.d.ts.map +1 -0
  28. package/dist/mesh/deduplicator.js +107 -0
  29. package/dist/mesh/deduplicator.js.map +1 -0
  30. package/dist/mesh/index.d.ts +6 -0
  31. package/dist/mesh/index.d.ts.map +1 -0
  32. package/dist/mesh/index.js +6 -0
  33. package/dist/mesh/index.js.map +1 -0
  34. package/dist/mesh/router.d.ts +90 -0
  35. package/dist/mesh/router.d.ts.map +1 -0
  36. package/dist/mesh/router.js +204 -0
  37. package/dist/mesh/router.js.map +1 -0
  38. package/dist/protocol/binary.d.ts +37 -0
  39. package/dist/protocol/binary.d.ts.map +1 -0
  40. package/dist/protocol/binary.js +310 -0
  41. package/dist/protocol/binary.js.map +1 -0
  42. package/dist/protocol/constants.d.ts +30 -0
  43. package/dist/protocol/constants.d.ts.map +1 -0
  44. package/dist/protocol/constants.js +37 -0
  45. package/dist/protocol/constants.js.map +1 -0
  46. package/dist/protocol/index.d.ts +8 -0
  47. package/dist/protocol/index.d.ts.map +1 -0
  48. package/dist/protocol/index.js +8 -0
  49. package/dist/protocol/index.js.map +1 -0
  50. package/dist/protocol/packets.d.ts +38 -0
  51. package/dist/protocol/packets.d.ts.map +1 -0
  52. package/dist/protocol/packets.js +177 -0
  53. package/dist/protocol/packets.js.map +1 -0
  54. package/dist/protocol/types.d.ts +134 -0
  55. package/dist/protocol/types.d.ts.map +1 -0
  56. package/dist/protocol/types.js +108 -0
  57. package/dist/protocol/types.js.map +1 -0
  58. package/dist/session/index.d.ts +5 -0
  59. package/dist/session/index.d.ts.map +1 -0
  60. package/dist/session/index.js +5 -0
  61. package/dist/session/index.js.map +1 -0
  62. package/dist/session/manager.d.ts +113 -0
  63. package/dist/session/manager.d.ts.map +1 -0
  64. package/dist/session/manager.js +371 -0
  65. package/dist/session/manager.js.map +1 -0
  66. package/dist/transport/ble.d.ts +92 -0
  67. package/dist/transport/ble.d.ts.map +1 -0
  68. package/dist/transport/ble.js +434 -0
  69. package/dist/transport/ble.js.map +1 -0
  70. package/dist/transport/index.d.ts +5 -0
  71. package/dist/transport/index.d.ts.map +1 -0
  72. package/dist/transport/index.js +5 -0
  73. package/dist/transport/index.js.map +1 -0
  74. package/dist/ui/index.d.ts +2 -0
  75. package/dist/ui/index.d.ts.map +1 -0
  76. package/dist/ui/index.js +2 -0
  77. package/dist/ui/index.js.map +1 -0
  78. package/dist/ui/server.d.ts +16 -0
  79. package/dist/ui/server.d.ts.map +1 -0
  80. package/dist/ui/server.js +510 -0
  81. package/dist/ui/server.js.map +1 -0
  82. package/package.json +79 -0
  83. package/src/bin/bitchat.ts +87 -0
  84. package/src/client.ts +519 -0
  85. package/src/crypto/index.ts +22 -0
  86. package/src/crypto/noise.ts +574 -0
  87. package/src/crypto/signing.ts +66 -0
  88. package/src/index.ts +95 -0
  89. package/src/mesh/deduplicator.ts +129 -0
  90. package/src/mesh/index.ts +6 -0
  91. package/src/mesh/router.ts +258 -0
  92. package/src/protocol/binary.ts +345 -0
  93. package/src/protocol/constants.ts +43 -0
  94. package/src/protocol/index.ts +15 -0
  95. package/src/protocol/packets.ts +223 -0
  96. package/src/protocol/types.ts +182 -0
  97. package/src/session/index.ts +9 -0
  98. package/src/session/manager.ts +476 -0
  99. package/src/transport/ble.ts +553 -0
  100. package/src/transport/index.ts +10 -0
  101. package/src/ui/index.ts +1 -0
  102. package/src/ui/server.ts +569 -0
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Noise Protocol Implementation
3
+ * Implements Noise_XX_25519_ChaChaPoly_SHA256 per the Noise spec
4
+ * From: bitchat/Noise/NoiseProtocol.swift
5
+ *
6
+ * @see http://www.noiseprotocol.org/noise.html
7
+ */
8
+
9
+ import * as chacha from '@stablelib/chacha20poly1305';
10
+ import { hmac } from '@stablelib/hmac';
11
+ import { hash, SHA256 } from '@stablelib/sha256';
12
+ import * as x25519 from '@stablelib/x25519';
13
+
14
+ const PROTOCOL_NAME = 'Noise_XX_25519_ChaChaPoly_SHA256';
15
+ const KEY_SIZE = 32;
16
+ const NONCE_SIZE = 12;
17
+ const TAG_SIZE = 16;
18
+ const NONCE_BYTES_IN_PACKET = 4; // For extracted nonce mode
19
+
20
+ // -------------------------------------------------------------------
21
+ // HKDF Implementation
22
+ // -------------------------------------------------------------------
23
+
24
+ function hkdfExtract(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array): Uint8Array {
25
+ return Uint8Array.from(hmac(SHA256, chainingKey, inputKeyMaterial));
26
+ }
27
+
28
+ function hkdfExpand(prk: Uint8Array, numOutputs: number): Uint8Array[] {
29
+ const outputs: Uint8Array[] = [];
30
+ let prev = new Uint8Array(0);
31
+
32
+ for (let i = 0; i < numOutputs; i++) {
33
+ const input = new Uint8Array(prev.length + 1);
34
+ input.set(prev);
35
+ input[prev.length] = i + 1;
36
+ prev = Uint8Array.from(hmac(SHA256, prk, input));
37
+ outputs.push(Uint8Array.from(prev));
38
+ }
39
+
40
+ return outputs;
41
+ }
42
+
43
+ function hkdf(
44
+ chainingKey: Uint8Array,
45
+ inputKeyMaterial: Uint8Array,
46
+ numOutputs: number
47
+ ): Uint8Array[] {
48
+ const tempKey = hkdfExtract(chainingKey, inputKeyMaterial);
49
+ return hkdfExpand(tempKey, numOutputs);
50
+ }
51
+
52
+ // -------------------------------------------------------------------
53
+ // Cipher State
54
+ // -------------------------------------------------------------------
55
+
56
+ /**
57
+ * Manages symmetric encryption with nonce tracking and replay protection
58
+ */
59
+ export class CipherState {
60
+ private key: Uint8Array | null = null;
61
+ private nonce: bigint = 0n;
62
+ private readonly useExtractedNonce: boolean;
63
+
64
+ // Replay protection (sliding window)
65
+ private highestReceivedNonce: bigint = 0n;
66
+ private replayWindow: Uint8Array = new Uint8Array(128); // 1024 bits
67
+
68
+ constructor(key?: Uint8Array, useExtractedNonce = false) {
69
+ if (key) this.key = Uint8Array.from(key);
70
+ this.useExtractedNonce = useExtractedNonce;
71
+ }
72
+
73
+ initializeKey(key: Uint8Array): void {
74
+ this.key = Uint8Array.from(key);
75
+ this.nonce = 0n;
76
+ }
77
+
78
+ hasKey(): boolean {
79
+ return this.key !== null;
80
+ }
81
+
82
+ private makeNonceBytes(n: bigint): Uint8Array {
83
+ const bytes = new Uint8Array(NONCE_SIZE);
84
+ const view = new DataView(bytes.buffer);
85
+ // Noise spec: 4 zero bytes + 8 bytes little-endian counter
86
+ view.setBigUint64(4, n, true);
87
+ return bytes;
88
+ }
89
+
90
+ private isValidNonce(receivedNonce: bigint): boolean {
91
+ const windowSize = 1024n;
92
+
93
+ if (
94
+ this.highestReceivedNonce >= windowSize &&
95
+ receivedNonce <= this.highestReceivedNonce - windowSize
96
+ ) {
97
+ return false; // Too old
98
+ }
99
+
100
+ if (receivedNonce > this.highestReceivedNonce) {
101
+ return true; // New
102
+ }
103
+
104
+ // Check window
105
+ const offset = Number(this.highestReceivedNonce - receivedNonce);
106
+ const byteIndex = Math.floor(offset / 8);
107
+ const bitIndex = offset % 8;
108
+
109
+ return (this.replayWindow[byteIndex] & (1 << bitIndex)) === 0;
110
+ }
111
+
112
+ private markNonceAsSeen(receivedNonce: bigint): void {
113
+ if (receivedNonce > this.highestReceivedNonce) {
114
+ const shift = Number(receivedNonce - this.highestReceivedNonce);
115
+
116
+ if (shift >= 1024) {
117
+ this.replayWindow.fill(0);
118
+ } else {
119
+ // Shift window
120
+ for (let i = this.replayWindow.length - 1; i >= 0; i--) {
121
+ const sourceByteIndex = i - Math.floor(shift / 8);
122
+ let newByte = 0;
123
+
124
+ if (sourceByteIndex >= 0) {
125
+ newByte = this.replayWindow[sourceByteIndex] >> (shift % 8);
126
+ if (sourceByteIndex > 0 && shift % 8 !== 0) {
127
+ newByte |= this.replayWindow[sourceByteIndex - 1] << (8 - (shift % 8));
128
+ }
129
+ }
130
+
131
+ this.replayWindow[i] = newByte;
132
+ }
133
+ }
134
+
135
+ this.highestReceivedNonce = receivedNonce;
136
+ this.replayWindow[0] |= 1;
137
+ } else {
138
+ const offset = Number(this.highestReceivedNonce - receivedNonce);
139
+ const byteIndex = Math.floor(offset / 8);
140
+ const bitIndex = offset % 8;
141
+ this.replayWindow[byteIndex] |= 1 << bitIndex;
142
+ }
143
+ }
144
+
145
+ encrypt(plaintext: Uint8Array, ad: Uint8Array = new Uint8Array()): Uint8Array {
146
+ if (!this.key) throw new Error('Cipher not initialized');
147
+
148
+ const currentNonce = this.nonce;
149
+ this.nonce += 1n;
150
+
151
+ const nonceBytes = this.makeNonceBytes(currentNonce);
152
+ const cipher = new chacha.ChaCha20Poly1305(this.key);
153
+ const ciphertext = cipher.seal(nonceBytes, plaintext, ad);
154
+
155
+ if (this.useExtractedNonce) {
156
+ // Prepend 4-byte nonce (big-endian)
157
+ const result = new Uint8Array(NONCE_BYTES_IN_PACKET + ciphertext.length);
158
+ const view = new DataView(result.buffer);
159
+ view.setUint32(0, Number(currentNonce), false);
160
+ result.set(ciphertext, NONCE_BYTES_IN_PACKET);
161
+ return result;
162
+ }
163
+
164
+ return ciphertext;
165
+ }
166
+
167
+ decrypt(ciphertext: Uint8Array, ad: Uint8Array = new Uint8Array()): Uint8Array {
168
+ if (!this.key) throw new Error('Cipher not initialized');
169
+
170
+ let actualCiphertext: Uint8Array;
171
+ let decryptionNonce: bigint;
172
+
173
+ if (this.useExtractedNonce) {
174
+ if (ciphertext.length < NONCE_BYTES_IN_PACKET + TAG_SIZE) {
175
+ throw new Error('Ciphertext too short');
176
+ }
177
+ const view = new DataView(ciphertext.buffer, ciphertext.byteOffset);
178
+ decryptionNonce = BigInt(view.getUint32(0, false));
179
+ actualCiphertext = ciphertext.subarray(NONCE_BYTES_IN_PACKET);
180
+
181
+ if (!this.isValidNonce(decryptionNonce)) {
182
+ throw new Error('Replay detected');
183
+ }
184
+ } else {
185
+ decryptionNonce = this.nonce;
186
+ this.nonce += 1n;
187
+ actualCiphertext = ciphertext;
188
+ }
189
+
190
+ const nonceBytes = this.makeNonceBytes(decryptionNonce);
191
+ const cipher = new chacha.ChaCha20Poly1305(this.key);
192
+ const plaintext = cipher.open(nonceBytes, actualCiphertext, ad);
193
+
194
+ if (!plaintext) {
195
+ throw new Error('Decryption failed');
196
+ }
197
+
198
+ if (this.useExtractedNonce) {
199
+ this.markNonceAsSeen(decryptionNonce);
200
+ }
201
+
202
+ return plaintext;
203
+ }
204
+
205
+ clear(): void {
206
+ if (this.key) {
207
+ this.key.fill(0);
208
+ this.key = null;
209
+ }
210
+ this.nonce = 0n;
211
+ this.highestReceivedNonce = 0n;
212
+ this.replayWindow.fill(0);
213
+ }
214
+ }
215
+
216
+ // -------------------------------------------------------------------
217
+ // Symmetric State
218
+ // -------------------------------------------------------------------
219
+
220
+ class SymmetricState {
221
+ private cipherState: CipherState;
222
+ private chainingKey: Uint8Array;
223
+ private h: Uint8Array;
224
+
225
+ constructor(protocolName: string) {
226
+ this.cipherState = new CipherState();
227
+
228
+ const nameBytes = new TextEncoder().encode(protocolName);
229
+ if (nameBytes.length <= 32) {
230
+ this.h = new Uint8Array(32);
231
+ this.h.set(nameBytes);
232
+ } else {
233
+ this.h = hash(nameBytes);
234
+ }
235
+ this.chainingKey = Uint8Array.from(this.h);
236
+ }
237
+
238
+ mixKey(inputKeyMaterial: Uint8Array): void {
239
+ const outputs = hkdf(this.chainingKey, inputKeyMaterial, 2);
240
+ this.chainingKey = outputs[0];
241
+ this.cipherState.initializeKey(outputs[1]);
242
+ }
243
+
244
+ mixHash(data: Uint8Array): void {
245
+ const combined = new Uint8Array(this.h.length + data.length);
246
+ combined.set(this.h);
247
+ combined.set(data, this.h.length);
248
+ this.h = hash(combined);
249
+ }
250
+
251
+ getHandshakeHash(): Uint8Array {
252
+ return Uint8Array.from(this.h);
253
+ }
254
+
255
+ hasCipherKey(): boolean {
256
+ return this.cipherState.hasKey();
257
+ }
258
+
259
+ encryptAndHash(plaintext: Uint8Array): Uint8Array {
260
+ if (this.cipherState.hasKey()) {
261
+ const ciphertext = this.cipherState.encrypt(plaintext, this.h);
262
+ this.mixHash(ciphertext);
263
+ return ciphertext;
264
+ } else {
265
+ this.mixHash(plaintext);
266
+ return plaintext;
267
+ }
268
+ }
269
+
270
+ decryptAndHash(ciphertext: Uint8Array): Uint8Array {
271
+ if (this.cipherState.hasKey()) {
272
+ const plaintext = this.cipherState.decrypt(ciphertext, this.h);
273
+ this.mixHash(ciphertext);
274
+ return plaintext;
275
+ } else {
276
+ this.mixHash(ciphertext);
277
+ return ciphertext;
278
+ }
279
+ }
280
+
281
+ split(useExtractedNonce: boolean): [CipherState, CipherState] {
282
+ const outputs = hkdf(this.chainingKey, new Uint8Array(), 2);
283
+
284
+ const c1 = new CipherState(outputs[0], useExtractedNonce);
285
+ const c2 = new CipherState(outputs[1], useExtractedNonce);
286
+
287
+ // Clear sensitive state
288
+ this.chainingKey.fill(0);
289
+ this.h.fill(0);
290
+
291
+ return [c1, c2];
292
+ }
293
+ }
294
+
295
+ // -------------------------------------------------------------------
296
+ // Handshake State
297
+ // -------------------------------------------------------------------
298
+
299
+ export type NoiseRole = 'initiator' | 'responder';
300
+
301
+ type Token = 'e' | 's' | 'ee' | 'es' | 'se' | 'ss';
302
+ const XX_PATTERNS: Token[][] = [['e'], ['e', 'ee', 's', 'es'], ['s', 'se']];
303
+
304
+ export interface NoiseKeyPair {
305
+ publicKey: Uint8Array;
306
+ secretKey: Uint8Array;
307
+ }
308
+
309
+ /**
310
+ * Orchestrates the XX handshake
311
+ */
312
+ export class HandshakeState {
313
+ private readonly role: NoiseRole;
314
+ private readonly symmetricState: SymmetricState;
315
+ private currentPattern = 0;
316
+
317
+ private localStaticPrivate: Uint8Array;
318
+ private localStaticPublic: Uint8Array;
319
+ private localEphemeralPrivate?: Uint8Array;
320
+ private localEphemeralPublic?: Uint8Array;
321
+ private remoteStaticPublic?: Uint8Array;
322
+ private remoteEphemeralPublic?: Uint8Array;
323
+
324
+ constructor(
325
+ role: NoiseRole,
326
+ localStaticKeyPair: NoiseKeyPair,
327
+ prologue: Uint8Array = new Uint8Array()
328
+ ) {
329
+ this.role = role;
330
+ this.localStaticPrivate = localStaticKeyPair.secretKey;
331
+ this.localStaticPublic = localStaticKeyPair.publicKey;
332
+
333
+ this.symmetricState = new SymmetricState(PROTOCOL_NAME);
334
+ this.symmetricState.mixHash(prologue);
335
+ }
336
+
337
+ writeMessage(payload: Uint8Array = new Uint8Array()): Uint8Array {
338
+ if (this.currentPattern >= XX_PATTERNS.length) {
339
+ throw new Error('Handshake already complete');
340
+ }
341
+
342
+ const parts: Uint8Array[] = [];
343
+ const pattern = XX_PATTERNS[this.currentPattern];
344
+
345
+ for (const token of pattern) {
346
+ switch (token) {
347
+ case 'e': {
348
+ const kp = x25519.generateKeyPair();
349
+ this.localEphemeralPrivate = kp.secretKey;
350
+ this.localEphemeralPublic = kp.publicKey;
351
+ parts.push(Uint8Array.from(this.localEphemeralPublic));
352
+ this.symmetricState.mixHash(this.localEphemeralPublic);
353
+ break;
354
+ }
355
+ case 's': {
356
+ const encrypted = this.symmetricState.encryptAndHash(this.localStaticPublic);
357
+ parts.push(encrypted);
358
+ break;
359
+ }
360
+ case 'ee': {
361
+ if (!this.localEphemeralPrivate || !this.remoteEphemeralPublic) {
362
+ throw new Error('Missing ephemeral keys for ee');
363
+ }
364
+ const shared = x25519.sharedKey(this.localEphemeralPrivate, this.remoteEphemeralPublic);
365
+ this.symmetricState.mixKey(shared);
366
+ break;
367
+ }
368
+ case 'es': {
369
+ const [localKey, remoteKey] =
370
+ this.role === 'initiator'
371
+ ? [this.localEphemeralPrivate, this.remoteStaticPublic]
372
+ : [this.localStaticPrivate, this.remoteEphemeralPublic];
373
+ if (!localKey || !remoteKey) throw new Error('Missing keys for es');
374
+ const shared = x25519.sharedKey(localKey, remoteKey);
375
+ this.symmetricState.mixKey(shared);
376
+ break;
377
+ }
378
+ case 'se': {
379
+ const [localKey, remoteKey] =
380
+ this.role === 'initiator'
381
+ ? [this.localStaticPrivate, this.remoteEphemeralPublic]
382
+ : [this.localEphemeralPrivate, this.remoteStaticPublic];
383
+ if (!localKey || !remoteKey) throw new Error('Missing keys for se');
384
+ const shared = x25519.sharedKey(localKey, remoteKey);
385
+ this.symmetricState.mixKey(shared);
386
+ break;
387
+ }
388
+ case 'ss': {
389
+ if (!this.remoteStaticPublic) throw new Error('Missing remote static for ss');
390
+ const shared = x25519.sharedKey(this.localStaticPrivate, this.remoteStaticPublic);
391
+ this.symmetricState.mixKey(shared);
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ const encryptedPayload = this.symmetricState.encryptAndHash(payload);
398
+ parts.push(encryptedPayload);
399
+
400
+ this.currentPattern++;
401
+
402
+ // Concatenate
403
+ const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
404
+ const result = new Uint8Array(totalLength);
405
+ let offset = 0;
406
+ for (const part of parts) {
407
+ result.set(part, offset);
408
+ offset += part.length;
409
+ }
410
+
411
+ return result;
412
+ }
413
+
414
+ readMessage(message: Uint8Array): Uint8Array {
415
+ if (this.currentPattern >= XX_PATTERNS.length) {
416
+ throw new Error('Handshake already complete');
417
+ }
418
+
419
+ let offset = 0;
420
+ const pattern = XX_PATTERNS[this.currentPattern];
421
+
422
+ for (const token of pattern) {
423
+ switch (token) {
424
+ case 'e': {
425
+ if (offset + KEY_SIZE > message.length) {
426
+ throw new Error('Message too short for ephemeral key');
427
+ }
428
+ this.remoteEphemeralPublic = Uint8Array.from(message.subarray(offset, offset + KEY_SIZE));
429
+ this.symmetricState.mixHash(this.remoteEphemeralPublic);
430
+ offset += KEY_SIZE;
431
+ break;
432
+ }
433
+ case 's': {
434
+ const keyLen = this.symmetricState.hasCipherKey() ? KEY_SIZE + TAG_SIZE : KEY_SIZE;
435
+ if (offset + keyLen > message.length) {
436
+ throw new Error('Message too short for static key');
437
+ }
438
+ const staticData = message.subarray(offset, offset + keyLen);
439
+ const decrypted = this.symmetricState.decryptAndHash(staticData);
440
+ this.remoteStaticPublic = Uint8Array.from(decrypted);
441
+ offset += keyLen;
442
+ break;
443
+ }
444
+ case 'ee':
445
+ case 'es':
446
+ case 'se':
447
+ case 'ss': {
448
+ this.performDH(token);
449
+ break;
450
+ }
451
+ }
452
+ }
453
+
454
+ const encryptedPayload = message.subarray(offset);
455
+ const payload = this.symmetricState.decryptAndHash(encryptedPayload);
456
+
457
+ this.currentPattern++;
458
+ return payload;
459
+ }
460
+
461
+ private performDH(token: Token): void {
462
+ let localKey: Uint8Array | undefined;
463
+ let remoteKey: Uint8Array | undefined;
464
+
465
+ switch (token) {
466
+ case 'ee':
467
+ localKey = this.localEphemeralPrivate;
468
+ remoteKey = this.remoteEphemeralPublic;
469
+ break;
470
+ case 'es':
471
+ if (this.role === 'initiator') {
472
+ localKey = this.localEphemeralPrivate;
473
+ remoteKey = this.remoteStaticPublic;
474
+ } else {
475
+ localKey = this.localStaticPrivate;
476
+ remoteKey = this.remoteEphemeralPublic;
477
+ }
478
+ break;
479
+ case 'se':
480
+ if (this.role === 'initiator') {
481
+ localKey = this.localStaticPrivate;
482
+ remoteKey = this.remoteEphemeralPublic;
483
+ } else {
484
+ localKey = this.localEphemeralPrivate;
485
+ remoteKey = this.remoteStaticPublic;
486
+ }
487
+ break;
488
+ case 'ss':
489
+ localKey = this.localStaticPrivate;
490
+ remoteKey = this.remoteStaticPublic;
491
+ break;
492
+ }
493
+
494
+ if (!localKey || !remoteKey) {
495
+ throw new Error(`Missing keys for ${token}`);
496
+ }
497
+
498
+ const shared = x25519.sharedKey(localKey, remoteKey);
499
+ this.symmetricState.mixKey(shared);
500
+ }
501
+
502
+ isComplete(): boolean {
503
+ return this.currentPattern >= XX_PATTERNS.length;
504
+ }
505
+
506
+ getRemoteStaticPublicKey(): Uint8Array | undefined {
507
+ return this.remoteStaticPublic;
508
+ }
509
+
510
+ getHandshakeHash(): Uint8Array {
511
+ return this.symmetricState.getHandshakeHash();
512
+ }
513
+
514
+ split(useExtractedNonce = true): { send: CipherState; receive: CipherState; hash: Uint8Array } {
515
+ if (!this.isComplete()) {
516
+ throw new Error('Handshake not complete');
517
+ }
518
+
519
+ const handshakeHash = this.symmetricState.getHandshakeHash();
520
+ const [c1, c2] = this.symmetricState.split(useExtractedNonce);
521
+
522
+ const ciphers =
523
+ this.role === 'initiator' ? { send: c1, receive: c2 } : { send: c2, receive: c1 };
524
+
525
+ return { ...ciphers, hash: handshakeHash };
526
+ }
527
+ }
528
+
529
+ // -------------------------------------------------------------------
530
+ // Session
531
+ // -------------------------------------------------------------------
532
+
533
+ /**
534
+ * An established Noise session for transport encryption
535
+ */
536
+ export class NoiseSession {
537
+ private sendCipher: CipherState;
538
+ private receiveCipher: CipherState;
539
+ readonly handshakeHash: Uint8Array;
540
+ readonly remotePublicKey: Uint8Array;
541
+
542
+ constructor(
543
+ sendCipher: CipherState,
544
+ receiveCipher: CipherState,
545
+ handshakeHash: Uint8Array,
546
+ remotePublicKey: Uint8Array
547
+ ) {
548
+ this.sendCipher = sendCipher;
549
+ this.receiveCipher = receiveCipher;
550
+ this.handshakeHash = Uint8Array.from(handshakeHash);
551
+ this.remotePublicKey = Uint8Array.from(remotePublicKey);
552
+ }
553
+
554
+ encrypt(plaintext: Uint8Array): Uint8Array {
555
+ return this.sendCipher.encrypt(plaintext);
556
+ }
557
+
558
+ decrypt(ciphertext: Uint8Array): Uint8Array {
559
+ return this.receiveCipher.decrypt(ciphertext);
560
+ }
561
+
562
+ clear(): void {
563
+ this.sendCipher.clear();
564
+ this.receiveCipher.clear();
565
+ }
566
+ }
567
+
568
+ // -------------------------------------------------------------------
569
+ // Key Generation
570
+ // -------------------------------------------------------------------
571
+
572
+ export function generateKeyPair(): NoiseKeyPair {
573
+ return x25519.generateKeyPair();
574
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Ed25519 Signing Utilities
3
+ * Used for packet authentication and identity binding
4
+ */
5
+
6
+ import * as ed25519 from '@stablelib/ed25519';
7
+ import { hash } from '@stablelib/sha256';
8
+
9
+ export interface SigningKeyPair {
10
+ publicKey: Uint8Array;
11
+ secretKey: Uint8Array;
12
+ }
13
+
14
+ /**
15
+ * Generate a new Ed25519 signing key pair
16
+ */
17
+ export function generateSigningKeyPair(): SigningKeyPair {
18
+ return ed25519.generateKeyPair();
19
+ }
20
+
21
+ /**
22
+ * Sign a message with Ed25519
23
+ */
24
+ export function sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array {
25
+ return ed25519.sign(secretKey, message);
26
+ }
27
+
28
+ /**
29
+ * Verify an Ed25519 signature
30
+ */
31
+ export function verify(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean {
32
+ try {
33
+ return ed25519.verify(publicKey, message, signature);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * SHA-256 hash
41
+ */
42
+ export function sha256(data: Uint8Array): Uint8Array {
43
+ return hash(data);
44
+ }
45
+
46
+ /**
47
+ * Derive a fingerprint from a public key
48
+ * Used for identity verification
49
+ */
50
+ export function fingerprint(publicKey: Uint8Array): string {
51
+ const h = sha256(publicKey);
52
+ return Array.from(h)
53
+ .map((b) => b.toString(16).padStart(2, '0'))
54
+ .join('');
55
+ }
56
+
57
+ /**
58
+ * Format fingerprint for display (colon-separated groups)
59
+ */
60
+ export function formatFingerprint(fp: string): string {
61
+ const groups: string[] = [];
62
+ for (let i = 0; i < fp.length; i += 4) {
63
+ groups.push(fp.slice(i, i + 4));
64
+ }
65
+ return groups.join(':').toUpperCase();
66
+ }