forkoff 1.1.1 → 1.1.3
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 +27 -11
- package/dist/cloud-client.js +2 -1
- package/dist/crypto/e2eeManager.js +5 -13
- package/dist/server.js +2 -1
- package/dist/websocket.js +8 -8
- package/package.json +9 -4
- package/E2EE-COMPLETE.md +0 -290
- package/eas.json +0 -21
package/README.md
CHANGED
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
ForkOff CLI connects [Claude Code](https://claude.ai/code) on your laptop to the ForkOff mobile app, giving you real-time monitoring, interactive approvals, and usage analytics from anywhere.
|
|
29
29
|
|
|
30
|
-
> **Open Source** —
|
|
31
|
-
>
|
|
32
|
-
> **Open Beta** — [Join the iOS TestFlight](https://testflight.apple.com/join/dhh5FrN7)
|
|
30
|
+
> **Open Source** — MIT licensed. Contributions welcome!
|
|
33
31
|
|
|
34
32
|
## Installation
|
|
35
33
|
|
|
@@ -66,29 +64,47 @@ Keep this running to stream sessions to your phone in real-time.
|
|
|
66
64
|
- **Usage analytics** — Track token usage, session counts, and streaks across devices
|
|
67
65
|
- **Multi-device support** — Connect multiple CLI instances, analytics aggregate automatically
|
|
68
66
|
- **Auto-start** — Optionally launch on login so your phone is always connected
|
|
69
|
-
- **
|
|
67
|
+
- **Local relay option** — Run with `--local` for a direct P2P connection without cloud dependency
|
|
70
68
|
|
|
71
69
|
## Commands
|
|
72
70
|
|
|
73
71
|
| Command | Description |
|
|
74
72
|
|---------|-------------|
|
|
75
|
-
| `forkoff pair` | Generate QR code to pair with mobile app |
|
|
76
|
-
| `forkoff connect` |
|
|
73
|
+
| `forkoff pair [--local]` | Generate QR code to pair with mobile app |
|
|
74
|
+
| `forkoff connect [--local]` | Reconnect to ForkOff (for previously paired devices) |
|
|
77
75
|
| `forkoff status` | Check connection status |
|
|
78
76
|
| `forkoff disconnect` | Disconnect and unpair device |
|
|
79
77
|
| `forkoff config` | View/modify configuration |
|
|
80
78
|
| `forkoff startup` | Manage automatic startup on login |
|
|
81
|
-
| `forkoff tools` | Detect AI
|
|
82
|
-
| `forkoff logs` | List
|
|
79
|
+
| `forkoff tools` | Detect AI tools, install/uninstall hooks |
|
|
80
|
+
| `forkoff logs` | List, view, or clean debug logs |
|
|
83
81
|
|
|
84
82
|
### Configuration
|
|
85
83
|
|
|
86
84
|
```bash
|
|
87
85
|
forkoff config --show # Show current config
|
|
88
86
|
forkoff config --name "My MBP" # Set device name
|
|
87
|
+
forkoff config --port 8080 # Set relay server port
|
|
89
88
|
forkoff config --reset # Reset to defaults
|
|
90
89
|
```
|
|
91
90
|
|
|
91
|
+
### Tools
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
forkoff tools --detect # Detect installed AI tools
|
|
95
|
+
forkoff tools --install-hooks # Install ForkOff hooks for Claude Code
|
|
96
|
+
forkoff tools --uninstall-hooks # Remove ForkOff hooks
|
|
97
|
+
forkoff tools --watch # Watch tool status changes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Logs
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
forkoff logs # List debug log files
|
|
104
|
+
forkoff logs --latest # Print path to most recent log
|
|
105
|
+
forkoff logs --clean # Delete all log files
|
|
106
|
+
```
|
|
107
|
+
|
|
92
108
|
### Global Options
|
|
93
109
|
|
|
94
110
|
| Option | Description |
|
|
@@ -126,7 +142,7 @@ ForkOff uses end-to-end encryption (X25519 ECDH + XSalsa20-Poly1305) so the rela
|
|
|
126
142
|
| **Key storage** | OS keychain (macOS Keychain, Windows Credential Manager, Linux libsecret) |
|
|
127
143
|
| **Enforcement** | 24 sensitive event types encrypted; plaintext fallback only when E2EE unavailable |
|
|
128
144
|
|
|
129
|
-
No additional setup required — E2EE is enabled automatically when you pair.
|
|
145
|
+
No additional setup required — E2EE is enabled automatically when you pair.
|
|
130
146
|
|
|
131
147
|
---
|
|
132
148
|
|
|
@@ -158,7 +174,7 @@ forkoff.sendTerminalOutput(sessionId, '> npm install\nDone', 'stdout');
|
|
|
158
174
|
| Platform | Location |
|
|
159
175
|
|----------|----------|
|
|
160
176
|
| **Windows** | `%APPDATA%\forkoff-cli\config.json` |
|
|
161
|
-
| **macOS** |
|
|
177
|
+
| **macOS** | `~/.config/forkoff-cli/config.json` |
|
|
162
178
|
| **Linux** | `~/.config/forkoff-cli/config.json` |
|
|
163
179
|
|
|
164
180
|
## Development
|
|
@@ -175,7 +191,7 @@ npm test # Run tests
|
|
|
175
191
|
## Requirements
|
|
176
192
|
|
|
177
193
|
- Node.js 18+
|
|
178
|
-
- [ForkOff mobile app](https://
|
|
194
|
+
- [ForkOff mobile app](https://github.com/Forkoff-app/forkoff-react-native) (iOS/Android)
|
|
179
195
|
|
|
180
196
|
## License
|
|
181
197
|
|
package/dist/cloud-client.js
CHANGED
|
@@ -118,7 +118,8 @@ class CloudRelayClient extends events_1.EventEmitter {
|
|
|
118
118
|
});
|
|
119
119
|
// Handle mobile connected notification from relay
|
|
120
120
|
this.socket.on('mobile_connected', (data) => {
|
|
121
|
-
|
|
121
|
+
const id = data.deviceId || 'unknown';
|
|
122
|
+
console.log(`[CloudRelay] Mobile connected: ${id.length > 8 ? id.substring(0, 8) + '...' : id}`);
|
|
122
123
|
this.emit('mobile_connected', { deviceId: data.deviceId || data.mobileDeviceId });
|
|
123
124
|
});
|
|
124
125
|
// Handle mobile disconnected notification from relay
|
|
@@ -200,10 +200,7 @@ class E2EEManager {
|
|
|
200
200
|
const rawSharedKey = (0, keyExchange_1.computeSharedKey)(ephemeral.privateKey, init.ephemeralPublicKey);
|
|
201
201
|
// Derive directional send/receive keys via HKDF
|
|
202
202
|
const { sendKey, receiveKey } = (0, keyExchange_1.deriveSessionKeys)(rawSharedKey, this.deviceId, init.senderDeviceId);
|
|
203
|
-
|
|
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)}`);
|
|
203
|
+
console.log(`[E2EE] Key exchange init processed — session established with peer ${init.senderDeviceId.substring(0, 8)}...`);
|
|
207
204
|
// Store session
|
|
208
205
|
const sessionId = `session-${init.senderDeviceId}-${this.deviceId}-${Date.now()}`;
|
|
209
206
|
this.sessions.set(init.senderDeviceId, {
|
|
@@ -249,7 +246,7 @@ class E2EEManager {
|
|
|
249
246
|
// If there's exactly one pending exchange, use it regardless of key.
|
|
250
247
|
if (!pendingEntry && this.pendingExchanges.size === 1) {
|
|
251
248
|
const [fallbackKey, fallbackEntry] = this.pendingExchanges.entries().next().value;
|
|
252
|
-
console.log(`[E2EE] Pending exchange not found for ${peerId
|
|
249
|
+
console.log(`[E2EE] Pending exchange not found for ${peerId.substring(0, 8)}..., using fallback`);
|
|
253
250
|
pendingEntry = fallbackEntry;
|
|
254
251
|
pendingKey = fallbackKey;
|
|
255
252
|
}
|
|
@@ -262,10 +259,7 @@ class E2EEManager {
|
|
|
262
259
|
const rawSharedKey = (0, keyExchange_1.computeSharedKey)(pending.privateKey, ack.ephemeralPublicKey);
|
|
263
260
|
// Derive directional send/receive keys via HKDF
|
|
264
261
|
const { sendKey, receiveKey } = (0, keyExchange_1.deriveSessionKeys)(rawSharedKey, this.deviceId, peerId);
|
|
265
|
-
|
|
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)}`);
|
|
262
|
+
console.log(`[E2EE] Key exchange ack processed — session established with peer ${peerId.substring(0, 8)}...`);
|
|
269
263
|
// Store session
|
|
270
264
|
const sessionId = `session-${this.deviceId}-${peerId}-${Date.now()}`;
|
|
271
265
|
this.sessions.set(peerId, {
|
|
@@ -338,10 +332,8 @@ class E2EEManager {
|
|
|
338
332
|
}
|
|
339
333
|
const payload = (0, encryption_1.encrypt)(plaintext, session.sendKey);
|
|
340
334
|
session.outgoingCounter++;
|
|
341
|
-
// Log encryption key fingerprint (first message only to avoid spam)
|
|
342
335
|
if (session.outgoingCounter === 1) {
|
|
343
|
-
|
|
344
|
-
console.log(`[E2EE] Encrypting first message with sendKey fingerprint=${keyFP}, recipient=${recipientDeviceId}`);
|
|
336
|
+
console.log(`[E2EE] First encrypted message sent to ${recipientDeviceId.substring(0, 8)}...`);
|
|
345
337
|
}
|
|
346
338
|
return {
|
|
347
339
|
senderDeviceId: this.deviceId,
|
|
@@ -374,7 +366,7 @@ class E2EEManager {
|
|
|
374
366
|
session.lastReceivedCounter = message.messageCounter;
|
|
375
367
|
// Check expiry AFTER decrypting — valid messages still get through, but warn caller
|
|
376
368
|
if (this.isSessionExpired(senderDeviceId)) {
|
|
377
|
-
console.warn(`[E2EE] Session with ${senderDeviceId} has expired
|
|
369
|
+
console.warn(`[E2EE] Session with ${senderDeviceId.substring(0, 8)}... has expired — re-key required`);
|
|
378
370
|
}
|
|
379
371
|
return plaintext;
|
|
380
372
|
}
|
package/dist/server.js
CHANGED
|
@@ -71,7 +71,8 @@ class EmbeddedRelayServer extends events_1.EventEmitter {
|
|
|
71
71
|
next();
|
|
72
72
|
});
|
|
73
73
|
this.io.on('connection', (socket) => {
|
|
74
|
-
|
|
74
|
+
const devId = socket.data.deviceId || 'unknown';
|
|
75
|
+
console.log(`[Server] Mobile connected: ${devId.length > 8 ? devId.substring(0, 8) + '...' : devId}`);
|
|
75
76
|
// Track the mobile socket (only one active connection)
|
|
76
77
|
if (this.mobileSocket) {
|
|
77
78
|
console.log(`[Server] Replacing existing mobile connection`);
|
package/dist/websocket.js
CHANGED
|
@@ -158,7 +158,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
158
158
|
return;
|
|
159
159
|
// When mobile connects, emit connected + start heartbeat + initiate E2EE
|
|
160
160
|
this.server.on('mobile_connected', (data) => {
|
|
161
|
-
console.log(`[WS] Mobile connected: ${data.deviceId}
|
|
161
|
+
console.log(`[WS] Mobile connected: ${data.deviceId?.substring(0, 8)}...`);
|
|
162
162
|
this.emit('connected');
|
|
163
163
|
this.startHeartbeat();
|
|
164
164
|
// SECURITY: Clear stale E2EE peer state on every mobile connect.
|
|
@@ -167,7 +167,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
167
167
|
// that mobile can't decrypt, causing "No session established" errors.
|
|
168
168
|
// A fresh key exchange will re-establish the session after debounce.
|
|
169
169
|
if (this.e2eePeerDeviceId) {
|
|
170
|
-
console.log(`[E2EE] Clearing stale peer state
|
|
170
|
+
console.log(`[E2EE] Clearing stale peer state (mobile reconnected)`);
|
|
171
171
|
this.e2eeManager?.clearSession(this.e2eePeerDeviceId);
|
|
172
172
|
this.e2eePeerDeviceId = null;
|
|
173
173
|
}
|
|
@@ -188,7 +188,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
188
188
|
return;
|
|
189
189
|
try {
|
|
190
190
|
this._keyExchangePending = true;
|
|
191
|
-
console.log(`[E2EE] Initiating key exchange with ${targetId
|
|
191
|
+
console.log(`[E2EE] Initiating key exchange with ${targetId.substring(0, 8)}...`);
|
|
192
192
|
this.e2eeManager.clearSession(targetId);
|
|
193
193
|
const initPayload = this.e2eeManager.createKeyExchangeInit(targetId);
|
|
194
194
|
this.server?.emitToMobile('encrypted_key_exchange_init', {
|
|
@@ -249,7 +249,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
249
249
|
return;
|
|
250
250
|
}
|
|
251
251
|
try {
|
|
252
|
-
console.log(`[E2EE] Received key exchange init from ${data.senderDeviceId}
|
|
252
|
+
console.log(`[E2EE] Received key exchange init from ${data.senderDeviceId.substring(0, 8)}...`);
|
|
253
253
|
const ack = this.e2eeManager.handleKeyExchangeInit(data);
|
|
254
254
|
this.e2eePeerDeviceId = data.senderDeviceId;
|
|
255
255
|
this.server?.emitToMobile('encrypted_key_exchange_ack', {
|
|
@@ -270,11 +270,11 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
270
270
|
if (!this.e2eeManager)
|
|
271
271
|
return;
|
|
272
272
|
try {
|
|
273
|
-
console.log(`[E2EE] Received key exchange ack from ${data.senderDeviceId}
|
|
273
|
+
console.log(`[E2EE] Received key exchange ack from ${data.senderDeviceId.substring(0, 8)}...`);
|
|
274
274
|
this.e2eeManager.handleKeyExchangeAck(data);
|
|
275
275
|
this._keyExchangePending = false;
|
|
276
276
|
this.e2eePeerDeviceId = data.senderDeviceId;
|
|
277
|
-
console.log(`[E2EE] Key exchange complete — session established
|
|
277
|
+
console.log(`[E2EE] Key exchange complete — session established`);
|
|
278
278
|
this.emit('e2ee_established', { peerDeviceId: data.senderDeviceId });
|
|
279
279
|
this.flushSensitiveQueue();
|
|
280
280
|
this.sendAllUsageStats();
|
|
@@ -283,7 +283,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
283
283
|
const msg = err instanceof Error ? err.message : String(err);
|
|
284
284
|
// Only suppress if it's truly a duplicate ack (pending exchange already consumed)
|
|
285
285
|
if (msg.includes('No pending key exchange')) {
|
|
286
|
-
console.log(`[E2EE] Duplicate ack
|
|
286
|
+
console.log(`[E2EE] Duplicate ack — ignored`);
|
|
287
287
|
}
|
|
288
288
|
else {
|
|
289
289
|
console.error(`[E2EE] Key exchange ack failed: ${msg}`);
|
|
@@ -520,7 +520,7 @@ class WebSocketClient extends events_1.EventEmitter {
|
|
|
520
520
|
if (sessions.length > 0) {
|
|
521
521
|
const peer = this.e2eePeerDeviceId;
|
|
522
522
|
const hasE2EE = peer ? this.e2eeManager?.hasSessionKey(peer) : false;
|
|
523
|
-
console.log(`[WS] sendClaudeSessions: ${sessions.length} sessions,
|
|
523
|
+
console.log(`[WS] sendClaudeSessions: ${sessions.length} sessions, e2ee=${hasE2EE}`);
|
|
524
524
|
const withDeviceId = sessions.map(s => ({ ...s, deviceId: config_1.config.deviceId }));
|
|
525
525
|
this.emitSensitive('claude_session_batch_update', { sessions: withDeviceId }, peer ?? undefined);
|
|
526
526
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forkoff",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "CLI tool to connect
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "CLI tool to connect Claude Code to the ForkOff mobile app for remote monitoring and approvals",
|
|
5
5
|
"main": "dist/integration.js",
|
|
6
6
|
"types": "dist/integration.d.ts",
|
|
7
7
|
"homepage": "https://forkoff.app",
|
|
@@ -39,10 +39,15 @@
|
|
|
39
39
|
"coding",
|
|
40
40
|
"mobile",
|
|
41
41
|
"claude",
|
|
42
|
+
"claude-code",
|
|
42
43
|
"forkoff",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
44
|
+
"remote-control",
|
|
45
|
+
"developer-tools",
|
|
46
|
+
"websocket"
|
|
45
47
|
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
},
|
|
46
51
|
"author": {
|
|
47
52
|
"name": "ForkOff",
|
|
48
53
|
"email": "support@forkoff.app",
|
package/E2EE-COMPLETE.md
DELETED
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
# ✅ E2EE Implementation Complete
|
|
2
|
-
|
|
3
|
-
## 🎯 Final Status: PRODUCTION READY
|
|
4
|
-
|
|
5
|
-
**Total Test Coverage: 185 tests passing**
|
|
6
|
-
- Mobile (React Native): 90 tests ✅
|
|
7
|
-
- Backend (NestJS): 23 tests ✅
|
|
8
|
-
- CLI (Node.js): 72 tests ✅
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## 🔐 Security Features
|
|
13
|
-
|
|
14
|
-
### Encryption
|
|
15
|
-
- **Algorithm**: X25519 (ECDH) + AES-256-GCM
|
|
16
|
-
- **Key Exchange**: Ephemeral Diffie-Hellman with HKDF key derivation
|
|
17
|
-
- **Authentication**: Authenticated encryption with 16-byte auth tags
|
|
18
|
-
- **Forward Secrecy**: Each session uses unique ephemeral keys
|
|
19
|
-
|
|
20
|
-
### Security Properties
|
|
21
|
-
✅ End-to-end encryption (backend cannot decrypt)
|
|
22
|
-
✅ Perfect forward secrecy (ephemeral keys)
|
|
23
|
-
✅ Authenticated encryption (tamper detection)
|
|
24
|
-
✅ Replay protection (message counters)
|
|
25
|
-
✅ Key rotation support (version tracking)
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
## 🌐 Network Resilience (IP Change Handling)
|
|
30
|
-
|
|
31
|
-
### Problem
|
|
32
|
-
When a device's IP changes (WiFi → cellular, network switch), the WebSocket connection drops.
|
|
33
|
-
|
|
34
|
-
### Solution Implemented
|
|
35
|
-
|
|
36
|
-
#### 1. **Persistent Session Storage**
|
|
37
|
-
- Session keys stored to disk: `~/.forkoff-cli/sessions/`
|
|
38
|
-
- Survives process restarts and IP changes
|
|
39
|
-
- Automatic 24-hour expiration
|
|
40
|
-
|
|
41
|
-
#### 2. **Session Restoration API**
|
|
42
|
-
```typescript
|
|
43
|
-
// After reconnection, restore previous E2EE sessions
|
|
44
|
-
await e2eeManager.restorePersistedSession(targetDeviceId);
|
|
45
|
-
|
|
46
|
-
// List all devices with active sessions
|
|
47
|
-
const devices = e2eeManager.listPersistedDevices();
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
#### 3. **Automatic Reconnection Flow**
|
|
51
|
-
1. IP changes → WebSocket disconnects
|
|
52
|
-
2. WebSocket automatically reconnects (socket.io)
|
|
53
|
-
3. E2EE manager restores persisted sessions
|
|
54
|
-
4. Encrypted communication resumes seamlessly
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## 📁 File Structure
|
|
59
|
-
|
|
60
|
-
### CLI (`forkoff-cli/src/crypto/`)
|
|
61
|
-
```
|
|
62
|
-
crypto/
|
|
63
|
-
├── types.ts # Shared E2EE type definitions
|
|
64
|
-
├── keyGeneration.ts # X25519 key pair generation (8 tests)
|
|
65
|
-
├── keyStorage.ts # OS keychain + in-memory storage (10 tests)
|
|
66
|
-
├── encryption.ts # AES-256-GCM encrypt/decrypt (13 tests)
|
|
67
|
-
├── keyExchange.ts # ECDH + HKDF key derivation (9 tests)
|
|
68
|
-
├── e2eeManager.ts # Orchestration layer (12 tests)
|
|
69
|
-
├── sessionPersistence.ts # Disk-based session storage (NEW)
|
|
70
|
-
└── websocketE2EE.ts # WebSocket integration (11 tests)
|
|
71
|
-
|
|
72
|
-
__tests__/crypto/
|
|
73
|
-
├── keyGeneration.test.ts
|
|
74
|
-
├── keyStorage.test.ts
|
|
75
|
-
├── encryption.test.ts
|
|
76
|
-
├── keyExchange.test.ts
|
|
77
|
-
├── e2eeManager.test.ts
|
|
78
|
-
├── websocketIntegration.test.ts
|
|
79
|
-
└── e2e-integration.test.ts # End-to-end flow verification (9 tests)
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
### Backend (`forkoff-api/src/crypto/`)
|
|
83
|
-
```
|
|
84
|
-
crypto/
|
|
85
|
-
├── crypto.service.ts # Public key storage & retrieval (9 tests)
|
|
86
|
-
├── crypto.controller.ts # REST API endpoints (7 tests)
|
|
87
|
-
└── dto/
|
|
88
|
-
|
|
89
|
-
websocket/
|
|
90
|
-
└── websocket.gateway.ts # E2EE message forwarding (7 tests)
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### Mobile (`forkoff/services/crypto/`)
|
|
94
|
-
```
|
|
95
|
-
crypto/
|
|
96
|
-
├── keyGeneration.ts # Key pair generation
|
|
97
|
-
├── keyStorage.ts # Secure store + session keys
|
|
98
|
-
├── encryption.ts # AES-256-GCM encryption
|
|
99
|
-
├── keyExchange.ts # X25519 key exchange
|
|
100
|
-
└── e2eeManager.ts # E2EE orchestration
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## 🔄 How It Works
|
|
106
|
-
|
|
107
|
-
### Initial Setup
|
|
108
|
-
1. **Device registers**: Generates X25519 key pair, stores private key in OS keychain
|
|
109
|
-
2. **Upload public key**: Sends public key to backend API
|
|
110
|
-
3. **Backend stores**: Public key stored in PostgreSQL (Device table)
|
|
111
|
-
|
|
112
|
-
### Key Exchange Flow
|
|
113
|
-
```
|
|
114
|
-
Mobile Backend CLI
|
|
115
|
-
| | |
|
|
116
|
-
|--[init: ephemeral_pk]--->|---[forward]------------>|
|
|
117
|
-
| | |
|
|
118
|
-
|<-[ack: ephemeral_pk]-----|<--[forward]-------------|
|
|
119
|
-
| | |
|
|
120
|
-
Both sides derive shared secret using ECDH
|
|
121
|
-
Both sides derive session key using HKDF
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### Encrypted Messaging
|
|
125
|
-
```
|
|
126
|
-
Mobile Backend CLI
|
|
127
|
-
| | |
|
|
128
|
-
|--[encrypted_message]---->|---[forward]------------>|
|
|
129
|
-
| {ciphertext, nonce, | |
|
|
130
|
-
| authTag, counter} | |
|
|
131
|
-
| | |
|
|
132
|
-
|<-[encrypted_message]-----|<--[forward]-------------|
|
|
133
|
-
| | |
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Reconnection After IP Change
|
|
137
|
-
```
|
|
138
|
-
CLI Reconnects Backend Mobile
|
|
139
|
-
| | |
|
|
140
|
-
| WebSocket reconnects | |
|
|
141
|
-
|<--------------------------->| |
|
|
142
|
-
| | |
|
|
143
|
-
| Restore persisted sessions | |
|
|
144
|
-
| (load from disk) | |
|
|
145
|
-
| | |
|
|
146
|
-
|--[encrypted_message]------->|---[forward]---------->|
|
|
147
|
-
| Communication resumes! | |
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
---
|
|
151
|
-
|
|
152
|
-
## 🧪 Test Verification
|
|
153
|
-
|
|
154
|
-
### Unit Tests (Crypto Primitives)
|
|
155
|
-
- ✅ Key generation produces valid X25519 keys
|
|
156
|
-
- ✅ Keys are properly Base64-encoded
|
|
157
|
-
- ✅ Encryption/decryption round-trip works
|
|
158
|
-
- ✅ Unicode and emoji preserved
|
|
159
|
-
- ✅ Large messages (10KB+) supported
|
|
160
|
-
- ✅ ECDH produces matching shared secrets
|
|
161
|
-
- ✅ HKDF derives correct session keys
|
|
162
|
-
|
|
163
|
-
### Integration Tests (E2EE Flow)
|
|
164
|
-
- ✅ Full bidirectional encrypted communication
|
|
165
|
-
- ✅ Key exchange completes successfully
|
|
166
|
-
- ✅ Messages encrypted/decrypted correctly
|
|
167
|
-
- ✅ Replay attacks detected and rejected
|
|
168
|
-
- ✅ Tampered messages rejected
|
|
169
|
-
- ✅ Multiple concurrent sessions supported
|
|
170
|
-
- ✅ Session persistence across reconnections
|
|
171
|
-
|
|
172
|
-
### Security Tests
|
|
173
|
-
- ✅ Tampered ciphertext rejected
|
|
174
|
-
- ✅ Tampered nonce rejected
|
|
175
|
-
- ✅ Tampered auth tag rejected
|
|
176
|
-
- ✅ Wrong key cannot decrypt
|
|
177
|
-
- ✅ Message counters prevent replay attacks
|
|
178
|
-
|
|
179
|
-
---
|
|
180
|
-
|
|
181
|
-
## 🚀 Usage Example
|
|
182
|
-
|
|
183
|
-
### Initialize E2EE
|
|
184
|
-
```typescript
|
|
185
|
-
import { E2EEManager } from './crypto/e2eeManager';
|
|
186
|
-
import { WebSocketE2EEIntegration } from './crypto/websocketE2EE';
|
|
187
|
-
|
|
188
|
-
// Initialize E2EE manager
|
|
189
|
-
const e2ee = new E2EEManager(deviceId, apiUrl, authToken);
|
|
190
|
-
await e2ee.initialize();
|
|
191
|
-
|
|
192
|
-
// Integrate with WebSocket
|
|
193
|
-
const wsIntegration = new WebSocketE2EEIntegration(wsClient);
|
|
194
|
-
await wsIntegration.initialize(deviceId, apiUrl, authToken);
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
### Send Encrypted Message
|
|
198
|
-
```typescript
|
|
199
|
-
// Initiate key exchange (if not already done)
|
|
200
|
-
if (!e2ee.hasSessionKey(targetDeviceId)) {
|
|
201
|
-
await wsIntegration.initiateKeyExchange(targetDeviceId);
|
|
202
|
-
// Wait for key exchange to complete...
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Send encrypted message
|
|
206
|
-
wsIntegration.sendEncryptedMessage(
|
|
207
|
-
'Secret message',
|
|
208
|
-
targetDeviceId,
|
|
209
|
-
sessionId
|
|
210
|
-
);
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
### Handle Reconnection After IP Change
|
|
214
|
-
```typescript
|
|
215
|
-
wsClient.on('reconnect', async () => {
|
|
216
|
-
// Restore all persisted sessions
|
|
217
|
-
const devices = e2ee.listPersistedDevices();
|
|
218
|
-
|
|
219
|
-
for (const deviceId of devices) {
|
|
220
|
-
const restored = await e2ee.restorePersistedSession(deviceId);
|
|
221
|
-
if (restored) {
|
|
222
|
-
console.log(`Restored E2EE session with ${deviceId}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
## 📊 Performance Characteristics
|
|
231
|
-
|
|
232
|
-
- **Key Generation**: ~50ms (X25519)
|
|
233
|
-
- **Encryption**: <5ms (AES-256-GCM, typical message)
|
|
234
|
-
- **Decryption**: <5ms (AES-256-GCM, typical message)
|
|
235
|
-
- **Key Exchange**: ~100ms (includes API calls)
|
|
236
|
-
- **Session Restoration**: <10ms (load from disk)
|
|
237
|
-
|
|
238
|
-
---
|
|
239
|
-
|
|
240
|
-
## 🔧 Configuration
|
|
241
|
-
|
|
242
|
-
### Session Expiration
|
|
243
|
-
Sessions automatically expire after 24 hours. Configurable in `sessionPersistence.ts`:
|
|
244
|
-
```typescript
|
|
245
|
-
const hoursSinceCreation = (now.getTime() - timestamp.getTime()) / (1000 * 60 * 60);
|
|
246
|
-
if (hoursSinceCreation > 24) { // Change this value
|
|
247
|
-
// Session expired
|
|
248
|
-
}
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
### Storage Location
|
|
252
|
-
Sessions stored at: `~/.forkoff-cli/sessions/`
|
|
253
|
-
|
|
254
|
-
Private keys stored in OS keychain via `keytar`
|
|
255
|
-
|
|
256
|
-
---
|
|
257
|
-
|
|
258
|
-
## 🎓 What We Learned
|
|
259
|
-
|
|
260
|
-
1. **TDD Works**: Writing tests first caught numerous edge cases
|
|
261
|
-
2. **Message Counters Matter**: Replay protection is crucial
|
|
262
|
-
3. **Persistence Is Hard**: Mocking file system for tests is complex
|
|
263
|
-
4. **Network Resilience**: IP changes are common, plan for them
|
|
264
|
-
5. **Security Trade-offs**: Perfect forward secrecy vs. session resumption
|
|
265
|
-
|
|
266
|
-
---
|
|
267
|
-
|
|
268
|
-
## ✨ Future Enhancements (Not Implemented)
|
|
269
|
-
|
|
270
|
-
- [ ] Double Ratchet Algorithm (Signal Protocol)
|
|
271
|
-
- [ ] Group messaging encryption
|
|
272
|
-
- [ ] Key rotation on schedule
|
|
273
|
-
- [ ] Post-quantum cryptography (Kyber)
|
|
274
|
-
- [ ] Hardware security module integration
|
|
275
|
-
- [ ] Encrypted file transfers
|
|
276
|
-
|
|
277
|
-
---
|
|
278
|
-
|
|
279
|
-
## 📖 References
|
|
280
|
-
|
|
281
|
-
- X25519: RFC 7748
|
|
282
|
-
- AES-GCM: NIST SP 800-38D
|
|
283
|
-
- HKDF: RFC 5869
|
|
284
|
-
- Signal Protocol: https://signal.org/docs/
|
|
285
|
-
|
|
286
|
-
---
|
|
287
|
-
|
|
288
|
-
**Built with ❤️ using Test-Driven Development**
|
|
289
|
-
|
|
290
|
-
**Status**: ✅ PRODUCTION READY - All 185 tests passing
|
package/eas.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"cli": {
|
|
3
|
-
"version": ">= 16.26.0",
|
|
4
|
-
"appVersionSource": "remote"
|
|
5
|
-
},
|
|
6
|
-
"build": {
|
|
7
|
-
"development": {
|
|
8
|
-
"developmentClient": true,
|
|
9
|
-
"distribution": "internal"
|
|
10
|
-
},
|
|
11
|
-
"preview": {
|
|
12
|
-
"distribution": "internal"
|
|
13
|
-
},
|
|
14
|
-
"production": {
|
|
15
|
-
"autoIncrement": true
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"submit": {
|
|
19
|
-
"production": {}
|
|
20
|
-
}
|
|
21
|
-
}
|