@hwip/mcp-server 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +10 -0
- package/README.md +413 -2
- package/dist/agent-target.d.ts +45 -0
- package/dist/agent-target.js +81 -0
- package/dist/api-client.d.ts +162 -0
- package/dist/api-client.js +517 -0
- package/dist/ask-context-attach.d.ts +67 -0
- package/dist/ask-context-attach.js +147 -0
- package/dist/ask-seal.d.ts +118 -0
- package/dist/ask-seal.js +194 -0
- package/dist/command-seal.d.ts +49 -0
- package/dist/command-seal.js +90 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +147 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.js +561 -0
- package/dist/crypto.d.ts +234 -0
- package/dist/crypto.js +606 -0
- package/dist/error-format.d.ts +7 -0
- package/dist/error-format.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +30 -0
- package/dist/key-changes.d.ts +50 -0
- package/dist/key-changes.js +163 -0
- package/dist/mime.d.ts +16 -0
- package/dist/mime.js +58 -0
- package/dist/peer-key.d.ts +36 -0
- package/dist/peer-key.js +35 -0
- package/dist/poll.d.ts +24 -0
- package/dist/poll.js +156 -0
- package/dist/push-audience.d.ts +35 -0
- package/dist/push-audience.js +64 -0
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +17 -0
- package/dist/remote-state.d.ts +38 -0
- package/dist/remote-state.js +81 -0
- package/dist/resources/channels.d.ts +4 -0
- package/dist/resources/channels.js +24 -0
- package/dist/resources/devices.d.ts +4 -0
- package/dist/resources/devices.js +24 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +106 -0
- package/dist/self-register.d.ts +34 -0
- package/dist/self-register.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +63 -0
- package/dist/tools/agent-local.d.ts +17 -0
- package/dist/tools/agent-local.js +139 -0
- package/dist/tools/agent-send.d.ts +6 -0
- package/dist/tools/agent-send.js +89 -0
- package/dist/tools/ask.d.ts +9 -0
- package/dist/tools/ask.js +275 -0
- package/dist/tools/broadcast.d.ts +5 -0
- package/dist/tools/broadcast.js +42 -0
- package/dist/tools/clipboard.d.ts +5 -0
- package/dist/tools/clipboard.js +47 -0
- package/dist/tools/dismiss.d.ts +5 -0
- package/dist/tools/dismiss.js +49 -0
- package/dist/tools/file.d.ts +5 -0
- package/dist/tools/file.js +169 -0
- package/dist/tools/list.d.ts +4 -0
- package/dist/tools/list.js +44 -0
- package/dist/tools/lookup.d.ts +10 -0
- package/dist/tools/lookup.js +45 -0
- package/dist/tools/notify.d.ts +5 -0
- package/dist/tools/notify.js +118 -0
- package/dist/tools/rename.d.ts +5 -0
- package/dist/tools/rename.js +45 -0
- package/dist/trust-pins.d.ts +70 -0
- package/dist/trust-pins.js +269 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +2 -0
- package/dist/url-check.d.ts +11 -0
- package/dist/url-check.js +57 -0
- package/dist/ws-wait.d.ts +72 -0
- package/dist/ws-wait.js +200 -0
- package/package.json +70 -4
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Per-device encryption for the MCP server — self-contained ECDH P-256 +
|
|
4
|
+
* AES-256-GCM. Mirrors @hwip/crypto API but bundled inline (no external
|
|
5
|
+
* dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
|
|
6
|
+
* (the `crypto` global only exists unflagged from Node 19, so we import it).
|
|
7
|
+
*
|
|
8
|
+
* How it works (ADR-0007):
|
|
9
|
+
*
|
|
10
|
+
* This host holds one ECDH keypair, in ~/.hwip/device-keys.json — the
|
|
11
|
+
* Machine Device's, shared with `hwip listener`. Either puts its public half
|
|
12
|
+
* on the device record when the list lacks it (`self-register.ts`). Whichever
|
|
13
|
+
* process starts first creates it; the
|
|
14
|
+
* private half never leaves the host — the server only ever sees public
|
|
15
|
+
* keys. A push is encrypted once
|
|
16
|
+
* with a random AES key, and that key is wrapped separately for each of the
|
|
17
|
+
* user's registered devices using ECDH(this host, that device).
|
|
18
|
+
*
|
|
19
|
+
* That makes it end-to-end in the standard sense: the backend stores
|
|
20
|
+
* ciphertext plus wrapped keys it cannot unwrap.
|
|
21
|
+
*
|
|
22
|
+
* What it still does not give you:
|
|
23
|
+
* • Forward secrecy — the ECDH secret for a given (sender, device) pair is
|
|
24
|
+
* static, so a compromise of either private key retroactively opens every
|
|
25
|
+
* push wrapped for that pair. The per-message AES key is random; its wrap
|
|
26
|
+
* key is not.
|
|
27
|
+
* • Authenticity beyond the key pairing — nothing signs `senderPublicKey`,
|
|
28
|
+
* so a server that swapped it could make a push undecryptable, though not
|
|
29
|
+
* readable.
|
|
30
|
+
*
|
|
31
|
+
* Superseded scheme: a single account-wide keypair whose private half the
|
|
32
|
+
* backend escrowed so it could sync to new devices. Key escrow was removed
|
|
33
|
+
* server-side, which left this client waiting for a private key
|
|
34
|
+
* the API stopped returning — encryption was silently off for months. Nothing
|
|
35
|
+
* here asks for that key any more.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.publicKeySender = exports.samePublicKey = exports.decryptPushBodyForDevice = exports.askContext = exports.ASK_CHOICES_CODE = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.cryptoStatusLine = exports.cryptoReady = exports.initCrypto = void 0;
|
|
39
|
+
/// <reference lib="dom" />
|
|
40
|
+
const fs_1 = require("fs");
|
|
41
|
+
const os_1 = require("os");
|
|
42
|
+
const path_1 = require("path");
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
44
|
+
// Node 18 has no `crypto` global (unflagged only from 19.0.0) — resolve the
|
|
45
|
+
// Web Crypto implementation explicitly so encryption works on the declared
|
|
46
|
+
// minimum runtime instead of silently staying disabled.
|
|
47
|
+
const crypto = node_crypto_1.webcrypto;
|
|
48
|
+
// ─── Base64 helpers ───
|
|
49
|
+
const toBase64 = (buffer) => Buffer.from(buffer).toString('base64');
|
|
50
|
+
const fromBase64 = (base64) => {
|
|
51
|
+
const buf = Buffer.from(base64, 'base64');
|
|
52
|
+
// Slice to the exact byte range — Buffer may share a larger pooled
|
|
53
|
+
// ArrayBuffer, so `.buffer` alone could expose unrelated memory.
|
|
54
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
55
|
+
};
|
|
56
|
+
// ─── ECDH key management ───
|
|
57
|
+
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
|
|
58
|
+
const generateKeyPair = async () => crypto.subtle.generateKey(ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
|
|
59
|
+
const exportKeyPair = async (keyPair) => {
|
|
60
|
+
const [publicRaw, privateRaw] = await Promise.all([
|
|
61
|
+
crypto.subtle.exportKey('spki', keyPair.publicKey),
|
|
62
|
+
crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
|
63
|
+
]);
|
|
64
|
+
return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
|
|
65
|
+
};
|
|
66
|
+
const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
|
|
67
|
+
const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
|
|
68
|
+
const importKeyPair = async (exported) => {
|
|
69
|
+
const [publicKey, privateKey] = await Promise.all([
|
|
70
|
+
importPublicKey(exported.publicKey),
|
|
71
|
+
importPrivateKey(exported.privateKey),
|
|
72
|
+
]);
|
|
73
|
+
return { publicKey, privateKey };
|
|
74
|
+
};
|
|
75
|
+
const deriveAesKey = async (privateKey, publicKey) => crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
76
|
+
/**
|
|
77
|
+
* Wrap one raw AES key for every recipient device.
|
|
78
|
+
*
|
|
79
|
+
* The payload is encrypted once and only the 44-byte wrapped key repeats, so
|
|
80
|
+
* an attachment costs one S3 object regardless of how many devices the account
|
|
81
|
+
* has. A recipient whose public key will not import is dropped rather than
|
|
82
|
+
* failing the send — one broken device record must not silence every push.
|
|
83
|
+
*/
|
|
84
|
+
const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
|
|
85
|
+
const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
|
|
86
|
+
try {
|
|
87
|
+
const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
|
|
88
|
+
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
89
|
+
const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
|
|
90
|
+
return [
|
|
91
|
+
deviceId,
|
|
92
|
+
JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
const keyMap = {};
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry)
|
|
103
|
+
keyMap[entry[0]] = entry[1];
|
|
104
|
+
}
|
|
105
|
+
if (Object.keys(keyMap).length === 0)
|
|
106
|
+
throw new Error('No recipient device accepted the wrapped key');
|
|
107
|
+
return keyMap;
|
|
108
|
+
};
|
|
109
|
+
// ─── Key persistence ───
|
|
110
|
+
const OLD_KEYS_DIR = (0, path_1.join)(process.env.XDG_CONFIG_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.config'), 'hwip');
|
|
111
|
+
// Superseded account-wide keypair. Never written any more; only deleted.
|
|
112
|
+
const LEGACY_KEYS_PATH = (0, path_1.join)(OLD_KEYS_DIR, 'keys.json');
|
|
113
|
+
// This server's own keypair before it shared the machine's. Deleted too: past
|
|
114
|
+
// pushes each carry the `senderPublicKey` they were wrapped with, and a
|
|
115
|
+
// recipient opens them with its own private key — nothing needs this one.
|
|
116
|
+
const RETIRED_DEVICE_KEYS_PATH = (0, path_1.join)(OLD_KEYS_DIR, 'device-keys.json');
|
|
117
|
+
// The Machine Device keypair (ADR-0007), the same file `hwip listener` uses.
|
|
118
|
+
const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.hwip');
|
|
119
|
+
const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
|
|
120
|
+
const loadDeviceKeys = () => {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(DEVICE_KEYS_PATH, 'utf-8'));
|
|
123
|
+
return parsed.publicKey && parsed.privateKey ? parsed : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Create the keypair file, exclusively. The listener creates the same file,
|
|
131
|
+
* and two processes each writing their own pair would leave one of them
|
|
132
|
+
* signing with a key that is not on disk. Returns what is on disk afterwards:
|
|
133
|
+
* ours, or the pair that won the race.
|
|
134
|
+
*/
|
|
135
|
+
const storeDeviceKeys = (exported) => {
|
|
136
|
+
(0, fs_1.mkdirSync)(DEVICE_KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
137
|
+
// Written whole to a private temp name, then linked into place: `link` is
|
|
138
|
+
// atomic and fails if the name exists, so the file under the real name is
|
|
139
|
+
// never half-written, and a process that loses the race reads a complete one.
|
|
140
|
+
const temp = `${DEVICE_KEYS_PATH}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
|
|
141
|
+
(0, fs_1.writeFileSync)(temp, JSON.stringify(exported, null, 2), { mode: 0o600, flag: 'wx' });
|
|
142
|
+
try {
|
|
143
|
+
(0, fs_1.linkSync)(temp, DEVICE_KEYS_PATH);
|
|
144
|
+
return exported;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (err.code !== 'EEXIST')
|
|
148
|
+
throw err;
|
|
149
|
+
const winner = loadDeviceKeys();
|
|
150
|
+
if (!winner)
|
|
151
|
+
throw new Error(`${DEVICE_KEYS_PATH} exists but holds no keypair`);
|
|
152
|
+
return winner;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
try {
|
|
156
|
+
(0, fs_1.unlinkSync)(temp);
|
|
157
|
+
}
|
|
158
|
+
catch { /* already gone — fine */ }
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const deleteRetiredKeys = () => {
|
|
162
|
+
for (const path of [LEGACY_KEYS_PATH, RETIRED_DEVICE_KEYS_PATH]) {
|
|
163
|
+
try {
|
|
164
|
+
(0, fs_1.unlinkSync)(path);
|
|
165
|
+
}
|
|
166
|
+
catch { /* not present — fine */ }
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
// ─── Cached state ───
|
|
170
|
+
let cachedKeyPair = null;
|
|
171
|
+
let cachedExportedPublicKey = null;
|
|
172
|
+
let initPromise = null;
|
|
173
|
+
/** The API key of a start that failed: the next send starts again with it, so a key file fixed meanwhile needs no restart. */
|
|
174
|
+
let failedApiKey;
|
|
175
|
+
/**
|
|
176
|
+
* Initialize crypto.
|
|
177
|
+
*
|
|
178
|
+
* This host generates its own keypair on first use and keeps it, whatever the
|
|
179
|
+
* account's plan: the keypair is this machine's identity, registered ungated so
|
|
180
|
+
* that any device can address it (ADR-0013 decision 3, amended 2026-09-16).
|
|
181
|
+
* The private key is created here and stays here. Every push is sealed (F16):
|
|
182
|
+
* there is no account setting and no host switch that turns it off.
|
|
183
|
+
*
|
|
184
|
+
* Asks the server nothing. Safe to call concurrently — deduplicates to a single init.
|
|
185
|
+
* Returns this host's public key, or '' when it has none.
|
|
186
|
+
*/
|
|
187
|
+
const initCrypto = (apiKey) => {
|
|
188
|
+
if (initPromise)
|
|
189
|
+
return initPromise;
|
|
190
|
+
initPromise = (async () => {
|
|
191
|
+
// Local-only mode (no apiKey): used by tests and offline setups. An existing
|
|
192
|
+
// device keypair is adopted and a missing one is not created — nothing sends
|
|
193
|
+
// without an apiKey, so there is nothing to make it for.
|
|
194
|
+
if (!apiKey) {
|
|
195
|
+
const stored = loadDeviceKeys();
|
|
196
|
+
if (!stored) {
|
|
197
|
+
(0, exports.disableCrypto)();
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
201
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
202
|
+
return stored.publicKey;
|
|
203
|
+
}
|
|
204
|
+
// Drop the escrowed account keypair if an old build left one on disk — it
|
|
205
|
+
// holds a private key this process has no use for and the server no longer
|
|
206
|
+
// accepts.
|
|
207
|
+
deleteRetiredKeys();
|
|
208
|
+
const stored = loadDeviceKeys();
|
|
209
|
+
if (stored) {
|
|
210
|
+
cachedKeyPair = await importKeyPair(stored);
|
|
211
|
+
cachedExportedPublicKey = stored.publicKey;
|
|
212
|
+
return stored.publicKey;
|
|
213
|
+
}
|
|
214
|
+
const keyPair = await generateKeyPair();
|
|
215
|
+
const exported = await exportKeyPair(keyPair);
|
|
216
|
+
const onDisk = storeDeviceKeys(exported);
|
|
217
|
+
cachedKeyPair = onDisk === exported ? keyPair : await importKeyPair(onDisk);
|
|
218
|
+
cachedExportedPublicKey = onDisk.publicKey;
|
|
219
|
+
return onDisk.publicKey;
|
|
220
|
+
})().then((publicKey) => {
|
|
221
|
+
failedApiKey = undefined;
|
|
222
|
+
return publicKey;
|
|
223
|
+
}, (err) => {
|
|
224
|
+
initPromise = null;
|
|
225
|
+
failedApiKey = apiKey;
|
|
226
|
+
throw err;
|
|
227
|
+
});
|
|
228
|
+
return initPromise;
|
|
229
|
+
};
|
|
230
|
+
exports.initCrypto = initCrypto;
|
|
231
|
+
/**
|
|
232
|
+
* Resolves once the start-up `initCrypto` has settled, however it went. index.ts connects the
|
|
233
|
+
* MCP transport without waiting for it, so the first send waits here instead. A start that
|
|
234
|
+
* failed (a key file that would not load) is tried once more for each send that finds no keys.
|
|
235
|
+
*/
|
|
236
|
+
const cryptoReady = async () => {
|
|
237
|
+
await (initPromise ?? Promise.resolve('')).then(() => undefined, () => undefined);
|
|
238
|
+
if (cachedKeyPair || failedApiKey === undefined)
|
|
239
|
+
return;
|
|
240
|
+
await (0, exports.initCrypto)(failedApiKey).then(() => undefined, () => undefined);
|
|
241
|
+
};
|
|
242
|
+
exports.cryptoReady = cryptoReady;
|
|
243
|
+
/** One line for stderr saying what `initCrypto` found. */
|
|
244
|
+
const cryptoStatusLine = () => {
|
|
245
|
+
if (!cachedKeyPair)
|
|
246
|
+
return '[Crypto] This machine has no device keys — every push will fail until they load.';
|
|
247
|
+
return `[Crypto] E2E encryption on (publicKey: ${cachedExportedPublicKey?.slice(0, 20)}...) — every push, question and command is sealed.`;
|
|
248
|
+
};
|
|
249
|
+
exports.cryptoStatusLine = cryptoStatusLine;
|
|
250
|
+
/**
|
|
251
|
+
* Keep only devices this host can actually encrypt for.
|
|
252
|
+
*
|
|
253
|
+
* A device without a public key has never run a build that registers one:
|
|
254
|
+
* wrapping for it produces a push it cannot open.
|
|
255
|
+
*/
|
|
256
|
+
const selectRecipients = (devices) => devices
|
|
257
|
+
.filter((d) => !!d.publicKey)
|
|
258
|
+
.map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
|
|
259
|
+
exports.selectRecipients = selectRecipients;
|
|
260
|
+
const getKeyPair = () => cachedKeyPair;
|
|
261
|
+
exports.getKeyPair = getKeyPair;
|
|
262
|
+
const getPublicKey = () => cachedExportedPublicKey;
|
|
263
|
+
exports.getPublicKey = getPublicKey;
|
|
264
|
+
/**
|
|
265
|
+
* Drop everything, keypair included, so this host does no crypto at all.
|
|
266
|
+
*
|
|
267
|
+
* Only the keyless path in `initCrypto` uses this. Keys on disk are untouched; a restart re-adopts them.
|
|
268
|
+
*/
|
|
269
|
+
const disableCrypto = () => {
|
|
270
|
+
cachedKeyPair = null;
|
|
271
|
+
cachedExportedPublicKey = null;
|
|
272
|
+
};
|
|
273
|
+
exports.disableCrypto = disableCrypto;
|
|
274
|
+
/**
|
|
275
|
+
* Encrypt a push body for the given recipient devices.
|
|
276
|
+
*
|
|
277
|
+
* Returns the wire fields the API expects: `body` carries the ciphertext and
|
|
278
|
+
* IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
|
|
279
|
+
* recipients need to derive the same secret back.
|
|
280
|
+
*/
|
|
281
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
282
|
+
if (!cachedKeyPair || !cachedExportedPublicKey)
|
|
283
|
+
throw new Error('Crypto not initialized');
|
|
284
|
+
assertAskWithinLimits(input.ask);
|
|
285
|
+
const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
286
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
287
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(JSON.stringify({
|
|
288
|
+
title: input.title,
|
|
289
|
+
body: input.body,
|
|
290
|
+
url: input.url,
|
|
291
|
+
cmd: input.cmd,
|
|
292
|
+
ask: input.ask,
|
|
293
|
+
answer: input.answer,
|
|
294
|
+
files: input.files,
|
|
295
|
+
keys: input.keys,
|
|
296
|
+
ifPrompt: input.ifPrompt,
|
|
297
|
+
context: input.context,
|
|
298
|
+
})));
|
|
299
|
+
const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
|
|
300
|
+
return {
|
|
301
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
302
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, cachedKeyPair.privateKey, recipients),
|
|
303
|
+
senderPublicKey: cachedExportedPublicKey,
|
|
304
|
+
isEncrypted: true,
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
308
|
+
/**
|
|
309
|
+
* Encrypt file content for the given recipient devices.
|
|
310
|
+
*/
|
|
311
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
312
|
+
if (!cachedKeyPair)
|
|
313
|
+
throw new Error('Crypto not initialized');
|
|
314
|
+
// Binary attachments arrive as a Buffer and must be encrypted byte for byte —
|
|
315
|
+
// running them through TextEncoder would UTF-8 mangle every non-ASCII byte.
|
|
316
|
+
// Both branches are views; subtle.encrypt honours byteOffset/byteLength, so a
|
|
317
|
+
// Buffer carved out of Node's pool encrypts only its own bytes.
|
|
318
|
+
const buffer = typeof content === 'string'
|
|
319
|
+
? new TextEncoder().encode(content)
|
|
320
|
+
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
|
321
|
+
const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
322
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
323
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
|
|
324
|
+
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
325
|
+
return {
|
|
326
|
+
ciphertext: Buffer.from(ciphertext),
|
|
327
|
+
iv: toBase64(iv.buffer),
|
|
328
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, cachedKeyPair.privateKey, recipients),
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
exports.encryptFileForDevices = encryptFileForDevices;
|
|
332
|
+
// ─── Opening a sealed push body ───
|
|
333
|
+
//
|
|
334
|
+
// Twin of the cli's `src/crypto.ts` (same wire format, same label parsing, same
|
|
335
|
+
// vectors in crypto-labels.vectors.json). An ask round trip opens the answer the
|
|
336
|
+
// phone sealed for this host with the Machine Device key.
|
|
337
|
+
/**
|
|
338
|
+
* Open this device's slot in a `deviceKeyMap` (the JSON `{ encryptedKey,
|
|
339
|
+
* keyIv }` `wrapForDevices` writes) and return the raw AES key inside —
|
|
340
|
+
* the per-file key for an attachment, or the message key for a push body.
|
|
341
|
+
* The ECDH secret is derived from this device's private half and the
|
|
342
|
+
* sender's `senderPublicKey`; anything but that pairing fails the GCM tag
|
|
343
|
+
* and rejects. Requires initCrypto().
|
|
344
|
+
*/
|
|
345
|
+
const unwrapDeviceKeyRaw = async (entryJson, senderPublicKeyRaw) => {
|
|
346
|
+
if (!cachedKeyPair)
|
|
347
|
+
throw new Error('Crypto not initialized');
|
|
348
|
+
const entry = JSON.parse(entryJson); // fields are type-checked on the next line
|
|
349
|
+
if (typeof entry.encryptedKey !== 'string' || typeof entry.keyIv !== 'string') {
|
|
350
|
+
throw new Error('deviceKeyMap entry is not { encryptedKey, keyIv }');
|
|
351
|
+
}
|
|
352
|
+
const sharedKey = await deriveAesKey(cachedKeyPair.privateKey, await importPublicKey(senderPublicKeyRaw));
|
|
353
|
+
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(entry.keyIv)) }, sharedKey, fromBase64(entry.encryptedKey));
|
|
354
|
+
};
|
|
355
|
+
const MAX_ASK_ACTIONS = 10;
|
|
356
|
+
const MAX_ACTION_ID_CHARS = 64;
|
|
357
|
+
const MAX_ACTION_LABEL_CHARS = 100;
|
|
358
|
+
const MAX_ASK_ID_CHARS = 128;
|
|
359
|
+
/** Most files the summary lists, and the longest path it shows (bytes): the summary rides in a size-capped body. */
|
|
360
|
+
const MAX_CONTEXT_LISTED = 20;
|
|
361
|
+
const MAX_CONTEXT_PATH_BYTES = 128;
|
|
362
|
+
/** A device id as the account issues it (the cli's `subscriber-trust` DEVICE_ID). */
|
|
363
|
+
const ASK_DEVICE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
364
|
+
/**
|
|
365
|
+
* The reader drops a whole label that is over its limits, so a question sealed
|
|
366
|
+
* past them would arrive as one with no `ask` label. Refuse at the seal instead.
|
|
367
|
+
*/
|
|
368
|
+
const assertAskWithinLimits = (ask) => {
|
|
369
|
+
if (!ask)
|
|
370
|
+
return;
|
|
371
|
+
if (ask.id.length === 0 || ask.id.length > MAX_ASK_ID_CHARS)
|
|
372
|
+
throw new Error(`ask id must be 1 to ${MAX_ASK_ID_CHARS} characters`);
|
|
373
|
+
if (!ASK_DEVICE_ID.test(ask.device))
|
|
374
|
+
throw new Error('ask device id is not a device id');
|
|
375
|
+
const actions = ask.actions ?? [];
|
|
376
|
+
// The choices are the caller's to shorten: `ASK_CHOICES_CODE` lets `prepareAsk` say so (a property, not a class,
|
|
377
|
+
// so it survives a module registry reset).
|
|
378
|
+
const choices = (message) => Object.assign(new Error(message), { code: exports.ASK_CHOICES_CODE });
|
|
379
|
+
if (actions.length > MAX_ASK_ACTIONS)
|
|
380
|
+
throw choices(`a question takes at most ${MAX_ASK_ACTIONS} choices`);
|
|
381
|
+
if (actions.some((a) => a.id.length > MAX_ACTION_ID_CHARS))
|
|
382
|
+
throw choices(`a choice id is over ${MAX_ACTION_ID_CHARS} characters`);
|
|
383
|
+
if (actions.some((a) => a.label.length > MAX_ACTION_LABEL_CHARS))
|
|
384
|
+
throw choices(`a choice label is over ${MAX_ACTION_LABEL_CHARS} characters`);
|
|
385
|
+
};
|
|
386
|
+
/** The `code` of the errors above that a shorter choice list fixes. */
|
|
387
|
+
exports.ASK_CHOICES_CODE = 'ask-choices-over-limits';
|
|
388
|
+
/** Named keys inside a sealed command: non-empty strings only, like the cli socket path's
|
|
389
|
+
* `isKeyList` — the listener allowlists the names. */
|
|
390
|
+
const sealedKeys = (value) => {
|
|
391
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
392
|
+
return undefined;
|
|
393
|
+
if (!value.every((item) => typeof item === 'string'))
|
|
394
|
+
return undefined;
|
|
395
|
+
return [...value];
|
|
396
|
+
};
|
|
397
|
+
const fileMetas = (value) => {
|
|
398
|
+
if (!Array.isArray(value))
|
|
399
|
+
return undefined;
|
|
400
|
+
const metas = [];
|
|
401
|
+
for (const item of value) {
|
|
402
|
+
if (typeof item !== 'object' || item === null)
|
|
403
|
+
return undefined;
|
|
404
|
+
if (!('iv' in item) || !('name' in item) || !('type' in item) || !('size' in item))
|
|
405
|
+
return undefined;
|
|
406
|
+
const { iv, name, type, size } = item;
|
|
407
|
+
if (typeof iv !== 'string' || typeof name !== 'string' || typeof type !== 'string' || typeof size !== 'number')
|
|
408
|
+
return undefined;
|
|
409
|
+
metas.push({ iv, name, type, size });
|
|
410
|
+
}
|
|
411
|
+
return metas;
|
|
412
|
+
};
|
|
413
|
+
const commandLabel = (value) => {
|
|
414
|
+
if (typeof value !== 'object' || value === null)
|
|
415
|
+
return undefined;
|
|
416
|
+
if (!('n' in value) || !('session' in value) || !('device' in value))
|
|
417
|
+
return undefined;
|
|
418
|
+
const { n, session, device } = value;
|
|
419
|
+
if (typeof n !== 'number' || typeof session !== 'string' || typeof device !== 'string')
|
|
420
|
+
return undefined;
|
|
421
|
+
if (!('from' in value) || value.from === undefined)
|
|
422
|
+
return { n, session, device };
|
|
423
|
+
if (typeof value.from !== 'string')
|
|
424
|
+
return undefined;
|
|
425
|
+
return { n, session, device, from: value.from };
|
|
426
|
+
};
|
|
427
|
+
const askActions = (value) => {
|
|
428
|
+
if (!Array.isArray(value) || value.length > MAX_ASK_ACTIONS)
|
|
429
|
+
return undefined;
|
|
430
|
+
const actions = [];
|
|
431
|
+
for (const item of value) {
|
|
432
|
+
if (typeof item !== 'object' || item === null || !('id' in item) || !('label' in item))
|
|
433
|
+
return undefined;
|
|
434
|
+
const { id, label } = item;
|
|
435
|
+
if (typeof id !== 'string' || id.length > MAX_ACTION_ID_CHARS)
|
|
436
|
+
return undefined;
|
|
437
|
+
if (typeof label !== 'string' || label.length > MAX_ACTION_LABEL_CHARS)
|
|
438
|
+
return undefined;
|
|
439
|
+
if (!('style' in item) || item.style === undefined) {
|
|
440
|
+
actions.push({ id, label });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (typeof item.style !== 'string')
|
|
444
|
+
return undefined;
|
|
445
|
+
actions.push({ id, label, style: item.style });
|
|
446
|
+
}
|
|
447
|
+
return actions;
|
|
448
|
+
};
|
|
449
|
+
const askLabel = (value) => {
|
|
450
|
+
if (typeof value !== 'object' || value === null)
|
|
451
|
+
return undefined;
|
|
452
|
+
if (!('id' in value) || !('device' in value))
|
|
453
|
+
return undefined;
|
|
454
|
+
const { id, device } = value;
|
|
455
|
+
if (typeof id !== 'string' || typeof device !== 'string')
|
|
456
|
+
return undefined;
|
|
457
|
+
if (id === '' || id.length > MAX_ASK_ID_CHARS || !ASK_DEVICE_ID.test(device))
|
|
458
|
+
return undefined;
|
|
459
|
+
const label = { id, device };
|
|
460
|
+
if ('actions' in value && value.actions !== undefined) {
|
|
461
|
+
const actions = askActions(value.actions);
|
|
462
|
+
if (!actions)
|
|
463
|
+
return undefined;
|
|
464
|
+
label.actions = actions;
|
|
465
|
+
}
|
|
466
|
+
if ('placeholder' in value && value.placeholder !== undefined) {
|
|
467
|
+
if (typeof value.placeholder !== 'string')
|
|
468
|
+
return undefined;
|
|
469
|
+
label.placeholder = value.placeholder;
|
|
470
|
+
}
|
|
471
|
+
return label;
|
|
472
|
+
};
|
|
473
|
+
const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
474
|
+
const contextFile = (value) => {
|
|
475
|
+
if (typeof value !== 'object' || value === null)
|
|
476
|
+
return undefined;
|
|
477
|
+
const { path, status, added, removed } = value;
|
|
478
|
+
const binary = value.binary;
|
|
479
|
+
if (typeof path !== 'string' || path === '' || Buffer.byteLength(path) > MAX_CONTEXT_PATH_BYTES)
|
|
480
|
+
return undefined;
|
|
481
|
+
if (status !== 'M' && status !== 'A' && status !== 'D' && status !== '?')
|
|
482
|
+
return undefined;
|
|
483
|
+
const a = count(added);
|
|
484
|
+
const r = count(removed);
|
|
485
|
+
if (a === undefined || r === undefined)
|
|
486
|
+
return undefined;
|
|
487
|
+
if (binary === undefined)
|
|
488
|
+
return { path, status, added: a, removed: r };
|
|
489
|
+
return binary === true ? { path, status, added: a, removed: r, binary } : undefined;
|
|
490
|
+
};
|
|
491
|
+
/** The `context` summary of a sealed question, or `undefined` when it is absent or not well formed (the question still opens without it). */
|
|
492
|
+
const askContext = (value) => {
|
|
493
|
+
if (typeof value !== 'object' || value === null)
|
|
494
|
+
return undefined;
|
|
495
|
+
const v = value;
|
|
496
|
+
if (v.diff !== 'ok' && v.diff !== 'no_repo' && v.diff !== 'failed')
|
|
497
|
+
return undefined;
|
|
498
|
+
const files = count(v.files);
|
|
499
|
+
const added = count(v.added);
|
|
500
|
+
const removed = count(v.removed);
|
|
501
|
+
const moreFiles = count(v.moreFiles);
|
|
502
|
+
const screenLines = count(v.screenLines);
|
|
503
|
+
const masked = count(v.masked);
|
|
504
|
+
if (files === undefined || added === undefined || removed === undefined)
|
|
505
|
+
return undefined;
|
|
506
|
+
if (moreFiles === undefined || screenLines === undefined || masked === undefined)
|
|
507
|
+
return undefined;
|
|
508
|
+
if (!Array.isArray(v.listed) || v.listed.length > MAX_CONTEXT_LISTED)
|
|
509
|
+
return undefined;
|
|
510
|
+
const listed = [];
|
|
511
|
+
for (const item of v.listed) {
|
|
512
|
+
const file = contextFile(item);
|
|
513
|
+
if (!file)
|
|
514
|
+
return undefined;
|
|
515
|
+
listed.push(file);
|
|
516
|
+
}
|
|
517
|
+
const cut = v.cut;
|
|
518
|
+
if (typeof cut !== 'object' || cut === null || typeof cut.diff !== 'boolean' || typeof cut.screen !== 'boolean')
|
|
519
|
+
return undefined;
|
|
520
|
+
return { diff: v.diff, files, added, removed, listed, moreFiles, screenLines, masked, cut: { diff: cut.diff, screen: cut.screen } };
|
|
521
|
+
};
|
|
522
|
+
exports.askContext = askContext;
|
|
523
|
+
const answerLabel = (value) => {
|
|
524
|
+
if (typeof value !== 'object' || value === null || !('ask' in value))
|
|
525
|
+
return undefined;
|
|
526
|
+
if (typeof value.ask !== 'string' || value.ask === '' || value.ask.length > MAX_ASK_ID_CHARS)
|
|
527
|
+
return undefined;
|
|
528
|
+
const label = { ask: value.ask };
|
|
529
|
+
if ('actionId' in value && value.actionId !== undefined) {
|
|
530
|
+
if (typeof value.actionId !== 'string')
|
|
531
|
+
return undefined;
|
|
532
|
+
label.actionId = value.actionId;
|
|
533
|
+
}
|
|
534
|
+
if ('exit' in value && value.exit !== undefined) {
|
|
535
|
+
if (value.exit !== true)
|
|
536
|
+
return undefined;
|
|
537
|
+
label.exit = true;
|
|
538
|
+
}
|
|
539
|
+
return label;
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Decrypt a push body sealed by `encryptPushBodyForDevices` (any client's
|
|
543
|
+
* twin of it) for this device: `body` is the JSON `{ ciphertext, iv }`
|
|
544
|
+
* envelope, the message key comes out of this device's `deviceKeyMap` slot.
|
|
545
|
+
* Returns the `{ title, body, url }` the sender put in, the `cmd` label of a
|
|
546
|
+
* sealed command, the `ask` / `answer` label of a sealed question / answer and
|
|
547
|
+
* the `files` list and the `context` summary — each only when well formed. `context` is read from any body;
|
|
548
|
+
* whether it belongs there (a question) is the caller's to decide.
|
|
549
|
+
*/
|
|
550
|
+
const decryptPushBodyForDevice = async (body, entryJson, senderPublicKeyRaw) => {
|
|
551
|
+
const envelope = JSON.parse(body); // fields are type-checked on the next line
|
|
552
|
+
if (typeof envelope.ciphertext !== 'string' || typeof envelope.iv !== 'string') {
|
|
553
|
+
throw new Error('push body is not a { ciphertext, iv } envelope');
|
|
554
|
+
}
|
|
555
|
+
const rawKey = await unwrapDeviceKeyRaw(entryJson, senderPublicKeyRaw);
|
|
556
|
+
const messageKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']);
|
|
557
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(envelope.iv)) }, messageKey, fromBase64(envelope.ciphertext));
|
|
558
|
+
// A SyntaxError from JSON.parse quotes the start of the text it could not read, and this text
|
|
559
|
+
// is authenticated plaintext: the message must not carry it. Same for the TypeError of `null`.
|
|
560
|
+
let parsed;
|
|
561
|
+
try {
|
|
562
|
+
parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
throw new Error('sealed body is not JSON');
|
|
566
|
+
}
|
|
567
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
568
|
+
throw new Error('sealed body is not a JSON object');
|
|
569
|
+
// Every field is type-checked below; a wrong shape is dropped, not trusted.
|
|
570
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
571
|
+
return { title: str(parsed.title), body: str(parsed.body), url: str(parsed.url), cmd: commandLabel(parsed.cmd), ask: askLabel(parsed.ask), answer: answerLabel(parsed.answer), files: fileMetas(parsed.files), keys: sealedKeys(parsed.keys), ifPrompt: str(parsed.ifPrompt), context: (0, exports.askContext)(parsed.context) };
|
|
572
|
+
};
|
|
573
|
+
exports.decryptPushBodyForDevice = decryptPushBodyForDevice;
|
|
574
|
+
// ─── Comparing device keys ───
|
|
575
|
+
/** DER header of a P-256 SPKI whose key is an uncompressed point; the 65-byte X9.63 point follows it. */
|
|
576
|
+
const P256_SPKI_PREFIX = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
|
|
577
|
+
const P256_POINT_BYTES = 65;
|
|
578
|
+
/**
|
|
579
|
+
* Whether two strings are the same device public key (twin of the cli's
|
|
580
|
+
* `samePublicKey`). A device key has exactly one spelling — standard padded
|
|
581
|
+
* Base64 of the 91-byte SPKI with an uncompressed point, what WebCrypto's
|
|
582
|
+
* `exportKey('spki')` and the app write — so two keys are the same exactly
|
|
583
|
+
* when their strings are. A string in any other spelling equals nothing,
|
|
584
|
+
* itself included.
|
|
585
|
+
*/
|
|
586
|
+
const samePublicKey = (a, b) => {
|
|
587
|
+
if (a !== b)
|
|
588
|
+
return false;
|
|
589
|
+
const der = Buffer.from(a, 'base64');
|
|
590
|
+
return der.length === P256_SPKI_PREFIX.length + P256_POINT_BYTES
|
|
591
|
+
&& der.subarray(0, P256_SPKI_PREFIX.length).equals(P256_SPKI_PREFIX)
|
|
592
|
+
&& der[P256_SPKI_PREFIX.length] === 0x04
|
|
593
|
+
&& der.toString('base64') === a;
|
|
594
|
+
};
|
|
595
|
+
exports.samePublicKey = samePublicKey;
|
|
596
|
+
/**
|
|
597
|
+
* The x coordinate of a device key's point, in hex (twin of the cli's `publicKeySender`): what the listener counts
|
|
598
|
+
* sealed command numbers by in the trust file's `commandKeys`. null for a string that is not a device key.
|
|
599
|
+
*/
|
|
600
|
+
const publicKeySender = (spkiBase64) => {
|
|
601
|
+
if (!(0, exports.samePublicKey)(spkiBase64, spkiBase64))
|
|
602
|
+
return null;
|
|
603
|
+
const point = P256_SPKI_PREFIX.length;
|
|
604
|
+
return Buffer.from(spkiBase64, 'base64').subarray(point + 1, point + 33).toString('hex');
|
|
605
|
+
};
|
|
606
|
+
exports.publicKeySender = publicKeySender;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { ToolError } from './types.js';
|
|
3
|
+
export declare const textResult: (obj: unknown) => CallToolResult;
|
|
4
|
+
export declare const errorResult: (obj: ToolError) => CallToolResult;
|
|
5
|
+
export declare const hookNotConfiguredError: () => CallToolResult;
|
|
6
|
+
export declare const formatToolError: (err: unknown) => CallToolResult;
|
|
7
|
+
//# sourceMappingURL=error-format.d.ts.map
|