@zeph-to/mcp-server 1.16.0 → 2.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 +20 -3
- package/dist/api-client.d.ts +7 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +16 -0
- package/dist/crypto.d.ts +65 -42
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +169 -149
- package/dist/e2e-fallback.d.ts +18 -5
- package/dist/e2e-fallback.d.ts.map +1 -1
- package/dist/e2e-fallback.js +38 -11
- package/dist/index.js +7 -2
- package/dist/response-files.d.ts +36 -0
- package/dist/response-files.d.ts.map +1 -0
- package/dist/response-files.js +75 -0
- package/dist/tools/ask.d.ts.map +1 -1
- package/dist/tools/ask.js +21 -25
- package/dist/tools/file.d.ts.map +1 -1
- package/dist/tools/file.js +45 -33
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +8 -2
- package/dist/tools/notify.d.ts.map +1 -1
- package/dist/tools/notify.js +44 -40
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/crypto.js
CHANGED
|
@@ -1,35 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Per-device encryption for the MCP server — self-contained ECDH P-256 +
|
|
4
4
|
* AES-256-GCM. Mirrors @zeph/crypto API but bundled inline (no external
|
|
5
5
|
* dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
|
|
6
6
|
* (the `crypto` global only exists unflagged from Node 19, so we import it).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* How it works (ADR-0007):
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* • Protection against a leaked DB snapshot taken without the key store
|
|
16
|
-
* • Cross-device readability (all your devices share one keypair)
|
|
17
|
-
* What it does NOT give you:
|
|
18
|
-
* • Protection against the Zeph backend itself
|
|
19
|
-
* • Forward secrecy — encryptPushBodyForSelf / encryptFileForSelf do
|
|
20
|
-
* ECDH(self, self), which collapses to a static derived key. A single
|
|
21
|
-
* device compromise (since all your devices share the same keypair)
|
|
22
|
-
* lets the attacker decrypt every past push for which they have the
|
|
23
|
-
* ciphertext. The per-message AES key is random, but its wrap key is
|
|
24
|
-
* static, so wrapped keys are decryptable forever.
|
|
10
|
+
* This process holds its own ECDH keypair. The private half is generated
|
|
11
|
+
* here, written to ~/.config/zeph/device-keys.json, and never leaves the
|
|
12
|
+
* host — the server only ever sees public keys. A push is encrypted once
|
|
13
|
+
* with a random AES key, and that key is wrapped separately for each of the
|
|
14
|
+
* user's registered devices using ECDH(this host, that device).
|
|
25
15
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
16
|
+
* That makes it end-to-end in the standard sense: the backend stores
|
|
17
|
+
* ciphertext plus wrapped keys it cannot unwrap.
|
|
18
|
+
*
|
|
19
|
+
* What it still does not give you:
|
|
20
|
+
* • Forward secrecy — the ECDH secret for a given (sender, device) pair is
|
|
21
|
+
* static, so a compromise of either private key retroactively opens every
|
|
22
|
+
* push wrapped for that pair. The per-message AES key is random; its wrap
|
|
23
|
+
* key is not.
|
|
24
|
+
* • Authenticity beyond the key pairing — nothing signs `senderPublicKey`,
|
|
25
|
+
* so a server that swapped it could make a push undecryptable, though not
|
|
26
|
+
* readable.
|
|
27
|
+
*
|
|
28
|
+
* Superseded scheme: a single account-wide keypair whose private half the
|
|
29
|
+
* backend escrowed so it could sync to new devices. Key escrow was removed
|
|
30
|
+
* server-side (zeph@8a6d21b), which left this client waiting for a private key
|
|
31
|
+
* the API stopped returning — encryption was silently off for months. Nothing
|
|
32
|
+
* here asks for that key any more.
|
|
30
33
|
*/
|
|
31
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
-
exports.
|
|
35
|
+
exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.initCrypto = void 0;
|
|
33
36
|
/// <reference lib="dom" />
|
|
34
37
|
const fs_1 = require("fs");
|
|
35
38
|
const os_1 = require("os");
|
|
@@ -49,8 +52,14 @@ const fromBase64 = (base64) => {
|
|
|
49
52
|
};
|
|
50
53
|
// ─── ECDH key management ───
|
|
51
54
|
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
const generateKeyPair = async () => crypto.subtle.generateKey(ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
|
|
56
|
+
const exportKeyPair = async (keyPair) => {
|
|
57
|
+
const [publicRaw, privateRaw] = await Promise.all([
|
|
58
|
+
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
59
|
+
crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
|
60
|
+
]);
|
|
61
|
+
return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
|
|
62
|
+
};
|
|
54
63
|
const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
|
|
55
64
|
const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
|
|
56
65
|
const importKeyPair = async (exported) => {
|
|
@@ -61,62 +70,60 @@ const importKeyPair = async (exported) => {
|
|
|
61
70
|
return { publicKey, privateKey };
|
|
62
71
|
};
|
|
63
72
|
const deriveAesKey = async (privateKey, publicKey) => crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
96
|
-
ciphertext: Buffer.from(ciphertext),
|
|
97
|
-
iv: toBase64(iv.buffer),
|
|
98
|
-
encryptedKey: toBase64(encryptedKey),
|
|
99
|
-
keyIv: toBase64(keyIv.buffer),
|
|
100
|
-
};
|
|
73
|
+
/**
|
|
74
|
+
* Wrap one raw AES key for every recipient device.
|
|
75
|
+
*
|
|
76
|
+
* The payload is encrypted once and only the 44-byte wrapped key repeats, so
|
|
77
|
+
* an attachment costs one S3 object regardless of how many devices the account
|
|
78
|
+
* has. A recipient whose public key will not import is dropped rather than
|
|
79
|
+
* failing the send — one broken device record must not silence every push.
|
|
80
|
+
*/
|
|
81
|
+
const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
|
|
82
|
+
const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
|
|
83
|
+
try {
|
|
84
|
+
const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
|
|
85
|
+
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
86
|
+
const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
|
|
87
|
+
return [
|
|
88
|
+
deviceId,
|
|
89
|
+
JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}));
|
|
97
|
+
const keyMap = {};
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry)
|
|
100
|
+
keyMap[entry[0]] = entry[1];
|
|
101
|
+
}
|
|
102
|
+
if (Object.keys(keyMap).length === 0)
|
|
103
|
+
throw new Error('No recipient device accepted the wrapped key');
|
|
104
|
+
return keyMap;
|
|
101
105
|
};
|
|
102
|
-
// ─── Key persistence
|
|
106
|
+
// ─── Key persistence ───
|
|
103
107
|
const KEYS_DIR = (0, path_1.join)(process.env.XDG_CONFIG_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.config'), 'zeph');
|
|
104
|
-
|
|
105
|
-
const
|
|
108
|
+
// Superseded account-wide keypair. Never written any more; only deleted.
|
|
109
|
+
const LEGACY_KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'keys.json');
|
|
110
|
+
const DEVICE_KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'device-keys.json');
|
|
111
|
+
const loadDeviceKeys = () => {
|
|
106
112
|
try {
|
|
107
|
-
|
|
113
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(DEVICE_KEYS_PATH, 'utf-8'));
|
|
114
|
+
return parsed.publicKey && parsed.privateKey ? parsed : null;
|
|
108
115
|
}
|
|
109
116
|
catch {
|
|
110
117
|
return null;
|
|
111
118
|
}
|
|
112
119
|
};
|
|
113
|
-
const
|
|
120
|
+
const storeDeviceKeys = (exported) => {
|
|
114
121
|
(0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
115
|
-
(0, fs_1.writeFileSync)(
|
|
122
|
+
(0, fs_1.writeFileSync)(DEVICE_KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
|
|
116
123
|
};
|
|
117
|
-
const
|
|
124
|
+
const deleteLegacyKeys = () => {
|
|
118
125
|
try {
|
|
119
|
-
(0, fs_1.unlinkSync)(
|
|
126
|
+
(0, fs_1.unlinkSync)(LEGACY_KEYS_PATH);
|
|
120
127
|
}
|
|
121
128
|
catch { /* not present — fine */ }
|
|
122
129
|
};
|
|
@@ -127,38 +134,32 @@ const envIsTrue = (key) => {
|
|
|
127
134
|
// ─── Cached state ───
|
|
128
135
|
let cachedKeyPair = null;
|
|
129
136
|
let cachedExportedPublicKey = null;
|
|
130
|
-
let
|
|
137
|
+
let cachedLegacyPublicKey = null;
|
|
131
138
|
let initPromise = null;
|
|
132
139
|
/**
|
|
133
140
|
* Initialize crypto.
|
|
134
141
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
142
|
+
* Encryption turns on only when the account has explicitly opted in —
|
|
143
|
+
* `encryptionEnabled` from `GET /users/me/keys` is the single authoritative
|
|
144
|
+
* signal (ADR-0008), set from the Zeph app. Server unreachable, flag off, or
|
|
145
|
+
* the hard opt-out below all leave the cache empty and every send plaintext.
|
|
139
146
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* anywhere" path; combined with a transient fetch failure, that silently
|
|
144
|
-
* turned encryption on without user consent and locked the account into
|
|
145
|
-
* an "encryption enabled" state on the server.
|
|
147
|
+
* When it is on, this host generates its own keypair on first use and keeps
|
|
148
|
+
* it. Unlike the superseded scheme this asks the server for nothing but the
|
|
149
|
+
* flag: the private key is created here and stays here.
|
|
146
150
|
*
|
|
147
151
|
* Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
|
|
148
|
-
* server state
|
|
149
|
-
* never want encryption.
|
|
152
|
+
* server state.
|
|
150
153
|
*
|
|
151
|
-
* Safe to call concurrently — deduplicates to single init.
|
|
152
|
-
* Returns
|
|
154
|
+
* Safe to call concurrently — deduplicates to a single init.
|
|
155
|
+
* Returns this host's public key when encryption is active, '' otherwise.
|
|
153
156
|
*
|
|
154
157
|
* NOTE: when `apiKey` is provided, `baseUrl` is required.
|
|
155
158
|
*/
|
|
156
159
|
const initCrypto = (apiKey, baseUrl) => {
|
|
157
160
|
// Hard opt-out — skip everything, leave cache empty.
|
|
158
161
|
if (envIsTrue('ZEPH_DISABLE_ENCRYPTION')) {
|
|
159
|
-
|
|
160
|
-
cachedExportedPublicKey = null;
|
|
161
|
-
cachedOwnPublicKey = null;
|
|
162
|
+
(0, exports.disableCrypto)();
|
|
162
163
|
return Promise.resolve('');
|
|
163
164
|
}
|
|
164
165
|
if (apiKey && !baseUrl) {
|
|
@@ -167,49 +168,43 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
167
168
|
}
|
|
168
169
|
if (initPromise)
|
|
169
170
|
return initPromise;
|
|
170
|
-
const baseUrlRequired = apiKey ? baseUrl : baseUrl;
|
|
171
171
|
initPromise = (async () => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
cachedOwnPublicKey = null;
|
|
181
|
-
// If the server is reachable and explicitly says encryption is off,
|
|
182
|
-
// drop any stale local cache so a future regression can't resurrect
|
|
183
|
-
// a keypair that the user already disabled.
|
|
184
|
-
if (serverResult && !serverResult.encryptionEnabled) {
|
|
185
|
-
deleteStoredKeys();
|
|
186
|
-
}
|
|
172
|
+
// Local-only mode (no apiKey): used by tests and offline setups. There is
|
|
173
|
+
// no flag to consult, so an existing device keypair is adopted and a
|
|
174
|
+
// missing one is not created — generating here would encrypt without any
|
|
175
|
+
// signal that the user asked for it.
|
|
176
|
+
if (!apiKey) {
|
|
177
|
+
const stored = loadDeviceKeys();
|
|
178
|
+
if (!stored) {
|
|
179
|
+
(0, exports.disableCrypto)();
|
|
187
180
|
return '';
|
|
188
181
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
storeKeys(keys);
|
|
193
|
-
}
|
|
194
|
-
cachedKeyPair = await importKeyPair(keys);
|
|
195
|
-
cachedExportedPublicKey = keys.publicKey;
|
|
196
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
197
|
-
return keys.publicKey;
|
|
182
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
183
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
184
|
+
return stored.publicKey;
|
|
198
185
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
186
|
+
const serverResult = await fetchEncryptionState(apiKey, baseUrl);
|
|
187
|
+
if (!serverResult?.encryptionEnabled) {
|
|
188
|
+
(0, exports.disableCrypto)();
|
|
189
|
+
// The account says encryption is off. Drop the escrowed account keypair
|
|
190
|
+
// if an old build left one on disk — it holds a private key this process
|
|
191
|
+
// has no use for and the server no longer accepts.
|
|
192
|
+
if (serverResult)
|
|
193
|
+
deleteLegacyKeys();
|
|
207
194
|
return '';
|
|
208
195
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
196
|
+
const stored = loadDeviceKeys();
|
|
197
|
+
if (stored) {
|
|
198
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
199
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
200
|
+
return stored.publicKey;
|
|
201
|
+
}
|
|
202
|
+
const keyPair = await generateKeyPair();
|
|
203
|
+
const exported = await exportKeyPair(keyPair);
|
|
204
|
+
storeDeviceKeys(exported);
|
|
205
|
+
cachedKeyPair = keyPair;
|
|
206
|
+
cachedExportedPublicKey = exported.publicKey;
|
|
207
|
+
return exported.publicKey;
|
|
213
208
|
})().catch((err) => {
|
|
214
209
|
initPromise = null;
|
|
215
210
|
throw err;
|
|
@@ -217,7 +212,7 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
217
212
|
return initPromise;
|
|
218
213
|
};
|
|
219
214
|
exports.initCrypto = initCrypto;
|
|
220
|
-
const
|
|
215
|
+
const fetchEncryptionState = async (apiKey, baseUrl) => {
|
|
221
216
|
try {
|
|
222
217
|
const url = `${baseUrl.replace(/\/$/, '')}/users/me/keys`;
|
|
223
218
|
// Bounded: index.ts awaits initCrypto before connecting the MCP
|
|
@@ -229,20 +224,29 @@ const fetchServerKeys = async (apiKey, baseUrl) => {
|
|
|
229
224
|
if (!res.ok)
|
|
230
225
|
return null;
|
|
231
226
|
const json = await res.json();
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
keys: keys?.publicKey && keys?.privateKey ? keys : null,
|
|
236
|
-
encryptionEnabled,
|
|
227
|
+
const state = {
|
|
228
|
+
encryptionEnabled: json.data?.encryptionEnabled === true,
|
|
229
|
+
legacyPublicKey: json.data?.encryptionKeys?.publicKey ?? null,
|
|
237
230
|
};
|
|
231
|
+
cachedLegacyPublicKey = state.legacyPublicKey;
|
|
232
|
+
return state;
|
|
238
233
|
}
|
|
239
234
|
catch {
|
|
240
235
|
return null;
|
|
241
236
|
}
|
|
242
237
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Keep only devices this host can actually encrypt for.
|
|
240
|
+
*
|
|
241
|
+
* A device without a public key has never run a build that registers one, and
|
|
242
|
+
* a device still advertising the account-wide key has not migrated to
|
|
243
|
+
* per-device E2E — wrapping for either produces a push it cannot open, which
|
|
244
|
+
* is worse than sending plaintext it can read.
|
|
245
|
+
*/
|
|
246
|
+
const selectRecipients = (devices) => devices
|
|
247
|
+
.filter((d) => !!d.publicKey && d.publicKey !== cachedLegacyPublicKey)
|
|
248
|
+
.map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
|
|
249
|
+
exports.selectRecipients = selectRecipients;
|
|
246
250
|
const getKeyPair = () => cachedKeyPair;
|
|
247
251
|
exports.getKeyPair = getKeyPair;
|
|
248
252
|
const getPublicKey = () => cachedExportedPublicKey;
|
|
@@ -259,35 +263,51 @@ exports.getPublicKey = getPublicKey;
|
|
|
259
263
|
const disableCrypto = () => {
|
|
260
264
|
cachedKeyPair = null;
|
|
261
265
|
cachedExportedPublicKey = null;
|
|
262
|
-
cachedOwnPublicKey = null;
|
|
263
266
|
};
|
|
264
267
|
exports.disableCrypto = disableCrypto;
|
|
265
268
|
/**
|
|
266
|
-
* Encrypt push body for
|
|
269
|
+
* Encrypt a push body for the given recipient devices.
|
|
270
|
+
*
|
|
271
|
+
* Returns the wire fields the API expects: `body` carries the ciphertext and
|
|
272
|
+
* IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
|
|
273
|
+
* recipients need to derive the same secret back.
|
|
267
274
|
*/
|
|
268
|
-
const
|
|
269
|
-
if (!cachedKeyPair || !cachedExportedPublicKey
|
|
275
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
276
|
+
if (!cachedKeyPair || !cachedExportedPublicKey)
|
|
270
277
|
throw new Error('Crypto not initialized');
|
|
271
|
-
const
|
|
278
|
+
const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
279
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
280
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(JSON.stringify({ title: input.title, body: input.body, url: input.url })));
|
|
281
|
+
const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
|
|
272
282
|
return {
|
|
273
|
-
body: JSON.stringify({ ciphertext:
|
|
274
|
-
|
|
283
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
284
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, cachedKeyPair.privateKey, recipients),
|
|
275
285
|
senderPublicKey: cachedExportedPublicKey,
|
|
276
286
|
isEncrypted: true,
|
|
277
287
|
};
|
|
278
288
|
};
|
|
279
|
-
exports.
|
|
289
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
280
290
|
/**
|
|
281
|
-
* Encrypt file content for
|
|
291
|
+
* Encrypt file content for the given recipient devices.
|
|
282
292
|
*/
|
|
283
|
-
const
|
|
284
|
-
if (!cachedKeyPair
|
|
293
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
294
|
+
if (!cachedKeyPair)
|
|
285
295
|
throw new Error('Crypto not initialized');
|
|
286
|
-
|
|
296
|
+
// Binary attachments arrive as a Buffer and must be encrypted byte for byte —
|
|
297
|
+
// running them through TextEncoder would UTF-8 mangle every non-ASCII byte.
|
|
298
|
+
// Both branches are views; subtle.encrypt honours byteOffset/byteLength, so a
|
|
299
|
+
// Buffer carved out of Node's pool encrypts only its own bytes.
|
|
300
|
+
const buffer = typeof content === 'string'
|
|
301
|
+
? new TextEncoder().encode(content)
|
|
302
|
+
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
|
303
|
+
const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
304
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
305
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
|
|
306
|
+
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
287
307
|
return {
|
|
288
|
-
ciphertext:
|
|
289
|
-
iv:
|
|
290
|
-
|
|
308
|
+
ciphertext: Buffer.from(ciphertext),
|
|
309
|
+
iv: toBase64(iv.buffer),
|
|
310
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, cachedKeyPair.privateKey, recipients),
|
|
291
311
|
};
|
|
292
312
|
};
|
|
293
|
-
exports.
|
|
313
|
+
exports.encryptFileForDevices = encryptFileForDevices;
|
package/dist/e2e-fallback.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import { type ZephApiClient } from './api-client.js';
|
|
2
|
+
import { type DeviceRecipient } from './crypto.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve who a push can be encrypted for, or null when it cannot be.
|
|
5
|
+
*
|
|
6
|
+
* The device list is fetched per send rather than cached: a phone that
|
|
7
|
+
* registered its key a minute ago must be able to read the next push, and a
|
|
8
|
+
* long-lived MCP process would otherwise keep wrapping for a stale set.
|
|
9
|
+
* A failure here is not fatal — plaintext the user can read beats a
|
|
10
|
+
* notification that never arrives.
|
|
11
|
+
*/
|
|
12
|
+
export declare const resolveRecipients: (client: ZephApiClient) => Promise<DeviceRecipient[] | null>;
|
|
1
13
|
/**
|
|
2
14
|
* Run a send, and repeat it unencrypted if the server says E2E needs Pro.
|
|
3
15
|
*
|
|
@@ -9,10 +21,11 @@
|
|
|
9
21
|
* retry rebuilds the whole payload (a file re-uploads as plaintext instead of
|
|
10
22
|
* leaving an undecryptable blob in S3).
|
|
11
23
|
*
|
|
12
|
-
* `send` receives
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
24
|
+
* `send` receives the recipient devices, or null when the push must go out in
|
|
25
|
+
* the clear, and must be safe to run twice — the encrypted first upload is
|
|
26
|
+
* left orphaned in S3, which is the accepted cost of not shipping an
|
|
27
|
+
* unreadable attachment. The retry is not itself retried: a second
|
|
28
|
+
* `PRO_REQUIRED` propagates.
|
|
16
29
|
*/
|
|
17
|
-
export declare const withPlaintextFallback: <T>(send: (
|
|
30
|
+
export declare const withPlaintextFallback: <T>(client: ZephApiClient, send: (recipients: DeviceRecipient[] | null) => Promise<T>) => Promise<T>;
|
|
18
31
|
//# sourceMappingURL=e2e-fallback.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"e2e-fallback.d.ts","sourceRoot":"","sources":["../src/e2e-fallback.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"e2e-fallback.d.ts","sourceRoot":"","sources":["../src/e2e-fallback.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAA6D,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9G;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAAU,QAAQ,aAAa,KAAG,OAAO,CAAC,eAAe,EAAE,GAAG,IAAI,CAa/F,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,qBAAqB,GAAU,CAAC,EAC3C,QAAQ,aAAa,EACrB,MAAM,CAAC,UAAU,EAAE,eAAe,EAAE,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KACzD,OAAO,CAAC,CAAC,CAYX,CAAC"}
|
package/dist/e2e-fallback.js
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.withPlaintextFallback = void 0;
|
|
3
|
+
exports.withPlaintextFallback = exports.resolveRecipients = void 0;
|
|
4
4
|
const api_client_js_1 = require("./api-client.js");
|
|
5
5
|
const crypto_js_1 = require("./crypto.js");
|
|
6
|
+
/**
|
|
7
|
+
* Resolve who a push can be encrypted for, or null when it cannot be.
|
|
8
|
+
*
|
|
9
|
+
* The device list is fetched per send rather than cached: a phone that
|
|
10
|
+
* registered its key a minute ago must be able to read the next push, and a
|
|
11
|
+
* long-lived MCP process would otherwise keep wrapping for a stale set.
|
|
12
|
+
* A failure here is not fatal — plaintext the user can read beats a
|
|
13
|
+
* notification that never arrives.
|
|
14
|
+
*/
|
|
15
|
+
const resolveRecipients = async (client) => {
|
|
16
|
+
if (!(0, crypto_js_1.getKeyPair)() || !(0, crypto_js_1.getPublicKey)())
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const recipients = (0, crypto_js_1.selectRecipients)((await client.listDevices()).data);
|
|
20
|
+
if (recipients.length === 0) {
|
|
21
|
+
console.error('[Crypto] No device has a per-device public key — sending plaintext.');
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return recipients;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
console.error('[Crypto] Could not list devices, sending plaintext:', err);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
exports.resolveRecipients = resolveRecipients;
|
|
6
32
|
/**
|
|
7
33
|
* Run a send, and repeat it unencrypted if the server says E2E needs Pro.
|
|
8
34
|
*
|
|
@@ -14,24 +40,25 @@ const crypto_js_1 = require("./crypto.js");
|
|
|
14
40
|
* retry rebuilds the whole payload (a file re-uploads as plaintext instead of
|
|
15
41
|
* leaving an undecryptable blob in S3).
|
|
16
42
|
*
|
|
17
|
-
* `send` receives
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
43
|
+
* `send` receives the recipient devices, or null when the push must go out in
|
|
44
|
+
* the clear, and must be safe to run twice — the encrypted first upload is
|
|
45
|
+
* left orphaned in S3, which is the accepted cost of not shipping an
|
|
46
|
+
* unreadable attachment. The retry is not itself retried: a second
|
|
47
|
+
* `PRO_REQUIRED` propagates.
|
|
21
48
|
*/
|
|
22
|
-
const withPlaintextFallback = async (send) => {
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
25
|
-
return send(
|
|
49
|
+
const withPlaintextFallback = async (client, send) => {
|
|
50
|
+
const recipients = await (0, exports.resolveRecipients)(client);
|
|
51
|
+
if (!recipients)
|
|
52
|
+
return send(null);
|
|
26
53
|
try {
|
|
27
|
-
return await send(
|
|
54
|
+
return await send(recipients);
|
|
28
55
|
}
|
|
29
56
|
catch (err) {
|
|
30
57
|
if (!(err instanceof api_client_js_1.ApiError) || err.code !== 'PRO_REQUIRED')
|
|
31
58
|
throw err;
|
|
32
59
|
(0, crypto_js_1.disableCrypto)();
|
|
33
60
|
console.error('[Crypto] End-to-end encryption requires Zeph Pro — resending as plaintext.');
|
|
34
|
-
return send(
|
|
61
|
+
return send(null);
|
|
35
62
|
}
|
|
36
63
|
};
|
|
37
64
|
exports.withPlaintextFallback = withPlaintextFallback;
|
package/dist/index.js
CHANGED
|
@@ -77,10 +77,15 @@ const createServer = (config) => {
|
|
|
77
77
|
};
|
|
78
78
|
const main = async () => {
|
|
79
79
|
const config = (0, config_js_1.loadConfig)();
|
|
80
|
-
//
|
|
80
|
+
// Load or create this host's keypair, if the account has opted in. Runs once
|
|
81
|
+
// per process and caches, so toggling E2E in the app while this server is
|
|
82
|
+
// running has no effect until it restarts.
|
|
81
83
|
try {
|
|
82
84
|
const publicKey = await (0, crypto_js_1.initCrypto)(config.apiKey, config.baseUrl);
|
|
83
|
-
|
|
85
|
+
if (publicKey)
|
|
86
|
+
console.error(`[Crypto] E2E encryption ready for zeph_notify / zeph_file (publicKey: ${publicKey.slice(0, 20)}...) — zeph_ask stays plaintext`);
|
|
87
|
+
else
|
|
88
|
+
console.error('[Crypto] E2E encryption off — enable it in the Zeph app, then restart this server.');
|
|
84
89
|
}
|
|
85
90
|
catch (err) {
|
|
86
91
|
console.error('[Crypto] E2E encryption unavailable:', err);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving the files a user attached to a hook answer.
|
|
3
|
+
*
|
|
4
|
+
* `zeph_ask` and `zeph_input` hand the agent a JSON result, and an agent
|
|
5
|
+
* cannot look at an S3 key — so an answered screenshot only becomes useful
|
|
6
|
+
* once it is a path on this machine. This module downloads each attachment
|
|
7
|
+
* to `~/.zeph/attachments/hook-<eventId>/` and returns the absolute paths,
|
|
8
|
+
* which the tool then names in its result for the agent to read.
|
|
9
|
+
*
|
|
10
|
+
* The files are plaintext by contract: the hook route carries no sender key,
|
|
11
|
+
* so the server rejects an encrypted attachment on a response rather than
|
|
12
|
+
* let one arrive here as bytes nothing can open.
|
|
13
|
+
*
|
|
14
|
+
* Failure is per file, never fatal. A question that was answered has been
|
|
15
|
+
* answered; losing one image must not turn that into a tool error and make
|
|
16
|
+
* the user answer again.
|
|
17
|
+
*/
|
|
18
|
+
import type { ZephApiClient } from './api-client.js';
|
|
19
|
+
import type { AttachedFile } from './types.js';
|
|
20
|
+
/**
|
|
21
|
+
* The part of a tool result that tells the agent about saved attachments.
|
|
22
|
+
* Spread into the result object; empty when nothing was attached, so a plain
|
|
23
|
+
* answer keeps the shape it has always had.
|
|
24
|
+
*
|
|
25
|
+
* The instruction is explicit because a bare list of paths in a JSON result
|
|
26
|
+
* reads as metadata — the agent has to be told these are the user's answer
|
|
27
|
+
* and that opening them is part of reading it.
|
|
28
|
+
*/
|
|
29
|
+
export declare const attachmentNote: (paths: string[]) => Record<string, unknown>;
|
|
30
|
+
export interface SaveResponseFilesDeps {
|
|
31
|
+
/** Test seam for the two-step fetch (metadata → presigned URL → bytes). */
|
|
32
|
+
fetchBytes?: (fileKey: string) => Promise<Uint8Array>;
|
|
33
|
+
dir?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const saveResponseFiles: (client: ZephApiClient, eventId: string, files: AttachedFile[] | undefined, deps?: SaveResponseFilesDeps) => Promise<string[]>;
|
|
36
|
+
//# sourceMappingURL=response-files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-files.d.ts","sourceRoot":"","sources":["../src/response-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAmB/C;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,EAAE,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAM/D,CAAC;AAET,MAAM,WAAW,qBAAqB;IACpC,2EAA2E;IAC3E,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,aAAa,EACrB,SAAS,MAAM,EACf,OAAO,YAAY,EAAE,GAAG,SAAS,EACjC,OAAM,qBAA0B,KAC/B,OAAO,CAAC,MAAM,EAAE,CAiBlB,CAAC"}
|