@zeph-to/mcp-server 1.15.2 → 2.0.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 -7
- package/dist/api-client.d.ts +1 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/crypto.d.ts +65 -42
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +169 -143
- 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 +8 -3
- package/dist/mime.d.ts.map +1 -1
- package/dist/mime.js +20 -0
- package/dist/tools/ask.d.ts.map +1 -1
- package/dist/tools/ask.js +12 -22
- package/dist/tools/file.d.ts.map +1 -1
- package/dist/tools/file.js +105 -38
- package/dist/tools/notify.d.ts.map +1 -1
- package/dist/tools/notify.js +44 -40
- package/dist/types.d.ts +7 -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,56 +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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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;
|
|
95
105
|
};
|
|
96
|
-
// ─── Key persistence
|
|
106
|
+
// ─── Key persistence ───
|
|
97
107
|
const KEYS_DIR = (0, path_1.join)(process.env.XDG_CONFIG_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.config'), 'zeph');
|
|
98
|
-
|
|
99
|
-
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 = () => {
|
|
100
112
|
try {
|
|
101
|
-
|
|
113
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(DEVICE_KEYS_PATH, 'utf-8'));
|
|
114
|
+
return parsed.publicKey && parsed.privateKey ? parsed : null;
|
|
102
115
|
}
|
|
103
116
|
catch {
|
|
104
117
|
return null;
|
|
105
118
|
}
|
|
106
119
|
};
|
|
107
|
-
const
|
|
120
|
+
const storeDeviceKeys = (exported) => {
|
|
108
121
|
(0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
109
|
-
(0, fs_1.writeFileSync)(
|
|
122
|
+
(0, fs_1.writeFileSync)(DEVICE_KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
|
|
110
123
|
};
|
|
111
|
-
const
|
|
124
|
+
const deleteLegacyKeys = () => {
|
|
112
125
|
try {
|
|
113
|
-
(0, fs_1.unlinkSync)(
|
|
126
|
+
(0, fs_1.unlinkSync)(LEGACY_KEYS_PATH);
|
|
114
127
|
}
|
|
115
128
|
catch { /* not present — fine */ }
|
|
116
129
|
};
|
|
@@ -121,38 +134,32 @@ const envIsTrue = (key) => {
|
|
|
121
134
|
// ─── Cached state ───
|
|
122
135
|
let cachedKeyPair = null;
|
|
123
136
|
let cachedExportedPublicKey = null;
|
|
124
|
-
let
|
|
137
|
+
let cachedLegacyPublicKey = null;
|
|
125
138
|
let initPromise = null;
|
|
126
139
|
/**
|
|
127
140
|
* Initialize crypto.
|
|
128
141
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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.
|
|
133
146
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* anywhere" path; combined with a transient fetch failure, that silently
|
|
138
|
-
* turned encryption on without user consent and locked the account into
|
|
139
|
-
* 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.
|
|
140
150
|
*
|
|
141
151
|
* Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
|
|
142
|
-
* server state
|
|
143
|
-
* never want encryption.
|
|
152
|
+
* server state.
|
|
144
153
|
*
|
|
145
|
-
* Safe to call concurrently — deduplicates to single init.
|
|
146
|
-
* Returns
|
|
154
|
+
* Safe to call concurrently — deduplicates to a single init.
|
|
155
|
+
* Returns this host's public key when encryption is active, '' otherwise.
|
|
147
156
|
*
|
|
148
157
|
* NOTE: when `apiKey` is provided, `baseUrl` is required.
|
|
149
158
|
*/
|
|
150
159
|
const initCrypto = (apiKey, baseUrl) => {
|
|
151
160
|
// Hard opt-out — skip everything, leave cache empty.
|
|
152
161
|
if (envIsTrue('ZEPH_DISABLE_ENCRYPTION')) {
|
|
153
|
-
|
|
154
|
-
cachedExportedPublicKey = null;
|
|
155
|
-
cachedOwnPublicKey = null;
|
|
162
|
+
(0, exports.disableCrypto)();
|
|
156
163
|
return Promise.resolve('');
|
|
157
164
|
}
|
|
158
165
|
if (apiKey && !baseUrl) {
|
|
@@ -161,49 +168,43 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
161
168
|
}
|
|
162
169
|
if (initPromise)
|
|
163
170
|
return initPromise;
|
|
164
|
-
const baseUrlRequired = apiKey ? baseUrl : baseUrl;
|
|
165
171
|
initPromise = (async () => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
cachedOwnPublicKey = null;
|
|
175
|
-
// If the server is reachable and explicitly says encryption is off,
|
|
176
|
-
// drop any stale local cache so a future regression can't resurrect
|
|
177
|
-
// a keypair that the user already disabled.
|
|
178
|
-
if (serverResult && !serverResult.encryptionEnabled) {
|
|
179
|
-
deleteStoredKeys();
|
|
180
|
-
}
|
|
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)();
|
|
181
180
|
return '';
|
|
182
181
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
storeKeys(keys);
|
|
187
|
-
}
|
|
188
|
-
cachedKeyPair = await importKeyPair(keys);
|
|
189
|
-
cachedExportedPublicKey = keys.publicKey;
|
|
190
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
191
|
-
return keys.publicKey;
|
|
182
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
183
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
184
|
+
return stored.publicKey;
|
|
192
185
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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();
|
|
201
194
|
return '';
|
|
202
195
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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;
|
|
207
208
|
})().catch((err) => {
|
|
208
209
|
initPromise = null;
|
|
209
210
|
throw err;
|
|
@@ -211,7 +212,7 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
211
212
|
return initPromise;
|
|
212
213
|
};
|
|
213
214
|
exports.initCrypto = initCrypto;
|
|
214
|
-
const
|
|
215
|
+
const fetchEncryptionState = async (apiKey, baseUrl) => {
|
|
215
216
|
try {
|
|
216
217
|
const url = `${baseUrl.replace(/\/$/, '')}/users/me/keys`;
|
|
217
218
|
// Bounded: index.ts awaits initCrypto before connecting the MCP
|
|
@@ -223,20 +224,29 @@ const fetchServerKeys = async (apiKey, baseUrl) => {
|
|
|
223
224
|
if (!res.ok)
|
|
224
225
|
return null;
|
|
225
226
|
const json = await res.json();
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
keys: keys?.publicKey && keys?.privateKey ? keys : null,
|
|
230
|
-
encryptionEnabled,
|
|
227
|
+
const state = {
|
|
228
|
+
encryptionEnabled: json.data?.encryptionEnabled === true,
|
|
229
|
+
legacyPublicKey: json.data?.encryptionKeys?.publicKey ?? null,
|
|
231
230
|
};
|
|
231
|
+
cachedLegacyPublicKey = state.legacyPublicKey;
|
|
232
|
+
return state;
|
|
232
233
|
}
|
|
233
234
|
catch {
|
|
234
235
|
return null;
|
|
235
236
|
}
|
|
236
237
|
};
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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;
|
|
240
250
|
const getKeyPair = () => cachedKeyPair;
|
|
241
251
|
exports.getKeyPair = getKeyPair;
|
|
242
252
|
const getPublicKey = () => cachedExportedPublicKey;
|
|
@@ -253,35 +263,51 @@ exports.getPublicKey = getPublicKey;
|
|
|
253
263
|
const disableCrypto = () => {
|
|
254
264
|
cachedKeyPair = null;
|
|
255
265
|
cachedExportedPublicKey = null;
|
|
256
|
-
cachedOwnPublicKey = null;
|
|
257
266
|
};
|
|
258
267
|
exports.disableCrypto = disableCrypto;
|
|
259
268
|
/**
|
|
260
|
-
* 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.
|
|
261
274
|
*/
|
|
262
|
-
const
|
|
263
|
-
if (!cachedKeyPair || !cachedExportedPublicKey
|
|
275
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
276
|
+
if (!cachedKeyPair || !cachedExportedPublicKey)
|
|
264
277
|
throw new Error('Crypto not initialized');
|
|
265
|
-
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);
|
|
266
282
|
return {
|
|
267
|
-
body: JSON.stringify({ ciphertext:
|
|
268
|
-
|
|
283
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
284
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, cachedKeyPair.privateKey, recipients),
|
|
269
285
|
senderPublicKey: cachedExportedPublicKey,
|
|
270
286
|
isEncrypted: true,
|
|
271
287
|
};
|
|
272
288
|
};
|
|
273
|
-
exports.
|
|
289
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
274
290
|
/**
|
|
275
|
-
* Encrypt file content for
|
|
291
|
+
* Encrypt file content for the given recipient devices.
|
|
276
292
|
*/
|
|
277
|
-
const
|
|
278
|
-
if (!cachedKeyPair
|
|
293
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
294
|
+
if (!cachedKeyPair)
|
|
279
295
|
throw new Error('Crypto not initialized');
|
|
280
|
-
|
|
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);
|
|
281
307
|
return {
|
|
282
|
-
ciphertext:
|
|
283
|
-
iv:
|
|
284
|
-
|
|
308
|
+
ciphertext: Buffer.from(ciphertext),
|
|
309
|
+
iv: toBase64(iv.buffer),
|
|
310
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, cachedKeyPair.privateKey, recipients),
|
|
285
311
|
};
|
|
286
312
|
};
|
|
287
|
-
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
|
@@ -49,7 +49,7 @@ const createServer = (config) => {
|
|
|
49
49
|
'- zeph_dismiss: Mark a push as read',
|
|
50
50
|
'- zeph_dismiss_all: Clear all notifications',
|
|
51
51
|
'- zeph_broadcast: Send to all subscribers of a channel',
|
|
52
|
-
'- zeph_file: Send a
|
|
52
|
+
'- zeph_file: Send a file — pass filePath for anything on disk (images, PDFs, logs), or content for generated text',
|
|
53
53
|
'- zeph_prompt: Ask user to choose from options (requires ZEPH_HOOK_ID)',
|
|
54
54
|
'- zeph_input: Request text input from user (requires ZEPH_HOOK_ID)',
|
|
55
55
|
'- zeph_ask: Ask user with buttons + text input combined (requires ZEPH_HOOK_ID). Prefer this over zeph_prompt/zeph_input when you need both options and free-text.',
|
|
@@ -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);
|
package/dist/mime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../src/mime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../src/mime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsCH,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,MAGhD,CAAC"}
|
package/dist/mime.js
CHANGED
|
@@ -22,6 +22,26 @@ const EXT_TO_MIME = {
|
|
|
22
22
|
js: 'text/javascript',
|
|
23
23
|
py: 'text/x-python',
|
|
24
24
|
sh: 'text/x-shellscript',
|
|
25
|
+
// Keep this image set in sync with IMAGE_EXTENSIONS in the Zeph client
|
|
26
|
+
// (`libs/shared/src/utils/file.ts`) — an extension the client calls an image
|
|
27
|
+
// but this map doesn't will be labelled text/plain and decoded as UTF-8 on
|
|
28
|
+
// open, which corrupts it. Separate repos, so nothing enforces the match.
|
|
29
|
+
png: 'image/png',
|
|
30
|
+
jpg: 'image/jpeg',
|
|
31
|
+
jpeg: 'image/jpeg',
|
|
32
|
+
gif: 'image/gif',
|
|
33
|
+
webp: 'image/webp',
|
|
34
|
+
heic: 'image/heic',
|
|
35
|
+
heif: 'image/heif',
|
|
36
|
+
bmp: 'image/bmp',
|
|
37
|
+
avif: 'image/avif',
|
|
38
|
+
ico: 'image/x-icon',
|
|
39
|
+
tiff: 'image/tiff',
|
|
40
|
+
tif: 'image/tiff',
|
|
41
|
+
// No svg entry on purpose: an SVG is a script-bearing document, and the
|
|
42
|
+
// clients open decrypted attachments from a same-origin blob URL. It falls
|
|
43
|
+
// through to text/plain, which the text viewer handles fine.
|
|
44
|
+
pdf: 'application/pdf',
|
|
25
45
|
};
|
|
26
46
|
const inferMimeType = (fileName) => {
|
|
27
47
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
package/dist/tools/ask.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAuBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAyH7H,CAAC"}
|