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 +7 -5
- package/dist/cloud-client.d.ts +30 -0
- package/dist/cloud-client.js +165 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +24 -0
- package/dist/crypto/e2eeManager.d.ts +8 -0
- package/dist/crypto/e2eeManager.js +90 -14
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +2 -1
- package/dist/crypto/keyExchange.d.ts +18 -0
- package/dist/crypto/keyExchange.js +37 -1
- package/dist/crypto/keyStorage.d.ts +4 -3
- package/dist/crypto/keyStorage.js +6 -4
- package/dist/crypto/sessionPersistence.js +24 -2
- package/dist/crypto/types.d.ts +3 -1
- package/dist/index.js +142 -30
- package/dist/websocket.d.ts +14 -1
- package/dist/websocket.js +159 -42
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -113,18 +113,20 @@ forkoff startup --status # Check registration
|
|
|
113
113
|
|
|
114
114
|
## Security
|
|
115
115
|
|
|
116
|
-
|
|
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
|
|
121
|
-
| **
|
|
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** |
|
|
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
|
|
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
|
-
|
|
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,
|
|
216
|
+
(0, keyStorage_1.storeSessionKey)(init.senderDeviceId, sendKey, receiveKey, sessionId);
|
|
190
217
|
// Persist to disk for reconnection resilience
|
|
191
218
|
const sessionKeys = {
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
272
|
+
sendKey,
|
|
273
|
+
receiveKey,
|
|
228
274
|
outgoingCounter: 0,
|
|
229
275
|
lastReceivedCounter: -1,
|
|
276
|
+
createdAt: Date.now(),
|
|
230
277
|
});
|
|
231
|
-
(0, keyStorage_1.storeSessionKey)(peerId,
|
|
278
|
+
(0, keyStorage_1.storeSessionKey)(peerId, sendKey, receiveKey, sessionId);
|
|
232
279
|
// Persist to disk
|
|
233
280
|
const sessionKeys = {
|
|
234
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/crypto/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
116
|
+
function storeSessionKey(deviceId, sendKey, receiveKey, sessionId) {
|
|
116
117
|
sessionKeyStore.set(deviceId, {
|
|
117
|
-
|
|
118
|
+
sendKey,
|
|
119
|
+
receiveKey,
|
|
118
120
|
sessionId,
|
|
119
121
|
deviceId,
|
|
120
122
|
messageCounter: 0,
|