@decentchat/decentchat-plugin 0.1.9

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.
@@ -0,0 +1,1057 @@
1
+ /**
2
+ * MessageProtocol - Handles message encryption/decryption and protocol format
3
+ *
4
+ * Uses DoubleRatchet (Signal-style) for forward secrecy.
5
+ * Falls back to legacy MessageCipher for peers that haven't upgraded.
6
+ */
7
+
8
+ import {
9
+ CryptoManager,
10
+ MessageCipher,
11
+ DoubleRatchet,
12
+ serializeRatchetState,
13
+ deserializeRatchetState,
14
+ PRE_KEY_BUNDLE_VERSION,
15
+ DEFAULT_PRE_KEY_LIFECYCLE_POLICY,
16
+ decideSignedPreKeyLifecycle,
17
+ planLocalOneTimePreKeyLifecycle,
18
+ normalizePeerPreKeyBundle as normalizePeerPreKeyBundlePolicy,
19
+ hasPeerPreKeyBundleChanged,
20
+ } from '@decentchat/protocol';
21
+ import type {
22
+ KeyPair,
23
+ RatchetState,
24
+ RatchetMessage,
25
+ SerializedRatchetState,
26
+ PreKeyBundle,
27
+ PersistedLocalPreKeyState,
28
+ PreKeySessionInitPayload,
29
+ PreKeyType,
30
+ } from '@decentchat/protocol';
31
+
32
+ interface EnvelopeMetadata {
33
+ fileName?: string;
34
+ fileSize?: number;
35
+ mimeType?: string;
36
+ assistant?: {
37
+ modelId?: string;
38
+ modelName?: string;
39
+ modelAlias?: string;
40
+ modelLabel?: string;
41
+ };
42
+ }
43
+
44
+ /** Wire format for ratchet-encrypted messages */
45
+ export interface RatchetEnvelope {
46
+ id: string;
47
+ timestamp: number;
48
+ sender: string;
49
+ type: 'text' | 'file' | 'system' | 'handshake';
50
+ /** Ratchet-encrypted payload */
51
+ ratchet: RatchetMessage;
52
+ /** ECDSA signature over plaintext */
53
+ signature: string;
54
+ /** Protocol version: 2 = DoubleRatchet */
55
+ protocolVersion: 2;
56
+ metadata?: EnvelopeMetadata;
57
+ }
58
+
59
+ /** Wire format for pre-key session-init messages */
60
+ export interface PreKeySessionEnvelope {
61
+ id: string;
62
+ timestamp: number;
63
+ sender: string;
64
+ type: 'text' | 'file' | 'system' | 'handshake';
65
+ /** First ratchet-encrypted payload after pre-key bootstrap */
66
+ ratchet: RatchetMessage;
67
+ /** ECDSA signature over plaintext */
68
+ signature: string;
69
+ /** Protocol version: 3 = pre-key bootstrap + DoubleRatchet */
70
+ protocolVersion: 3;
71
+ sessionInit: PreKeySessionInitPayload;
72
+ metadata?: EnvelopeMetadata;
73
+ }
74
+
75
+ /** Legacy wire format (v1: static shared secret) */
76
+ export interface LegacyEnvelope {
77
+ id: string;
78
+ timestamp: number;
79
+ sender: string;
80
+ type: 'text' | 'file' | 'system' | 'handshake';
81
+ encrypted: {
82
+ ciphertext: string;
83
+ iv: string;
84
+ tag: string;
85
+ };
86
+ signature: string;
87
+ protocolVersion?: 1 | undefined;
88
+ metadata?: EnvelopeMetadata;
89
+ }
90
+
91
+ export type MessageEnvelope = RatchetEnvelope | PreKeySessionEnvelope | LegacyEnvelope;
92
+
93
+ export interface HandshakeData {
94
+ publicKey: string; // Base64 ECDH public key (identity key, for ratchet key exchange)
95
+ peerId: string;
96
+ /** Bob's ratchet DH public key (raw, base64) for initializing Alice's ratchet */
97
+ ratchetDHPublicKey?: string;
98
+ protocolVersion?: number;
99
+ /** Base64 ECDSA signing public key (for message signature verification) */
100
+ signingPublicKey?: string;
101
+ /** Advertise support for pre-key bundle based bootstrap */
102
+ preKeySupport?: boolean;
103
+ }
104
+
105
+ /** Persistence interface for ratchet state */
106
+ export interface RatchetPersistence {
107
+ save(peerId: string, state: SerializedRatchetState): Promise<void>;
108
+ load(peerId: string): Promise<SerializedRatchetState | null>;
109
+ delete(peerId: string): Promise<void>;
110
+ savePreKeyBundle?(peerId: string, bundle: PreKeyBundle): Promise<void>;
111
+ loadPreKeyBundle?(peerId: string): Promise<PreKeyBundle | null>;
112
+ deletePreKeyBundle?(peerId: string): Promise<void>;
113
+ saveLocalPreKeyState?(ownerPeerId: string, state: PersistedLocalPreKeyState): Promise<void>;
114
+ loadLocalPreKeyState?(ownerPeerId: string): Promise<PersistedLocalPreKeyState | null>;
115
+ deleteLocalPreKeyState?(ownerPeerId: string): Promise<void>;
116
+ }
117
+
118
+ interface LocalPreKeyRuntimeRecord {
119
+ keyId: number;
120
+ publicKey: CryptoKey;
121
+ privateKey: CryptoKey;
122
+ createdAt: number;
123
+ }
124
+
125
+ interface LocalSignedPreKeyRuntimeRecord extends LocalPreKeyRuntimeRecord {
126
+ signature: string;
127
+ expiresAt: number;
128
+ }
129
+
130
+ const PRE_KEY_POLICY = DEFAULT_PRE_KEY_LIFECYCLE_POLICY;
131
+
132
+ export class NodeMessageProtocol {
133
+ private cryptoManager: CryptoManager;
134
+ private cipher: MessageCipher;
135
+ private myPeerId: string;
136
+ private _signingKeyPair: KeyPair | null = null;
137
+
138
+ /** Per-peer ratchet state (active sessions) */
139
+ private ratchetStates = new Map<string, RatchetState>();
140
+
141
+ /** Pre-generated ratchet DH key pair (used as Bob in handshake) */
142
+ private ratchetDHKeyPair: CryptoKeyPair | null = null;
143
+
144
+ /** Legacy shared secrets (fallback for old peers) */
145
+ private sharedSecrets = new Map<string, CryptoKey>();
146
+
147
+ /** Per-peer ECDSA signing public keys (for message signature verification) */
148
+ private signingPublicKeys = new Map<string, CryptoKey>();
149
+
150
+ /** Cached peer pre-key bundles (public only) */
151
+ private peerPreKeyBundles = new Map<string, PreKeyBundle>();
152
+
153
+ /** Local signed pre-key + one-time pre-keys (includes private key material) */
154
+ private localSignedPreKey: LocalSignedPreKeyRuntimeRecord | null = null;
155
+ private localOneTimePreKeys = new Map<number, LocalPreKeyRuntimeRecord>();
156
+ private localPreKeyBundleCache: PreKeyBundle | null = null;
157
+ private nextOneTimePreKeyId = 1;
158
+
159
+ /** Persistence backend (optional) */
160
+ private persistence: RatchetPersistence | null = null;
161
+ private preKeyReady: Promise<void> | null = null;
162
+ private localPreKeyMutation: Promise<void> = Promise.resolve();
163
+
164
+ /** Get a peer's ECDSA signing public key (for auth verification) */
165
+ getSigningPublicKey(peerId: string): CryptoKey | undefined {
166
+ return this.signingPublicKeys.get(peerId);
167
+ }
168
+
169
+ async signData(data: string): Promise<string> {
170
+ if (!this._signingKeyPair) {
171
+ throw new Error('MessageProtocol not initialized with signing keys');
172
+ }
173
+ return this.cipher.sign(data, this._signingKeyPair.privateKey);
174
+ }
175
+
176
+ async verifyData(data: string, signature: string, peerId: string): Promise<boolean> {
177
+ const signingKey = this.signingPublicKeys.get(peerId);
178
+ if (!signingKey) return false;
179
+ return this.cipher.verify(data, signature, signingKey);
180
+ }
181
+
182
+ constructor(cryptoManager: CryptoManager, myPeerId: string) {
183
+ this.cryptoManager = cryptoManager;
184
+ this.cipher = new MessageCipher();
185
+ this.myPeerId = myPeerId;
186
+ }
187
+
188
+ async init(signingKeyPair: KeyPair): Promise<void> {
189
+ this._signingKeyPair = signingKeyPair;
190
+ // Pre-generate a DH key pair for ratchet init (Bob's role)
191
+ this.ratchetDHKeyPair = await crypto.subtle.generateKey(
192
+ { name: 'ECDH', namedCurve: 'P-256' },
193
+ true,
194
+ ['deriveBits'],
195
+ );
196
+ await this.ensureLocalPreKeyMaterial();
197
+ }
198
+
199
+ setPersistence(persistence: RatchetPersistence): void {
200
+ this.persistence = persistence;
201
+ // If init already generated local pre-keys before persistence was wired,
202
+ // persist them now so restarts can consume one-time keys correctly.
203
+ void this.persistLocalPreKeyState();
204
+ }
205
+
206
+ private async runWithLocalPreKeyMutation<T>(operation: () => Promise<T>): Promise<T> {
207
+ const pending = this.localPreKeyMutation.catch(() => undefined).then(operation);
208
+ this.localPreKeyMutation = pending.then(
209
+ () => undefined,
210
+ () => undefined,
211
+ );
212
+ return pending;
213
+ }
214
+
215
+ async createHandshake(): Promise<HandshakeData> {
216
+ const keyPair = await this.cryptoManager.getKeyPair();
217
+ const publicKey = await this.cryptoManager.exportPublicKey(keyPair.publicKey);
218
+
219
+ let ratchetDHPublicKey: string | undefined;
220
+ if (this.ratchetDHKeyPair) {
221
+ const ratchetPubRaw = await crypto.subtle.exportKey('raw', this.ratchetDHKeyPair.publicKey);
222
+ ratchetDHPublicKey = arrayBufferToBase64(ratchetPubRaw);
223
+ }
224
+
225
+ let signingPublicKey: string | undefined;
226
+ if (this._signingKeyPair) {
227
+ signingPublicKey = await this.cryptoManager.exportPublicKey(this._signingKeyPair.publicKey);
228
+ }
229
+
230
+ return {
231
+ publicKey,
232
+ peerId: this.myPeerId,
233
+ ratchetDHPublicKey,
234
+ protocolVersion: 2,
235
+ signingPublicKey,
236
+ preKeySupport: true,
237
+ };
238
+ }
239
+
240
+ async processHandshake(peerId: string, handshake: HandshakeData): Promise<void> {
241
+ const peerPublicKey = await this.cryptoManager.importPublicKey(handshake.publicKey);
242
+
243
+ // Store peer's ECDSA signing public key for message signature verification
244
+ if (handshake.signingPublicKey) {
245
+ try {
246
+ const signingKey = await this.cryptoManager.importSigningPublicKey(handshake.signingPublicKey);
247
+ this.signingPublicKeys.set(peerId, signingKey);
248
+ } catch (e) {
249
+ console.warn(`[MessageProtocol] Failed to import signing key for ${peerId.slice(0, 8)}:`, e);
250
+ }
251
+ }
252
+
253
+ // Always derive a legacy shared secret for fallback
254
+ const sharedSecret = await this.cryptoManager.deriveSharedSecret(
255
+ peerPublicKey,
256
+ undefined,
257
+ this.myPeerId,
258
+ peerId,
259
+ );
260
+ this.sharedSecrets.set(peerId, sharedSecret);
261
+
262
+ // If peer supports ratchet protocol
263
+ if (handshake.protocolVersion === 2 && handshake.ratchetDHPublicKey) {
264
+ // Check if we already have ratchet state (persisted or in-memory)
265
+ if (this.ratchetStates.has(peerId)) return;
266
+
267
+ // Try restoring from persistence
268
+ if (this.persistence) {
269
+ const saved = await this.persistence.load(peerId);
270
+ if (saved) {
271
+ try {
272
+ this.ratchetStates.set(peerId, await deserializeRatchetState(saved));
273
+ return;
274
+ } catch (e) {
275
+ console.warn(`[Ratchet] Failed to restore state for ${peerId.slice(0, 8)}, re-initializing:`, e);
276
+ }
277
+ }
278
+ }
279
+
280
+ // Determine role: lower peerId is Alice (initiator)
281
+ const isAlice = this.myPeerId < peerId;
282
+
283
+ // Derive initial shared secret (raw ECDH bytes for ratchet root key)
284
+ const myKeyPair = await this.cryptoManager.getKeyPair();
285
+ const initialSecret = await crypto.subtle.deriveBits(
286
+ { name: 'ECDH', public: peerPublicKey },
287
+ myKeyPair.privateKey,
288
+ 256,
289
+ );
290
+
291
+ // Import peer's ratchet DH public key
292
+ const peerRatchetDH = await crypto.subtle.importKey(
293
+ 'raw',
294
+ base64ToArrayBuffer(handshake.ratchetDHPublicKey),
295
+ { name: 'ECDH', namedCurve: 'P-256' },
296
+ true,
297
+ [],
298
+ );
299
+
300
+ let state: RatchetState;
301
+ if (isAlice) {
302
+ state = await DoubleRatchet.initAlice(initialSecret, peerRatchetDH);
303
+ } else {
304
+ state = await DoubleRatchet.initBob(initialSecret, this.ratchetDHKeyPair!);
305
+ // Generate a fresh DH key pair for the next handshake
306
+ this.ratchetDHKeyPair = await crypto.subtle.generateKey(
307
+ { name: 'ECDH', namedCurve: 'P-256' },
308
+ true,
309
+ ['deriveBits'],
310
+ );
311
+ }
312
+
313
+ this.ratchetStates.set(peerId, state);
314
+ await this.persistState(peerId);
315
+ }
316
+ }
317
+
318
+ async createPreKeyBundle(): Promise<PreKeyBundle> {
319
+ await this.ensureLocalPreKeyMaterial();
320
+ return this.runWithLocalPreKeyMutation(async () => {
321
+ const changed = await this.applyLocalPreKeyLifecyclePolicy();
322
+ if (changed) {
323
+ await this.persistLocalPreKeyState();
324
+ }
325
+ if (!changed && this.localPreKeyBundleCache) {
326
+ return structuredClone(this.localPreKeyBundleCache);
327
+ }
328
+
329
+ const bundle = await this.snapshotLocalPreKeyBundle();
330
+ this.localPreKeyBundleCache = bundle;
331
+ return structuredClone(bundle);
332
+ });
333
+ }
334
+
335
+ async storePeerPreKeyBundle(peerId: string, bundle: PreKeyBundle): Promise<boolean> {
336
+ const sanitized = await this.sanitizeAndVerifyPeerPreKeyBundle(peerId, bundle);
337
+ if (!sanitized) return false;
338
+
339
+ this.peerPreKeyBundles.set(peerId, sanitized);
340
+ await this.persistPeerPreKeyBundle(peerId, sanitized);
341
+ return true;
342
+ }
343
+
344
+ async getPeerPreKeyBundle(peerId: string): Promise<PreKeyBundle | null> {
345
+ const cached = this.peerPreKeyBundles.get(peerId);
346
+ if (cached) {
347
+ const normalized = this.normalizePeerPreKeyBundle(cached);
348
+ if (!normalized) {
349
+ await this.clearPeerPreKeyBundle(peerId);
350
+ return null;
351
+ }
352
+
353
+ if (this.hasPeerBundleChanged(cached, normalized)) {
354
+ this.peerPreKeyBundles.set(peerId, normalized);
355
+ await this.persistPeerPreKeyBundle(peerId, normalized);
356
+ }
357
+
358
+ return normalized;
359
+ }
360
+
361
+ if (!this.persistence?.loadPreKeyBundle) return null;
362
+
363
+ try {
364
+ const loaded = await this.persistence.loadPreKeyBundle(peerId);
365
+ if (!loaded) return null;
366
+
367
+ const sanitized = await this.sanitizeAndVerifyPeerPreKeyBundle(peerId, loaded);
368
+ if (!sanitized) {
369
+ if (this.persistence?.deletePreKeyBundle) {
370
+ try {
371
+ await this.persistence.deletePreKeyBundle(peerId);
372
+ } catch (deleteError) {
373
+ console.warn(`[PreKey] Failed to delete stale peer bundle for ${peerId.slice(0, 8)}:`, deleteError);
374
+ }
375
+ }
376
+ return null;
377
+ }
378
+
379
+ this.peerPreKeyBundles.set(peerId, sanitized);
380
+ if (this.hasPeerBundleChanged(loaded, sanitized)) {
381
+ await this.persistPeerPreKeyBundle(peerId, sanitized);
382
+ }
383
+ return sanitized;
384
+ } catch (e) {
385
+ console.warn(`[PreKey] Failed to load peer bundle for ${peerId.slice(0, 8)}:`, e);
386
+ return null;
387
+ }
388
+ }
389
+
390
+ async clearPeerPreKeyBundle(peerId: string): Promise<void> {
391
+ this.peerPreKeyBundles.delete(peerId);
392
+ if (!this.persistence?.deletePreKeyBundle) return;
393
+ try {
394
+ await this.persistence.deletePreKeyBundle(peerId);
395
+ } catch (e) {
396
+ console.warn(`[PreKey] Failed to delete peer bundle for ${peerId.slice(0, 8)}:`, e);
397
+ }
398
+ }
399
+
400
+ async encryptMessage(
401
+ peerId: string,
402
+ content: string,
403
+ type: 'text' | 'file' | 'system' | 'handshake' = 'text',
404
+ metadata?: EnvelopeMetadata,
405
+ ): Promise<MessageEnvelope> {
406
+ const signature = this._signingKeyPair
407
+ ? await this.cipher.sign(content, this._signingKeyPair.privateKey)
408
+ : '';
409
+
410
+ // Prefer ratchet when the sending chain is ready.
411
+ // Bob-side handshake states start with sendChainKey = null until the first receive.
412
+ const state = this.ratchetStates.get(peerId);
413
+ if (state?.sendChainKey) {
414
+ const ratchet = await DoubleRatchet.encrypt(state, content);
415
+ await this.persistState(peerId);
416
+ return {
417
+ id: this.generateMessageId(),
418
+ timestamp: Date.now(),
419
+ sender: this.myPeerId,
420
+ type,
421
+ ratchet,
422
+ signature,
423
+ protocolVersion: 2,
424
+ metadata,
425
+ };
426
+ }
427
+
428
+ // Fallback: legacy static shared secret
429
+ const sharedSecret = this.sharedSecrets.get(peerId);
430
+ if (sharedSecret) {
431
+ const encrypted = await this.cipher.encrypt(content, sharedSecret);
432
+ return {
433
+ id: this.generateMessageId(),
434
+ timestamp: Date.now(),
435
+ sender: this.myPeerId,
436
+ type,
437
+ encrypted,
438
+ signature,
439
+ protocolVersion: 1,
440
+ metadata,
441
+ };
442
+ }
443
+
444
+ // No shared secret and no ratchet: try pre-key bootstrap from cached bundle.
445
+ const bootstrapped = await this.encryptWithPreKeyBootstrap(peerId, content, type, signature, metadata);
446
+ if (bootstrapped) return bootstrapped;
447
+
448
+ throw new Error(`No shared secret with peer ${peerId.slice(0, 8)}`);
449
+ }
450
+
451
+ async decryptMessage(peerId: string, envelope: MessageEnvelope, peerPublicKey: CryptoKey): Promise<string | null> {
452
+ // Pre-key session init (protocol v3)
453
+ if ((envelope as PreKeySessionEnvelope).protocolVersion === 3 && (envelope as PreKeySessionEnvelope).sessionInit) {
454
+ return this.decryptPreKeySessionInit(peerId, envelope as PreKeySessionEnvelope, peerPublicKey);
455
+ }
456
+
457
+ // Ratchet envelope (v2)
458
+ if (envelope.protocolVersion === 2 && 'ratchet' in envelope) {
459
+ let state = this.ratchetStates.get(peerId);
460
+
461
+ // Try restoring from persistence if not in memory
462
+ if (!state && this.persistence) {
463
+ const saved = await this.persistence.load(peerId);
464
+ if (saved) {
465
+ try {
466
+ state = await deserializeRatchetState(saved);
467
+ this.ratchetStates.set(peerId, state);
468
+ } catch (e) {
469
+ console.warn(`[Ratchet] Failed to restore state for ${peerId.slice(0, 8)}:`, e);
470
+ }
471
+ }
472
+ }
473
+
474
+ if (!state) {
475
+ throw new Error(`No ratchet state with peer ${peerId.slice(0, 8)}`);
476
+ }
477
+
478
+ const content = await DoubleRatchet.decrypt(state, envelope.ratchet);
479
+ await this.persistState(peerId);
480
+
481
+ // Verify ECDSA signature (use dedicated signing key if known)
482
+ const signingKey = this.signingPublicKeys.get(peerId) ?? peerPublicKey;
483
+ const isValid = await this.cipher.verify(content, envelope.signature, signingKey);
484
+ if (!isValid) return null;
485
+
486
+ return content;
487
+ }
488
+
489
+ // Legacy v1
490
+ if (!('encrypted' in envelope)) {
491
+ throw new Error('Unsupported envelope format');
492
+ }
493
+
494
+ const sharedSecret = this.sharedSecrets.get(peerId);
495
+ if (!sharedSecret) throw new Error(`No shared secret with peer ${peerId.slice(0, 8)}`);
496
+
497
+ const content = await this.cipher.decrypt(envelope.encrypted, sharedSecret);
498
+
499
+ // Verify ECDSA signature (use dedicated signing key if known)
500
+ const signingKey = this.signingPublicKeys.get(peerId) ?? peerPublicKey;
501
+ const isValid = await this.cipher.verify(content, envelope.signature, signingKey);
502
+ if (!isValid) return null;
503
+
504
+ return content;
505
+ }
506
+
507
+ hasSharedSecret(peerId: string): boolean {
508
+ return this.ratchetStates.has(peerId) || this.sharedSecrets.has(peerId);
509
+ }
510
+
511
+ hasRatchetState(peerId: string): boolean {
512
+ return this.ratchetStates.has(peerId);
513
+ }
514
+
515
+ clearSharedSecret(peerId: string): void {
516
+ this.sharedSecrets.delete(peerId);
517
+ // Keep ratchet state — it survives reconnections
518
+ }
519
+
520
+ async clearRatchetState(peerId: string): Promise<void> {
521
+ this.ratchetStates.delete(peerId);
522
+ if (this.persistence) {
523
+ await this.persistence.delete(peerId);
524
+ }
525
+ }
526
+
527
+ clearAllSecrets(): void {
528
+ this.sharedSecrets.clear();
529
+ }
530
+
531
+ /** Restore ratchet state from persistence for a peer */
532
+ async restoreRatchetState(peerId: string): Promise<boolean> {
533
+ if (!this.persistence) return false;
534
+ const saved = await this.persistence.load(peerId);
535
+ if (!saved) return false;
536
+
537
+ try {
538
+ this.ratchetStates.set(peerId, await deserializeRatchetState(saved));
539
+ return true;
540
+ } catch (e) {
541
+ console.warn(`[Ratchet] Failed to restore state for ${peerId.slice(0, 8)}:`, e);
542
+ return false;
543
+ }
544
+ }
545
+
546
+ /** Persist the ratchet state for a peer */
547
+ private async persistState(peerId: string): Promise<void> {
548
+ if (!this.persistence) return;
549
+ const state = this.ratchetStates.get(peerId);
550
+ if (!state) return;
551
+
552
+ try {
553
+ const serialized = await serializeRatchetState(state);
554
+ await this.persistence.save(peerId, serialized);
555
+ } catch (e) {
556
+ console.warn(`[Ratchet] Failed to persist state for ${peerId.slice(0, 8)}:`, e);
557
+ }
558
+ }
559
+
560
+ private async encryptWithPreKeyBootstrap(
561
+ peerId: string,
562
+ content: string,
563
+ type: 'text' | 'file' | 'system' | 'handshake',
564
+ signature: string,
565
+ metadata?: EnvelopeMetadata,
566
+ ): Promise<PreKeySessionEnvelope | null> {
567
+ const bundle = await this.getPeerPreKeyBundle(peerId);
568
+ if (!bundle) return null;
569
+
570
+ const oneTimeKey = bundle.oneTimePreKeys[0];
571
+ const selectedType: PreKeyType = oneTimeKey ? 'one-time' : 'signed';
572
+ const selectedKeyId = oneTimeKey?.keyId ?? bundle.signedPreKey.keyId;
573
+ const selectedPublic = oneTimeKey?.publicKey ?? bundle.signedPreKey.publicKey;
574
+
575
+ if (!selectedPublic) return null;
576
+ if (!oneTimeKey && bundle.signedPreKey.expiresAt <= Date.now()) {
577
+ return null;
578
+ }
579
+
580
+ const selectedPublicKey = await this.importEcdhPublicKey(selectedPublic);
581
+ const senderEphemeral = await crypto.subtle.generateKey(
582
+ { name: 'ECDH', namedCurve: 'P-256' },
583
+ true,
584
+ ['deriveBits'],
585
+ );
586
+
587
+ const initialSecret = await crypto.subtle.deriveBits(
588
+ { name: 'ECDH', public: selectedPublicKey },
589
+ senderEphemeral.privateKey,
590
+ 256,
591
+ );
592
+
593
+ const state = await DoubleRatchet.initAlice(initialSecret, selectedPublicKey);
594
+ const ratchet = await DoubleRatchet.encrypt(state, content);
595
+ this.ratchetStates.set(peerId, state);
596
+ await this.persistState(peerId);
597
+
598
+ const senderEphemeralPublicKey = await this.exportEcdhPublicKey(senderEphemeral.publicKey);
599
+
600
+ if (oneTimeKey) {
601
+ const consumedBundle: PreKeyBundle = {
602
+ ...bundle,
603
+ oneTimePreKeys: bundle.oneTimePreKeys.slice(1),
604
+ };
605
+ const normalized = this.normalizePeerPreKeyBundle(consumedBundle);
606
+ if (normalized) {
607
+ this.peerPreKeyBundles.set(peerId, normalized);
608
+ await this.persistPeerPreKeyBundle(peerId, normalized, 'consumed peer bundle');
609
+ } else {
610
+ await this.clearPeerPreKeyBundle(peerId);
611
+ }
612
+ }
613
+
614
+ return {
615
+ id: this.generateMessageId(),
616
+ timestamp: Date.now(),
617
+ sender: this.myPeerId,
618
+ type,
619
+ ratchet,
620
+ signature,
621
+ protocolVersion: 3,
622
+ sessionInit: {
623
+ type: 'pre-key-session-init',
624
+ bundleVersion: PRE_KEY_BUNDLE_VERSION,
625
+ selectedPreKeyId: selectedKeyId,
626
+ selectedPreKeyType: selectedType,
627
+ senderEphemeralPublicKey,
628
+ createdAt: Date.now(),
629
+ },
630
+ metadata,
631
+ };
632
+ }
633
+
634
+ private async decryptPreKeySessionInit(
635
+ peerId: string,
636
+ envelope: PreKeySessionEnvelope,
637
+ peerPublicKey: CryptoKey,
638
+ ): Promise<string | null> {
639
+ if (this.ratchetStates.has(peerId)) {
640
+ throw new Error(`Ratchet already established with peer ${peerId.slice(0, 8)}`);
641
+ }
642
+
643
+ await this.ensureLocalPreKeyMaterial();
644
+ return this.runWithLocalPreKeyMutation(async () => {
645
+ const init = envelope.sessionInit;
646
+ if (!init || init.type !== 'pre-key-session-init') {
647
+ throw new Error('Invalid pre-key session-init payload');
648
+ }
649
+
650
+ const localPreKey = this.resolveLocalPreKey(init.selectedPreKeyType, init.selectedPreKeyId);
651
+ if (!localPreKey) {
652
+ throw new Error(`Pre-key ${init.selectedPreKeyType}:${init.selectedPreKeyId} unavailable`);
653
+ }
654
+
655
+ const senderEphemeral = await this.importEcdhPublicKey(init.senderEphemeralPublicKey);
656
+ const initialSecret = await crypto.subtle.deriveBits(
657
+ { name: 'ECDH', public: senderEphemeral },
658
+ localPreKey.privateKey,
659
+ 256,
660
+ );
661
+
662
+ const state = await DoubleRatchet.initBob(initialSecret, {
663
+ publicKey: localPreKey.publicKey,
664
+ privateKey: localPreKey.privateKey,
665
+ });
666
+
667
+ const content = await DoubleRatchet.decrypt(state, envelope.ratchet);
668
+
669
+ // Verify signature before mutating durable state.
670
+ const signingKey = this.signingPublicKeys.get(peerId) ?? peerPublicKey;
671
+ const isValid = await this.cipher.verify(content, envelope.signature, signingKey);
672
+ if (!isValid) return null;
673
+
674
+ this.ratchetStates.set(peerId, state);
675
+ await this.persistState(peerId);
676
+
677
+ let localStateChanged = false;
678
+ if (init.selectedPreKeyType === 'one-time') {
679
+ this.localOneTimePreKeys.delete(init.selectedPreKeyId);
680
+ this.invalidateLocalPreKeyBundleCache();
681
+ localStateChanged = true;
682
+ }
683
+
684
+ if (await this.applyLocalPreKeyLifecyclePolicy()) {
685
+ localStateChanged = true;
686
+ }
687
+
688
+ if (localStateChanged) {
689
+ await this.persistLocalPreKeyState();
690
+ }
691
+
692
+ return content;
693
+ });
694
+ }
695
+
696
+ private resolveLocalPreKey(type: PreKeyType, keyId: number): LocalPreKeyRuntimeRecord | null {
697
+ if (type === 'signed') {
698
+ if (!this.localSignedPreKey || this.localSignedPreKey.keyId !== keyId) return null;
699
+ return this.localSignedPreKey;
700
+ }
701
+ return this.localOneTimePreKeys.get(keyId) ?? null;
702
+ }
703
+
704
+ private async ensureLocalPreKeyMaterial(): Promise<void> {
705
+ if (this.preKeyReady) {
706
+ await this.preKeyReady;
707
+ return;
708
+ }
709
+
710
+ this.preKeyReady = (async () => {
711
+ let restored = false;
712
+
713
+ // Try restore persisted local state first.
714
+ if (this.persistence?.loadLocalPreKeyState) {
715
+ try {
716
+ const persisted = await this.persistence.loadLocalPreKeyState(this.myPeerId);
717
+ if (persisted) {
718
+ await this.loadLocalPreKeyState(persisted);
719
+ restored = true;
720
+ }
721
+ } catch (e) {
722
+ console.warn('[PreKey] Failed to load local pre-key state:', e);
723
+ }
724
+ }
725
+
726
+ if (!restored) {
727
+ await this.generateFreshLocalPreKeys();
728
+ }
729
+
730
+ const changed = await this.applyLocalPreKeyLifecyclePolicy();
731
+ if (!restored || changed) {
732
+ await this.persistLocalPreKeyState();
733
+ }
734
+ })();
735
+
736
+ await this.preKeyReady;
737
+ }
738
+
739
+ private async loadLocalPreKeyState(state: PersistedLocalPreKeyState): Promise<void> {
740
+ this.localSignedPreKey = {
741
+ keyId: state.signedPreKey.keyId,
742
+ createdAt: state.signedPreKey.createdAt,
743
+ expiresAt: state.signedPreKey.expiresAt,
744
+ signature: state.signedPreKey.signature,
745
+ publicKey: await this.importEcdhPublicKey(state.signedPreKey.publicKey),
746
+ privateKey: await this.importEcdhPrivateKey(state.signedPreKey.privateKey),
747
+ };
748
+
749
+ this.localOneTimePreKeys.clear();
750
+ for (const key of state.oneTimePreKeys) {
751
+ this.localOneTimePreKeys.set(key.keyId, {
752
+ keyId: key.keyId,
753
+ createdAt: key.createdAt,
754
+ publicKey: await this.importEcdhPublicKey(key.publicKey),
755
+ privateKey: await this.importEcdhPrivateKey(key.privateKey),
756
+ });
757
+ }
758
+
759
+ this.nextOneTimePreKeyId = Math.max(
760
+ state.nextOneTimePreKeyId,
761
+ ...Array.from(this.localOneTimePreKeys.keys(), (id) => id + 1),
762
+ 1,
763
+ );
764
+ this.invalidateLocalPreKeyBundleCache();
765
+ }
766
+
767
+ private async generateFreshLocalPreKeys(): Promise<void> {
768
+ if (!this._signingKeyPair) throw new Error('MessageProtocol not initialized with signing keys');
769
+
770
+ this.invalidateLocalPreKeyBundleCache();
771
+ const now = Date.now();
772
+ const signedPair = await crypto.subtle.generateKey(
773
+ { name: 'ECDH', namedCurve: 'P-256' },
774
+ true,
775
+ ['deriveBits'],
776
+ );
777
+ const signedPub = await this.exportEcdhPublicKey(signedPair.publicKey);
778
+
779
+ this.localSignedPreKey = {
780
+ keyId: now,
781
+ publicKey: signedPair.publicKey,
782
+ privateKey: signedPair.privateKey,
783
+ createdAt: now,
784
+ expiresAt: now + PRE_KEY_POLICY.signedPreKeyTtlMs,
785
+ signature: await this.cipher.sign(signedPub, this._signingKeyPair.privateKey),
786
+ };
787
+
788
+ this.localOneTimePreKeys.clear();
789
+ this.nextOneTimePreKeyId = 1;
790
+ await this.generateMoreOneTimePreKeys(PRE_KEY_POLICY.targetOneTimePreKeys);
791
+ }
792
+
793
+ private async rotateLocalSignedPreKey(now = Date.now()): Promise<void> {
794
+ if (!this._signingKeyPair) throw new Error('MessageProtocol not initialized with signing keys');
795
+
796
+ this.invalidateLocalPreKeyBundleCache();
797
+ const signedPair = await crypto.subtle.generateKey(
798
+ { name: 'ECDH', namedCurve: 'P-256' },
799
+ true,
800
+ ['deriveBits'],
801
+ );
802
+ const signedPub = await this.exportEcdhPublicKey(signedPair.publicKey);
803
+
804
+ this.localSignedPreKey = {
805
+ keyId: Math.max(now, (this.localSignedPreKey?.keyId ?? 0) + 1),
806
+ publicKey: signedPair.publicKey,
807
+ privateKey: signedPair.privateKey,
808
+ createdAt: now,
809
+ expiresAt: now + PRE_KEY_POLICY.signedPreKeyTtlMs,
810
+ signature: await this.cipher.sign(signedPub, this._signingKeyPair.privateKey),
811
+ };
812
+ }
813
+
814
+ private async applyLocalPreKeyLifecyclePolicy(now = Date.now()): Promise<boolean> {
815
+ const signedDecision = decideSignedPreKeyLifecycle(this.localSignedPreKey, {
816
+ now,
817
+ refreshWindowMs: PRE_KEY_POLICY.signedPreKeyRefreshWindowMs,
818
+ });
819
+
820
+ if (signedDecision.regenerateAll) {
821
+ await this.generateFreshLocalPreKeys();
822
+ return true;
823
+ }
824
+
825
+ let changed = false;
826
+
827
+ if (signedDecision.rotateSignedPreKey) {
828
+ await this.rotateLocalSignedPreKey(now);
829
+ changed = true;
830
+ }
831
+
832
+ const oneTimePlan = planLocalOneTimePreKeyLifecycle(this.localOneTimePreKeys.values(), {
833
+ now,
834
+ maxAgeMs: PRE_KEY_POLICY.maxOneTimePreKeyAgeMs,
835
+ targetCount: PRE_KEY_POLICY.targetOneTimePreKeys,
836
+ lowWatermark: PRE_KEY_POLICY.lowWatermarkOneTimePreKeys,
837
+ });
838
+
839
+ if (oneTimePlan.staleKeyIds.length > 0) {
840
+ for (const keyId of oneTimePlan.staleKeyIds) {
841
+ this.localOneTimePreKeys.delete(keyId);
842
+ }
843
+ this.invalidateLocalPreKeyBundleCache();
844
+ changed = true;
845
+ }
846
+
847
+ if (this.localOneTimePreKeys.size > PRE_KEY_POLICY.targetOneTimePreKeys) {
848
+ const keyIdsToRemove = Array.from(this.localOneTimePreKeys.values())
849
+ .sort((a, b) => b.keyId - a.keyId)
850
+ .slice(PRE_KEY_POLICY.targetOneTimePreKeys)
851
+ .map((record) => record.keyId);
852
+ for (const keyId of keyIdsToRemove) {
853
+ this.localOneTimePreKeys.delete(keyId);
854
+ }
855
+ this.invalidateLocalPreKeyBundleCache();
856
+ changed = true;
857
+ }
858
+
859
+ if (oneTimePlan.replenishCount > 0) {
860
+ await this.generateMoreOneTimePreKeys(oneTimePlan.replenishCount);
861
+ changed = true;
862
+ }
863
+
864
+ return changed;
865
+ }
866
+
867
+ private async generateMoreOneTimePreKeys(count: number): Promise<void> {
868
+ if (count > 0) {
869
+ this.invalidateLocalPreKeyBundleCache();
870
+ }
871
+ for (let i = 0; i < count; i++) {
872
+ const keyId = this.nextOneTimePreKeyId++;
873
+ const pair = await crypto.subtle.generateKey(
874
+ { name: 'ECDH', namedCurve: 'P-256' },
875
+ true,
876
+ ['deriveBits'],
877
+ );
878
+ this.localOneTimePreKeys.set(keyId, {
879
+ keyId,
880
+ publicKey: pair.publicKey,
881
+ privateKey: pair.privateKey,
882
+ createdAt: Date.now(),
883
+ });
884
+ }
885
+ }
886
+
887
+ private async snapshotLocalPreKeyBundle(): Promise<PreKeyBundle> {
888
+ if (!this.localSignedPreKey || !this._signingKeyPair) {
889
+ throw new Error('Local pre-key state unavailable');
890
+ }
891
+
892
+ const oneTimePreKeys = await Promise.all(
893
+ Array.from(this.localOneTimePreKeys.values())
894
+ .sort((a, b) => a.keyId - b.keyId)
895
+ .map(async (record) => ({
896
+ keyId: record.keyId,
897
+ publicKey: await this.exportEcdhPublicKey(record.publicKey),
898
+ createdAt: record.createdAt,
899
+ })),
900
+ );
901
+
902
+ return {
903
+ version: PRE_KEY_BUNDLE_VERSION,
904
+ peerId: this.myPeerId,
905
+ generatedAt: Date.now(),
906
+ signingPublicKey: await this.cryptoManager.exportPublicKey(this._signingKeyPair.publicKey),
907
+ signedPreKey: {
908
+ keyId: this.localSignedPreKey.keyId,
909
+ publicKey: await this.exportEcdhPublicKey(this.localSignedPreKey.publicKey),
910
+ signature: this.localSignedPreKey.signature,
911
+ createdAt: this.localSignedPreKey.createdAt,
912
+ expiresAt: this.localSignedPreKey.expiresAt,
913
+ },
914
+ oneTimePreKeys,
915
+ };
916
+ }
917
+
918
+ private async persistLocalPreKeyState(): Promise<void> {
919
+ if (!this.persistence?.saveLocalPreKeyState || !this.localSignedPreKey) return;
920
+
921
+ try {
922
+ const state: PersistedLocalPreKeyState = {
923
+ version: PRE_KEY_BUNDLE_VERSION,
924
+ generatedAt: Date.now(),
925
+ signedPreKey: {
926
+ keyId: this.localSignedPreKey.keyId,
927
+ publicKey: await this.exportEcdhPublicKey(this.localSignedPreKey.publicKey),
928
+ privateKey: await this.exportEcdhPrivateKey(this.localSignedPreKey.privateKey),
929
+ signature: this.localSignedPreKey.signature,
930
+ createdAt: this.localSignedPreKey.createdAt,
931
+ expiresAt: this.localSignedPreKey.expiresAt,
932
+ },
933
+ oneTimePreKeys: await Promise.all(
934
+ Array.from(this.localOneTimePreKeys.values())
935
+ .sort((a, b) => a.keyId - b.keyId)
936
+ .map(async (record) => ({
937
+ keyId: record.keyId,
938
+ publicKey: await this.exportEcdhPublicKey(record.publicKey),
939
+ privateKey: await this.exportEcdhPrivateKey(record.privateKey),
940
+ createdAt: record.createdAt,
941
+ })),
942
+ ),
943
+ nextOneTimePreKeyId: this.nextOneTimePreKeyId,
944
+ };
945
+
946
+ await this.persistence.saveLocalPreKeyState(this.myPeerId, state);
947
+ } catch (e) {
948
+ console.warn('[PreKey] Failed to persist local pre-key state:', e);
949
+ }
950
+ }
951
+
952
+ private async persistPeerPreKeyBundle(
953
+ peerId: string,
954
+ bundle: PreKeyBundle,
955
+ context: string = 'peer bundle',
956
+ ): Promise<void> {
957
+ if (!this.persistence?.savePreKeyBundle) return;
958
+
959
+ try {
960
+ await this.persistence.savePreKeyBundle(peerId, bundle);
961
+ } catch (e) {
962
+ console.warn(`[PreKey] Failed to persist ${context} for ${peerId.slice(0, 8)}:`, e);
963
+ }
964
+ }
965
+
966
+ private invalidateLocalPreKeyBundleCache(): void {
967
+ this.localPreKeyBundleCache = null;
968
+ }
969
+
970
+ private normalizePeerPreKeyBundle(bundle: PreKeyBundle, now = Date.now()): PreKeyBundle | null {
971
+ return normalizePeerPreKeyBundlePolicy(bundle, {
972
+ now,
973
+ expectedVersion: PRE_KEY_BUNDLE_VERSION,
974
+ maxBundleAgeMs: PRE_KEY_POLICY.maxPeerBundleAgeMs,
975
+ maxOneTimePreKeyAgeMs: PRE_KEY_POLICY.maxOneTimePreKeyAgeMs,
976
+ });
977
+ }
978
+
979
+ private hasPeerBundleChanged(before: PreKeyBundle, after: PreKeyBundle): boolean {
980
+ return hasPeerPreKeyBundleChanged(before, after);
981
+ }
982
+
983
+ private async sanitizeAndVerifyPeerPreKeyBundle(peerId: string, bundle: PreKeyBundle): Promise<PreKeyBundle | null> {
984
+ const normalized = this.normalizePeerPreKeyBundle(bundle);
985
+ if (!normalized) return null;
986
+ if (normalized.peerId !== peerId) return null;
987
+
988
+ try {
989
+ const signingKey = await this.cryptoManager.importSigningPublicKey(normalized.signingPublicKey);
990
+ const isValid = await this.cipher.verify(
991
+ normalized.signedPreKey.publicKey,
992
+ normalized.signedPreKey.signature,
993
+ signingKey,
994
+ );
995
+ if (!isValid) return null;
996
+
997
+ // Validate public keys are importable ECDH keys.
998
+ await this.importEcdhPublicKey(normalized.signedPreKey.publicKey);
999
+ for (const entry of normalized.oneTimePreKeys) {
1000
+ await this.importEcdhPublicKey(entry.publicKey);
1001
+ }
1002
+ return normalized;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+
1008
+ private async exportEcdhPublicKey(key: CryptoKey): Promise<string> {
1009
+ const raw = await crypto.subtle.exportKey('raw', key);
1010
+ return arrayBufferToBase64(raw);
1011
+ }
1012
+
1013
+ private async exportEcdhPrivateKey(key: CryptoKey): Promise<string> {
1014
+ const pkcs8 = await crypto.subtle.exportKey('pkcs8', key);
1015
+ return arrayBufferToBase64(pkcs8);
1016
+ }
1017
+
1018
+ private async importEcdhPublicKey(rawBase64: string): Promise<CryptoKey> {
1019
+ return crypto.subtle.importKey(
1020
+ 'raw',
1021
+ base64ToArrayBuffer(rawBase64),
1022
+ { name: 'ECDH', namedCurve: 'P-256' },
1023
+ true,
1024
+ [],
1025
+ );
1026
+ }
1027
+
1028
+ private async importEcdhPrivateKey(pkcs8Base64: string): Promise<CryptoKey> {
1029
+ return crypto.subtle.importKey(
1030
+ 'pkcs8',
1031
+ base64ToArrayBuffer(pkcs8Base64),
1032
+ { name: 'ECDH', namedCurve: 'P-256' },
1033
+ true,
1034
+ ['deriveBits'],
1035
+ );
1036
+ }
1037
+
1038
+ private generateMessageId(): string {
1039
+ return crypto.randomUUID();
1040
+ }
1041
+ }
1042
+
1043
+ // ── Base64 Utilities ──────────────────────────────────────────────────────────
1044
+
1045
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
1046
+ const bytes = new Uint8Array(buffer);
1047
+ let binary = '';
1048
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
1049
+ return btoa(binary);
1050
+ }
1051
+
1052
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
1053
+ const binary = atob(base64);
1054
+ const bytes = new Uint8Array(binary.length);
1055
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1056
+ return bytes.buffer;
1057
+ }