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