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.
- package/README.md +3 -6
- package/dist/approval.d.ts +1 -0
- package/dist/approval.js +9 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +62 -16
- package/dist/crypto/e2eeManager.d.ts +49 -52
- package/dist/crypto/e2eeManager.js +256 -181
- package/dist/crypto/encryption.d.ts +8 -10
- package/dist/crypto/encryption.js +29 -94
- package/dist/crypto/index.d.ts +10 -0
- package/dist/crypto/index.js +22 -0
- package/dist/crypto/keyExchange.d.ts +6 -20
- package/dist/crypto/keyExchange.js +18 -110
- package/dist/crypto/keyGeneration.d.ts +2 -13
- package/dist/crypto/keyGeneration.js +14 -88
- package/dist/crypto/keyStorage.d.ts +32 -5
- package/dist/crypto/keyStorage.js +152 -8
- package/dist/crypto/sessionPersistence.d.ts +7 -13
- package/dist/crypto/sessionPersistence.js +108 -33
- package/dist/crypto/types.d.ts +24 -3
- package/dist/crypto/types.js +2 -1
- package/dist/crypto/websocketE2EE.d.ts +6 -17
- package/dist/crypto/websocketE2EE.js +21 -38
- package/dist/index.js +203 -280
- package/dist/integration.d.ts +0 -1
- package/dist/integration.js +2 -4
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +209 -1
- package/dist/server.d.ts +30 -0
- package/dist/server.js +162 -0
- package/dist/startup.js +15 -6
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +94 -1
- package/dist/tools/claude-process.d.ts +8 -0
- package/dist/tools/claude-process.js +199 -26
- package/dist/tools/claude-sessions.d.ts +1 -0
- package/dist/tools/claude-sessions.js +36 -10
- package/dist/tools/detector.js +11 -3
- package/dist/tools/permission-hook.js +94 -27
- package/dist/tools/permission-ipc.d.ts +1 -0
- package/dist/tools/permission-ipc.js +61 -14
- package/dist/transcript-streamer.d.ts +1 -0
- package/dist/transcript-streamer.js +18 -4
- package/dist/usage-tracker.d.ts +45 -0
- package/dist/usage-tracker.js +243 -0
- package/dist/websocket.d.ts +43 -12
- package/dist/websocket.js +418 -214
- package/package.json +7 -5
- package/dist/__tests__/cli-commands.test.d.ts +0 -6
- package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
- package/dist/__tests__/cli-commands.test.js +0 -213
- package/dist/__tests__/cli-commands.test.js.map +0 -1
- package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
- package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
- package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
- package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
- package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
- package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
- package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
- package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/encryption.test.js +0 -116
- package/dist/__tests__/crypto/encryption.test.js.map +0 -1
- package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyExchange.test.js +0 -84
- package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
- package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
- package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
- package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyStorage.test.js +0 -133
- package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
- package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
- package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
- package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
- package/dist/__tests__/startup.test.d.ts +0 -11
- package/dist/__tests__/startup.test.d.ts.map +0 -1
- package/dist/__tests__/startup.test.js +0 -241
- package/dist/__tests__/startup.test.js.map +0 -1
- package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
- package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
- package/dist/__tests__/tools/claude-process.test.js +0 -430
- package/dist/__tests__/tools/claude-process.test.js.map +0 -1
- package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
- package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
- package/dist/__tests__/tools/permission-hook.test.js +0 -616
- package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
- package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
- package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
- package/dist/__tests__/tools/permission-ipc.test.js +0 -612
- package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
- package/dist/__tests__/websocket.test.d.ts +0 -13
- package/dist/__tests__/websocket.test.d.ts.map +0 -1
- package/dist/__tests__/websocket.test.js +0 -204
- package/dist/__tests__/websocket.test.js.map +0 -1
- package/dist/api.d.ts +0 -44
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js +0 -76
- package/dist/api.js.map +0 -1
- package/dist/approval.d.ts.map +0 -1
- package/dist/approval.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/crypto/e2eeManager.d.ts.map +0 -1
- package/dist/crypto/e2eeManager.js.map +0 -1
- package/dist/crypto/encryption.d.ts.map +0 -1
- package/dist/crypto/encryption.js.map +0 -1
- package/dist/crypto/keyExchange.d.ts.map +0 -1
- package/dist/crypto/keyExchange.js.map +0 -1
- package/dist/crypto/keyGeneration.d.ts.map +0 -1
- package/dist/crypto/keyGeneration.js.map +0 -1
- package/dist/crypto/keyStorage.d.ts.map +0 -1
- package/dist/crypto/keyStorage.js.map +0 -1
- package/dist/crypto/sessionPersistence.d.ts.map +0 -1
- package/dist/crypto/sessionPersistence.js.map +0 -1
- package/dist/crypto/types.d.ts.map +0 -1
- package/dist/crypto/types.js.map +0 -1
- package/dist/crypto/websocketE2EE.d.ts.map +0 -1
- package/dist/crypto/websocketE2EE.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/integration.d.ts.map +0 -1
- package/dist/integration.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/startup.d.ts.map +0 -1
- package/dist/startup.js.map +0 -1
- package/dist/terminal.d.ts.map +0 -1
- package/dist/terminal.js.map +0 -1
- package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
- package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
- package/dist/tools/__tests__/claude-sessions.test.js +0 -306
- package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
- package/dist/tools/claude-hooks.d.ts.map +0 -1
- package/dist/tools/claude-hooks.js.map +0 -1
- package/dist/tools/claude-process.d.ts.map +0 -1
- package/dist/tools/claude-process.js.map +0 -1
- package/dist/tools/claude-sessions.d.ts.map +0 -1
- package/dist/tools/claude-sessions.js.map +0 -1
- package/dist/tools/detector.d.ts.map +0 -1
- package/dist/tools/detector.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/permission-hook.d.ts.map +0 -1
- package/dist/tools/permission-hook.js.map +0 -1
- package/dist/tools/permission-ipc.d.ts.map +0 -1
- package/dist/tools/permission-ipc.js.map +0 -1
- package/dist/transcript-streamer.d.ts.map +0 -1
- package/dist/transcript-streamer.js.map +0 -1
- package/dist/websocket.d.ts.map +0 -1
- package/dist/websocket.js.map +0 -1
- 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
|
|
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
|
|
112
|
+
* @param sharedKey - NaCl secretbox shared key (32 bytes)
|
|
94
113
|
* @param sessionId - Unique session identifier
|
|
95
114
|
*/
|
|
96
|
-
function storeSessionKey(deviceId,
|
|
115
|
+
function storeSessionKey(deviceId, sharedKey, sessionId) {
|
|
97
116
|
sessionKeyStore.set(deviceId, {
|
|
98
|
-
|
|
117
|
+
sharedKey,
|
|
99
118
|
sessionId,
|
|
119
|
+
deviceId,
|
|
120
|
+
messageCounter: 0,
|
|
121
|
+
lastReceivedCounter: -1,
|
|
100
122
|
});
|
|
101
123
|
}
|
|
102
124
|
/**
|
|
103
|
-
* Retrieves session
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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));
|
|
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
|
}
|
package/dist/crypto/types.d.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* E2EE Type Definitions for ForkOff CLI
|
|
3
3
|
*
|
|
4
|
-
* X25519 key exchange +
|
|
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
|
-
|
|
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
|
package/dist/crypto/types.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* E2EE Type Definitions for ForkOff CLI
|
|
4
4
|
*
|
|
5
|
-
* X25519 key exchange +
|
|
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
|
-
*
|
|
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
|
|
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):
|
|
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
|
|
35
|
+
export declare function createE2EEWebSocketClient(wsClient: WebSocketClient, deviceId: string): Promise<WebSocketE2EEIntegration>;
|
|
47
36
|
//# sourceMappingURL=websocketE2EE.d.ts.map
|