forkoff 1.0.16 → 1.0.18

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 (157) hide show
  1. package/README.md +3 -6
  2. package/dist/approval.d.ts +1 -0
  3. package/dist/approval.js +9 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +62 -16
  6. package/dist/crypto/e2eeManager.d.ts +49 -52
  7. package/dist/crypto/e2eeManager.js +256 -181
  8. package/dist/crypto/encryption.d.ts +8 -10
  9. package/dist/crypto/encryption.js +29 -94
  10. package/dist/crypto/index.d.ts +10 -0
  11. package/dist/crypto/index.js +22 -0
  12. package/dist/crypto/keyExchange.d.ts +6 -20
  13. package/dist/crypto/keyExchange.js +18 -110
  14. package/dist/crypto/keyGeneration.d.ts +2 -13
  15. package/dist/crypto/keyGeneration.js +14 -88
  16. package/dist/crypto/keyStorage.d.ts +32 -5
  17. package/dist/crypto/keyStorage.js +152 -8
  18. package/dist/crypto/sessionPersistence.d.ts +7 -13
  19. package/dist/crypto/sessionPersistence.js +108 -33
  20. package/dist/crypto/types.d.ts +24 -3
  21. package/dist/crypto/types.js +2 -1
  22. package/dist/crypto/websocketE2EE.d.ts +6 -17
  23. package/dist/crypto/websocketE2EE.js +21 -38
  24. package/dist/index.js +203 -280
  25. package/dist/integration.d.ts +0 -1
  26. package/dist/integration.js +2 -4
  27. package/dist/logger.d.ts +15 -0
  28. package/dist/logger.js +209 -1
  29. package/dist/server.d.ts +30 -0
  30. package/dist/server.js +162 -0
  31. package/dist/startup.js +15 -6
  32. package/dist/terminal.d.ts +1 -0
  33. package/dist/terminal.js +94 -1
  34. package/dist/tools/claude-process.d.ts +8 -0
  35. package/dist/tools/claude-process.js +199 -26
  36. package/dist/tools/claude-sessions.d.ts +1 -0
  37. package/dist/tools/claude-sessions.js +36 -10
  38. package/dist/tools/detector.js +11 -3
  39. package/dist/tools/permission-hook.js +94 -27
  40. package/dist/tools/permission-ipc.d.ts +1 -0
  41. package/dist/tools/permission-ipc.js +61 -14
  42. package/dist/transcript-streamer.d.ts +1 -0
  43. package/dist/transcript-streamer.js +18 -4
  44. package/dist/usage-tracker.d.ts +45 -0
  45. package/dist/usage-tracker.js +243 -0
  46. package/dist/websocket.d.ts +43 -12
  47. package/dist/websocket.js +418 -214
  48. package/package.json +7 -5
  49. package/dist/__tests__/cli-commands.test.d.ts +0 -6
  50. package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
  51. package/dist/__tests__/cli-commands.test.js +0 -213
  52. package/dist/__tests__/cli-commands.test.js.map +0 -1
  53. package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
  54. package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
  55. package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
  56. package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
  57. package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
  58. package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
  59. package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
  60. package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
  61. package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
  62. package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
  63. package/dist/__tests__/crypto/encryption.test.js +0 -116
  64. package/dist/__tests__/crypto/encryption.test.js.map +0 -1
  65. package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
  66. package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
  67. package/dist/__tests__/crypto/keyExchange.test.js +0 -84
  68. package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
  69. package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
  70. package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
  71. package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
  72. package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
  73. package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
  74. package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
  75. package/dist/__tests__/crypto/keyStorage.test.js +0 -133
  76. package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
  77. package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
  78. package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
  79. package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
  80. package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
  81. package/dist/__tests__/startup.test.d.ts +0 -11
  82. package/dist/__tests__/startup.test.d.ts.map +0 -1
  83. package/dist/__tests__/startup.test.js +0 -241
  84. package/dist/__tests__/startup.test.js.map +0 -1
  85. package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
  86. package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
  87. package/dist/__tests__/tools/claude-process.test.js +0 -430
  88. package/dist/__tests__/tools/claude-process.test.js.map +0 -1
  89. package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
  90. package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
  91. package/dist/__tests__/tools/permission-hook.test.js +0 -616
  92. package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
  93. package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
  94. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
  95. package/dist/__tests__/tools/permission-ipc.test.js +0 -612
  96. package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
  97. package/dist/__tests__/websocket.test.d.ts +0 -13
  98. package/dist/__tests__/websocket.test.d.ts.map +0 -1
  99. package/dist/__tests__/websocket.test.js +0 -204
  100. package/dist/__tests__/websocket.test.js.map +0 -1
  101. package/dist/api.d.ts +0 -44
  102. package/dist/api.d.ts.map +0 -1
  103. package/dist/api.js +0 -76
  104. package/dist/api.js.map +0 -1
  105. package/dist/approval.d.ts.map +0 -1
  106. package/dist/approval.js.map +0 -1
  107. package/dist/config.d.ts.map +0 -1
  108. package/dist/config.js.map +0 -1
  109. package/dist/crypto/e2eeManager.d.ts.map +0 -1
  110. package/dist/crypto/e2eeManager.js.map +0 -1
  111. package/dist/crypto/encryption.d.ts.map +0 -1
  112. package/dist/crypto/encryption.js.map +0 -1
  113. package/dist/crypto/keyExchange.d.ts.map +0 -1
  114. package/dist/crypto/keyExchange.js.map +0 -1
  115. package/dist/crypto/keyGeneration.d.ts.map +0 -1
  116. package/dist/crypto/keyGeneration.js.map +0 -1
  117. package/dist/crypto/keyStorage.d.ts.map +0 -1
  118. package/dist/crypto/keyStorage.js.map +0 -1
  119. package/dist/crypto/sessionPersistence.d.ts.map +0 -1
  120. package/dist/crypto/sessionPersistence.js.map +0 -1
  121. package/dist/crypto/types.d.ts.map +0 -1
  122. package/dist/crypto/types.js.map +0 -1
  123. package/dist/crypto/websocketE2EE.d.ts.map +0 -1
  124. package/dist/crypto/websocketE2EE.js.map +0 -1
  125. package/dist/index.d.ts.map +0 -1
  126. package/dist/index.js.map +0 -1
  127. package/dist/integration.d.ts.map +0 -1
  128. package/dist/integration.js.map +0 -1
  129. package/dist/logger.d.ts.map +0 -1
  130. package/dist/logger.js.map +0 -1
  131. package/dist/startup.d.ts.map +0 -1
  132. package/dist/startup.js.map +0 -1
  133. package/dist/terminal.d.ts.map +0 -1
  134. package/dist/terminal.js.map +0 -1
  135. package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
  136. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
  137. package/dist/tools/__tests__/claude-sessions.test.js +0 -306
  138. package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
  139. package/dist/tools/claude-hooks.d.ts.map +0 -1
  140. package/dist/tools/claude-hooks.js.map +0 -1
  141. package/dist/tools/claude-process.d.ts.map +0 -1
  142. package/dist/tools/claude-process.js.map +0 -1
  143. package/dist/tools/claude-sessions.d.ts.map +0 -1
  144. package/dist/tools/claude-sessions.js.map +0 -1
  145. package/dist/tools/detector.d.ts.map +0 -1
  146. package/dist/tools/detector.js.map +0 -1
  147. package/dist/tools/index.d.ts.map +0 -1
  148. package/dist/tools/index.js.map +0 -1
  149. package/dist/tools/permission-hook.d.ts.map +0 -1
  150. package/dist/tools/permission-hook.js.map +0 -1
  151. package/dist/tools/permission-ipc.d.ts.map +0 -1
  152. package/dist/tools/permission-ipc.js.map +0 -1
  153. package/dist/transcript-streamer.d.ts.map +0 -1
  154. package/dist/transcript-streamer.js.map +0 -1
  155. package/dist/websocket.d.ts.map +0 -1
  156. package/dist/websocket.js.map +0 -1
  157. package/jest.config.js +0 -18
