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
@@ -39,10 +39,29 @@ exports.deletePrivateKey = deletePrivateKey;
39
39
  exports.storeSessionKey = storeSessionKey;
40
40
  exports.getSessionKey = getSessionKey;
41
41
  exports.clearSessionKeys = clearSessionKeys;
42
+ exports.storeSigningKeyPair = storeSigningKeyPair;
43
+ exports.getSigningKeyPair = getSigningKeyPair;
44
+ exports.loadTrustedPeerKeys = loadTrustedPeerKeys;
45
+ exports.getTrustedPeerKey = getTrustedPeerKey;
46
+ exports.trustPeerKey = trustPeerKey;
47
+ exports.removeTrustedPeerKey = removeTrustedPeerKey;
48
+ /**
49
+ * Key Storage Service
50
+ * Stores E2EE identity keys via OS keychain (keytar) and session keys in memory.
51
+ * Updated to use NaCl-compatible SessionKeys (sharedKey instead of encryptionKey).
52
+ */
42
53
  const keytar = __importStar(require("keytar"));
54
+ const fs = __importStar(require("fs"));
55
+ const path = __importStar(require("path"));
56
+ const os = __importStar(require("os"));
43
57
  const SERVICE_NAME = 'forkoff-cli';
44
58
  // In-memory storage for session keys (not persisted)
45
59
  const sessionKeyStore = new Map();
60
+ // In-memory cache of trusted peer identity keys
61
+ const trustedPeerKeys = new Map();
62
+ // Path to trusted keys file
63
+ const TRUSTED_KEYS_DIR = path.join(os.homedir(), '.forkoff-cli');
64
+ const TRUSTED_KEYS_FILE = path.join(TRUSTED_KEYS_DIR, 'trusted-keys.json');
46
65
  /**
47
66
  * Stores private key in OS keychain
48
67
  * @param deviceId - Device ID
@@ -53,7 +72,7 @@ async function storePrivateKey(deviceId, privateKey) {
53
72
  await keytar.setPassword(SERVICE_NAME, `e2ee-private-key-${deviceId}`, privateKey);
54
73
  }
55
74
  catch (error) {
56
- console.error('Failed to store private key in keychain:', error);
75
+ console.error('Failed to store private key in keychain:', error.message);
57
76
  throw error;
58
77
  }
59
78
  }
@@ -68,7 +87,7 @@ async function getPrivateKey(deviceId) {
68
87
  return key;
69
88
  }
70
89
  catch (error) {
71
- console.error('Failed to retrieve private key from keychain:', error);
90
+ console.error('Failed to retrieve private key from keychain:', error.message);
72
91
  return null;
73
92
  }
74
93
  }
@@ -81,26 +100,29 @@ async function deletePrivateKey(deviceId) {
81
100
  await keytar.deletePassword(SERVICE_NAME, `e2ee-private-key-${deviceId}`);
82
101
  }
83
102
  catch (error) {
84
- console.error('Failed to delete private key from keychain:', error);
103
+ console.error('Failed to delete private key from keychain:', error.message);
85
104
  throw error;
86
105
  }
87
106
  }
88
107
  /**
89
- * Stores session encryption key in memory
108
+ * Stores session key in memory
90
109
  * Session keys are ephemeral and not persisted to disk
91
110
  *
92
111
  * @param deviceId - Target device ID
93
- * @param encryptionKey - AES-256-GCM encryption key (32 bytes)
112
+ * @param sharedKey - NaCl secretbox shared key (32 bytes)
94
113
  * @param sessionId - Unique session identifier
95
114
  */
