@zeph-to/mcp-server 1.16.0 → 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 CHANGED
@@ -329,9 +329,16 @@ Create an API key with the **MCP** preset in Settings > API Keys for the correct
329
329
 
330
330
  ## Encryption
331
331
 
332
- Push bodies are encrypted with AES-256-GCM. The wrapping key is derived via ECDH P-256 and synced across your own devices on first server startup so every device can read the same push. Toggle encryption in the Zeph app (Settings Encryption); when disabled, the server sends plaintext. No configuration needed.
332
+ Push bodies and file attachments are encrypted with AES-256-GCM. This server holds its own ECDH P-256 keypair, generated on first use and stored in `~/.config/zeph/device-keys.json` the private half never leaves the machine. Each push is encrypted once, and its AES key is wrapped separately for every device on your account using ECDH against that device's public key.
333
333
 
334
- **Threat model honesty:** keys are persisted on the Zeph backend to enable cross-device sync, so this is *device-shared* encryption not true end-to-end. It protects push contents from passive network observers and from a leaked database snapshot taken without the key store, but it does **not** protect against the Zeph backend itself (it has the keys it serves to your devices). A true E2E mode (per-device keypairs, server stores only public keys, no key escrow) is on the roadmap.
334
+ Toggle encryption in the Zeph app (Settings Encryption); when it is off, pushes go out as plaintext. No configuration needed. The opt-in is read once at startup, so **turning it on while this server is running takes effect only after a restart.**
335
+
336
+ **Threat model:** against a passive backend — a leaked snapshot, an operator reading the table — the stored ciphertext and wrapped keys are useless, so push contents stay private. Three limits worth knowing:
337
+ - **No protection from an active malicious operator.** Recipient public keys come from `GET /devices` on that same server, unsigned and unpinned. A backend that injects a device record carrying its own key gets the message key wrapped for it, and reads everything. Closing this needs out-of-band device verification (ADR-0007 Phase 4, not built).
338
+ - **No forward secrecy.** The ECDH secret for a given sender/device pair is static, so compromising either private key opens every past push wrapped for that pair.
339
+ - **`senderPublicKey` is unsigned**, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.
340
+
341
+ A device that has not registered a per-device public key cannot be sent to; it is skipped, and if no device qualifies the push goes out in the clear rather than arriving as something nothing can open.
335
342
 
336
343
  ## License
337
344
 
@@ -20,8 +20,8 @@ export declare class ZephApiClient {
20
20
  sessionId?: string;
21
21
  files?: AttachedFile[];
22
22
  isEncrypted?: boolean;
23
- encryptedKey?: string;
24
23
  senderPublicKey?: string;
24
+ deviceKeyMap?: Record<string, string>;
25
25
  }): Promise<PushResponse>;