@@ -4,267 +4,342 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.E2EEManager = void 0;
7
- const axios_1 = __importDefault(require("axios"));
7
+ /**
8
+ * E2EE Manager for CLI
9
+ * Orchestrates key generation, storage, exchange, and message encryption/decryption.
10
+ * Uses NaCl (tweetnacl) — compatible with mobile app's implementation.
11
+ *
12
+ * Security features:
13
+ * - X25519 ECDH key exchange for shared secret derivation
14
+ * - Ed25519 identity signatures on ephemeral keys (MITM protection)
15
+ * - TOFU (Trust On First Use) for peer identity verification
16
+ * - XSalsa20-Poly1305 authenticated encryption
17
+ * - Per-peer monotonic message counters (replay protection)
18
+ */
19
+ const tweetnacl_1 = __importDefault(require("tweetnacl"));
20
+ const tweetnacl_util_1 = require("tweetnacl-util");
8
21
  const keyGeneration_1 = require("./keyGeneration");
9
- const keyStorage_1 = require("./keyStorage");
10
22
  const keyExchange_1 = require("./keyExchange");
11
23
  const encryption_1 = require("./encryption");
24
+ const keyStorage_1 = require("./keyStorage");
12
25
  const sessionPersistence_1 = require("./sessionPersistence");
