forkoff 1.0.19 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,18 +113,20 @@ forkoff startup --status # Check registration
113
113
 
114
114
  ## Security
115
115
 
116
- All communication between the CLI and mobile app is end-to-end encrypted. The relay server never sees plaintext session data.
116
+ ForkOff uses end-to-end encryption (X25519 ECDH + XSalsa20-Poly1305) so the relay server never sees your code, prompts, or approvals — only opaque encrypted blobs routed between device UUIDs.
117
117
 
118
118
  | Layer | Implementation |
119
119
  |-------|---------------|
120
- | **Key exchange** | X25519 ECDH with Ed25519 identity signatures |
121
- | **Encryption** | XSalsa20-Poly1305 authenticated encryption (NaCl) |
120
+ | **Key exchange** | X25519 ECDH with HKDF-SHA256 directional key derivation |
121
+ | **Authentication** | Ed25519 identity signatures on ephemeral keys (MITM protection) |
122
+ | **Encryption** | XSalsa20-Poly1305 authenticated encryption (NaCl secretbox) |
122
123
  | **Identity** | TOFU (Trust On First Use) with key pinning |
123
124
  | **Replay protection** | Per-peer monotonic message counters |
125
+ | **Session expiry** | Automatic re-key every 24 hours or 10,000 messages |
124
126
  | **Key storage** | OS keychain (macOS Keychain, Windows Credential Manager, Linux libsecret) |
125
- | **Enforcement** | Sensitive events (session content, approvals, files) never sent in plaintext |
127
+ | **Enforcement** | 24 sensitive event types encrypted; plaintext fallback only when E2EE unavailable |
126
128
 