26
26
  triggerHook(hookId: string, params: {
27
27
  title: string;
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
package/dist/crypto.d.ts CHANGED
@@ -1,57 +1,76 @@
1
1
  /**
2
- * Device-shared encryption for MCP server — self-contained ECDH P-256 +
2
+ * Per-device encryption for the MCP server — self-contained ECDH P-256 +
3
3
  * AES-256-GCM. Mirrors @zeph/crypto API but bundled inline (no external
4
4
  * dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
5
5
  * (the `crypto` global only exists unflagged from Node 19, so we import it).
6
6
  *
7
- * Threat model honesty (do not call this "E2E" without a footnote):
7
+ * How it works (ADR-0007):
8
8
  *
9
- * The Zeph backend persists the per-user private key in plaintext so it
10
- * can be synced down to a fresh device (fetchServerKeys / uploadServerKeys
11
- * below). That means the backend can decrypt any push body this is NOT
12
- * end-to-end in the standard sense. What it gives you is:
13
- * Protection against passive network observers
14
- * • Protection against a leaked DB snapshot taken without the key store
15
- * • Cross-device readability (all your devices share one keypair)
16
- * What it does NOT give you:
17
- * • Protection against the Zeph backend itself
18
- * • Forward secrecy — encryptPushBodyForSelf / encryptFileForSelf do
19
- * ECDH(self, self), which collapses to a static derived key. A single
20
- * device compromise (since all your devices share the same keypair)
21
- * lets the attacker decrypt every past push for which they have the
22
- * ciphertext. The per-message AES key is random, but its wrap key is
23
- * static, so wrapped keys are decryptable forever.
9
+ * This process holds its own ECDH keypair. The private half is generated
10
+ * here, written to ~/.config/zeph/device-keys.json, and never leaves the
11
+ * host the server only ever sees public keys. A push is encrypted once
12
+ * with a random AES key, and that key is wrapped separately for each of the
13
+ * user's registered devices using ECDH(this host, that device).
24
14
  *
25
- * True E2E would require a per-device keypair (server stores only public
26
- * keys; senders wrap the message key once per recipient device public
27
- * key). That refactor is on the roadmap; until then, treat push bodies as
28
- * sensitive-but-not-secret.
15
+ * That makes it end-to-end in the standard sense: the backend stores
16
+ * ciphertext plus wrapped keys it cannot unwrap.
17
+ *
18
+ * What it still does not give you:
19
+ * • Forward secrecy — the ECDH secret for a given (sender, device) pair is
20
+ * static, so a compromise of either private key retroactively opens every
21
+ * push wrapped for that pair. The per-message AES key is random; its wrap
22
+ * key is not.
23
+ * • Authenticity beyond the key pairing — nothing signs `senderPublicKey`,
24
+ * so a server that swapped it could make a push undecryptable, though not
25
+ * readable.
26
+ *
27
+ * Superseded scheme: a single account-wide keypair whose private half the
28
+ * backend escrowed so it could sync to new devices. Key escrow was removed
29
+ * server-side (zeph@8a6d21b), which left this client waiting for a private key
30
+ * the API stopped returning — encryption was silently off for months. Nothing
31
+ * here asks for that key any more.
29
32
  */
33
+ /** One target device, as returned by `GET /devices`. */
34
+ export interface DeviceRecipient {
35
+ deviceId: string;
36
+ /** Base64 SPKI of that device's per-device public key. */
37
+ publicKey: string;
38
+ }
39
+ /** deviceId → JSON `{ encryptedKey, keyIv }`, the wire shape the clients parse. */
40
+ export type DeviceKeyMap = Record<string, string>;
30
41
  /**
31
42
  * Initialize crypto.
32
43
  *
33
- * The MCP server is a CONSUMER of encryption keys, not a generator. Keys
34
- * are created in the Zeph app where the user explicitly opts in (Settings
35
- * Encryption). This function only imports keys that the server already
36
- * has, and only when the server confirms encryption is enabled.
44
+ * Encryption turns on only when the account has explicitly opted in
45
+ * `encryptionEnabled` from `GET /users/me/keys` is the single authoritative
46
+ * signal (ADR-0008), set from the Zeph app. Server unreachable, flag off, or
47
+ * the hard opt-out below all leave the cache empty and every send plaintext.
37
48
  *
38
- * Any other state server says disabled, server has no keys, server is
39
- * unreachable leaves encryption OFF (cache empty, no fallback). A
40
- * previous version generated and uploaded a fresh keypair on the "no keys
41
- * anywhere" path; combined with a transient fetch failure, that silently
42
- * turned encryption on without user consent and locked the account into
43
- * an "encryption enabled" state on the server.
49
+ * When it is on, this host generates its own keypair on first use and keeps
50
+ * it. Unlike the superseded scheme this asks the server for nothing but the
51
+ * flag: the private key is created here and stays here.
44
52
  *
45
53
  * Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
46
- * server state — useful while cleaning up legacy state or for users who
47
- * never want encryption.
54
+ * server state.
48
55
  *
49
- * Safe to call concurrently — deduplicates to single init.
50
- * Returns the exported public key when encryption is active, '' otherwise.
56
+ * Safe to call concurrently — deduplicates to a single init.
57
+ * Returns this host's public key when encryption is active, '' otherwise.
51
58
  *
52
59
  * NOTE: when `apiKey` is provided, `baseUrl` is required.
53
60
  */
54
61
  export declare const initCrypto: (apiKey?: string, baseUrl?: string) => Promise<string>;
62
+ /**
63
+ * Keep only devices this host can actually encrypt for.
64
+ *
65
+ * A device without a public key has never run a build that registers one, and
66
+ * a device still advertising the account-wide key has not migrated to
67
+ * per-device E2E — wrapping for either produces a push it cannot open, which
68
+ * is worse than sending plaintext it can read.
69
+ */
70
+ export declare const selectRecipients: (devices: {
71
+ deviceId: string;
72
+ publicKey?: string;
73
+ }[]) => DeviceRecipient[];
55
74
  export declare const getKeyPair: () => CryptoKeyPair | null;
56
75
  export declare const getPublicKey: () => string | null;
57
76
  /**
@@ -65,24 +84,28 @@ export declare const getPublicKey: () => string | null;
65
84
  */
66
85
  export declare const disableCrypto: () => void;
67
86
  /**
68
- * Encrypt push body for self (all own devices).
87
+ * Encrypt a push body for the given recipient devices.
88
+ *
89
+ * Returns the wire fields the API expects: `body` carries the ciphertext and
90
+ * IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
91
+ * recipients need to derive the same secret back.
69
92
  */
70
- export declare const encryptPushBodyForSelf: (input: {
93
+ export declare const encryptPushBodyForDevices: (input: {
71
94
  title?: string;
72
95
  body?: string;
73
96
  url?: string;
74
- }) => Promise<{
97
+ }, recipients: DeviceRecipient[]) => Promise<{
75
98
  body: string;
76
- encryptedKey: string;
99
+ deviceKeyMap: DeviceKeyMap;
77
100
  senderPublicKey: string;
78
101
  isEncrypted: true;
79
102
  }>;
80
103
  /**
81
- * Encrypt file content for self (all own devices).
104
+ * Encrypt file content for the given recipient devices.
82
105
  */
83
- export declare const encryptFileForSelf: (content: string | Buffer) => Promise<{
106
+ export declare const encryptFileForDevices: (content: string | Buffer, recipients: DeviceRecipient[]) => Promise<{
84
107
  ciphertext: Buffer;
85
108
  iv: string;
86
- encryptedKey: string;
109
+ deviceKeyMap: DeviceKeyMap;
87
110
  }>;
88
111
  //# sourceMappingURL=crypto.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAiKH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AAmCF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,QAAO,IAIhC,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,GAAG,MAAM,KACvB,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAuEH,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,mFAAmF;AACnF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AA6ElD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CA2D5E,CAAC;AAuCF;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAClD,eAAe,EAG8C,CAAC;AAEjE,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,QAAO,IAGhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACpC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,YAAY,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAkBA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,qBAAqB,GAChC,SAAS,MAAM,GAAG,MAAM,EACxB,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,YAAY,CAAA;CAAE,CAsBxE,CAAC"}
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,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
- 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
- // Binary attachments arrive as a Buffer and must be encrypted byte for byte —
82
- // running them through TextEncoder would UTF-8 mangle every non-ASCII byte.
83
- // Both branches are views; subtle.encrypt honours byteOffset/byteLength, so a
84
- // Buffer carved out of Node's pool encrypts only its own bytes.
85
- const buffer = typeof content === 'string'
86
- ? new TextEncoder().encode(content)
87
- : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
88
- const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
89
- const iv = crypto.getRandomValues(new Uint8Array(12));
90
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
91
- const sharedKey = await deriveAesKey(senderPrivateKey, recipientPublicKey);
92
- const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
93
- const keyIv = crypto.getRandomValues(new Uint8Array(12));
94
- const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawFileKey);
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 (~/.config/zeph/keys.json) ───
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
- const KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'keys.json');
105
- 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 = () => {
106
112
  try {
107
- 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;
108
115
  }
109
116
  catch {
110
117
  return null;
111
118
  }
112
119
  };
113
- const storeKeys = (exported) => {
120
+ const storeDeviceKeys = (exported) => {
114
121
  (0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
115
- (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 });
116
123
  };
117
- const deleteStoredKeys = () => {
124
+ const deleteLegacyKeys = () => {
118
125
  try {
119
- (0, fs_1.unlinkSync)(KEYS_PATH);
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 cachedOwnPublicKey = null;
137
+ let cachedLegacyPublicKey = null;
131
138
  let initPromise = null;
132
139
  /**
133
140
  * Initialize crypto.
134
141
  *
135
- * The MCP server is a CONSUMER of encryption keys, not a generator. Keys
136
- * are created in the Zeph app where the user explicitly opts in (Settings
137
- * Encryption). This function only imports keys that the server already
138
- * 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.
139
146
  *
140
- * Any other state server says disabled, server has no keys, server is
141
- * unreachable leaves encryption OFF (cache empty, no fallback). A
142
- * previous version generated and uploaded a fresh keypair on the "no keys
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 — useful while cleaning up legacy state or for users who
149
- * never want encryption.
152
+ * server state.
150
153
  *
151
- * Safe to call concurrently — deduplicates to single init.
152
- * 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.
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
- cachedKeyPair = null;
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
- if (apiKey) {
173
- const serverResult = await fetchServerKeys(apiKey, baseUrlRequired);
174
- // The only path that turns encryption ON: server confirms enabled AND
175
- // hands us a real keypair. Everything else leaves the cache empty.
176
- const haveServerKeys = !!serverResult && serverResult.encryptionEnabled && !!serverResult.keys;
177
- if (!haveServerKeys) {
178
- cachedKeyPair = null;
179
- cachedExportedPublicKey = null;
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
- const keys = serverResult.keys;
190
- const stored = loadStoredKeys();
191
- if (!stored || stored.publicKey !== keys.publicKey) {
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
- // Local-only mode (no apiKey): load stored keys if they exist; do NOT
200
- // generate. Used by tests and offline / pre-provisioned setups where
201
- // a keypair has been dropped into ~/.config/zeph/keys.json out-of-band.
202
- const stored = loadStoredKeys();
203
- if (!stored) {
204
- cachedKeyPair = null;
205
- cachedExportedPublicKey = null;
206
- 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();
207
194
  return '';
208
195
  }
209
- cachedKeyPair = await importKeyPair(stored);
210
- cachedExportedPublicKey = stored.publicKey;
211
- cachedOwnPublicKey = cachedKeyPair.publicKey;
212
- 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;
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 fetchServerKeys = async (apiKey, baseUrl) => {
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 keys = json.data?.encryptionKeys;
233
- const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
234
- return {
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
- // uploadServerKeys was removed in fix/no-auto-encryption — the MCP server
244
- // must never write to /users/me/keys. Keys are created by the Zeph app
245
- // 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;
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 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.
267
274
  */
268
- const encryptPushBodyForSelf = async (input) => {
269
- if (!cachedKeyPair || !cachedExportedPublicKey || !cachedOwnPublicKey)
275
+ const encryptPushBodyForDevices = async (input, recipients) => {
276
+ if (!cachedKeyPair || !cachedExportedPublicKey)
270
277
  throw new Error('Crypto not initialized');
271
- 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);
272
282
  return {
273
- body: JSON.stringify({ ciphertext: payload.ciphertext, iv: payload.iv }),
274
- 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),
275
285
  senderPublicKey: cachedExportedPublicKey,
276
286
  isEncrypted: true,
277
287
  };
278
288
  };
279
- exports.encryptPushBodyForSelf = encryptPushBodyForSelf;
289
+ exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
280
290
  /**
281
- * Encrypt file content for self (all own devices).
291
+ * Encrypt file content for the given recipient devices.
282
292
  */
283
- const encryptFileForSelf = async (content) => {
284
- if (!cachedKeyPair || !cachedOwnPublicKey)
293
+ const encryptFileForDevices = async (content, recipients) => {
294
+ if (!cachedKeyPair)
285
295
  throw new Error('Crypto not initialized');
286
- 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);
287
307
  return {
288
- ciphertext: result.ciphertext,
289
- iv: result.iv,
290
- 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),
291
311
  };
292
312
  };
293
- 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
@@ -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":"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"}
package/dist/tools/ask.js CHANGED
@@ -5,7 +5,6 @@ const zod_1 = require("zod");
5
5
  const error_format_js_1 = require("../error-format.js");
6
6
  const poll_js_1 = require("../poll.js");
7
7
  const config_js_1 = require("../config.js");
8
- const crypto_js_1 = require("../crypto.js");
9
8
  const mime_js_1 = require("../mime.js");
10
9
  const sanitize_js_1 = require("../sanitize.js");
11
10
  // The device feed shows a short preview of the body. Anything longer than
@@ -23,7 +22,7 @@ const buildAskMarkdown = (title, body, actions) => {
23
22
  };
24
23
  const registerAskTool = (server, client, config, waiter) => {
25
24
  server.registerTool('zeph_ask', {
26
- description: 'Ask the user a question with optional quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. The user can either tap a button or type a response. Blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
25
+ description: 'Ask the user a question with optional quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. The user can either tap a button or type a response. Blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable. NOTE: unlike zeph_notify and zeph_file, this tool is never end-to-end encrypted — the hook route it uses cannot carry the sender key — so do not put secrets in the question or expect a private answer.',
27
26
  annotations: {
28
27
  readOnlyHint: false,
29
28
  destructiveHint: false,
@@ -77,30 +76,21 @@ const registerAskTool = (server, client, config, waiter) => {
77
76
  if (exceedsPreview && cleanBody) {
78
77
  const fileName = 'response.md';
79
78
  const fileType = (0, mime_js_1.inferMimeType)(fileName);
80
- const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
81
79
  // Self-contained Markdown so the file alone tells the whole story.
82
80
  const fileMarkdown = buildAskMarkdown(title, cleanBody, effectiveActions);
83
81
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
84
- let uploadContent = fileMarkdown;
85
- let uploadContentType = fileType;
86
- let fileIv;
87
- let fileEncryptedKey;
88
- if (canEncrypt) {
89
- try {
90
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(fileMarkdown);
91
- uploadContent = encrypted.ciphertext;
92
- uploadContentType = 'application/octet-stream';
93
- fileIv = encrypted.iv;
94
- fileEncryptedKey = encrypted.encryptedKey;
95
- }
96
- catch (err) {
97
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
98
- }
99
- }
100
- const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
101
- await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
82
+ // Deliberately NOT encrypted, unlike zeph_notify / zeph_file.
83
+ //
84
+ // This attachment rides `POST /hooks/:id/trigger`, which creates a
85
+ // plaintext push: it neither accepts nor persists `isEncrypted` or
86
+ // `senderPublicKey` (apps/server/src/functions/hooks.ts). Clients
87
+ // gate decryption on both, so an encrypted attachment here would be
88
+ // downloaded as raw ciphertext named response.md. Encrypting this
89
+ // path needs the hook route to carry the sender key first.
90
+ const upload = await client.requestUpload({ fileName, fileType, fileSize: fileBytes });
91
+ await client.uploadToS3(upload.data.uploadUrl, fileMarkdown, fileType);
102
92
  triggerBody = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
103
- files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }];
93
+ files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType }];
104
94
  }
105
95
  const trigger = await client.triggerHook(config.hookId, {
106
96
  title: pushTitle,
@@ -1 +1 @@
1
- {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/tools/file.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AA4BrE,eAAO,MAAM,gBAAgB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAoHjG,CAAC"}
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/tools/file.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AA4BrE,eAAO,MAAM,gBAAgB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAmIjG,CAAC"}
@@ -81,53 +81,65 @@ const registerFileTool = (server, client, config) => {
81
81
  // Runs a second time as plaintext if the server refuses E2E (Pro-only,
82
82
  // ADR-0008). The retry re-uploads the file unencrypted, so the whole
83
83
  // upload-then-send sequence has to sit inside this closure.
84
- const send = async (canEncrypt) => {
85
- let fileType = (0, mime_js_1.inferMimeType)(fileName);
86
- // Step 1: Optionally encrypt file content
87
- let uploadContent = body;
88
- let uploadSize = originalSize;
89
- let fileIv;
90
- let fileEncryptedKey;
91
- if (canEncrypt) {
84
+ const send = async (recipients) => {
85
+ const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title ?? fileName);
86
+ // Step 1: Encrypt the attachment and the push body together, before
87
+ // anything is uploaded. Doing them one at a time around the upload let
88
+ // a failure land in between and ship ciphertext under a push with no
89
+ // `isEncrypted` — an attachment no client would even try to open.
90
+ let encrypted = null;
91
+ if (recipients) {
92
92
  try {
93
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(body);
94
- uploadContent = encrypted.ciphertext;
95
- uploadSize = encrypted.ciphertext.length;
96
- fileType = 'application/octet-stream';
97
- fileIv = encrypted.iv;
98
- fileEncryptedKey = encrypted.encryptedKey;
93
+ encrypted = {
94
+ file: await (0, crypto_js_1.encryptFileForDevices)(body, recipients),
95
+ push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle }, recipients),
96
+ };
99
97
  }
100
98
  catch (err) {
101
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
99
+ console.error('[Crypto] Encryption failed, sending plaintext:', err);
102
100
  }
103
101
  }
102
+ const uploadContent = encrypted?.file.ciphertext ?? body;
103
+ const uploadType = encrypted ? 'application/octet-stream' : (0, mime_js_1.inferMimeType)(fileName);
104
+ const uploadSize = encrypted ? encrypted.file.ciphertext.length : originalSize;
104
105
  // Step 2: Request upload URL
105
- const upload = await client.requestUpload({ fileName, fileType, fileSize: uploadSize });
106
+ const upload = await client.requestUpload({ fileName, fileType: uploadType, fileSize: uploadSize });
106
107
  // Step 3: Upload content to S3
107
- await client.uploadToS3(upload.data.uploadUrl, uploadContent, fileType);
108
- // Step 4: Send file push (encrypt push body if possible)
109
- const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title ?? fileName);
110
- let pushPayload = {
111
- title: pushTitle,
108
+ await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadType);
109
+ // Step 4: Send the push. `fileType` on the descriptor stays the real
110
+ // type it drives how the client renders the decrypted bytes.
111
+ const pushPayload = {
112
+ title: encrypted ? undefined : pushTitle,
112
113
  type: 'file',
113
- files: [{ fileKey: upload.data.fileKey, fileName, fileSize: originalSize, fileType: (0, mime_js_1.inferMimeType)(fileName), iv: fileIv, encryptedKey: fileEncryptedKey }],
114
+ files: [{
115
+ fileKey: upload.data.fileKey,
116
+ fileName,
117
+ fileSize: originalSize,
118
+ fileType: (0, mime_js_1.inferMimeType)(fileName),
119
+ iv: encrypted?.file.iv,
120
+ deviceKeyMap: encrypted?.file.deviceKeyMap,
121
+ }],
114
122
  targetDeviceId: targetDeviceId ?? config.deviceId,
115
123
  sessionId: config.sessionId,
124
+ ...(encrypted && {
125
+ body: encrypted.push.body,
126
+ isEncrypted: encrypted.push.isEncrypted,
127
+ deviceKeyMap: encrypted.push.deviceKeyMap,
128
+ senderPublicKey: encrypted.push.senderPublicKey,
129
+ }),
116
130
  };
117
- if (canEncrypt) {
118
- try {
119
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle });
120
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
121
- }
122
- catch (err) {
123
- console.error('[Crypto] Push encryption failed, sending plaintext:', err);
124
- }
125
- }
126
131
  const result = await client.sendPush(pushPayload);
127
- return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, fileSize: originalSize, encrypted: canEncrypt });
132
+ // Report what actually went out an encryption failure above falls
133
+ // back to plaintext, so the recipient list alone would over-claim.
134
+ return (0, error_format_js_1.textResult)({
135
+ pushId: result.data.pushId,
136
+ fileKey: upload.data.fileKey,
137
+ fileSize: originalSize,
138
+ encrypted: !!encrypted,
139
+ });
128
140
  };
129
141
  try {
130
- return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
142
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(client, send);
131
143
  }
132
144
  catch (err) {
133
145
  return (0, error_format_js_1.formatToolError)(err);
@@ -1 +1 @@
1
- {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAUrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAgInG,CAAC"}
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAUrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAuInG,CAAC"}
@@ -32,7 +32,7 @@ const registerNotifyTool = (server, client, config) => {
32
32
  }, async ({ title, body, url, priority, targetDeviceId }) => {
33
33
  // Runs a second time as plaintext if the server refuses E2E (Pro-only,
34
34
  // ADR-0008) — hence everything after the encryption decision lives here.
35
- const send = async (canEncrypt) => {
35
+ const send = async (recipients) => {
36
36
  const deviceId = targetDeviceId ?? config.deviceId;
37
37
  const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
38
38
  // Strip any tool-call markup that leaked into the body argument.
@@ -45,70 +45,74 @@ const registerNotifyTool = (server, client, config) => {
45
45
  // Self-contained Markdown so the file alone carries the full text.
46
46
  const fileMarkdown = `# ${title}\n\n${cleanBody}`;
47
47
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
48
- // Encrypt file content if keys available
49
- let uploadContent = fileMarkdown;
50
- let uploadContentType = fileType;
51
- let fileIv;
52
- let fileEncryptedKey;
53
- let fileEncrypted = false;
54
- if (canEncrypt) {
48
+ const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
49
+ // Encrypt the attachment and the push body together, before anything
50
+ // is uploaded. Doing them one at a time around the upload let a
51
+ // failure land in between and ship ciphertext under a push with no
52
+ // `isEncrypted` — an attachment no client would even try to open.
53
+ let encrypted = null;
54
+ if (recipients) {
55
55
  try {
56
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(fileMarkdown);
57
- uploadContent = encrypted.ciphertext;
58
- uploadContentType = 'application/octet-stream';
59
- fileIv = encrypted.iv;
60
- fileEncryptedKey = encrypted.encryptedKey;
61
- fileEncrypted = true;
56
+ encrypted = {
57
+ file: await (0, crypto_js_1.encryptFileForDevices)(fileMarkdown, recipients),
58
+ push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: preview, url }, recipients),
59
+ };
62
60
  }
63
61
  catch (err) {
64
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
62
+ console.error('[Crypto] Encryption failed, sending plaintext:', err);
65
63
  }
66
64
  }
65
+ const uploadContent = encrypted?.file.ciphertext ?? fileMarkdown;
66
+ const uploadContentType = encrypted ? 'application/octet-stream' : fileType;
67
67
  const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
68
68
  await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
69
- const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
70
- // Encrypt push body (title/preview/url) if keys available
71
- let pushPayload = {
72
- title: pushTitle,
73
- body: preview,
74
- url,
69
+ const pushPayload = {
70
+ title: encrypted ? undefined : pushTitle,
71
+ body: encrypted ? encrypted.push.body : preview,
72
+ // Dropped alongside the title when encrypted: the url is already
73
+ // sealed inside the ciphertext, and for a link push it is the whole
74
+ // payload. Leaving the plaintext copy here would hand the server the
75
+ // one thing `isEncrypted` promises it cannot see.
76
+ url: encrypted ? undefined : url,
75
77
  type: 'file',
76
78
  priority,
77
- files: [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }],
79
+ files: [{
80
+ fileKey: upload.data.fileKey,
81
+ fileName,
82
+ fileSize: fileBytes,
83
+ fileType,
84
+ iv: encrypted?.file.iv,
85
+ deviceKeyMap: encrypted?.file.deviceKeyMap,
86
+ }],
78
87
  targetDeviceId: deviceId,
79
88
  sessionId: config.sessionId,
89
+ ...(encrypted && {
90
+ isEncrypted: encrypted.push.isEncrypted,
91
+ deviceKeyMap: encrypted.push.deviceKeyMap,
92
+ senderPublicKey: encrypted.push.senderPublicKey,
93
+ }),
80
94
  };
81
- let pushEncrypted = false;
82
- if (canEncrypt) {
83
- try {
84
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: preview, url });
85
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
86
- pushEncrypted = true;
87
- }
88
- catch (err) {
89
- console.error('[Crypto] Push encryption failed, sending plaintext:', err);
90
- }
91
- }
92
95
  const result = await client.sendPush(pushPayload);
93
96
  // Report what actually went out — an encryption failure above falls
94
- // back to plaintext, so `canEncrypt` alone would over-claim.
95
- return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, autoFile: true, encrypted: fileEncrypted && pushEncrypted });
97
+ // back to plaintext, so the recipient list alone would over-claim.
98
+ return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, autoFile: true, encrypted: !!encrypted });
96
99
  }
97
100
  // Short body — encrypt push only
98
101
  let pushPayload = {
99
102
  title: pushTitle,
100
103
  body: cleanBody,
101
- url,
104
+ url, // replaced below when the encrypted envelope takes over
102
105
  type: 'hook',
103
106
  priority,
104
107
  targetDeviceId: deviceId,
105
108
  sessionId: config.sessionId,
106
109
  };
107
110
  let pushEncrypted = false;
108
- if (canEncrypt) {
111
+ if (recipients) {
109
112
  try {
110
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: cleanBody, url });
111
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
113
+ const enc = await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: cleanBody, url }, recipients);
114
+ // `url: undefined` for the same reason as `title` see the file branch.
115
+ pushPayload = { ...pushPayload, title: undefined, url: undefined, body: enc.body, isEncrypted: enc.isEncrypted, deviceKeyMap: enc.deviceKeyMap, senderPublicKey: enc.senderPublicKey };
112
116
  pushEncrypted = true;
