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.
- package/README.md +223 -0
- package/dist/bin/bitchat.d.ts +7 -0
- package/dist/bin/bitchat.d.ts.map +1 -0
- package/dist/bin/bitchat.js +69 -0
- package/dist/bin/bitchat.js.map +1 -0
- package/dist/client.d.ts +77 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +411 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto/index.d.ts +6 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +6 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/noise.d.ts +72 -0
- package/dist/crypto/noise.d.ts.map +1 -0
- package/dist/crypto/noise.js +470 -0
- package/dist/crypto/noise.js.map +1 -0
- package/dist/crypto/signing.d.ts +34 -0
- package/dist/crypto/signing.d.ts.map +1 -0
- package/dist/crypto/signing.js +56 -0
- package/dist/crypto/signing.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/mesh/deduplicator.d.ts +48 -0
- package/dist/mesh/deduplicator.d.ts.map +1 -0
- package/dist/mesh/deduplicator.js +107 -0
- package/dist/mesh/deduplicator.js.map +1 -0
- package/dist/mesh/index.d.ts +6 -0
- package/dist/mesh/index.d.ts.map +1 -0
- package/dist/mesh/index.js +6 -0
- package/dist/mesh/index.js.map +1 -0
- package/dist/mesh/router.d.ts +90 -0
- package/dist/mesh/router.d.ts.map +1 -0
- package/dist/mesh/router.js +204 -0
- package/dist/mesh/router.js.map +1 -0
- package/dist/protocol/binary.d.ts +37 -0
- package/dist/protocol/binary.d.ts.map +1 -0
- package/dist/protocol/binary.js +310 -0
- package/dist/protocol/binary.js.map +1 -0
- package/dist/protocol/constants.d.ts +30 -0
- package/dist/protocol/constants.d.ts.map +1 -0
- package/dist/protocol/constants.js +37 -0
- package/dist/protocol/constants.js.map +1 -0
- package/dist/protocol/index.d.ts +8 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +8 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/packets.d.ts +38 -0
- package/dist/protocol/packets.d.ts.map +1 -0
- package/dist/protocol/packets.js +177 -0
- package/dist/protocol/packets.js.map +1 -0
- package/dist/protocol/types.d.ts +134 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/types.js +108 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/session/index.d.ts +5 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +5 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/manager.d.ts +113 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +371 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/transport/ble.d.ts +92 -0
- package/dist/transport/ble.d.ts.map +1 -0
- package/dist/transport/ble.js +434 -0
- package/dist/transport/ble.js.map +1 -0
- package/dist/transport/index.d.ts +5 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +5 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/server.d.ts +16 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +510 -0
- package/dist/ui/server.js.map +1 -0
- package/package.json +79 -0
- package/src/bin/bitchat.ts +87 -0
- package/src/client.ts +519 -0
- package/src/crypto/index.ts +22 -0
- package/src/crypto/noise.ts +574 -0
- package/src/crypto/signing.ts +66 -0
- package/src/index.ts +95 -0
- package/src/mesh/deduplicator.ts +129 -0
- package/src/mesh/index.ts +6 -0
- package/src/mesh/router.ts +258 -0
- package/src/protocol/binary.ts +345 -0
- package/src/protocol/constants.ts +43 -0
- package/src/protocol/index.ts +15 -0
- package/src/protocol/packets.ts +223 -0
- package/src/protocol/types.ts +182 -0
- package/src/session/index.ts +9 -0
- package/src/session/manager.ts +476 -0
- package/src/transport/ble.ts +553 -0
- package/src/transport/index.ts +10 -0
- package/src/ui/index.ts +1 -0
- 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
|
+
}
|