@btatum5/codex-bridge 0.1.0 → 1.3.3

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,738 @@
1
+ // FILE: secure-transport.js
2
+ // Purpose: Owns the bridge-side E2EE handshake, envelope crypto, and reconnect catch-up buffer.
3
+ // Layer: CLI helper
4
+ // Exports: createBridgeSecureTransport, SECURE_PROTOCOL_VERSION, PAIRING_QR_VERSION
5
+ // Depends on: crypto, ./secure-device-state
6
+
7
+ const {
8
+ createCipheriv,
9
+ createDecipheriv,
10
+ createHash,
11
+ createPrivateKey,
12
+ createPublicKey,
13
+ diffieHellman,
14
+ generateKeyPairSync,
15
+ hkdfSync,
16
+ randomBytes,
17
+ sign,
18
+ verify,
19
+ } = require("crypto");
20
+ const {
21
+ getTrustedPhonePublicKey,
22
+ rememberTrustedPhone,
23
+ } = require("./secure-device-state");
24
+
25
+ const PAIRING_QR_VERSION = 2;
26
+ const SECURE_PROTOCOL_VERSION = 1;
27
+ const HANDSHAKE_TAG = "codex-e2ee-v1";
28
+ const HANDSHAKE_MODE_QR_BOOTSTRAP = "qr_bootstrap";
29
+ const HANDSHAKE_MODE_TRUSTED_RECONNECT = "trusted_reconnect";
30
+ const SECURE_SENDER_MAC = "mac";
31
+ const SECURE_SENDER_IPHONE = "iphone";
32
+ const MAX_PAIRING_AGE_MS = 5 * 60 * 1000;
33
+ const MAX_BRIDGE_OUTBOUND_MESSAGES = 500;
34
+ const MAX_BRIDGE_OUTBOUND_BYTES = 10 * 1024 * 1024;
35
+
36
+ function createBridgeSecureTransport({
37
+ sessionId,
38
+ relayUrl,
39
+ deviceState,
40
+ onTrustedPhoneUpdate = null,
41
+ }) {
42
+ let currentDeviceState = deviceState;
43
+ let pendingHandshake = null;
44
+ let activeSession = null;
45
+ let liveSendWireMessage = null;
46
+ // Tracks the highest bridge seq the phone has definitely acked, so replay
47
+ // decisions never depend on best-effort local socket writes.
48
+ let lastRelayedBridgeOutboundSeq = 0;
49
+ let currentPairingExpiresAt = Date.now() + MAX_PAIRING_AGE_MS;
50
+ let nextKeyEpoch = 1;
51
+ let nextBridgeOutboundSeq = 1;
52
+ let outboundBufferBytes = 0;
53
+ const outboundBuffer = [];
54
+
55
+ function createPairingPayload() {
56
+ currentPairingExpiresAt = Date.now() + MAX_PAIRING_AGE_MS;
57
+ return {
58
+ v: PAIRING_QR_VERSION,
59
+ relay: relayUrl,
60
+ sessionId,
61
+ macDeviceId: currentDeviceState.macDeviceId,
62
+ macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
63
+ expiresAt: currentPairingExpiresAt,
64
+ };
65
+ }
66
+
67
+ function handleIncomingWireMessage(rawMessage, { sendControlMessage, onApplicationMessage }) {
68
+ const parsed = safeParseJSON(rawMessage);
69
+ if (!parsed || typeof parsed !== "object") {
70
+ return false;
71
+ }
72
+
73
+ const kind = normalizeNonEmptyString(parsed.kind);
74
+ if (!kind) {
75
+ if (parsed.method || parsed.id != null) {
76
+ sendControlMessage(createSecureError({
77
+ code: "update_required",
78
+ message: "This bridge requires the latest Codex iPhone app for secure pairing.",
79
+ }));
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ switch (kind) {
86
+ case "clientHello":
87
+ handleClientHello(parsed, sendControlMessage);
88
+ return true;
89
+ case "clientAuth":
90
+ handleClientAuth(parsed, sendControlMessage);
91
+ return true;
92
+ case "resumeState":
93
+ handleResumeState(parsed);
94
+ return true;
95
+ case "encryptedEnvelope":
96
+ return handleEncryptedEnvelope(parsed, sendControlMessage, onApplicationMessage);
97
+ default:
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function queueOutboundApplicationMessage(payloadText, sendWireMessage) {
103
+ const normalizedPayload = normalizeNonEmptyString(payloadText);
104
+ if (!normalizedPayload) {
105
+ return;
106
+ }
107
+
108
+ const bufferEntry = {
109
+ bridgeOutboundSeq: nextBridgeOutboundSeq,
110
+ payloadText: normalizedPayload,
111
+ sizeBytes: Buffer.byteLength(normalizedPayload, "utf8"),
112
+ };
113
+ nextBridgeOutboundSeq += 1;
114
+ outboundBuffer.push(bufferEntry);
115
+ outboundBufferBytes += bufferEntry.sizeBytes;
116
+ trimOutboundBuffer();
117
+
118
+ const liveSessionSender = activeSession?.sendWireMessage;
119
+ const effectiveSendWireMessage = typeof liveSessionSender === "function"
120
+ ? liveSessionSender
121
+ : sendWireMessage;
122
+ if (activeSession?.isResumed && typeof effectiveSendWireMessage === "function") {
123
+ sendBufferedEntry(bufferEntry, effectiveSendWireMessage);
124
+ }
125
+ }
126
+
127
+ function isSecureChannelReady() {
128
+ return Boolean(activeSession?.isResumed);
129
+ }
130
+
131
+ function handleClientHello(message, sendControlMessage) {
132
+ const protocolVersion = Number(message.protocolVersion);
133
+ const incomingSessionId = normalizeNonEmptyString(message.sessionId);
134
+ const handshakeMode = normalizeNonEmptyString(message.handshakeMode);
135
+ const phoneDeviceId = normalizeNonEmptyString(message.phoneDeviceId);
136
+ const phoneIdentityPublicKey = normalizeNonEmptyString(message.phoneIdentityPublicKey);
137
+ const phoneEphemeralPublicKey = normalizeNonEmptyString(message.phoneEphemeralPublicKey);
138
+ const clientNonceBase64 = normalizeNonEmptyString(message.clientNonce);
139
+
140
+ if (protocolVersion !== SECURE_PROTOCOL_VERSION || incomingSessionId !== sessionId) {
141
+ sendControlMessage(createSecureError({
142
+ code: "update_required",
143
+ message: "The bridge and iPhone are not using the same secure transport version.",
144
+ }));
145
+ return;
146
+ }
147
+
148
+ if (!phoneDeviceId || !phoneIdentityPublicKey || !phoneEphemeralPublicKey || !clientNonceBase64) {
149
+ sendControlMessage(createSecureError({
150
+ code: "invalid_client_hello",
151
+ message: "The iPhone handshake is missing required secure fields.",
152
+ }));
153
+ return;
154
+ }
155
+
156
+ if (handshakeMode !== HANDSHAKE_MODE_QR_BOOTSTRAP && handshakeMode !== HANDSHAKE_MODE_TRUSTED_RECONNECT) {
157
+ sendControlMessage(createSecureError({
158
+ code: "invalid_handshake_mode",
159
+ message: "The iPhone requested an unknown secure pairing mode.",
160
+ }));
161
+ return;
162
+ }
163
+
164
+ if (handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP && Date.now() > currentPairingExpiresAt) {
165
+ sendControlMessage(createSecureError({
166
+ code: "pairing_expired",
167
+ message: "The pairing QR code has expired. Generate a new QR code from the bridge.",
168
+ }));
169
+ return;
170
+ }
171
+
172
+ const trustedPhonePublicKey = getTrustedPhonePublicKey(currentDeviceState, phoneDeviceId);
173
+ if (handshakeMode === HANDSHAKE_MODE_TRUSTED_RECONNECT) {
174
+ if (!trustedPhonePublicKey) {
175
+ sendControlMessage(createSecureError({
176
+ code: "phone_not_trusted",
177
+ message: "This iPhone is not trusted by the current bridge session. Scan a fresh QR code to pair again.",
178
+ }));
179
+ return;
180
+ }
181
+ if (trustedPhonePublicKey !== phoneIdentityPublicKey) {
182
+ sendControlMessage(createSecureError({
183
+ code: "phone_identity_changed",
184
+ message: "The trusted iPhone identity does not match this reconnect attempt.",
185
+ }));
186
+ return;
187
+ }
188
+ }
189
+
190
+ const clientNonce = base64ToBuffer(clientNonceBase64);
191
+ if (!clientNonce || clientNonce.length === 0) {
192
+ sendControlMessage(createSecureError({
193
+ code: "invalid_client_nonce",
194
+ message: "The iPhone secure nonce could not be decoded.",
195
+ }));
196
+ return;
197
+ }
198
+
199
+ const ephemeral = generateKeyPairSync("x25519");
200
+ const privateJwk = ephemeral.privateKey.export({ format: "jwk" });
201
+ const publicJwk = ephemeral.publicKey.export({ format: "jwk" });
202
+ const serverNonce = randomBytes(32);
203
+ const keyEpoch = nextKeyEpoch;
204
+ const expiresAtForTranscript = handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP
205
+ ? currentPairingExpiresAt
206
+ : 0;
207
+ const transcriptBytes = buildTranscriptBytes({
208
+ sessionId,
209
+ protocolVersion,
210
+ handshakeMode,
211
+ keyEpoch,
212
+ macDeviceId: currentDeviceState.macDeviceId,
213
+ phoneDeviceId,
214
+ macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
215
+ phoneIdentityPublicKey,
216
+ macEphemeralPublicKey: base64UrlToBase64(publicJwk.x),
217
+ phoneEphemeralPublicKey,
218
+ clientNonce,
219
+ serverNonce,
220
+ expiresAtForTranscript,
221
+ });
222
+ const macSignature = signTranscript(
223
+ currentDeviceState.macIdentityPrivateKey,
224
+ currentDeviceState.macIdentityPublicKey,
225
+ transcriptBytes
226
+ );
227
+ debugSecureLog(
228
+ `serverHello mode=${handshakeMode} session=${shortId(sessionId)} keyEpoch=${keyEpoch} `
229
+ + `mac=${shortId(currentDeviceState.macDeviceId)} phone=${shortId(phoneDeviceId)} `
230
+ + `macKey=${shortFingerprint(currentDeviceState.macIdentityPublicKey)} `
231
+ + `phoneKey=${shortFingerprint(phoneIdentityPublicKey)} `
232
+ + `transcript=${transcriptDigest(transcriptBytes)}`
233
+ );
234
+
235
+ pendingHandshake = {
236
+ sessionId,
237
+ handshakeMode,
238
+ keyEpoch,
239
+ phoneDeviceId,
240
+ phoneIdentityPublicKey,
241
+ phoneEphemeralPublicKey,
242
+ macEphemeralPrivateKey: base64UrlToBase64(privateJwk.d),
243
+ macEphemeralPublicKey: base64UrlToBase64(publicJwk.x),
244
+ transcriptBytes,
245
+ expiresAtForTranscript,
246
+ };
247
+ activeSession = null;
248
+
249
+ sendControlMessage({
250
+ kind: "serverHello",
251
+ protocolVersion: SECURE_PROTOCOL_VERSION,
252
+ sessionId,
253
+ handshakeMode,
254
+ macDeviceId: currentDeviceState.macDeviceId,
255
+ macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
256
+ macEphemeralPublicKey: pendingHandshake.macEphemeralPublicKey,
257
+ serverNonce: serverNonce.toString("base64"),
258
+ keyEpoch,
259
+ expiresAtForTranscript,
260
+ macSignature,
261
+ clientNonce: clientNonceBase64,
262
+ });
263
+ }
264
+
265
+ function handleClientAuth(message, sendControlMessage) {
266
+ if (!pendingHandshake) {
267
+ sendControlMessage(createSecureError({
268
+ code: "unexpected_client_auth",
269
+ message: "The bridge did not have a pending secure handshake to finalize.",
270
+ }));
271
+ return;
272
+ }
273
+
274
+ const incomingSessionId = normalizeNonEmptyString(message.sessionId);
275
+ const phoneDeviceId = normalizeNonEmptyString(message.phoneDeviceId);
276
+ const keyEpoch = Number(message.keyEpoch);
277
+ const phoneSignature = normalizeNonEmptyString(message.phoneSignature);
278
+ if (
279
+ incomingSessionId !== pendingHandshake.sessionId
280
+ || phoneDeviceId !== pendingHandshake.phoneDeviceId
281
+ || keyEpoch !== pendingHandshake.keyEpoch
282
+ || !phoneSignature
283
+ ) {
284
+ pendingHandshake = null;
285
+ sendControlMessage(createSecureError({
286
+ code: "invalid_client_auth",
287
+ message: "The secure client authentication payload was invalid.",
288
+ }));
289
+ return;
290
+ }
291
+
292
+ const clientAuthTranscript = Buffer.concat([
293
+ pendingHandshake.transcriptBytes,
294
+ encodeLengthPrefixedUTF8("client-auth"),
295
+ ]);
296
+ const phoneVerified = verifyTranscript(
297
+ pendingHandshake.phoneIdentityPublicKey,
298
+ clientAuthTranscript,
299
+ phoneSignature
300
+ );
301
+ if (!phoneVerified) {
302
+ pendingHandshake = null;
303
+ sendControlMessage(createSecureError({
304
+ code: "invalid_phone_signature",
305
+ message: "The iPhone secure signature could not be verified.",
306
+ }));
307
+ return;
308
+ }
309
+
310
+ const sharedSecret = diffieHellman({
311
+ privateKey: createPrivateKey({
312
+ key: {
313
+ crv: "X25519",
314
+ d: base64ToBase64Url(pendingHandshake.macEphemeralPrivateKey),
315
+ kty: "OKP",
316
+ x: base64ToBase64Url(pendingHandshake.macEphemeralPublicKey),
317
+ },
318
+ format: "jwk",
319
+ }),
320
+ publicKey: createPublicKey({
321
+ key: {
322
+ crv: "X25519",
323
+ kty: "OKP",
324
+ x: base64ToBase64Url(pendingHandshake.phoneEphemeralPublicKey),
325
+ },
326
+ format: "jwk",
327
+ }),
328
+ });
329
+ const salt = createHash("sha256").update(pendingHandshake.transcriptBytes).digest();
330
+ const infoPrefix = [
331
+ HANDSHAKE_TAG,
332
+ pendingHandshake.sessionId,
333
+ currentDeviceState.macDeviceId,
334
+ pendingHandshake.phoneDeviceId,
335
+ String(pendingHandshake.keyEpoch),
336
+ ].join("|");
337
+
338
+ activeSession = {
339
+ sessionId: pendingHandshake.sessionId,
340
+ keyEpoch: pendingHandshake.keyEpoch,
341
+ phoneDeviceId: pendingHandshake.phoneDeviceId,
342
+ phoneIdentityPublicKey: pendingHandshake.phoneIdentityPublicKey,
343
+ phoneToMacKey: deriveAesKey(sharedSecret, salt, `${infoPrefix}|phoneToMac`),
344
+ macToPhoneKey: deriveAesKey(sharedSecret, salt, `${infoPrefix}|macToPhone`),
345
+ lastInboundCounter: -1,
346
+ nextOutboundCounter: 0,
347
+ isResumed: false,
348
+ sendWireMessage: liveSendWireMessage,
349
+ };
350
+
351
+ nextKeyEpoch = pendingHandshake.keyEpoch + 1;
352
+ if (
353
+ pendingHandshake.handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP
354
+ || getTrustedPhonePublicKey(currentDeviceState, pendingHandshake.phoneDeviceId)
355
+ ) {
356
+ // Lock the trusted phone identity so later reconnects can be verified cleanly.
357
+ const previousTrustedPhonePublicKey = getTrustedPhonePublicKey(
358
+ currentDeviceState,
359
+ pendingHandshake.phoneDeviceId
360
+ );
361
+ currentDeviceState = rememberTrustedPhone(
362
+ currentDeviceState,
363
+ pendingHandshake.phoneDeviceId,
364
+ pendingHandshake.phoneIdentityPublicKey
365
+ );
366
+ if (previousTrustedPhonePublicKey !== pendingHandshake.phoneIdentityPublicKey) {
367
+ onTrustedPhoneUpdate?.(currentDeviceState);
368
+ }
369
+ }
370
+ if (pendingHandshake.handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP) {
371
+ resetOutboundReplayState();
372
+ }
373
+
374
+ pendingHandshake = null;
375
+ sendControlMessage({
376
+ kind: "secureReady",
377
+ sessionId,
378
+ keyEpoch: activeSession.keyEpoch,
379
+ macDeviceId: currentDeviceState.macDeviceId,
380
+ });
381
+ }
382
+
383
+ function handleResumeState(message) {
384
+ if (!activeSession) {
385
+ return;
386
+ }
387
+
388
+ const incomingSessionId = normalizeNonEmptyString(message.sessionId);
389
+ const keyEpoch = Number(message.keyEpoch);
390
+ if (incomingSessionId !== sessionId || keyEpoch !== activeSession.keyEpoch) {
391
+ return;
392
+ }
393
+
394
+ const lastAppliedBridgeOutboundSeq = Number(message.lastAppliedBridgeOutboundSeq) || 0;
395
+ lastRelayedBridgeOutboundSeq = lastAppliedBridgeOutboundSeq;
396
+ const missingEntries = replayableOutboundEntries(lastAppliedBridgeOutboundSeq);
397
+ activeSession.isResumed = true;
398
+ for (const entry of missingEntries) {
399
+ if (!sendBufferedEntry(entry, activeSession.sendWireMessage)) {
400
+ break;
401
+ }
402
+ }
403
+ }
404
+
405
+ function handleEncryptedEnvelope(message, sendControlMessage, onApplicationMessage) {
406
+ if (!activeSession) {
407
+ sendControlMessage(createSecureError({
408
+ code: "secure_channel_unavailable",
409
+ message: "The secure channel is not ready yet on the bridge.",
410
+ }));
411
+ return true;
412
+ }
413
+
414
+ const incomingSessionId = normalizeNonEmptyString(message.sessionId);
415
+ const keyEpoch = Number(message.keyEpoch);
416
+ const sender = normalizeNonEmptyString(message.sender);
417
+ const counter = Number(message.counter);
418
+ if (
419
+ incomingSessionId !== sessionId
420
+ || keyEpoch !== activeSession.keyEpoch
421
+ || sender !== SECURE_SENDER_IPHONE
422
+ || !Number.isInteger(counter)
423
+ || counter <= activeSession.lastInboundCounter
424
+ ) {
425
+ sendControlMessage(createSecureError({
426
+ code: "invalid_envelope",
427
+ message: "The bridge rejected an invalid or replayed secure envelope.",
428
+ }));
429
+ return true;
430
+ }
431
+
432
+ const plaintextBuffer = decryptEnvelopeBuffer(message, activeSession.phoneToMacKey, SECURE_SENDER_IPHONE, counter);
433
+ if (!plaintextBuffer) {
434
+ sendControlMessage(createSecureError({
435
+ code: "decrypt_failed",
436
+ message: "The bridge could not decrypt the iPhone secure payload.",
437
+ }));
438
+ return true;
439
+ }
440
+
441
+ activeSession.lastInboundCounter = counter;
442
+ const payloadObject = safeParseJSON(plaintextBuffer.toString("utf8"));
443
+ const payloadText = normalizeNonEmptyString(payloadObject?.payloadText);
444
+ if (!payloadText) {
445
+ sendControlMessage(createSecureError({
446
+ code: "invalid_payload",
447
+ message: "The secure payload did not contain a usable application message.",
448
+ }));
449
+ return true;
450
+ }
451
+
452
+ onApplicationMessage(payloadText);
453
+ return true;
454
+ }
455
+
456
+ function bindLiveSendWireMessage(sendWireMessage) {
457
+ liveSendWireMessage = sendWireMessage;
458
+ if (activeSession) {
459
+ activeSession.sendWireMessage = sendWireMessage;
460
+ replayBufferedOutboundMessages();
461
+ }
462
+ }
463
+
464
+ function trimOutboundBuffer() {
465
+ while (
466
+ outboundBuffer.length > MAX_BRIDGE_OUTBOUND_MESSAGES
467
+ || outboundBufferBytes > MAX_BRIDGE_OUTBOUND_BYTES
468
+ ) {
469
+ const removed = outboundBuffer.shift();
470
+ if (!removed) {
471
+ break;
472
+ }
473
+ outboundBufferBytes = Math.max(0, outboundBufferBytes - removed.sizeBytes);
474
+ }
475
+ }
476
+
477
+ // Starts each fresh QR bootstrap with a clean catch-up window for the single trusted phone.
478
+ function resetOutboundReplayState() {
479
+ outboundBuffer.length = 0;
480
+ outboundBufferBytes = 0;
481
+ lastRelayedBridgeOutboundSeq = 0;
482
+ nextBridgeOutboundSeq = 1;
483
+ }
484
+
485
+ function sendBufferedEntry(entry, sendWireMessage) {
486
+ if (!activeSession?.isResumed || typeof sendWireMessage !== "function") {
487
+ return false;
488
+ }
489
+
490
+ const envelope = encryptEnvelopePayload(
491
+ {
492
+ bridgeOutboundSeq: entry.bridgeOutboundSeq,
493
+ payloadText: entry.payloadText,
494
+ },
495
+ activeSession.macToPhoneKey,
496
+ SECURE_SENDER_MAC,
497
+ activeSession.nextOutboundCounter,
498
+ sessionId,
499
+ activeSession.keyEpoch
500
+ );
501
+ activeSession.nextOutboundCounter += 1;
502
+ return sendWireMessage(JSON.stringify(envelope)) !== false;
503
+ }
504
+
505
+ function replayableOutboundEntries(lastAppliedBridgeOutboundSeq) {
506
+ return outboundBuffer.filter(
507
+ (entry) => entry.bridgeOutboundSeq > lastAppliedBridgeOutboundSeq
508
+ );
509
+ }
510
+
511
+ // Replays from the last phone ack instead of local socket writes, so a relay
512
+ // flap cannot make the bridge skip output the phone never actually received.
513
+ function replayBufferedOutboundMessages() {
514
+ if (!activeSession?.isResumed || typeof activeSession.sendWireMessage !== "function") {
515
+ return;
516
+ }
517
+
518
+ for (const entry of replayableOutboundEntries(lastRelayedBridgeOutboundSeq)) {
519
+ if (!sendBufferedEntry(entry, activeSession.sendWireMessage)) {
520
+ break;
521
+ }
522
+ }
523
+ }
524
+
525
+ return {
526
+ PAIRING_QR_VERSION,
527
+ SECURE_PROTOCOL_VERSION,
528
+ bindLiveSendWireMessage,
529
+ createPairingPayload,
530
+ handleIncomingWireMessage,
531
+ isSecureChannelReady,
532
+ queueOutboundApplicationMessage,
533
+ };
534
+ }
535
+
536
+ function debugSecureLog(message) {
537
+ console.log(`[codex-bridge][secure] ${message}`);
538
+ }
539
+
540
+ function shortId(value) {
541
+ const normalized = normalizeNonEmptyString(value);
542
+ return normalized ? normalized.slice(0, 8) : "none";
543
+ }
544
+
545
+ function shortFingerprint(publicKeyBase64) {
546
+ const bytes = base64ToBuffer(publicKeyBase64);
547
+ if (!bytes || bytes.length === 0) {
548
+ return "invalid";
549
+ }
550
+ return createHash("sha256").update(bytes).digest("hex").slice(0, 12);
551
+ }
552
+
553
+ function transcriptDigest(transcriptBytes) {
554
+ return createHash("sha256").update(transcriptBytes).digest("hex").slice(0, 16);
555
+ }
556
+
557
+ function encryptEnvelopePayload(payloadObject, key, sender, counter, sessionId, keyEpoch) {
558
+ const nonce = nonceForDirection(sender, counter);
559
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
560
+ const ciphertext = Buffer.concat([
561
+ cipher.update(Buffer.from(JSON.stringify(payloadObject), "utf8")),
562
+ cipher.final(),
563
+ ]);
564
+ const tag = cipher.getAuthTag();
565
+
566
+ return {
567
+ kind: "encryptedEnvelope",
568
+ v: SECURE_PROTOCOL_VERSION,
569
+ sessionId,
570
+ keyEpoch,
571
+ sender,
572
+ counter,
573
+ ciphertext: ciphertext.toString("base64"),
574
+ tag: tag.toString("base64"),
575
+ };
576
+ }
577
+
578
+ function decryptEnvelopeBuffer(envelope, key, sender, counter) {
579
+ try {
580
+ const nonce = nonceForDirection(sender, counter);
581
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
582
+ decipher.setAuthTag(base64ToBuffer(envelope.tag));
583
+ return Buffer.concat([
584
+ decipher.update(base64ToBuffer(envelope.ciphertext)),
585
+ decipher.final(),
586
+ ]);
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+
592
+ function deriveAesKey(sharedSecret, salt, infoLabel) {
593
+ return Buffer.from(hkdfSync("sha256", sharedSecret, salt, Buffer.from(infoLabel, "utf8"), 32));
594
+ }
595
+
596
+ function signTranscript(privateKeyBase64, publicKeyBase64, transcriptBytes) {
597
+ const signature = sign(
598
+ null,
599
+ transcriptBytes,
600
+ createPrivateKey({
601
+ key: {
602
+ crv: "Ed25519",
603
+ d: base64ToBase64Url(privateKeyBase64),
604
+ kty: "OKP",
605
+ x: base64ToBase64Url(publicKeyBase64),
606
+ },
607
+ format: "jwk",
608
+ })
609
+ );
610
+ return signature.toString("base64");
611
+ }
612
+
613
+ function verifyTranscript(publicKeyBase64, transcriptBytes, signatureBase64) {
614
+ try {
615
+ return verify(
616
+ null,
617
+ transcriptBytes,
618
+ createPublicKey({
619
+ key: {
620
+ crv: "Ed25519",
621
+ kty: "OKP",
622
+ x: base64ToBase64Url(publicKeyBase64),
623
+ },
624
+ format: "jwk",
625
+ }),
626
+ base64ToBuffer(signatureBase64)
627
+ );
628
+ } catch {
629
+ return false;
630
+ }
631
+ }
632
+
633
+ function buildTranscriptBytes({
634
+ sessionId,
635
+ protocolVersion,
636
+ handshakeMode,
637
+ keyEpoch,
638
+ macDeviceId,
639
+ phoneDeviceId,
640
+ macIdentityPublicKey,
641
+ phoneIdentityPublicKey,
642
+ macEphemeralPublicKey,
643
+ phoneEphemeralPublicKey,
644
+ clientNonce,
645
+ serverNonce,
646
+ expiresAtForTranscript,
647
+ }) {
648
+ return Buffer.concat([
649
+ encodeLengthPrefixedUTF8(HANDSHAKE_TAG),
650
+ encodeLengthPrefixedUTF8(sessionId),
651
+ encodeLengthPrefixedUTF8(String(protocolVersion)),
652
+ encodeLengthPrefixedUTF8(handshakeMode),
653
+ encodeLengthPrefixedUTF8(String(keyEpoch)),
654
+ encodeLengthPrefixedUTF8(macDeviceId),
655
+ encodeLengthPrefixedUTF8(phoneDeviceId),
656
+ encodeLengthPrefixedBuffer(base64ToBuffer(macIdentityPublicKey)),
657
+ encodeLengthPrefixedBuffer(base64ToBuffer(phoneIdentityPublicKey)),
658
+ encodeLengthPrefixedBuffer(base64ToBuffer(macEphemeralPublicKey)),
659
+ encodeLengthPrefixedBuffer(base64ToBuffer(phoneEphemeralPublicKey)),
660
+ encodeLengthPrefixedBuffer(clientNonce),
661
+ encodeLengthPrefixedBuffer(serverNonce),
662
+ encodeLengthPrefixedUTF8(String(expiresAtForTranscript)),
663
+ ]);
664
+ }
665
+
666
+ function encodeLengthPrefixedUTF8(value) {
667
+ return encodeLengthPrefixedBuffer(Buffer.from(String(value), "utf8"));
668
+ }
669
+
670
+ function encodeLengthPrefixedBuffer(buffer) {
671
+ const lengthBuffer = Buffer.allocUnsafe(4);
672
+ lengthBuffer.writeUInt32BE(buffer.length, 0);
673
+ return Buffer.concat([lengthBuffer, buffer]);
674
+ }
675
+
676
+ function nonceForDirection(sender, counter) {
677
+ const nonce = Buffer.alloc(12, 0);
678
+ nonce.writeUInt8(sender === SECURE_SENDER_MAC ? 1 : 2, 0);
679
+ let value = BigInt(counter);
680
+ for (let index = 11; index >= 1; index -= 1) {
681
+ nonce[index] = Number(value & 0xffn);
682
+ value >>= 8n;
683
+ }
684
+ return nonce;
685
+ }
686
+
687
+ function createSecureError({ code, message }) {
688
+ return {
689
+ kind: "secureError",
690
+ code,
691
+ message,
692
+ };
693
+ }
694
+
695
+ function normalizeNonEmptyString(value) {
696
+ if (typeof value !== "string") {
697
+ return "";
698
+ }
699
+ return value.trim();
700
+ }
701
+
702
+ function safeParseJSON(value) {
703
+ if (typeof value !== "string") {
704
+ return null;
705
+ }
706
+
707
+ try {
708
+ return JSON.parse(value);
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ function base64ToBuffer(value) {
715
+ try {
716
+ return Buffer.from(value, "base64");
717
+ } catch {
718
+ return null;
719
+ }
720
+ }
721
+
722
+ function base64UrlToBase64(value) {
723
+ const padded = `${value}${"=".repeat((4 - (value.length % 4 || 4)) % 4)}`;
724
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
725
+ }
726
+
727
+ function base64ToBase64Url(value) {
728
+ return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
729
+ }
730
+
731
+ module.exports = {
732
+ HANDSHAKE_MODE_QR_BOOTSTRAP,
733
+ HANDSHAKE_MODE_TRUSTED_RECONNECT,
734
+ PAIRING_QR_VERSION,
735
+ SECURE_PROTOCOL_VERSION,
736
+ createBridgeSecureTransport,
737
+ nonceForDirection,
738
+ };