113
117
  }
114
118
  catch (err) {
@@ -119,7 +123,7 @@ const registerNotifyTool = (server, client, config) => {
119
123
  return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, encrypted: pushEncrypted });
120
124
  };
121
125
  try {
122
- return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
126
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(client, send);
123
127
  }
124
128
  catch (err) {
125
129
  return (0, error_format_js_1.formatToolError)(err);
package/dist/types.d.ts CHANGED
@@ -95,7 +95,13 @@ export interface AttachedFile {
95
95
  fileSize: number;
96
96
  fileType: string;
97
97
  iv?: string;
98
- encryptedKey?: string;
98
+ /**
99
+ * E2E: the file AES key wrapped once per recipient device, keyed by
100
+ * `deviceId`. Each value is a JSON string `{ encryptedKey, keyIv }`.
101
+ * (The superseded account-wide `encryptedKey` field is not written here —
102
+ * nothing can unwrap it since key escrow was removed.)
103
+ */
104
+ deviceKeyMap?: Record<string, string>;
99
105
  }
100
106
  export interface ToolError {
101
107
  error: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/mcp-server",
3
- "version": "1.16.0",
3
+ "version": "2.0.0",
4
4
  "description": "Zeph MCP server — AI agent notifications, prompts, and input via MCP protocol",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",