@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/dist/crypto.js CHANGED
@@ -1,35 +1,38 @@
1
1
  "use strict";
2
2
  /**
3
- * Device-shared encryption for MCP server — self-contained ECDH P-256 +
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
- * Threat model honesty (do not call this "E2E" without a footnote):
8
+ * How it works (ADR-0007):
9
9
  *
10
- * The Zeph backend persists the per-user private key in plaintext so it
11
- * can be synced down to a fresh device (fetchServerKeys / uploadServerKeys
12
- * below). That means the backend can decrypt any push body this is NOT
13
- * end-to-end in the standard sense. What it gives you is:
14
- * Protection against passive network observers
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
- * True E2E would require a per-device keypair (server stores only public
27
- * keys; senders wrap the message key once per recipient device public
28
- * key). That refactor is on the roadmap; until then, treat push bodies as
29
- * sensitive-but-not-secret.
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.encryptFileForSelf = exports.encryptPushBodyForSelf = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.initCrypto = void 0;
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
- // generateKeyPair / exportKeyPair were removed in fix/no-auto-encryption.
53
- // This module imports keys only; it never creates or exports them.
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
- const encrypt = async (plaintext, senderPrivateKey, recipientPublicKey) => {
65
- const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
66
- const iv = crypto.getRandomValues(new Uint8Array(12));
67
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(plaintext));
68
- const sharedKey = await deriveAesKey(senderPrivateKey, recipientPublicKey);
69
- const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
70
- const keyIv = crypto.getRandomValues(new Uint8Array(12));
71
- const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawMessageKey);
72
- return {
73
- ciphertext: toBase64(ciphertext),
74
- iv: toBase64(iv.buffer),
75
- encryptedKey: toBase64(encryptedKey),
76
- keyIv: toBase64(keyIv.buffer),
77
- };
78
- };
79
- // ─── File encryption ───
80
- const encryptFileContent = async (content, senderPrivateKey, recipientPublicKey) => {
81
- const buffer = new TextEncoder().encode(content).buffer;
82
- const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
83
- const iv = crypto.getRandomValues(new Uint8Array(12));
84
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
85
- const sharedKey = await deriveAesKey(senderPrivateKey, recipientPublicKey);
86
- const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
87
- const keyIv = crypto.getRandomValues(new Uint8Array(12));
88
- const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawFileKey);
89
- return {
90
- ciphertext: Buffer.from(ciphertext),
91
- iv: toBase64(iv.buffer),
92
- encryptedKey: toBase64(encryptedKey),
93
- keyIv: toBase64(keyIv.buffer),
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 (~/.config/zeph/keys.json) ───
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
- const KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'keys.json');
99
- const loadStoredKeys = () => {
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
- return JSON.parse((0, fs_1.readFileSync)(KEYS_PATH, 'utf-8'));
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 storeKeys = (exported) => {
120
+ const storeDeviceKeys = (exported) => {
108
121
  (0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
109
- (0, fs_1.writeFileSync)(KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
122
+ (0, fs_1.writeFileSync)(DEVICE_KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
110
123
  };
111
- const deleteStoredKeys = () => {
124
+ const deleteLegacyKeys = () => {
112
125
  try {
113
- (0, fs_1.unlinkSync)(KEYS_PATH);
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 cachedOwnPublicKey = null;
137
+ let cachedLegacyPublicKey = null;
125
138
  let initPromise = null;
126
139
  /**
127
140
  * Initialize crypto.
128
141
  *
129
- * The MCP server is a CONSUMER of encryption keys, not a generator. Keys
130
- * are created in the Zeph app where the user explicitly opts in (Settings
131
- * Encryption). This function only imports keys that the server already
132
- * has, and only when the server confirms encryption is enabled.
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
- * Any other state server says disabled, server has no keys, server is
135
- * unreachable leaves encryption OFF (cache empty, no fallback). A
136
- * previous version generated and uploaded a fresh keypair on the "no keys
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 — useful while cleaning up legacy state or for users who
143
- * never want encryption.
152
+ * server state.
144
153
  *
145
- * Safe to call concurrently — deduplicates to single init.
146
- * Returns the exported public key when encryption is active, '' otherwise.
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
- cachedKeyPair = null;
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
- if (apiKey) {
167
- const serverResult = await fetchServerKeys(apiKey, baseUrlRequired);
168
- // The only path that turns encryption ON: server confirms enabled AND
169
- // hands us a real keypair. Everything else leaves the cache empty.
170
- const haveServerKeys = !!serverResult && serverResult.encryptionEnabled && !!serverResult.keys;
171
- if (!haveServerKeys) {
172
- cachedKeyPair = null;
173
- cachedExportedPublicKey = null;
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
- const keys = serverResult.keys;
184
- const stored = loadStoredKeys();
185
- if (!stored || stored.publicKey !== keys.publicKey) {
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
- // Local-only mode (no apiKey): load stored keys if they exist; do NOT
194
- // generate. Used by tests and offline / pre-provisioned setups where
195
- // a keypair has been dropped into ~/.config/zeph/keys.json out-of-band.
196
- const stored = loadStoredKeys();
197
- if (!stored) {
198
- cachedKeyPair = null;
199
- cachedExportedPublicKey = null;
200
- cachedOwnPublicKey = null;
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
- cachedKeyPair = await importKeyPair(stored);
204
- cachedExportedPublicKey = stored.publicKey;
205
- cachedOwnPublicKey = cachedKeyPair.publicKey;
206
- return stored.publicKey;
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 fetchServerKeys = async (apiKey, baseUrl) => {
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 keys = json.data?.encryptionKeys;
227
- const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
228
- return {
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
- // uploadServerKeys was removed in fix/no-auto-encryption — the MCP server
238
- // must never write to /users/me/keys. Keys are created by the Zeph app
239
- // where the user explicitly opts in.
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 self (all own devices).
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 encryptPushBodyForSelf = async (input) => {
263
- if (!cachedKeyPair || !cachedExportedPublicKey || !cachedOwnPublicKey)
275
+ const encryptPushBodyForDevices = async (input, recipients) => {
276
+ if (!cachedKeyPair || !cachedExportedPublicKey)
264
277
  throw new Error('Crypto not initialized');
265
- const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, cachedOwnPublicKey);
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: payload.ciphertext, iv: payload.iv }),
268
- encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
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.encryptPushBodyForSelf = encryptPushBodyForSelf;
289
+ exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
274
290
  /**
275
- * Encrypt file content for self (all own devices).
291
+ * Encrypt file content for the given recipient devices.
276
292
  */
