@zeph-to/cli 1.27.1 → 2.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/README.md +59 -29
- package/dist/cli.js +6 -1
- package/dist/crypto.d.ts +80 -55
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +178 -184
- package/dist/gate.d.ts +47 -2
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +50 -4
- package/dist/input-sequencer.d.ts +66 -0
- package/dist/input-sequencer.d.ts.map +1 -0
- package/dist/input-sequencer.js +127 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +7 -3
- package/dist/listener.d.ts +119 -2
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +549 -40
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +15 -5
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/zeph-hook.d.ts +9 -0
- package/dist/zeph-hook.d.ts.map +1 -1
- package/dist/zeph-hook.js +81 -46
- package/package.json +1 -1
package/dist/crypto.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Per-device encryption for the Hook SDK — 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
|
-
*
|
|
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 host holds its own ECDH keypair in ~/.zeph/device-keys.json. The
|
|
11
|
+
* private half is generated here and never leaves — the server only ever
|
|
12
|
+
* sees public keys. A push is encrypted once with a random AES key, and
|
|
13
|
+
* that key is wrapped separately for each of the user's registered devices
|
|
14
|
+
* using ECDH(this host, that device). Same keypair the stream frames below
|
|
15
|
+
* already used; push and file bodies now share it.
|
|
25
16
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
17
|
+
* What it does not give you:
|
|
18
|
+
* • Forward secrecy — the ECDH secret for a given (sender, device) pair is
|
|
19
|
+
* static, so a compromise of either private key retroactively opens every
|
|
20
|
+
* push wrapped for that pair.
|
|
21
|
+
* • Authenticity beyond the key pairing — nothing signs `senderPublicKey`.
|
|
22
|
+
*
|
|
23
|
+
* Superseded scheme: a single account-wide keypair whose private half the
|
|
24
|
+
* backend escrowed so it could sync to new devices. Key escrow was removed
|
|
25
|
+
* server-side (zeph@8a6d21b) and `GET /users/me/keys` has returned a public
|
|
26
|
+
* key only ever since. This client used to react by generating a fresh
|
|
27
|
+
* account keypair and PUTting it back — which overwrote the account public
|
|
28
|
+
* key and encrypted to a key no device held. Both that upload path and the
|
|
29
|
+
* account keypair are gone.
|
|
30
30
|
*/
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
-
exports.
|
|
32
|
+
exports.decryptEphemeral = exports.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.initCrypto = void 0;
|
|
33
33
|
/// <reference lib="dom" />
|
|
34
34
|
const fs_1 = require("fs");
|
|
35
35
|
const os_1 = require("os");
|
|
@@ -91,108 +91,68 @@ const encrypt = async (plaintext, senderPrivateKey, recipientPublicKey) => {
|
|
|
91
91
|
keyIv: toBase64(keyIv.buffer),
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
const sharedKey = await deriveAesKey(senderPrivateKey, recipientPublicKey);
|
|
101
|
-
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
102
|
-
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
103
|
-
const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawFileKey);
|
|
104
|
-
return {
|
|
105
|
-
ciphertext: Buffer.from(ciphertext),
|
|
106
|
-
iv: toBase64(iv.buffer),
|
|
107
|
-
encryptedKey: toBase64(encryptedKey),
|
|
108
|
-
keyIv: toBase64(keyIv.buffer),
|
|
109
|
-
};
|
|
94
|
+
const decrypt = async (payload, recipientPrivateKey, senderPublicKey) => {
|
|
95
|
+
const sharedKey = await deriveAesKey(recipientPrivateKey, senderPublicKey);
|
|
96
|
+
const rawMessageKey = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(payload.keyIv)) }, sharedKey, fromBase64(payload.encryptedKey));
|
|
97
|
+
const messageKey = await crypto.subtle.importKey('raw', rawMessageKey, { name: 'AES-GCM' }, false, ['decrypt']);
|
|
98
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(payload.iv)) }, messageKey, fromBase64(payload.ciphertext));
|
|
99
|
+
return new TextDecoder().decode(plaintext);
|
|
110
100
|
};
|
|
111
|
-
// ───
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
// ─── Superseded account keystore ───
|
|
102
|
+
//
|
|
103
|
+
// Held the escrowed account keypair. Nothing reads it any more; it is deleted
|
|
104
|
+
// on sight so a private key the server no longer issues stops sitting on disk.
|
|
105
|
+
const LEGACY_KEYS_PATH = (0, path_1.join)((0, os_1.homedir)(), '.config', 'zeph', 'keys.json');
|
|
106
|
+
const deleteLegacyKeys = () => {
|
|
115
107
|
try {
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return null;
|
|
108
|
+
(0, fs_1.unlinkSync)(LEGACY_KEYS_PATH);
|
|
120
109
|
}
|
|
121
|
-
}
|
|
122
|
-
const storeKeys = (exported) => {
|
|
123
|
-
(0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
124
|
-
(0, fs_1.writeFileSync)(KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
|
|
110
|
+
catch { /* not present — fine */ }
|
|
125
111
|
};
|
|
126
112
|
// ─── Cached state ───
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Whether the account has opted into E2E (`encryptionEnabled`, ADR-0008).
|
|
115
|
+
* Kept separate from the device keypair because that keypair also backs
|
|
116
|
+
* stream frames, which are encrypted regardless — reading its presence as
|
|
117
|
+
* consent would turn push encryption on for everyone.
|
|
118
|
+
*/
|
|
119
|
+
let pushCryptoEnabled = false;
|
|
120
|
+
let cachedLegacyPublicKey = null;
|
|
130
121
|
let initPromise = null;
|
|
131
122
|
/**
|
|
132
|
-
* Initialize
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
123
|
+
* Initialize push/file encryption.
|
|
124
|
+
*
|
|
125
|
+
* Encryption turns on only when the account has explicitly opted in —
|
|
126
|
+
* `encryptionEnabled` from `GET /users/me/keys` is the single authoritative
|
|
127
|
+
* signal (ADR-0008). Server unreachable or flag off leaves it off and every
|
|
128
|
+
* send goes out in the clear.
|
|
129
|
+
*
|
|
130
|
+
* When it is on, this delegates to the per-device keypair: nothing is asked
|
|
131
|
+
* of the server but the flag, and nothing is ever uploaded.
|
|
132
|
+
*
|
|
133
|
+
* Safe to call concurrently — deduplicates to a single init.
|
|
134
|
+
* Returns this host's public key when encryption is active, '' otherwise.
|
|
136
135
|
*/
|
|
137
136
|
const initCrypto = (apiKey, baseUrl) => {
|
|
138
137
|
if (initPromise)
|
|
139
138
|
return initPromise;
|
|
140
139
|
initPromise = (async () => {
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Server says encryption disabled — skip crypto init
|
|
147
|
-
if (serverResult && !serverResult.encryptionEnabled) {
|
|
148
|
-
cachedKeyPair = null;
|
|
149
|
-
cachedExportedPublicKey = null;
|
|
150
|
-
cachedOwnPublicKey = null;
|
|
151
|
-
return '';
|
|
152
|
-
}
|
|
153
|
-
if (serverResult?.keys) {
|
|
154
|
-
// Server has keys — adopt them (server is source of truth)
|
|
155
|
-
if (!stored || stored.publicKey !== serverResult.keys.publicKey) {
|
|
156
|
-
storeKeys(serverResult.keys);
|
|
157
|
-
}
|
|
158
|
-
cachedKeyPair = await importKeyPair(serverResult.keys);
|
|
159
|
-
cachedExportedPublicKey = serverResult.keys.publicKey;
|
|
160
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
161
|
-
return serverResult.keys.publicKey;
|
|
162
|
-
}
|
|
163
|
-
// Server has no keys
|
|
164
|
-
if (stored) {
|
|
165
|
-
// Upload local keys to server
|
|
166
|
-
await uploadServerKeys(stored, apiKey, baseUrl);
|
|
167
|
-
cachedKeyPair = await importKeyPair(stored);
|
|
168
|
-
cachedExportedPublicKey = stored.publicKey;
|
|
169
|
-
cachedOwnPublicKey = cachedKeyPair.publicKey;
|
|
170
|
-
return stored.publicKey;
|
|
171
|
-
}
|
|
172
|
-
// No keys anywhere — generate + upload
|
|
173
|
-
const keyPair = await generateKeyPair();
|
|
174
|
-
const exported = await exportKeyPair(keyPair);
|
|
175
|
-
storeKeys(exported);
|
|
176
|
-
await uploadServerKeys(exported, apiKey, baseUrl);
|
|
177
|
-
cachedKeyPair = keyPair;
|
|
178
|
-
cachedExportedPublicKey = exported.publicKey;
|
|
179
|
-
cachedOwnPublicKey = keyPair.publicKey;
|
|
180
|
-
return exported.publicKey;
|
|
140
|
+
// Local-only mode (no apiKey): used by tests and offline setups. There is
|
|
141
|
+
// no flag to consult, so encryption stays off rather than being inferred.
|
|
142
|
+
if (!apiKey) {
|
|
143
|
+
pushCryptoEnabled = false;
|
|
144
|
+
return '';
|
|
181
145
|
}
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
146
|
+
const state = await fetchEncryptionState(apiKey, baseUrl);
|
|
147
|
+
if (state)
|
|
148
|
+
deleteLegacyKeys();
|
|
149
|
+
if (!state?.encryptionEnabled) {
|
|
150
|
+
pushCryptoEnabled = false;
|
|
151
|
+
return '';
|
|
188
152
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
cachedKeyPair = keyPair;
|
|
193
|
-
cachedExportedPublicKey = exported.publicKey;
|
|
194
|
-
cachedOwnPublicKey = keyPair.publicKey;
|
|
195
|
-
return exported.publicKey;
|
|
153
|
+
const publicKey = await (0, exports.initDeviceCrypto)();
|
|
154
|
+
pushCryptoEnabled = true;
|
|
155
|
+
return publicKey;
|
|
196
156
|
})().catch((err) => {
|
|
197
157
|
initPromise = null;
|
|
198
158
|
throw err;
|
|
@@ -200,44 +160,74 @@ const initCrypto = (apiKey, baseUrl) => {
|
|
|
200
160
|
return initPromise;
|
|
201
161
|
};
|
|
202
162
|
exports.initCrypto = initCrypto;
|
|
203
|
-
const
|
|
163
|
+
const fetchEncryptionState = async (apiKey, baseUrl) => {
|
|
204
164
|
try {
|
|
205
165
|
const url = `${(baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, '')}/users/me/keys`;
|
|
206
166
|
const res = await fetch(url, { headers: { 'X-API-Key': apiKey } });
|
|
207
167
|
if (!res.ok)
|
|
208
168
|
return null;
|
|
209
169
|
const json = await res.json();
|
|
210
|
-
|
|
211
|
-
const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
|
|
170
|
+
cachedLegacyPublicKey = json.data?.encryptionKeys?.publicKey ?? null;
|
|
212
171
|
return {
|
|
213
|
-
|
|
214
|
-
|
|
172
|
+
encryptionEnabled: json.data?.encryptionEnabled === true,
|
|
173
|
+
legacyPublicKey: cachedLegacyPublicKey,
|
|
215
174
|
};
|
|
216
175
|
}
|
|
217
176
|
catch {
|
|
218
177
|
return null;
|
|
219
178
|
}
|
|
220
179
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Keep only devices this host can actually encrypt for.
|
|
182
|
+
*
|
|
183
|
+
* A device without a public key has never run a build that registers one, and
|
|
184
|
+
* a device still advertising the account-wide key has not migrated to
|
|
185
|
+
* per-device E2E — wrapping for either produces a push it cannot open, which
|
|
186
|
+
* is worse than sending plaintext it can read.
|
|
187
|
+
*/
|
|
188
|
+
const selectRecipients = (devices) => devices
|
|
189
|
+
.filter((d) => !!d.publicKey && d.publicKey !== cachedLegacyPublicKey)
|
|
190
|
+
.map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
|
|
191
|
+
exports.selectRecipients = selectRecipients;
|
|
192
|
+
/**
|
|
193
|
+
* Wrap one raw AES key for every recipient device.
|
|
194
|
+
*
|
|
195
|
+
* The payload is encrypted once and only the wrapped key repeats, so an
|
|
196
|
+
* attachment costs one S3 object regardless of device count. A recipient
|
|
197
|
+
* whose public key will not import is dropped rather than failing the send —
|
|
198
|
+
* one broken device record must not silence every push.
|
|
199
|
+
*/
|
|
200
|
+
const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
|
|
201
|
+
const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
|
|
202
|
+
try {
|
|
203
|
+
const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
|
|
204
|
+
const keyIv = crypto.getRandomValues(new Uint8Array(12));
|
|
205
|
+
const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
|
|
206
|
+
return [
|
|
207
|
+
deviceId,
|
|
208
|
+
JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
const keyMap = {};
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (entry)
|
|
219
|
+
keyMap[entry[0]] = entry[1];
|
|
235
220
|
}
|
|
236
|
-
|
|
221
|
+
if (Object.keys(keyMap).length === 0)
|
|
222
|
+
throw new Error('No recipient device accepted the wrapped key');
|
|
223
|
+
return keyMap;
|
|
237
224
|
};
|
|
238
|
-
|
|
225
|
+
// Gated on the account opt-in, not on the keypair's existence: the same
|
|
226
|
+
// keypair backs stream frames, which are encrypted regardless, so presence
|
|
227
|
+
// alone would turn push encryption on for accounts that never asked.
|
|
228
|
+
const getKeyPair = () => (pushCryptoEnabled ? deviceKeyPair : null);
|
|
239
229
|
exports.getKeyPair = getKeyPair;
|
|
240
|
-
const getPublicKey = () =>
|
|
230
|
+
const getPublicKey = () => (pushCryptoEnabled ? deviceExportedPublicKey : null);
|
|
241
231
|
exports.getPublicKey = getPublicKey;
|
|
242
232
|
/**
|
|
243
233
|
* Drop the cached keys so every later send goes out as plaintext.
|
|
@@ -251,73 +241,55 @@ exports.getPublicKey = getPublicKey;
|
|
|
251
241
|
* circuits on `cryptoInitialized`. A restart after an upgrade re-adopts them.
|
|
252
242
|
*/
|
|
253
243
|
const disableCrypto = () => {
|
|
254
|
-
|
|
255
|
-
cachedExportedPublicKey = null;
|
|
256
|
-
cachedOwnPublicKey = null;
|
|
244
|
+
pushCryptoEnabled = false;
|
|
257
245
|
};
|
|
258
246
|
exports.disableCrypto = disableCrypto;
|
|
259
247
|
/**
|
|
260
|
-
* Encrypt push body for
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
throw new Error('Crypto not initialized');
|
|
266
|
-
const recipientKey = await importPublicKey(recipientPublicKeyRaw);
|
|
267
|
-
const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, recipientKey);
|
|
268
|
-
return {
|
|
269
|
-
body: JSON.stringify({ ciphertext: payload.ciphertext, iv: payload.iv }),
|
|
270
|
-
encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
|
|
271
|
-
senderPublicKey: cachedExportedPublicKey,
|
|
272
|
-
isEncrypted: true,
|
|
273
|
-
};
|
|
274
|
-
};
|
|
275
|
-
exports.encryptPushBody = encryptPushBody;
|
|
276
|
-
/**
|
|
277
|
-
* Encrypt push body for self (all own devices).
|
|
248
|
+
* Encrypt a push body for the given recipient devices.
|
|
249
|
+
*
|
|
250
|
+
* Returns the wire fields the API expects: `body` carries the ciphertext and
|
|
251
|
+
* IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
|
|
252
|
+
* recipients need to derive the same secret back.
|
|
278
253
|
*/
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
254
|
+
const encryptPushBodyForDevices = async (input, recipients) => {
|
|
255
|
+
if (!deviceKeyPair || !deviceExportedPublicKey)
|
|
281
256
|
throw new Error('Crypto not initialized');
|
|
282
|
-
const
|
|
257
|
+
const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
258
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
259
|
+
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 })));
|
|
260
|
+
const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
|
|
283
261
|
return {
|
|
284
|
-
body: JSON.stringify({ ciphertext:
|
|
285
|
-
|
|
286
|
-
senderPublicKey:
|
|
262
|
+
body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
|
|
263
|
+
deviceKeyMap: await wrapForDevices(rawMessageKey, deviceKeyPair.privateKey, recipients),
|
|
264
|
+
senderPublicKey: deviceExportedPublicKey,
|
|
287
265
|
isEncrypted: true,
|
|
288
266
|
};
|
|
289
267
|
};
|
|
290
|
-
exports.
|
|
268
|
+
exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
|
|
291
269
|
/**
|
|
292
|
-
* Encrypt file content for
|
|
293
|
-
* Returns encrypted buffer + key material for file attachment metadata.
|
|
270
|
+
* Encrypt file content for the given recipient devices.
|
|
294
271
|
*/
|
|
295
|
-
const
|
|
296
|
-
if (!
|
|
272
|
+
const encryptFileForDevices = async (content, recipients) => {
|
|
273
|
+
if (!deviceKeyPair)
|
|
297
274
|
throw new Error('Crypto not initialized');
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
*/
|
|
310
|
-
const encryptFileForSelf = async (content) => {
|
|
311
|
-
if (!cachedKeyPair || !cachedOwnPublicKey)
|
|
312
|
-
throw new Error('Crypto not initialized');
|
|
313
|
-
const result = await encryptFileContent(content, cachedKeyPair.privateKey, cachedOwnPublicKey);
|
|
275
|
+
// Binary content must be encrypted byte for byte — running a Buffer through
|
|
276
|
+
// TextEncoder would UTF-8 mangle every non-ASCII byte. Today's only caller
|
|
277
|
+
// passes markdown, so this is a guard against the first binary sender rather
|
|
278
|
+
// than a live fix (the MCP twin took that bug in production).
|
|
279
|
+
const buffer = typeof content === 'string'
|
|
280
|
+
? new TextEncoder().encode(content)
|
|
281
|
+
: new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
|
282
|
+
const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
283
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
284
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
|
|
285
|
+
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
|
314
286
|
return {
|
|
315
|
-
ciphertext:
|
|
316
|
-
iv:
|
|
317
|
-
|
|
287
|
+
ciphertext: Buffer.from(ciphertext),
|
|
288
|
+
iv: toBase64(iv.buffer),
|
|
289
|
+
deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
|
|
318
290
|
};
|
|
319
291
|
};
|
|
320
|
-
exports.
|
|
292
|
+
exports.encryptFileForDevices = encryptFileForDevices;
|
|
321
293
|
const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph');
|
|
322
294
|
const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
|
|
323
295
|
let deviceKeyPair = null;
|
|
@@ -377,3 +349,25 @@ const encryptEphemeral = async (plaintext, recipientPublicKeyRaw) => {
|
|
|
377
349
|
return { ...payload, senderPublicKey: deviceExportedPublicKey };
|
|
378
350
|
};
|
|
379
351
|
exports.encryptEphemeral = encryptEphemeral;
|
|
352
|
+
/**
|
|
353
|
+
* Open an ephemeral envelope addressed to this device — the exact inverse of
|
|
354
|
+
* encryptEphemeral, and of the web's `encrypt` from @zeph/crypto, which
|
|
355
|
+
* produces the same five fields.
|
|
356
|
+
*
|
|
357
|
+
* Rejects rather than returning null: AES-GCM is authenticated, so a throw
|
|
358
|
+
* here means the envelope was sealed for another key, tampered with, or is not
|
|
359
|
+
* an envelope at all. Callers must treat every rejection as a refusal — there
|
|
360
|
+
* is no partial result to fall back on. Requires initDeviceCrypto().
|
|
361
|
+
*
|
|
362
|
+
* Note that opening an envelope proves only that its sender holds the private
|
|
363
|
+
* half of `senderPublicKey`; nothing signs that field, so it authenticates the
|
|
364
|
+
* key pairing and not the sender. A caller that needs to know *which* peer
|
|
365
|
+
* sent this must compare `senderPublicKey` against a key it already trusts.
|
|
366
|
+
*/
|
|
367
|
+
const decryptEphemeral = async (payload) => {
|
|
368
|
+
if (!deviceKeyPair)
|
|
369
|
+
throw new Error('Device crypto not initialized');
|
|
370
|
+
const senderKey = await importPublicKey(payload.senderPublicKey);
|
|
371
|
+
return decrypt(payload, deviceKeyPair.privateKey, senderKey);
|
|
372
|
+
};
|
|
373
|
+
exports.decryptEphemeral = decryptEphemeral;
|
package/dist/gate.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface GateVerdict {
|
|
|
19
19
|
* (most non-Claude agents pass no counts): in normal mode the push still
|
|
20
20
|
* fires — preserving the historical always-push behavior of the dumb
|
|
21
21
|
* hooks — while quiet/loud now work everywhere.
|
|
22
|
+
*
|
|
23
|
+
* These defaults cannot rescue a quiet dial: quiet only lets a `high` marker
|
|
24
|
+
* through, and a hook with no turn facts has no marker either. That is why
|
|
25
|
+
* the installed templates pass `--pushmode-default normal` (see templates.ts)
|
|
26
|
+
* — for them quiet is not a lower volume, it is permanent silence.
|
|
22
27
|
*/
|
|
23
28
|
export declare const GATE_DEFAULTS: {
|
|
24
29
|
readonly toolCount: 2;
|
|
@@ -41,6 +46,46 @@ export declare const remoteMarkerPath: (hash: string) => string;
|
|
|
41
46
|
export declare const remoteDigest: (text: string) => string;
|
|
42
47
|
/** True when the user ran /zeph-mute for this project. */
|
|
43
48
|
export declare const isMuted: (dir: string) => boolean;
|
|
44
|
-
/**
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Push mode for an install that has never set a dial. The twin is
|
|
51
|
+
* plugin/hooks/gate.sh's missing-5th-argument default; the shared vectors
|
|
52
|
+
* never reach either one (every vector passes pushMode explicitly), so both
|
|
53
|
+
* sides pin it in their own tests.
|
|
54
|
+
*/
|
|
55
|
+
export declare const PUSHMODE_DEFAULT: GatePushMode;
|
|
56
|
+
/**
|
|
57
|
+
* `notify` flag naming the push mode to assume when the project has no dial.
|
|
58
|
+
* Written by templates.ts into every hook-driven agent's completion hook and
|
|
59
|
+
* read back in cli.ts — shared so the two can never drift apart.
|
|
60
|
+
*/
|
|
61
|
+
export declare const PUSHMODE_DEFAULT_FLAG = "pushmode-default";
|
|
62
|
+
/**
|
|
63
|
+
* The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
|
|
64
|
+
*
|
|
65
|
+
* Three failure shapes, three answers — "no dial" is the only one that gets
|
|
66
|
+
* the quiet default:
|
|
67
|
+
* - no dial file → `fallback` (PUSHMODE_DEFAULT unless the caller
|
|
68
|
+
* overrides it with --pushmode-default)
|
|
69
|
+
* - unusable dial file → normal. A missing project hash, an unreadable
|
|
70
|
+
* file, or a garbled/empty value is a broken
|
|
71
|
+
* setting, and resolving breakage to silence
|
|
72
|
+
* leaves the user with no symptom to debug. The
|
|
73
|
+
* point of the new default is quiet, not hidden
|
|
74
|
+
* errors.
|
|
75
|
+
* - readable dial file → whatever it says.
|
|
76
|
+
*/
|
|
77
|
+
export declare const readPushMode: (dir: string, fallback?: GatePushMode) => GatePushMode;
|
|
78
|
+
/**
|
|
79
|
+
* Push mode for a `--auto` notify: the user's dial if they set one, otherwise
|
|
80
|
+
* the mode named by `--pushmode-default`, otherwise the built-in quiet.
|
|
81
|
+
*
|
|
82
|
+
* The dial outranks the flag deliberately. The flag exists so an agent whose
|
|
83
|
+
* hook cannot participate in the heuristic still pushes out of the box; if it
|
|
84
|
+
* outranked the dial, `/zeph-quiet` would silently do nothing for that agent.
|
|
85
|
+
*
|
|
86
|
+
* A flag value that isn't one of the three modes resolves to `normal`, not to
|
|
87
|
+
* the quiet default — same rule as a garbled dial file. A caller that passes
|
|
88
|
+
* nonsense has a bug, and answering a bug with silence hides it.
|
|
89
|
+
*/
|
|
90
|
+
export declare const autoPushMode: (dir: string, flag: string | boolean | undefined) => GatePushMode;
|
|
46
91
|
//# sourceMappingURL=gate.d.ts.map
|
package/dist/gate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED
|
|
1
|
+
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAEnB,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
|
package/dist/gate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.readPushMode = exports.isMuted = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
|
|
3
|
+
exports.autoPushMode = exports.readPushMode = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Push-gate decision — the portable half of the Zeph Stop-hook logic.
|
|
6
6
|
*
|
|
@@ -30,6 +30,11 @@ const path_1 = require("path");
|
|
|
30
30
|
* (most non-Claude agents pass no counts): in normal mode the push still
|
|
31
31
|
* fires — preserving the historical always-push behavior of the dumb
|
|
32
32
|
* hooks — while quiet/loud now work everywhere.
|
|
33
|
+
*
|
|
34
|
+
* These defaults cannot rescue a quiet dial: quiet only lets a `high` marker
|
|
35
|
+
* through, and a hook with no turn facts has no marker either. That is why
|
|
36
|
+
* the installed templates pass `--pushmode-default normal` (see templates.ts)
|
|
37
|
+
* — for them quiet is not a lower volume, it is permanent silence.
|
|
33
38
|
*/
|
|
34
39
|
exports.GATE_DEFAULTS = {
|
|
35
40
|
toolCount: 2,
|
|
@@ -133,14 +138,41 @@ const isMuted = (dir) => {
|
|
|
133
138
|
return hash !== null && findStateFile('muted', hash) !== null;
|
|
134
139
|
};
|
|
135
140
|
exports.isMuted = isMuted;
|
|
136
|
-
/**
|
|
137
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Push mode for an install that has never set a dial. The twin is
|
|
143
|
+
* plugin/hooks/gate.sh's missing-5th-argument default; the shared vectors
|
|
144
|
+
* never reach either one (every vector passes pushMode explicitly), so both
|
|
145
|
+
* sides pin it in their own tests.
|
|
146
|
+
*/
|
|
147
|
+
exports.PUSHMODE_DEFAULT = 'quiet';
|
|
148
|
+
/**
|
|
149
|
+
* `notify` flag naming the push mode to assume when the project has no dial.
|
|
150
|
+
* Written by templates.ts into every hook-driven agent's completion hook and
|
|
151
|
+
* read back in cli.ts — shared so the two can never drift apart.
|
|
152
|
+
*/
|
|
153
|
+
exports.PUSHMODE_DEFAULT_FLAG = 'pushmode-default';
|
|
154
|
+
/**
|
|
155
|
+
* The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
|
|
156
|
+
*
|
|
157
|
+
* Three failure shapes, three answers — "no dial" is the only one that gets
|
|
158
|
+
* the quiet default:
|
|
159
|
+
* - no dial file → `fallback` (PUSHMODE_DEFAULT unless the caller
|
|
160
|
+
* overrides it with --pushmode-default)
|
|
161
|
+
* - unusable dial file → normal. A missing project hash, an unreadable
|
|
162
|
+
* file, or a garbled/empty value is a broken
|
|
163
|
+
* setting, and resolving breakage to silence
|
|
164
|
+
* leaves the user with no symptom to debug. The
|
|
165
|
+
* point of the new default is quiet, not hidden
|
|
166
|
+
* errors.
|
|
167
|
+
* - readable dial file → whatever it says.
|
|
168
|
+
*/
|
|
169
|
+
const readPushMode = (dir, fallback = exports.PUSHMODE_DEFAULT) => {
|
|
138
170
|
const hash = (0, exports.projectHash)(dir);
|
|
139
171
|
if (!hash)
|
|
140
172
|
return 'normal';
|
|
141
173
|
const file = findStateFile('pushmode', hash);
|
|
142
174
|
if (!file)
|
|
143
|
-
return
|
|
175
|
+
return fallback;
|
|
144
176
|
try {
|
|
145
177
|
return (0, exports.normalizePushMode)((0, fs_1.readFileSync)(file, 'utf-8').replace(/\s+/g, ''));
|
|
146
178
|
}
|
|
@@ -149,3 +181,17 @@ const readPushMode = (dir) => {
|
|
|
149
181
|
}
|
|
150
182
|
};
|
|
151
183
|
exports.readPushMode = readPushMode;
|
|
184
|
+
/**
|
|
185
|
+
* Push mode for a `--auto` notify: the user's dial if they set one, otherwise
|
|
186
|
+
* the mode named by `--pushmode-default`, otherwise the built-in quiet.
|
|
187
|
+
*
|
|
188
|
+
* The dial outranks the flag deliberately. The flag exists so an agent whose
|
|
189
|
+
* hook cannot participate in the heuristic still pushes out of the box; if it
|
|
190
|
+
* outranked the dial, `/zeph-quiet` would silently do nothing for that agent.
|
|
191
|
+
*
|
|
192
|
+
* A flag value that isn't one of the three modes resolves to `normal`, not to
|
|
193
|
+
* the quiet default — same rule as a garbled dial file. A caller that passes
|
|
194
|
+
* nonsense has a bug, and answering a bug with silence hides it.
|
|
195
|
+
*/
|
|
196
|
+
const autoPushMode = (dir, flag) => (0, exports.readPushMode)(dir, typeof flag === 'string' ? (0, exports.normalizePushMode)(flag) : exports.PUSHMODE_DEFAULT);
|
|
197
|
+
exports.autoPushMode = autoPushMode;
|