96
- function storeSessionKey(deviceId, encryptionKey, sessionId) {
115
+ function storeSessionKey(deviceId, sharedKey, sessionId) {
97
116
  sessionKeyStore.set(deviceId, {
98
- encryptionKey,
117
+ sharedKey,
99
118
  sessionId,
119
+ deviceId,
120
+ messageCounter: 0,
121
+ lastReceivedCounter: -1,
100
122
  });
101
123
  }
102
124
  /**
103
- * Retrieves session encryption key from memory
125
+ * Retrieves session key from memory
104
126
  * @param deviceId - Target device ID
105
127
  * @returns SessionKeys or null if not found
106
128
  */
@@ -114,4 +136,126 @@ function getSessionKey(deviceId) {
114
136
  function clearSessionKeys() {
115
137
  sessionKeyStore.clear();
116
138
  }
139
+ // --- Ed25519 Signing Key Pair (for identity verification) ---
140
+ /**
141
+ * Stores the Ed25519 signing key pair in OS keychain
142
+ */
143
+ async function storeSigningKeyPair(deviceId, keyPair) {
144
+ try {
145
+ await keytar.setPassword(SERVICE_NAME, `e2ee-signing-public-${deviceId}`, keyPair.publicKey);
146
+ await keytar.setPassword(SERVICE_NAME, `e2ee-signing-secret-${deviceId}`, keyPair.secretKey);
147
+ }
148
+ catch (error) {
149
+ console.error('Failed to store signing key pair in keychain:', error.message);
150
+ throw error;
151
+ }
152
+ }
153
+ /**
154
+ * Retrieves the Ed25519 signing key pair from OS keychain
155
+ */
156
+ async function getSigningKeyPair(deviceId) {
157
+ try {
158
+ const publicKey = await keytar.getPassword(SERVICE_NAME, `e2ee-signing-public-${deviceId}`);
159
+ const secretKey = await keytar.getPassword(SERVICE_NAME, `e2ee-signing-secret-${deviceId}`);
160
+ if (!publicKey || !secretKey)
161
+ return null;
162
+ return { publicKey, secretKey };
163
+ }
164
+ catch (error) {
165
+ console.error('Failed to retrieve signing key pair from keychain:', error.message);
166
+ return null;
167
+ }
168
+ }
169
+ // --- Trusted Peer Identity Keys (TOFU) ---
170
+ /**
171
+ * Load trusted peer keys from disk into memory.
172
+ * Called once at init time. Validates file ownership and permissions.
173
+ */
174
+ function loadTrustedPeerKeys() {
175
+ try {
176
+ if (!fs.existsSync(TRUSTED_KEYS_FILE))
177
+ return;
178
+ const stat = fs.lstatSync(TRUSTED_KEYS_FILE);
179
+ if (stat.isSymbolicLink()) {
180
+ console.error('[Security] Symlink detected at trusted-keys.json, refusing to read');
181
+ return;
182
+ }
183
+ // Verify file permissions — reject if world-readable/writable (non-Windows)
184
+ if (process.platform !== 'win32') {
185
+ const mode = stat.mode & 0o777;
186
+ if (mode & 0o077) {
187
+ console.error(`[Security] trusted-keys.json has unsafe permissions (${mode.toString(8)}), refusing to load. Fix with: chmod 600 ${TRUSTED_KEYS_FILE}`);
188
+ return;
189
+ }
190
+ }
191
+ const data = JSON.parse(fs.readFileSync(TRUSTED_KEYS_FILE, 'utf-8'));
192
+ if (data && typeof data === 'object') {
193
+ let count = 0;
194
+ for (const [deviceId, pubKey] of Object.entries(data)) {
195
+ if (typeof pubKey === 'string' && pubKey.length > 0 && pubKey.length < 256) {
196
+ trustedPeerKeys.set(deviceId, pubKey);
197
+ count++;
198
+ }
199
+ }
200
+ // Loaded trusted peer keys
201
+ }
202
+ }
203
+ catch {
204
+ // File doesn't exist or is malformed — start fresh
205
+ }
206
+ }
207
+ /**
208
+ * Save trusted peer keys from memory to disk.
209
+ */
210
+ function saveTrustedPeerKeys() {
211
+ try {
212
+ if (!fs.existsSync(TRUSTED_KEYS_DIR)) {
213
+ fs.mkdirSync(TRUSTED_KEYS_DIR, { recursive: true, mode: 0o700 });
214
+ }
215
+ const data = {};
216
+ for (const [deviceId, pubKey] of trustedPeerKeys) {
217
+ data[deviceId] = pubKey;
218
+ }
219
+ // Atomic write via temp file + rename to prevent TOCTOU race condition
220
+ const tmpPath = TRUSTED_KEYS_FILE + '.tmp.' + process.pid;
221
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
222
+ fs.renameSync(tmpPath, TRUSTED_KEYS_FILE);
223
+ }
224
+ catch (error) {
225
+ console.error('Failed to save trusted peer keys:', error.message);
226
+ }
227
+ }
228
+ /**
229
+ * Get the trusted identity public key for a peer device (TOFU).
230
+ * Returns null if no key is stored (first contact).
231
+ */
232
+ function getTrustedPeerKey(deviceId) {
233
+ return trustedPeerKeys.get(deviceId) ?? null;
234
+ }
235
+ /**
236
+ * Store a peer's identity public key (TOFU — Trust On First Use).
237
+ * Returns false if a DIFFERENT key is already stored (potential MITM).
238
+ */
239
+ function trustPeerKey(deviceId, identityPublicKey) {
240
+ const existing = trustedPeerKeys.get(deviceId);
241
+ if (existing && existing !== identityPublicKey) {
242
+ return false; // Key mismatch — possible MITM
243
+ }
244
+ if (!existing) {
245
+ trustedPeerKeys.set(deviceId, identityPublicKey);
246
+ saveTrustedPeerKeys();
247
+ // TOFU: trusted new identity key
248
+ }
249
+ return true;
250
+ }
251
+ /**
252
+ * Remove a peer's trusted identity key (used on re-pair to reset TOFU).
253
+ */
254
+ function removeTrustedPeerKey(deviceId) {
255
+ if (trustedPeerKeys.has(deviceId)) {
256
+ trustedPeerKeys.delete(deviceId);
257
+ saveTrustedPeerKeys();
258
+ // TOFU: removed trusted key for re-pair reset
259
+ }
260
+ }
117
261
  //# sourceMappingURL=keyStorage.js.map
@@ -1,33 +1,27 @@
1
1
  import { SessionKeys } from './types';
2
2
  /**
3
- * Persists a session key to disk
4
- * @param deviceId - Current device ID
5
- * @param targetDeviceId - Target device ID
6
- * @param sessionKeys - Session encryption keys
3
+ * Initialize session persistence with a key for encrypting session files at rest.
4
+ * Derives a 32-byte key from the identity private key using NaCl hash.
5
+ */
6
+ export declare function initSessionPersistence(identityPrivateKeyB64: string): void;
7
+ /**
8
+ * Persists a session key to disk, encrypted at rest.
7
9
  */
8
10
  export declare function persistSessionKey(deviceId: string, targetDeviceId: string, sessionKeys: SessionKeys): void;
9
11
  /**
10
- * Loads a persisted session key from disk
11
- * @param deviceId - Current device ID
12
- * @param targetDeviceId - Target device ID
13
- * @returns SessionKeys or null if not found
12
+ * Loads a persisted session key from disk, decrypting if encrypted.
14
13
  */
15
14
  export declare function loadPersistedSessionKey(deviceId: string, targetDeviceId: string): SessionKeys | null;
16
15
  /**
17
16
  * Deletes a persisted session
18
- * @param deviceId - Current device ID
19
- * @param targetDeviceId - Target device ID
20
17
  */
21
18
  export declare function deletePersistedSession(deviceId: string, targetDeviceId: string): void;
22
19
  /**
23
20
  * Deletes all persisted sessions for a device
24
- * @param deviceId - Current device ID
25
21
  */
26
22
  export declare function deleteAllPersistedSessions(deviceId: string): void;
27
23
  /**
28
24
  * Lists all persisted sessions for a device
29
- * @param deviceId - Current device ID
30
- * @returns Array of target device IDs with active sessions
31
25
  */
32
26
  export declare function listPersistedSessions(deviceId: string): string[];
33
27
  //# sourceMappingURL=sessionPersistence.d.ts.map
@@ -32,7 +32,11 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.initSessionPersistence = initSessionPersistence;
36
40
  exports.persistSessionKey = persistSessionKey;
37
41
  exports.loadPersistedSessionKey = loadPersistedSessionKey;
38
42
  exports.deletePersistedSession = deletePersistedSession;
@@ -41,51 +45,97 @@ exports.listPersistedSessions = listPersistedSessions;
41
45
  const fs = __importStar(require("fs"));
42
46
  const path = __importStar(require("path"));
43
47
  const os = __importStar(require("os"));
48
+ const tweetnacl_1 = __importDefault(require("tweetnacl"));
49
+ const tweetnacl_util_1 = require("tweetnacl-util");
44
50
  /**
45
51
  * Session Persistence
46
- * Stores session keys to disk so they survive reconnections after IP changes
52
+ * Stores session keys to disk so they survive reconnections after IP changes.
53
+ * Session data is encrypted at rest using a key derived from the device's identity private key.
47
54
  */
48
55
  const SESSION_STORE_DIR = path.join(os.homedir(), '.forkoff-cli', 'sessions');
56
+ // In-memory encryption key for session files (derived from identity key at init)
57
+ let sessionFileKey = null;
49
58
  /**
50
- * Ensures the session store directory exists
59
+ * Initialize session persistence with a key for encrypting session files at rest.
60
+ * Derives a 32-byte key from the identity private key using NaCl hash.
51
61
  */
62
+ function initSessionPersistence(identityPrivateKeyB64) {
63
+ const privateKey = (0, tweetnacl_util_1.decodeBase64)(identityPrivateKeyB64);
64
+ // Use NaCl hash to derive a deterministic 32-byte key from the private key
65
+ // SHA-512 first 32 bytes = consistent encryption key for session files
66
+ const hash = tweetnacl_1.default.hash(privateKey);
67
+ sessionFileKey = hash.slice(0, 32);
68
+ }
52
69
  function ensureSessionStoreExists() {
53
70
  if (!fs.existsSync(SESSION_STORE_DIR)) {
54
- fs.mkdirSync(SESSION_STORE_DIR, { recursive: true });
71
+ fs.mkdirSync(SESSION_STORE_DIR, { recursive: true, mode: 0o700 });
72
+ }
73
+ else {
74
+ // SECURITY: Validate existing dir isn't world/group-writable (non-Windows only)
75
+ if (process.platform !== 'win32') {
76
+ const stat = fs.statSync(SESSION_STORE_DIR);
77
+ const mode = stat.mode & 0o777;
78
+ if (mode & 0o022) { // group or other writable
79
+ throw new Error(`Session store has unsafe permissions (${mode.toString(8)})`);
80
+ }
81
+ }
55
82
  }
56
83
  }
57
- /**
58
- * Gets the file path for a device's session
59
- */
84
+ function sanitizeDeviceIdForPath(id) {
85
+ // Strip path traversal and dangerous characters
86
+ return id.replace(/[\/\\\.]+/g, '_').replace(/\.\./g, '_').substring(0, 64);
87
+ }
60
88
  function getSessionFilePath(deviceId, targetDeviceId) {
61
- return path.join(SESSION_STORE_DIR, `${deviceId}-${targetDeviceId}.json`);
89
+ const safeDeviceId = sanitizeDeviceIdForPath(deviceId);
90
+ const safeTargetId = sanitizeDeviceIdForPath(targetDeviceId);
91
+ const filePath = path.join(SESSION_STORE_DIR, `${safeDeviceId}-${safeTargetId}.json`);
92
+ // Ensure resolved path is within SESSION_STORE_DIR
93
+ const resolved = path.resolve(filePath);
94
+ if (!resolved.startsWith(path.resolve(SESSION_STORE_DIR))) {
95
+ throw new Error('E2EE: Session file path escapes store directory');
96
+ }
97
+ return filePath;
62
98
  }
63
99
  /**
64
- * Persists a session key to disk
65
- * @param deviceId - Current device ID
66
- * @param targetDeviceId - Target device ID
67
- * @param sessionKeys - Session encryption keys
100
+ * Persists a session key to disk, encrypted at rest.
68
101
  */
69
102
  function persistSessionKey(deviceId, targetDeviceId, sessionKeys) {
70
103
  try {
71
104
  ensureSessionStoreExists();
72
- const data = {
73
- encryptionKey: Array.from(sessionKeys.encryptionKey), // Convert Uint8Array to Array for JSON
105
+ const plainData = JSON.stringify({
106
+ sharedKey: Array.from(sessionKeys.sharedKey),
74
107
  sessionId: sessionKeys.sessionId,
108
+ deviceId: sessionKeys.deviceId,
109
+ messageCounter: sessionKeys.messageCounter,
110
+ lastReceivedCounter: sessionKeys.lastReceivedCounter,
75
111
  timestamp: new Date().toISOString(),
76
- };
112
+ });
77
113
  const filePath = getSessionFilePath(deviceId, targetDeviceId);
78
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
114
+ // SECURITY: Atomic write via temp + rename to prevent TOCTOU
115
+ const tmpPath = filePath + '.tmp.' + process.pid;
116
+ if (!sessionFileKey) {
117
+ // SECURITY: Refuse to persist session keys in plaintext — wait for key initialization
118
+ console.error('[Security] Session persistence key not initialized, refusing to write plaintext session');
119
+ return;
120
+ }
121
+ // Encrypt session data at rest
122
+ const nonce = tweetnacl_1.default.randomBytes(tweetnacl_1.default.secretbox.nonceLength);
123
+ const messageBytes = new TextEncoder().encode(plainData);
124
+ const ciphertext = tweetnacl_1.default.secretbox(messageBytes, nonce, sessionFileKey);
125
+ const encrypted = {
126
+ _encrypted: true,
127
+ nonce: (0, tweetnacl_util_1.encodeBase64)(nonce),
128
+ ciphertext: (0, tweetnacl_util_1.encodeBase64)(ciphertext),
129
+ };
130
+ fs.writeFileSync(tmpPath, JSON.stringify(encrypted), { encoding: 'utf8', mode: 0o600 });
131
+ fs.renameSync(tmpPath, filePath);
79
132
  }
80
133
  catch (error) {
81
- console.error('Failed to persist session key:', error);
134
+ console.error('Failed to persist session key:', error.message);
82
135
  }
83
136
  }
84
137
  /**
85
- * Loads a persisted session key from disk
86
- * @param deviceId - Current device ID
87
- * @param targetDeviceId - Target device ID
88
- * @returns SessionKeys or null if not found
138
+ * Loads a persisted session key from disk, decrypting if encrypted.
89
139
  */
90
140
  function loadPersistedSessionKey(deviceId, targetDeviceId) {
91
141
  try {
@@ -93,30 +143,58 @@ function loadPersistedSessionKey(deviceId, targetDeviceId) {
93
143
  if (!fs.existsSync(filePath)) {
94
144
  return null;
95
145
  }
96
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
146
+ const stat = fs.lstatSync(filePath);
147
+ if (stat.isSymbolicLink()) {
148
+ console.error('[Security] Symlink detected at session file, refusing to read');
149
+ return null;
150
+ }
151
+ const raw = fs.readFileSync(filePath, 'utf8');
152
+ let data;
153
+ const parsed = JSON.parse(raw);
154
+ if (parsed._encrypted && sessionFileKey) {
155
+ // Decrypt at-rest encryption
156
+ const nonce = (0, tweetnacl_util_1.decodeBase64)(parsed.nonce);
157
+ const ciphertext = (0, tweetnacl_util_1.decodeBase64)(parsed.ciphertext);
158
+ const decrypted = tweetnacl_1.default.secretbox.open(ciphertext, nonce, sessionFileKey);
159
+ if (!decrypted) {
160
+ console.error('[Security] Session file decryption failed — key may have changed');
161
+ fs.unlinkSync(filePath);
162
+ return null;
163
+ }
164
+ data = JSON.parse(new TextDecoder().decode(decrypted));
165
+ }
166
+ else if (parsed._encrypted && !sessionFileKey) {
167
+ // Encrypted but no key available — skip
168
+ console.error('[Security] Session file is encrypted but decryption key not available');
169
+ return null;
170
+ }
171
+ else {
172
+ // Legacy unencrypted format
173
+ data = parsed;
174
+ }
97
175
  // Check if session is expired (older than 24 hours)
98
176
  const timestamp = new Date(data.timestamp);
99
177
  const now = new Date();
100
178
  const hoursSinceCreation = (now.getTime() - timestamp.getTime()) / (1000 * 60 * 60);
101
179
  if (hoursSinceCreation > 24) {
102
- // Session expired, delete it
103
180
  fs.unlinkSync(filePath);
104
181
  return null;
105
182
  }
106
183
  return {
107
- encryptionKey: new Uint8Array(data.encryptionKey),
184
+ sharedKey: new Uint8Array(data.sharedKey),
108
185
  sessionId: data.sessionId,
186
+ deviceId: data.deviceId || targetDeviceId,
187
+ messageCounter: data.messageCounter || 0,
188
+ lastReceivedCounter: data.lastReceivedCounter || -1,
109
189
  };
110
190
  }
111
191
  catch (error) {
112
- console.error('Failed to load persisted session key:', error);
192
+ console.error('Failed to load persisted session key:', error.message);
113
193
  return null;
114
194
  }
115
195
  }
116
196
  /**
117
197
  * Deletes a persisted session
118
- * @param deviceId - Current device ID
119
- * @param targetDeviceId - Target device ID
120
198
  */
121
199
  function deletePersistedSession(deviceId, targetDeviceId) {
122
200
  try {
@@ -126,12 +204,11 @@ function deletePersistedSession(deviceId, targetDeviceId) {
126
204
  }
127
205
  }
128
206
  catch (error) {
129
- console.error('Failed to delete persisted session:', error);
207
+ console.error('Failed to delete persisted session:', error.message);
130
208
  }
131
209
  }
132
210
  /**
133
211
  * Deletes all persisted sessions for a device
134
- * @param deviceId - Current device ID
135
212
  */
136
213
  function deleteAllPersistedSessions(deviceId) {
137
214
  try {
@@ -146,13 +223,11 @@ function deleteAllPersistedSessions(deviceId) {
146
223
  }
147
224
  }
148
225
  catch (error) {
149
- console.error('Failed to delete all persisted sessions:', error);
226
+ console.error('Failed to delete all persisted sessions:', error.message);
150
227
  }
151
228
  }
152
229
  /**
153
230
  * Lists all persisted sessions for a device
154
- * @param deviceId - Current device ID
155
- * @returns Array of target device IDs with active sessions
156
231
  */
157
232
  function listPersistedSessions(deviceId) {
158
233
  try {
@@ -163,10 +238,10 @@ function listPersistedSessions(deviceId) {
163
238
  const prefix = `${deviceId}-`;
164
239
  return files
165
240
  .filter((file) => file.startsWith(prefix) && file.endsWith('.json'))
166
- .map((file) => file.substring(prefix.length, file.length - 5)); // Remove prefix and .json
241
+ .map((file) => file.substring(prefix.length, file.length - 5));
167
242
  }
168
243
  catch (error) {
169
- console.error('Failed to list persisted sessions:', error);
244
+ console.error('Failed to list persisted sessions:', error.message);
170
245
  return [];
171
246
  }
172
247
  }
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * E2EE Type Definitions for ForkOff CLI
3
3
  *
4
- * X25519 key exchange + AES-256-GCM encryption
4
+ * X25519 key exchange + NaCl secretbox (XSalsa20-Poly1305)
5
+ * Compatible with mobile app's tweetnacl implementation.
5
6
  */
7
+ /** X25519 key pair for Diffie-Hellman key exchange */
6
8
  export interface E2EEKeyPair {
7
9
  publicKey: string;
8
10
  privateKey: string;
9
11
  }
12
+ /** Encrypted payload using NaCl secretbox (XSalsa20-Poly1305) */
10
13
  export interface EncryptedPayload {
11
14
  ciphertext: string;
12
15
  nonce: string;
13
- authTag: string;
14
16
  }
17
+ /** Full encrypted message sent over WebSocket */
15
18
  export interface EncryptedMessage {
16
19
  senderDeviceId: string;
17
20
  recipientDeviceId: string;
@@ -20,16 +23,34 @@ export interface EncryptedMessage {
20
23
  messageCounter: number;
21
24
  timestamp: string;
22
25
  }
26
+ /** Derived session keys for a specific device-to-device connection */
23
27
  export interface SessionKeys {
24
- encryptionKey: Uint8Array;
28
+ sharedKey: Uint8Array;
25
29
  sessionId: string;
30
+ deviceId: string;
31
+ messageCounter: number;
32
+ lastReceivedCounter: number;
33
+ }
34
+ /** Ed25519 signing key pair for identity verification during key exchange */
35
+ export interface SigningKeyPair {
36
+ publicKey: string;
37
+ secretKey: string;
26
38
  }
39
+ /** Key exchange initiation (sender → recipient via server) */
27
40
  export interface KeyExchangeInit {
28
41
  senderDeviceId: string;
29
42
  ephemeralPublicKey: string;
43
+ identityPublicKey?: string;
44
+ signature?: string;
30
45
  }
46
+ /** Key exchange acknowledgment (recipient → sender via server) */
31
47
  export interface KeyExchangeAck {
48
+ senderDeviceId: string;
32
49
  recipientDeviceId: string;
33
50
  ephemeralPublicKey: string;
51
+ identityPublicKey?: string;
52
+ signature?: string;
34
53
  }
54
+ /** E2EE session status */
55
+ export type E2EESessionStatus = 'none' | 'initiating' | 'established' | 'failed';
35
56
  //# sourceMappingURL=types.d.ts.map
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * E2EE Type Definitions for ForkOff CLI
4
4
  *
5
- * X25519 key exchange + AES-256-GCM encryption
5
+ * X25519 key exchange + NaCl secretbox (XSalsa20-Poly1305)
6
+ * Compatible with mobile app's tweetnacl implementation.
6
7
  */
7
8
  Object.defineProperty(exports, "__esModule", { value: true });
8
9
  //# sourceMappingURL=types.js.map
@@ -1,7 +1,8 @@
1
1
  import { WebSocketClient } from '../websocket';
2
2
  /**
3
- * WebSocket E2EE Integration
4
- * Adds end-to-end encryption capabilities to WebSocket client
3
+ * WebSocket E2EE Integration (legacy wrapper)
4
+ * E2EE is now wired directly into WebSocketClient.
5
+ * This class remains for backward compatibility.
5
6
  */
6
7
  export declare class WebSocketE2EEIntegration {
7
8
  private wsClient;
@@ -11,30 +12,18 @@ export declare class WebSocketE2EEIntegration {
11
12
  /**
12
13
  * Initializes E2EE and sets up event handlers
13
14
  */
14
- initialize(deviceId: string, apiUrl: string, authToken: string): Promise<void>;
15
- /**
16
- * Sets up WebSocket event handlers for E2EE
17
- */
15
+ initialize(deviceId: string): Promise<void>;
18
16
  private setupEventHandlers;
19
17
  /**
20
18
  * Initiates key exchange with a target device
21
19
  */
22
- initiateKeyExchange(targetDeviceId: string): Promise<void>;
20
+ initiateKeyExchange(targetDeviceId: string): void;
23
21
  /**
24
22
  * Encrypts and sends a message to a target device
25
23
  */
26
24
  sendEncryptedMessage(plaintext: string, targetDeviceId: string, sessionId: string): void;
27
- /**
28
- * Checks if E2EE session exists for a device
29
- */
30
25
  hasSession(deviceId: string): boolean;
31
- /**
32
- * Checks if E2EE is enabled
33
- */
34
26
  isEnabled(): boolean;
35
- /**
36
- * Cleans up E2EE sessions
37
- */
38
27
  cleanup(): void;
39
28
  private emitKeyExchangeInit;
40
29
  private emitKeyExchangeAck;
@@ -43,5 +32,5 @@ export declare class WebSocketE2EEIntegration {
43
32
  /**
44
33
  * Factory function to create E2EE-enabled WebSocket client
45
34
  */
46
- export declare function createE2EEWebSocketClient(wsClient: WebSocketClient, deviceId: string, apiUrl: string, authToken: string): Promise<WebSocketE2EEIntegration>;
35
+ export declare function createE2EEWebSocketClient(wsClient: WebSocketClient, deviceId: string): Promise<WebSocketE2EEIntegration>;
47
36
  //# sourceMappingURL=websocketE2EE.d.ts.map