127
- No additional setup required — E2EE is enabled automatically when you pair.
129
+ No additional setup required — E2EE is enabled automatically when you pair. See [SECURITY.md](https://github.com/Forkoff-app/forkoff-cli/blob/main/docs/SECURITY.md) for the full whitepaper.
128
130
 
129
131
  ---
130
132
 
@@ -0,0 +1,30 @@
1
+ import { EventEmitter } from 'events';
2
+ export interface CloudRelayOptions {
3
+ url: string;
4
+ deviceId: string;
5
+ deviceName: string;
6
+ relayToken?: string | null;
7
+ }
8
+ export declare class CloudRelayClient extends EventEmitter {
9
+ private socket;
10
+ private url;
11
+ private deviceId;
12
+ private deviceName;
13
+ private relayToken;
14
+ /** The pairing code the CLI generated — sent to relay for registration */
15
+ private currentPairingCode;
16
+ constructor(options: CloudRelayOptions);
17
+ /** Set the pairing code — will be registered with the relay on connect */
18
+ setPairingCode(code: string): void;
19
+ /** Connect to the cloud relay as a CLI client */
20
+ start(): Promise<void>;
21
+ /** Emit an event to the mobile client via the relay */
22
+ emitToMobile(event: string, data: any): void;
23
+ /** Check if mobile is connected (based on relay notifications) */
24
+ hasMobileConnection(): boolean;
25
+ /** Get the connected mobile device ID (set after pairing or mobile_connected) */
26
+ getMobileDeviceId(): string | null;
27
+ /** Graceful shutdown */
28
+ stop(): Promise<void>;
29
+ }
30
+ //# sourceMappingURL=cloud-client.d.ts.map
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CloudRelayClient = void 0;
4
+ /**
5
+ * Cloud relay client — CLI connects as a Socket.io CLIENT to the relay server
6
+ * (e.g., wss://api.forkoff.app). The relay routes events to/from the paired mobile.
7
+ * Same interface as EmbeddedRelayServer so WebSocketClient can use either.
8
+ */
9
+ const socket_io_client_1 = require("socket.io-client");
10
+ const events_1 = require("events");
11
+ const config_1 = require("./config");
12
+ /** Events the cloud relay forwards from mobile → CLI (same set as EmbeddedRelayServer) */
13
+ const MOBILE_EVENTS = [
14
+ 'terminal_command', 'terminal_create', 'user_message',
15
+ 'claude_start_session', 'claude_resume_session', 'claude_sessions_request',
16
+ 'directory_list', 'read_file', 'transcript_fetch', 'transcript_subscribe',
17
+ 'transcript_unsubscribe', 'approval_response', 'claude_approval_response',
18
+ 'permission_response', 'permission_rules_sync', 'claude_abort', 'tab_complete',
19
+ 'subscribe_device', 'unsubscribe_device',
20
+ 'sdk_session_history', 'usage_stats_request', 'session_settings_update',
21
+ 'transcript_subscribe_sdk',
22
+ // E2EE key exchange and encrypted messages from mobile
23
+ 'encrypted_key_exchange_init', 'encrypted_key_exchange_ack', 'encrypted_message',
24
+ ];
25
+ class CloudRelayClient extends events_1.EventEmitter {
26
+ constructor(options) {
27
+ super();
28
+ this.socket = null;
29
+ /** The pairing code the CLI generated — sent to relay for registration */
30
+ this.currentPairingCode = null;
31
+ this.url = options.url;
32
+ this.deviceId = options.deviceId;
33
+ this.deviceName = options.deviceName;
34
+ this.relayToken = options.relayToken ?? null;
35
+ }
36
+ /** Set the pairing code — will be registered with the relay on connect */
37
+ setPairingCode(code) {
38
+ this.currentPairingCode = code;
39
+ // If already connected, register immediately
40
+ if (this.socket?.connected) {
41
+ this.socket.emit('register_pairing_code', {
42
+ code,
43
+ deviceId: this.deviceId,
44
+ deviceName: this.deviceName,
45
+ platform: process.platform,
46
+ });
47
+ }
48
+ }
49
+ /** Connect to the cloud relay as a CLI client */
50
+ start() {
51
+ return new Promise((resolve, reject) => {
52
+ this.socket = (0, socket_io_client_1.io)(this.url, {
53
+ auth: {
54
+ clientType: 'cli',
55
+ deviceId: this.deviceId,
56
+ deviceName: this.deviceName,
57
+ platform: process.platform,
58
+ hostname: require('os').hostname(),
59
+ relayToken: this.relayToken,
60
+ userId: config_1.config.userId,
61
+ },
62
+ transports: ['websocket'],
63
+ reconnection: true,
64
+ reconnectionAttempts: Infinity,
65
+ reconnectionDelay: 1000,
66
+ reconnectionDelayMax: 15000,
67
+ timeout: 10000,
68
+ });
69
+ let resolved = false;
70
+ this.socket.on('connect', () => {
71
+ console.log(`[CloudRelay] Connected to ${this.url}`);
72
+ // Register pairing code if we have one
73
+ if (this.currentPairingCode) {
74
+ this.socket.emit('register_pairing_code', {
75
+ code: this.currentPairingCode,
76
+ deviceId: this.deviceId,
77
+ deviceName: this.deviceName,
78
+ platform: process.platform,
79
+ });
80
+ }
81
+ if (!resolved) {
82
+ resolved = true;
83
+ resolve();
84
+ }
85
+ });
86
+ this.socket.on('connect_error', (err) => {
87
+ console.error(`[CloudRelay] Connection error: ${err.message}`);
88
+ if (!resolved) {
89
+ resolved = true;
90
+ reject(new Error(`Failed to connect to cloud relay at ${this.url}: ${err.message}`));
91
+ }
92
+ });
93
+ this.socket.on('disconnect', (reason) => {
94
+ console.log(`[CloudRelay] Disconnected: ${reason}`);
95
+ });
96
+ // Handle cloud pairing flow: relay sends pair_device when mobile enters our code
97
+ this.socket.on('pair_device', (data) => {
98
+ console.log(`[CloudRelay] Pairing request received from mobile`);
99
+ // Emit internally so WebSocketClient can handle it
100
+ this.emit('pair_device', { mobileDeviceId: data.mobileDeviceId });
101
+ // Send ack back through relay — relay will forward to mobile with mobileRelayToken
102
+ this.socket.emit('pair_device_ack', {
103
+ deviceId: this.deviceId,
104
+ deviceName: this.deviceName,
105
+ platform: process.platform,
106
+ mobileDeviceId: data.mobileDeviceId,
107
+ pairId: data.pairId,
108
+ cliRelayToken: data.cliRelayToken,
109
+ });
110
+ // Store relay credentials locally
111
+ if (data.cliRelayToken) {
112
+ config_1.config.relayToken = data.cliRelayToken;
113
+ this.relayToken = data.cliRelayToken;
114
+ }
115
+ if (data.pairId) {
116
+ config_1.config.pairId = data.pairId;
117
+ }
118
+ });
119
+ // Handle mobile connected notification from relay
120
+ this.socket.on('mobile_connected', (data) => {
121
+ console.log(`[CloudRelay] Mobile connected: ${data.deviceId || 'unknown'}`);
122
+ this.emit('mobile_connected', { deviceId: data.deviceId || data.mobileDeviceId });
123
+ });
124
+ // Handle mobile disconnected notification from relay
125
+ this.socket.on('mobile_disconnected', (data) => {
126
+ console.log(`[CloudRelay] Mobile disconnected`);
127
+ this.emit('mobile_disconnected', { deviceId: data.deviceId, reason: data.reason || 'disconnected' });
128
+ });
129
+ // Forward all mobile events → internal EventEmitter (same as EmbeddedRelayServer)
130
+ for (const event of MOBILE_EVENTS) {
131
+ this.socket.on(event, (data) => {
132
+ this.emit(event, data);
133
+ });
134
+ }
135
+ });
136
+ }
137
+ /** Emit an event to the mobile client via the relay */
138
+ emitToMobile(event, data) {
139
+ if (this.socket?.connected) {
140
+ // Relay will route this to the paired mobile client
141
+ this.socket.emit(event, data);
142
+ }
143
+ }
144
+ /** Check if mobile is connected (based on relay notifications) */
145
+ hasMobileConnection() {
146
+ return this.socket?.connected ?? false;
147
+ }
148
+ /** Get the connected mobile device ID (set after pairing or mobile_connected) */
149
+ getMobileDeviceId() {
150
+ // The relay manages this — we don't track directly in cloud mode
151
+ return null;
152
+ }
153
+ /** Graceful shutdown */
154
+ stop() {
155
+ return new Promise((resolve) => {
156
+ if (this.socket) {
157
+ this.socket.disconnect();
158
+ this.socket = null;
159
+ }
160
+ resolve();
161
+ });
162
+ }
163
+ }
164
+ exports.CloudRelayClient = CloudRelayClient;
165
+ //# sourceMappingURL=cloud-client.js.map
package/dist/config.d.ts CHANGED
@@ -25,6 +25,12 @@ declare class Config {
25
25
  set startupBinaryPath(value: string | null);
26
26
  get relayPort(): number;
27
27
  set relayPort(value: number);
28
+ get relayMode(): 'cloud' | 'local';
29
+ set relayMode(value: 'cloud' | 'local');
30
+ get relayToken(): string | null;
31
+ set relayToken(value: string | null);
32
+ get pairId(): string | null;
33
+ set pairId(value: string | null);
28
34
  get isPaired(): boolean;
29
35
  ensureDeviceId(): string;
30
36
  getMachineId(): string;
package/dist/config.js CHANGED
@@ -87,6 +87,9 @@ const defaultConfig = {
87
87
  userId: null,
88
88
  startupEnabled: true,
89
89
  startupBinaryPath: null,
90
+ relayMode: 'cloud',
91
+ relayToken: null,
92
+ pairId: null,
90
93
  };
91
94
  class Config {
92
95
  constructor() {
@@ -221,6 +224,27 @@ class Config {
221
224
  this.data.relayPort = value;
222
225
  this.save();
223
226
  }
227
+ get relayMode() {
228
+ return this.data.relayMode;
229
+ }
230
+ set relayMode(value) {
231
+ this.data.relayMode = value;
232
+ this.save();
233
+ }
234
+ get relayToken() {
235
+ return this.data.relayToken;
236
+ }
237
+ set relayToken(value) {
238
+ this.data.relayToken = value;
239
+ this.save();
240
+ }
241
+ get pairId() {
242
+ return this.data.pairId;
243
+ }
244
+ set pairId(value) {
245
+ this.data.pairId = value;
246
+ this.save();
247
+ }
224
248
  get isPaired() {
225
249
  return !!this.deviceId && !!this.pairedAt;
226
250
  }
@@ -5,6 +5,8 @@ export declare class E2EEManager {
5
5
  private signingKeyPair;
6
6
  private initialized;
7
7
  private sessions;
8
+ private static readonly SESSION_MAX_AGE_MS;
9
+ private static readonly SESSION_MAX_MESSAGES;
8
10
  private static readonly MAX_PENDING_EXCHANGES;
9
11
  private static readonly PENDING_EXCHANGE_TTL_MS;
10
12
  private pendingExchanges;
@@ -19,6 +21,11 @@ export declare class E2EEManager {
19
21
  getSigningPublicKey(): string | null;
20
22
  /** Check if manager is initialized */
21
23
  isInitialized(): boolean;
24
+ /**
25
+ * Check if a session has expired (age or message count).
26
+ * Returns true if the session should be torn down and re-keyed.
27
+ */
28
+ isSessionExpired(deviceId: string): boolean;
22
29
  /**
23
30
  * Sign a key exchange payload with our Ed25519 identity key.
24
31
  * The signed message is: "prefix:senderDeviceId:ephemeralPublicKey[:recipientDeviceId]"
@@ -52,6 +59,7 @@ export declare class E2EEManager {
52
59
  handleKeyExchangeAck(ack: KeyExchangeAck): void;
53
60
  /**
54
61
  * Attempts to restore a persisted session after reconnection.
62
+ * If the session has expired, it is deleted from persistence and not restored.
55
63
  */
56
64
  restorePersistedSession(targetDeviceId: string): Promise<boolean>;
57
65
  /** Lists all devices with persisted sessions */
@@ -82,6 +82,25 @@ class E2EEManager {
82
82
  isInitialized() {
83
83
  return this.initialized;
84
84
  }
85
+ /**
86
+ * Check if a session has expired (age or message count).
87
+ * Returns true if the session should be torn down and re-keyed.
88
+ */
89
+ isSessionExpired(deviceId) {
90
+ const session = this.sessions.get(deviceId);
91
+ if (!session)
92
+ return false;
93
+ if (Date.now() - session.createdAt > E2EEManager.SESSION_MAX_AGE_MS) {
94
+ return true;
95
+ }
96
+ if (session.outgoingCounter >= E2EEManager.SESSION_MAX_MESSAGES) {
97
+ return true;
98
+ }
99
+ if (session.lastReceivedCounter >= E2EEManager.SESSION_MAX_MESSAGES) {
100
+ return true;
101
+ }
102
+ return false;
103
+ }
85
104
  /**
86
105
  * Sign a key exchange payload with our Ed25519 identity key.
87
106
  * The signed message is: "prefix:senderDeviceId:ephemeralPublicKey[:recipientDeviceId]"
@@ -178,22 +197,32 @@ class E2EEManager {
178
197
  // Verify peer's signature (TOFU)
179
198
  this.verifyPeerSignature(init.senderDeviceId, init.identityPublicKey, init.signature, init.ephemeralPublicKey, 'KEY_EXCHANGE_INIT');
180
199
  const ephemeral = (0, keyGeneration_1.generateKeyPair)();
181
- const sharedKey = (0, keyExchange_1.computeSharedKey)(ephemeral.privateKey, init.ephemeralPublicKey);
200
+ const rawSharedKey = (0, keyExchange_1.computeSharedKey)(ephemeral.privateKey, init.ephemeralPublicKey);
201
+ // Derive directional send/receive keys via HKDF
202
+ const { sendKey, receiveKey } = (0, keyExchange_1.deriveSessionKeys)(rawSharedKey, this.deviceId, init.senderDeviceId);
203
+ // Log key fingerprint for debugging key mismatch
204
+ const sendKeyFP = (0, tweetnacl_util_1.encodeBase64)(sendKey).substring(0, 12);
205
+ const recvKeyFP = (0, tweetnacl_util_1.encodeBase64)(receiveKey).substring(0, 12);
206
+ console.log(`[E2EE] Init: sendKey=${sendKeyFP}, recvKey=${recvKeyFP}, peer=${init.senderDeviceId}, myEphPub=${ephemeral.publicKey.substring(0, 12)}, peerEphPub=${init.ephemeralPublicKey.substring(0, 12)}`);
182
207
  // Store session
183
208
  const sessionId = `session-${init.senderDeviceId}-${this.deviceId}-${Date.now()}`;
184
209
  this.sessions.set(init.senderDeviceId, {
185
- sharedKey,
210
+ sendKey,
211
+ receiveKey,
186
212
  outgoingCounter: 0,
187
213
  lastReceivedCounter: -1,
214
+ createdAt: Date.now(),
188
215
  });
189
- (0, keyStorage_1.storeSessionKey)(init.senderDeviceId, sharedKey, sessionId);
216
+ (0, keyStorage_1.storeSessionKey)(init.senderDeviceId, sendKey, receiveKey, sessionId);
190
217
  // Persist to disk for reconnection resilience
191
218
  const sessionKeys = {
192
- sharedKey,
219
+ sendKey,
220
+ receiveKey,
193
221
  sessionId,
194
222
  deviceId: init.senderDeviceId,
195
223
  messageCounter: 0,
196
224
  lastReceivedCounter: -1,
225
+ createdAt: Date.now(),
197
226
  };
198
227
  (0, sessionPersistence_1.persistSessionKey)(this.deviceId, init.senderDeviceId, sessionKeys);
199
228
  // E2EE session established
@@ -213,36 +242,57 @@ class E2EEManager {
213
242
  */
214
243
  handleKeyExchangeAck(ack) {
215
244
  const peerId = ack.senderDeviceId;
216
- const pendingEntry = this.pendingExchanges.get(peerId);
245
+ let pendingEntry = this.pendingExchanges.get(peerId);
246
+ let pendingKey = peerId;
247
+ // Fallback: relay may send mobile_connected with a different ID than the mobile's
248
+ // real device ID, so the pending exchange may be stored under a different key.
249
+ // If there's exactly one pending exchange, use it regardless of key.
250
+ if (!pendingEntry && this.pendingExchanges.size === 1) {
251
+ const [fallbackKey, fallbackEntry] = this.pendingExchanges.entries().next().value;
252
+ console.log(`[E2EE] Pending exchange not found for ${peerId}, falling back to ${fallbackKey}`);
253
+ pendingEntry = fallbackEntry;
254
+ pendingKey = fallbackKey;
255
+ }
217
256
  if (!pendingEntry) {
218
257
  throw new Error(`E2EE: No pending key exchange for device ${peerId}`);
219
258
  }
220
259
  const pending = pendingEntry.keyPair;
221
260
  // Verify peer's signature (TOFU)
222
261
  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);
262
+ const rawSharedKey = (0, keyExchange_1.computeSharedKey)(pending.privateKey, ack.ephemeralPublicKey);
263
+ // Derive directional send/receive keys via HKDF
264
+ const { sendKey, receiveKey } = (0, keyExchange_1.deriveSessionKeys)(rawSharedKey, this.deviceId, peerId);
265
+ // Log key fingerprint for debugging key mismatch
266
+ const sendKeyFP = (0, tweetnacl_util_1.encodeBase64)(sendKey).substring(0, 12);
267
+ const recvKeyFP = (0, tweetnacl_util_1.encodeBase64)(receiveKey).substring(0, 12);
268
+ console.log(`[E2EE] Ack: sendKey=${sendKeyFP}, recvKey=${recvKeyFP}, peer=${peerId}, myEphPub=${pending.publicKey.substring(0, 12)}, peerEphPub=${ack.ephemeralPublicKey.substring(0, 12)}`);
224
269
  // Store session
225
270
  const sessionId = `session-${this.deviceId}-${peerId}-${Date.now()}`;
226
271
  this.sessions.set(peerId, {
227
- sharedKey,
272
+ sendKey,
273
+ receiveKey,
228
274
  outgoingCounter: 0,
229
275
  lastReceivedCounter: -1,
276
+ createdAt: Date.now(),
230
277
  });
231
- (0, keyStorage_1.storeSessionKey)(peerId, sharedKey, sessionId);
278
+ (0, keyStorage_1.storeSessionKey)(peerId, sendKey, receiveKey, sessionId);
232
279
  // Persist to disk
233
280
  const sessionKeys = {
234
- sharedKey,
281
+ sendKey,
282
+ receiveKey,
235
283
  sessionId,
236
284
  deviceId: peerId,
237
285
  messageCounter: 0,
238
286
  lastReceivedCounter: -1,
287
+ createdAt: Date.now(),
239
288
  };
240
289
  (0, sessionPersistence_1.persistSessionKey)(this.deviceId, peerId, sessionKeys);
241
- this.pendingExchanges.delete(peerId);
290
+ this.pendingExchanges.delete(pendingKey);
242
291
  // E2EE session established
243
292
  }
244
293
  /**
245
294
  * Attempts to restore a persisted session after reconnection.
295
+ * If the session has expired, it is deleted from persistence and not restored.
246
296
  */
247
297
  async restorePersistedSession(targetDeviceId) {
248
298
  const persisted = (0, sessionPersistence_1.loadPersistedSessionKey)(this.deviceId, targetDeviceId);
@@ -250,11 +300,20 @@ class E2EEManager {
250
300
  return false;
251
301
  }
252
302
  this.sessions.set(targetDeviceId, {
253
- sharedKey: persisted.sharedKey,
303
+ sendKey: persisted.sendKey,
304
+ receiveKey: persisted.receiveKey,
254
305
  outgoingCounter: persisted.messageCounter,
255
306
  lastReceivedCounter: persisted.lastReceivedCounter,
307
+ createdAt: persisted.createdAt ?? Date.now(),
256
308
  });
257
- (0, keyStorage_1.storeSessionKey)(targetDeviceId, persisted.sharedKey, persisted.sessionId);
309
+ // Check if the restored session is already expired
310
+ if (this.isSessionExpired(targetDeviceId)) {
311
+ console.log(`[E2EE] Persisted session with ${targetDeviceId} has expired — deleting`);
312
+ this.sessions.delete(targetDeviceId);
313
+ (0, sessionPersistence_1.deletePersistedSession)(this.deviceId, targetDeviceId);
314
+ return false;
315
+ }
316
+ (0, keyStorage_1.storeSessionKey)(targetDeviceId, persisted.sendKey, persisted.receiveKey, persisted.sessionId);
258
317
  // Restored persisted E2EE session
259
318
  return true;
260
319
  }
@@ -272,8 +331,18 @@ class E2EEManager {
272
331
  if (!session) {
273
332
  throw new Error(`E2EE: No session established with device ${recipientDeviceId}`);
274
333
  }
275
- const payload = (0, encryption_1.encrypt)(plaintext, session.sharedKey);
334
+ // Check session expiry BEFORE encrypting — force re-key
335
+ if (this.isSessionExpired(recipientDeviceId)) {
336
+ this.sessions.delete(recipientDeviceId);
337
+ throw new Error(`E2EE: Session expired with device ${recipientDeviceId} — re-key required`);
338
+ }
339
+ const payload = (0, encryption_1.encrypt)(plaintext, session.sendKey);
276
340
  session.outgoingCounter++;
341
+ // Log encryption key fingerprint (first message only to avoid spam)
342
+ if (session.outgoingCounter === 1) {
343
+ const keyFP = (0, tweetnacl_util_1.encodeBase64)(session.sendKey).substring(0, 12);
344
+ console.log(`[E2EE] Encrypting first message with sendKey fingerprint=${keyFP}, recipient=${recipientDeviceId}`);
345
+ }
277
346
  return {
278
347
  senderDeviceId: this.deviceId,
279
348
  recipientDeviceId,
@@ -301,8 +370,12 @@ class E2EEManager {
301
370
  if (message.messageCounter <= session.lastReceivedCounter) {
302
371
  throw new Error('E2EE: Replay attack detected - message counter too low');
303
372
  }
304
- const plaintext = (0, encryption_1.decrypt)(message.payload, session.sharedKey);
373
+ const plaintext = (0, encryption_1.decrypt)(message.payload, session.receiveKey);
305
374
  session.lastReceivedCounter = message.messageCounter;
375
+ // Check expiry AFTER decrypting — valid messages still get through, but warn caller
376
+ if (this.isSessionExpired(senderDeviceId)) {
377
+ console.warn(`[E2EE] Session with ${senderDeviceId} has expired after decryption — re-key required`);
378
+ }
306
379
  return plaintext;
307
380
  }
308
381
  /** Clear session for a specific device */
@@ -339,6 +412,9 @@ class E2EEManager {
339
412
  }
340
413
  }
341
414
  exports.E2EEManager = E2EEManager;
415
+ // Session expiry limits — re-key required after either threshold
416
+ E2EEManager.SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
417
+ E2EEManager.SESSION_MAX_MESSAGES = 10000;
342
418
  // Ephemeral keys for pending key exchanges (with creation timestamp for TTL)
343
419
  E2EEManager.MAX_PENDING_EXCHANGES = 20;
344
420
  E2EEManager.PENDING_EXCHANGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -2,7 +2,7 @@
2
2
  * E2EE Crypto Module - Barrel Export
3
3
  */
4
4
  export { generateKeyPair } from './keyGeneration';
5
- export { computeSharedKey } from './keyExchange';
5
+ export { computeSharedKey, deriveSessionKeys } from './keyExchange';
6
6
  export { encrypt, decrypt } from './encryption';
7
7
  export { storePrivateKey, getPrivateKey, storeSessionKey, getSessionKey, clearSessionKeys } from './keyStorage';
8
8
  export { E2EEManager } from './e2eeManager';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.E2EEManager = exports.clearSessionKeys = exports.getSessionKey = exports.storeSessionKey = exports.getPrivateKey = exports.storePrivateKey = exports.decrypt = exports.encrypt = exports.computeSharedKey = exports.generateKeyPair = void 0;
3
+ exports.E2EEManager = exports.clearSessionKeys = exports.getSessionKey = exports.storeSessionKey = exports.getPrivateKey = exports.storePrivateKey = exports.decrypt = exports.encrypt = exports.deriveSessionKeys = exports.computeSharedKey = exports.generateKeyPair = void 0;
4
4
  /**
5
5
  * E2EE Crypto Module - Barrel Export
6
6
  */
@@ -8,6 +8,7 @@ var keyGeneration_1 = require("./keyGeneration");
8
8
  Object.defineProperty(exports, "generateKeyPair", { enumerable: true, get: function () { return keyGeneration_1.generateKeyPair; } });
9
9
  var keyExchange_1 = require("./keyExchange");
10
10
  Object.defineProperty(exports, "computeSharedKey", { enumerable: true, get: function () { return keyExchange_1.computeSharedKey; } });
11
+ Object.defineProperty(exports, "deriveSessionKeys", { enumerable: true, get: function () { return keyExchange_1.deriveSessionKeys; } });
11
12
  var encryption_1 = require("./encryption");
12
13
  Object.defineProperty(exports, "encrypt", { enumerable: true, get: function () { return encryption_1.encrypt; } });
13
14
  Object.defineProperty(exports, "decrypt", { enumerable: true, get: function () { return encryption_1.decrypt; } });
@@ -7,4 +7,22 @@
7
7
  * @returns 32-byte shared key suitable for NaCl secretbox encryption
8
8
  */
9
9
  export declare function computeSharedKey(myPrivateKeyB64: string, theirPublicKeyB64: string): Uint8Array;
10
+ /**
11
+ * Derive directional send/receive keys from the raw ECDH shared secret using HKDF-SHA256.
12
+ *
13
+ * Both sides must derive identical key material. Directionality is determined by
14
+ * lexicographic ordering of device IDs:
15
+ * - The device with the lexicographically smaller ID gets bytes 0-31 as sendKey
16
+ * - The device with the lexicographically larger ID gets bytes 32-63 as sendKey
17
+ * - receiveKey is always the opposite half
18
+ *
19
+ * @param rawSharedKey - 32-byte ECDH output from computeSharedKey
20
+ * @param myDeviceId - This device's ID
21
+ * @param peerDeviceId - The remote device's ID
22
+ * @returns { sendKey, receiveKey } - 32-byte directional keys
23
+ */
24
+ export declare function deriveSessionKeys(rawSharedKey: Uint8Array, myDeviceId: string, peerDeviceId: string): {
25
+ sendKey: Uint8Array;
26
+ receiveKey: Uint8Array;
27
+ };
10
28
  //# sourceMappingURL=keyExchange.d.ts.map
@@ -4,13 +4,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.computeSharedKey = computeSharedKey;
7
+ exports.deriveSessionKeys = deriveSessionKeys;
7
8
  /**
8
9
  * Key Exchange Service
9
- * Performs X25519 Diffie-Hellman key exchange using NaCl box.before.
10
+ * Performs X25519 Diffie-Hellman key exchange using NaCl box.before,
11
+ * then derives directional send/receive keys via HKDF-SHA256.
10
12
  * Compatible with mobile app's tweetnacl implementation.
11
13
  */
12
14
  const tweetnacl_1 = __importDefault(require("tweetnacl"));
13
15
  const tweetnacl_util_1 = require("tweetnacl-util");
16
+ const hkdf_1 = require("@noble/hashes/hkdf");
17
+ const sha256_1 = require("@noble/hashes/sha256");
14
18
  /**
15
19
  * Compute a shared secret key from a private key and a remote public key.
16
20
  * Uses NaCl's box.before which performs X25519 ECDH + HSalsa20 key derivation.
@@ -24,4 +28,36 @@ function computeSharedKey(myPrivateKeyB64, theirPublicKeyB64) {
24
28
  const theirPublicKey = (0, tweetnacl_util_1.decodeBase64)(theirPublicKeyB64);
25
29
  return tweetnacl_1.default.box.before(theirPublicKey, myPrivateKey);
26
30
  }
31
+ /**
32
+ * Derive directional send/receive keys from the raw ECDH shared secret using HKDF-SHA256.
33
+ *
34
+ * Both sides must derive identical key material. Directionality is determined by
35
+ * lexicographic ordering of device IDs:
36
+ * - The device with the lexicographically smaller ID gets bytes 0-31 as sendKey
37
+ * - The device with the lexicographically larger ID gets bytes 32-63 as sendKey
38
+ * - receiveKey is always the opposite half
39
+ *
40
+ * @param rawSharedKey - 32-byte ECDH output from computeSharedKey
41
+ * @param myDeviceId - This device's ID
42
+ * @param peerDeviceId - The remote device's ID
43
+ * @returns { sendKey, receiveKey } - 32-byte directional keys
44
+ */
45
+ function deriveSessionKeys(rawSharedKey, myDeviceId, peerDeviceId) {
46
+ // Salt: sorted concatenation of both device IDs (deterministic regardless of who calls)
47
+ const sortedIds = [myDeviceId, peerDeviceId].sort();
48
+ const salt = (0, tweetnacl_util_1.decodeUTF8)(sortedIds[0] + sortedIds[1]);
49
+ // Info string identifying the protocol version
50
+ const info = (0, tweetnacl_util_1.decodeUTF8)('forkoff-e2ee-v1');
51
+ // Derive 64 bytes of key material
52
+ const derivedKeyMaterial = (0, hkdf_1.hkdf)(sha256_1.sha256, rawSharedKey, salt, info, 64);
53
+ // Split into two 32-byte keys
54
+ const firstHalf = derivedKeyMaterial.slice(0, 32);
55
+ const secondHalf = derivedKeyMaterial.slice(32, 64);
56
+ // Directionality: device with smaller ID gets firstHalf as sendKey
57
+ const iAmSmaller = myDeviceId < peerDeviceId;
58
+ return {
59
+ sendKey: iAmSmaller ? firstHalf : secondHalf,
60
+ receiveKey: iAmSmaller ? secondHalf : firstHalf,
61
+ };
62
+ }
27
63
  //# sourceMappingURL=keyExchange.js.map
@@ -17,14 +17,15 @@ export declare function getPrivateKey(deviceId: string): Promise<string | null>;
17
17
  */
18
18
  export declare function deletePrivateKey(deviceId: string): Promise<void>;
19
19
  /**
20
- * Stores session key in memory
20
+ * Stores session keys in memory
21
21
  * Session keys are ephemeral and not persisted to disk
22
22
  *
23
23
  * @param deviceId - Target device ID
24
- * @param sharedKey - NaCl secretbox shared key (32 bytes)
24
+ * @param sendKey - 32-byte directional key for encrypting outgoing messages
25
+ * @param receiveKey - 32-byte directional key for decrypting incoming messages
25
26
  * @param sessionId - Unique session identifier
26
27
  */
27
- export declare function storeSessionKey(deviceId: string, sharedKey: Uint8Array, sessionId: string): void;
28
+ export declare function storeSessionKey(deviceId: string, sendKey: Uint8Array, receiveKey: Uint8Array, sessionId: string): void;
28
29
  /**
29
30
  * Retrieves session key from memory
30
31
  * @param deviceId - Target device ID
@@ -105,16 +105,18 @@ async function deletePrivateKey(deviceId) {
105
105
  }
106
106
  }
107
107
  /**
108
- * Stores session key in memory
108
+ * Stores session keys in memory
109
109
  * Session keys are ephemeral and not persisted to disk
110
110
  *
111
111
  * @param deviceId - Target device ID
112
- * @param sharedKey - NaCl secretbox shared key (32 bytes)
112
+ * @param sendKey - 32-byte directional key for encrypting outgoing messages
113
+ * @param receiveKey - 32-byte directional key for decrypting incoming messages
113
114
  * @param sessionId - Unique session identifier
114
115
  */
115
- function storeSessionKey(deviceId, sharedKey, sessionId) {
116
+ function storeSessionKey(deviceId, sendKey, receiveKey, sessionId) {
116
117
  sessionKeyStore.set(deviceId, {
117
- sharedKey,
118
+ sendKey,
119
+ receiveKey,
118
120
  sessionId,
119
121
  deviceId,
120
122
  messageCounter: 0,