277
- const encryptFileForSelf = async (content) => {
278
- if (!cachedKeyPair || !cachedOwnPublicKey)
293
+ const encryptFileForDevices = async (content, recipients) => {
294
+ if (!cachedKeyPair)
279
295
  throw new Error('Crypto not initialized');
280
- const result = await encryptFileContent(content, cachedKeyPair.privateKey, cachedOwnPublicKey);
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: result.ciphertext,
283
- iv: result.iv,
284
- encryptedKey: JSON.stringify({ encryptedKey: result.encryptedKey, keyIv: result.keyIv }),
308
+ ciphertext: Buffer.from(ciphertext),
309
+ iv: toBase64(iv.buffer),
310
+ deviceKeyMap: await wrapForDevices(rawFileKey, cachedKeyPair.privateKey, recipients),
285
311
  };
286
312
  };
287
- exports.encryptFileForSelf = encryptFileForSelf;
313
+ exports.encryptFileForDevices = encryptFileForDevices;
@@ -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 whether it may encrypt, and must be safe to run twice the
13
- * encrypted first upload is left orphaned in S3, which is the accepted cost of
14
- * not shipping an unreadable attachment. The retry is not itself retried: a
15
- * second `PRO_REQUIRED` propagates.
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: (canEncrypt: boolean) => Promise<T>) => Promise<T>;
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":"AAGA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,GAAU,CAAC,EAC3C,MAAM,CAAC,UAAU,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,KACxC,OAAO,CAAC,CAAC,CAYX,CAAC"}
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"}
@@ -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 whether it may encrypt, and must be safe to run twice the
18
- * encrypted first upload is left orphaned in S3, which is the accepted cost of
19
- * not shipping an unreadable attachment. The retry is not itself retried: a
20
- * second `PRO_REQUIRED` propagates.
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 canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
24
- if (!canEncrypt)
25
- return send(false);
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(true);
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(false);
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 text file (logs, reports, code)',
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
- // Initialize E2E encryption keys (sync with server)
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
- console.error(`[Crypto] E2E encryption ready (publicKey: ${publicKey.slice(0, 20)}...)`);
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);
@@ -1 +1 @@
1
- {"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../src/mime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAkBH,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,MAGhD,CAAC"}
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();
@@ -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;AAwBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAmI7H,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"}