13
- /**
14
- * E2EE Manager for CLI
15
- * Orchestrates all end-to-end encryption operations
16
- */
17
26
  class E2EEManager {
18
- constructor(deviceId, apiUrl, authToken) {
27
+ constructor(deviceId) {
19
28
  this.keyPair = null;
29
+ this.signingKeyPair = null;
20
30
  this.initialized = false;
21
- // Track ephemeral keys for pending key exchanges
22
- this.pendingKeyExchanges = new Map(); // deviceId -> ephemeralPrivateKey
23
- // Track message counters for replay protection
24
- this.outgoingCounters = new Map(); // deviceId -> counter
25
- this.incomingCounters = new Map(); // deviceId -> last seen counter
31
+ // In-memory active sessions (parallel to keyStorage for fast access)
32
+ this.sessions = new Map();
33
+ this.pendingExchanges = new Map();
26
34
  this.deviceId = deviceId;
27
- this.apiUrl = apiUrl;
28
- this.authToken = authToken;
29
- this.axiosInstance = axios_1.default.create({
30
- headers: {
31
- Authorization: `Bearer ${authToken}`,
32
- },
33
- });
34
35
  }
35
36
  /**
36
- * Initializes E2EE manager
37
- * - Loads or generates key pair
38
- * - Uploads public key to backend
37
+ * Initializes E2EE manager: loads or generates identity key pairs (DH + signing).
39
38
  */
40
39
  async initialize() {
41
- // Try to load existing key pair
40
+ // Load or generate X25519 DH key pair
42
41
  const existingPrivateKey = await (0, keyStorage_1.getPrivateKey)(this.deviceId);
43
42
  if (existingPrivateKey) {
44
- // Derive public key from existing private key
45
- const publicKey = this.derivePublicKeyFromPrivateKey(existingPrivateKey);
43
+ const secretKey = (0, tweetnacl_util_1.decodeBase64)(existingPrivateKey);
44
+ const kp = tweetnacl_1.default.box.keyPair.fromSecretKey(secretKey);
46
45
  this.keyPair = {
47
- publicKey,
46
+ publicKey: (0, tweetnacl_util_1.encodeBase64)(kp.publicKey),
48
47
  privateKey: existingPrivateKey,
49
48
  };
50
49
  }
51
50
  else {
52
- // Generate new key pair
53
51
  this.keyPair = (0, keyGeneration_1.generateKeyPair)();
54
52
  await (0, keyStorage_1.storePrivateKey)(this.deviceId, this.keyPair.privateKey);
55
53
  }
56
- // Upload public key to backend
57
- await this.uploadPublicKey();
54
+ // Load or generate Ed25519 signing key pair
55
+ const existingSigningKP = await (0, keyStorage_1.getSigningKeyPair)(this.deviceId);
56
+ if (existingSigningKP) {
57
+ this.signingKeyPair = existingSigningKP;
58
+ }
59
+ else {
60
+ const signKP = tweetnacl_1.default.sign.keyPair();
61
+ this.signingKeyPair = {
62
+ publicKey: (0, tweetnacl_util_1.encodeBase64)(signKP.publicKey),
63
+ secretKey: (0, tweetnacl_util_1.encodeBase64)(signKP.secretKey),
64
+ };
65
+ await (0, keyStorage_1.storeSigningKeyPair)(this.deviceId, this.signingKeyPair);
66
+ }
67
+ // Load trusted peer keys from disk
68
+ (0, keyStorage_1.loadTrustedPeerKeys)();
69
+ // Initialize session persistence encryption (derives key from identity private key)
70
+ (0, sessionPersistence_1.initSessionPersistence)(this.keyPair.privateKey);
58
71
  this.initialized = true;
59
72
  }
73
+ /** Get the public key (for uploading to server) */
74
+ getPublicKey() {
75
+ return this.keyPair?.publicKey ?? null;
76
+ }
77
+ /** Get the signing public key */
78
+ getSigningPublicKey() {
79
+ return this.signingKeyPair?.publicKey ?? null;
80
+ }
81
+ /** Check if manager is initialized */
82
+ isInitialized() {
83
+ return this.initialized;
84
+ }
60
85
  /**
61
- * Derives X25519 public key from private key
86
+ * Sign a key exchange payload with our Ed25519 identity key.
87
+ * The signed message is: "prefix:senderDeviceId:ephemeralPublicKey[:recipientDeviceId]"
62
88
  */
63
- derivePublicKeyFromPrivateKey(privateKey) {
64
- const crypto = require('crypto');
65
- const privateKeyBytes = Buffer.from(privateKey, 'base64');
66
- // Create private key object
67
- const privateKeyObject = crypto.createPrivateKey({
68
- key: Buffer.concat([
69
- Buffer.from([
70
- 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
71
- 0x6e, 0x04, 0x22, 0x04, 0x20,
72
- ]),
73
- privateKeyBytes,
74
- ]),
75
- format: 'der',
76
- type: 'pkcs8',
77
- });
78
- // Derive public key
79
- const publicKeyObject = crypto.createPublicKey(privateKeyObject);
80
- const publicKeyDER = publicKeyObject.export({
81
- type: 'spki',
82
- format: 'der',
83
- });
84
- // Extract raw 32-byte public key
85
- const rawPublicKey = publicKeyDER.slice(-32);
86
- return rawPublicKey.toString('base64');
89
+ signPayload(prefix, ephemeralPublicKey, recipientDeviceId) {
90
+ if (!this.signingKeyPair)
91
+ return undefined;
92
+ const parts = [prefix, this.deviceId, ephemeralPublicKey];
93
+ if (recipientDeviceId)
94
+ parts.push(recipientDeviceId);
95
+ const message = (0, tweetnacl_util_1.decodeUTF8)(parts.join(':'));
96
+ const secretKey = (0, tweetnacl_util_1.decodeBase64)(this.signingKeyPair.secretKey);
97
+ const signature = tweetnacl_1.default.sign.detached(message, secretKey);
98
+ return (0, tweetnacl_util_1.encodeBase64)(signature);
87
99
  }
88
100
  /**
89
- * Uploads public key to backend
101
+ * Verify a peer's signature on a key exchange payload.
102
+ * Returns true if signature is valid OR if peer has no identity key (unsigned exchange accepted with warning).
103
+ * Throws if the peer's identity key doesn't match TOFU record (potential MITM).
90
104
  */
91
- async uploadPublicKey() {
92
- if (!this.keyPair) {
93
- throw new Error('Key pair not initialized');
105
+ verifyPeerSignature(peerId, identityPublicKey, signature, ephemeralPublicKey, prefix, recipientDeviceId) {
106
+ if (!identityPublicKey || !signature) {
107
+ throw new Error(`E2EE: Peer ${peerId} sent UNSIGNED key exchange. ` +
108
+ `Identity verification is required. Update the peer to the latest version.`);
109
+ }
110
+ // TOFU: check if we already trust a different key for this peer
111
+ const trusted = (0, keyStorage_1.getTrustedPeerKey)(peerId);
112
+ if (trusted && trusted !== identityPublicKey) {
113
+ throw new Error(`E2EE: IDENTITY KEY MISMATCH for device ${peerId}! ` +
114
+ `Expected ${trusted.substring(0, 8)}... but got ${identityPublicKey.substring(0, 8)}... ` +
115
+ `This could indicate a man-in-the-middle attack. Key exchange rejected.`);
116
+ }
117
+ // Verify the Ed25519 signature
118
+ const parts = [prefix, peerId, ephemeralPublicKey];
119
+ if (recipientDeviceId)
120
+ parts.push(recipientDeviceId);
121
+ const msgString = parts.join(':');
122
+ const message = (0, tweetnacl_util_1.decodeUTF8)(msgString);
123
+ const sigBytes = (0, tweetnacl_util_1.decodeBase64)(signature);
124
+ const pubKeyBytes = (0, tweetnacl_util_1.decodeBase64)(identityPublicKey);
125
+ const valid = tweetnacl_1.default.sign.detached.verify(message, sigBytes, pubKeyBytes);
126
+ if (!valid) {
127
+ throw new Error(`E2EE: INVALID SIGNATURE from device ${peerId}! ` +
128
+ `The ephemeral key was not properly signed. Key exchange rejected.`);
129
+ }
130
+ // TOFU: trust this key if it's new
131
+ if (!trusted) {
132
+ (0, keyStorage_1.trustPeerKey)(peerId, identityPublicKey);
94
133
  }
95
- await this.axiosInstance.put(`${this.apiUrl}/devices/${this.deviceId}/public-key`, { publicKey: this.keyPair.publicKey });
96
134
  }
97
135
  /**
98
- * Initiates key exchange with target device
99
- * Returns payload to send via WebSocket
136
+ * Create a key exchange initiation to send to a remote device.
137
+ * Generates an ephemeral key pair and signs it with our identity key.
138
+ */
139
+ /**
140
+ * Evict expired or excess pending key exchanges.
100
141
  */
101
- async initiateKeyExchange(targetDeviceId) {
102
- if (!this.keyPair) {
103
- throw new Error('E2EE Manager not initialized');
142
+ cleanupPendingExchanges() {
143
+ const now = Date.now();
144
+ for (const [deviceId, entry] of this.pendingExchanges) {
145
+ if (now - entry.createdAt > E2EEManager.PENDING_EXCHANGE_TTL_MS) {
146
+ this.pendingExchanges.delete(deviceId);
147
+ // Evicted expired pending exchange
148
+ }
149
+ }
150
+ while (this.pendingExchanges.size >= E2EEManager.MAX_PENDING_EXCHANGES) {
151
+ const oldestKey = this.pendingExchanges.keys().next().value;
152
+ if (oldestKey) {
153
+ this.pendingExchanges.delete(oldestKey);
154
+ // MAX_PENDING_EXCHANGES reached, evicted oldest
155
+ }
156
+ else
157
+ break;
104
158
  }
105
- // Generate ephemeral key pair for this exchange
106
- const ephemeralKeyPair = (0, keyGeneration_1.generateKeyPair)();
107
- // Store ephemeral private key for when we receive ack
108
- this.pendingKeyExchanges.set(targetDeviceId, ephemeralKeyPair.privateKey);
109
- // Fetch target device's public key
110
- const response = await this.axiosInstance.get(`${this.apiUrl}/devices/${targetDeviceId}/public-key`);
111
- const targetPublicKey = response.data.publicKey;
112
- // Compute session key using our ephemeral private key and their public key
113
- const sessionKey = (0, keyExchange_1.performKeyExchange)(ephemeralKeyPair.privateKey, targetPublicKey);
114
- // Store session key (in memory and on disk for reconnection resilience)
115
- const sessionId = `session-${this.deviceId}-${targetDeviceId}-${Date.now()}`;
116
- const sessionKeys = { encryptionKey: sessionKey, sessionId };
117
- (0, keyStorage_1.storeSessionKey)(targetDeviceId, sessionKey, sessionId);
118
- (0, sessionPersistence_1.persistSessionKey)(this.deviceId, targetDeviceId, sessionKeys); // Persist to disk
119
- // Initialize counters
120
- this.outgoingCounters.set(targetDeviceId, 0);
121
- this.incomingCounters.set(targetDeviceId, -1); // Start at -1 so first message (counter: 0) is accepted
159
+ }
160
+ createKeyExchangeInit(targetDeviceId) {
161
+ this.cleanupPendingExchanges();
162
+ const ephemeral = (0, keyGeneration_1.generateKeyPair)();
163
+ this.pendingExchanges.set(targetDeviceId, { keyPair: ephemeral, createdAt: Date.now() });
164
+ const signature = this.signPayload('KEY_EXCHANGE_INIT', ephemeral.publicKey);
122
165
  return {
123
166
  senderDeviceId: this.deviceId,
124
- ephemeralPublicKey: ephemeralKeyPair.publicKey,
167
+ ephemeralPublicKey: ephemeral.publicKey,
168
+ identityPublicKey: this.signingKeyPair?.publicKey,
169
+ signature,
125
170
  };
126
171
  }
127
172
  /**
128
- * Handles incoming key exchange init from sender
129
- * Returns ack payload to send back via WebSocket
173
+ * Handle an incoming key exchange init from a remote device.
174
+ * Verifies the peer's identity signature (TOFU), computes shared key,
175
+ * and returns a signed ack.
130
176
  */
131
- async handleKeyExchangeInit(senderDeviceId, senderEphemeralPublicKey) {
132
- if (!this.keyPair) {
133
- throw new Error('E2EE Manager not initialized');
134
- }
135
- // Generate our ephemeral key pair
136
- const ephemeralKeyPair = (0, keyGeneration_1.generateKeyPair)();
137
- // Compute session key using our ephemeral private key and their ephemeral public key
138
- const sessionKey = (0, keyExchange_1.performKeyExchange)(ephemeralKeyPair.privateKey, senderEphemeralPublicKey);
139
- // Store session key (in memory and on disk for reconnection resilience)
140
- const sessionId = `session-${senderDeviceId}-${this.deviceId}-${Date.now()}`;
141
- const sessionKeys = { encryptionKey: sessionKey, sessionId };
142
- (0, keyStorage_1.storeSessionKey)(senderDeviceId, sessionKey, sessionId);
143
- (0, sessionPersistence_1.persistSessionKey)(this.deviceId, senderDeviceId, sessionKeys); // Persist to disk
144
- // Initialize counters
145
- this.outgoingCounters.set(senderDeviceId, 0);
146
- this.incomingCounters.set(senderDeviceId, -1); // Start at -1 so first message (counter: 0) is accepted
177
+ handleKeyExchangeInit(init) {
178
+ // Verify peer's signature (TOFU)
179
+ this.verifyPeerSignature(init.senderDeviceId, init.identityPublicKey, init.signature, init.ephemeralPublicKey, 'KEY_EXCHANGE_INIT');
180
+ const ephemeral = (0, keyGeneration_1.generateKeyPair)();
181
+ const sharedKey = (0, keyExchange_1.computeSharedKey)(ephemeral.privateKey, init.ephemeralPublicKey);
182
+ // Store session
183
+ const sessionId = `session-${init.senderDeviceId}-${this.deviceId}-${Date.now()}`;
184
+ this.sessions.set(init.senderDeviceId, {
185
+ sharedKey,
186
+ outgoingCounter: 0,
187
+ lastReceivedCounter: -1,
188
+ });
189
+ (0, keyStorage_1.storeSessionKey)(init.senderDeviceId, sharedKey, sessionId);
190
+ // Persist to disk for reconnection resilience
191
+ const sessionKeys = {
192
+ sharedKey,
193
+ sessionId,
194
+ deviceId: init.senderDeviceId,
195
+ messageCounter: 0,
196
+ lastReceivedCounter: -1,
197
+ };
198
+ (0, sessionPersistence_1.persistSessionKey)(this.deviceId, init.senderDeviceId, sessionKeys);
199
+ // E2EE session established
200
+ // Sign our ack
201
+ const signature = this.signPayload('KEY_EXCHANGE_ACK', ephemeral.publicKey, init.senderDeviceId);
147
202
  return {
148
- recipientDeviceId: this.deviceId,
149
- ephemeralPublicKey: ephemeralKeyPair.publicKey,
203
+ senderDeviceId: this.deviceId,
204
+ recipientDeviceId: init.senderDeviceId,
205
+ ephemeralPublicKey: ephemeral.publicKey,
206
+ identityPublicKey: this.signingKeyPair?.publicKey,
207
+ signature,
150
208
  };
151
209
  }
152
210
  /**
153
- * Handles incoming key exchange ack from recipient
154
- * Completes the key exchange by deriving the final session key
211
+ * Handle an incoming key exchange ack from a remote device.
212
+ * Verifies the peer's identity signature (TOFU) and completes the key exchange.
155
213
  */
156
- async handleKeyExchangeAck(recipientDeviceId, recipientEphemeralPublicKey) {
157
- const ephemeralPrivateKey = this.pendingKeyExchanges.get(recipientDeviceId);
158
- if (!ephemeralPrivateKey) {
159
- throw new Error('No pending key exchange for this device. Must call initiateKeyExchange first.');
214
+ handleKeyExchangeAck(ack) {
215
+ const peerId = ack.senderDeviceId;
216
+ const pendingEntry = this.pendingExchanges.get(peerId);
217
+ if (!pendingEntry) {
218
+ throw new Error(`E2EE: No pending key exchange for device ${peerId}`);
160
219
  }
161
- // Compute session key using our ephemeral private key and their ephemeral public key
162
- const sessionKey = (0, keyExchange_1.performKeyExchange)(ephemeralPrivateKey, recipientEphemeralPublicKey);
163
- // Store session key (overwrites the one from init, in memory and on disk)
164
- const sessionId = `session-${this.deviceId}-${recipientDeviceId}-${Date.now()}`;
165
- const sessionKeys = { encryptionKey: sessionKey, sessionId };
166
- (0, keyStorage_1.storeSessionKey)(recipientDeviceId, sessionKey, sessionId);
167
- (0, sessionPersistence_1.persistSessionKey)(this.deviceId, recipientDeviceId, sessionKeys); // Persist to disk
168
- // Clean up pending exchange
169
- this.pendingKeyExchanges.delete(recipientDeviceId);
220
+ const pending = pendingEntry.keyPair;
221
+ // Verify peer's signature (TOFU)
222
+ this.verifyPeerSignature(peerId, ack.identityPublicKey, ack.signature, ack.ephemeralPublicKey, 'KEY_EXCHANGE_ACK', ack.recipientDeviceId);
223
+ const sharedKey = (0, keyExchange_1.computeSharedKey)(pending.privateKey, ack.ephemeralPublicKey);
224
+ // Store session
225
+ const sessionId = `session-${this.deviceId}-${peerId}-${Date.now()}`;
226
+ this.sessions.set(peerId, {
227
+ sharedKey,
228
+ outgoingCounter: 0,
229
+ lastReceivedCounter: -1,
230
+ });
231
+ (0, keyStorage_1.storeSessionKey)(peerId, sharedKey, sessionId);
232
+ // Persist to disk
233
+ const sessionKeys = {
234
+ sharedKey,
235
+ sessionId,
236
+ deviceId: peerId,
237
+ messageCounter: 0,
238
+ lastReceivedCounter: -1,
239
+ };
240
+ (0, sessionPersistence_1.persistSessionKey)(this.deviceId, peerId, sessionKeys);
241
+ this.pendingExchanges.delete(peerId);
242
+ // E2EE session established
170
243
  }
171
244
  /**
172
- * Attempts to restore a persisted session after reconnection
173
- * Useful when IP changes cause WebSocket disconnection
245
+ * Attempts to restore a persisted session after reconnection.
174
246
  */
175
247
  async restorePersistedSession(targetDeviceId) {
176
- const persistedKeys = (0, sessionPersistence_1.loadPersistedSessionKey)(this.deviceId, targetDeviceId);
177
- if (!persistedKeys) {
248
+ const persisted = (0, sessionPersistence_1.loadPersistedSessionKey)(this.deviceId, targetDeviceId);
249
+ if (!persisted) {
178
250
  return false;
179
251
  }
180
- // Restore session to memory
181
- (0, keyStorage_1.storeSessionKey)(targetDeviceId, persistedKeys.encryptionKey, persistedKeys.sessionId);
182
- // Reset counters (conservative approach - could be persisted too)
183
- this.outgoingCounters.set(targetDeviceId, 0);
184
- this.incomingCounters.set(targetDeviceId, -1);
185
- console.log(`[E2EE] Restored persisted session for ${targetDeviceId}`);
252
+ this.sessions.set(targetDeviceId, {
253
+ sharedKey: persisted.sharedKey,
254
+ outgoingCounter: persisted.messageCounter,
255
+ lastReceivedCounter: persisted.lastReceivedCounter,
256
+ });
257
+ (0, keyStorage_1.storeSessionKey)(targetDeviceId, persisted.sharedKey, persisted.sessionId);
258
+ // Restored persisted E2EE session
186
259
  return true;
187
260
  }
188
- /**
189
- * Lists all devices with persisted sessions
190
- * Useful for auto-reconnection after network changes
191
- */
261
+ /** Lists all devices with persisted sessions */
192
262
  listPersistedDevices() {
193
263
  return (0, sessionPersistence_1.listPersistedSessions)(this.deviceId);
194
264
  }
195
- /**
196
- * Encrypts a message for a target device
197
- */
198
- encryptMessage(plaintext, targetDeviceId, sessionId) {
199
- const sessionKeys = (0, keyStorage_1.getSessionKey)(targetDeviceId);
200
- if (!sessionKeys) {
201
- throw new Error(`No session key found for device ${targetDeviceId}. Must complete key exchange first.`);
265
+ /** Check if an encrypted session is established with a device */
266
+ hasSessionKey(deviceId) {
267
+ return this.sessions.has(deviceId);
268
+ }
269
+ /** Encrypt a message for a specific device */
270
+ encryptMessage(plaintext, recipientDeviceId, sessionId) {
271
+ const session = this.sessions.get(recipientDeviceId);
272
+ if (!session) {
273
+ throw new Error(`E2EE: No session established with device ${recipientDeviceId}`);
202
274
  }
203
- // Encrypt message
204
- const encryptedPayload = (0, encryption_1.encrypt)(plaintext, sessionKeys.encryptionKey);
205
- // Get and increment counter
206
- const counter = this.outgoingCounters.get(targetDeviceId) ?? 0;
207
- this.outgoingCounters.set(targetDeviceId, counter + 1);
275
+ const payload = (0, encryption_1.encrypt)(plaintext, session.sharedKey);
276
+ session.outgoingCounter++;
208
277
  return {
209
278
  senderDeviceId: this.deviceId,
210
- recipientDeviceId: targetDeviceId,
279
+ recipientDeviceId,
211
280
  sessionId,
212
- payload: encryptedPayload,
213
- messageCounter: counter,
281
+ payload,
282
+ messageCounter: session.outgoingCounter,
214
283
  timestamp: new Date().toISOString(),
215
284
  };
216
285
  }
217
- /**
218
- * Decrypts a message from a sender device
219
- */
220
- decryptMessage(encryptedMessage, senderDeviceId) {
221
- const sessionKeys = (0, keyStorage_1.getSessionKey)(senderDeviceId);
222
- if (!sessionKeys) {
223
- throw new Error(`No session key found for device ${senderDeviceId}. Key exchange not completed.`);
286
+ /** Decrypt an incoming encrypted message */
287
+ decryptMessage(message, senderDeviceId) {
288
+ const session = this.sessions.get(senderDeviceId);
289
+ if (!session) {
290
+ throw new Error(`E2EE: No session established with device ${senderDeviceId}`);
224
291
  }
225
- // Replay protection: check message counter
226
- const lastCounter = this.incomingCounters.get(senderDeviceId) ?? -1;
227
- if (encryptedMessage.messageCounter <= lastCounter) {
228
- throw new Error(`Invalid message counter. Possible replay attack. Expected > ${lastCounter}, got ${encryptedMessage.messageCounter}`);
292
+ // SECURITY: Validate counter is a positive finite integer within safe bounds
293
+ if (typeof message.messageCounter !== 'number' ||
294
+ !Number.isFinite(message.messageCounter) ||
295
+ !Number.isInteger(message.messageCounter) ||
296
+ message.messageCounter < 1 ||
297
+ message.messageCounter > Number.MAX_SAFE_INTEGER - 1) {
298
+ throw new Error('E2EE: Invalid message counter value');
229
299
  }
230
- // Update last seen counter
231
- this.incomingCounters.set(senderDeviceId, encryptedMessage.messageCounter);
232
- // Decrypt message
233
- const plaintext = (0, encryption_1.decrypt)(encryptedMessage.payload, sessionKeys.encryptionKey);
300
+ // Replay protection
301
+ if (message.messageCounter <= session.lastReceivedCounter) {
302
+ throw new Error('E2EE: Replay attack detected - message counter too low');
303
+ }
304
+ const plaintext = (0, encryption_1.decrypt)(message.payload, session.sharedKey);
305
+ session.lastReceivedCounter = message.messageCounter;
234
306
  return plaintext;
235
307
  }
236
- /**
237
- * Checks if a session key exists for a device
238
- */
239
- hasSessionKey(deviceId) {
240
- return (0, keyStorage_1.getSessionKey)(deviceId) !== null;
241
- }
242
- /**
243
- * Checks if manager is initialized
244
- */
245
- isInitialized() {
246
- return this.initialized;
308
+ /** Clear session for a specific device */
309
+ clearSession(deviceId) {
310
+ this.sessions.delete(deviceId);
311
+ this.pendingExchanges.delete(deviceId);
247
312
  }
248
313
  /**
249
- * Cleans up all session keys and pending exchanges
250
- * @param deletePersisted - Whether to delete persisted sessions from disk (default: false)
314
+ * Cleans up all session keys and pending exchanges.
315
+ * @param deletePersisted - Whether to delete persisted sessions from disk
251
316
  */
252
317
  cleanup(deletePersisted = false) {
253
318
  (0, keyStorage_1.clearSessionKeys)();
254
- this.pendingKeyExchanges.clear();
255
- this.outgoingCounters.clear();
256
- this.incomingCounters.clear();
257
- // Optionally delete persisted sessions
319
+ this.sessions.clear();
320
+ this.pendingExchanges.clear();
258
321
  if (deletePersisted) {
259
322
  (0, sessionPersistence_1.deleteAllPersistedSessions)(this.deviceId);
260
323
  }
261
324
  }
262
- /**
263
- * Removes a specific persisted session
264
- */
325
+ /** Removes a specific persisted session */
265
326
  removePersistedSession(targetDeviceId) {
266
327
  (0, sessionPersistence_1.deletePersistedSession)(this.deviceId, targetDeviceId);
267
328
  }
329
+ /** Reset TOFU trust for a peer (used on re-pair so new keys are accepted) */
330
+ resetPeerTrust(targetDeviceId) {
331
+ (0, keyStorage_1.removeTrustedPeerKey)(targetDeviceId);
332
+ this.sessions.delete(targetDeviceId);
333
+ this.pendingExchanges.delete(targetDeviceId);
334
+ (0, sessionPersistence_1.deletePersistedSession)(this.deviceId, targetDeviceId);
335
+ }
336
+ /** Light trust reset — only clears TOFU key, preserves pending exchanges in-flight */
337
+ clearTrustOnly(targetDeviceId) {
338
+ (0, keyStorage_1.removeTrustedPeerKey)(targetDeviceId);
339
+ }
268
340
  }
269
341
  exports.E2EEManager = E2EEManager;
342
+ // Ephemeral keys for pending key exchanges (with creation timestamp for TTL)
343
+ E2EEManager.MAX_PENDING_EXCHANGES = 20;
344
+ E2EEManager.PENDING_EXCHANGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
270
345
  //# sourceMappingURL=e2eeManager.js.map
@@ -1,18 +1,16 @@
1
1
  import { EncryptedPayload } from './types';
2
2
  /**
3
- * Encrypts plaintext using AES-256-GCM
4
- *
5
- * @param plaintext - Text to encrypt
6
- * @param key - 32-byte encryption key (AES-256)
7
- * @returns EncryptedPayload with Base64-encoded ciphertext, nonce, and authTag
3
+ * Encrypt a plaintext string using NaCl secretbox.
4
+ * @param plaintext - The message to encrypt
5
+ * @param key - 32-byte shared secret key
6
+ * @returns EncryptedPayload with Base64-encoded ciphertext and nonce
8
7
  */
9
8
  export declare function encrypt(plaintext: string, key: Uint8Array): EncryptedPayload;
10
9
  /**
11
- * Decrypts ciphertext using AES-256-GCM
12
- *
13
- * @param payload - EncryptedPayload with Base64-encoded ciphertext, nonce, and authTag
14
- * @param key - 32-byte encryption key (AES-256)
15
- * @returns Decrypted plaintext
10
+ * Decrypt an EncryptedPayload back to plaintext.
11
+ * @param payload - The encrypted payload (ciphertext + nonce)
12
+ * @param key - 32-byte shared secret key (must match encryption key)
13
+ * @returns Decrypted plaintext string
16
14
  * @throws Error if decryption fails (wrong key, tampered data, etc.)
17
15
  */
18
16
  export declare function decrypt(payload: EncryptedPayload, key: Uint8Array): string;