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
|
@@ -4,267 +4,342 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.E2EEManager = void 0;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* E2EE Manager for CLI
|
|
9
|
+
* Orchestrates key generation, storage, exchange, and message encryption/decryption.
|
|
10
|
+
* Uses NaCl (tweetnacl) — compatible with mobile app's implementation.
|
|
11
|
+
*
|
|
12
|
+
* Security features:
|
|
13
|
+
* - X25519 ECDH key exchange for shared secret derivation
|
|
14
|
+
* - Ed25519 identity signatures on ephemeral keys (MITM protection)
|
|
15
|
+
* - TOFU (Trust On First Use) for peer identity verification
|
|
16
|
+
* - XSalsa20-Poly1305 authenticated encryption
|
|
17
|
+
* - Per-peer monotonic message counters (replay protection)
|
|
18
|
+
*/
|
|
19
|
+
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
20
|
+
const tweetnacl_util_1 = require("tweetnacl-util");
|
|
8
21
|
const keyGeneration_1 = require("./keyGeneration");
|
|
9
|
-
const keyStorage_1 = require("./keyStorage");
|
|
10
22
|
const keyExchange_1 = require("./keyExchange");
|
|
11
23
|
const encryption_1 = require("./encryption");
|
|
24
|
+
const keyStorage_1 = require("./keyStorage");
|
|
12
25
|
const sessionPersistence_1 = require("./sessionPersistence");
|
|
13
|
-
/**
|
|
14
|
-
* E2EE Manager for CLI
|
|
15
|
-
* Orchestrates all end-to-end encryption operations
|
|
16
|
-
*/
|
|
17
26
|
class E2EEManager {
|
|
18
|
-
constructor(deviceId
|
|
27
|
+
constructor(deviceId) {
|
|
19
28
|
this.keyPair = null;
|
|
29
|
+
this.signingKeyPair = null;
|
|
20
30
|
this.initialized = false;
|
|
21
|
-
//
|
|
22
|
-
this.
|
|
23
|
-
|
|
24
|
-
this.outgoingCounters = new Map(); // deviceId -> counter
|
|
25
|
-
this.incomingCounters = new Map(); // deviceId -> last seen counter
|
|
31
|
+
// In-memory active sessions (parallel to keyStorage for fast access)
|
|
32
|
+
this.sessions = new Map();
|
|
33
|
+
this.pendingExchanges = new Map();
|
|
26
34
|
this.deviceId = deviceId;
|
|
27
|
-
this.apiUrl = apiUrl;
|
|
28
|
-
this.authToken = authToken;
|
|
29
|
-
this.axiosInstance = axios_1.default.create({
|
|
30
|
-
headers: {
|
|
31
|
-
Authorization: `Bearer ${authToken}`,
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
|
-
* Initializes E2EE manager
|
|
37
|
-
* - Loads or generates key pair
|
|
38
|
-
* - Uploads public key to backend
|
|
37
|
+
* Initializes E2EE manager: loads or generates identity key pairs (DH + signing).
|
|
39
38
|
*/
|
|
40
39
|
async initialize() {
|
|
41
|
-
//
|
|
40
|
+
// Load or generate X25519 DH key pair
|
|
42
41
|
const existingPrivateKey = await (0, keyStorage_1.getPrivateKey)(this.deviceId);
|
|
43
42
|
if (existingPrivateKey) {
|
|
44
|
-
|
|
45
|
-
const
|
|
43
|
+
const secretKey = (0, tweetnacl_util_1.decodeBase64)(existingPrivateKey);
|
|
44
|
+
const kp = tweetnacl_1.default.box.keyPair.fromSecretKey(secretKey);
|
|
46
45
|
this.keyPair = {
|
|
47
|
-
publicKey,
|
|
46
|
+
publicKey: (0, tweetnacl_util_1.encodeBase64)(kp.publicKey),
|
|
48
47
|
privateKey: existingPrivateKey,
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
else {
|
|
52
|
-
// Generate new key pair
|
|
53
51
|
this.keyPair = (0, keyGeneration_1.generateKeyPair)();
|
|
54
52
|
await (0, keyStorage_1.storePrivateKey)(this.deviceId, this.keyPair.privateKey);
|
|
55
53
|
}
|
|
56
|
-
//
|
|
57
|
-
await this.
|
|
54
|
+
// Load or generate Ed25519 signing key pair
|
|
55
|
+
const existingSigningKP = await (0, keyStorage_1.getSigningKeyPair)(this.deviceId);
|
|
56
|
+
if (existingSigningKP) {
|
|
57
|
+
this.signingKeyPair = existingSigningKP;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const signKP = tweetnacl_1.default.sign.keyPair();
|
|
61
|
+
this.signingKeyPair = {
|
|
62
|
+
publicKey: (0, tweetnacl_util_1.encodeBase64)(signKP.publicKey),
|
|
63
|
+
secretKey: (0, tweetnacl_util_1.encodeBase64)(signKP.secretKey),
|
|
64
|
+
};
|
|
65
|
+
await (0, keyStorage_1.storeSigningKeyPair)(this.deviceId, this.signingKeyPair);
|
|
66
|
+
}
|
|
67
|
+
// Load trusted peer keys from disk
|
|
68
|
+
(0, keyStorage_1.loadTrustedPeerKeys)();
|
|
69
|
+
// Initialize session persistence encryption (derives key from identity private key)
|
|
70
|
+
(0, sessionPersistence_1.initSessionPersistence)(this.keyPair.privateKey);
|
|
58
71
|
this.initialized = true;
|
|
59
72
|
}
|
|
73
|
+
/** Get the public key (for uploading to server) */
|
|
74
|
+
getPublicKey() {
|
|
75
|
+
return this.keyPair?.publicKey ?? null;
|
|
76
|
+
}
|
|
77
|
+
/** Get the signing public key */
|
|
78
|
+
getSigningPublicKey() {
|
|
79
|
+
return this.signingKeyPair?.publicKey ?? null;
|
|
80
|
+
}
|
|
81
|
+
/** Check if manager is initialized */
|
|
82
|
+
isInitialized() {
|
|
83
|
+
return this.initialized;
|
|
84
|
+
}
|
|
60
85
|
/**
|
|
61
|
-
*
|
|
86
|
+
* Sign a key exchange payload with our Ed25519 identity key.
|
|
87
|
+
* The signed message is: "prefix:senderDeviceId:ephemeralPublicKey[:recipientDeviceId]"
|
|
62
88
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
privateKeyBytes,
|
|
74
|
-
]),
|
|
75
|
-
format: 'der',
|
|
76
|
-
type: 'pkcs8',
|
|
77
|
-
});
|
|
78
|
-
// Derive public key
|
|
79
|
-
const publicKeyObject = crypto.createPublicKey(privateKeyObject);
|
|
80
|
-
const publicKeyDER = publicKeyObject.export({
|
|
81
|
-
type: 'spki',
|
|
82
|
-
format: 'der',
|
|
83
|
-
});
|
|
84
|
-
// Extract raw 32-byte public key
|
|
85
|
-
const rawPublicKey = publicKeyDER.slice(-32);
|
|
86
|
-
return rawPublicKey.toString('base64');
|
|
89
|
+
signPayload(prefix, ephemeralPublicKey, recipientDeviceId) {
|
|
90
|
+
if (!this.signingKeyPair)
|
|
91
|
+
return undefined;
|
|
92
|
+
const parts = [prefix, this.deviceId, ephemeralPublicKey];
|
|
93
|
+
if (recipientDeviceId)
|
|
94
|
+
parts.push(recipientDeviceId);
|
|
95
|
+
const message = (0, tweetnacl_util_1.decodeUTF8)(parts.join(':'));
|
|
96
|
+
const secretKey = (0, tweetnacl_util_1.decodeBase64)(this.signingKeyPair.secretKey);
|
|
97
|
+
const signature = tweetnacl_1.default.sign.detached(message, secretKey);
|
|
98
|
+
return (0, tweetnacl_util_1.encodeBase64)(signature);
|
|
87
99
|
}
|
|
88
100
|
/**
|
|
89
|
-
*
|
|
101
|
+
* Verify a peer's signature on a key exchange payload.
|
|
102
|
+
* Returns true if signature is valid OR if peer has no identity key (unsigned exchange accepted with warning).
|
|
103
|
+
* Throws if the peer's identity key doesn't match TOFU record (potential MITM).
|
|
90
104
|
*/
|
|
91
|
-
|
|
92
|
-
if (!
|
|
93
|
-
throw new Error(
|
|
105
|
+
verifyPeerSignature(peerId, identityPublicKey, signature, ephemeralPublicKey, prefix, recipientDeviceId) {
|
|
106
|
+
if (!identityPublicKey || !signature) {
|
|
107
|
+
throw new Error(`E2EE: Peer ${peerId} sent UNSIGNED key exchange. ` +
|
|
108
|
+
`Identity verification is required. Update the peer to the latest version.`);
|
|
109
|
+
}
|
|
110
|
+
// TOFU: check if we already trust a different key for this peer
|
|
111
|
+
const trusted = (0, keyStorage_1.getTrustedPeerKey)(peerId);
|
|
112
|
+
if (trusted && trusted !== identityPublicKey) {
|
|
113
|
+
throw new Error(`E2EE: IDENTITY KEY MISMATCH for device ${peerId}! ` +
|
|
114
|
+
`Expected ${trusted.substring(0, 8)}... but got ${identityPublicKey.substring(0, 8)}... ` +
|
|
115
|
+
`This could indicate a man-in-the-middle attack. Key exchange rejected.`);
|
|
116
|
+
}
|
|
117
|
+
// Verify the Ed25519 signature
|
|
118
|
+
const parts = [prefix, peerId, ephemeralPublicKey];
|
|
119
|
+
if (recipientDeviceId)
|
|
120
|
+
parts.push(recipientDeviceId);
|
|
121
|
+
const msgString = parts.join(':');
|
|
122
|
+
const message = (0, tweetnacl_util_1.decodeUTF8)(msgString);
|
|
123
|
+
const sigBytes = (0, tweetnacl_util_1.decodeBase64)(signature);
|
|
124
|
+
const pubKeyBytes = (0, tweetnacl_util_1.decodeBase64)(identityPublicKey);
|
|
125
|
+
const valid = tweetnacl_1.default.sign.detached.verify(message, sigBytes, pubKeyBytes);
|
|
126
|
+
if (!valid) {
|
|
127
|
+
throw new Error(`E2EE: INVALID SIGNATURE from device ${peerId}! ` +
|
|
128
|
+
`The ephemeral key was not properly signed. Key exchange rejected.`);
|
|
129
|
+
}
|
|
130
|
+
// TOFU: trust this key if it's new
|
|
131
|
+
if (!trusted) {
|
|
132
|
+
(0, keyStorage_1.trustPeerKey)(peerId, identityPublicKey);
|
|
94
133
|
}
|
|
95
|
-
await this.axiosInstance.put(`${this.apiUrl}/devices/${this.deviceId}/public-key`, { publicKey: this.keyPair.publicKey });
|
|
96
134
|
}
|
|
97
135
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
136
|
+
* Create a key exchange initiation to send to a remote device.
|
|
137
|
+
* Generates an ephemeral key pair and signs it with our identity key.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Evict expired or excess pending key exchanges.
|
|
100
141
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
cleanupPendingExchanges() {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
for (const [deviceId, entry] of this.pendingExchanges) {
|
|
145
|
+
if (now - entry.createdAt > E2EEManager.PENDING_EXCHANGE_TTL_MS) {
|
|
146
|
+
this.pendingExchanges.delete(deviceId);
|
|
147
|
+
// Evicted expired pending exchange
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
while (this.pendingExchanges.size >= E2EEManager.MAX_PENDING_EXCHANGES) {
|
|
151
|
+
const oldestKey = this.pendingExchanges.keys().next().value;
|
|
152
|
+
if (oldestKey) {
|
|
153
|
+
this.pendingExchanges.delete(oldestKey);
|
|
154
|
+
// MAX_PENDING_EXCHANGES reached, evicted oldest
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
break;
|
|
104
158
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const targetPublicKey = response.data.publicKey;
|
|
112
|
-
// Compute session key using our ephemeral private key and their public key
|
|
113
|
-
const sessionKey = (0, keyExchange_1.performKeyExchange)(ephemeralKeyPair.privateKey, targetPublicKey);
|
|
114
|
-
// Store session key (in memory and on disk for reconnection resilience)
|
|
115
|
-
const sessionId = `session-${this.deviceId}-${targetDeviceId}-${Date.now()}`;
|
|
116
|
-
const sessionKeys = { encryptionKey: sessionKey, sessionId };
|
|
117
|
-
(0, keyStorage_1.storeSessionKey)(targetDeviceId, sessionKey, sessionId);
|
|
118
|
-
(0, sessionPersistence_1.persistSessionKey)(this.deviceId, targetDeviceId, sessionKeys); // Persist to disk
|
|
119
|
-
// Initialize counters
|
|
120
|
-
this.outgoingCounters.set(targetDeviceId, 0);
|
|
121
|
-
this.incomingCounters.set(targetDeviceId, -1); // Start at -1 so first message (counter: 0) is accepted
|
|
159
|
+
}
|
|
160
|
+
createKeyExchangeInit(targetDeviceId) {
|
|
161
|
+
this.cleanupPendingExchanges();
|
|
162
|
+
const ephemeral = (0, keyGeneration_1.generateKeyPair)();
|
|
163
|
+
this.pendingExchanges.set(targetDeviceId, { keyPair: ephemeral, createdAt: Date.now() });
|
|
164
|
+
const signature = this.signPayload('KEY_EXCHANGE_INIT', ephemeral.publicKey);
|
|
122
165
|
return {
|
|
123
166
|
senderDeviceId: this.deviceId,
|
|
124
|
-
ephemeralPublicKey:
|
|
167
|
+
ephemeralPublicKey: ephemeral.publicKey,
|
|
168
|
+
identityPublicKey: this.signingKeyPair?.publicKey,
|
|
169
|
+
signature,
|
|
125
170
|
};
|
|
126
171
|
}
|
|
127
172
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
173
|
+
* Handle an incoming key exchange init from a remote device.
|
|
174
|
+
* Verifies the peer's identity signature (TOFU), computes shared key,
|
|
175
|
+
* and returns a signed ack.
|
|
130
176
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
(0,
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
handleKeyExchangeInit(init) {
|
|
178
|
+
// Verify peer's signature (TOFU)
|
|
179
|
+
this.verifyPeerSignature(init.senderDeviceId, init.identityPublicKey, init.signature, init.ephemeralPublicKey, 'KEY_EXCHANGE_INIT');
|
|
180
|
+
const ephemeral = (0, keyGeneration_1.generateKeyPair)();
|
|
181
|
+
const sharedKey = (0, keyExchange_1.computeSharedKey)(ephemeral.privateKey, init.ephemeralPublicKey);
|
|
182
|
+
// Store session
|
|
183
|
+
const sessionId = `session-${init.senderDeviceId}-${this.deviceId}-${Date.now()}`;
|
|
184
|
+
this.sessions.set(init.senderDeviceId, {
|
|
185
|
+
sharedKey,
|
|
186
|
+
outgoingCounter: 0,
|
|
187
|
+
lastReceivedCounter: -1,
|
|
188
|
+
});
|
|
189
|
+
(0, keyStorage_1.storeSessionKey)(init.senderDeviceId, sharedKey, sessionId);
|
|
190
|
+
// Persist to disk for reconnection resilience
|
|
191
|
+
const sessionKeys = {
|
|
192
|
+
sharedKey,
|
|
193
|
+
sessionId,
|
|
194
|
+
deviceId: init.senderDeviceId,
|
|
195
|
+
messageCounter: 0,
|
|
196
|
+
lastReceivedCounter: -1,
|
|
197
|
+
};
|
|
198
|
+
(0, sessionPersistence_1.persistSessionKey)(this.deviceId, init.senderDeviceId, sessionKeys);
|
|
199
|
+
// E2EE session established
|
|
200
|
+
// Sign our ack
|
|
201
|
+
const signature = this.signPayload('KEY_EXCHANGE_ACK', ephemeral.publicKey, init.senderDeviceId);
|
|
147
202
|
return {
|
|
148
|
-
|
|
149
|
-
|
|
203
|
+
senderDeviceId: this.deviceId,
|
|
204
|
+
recipientDeviceId: init.senderDeviceId,
|
|
205
|
+
ephemeralPublicKey: ephemeral.publicKey,
|
|
206
|
+
identityPublicKey: this.signingKeyPair?.publicKey,
|
|
207
|
+
signature,
|
|
150
208
|
};
|
|
151
209
|
}
|
|
152
210
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
211
|
+
* Handle an incoming key exchange ack from a remote device.
|
|
212
|
+
* Verifies the peer's identity signature (TOFU) and completes the key exchange.
|
|
155
213
|
*/
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
214
|
+
handleKeyExchangeAck(ack) {
|
|
215
|
+
const peerId = ack.senderDeviceId;
|
|
216
|
+
const pendingEntry = this.pendingExchanges.get(peerId);
|
|
217
|
+
if (!pendingEntry) {
|
|
218
|
+
throw new Error(`E2EE: No pending key exchange for device ${peerId}`);
|
|
160
219
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
220
|
+
const pending = pendingEntry.keyPair;
|
|
221
|
+
// Verify peer's signature (TOFU)
|
|
222
|
+
this.verifyPeerSignature(peerId, ack.identityPublicKey, ack.signature, ack.ephemeralPublicKey, 'KEY_EXCHANGE_ACK', ack.recipientDeviceId);
|
|
223
|
+
const sharedKey = (0, keyExchange_1.computeSharedKey)(pending.privateKey, ack.ephemeralPublicKey);
|
|
224
|
+
// Store session
|
|
225
|
+
const sessionId = `session-${this.deviceId}-${peerId}-${Date.now()}`;
|
|
226
|
+
this.sessions.set(peerId, {
|
|
227
|
+
sharedKey,
|
|
228
|
+
outgoingCounter: 0,
|
|
229
|
+
lastReceivedCounter: -1,
|
|
230
|
+
});
|
|
231
|
+
(0, keyStorage_1.storeSessionKey)(peerId, sharedKey, sessionId);
|
|
232
|
+
// Persist to disk
|
|
233
|
+
const sessionKeys = {
|
|
234
|
+
sharedKey,
|
|
235
|
+
sessionId,
|
|
236
|
+
deviceId: peerId,
|
|
237
|
+
messageCounter: 0,
|
|
238
|
+
lastReceivedCounter: -1,
|
|
239
|
+
};
|
|
240
|
+
(0, sessionPersistence_1.persistSessionKey)(this.deviceId, peerId, sessionKeys);
|
|
241
|
+
this.pendingExchanges.delete(peerId);
|
|
242
|
+
// E2EE session established
|
|
170
243
|
}
|
|
171
244
|
/**
|
|
172
|
-
* Attempts to restore a persisted session after reconnection
|
|
173
|
-
* Useful when IP changes cause WebSocket disconnection
|
|
245
|
+
* Attempts to restore a persisted session after reconnection.
|
|
174
246
|
*/
|
|
175
247
|
async restorePersistedSession(targetDeviceId) {
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
248
|
+
const persisted = (0, sessionPersistence_1.loadPersistedSessionKey)(this.deviceId, targetDeviceId);
|
|
249
|
+
if (!persisted) {
|
|
178
250
|
return false;
|
|
179
251
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
252
|
+
this.sessions.set(targetDeviceId, {
|
|
253
|
+
sharedKey: persisted.sharedKey,
|
|
254
|
+
outgoingCounter: persisted.messageCounter,
|
|
255
|
+
lastReceivedCounter: persisted.lastReceivedCounter,
|
|
256
|
+
});
|
|
257
|
+
(0, keyStorage_1.storeSessionKey)(targetDeviceId, persisted.sharedKey, persisted.sessionId);
|
|
258
|
+
// Restored persisted E2EE session
|
|
186
259
|
return true;
|
|
187
260
|
}
|
|
188
|
-
/**
|
|
189
|
-
* Lists all devices with persisted sessions
|
|
190
|
-
* Useful for auto-reconnection after network changes
|
|
191
|
-
*/
|
|
261
|
+
/** Lists all devices with persisted sessions */
|
|
192
262
|
listPersistedDevices() {
|
|
193
263
|
return (0, sessionPersistence_1.listPersistedSessions)(this.deviceId);
|
|
194
264
|
}
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
265
|
+
/** Check if an encrypted session is established with a device */
|
|
266
|
+
hasSessionKey(deviceId) {
|
|
267
|
+
return this.sessions.has(deviceId);
|
|
268
|
+
}
|
|
269
|
+
/** Encrypt a message for a specific device */
|
|
270
|
+
encryptMessage(plaintext, recipientDeviceId, sessionId) {
|
|
271
|
+
const session = this.sessions.get(recipientDeviceId);
|
|
272
|
+
if (!session) {
|
|
273
|
+
throw new Error(`E2EE: No session established with device ${recipientDeviceId}`);
|
|
202
274
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Get and increment counter
|
|
206
|
-
const counter = this.outgoingCounters.get(targetDeviceId) ?? 0;
|
|
207
|
-
this.outgoingCounters.set(targetDeviceId, counter + 1);
|
|
275
|
+
const payload = (0, encryption_1.encrypt)(plaintext, session.sharedKey);
|
|
276
|
+
session.outgoingCounter++;
|
|
208
277
|
return {
|
|
209
278
|
senderDeviceId: this.deviceId,
|
|
210
|
-
recipientDeviceId
|
|
279
|
+
recipientDeviceId,
|
|
211
280
|
sessionId,
|
|
212
|
-
payload
|
|
213
|
-
messageCounter:
|
|
281
|
+
payload,
|
|
282
|
+
messageCounter: session.outgoingCounter,
|
|
214
283
|
timestamp: new Date().toISOString(),
|
|
215
284
|
};
|
|
216
285
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (!sessionKeys) {
|
|
223
|
-
throw new Error(`No session key found for device ${senderDeviceId}. Key exchange not completed.`);
|
|
286
|
+
/** Decrypt an incoming encrypted message */
|
|
287
|
+
decryptMessage(message, senderDeviceId) {
|
|
288
|
+
const session = this.sessions.get(senderDeviceId);
|
|
289
|
+
if (!session) {
|
|
290
|
+
throw new Error(`E2EE: No session established with device ${senderDeviceId}`);
|
|
224
291
|
}
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
292
|
+
// SECURITY: Validate counter is a positive finite integer within safe bounds
|
|
293
|
+
if (typeof message.messageCounter !== 'number' ||
|
|
294
|
+
!Number.isFinite(message.messageCounter) ||
|
|
295
|
+
!Number.isInteger(message.messageCounter) ||
|
|
296
|
+
message.messageCounter < 1 ||
|
|
297
|
+
message.messageCounter > Number.MAX_SAFE_INTEGER - 1) {
|
|
298
|
+
throw new Error('E2EE: Invalid message counter value');
|
|
229
299
|
}
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
300
|
+
// Replay protection
|
|
301
|
+
if (message.messageCounter <= session.lastReceivedCounter) {
|
|
302
|
+
throw new Error('E2EE: Replay attack detected - message counter too low');
|
|
303
|
+
}
|
|
304
|
+
const plaintext = (0, encryption_1.decrypt)(message.payload, session.sharedKey);
|
|
305
|
+
session.lastReceivedCounter = message.messageCounter;
|
|
234
306
|
return plaintext;
|
|
235
307
|
}
|
|
236
|
-
/**
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return (0, keyStorage_1.getSessionKey)(deviceId) !== null;
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* Checks if manager is initialized
|
|
244
|
-
*/
|
|
245
|
-
isInitialized() {
|
|
246
|
-
return this.initialized;
|
|
308
|
+
/** Clear session for a specific device */
|
|
309
|
+
clearSession(deviceId) {
|
|
310
|
+
this.sessions.delete(deviceId);
|
|
311
|
+
this.pendingExchanges.delete(deviceId);
|
|
247
312
|
}
|
|
248
313
|
/**
|
|
249
|
-
* Cleans up all session keys and pending exchanges
|
|
250
|
-
* @param deletePersisted - Whether to delete persisted sessions from disk
|
|
314
|
+
* Cleans up all session keys and pending exchanges.
|
|
315
|
+
* @param deletePersisted - Whether to delete persisted sessions from disk
|
|
251
316
|
*/
|
|
252
317
|
cleanup(deletePersisted = false) {
|
|
253
318
|
(0, keyStorage_1.clearSessionKeys)();
|
|
254
|
-
this.
|
|
255
|
-
this.
|
|
256
|
-
this.incomingCounters.clear();
|
|
257
|
-
// Optionally delete persisted sessions
|
|
319
|
+
this.sessions.clear();
|
|
320
|
+
this.pendingExchanges.clear();
|
|
258
321
|
if (deletePersisted) {
|
|
259
322
|
(0, sessionPersistence_1.deleteAllPersistedSessions)(this.deviceId);
|
|
260
323
|
}
|
|
261
324
|
}
|
|
262
|
-
/**
|
|
263
|
-
* Removes a specific persisted session
|
|
264
|
-
*/
|
|
325
|
+
/** Removes a specific persisted session */
|
|
265
326
|
removePersistedSession(targetDeviceId) {
|
|
266
327
|
(0, sessionPersistence_1.deletePersistedSession)(this.deviceId, targetDeviceId);
|
|
267
328
|
}
|
|
329
|
+
/** Reset TOFU trust for a peer (used on re-pair so new keys are accepted) */
|
|
330
|
+
resetPeerTrust(targetDeviceId) {
|
|
331
|
+
(0, keyStorage_1.removeTrustedPeerKey)(targetDeviceId);
|
|
332
|
+
this.sessions.delete(targetDeviceId);
|
|
333
|
+
this.pendingExchanges.delete(targetDeviceId);
|
|
334
|
+
(0, sessionPersistence_1.deletePersistedSession)(this.deviceId, targetDeviceId);
|
|
335
|
+
}
|
|
336
|
+
/** Light trust reset — only clears TOFU key, preserves pending exchanges in-flight */
|
|
337
|
+
clearTrustOnly(targetDeviceId) {
|
|
338
|
+
(0, keyStorage_1.removeTrustedPeerKey)(targetDeviceId);
|
|
339
|
+
}
|
|
268
340
|
}
|
|
269
341
|
exports.E2EEManager = E2EEManager;
|
|
342
|
+
// Ephemeral keys for pending key exchanges (with creation timestamp for TTL)
|
|
343
|
+
E2EEManager.MAX_PENDING_EXCHANGES = 20;
|
|
344
|
+
E2EEManager.PENDING_EXCHANGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
270
345
|
//# sourceMappingURL=e2eeManager.js.map
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import { EncryptedPayload } from './types';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* @param
|
|
6
|
-
* @
|
|
7
|
-
* @returns EncryptedPayload with Base64-encoded ciphertext, nonce, and authTag
|
|
3
|
+
* Encrypt a plaintext string using NaCl secretbox.
|
|
4
|
+
* @param plaintext - The message to encrypt
|
|
5
|
+
* @param key - 32-byte shared secret key
|
|
6
|
+
* @returns EncryptedPayload with Base64-encoded ciphertext and nonce
|
|
8
7
|
*/
|
|
9
8
|
export declare function encrypt(plaintext: string, key: Uint8Array): EncryptedPayload;
|
|
10
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* @param
|
|
14
|
-
* @
|
|
15
|
-
* @returns Decrypted plaintext
|
|
10
|
+
* Decrypt an EncryptedPayload back to plaintext.
|
|
11
|
+
* @param payload - The encrypted payload (ciphertext + nonce)
|
|
12
|
+
* @param key - 32-byte shared secret key (must match encryption key)
|
|
13
|
+
* @returns Decrypted plaintext string
|
|
16
14
|
* @throws Error if decryption fails (wrong key, tampered data, etc.)
|
|
17
15
|
*/
|
|
18
16
|
export declare function decrypt(payload: EncryptedPayload, key: Uint8Array): string;
|