@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/dist/crypto.js CHANGED
@@ -1,35 +1,35 @@
1
1
  "use strict";
2
2
  /**
3
- * Device-shared encryption for Hook SDK — self-contained ECDH P-256 +
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
- * Threat model honesty (do not call this "E2E" without a footnote):
8
+ * How it works (ADR-0007):
9
9
  *
10
- * The Zeph backend persists the per-user private key in plaintext so it
11
- * can be synced down to a fresh device (fetchServerKeys / uploadServerKeys
12
- * below). That means the backend can decrypt any push body this is NOT
13
- * end-to-end in the standard sense. What it gives you is:
14
- * Protection against passive network observers
15
- * Protection against a leaked DB snapshot taken without the key store
16
- * • Cross-device readability (all your devices share one keypair)
17
- * What it does NOT give you:
18
- * • Protection against the Zeph backend itself
19
- * • Forward secrecy — encryptPushBodyForSelf / encryptFileForSelf do
20
- * ECDH(self, self), which collapses to a static derived key. A single
21
- * device compromise (since all your devices share the same keypair)
22
- * lets the attacker decrypt every past push for which they have the
23
- * ciphertext. The per-message AES key is random, but its wrap key is
24
- * static, so wrapped keys are decryptable forever.
10
+ * This 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
- * True E2E would require a per-device keypair (server stores only public
27
- * keys; senders wrap the message key once per recipient device public
28
- * key). That refactor is on the roadmap; until then, treat push bodies as
29
- * sensitive-but-not-secret.
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.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForSelf = exports.encryptFileForRecipient = exports.encryptPushBodyForSelf = exports.encryptPushBody = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.initCrypto = void 0;
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
- // ─── File encryption ───
95
- const encryptFileContent = async (content, senderPrivateKey, recipientPublicKey) => {
96
- const buffer = new TextEncoder().encode(content).buffer;
97
- const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
98
- const iv = crypto.getRandomValues(new Uint8Array(12));
99
- const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
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
- // ─── Key persistence (~/.config/zeph/keys.json) ───
112
- const KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.config', 'zeph');
113
- const KEYS_PATH = (0, path_1.join)(KEYS_DIR, 'keys.json');
114
- const loadStoredKeys = () => {
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
- return JSON.parse((0, fs_1.readFileSync)(KEYS_PATH, 'utf-8'));
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
- let cachedKeyPair = null;
128
- let cachedExportedPublicKey = null;
129
- let cachedOwnPublicKey = null;
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 crypto: sync keys with server, then fallback to local/generate.
133
- * Server is source of truth for per-user key pair.
134
- * Safe to call concurrently deduplicates to single init.
135
- * Returns the exported public key (Base64 SPKI).
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
- // Try local cache first
142
- const stored = loadStoredKeys();
143
- // Try server sync if API key available
144
- if (apiKey) {
145
- const serverResult = await fetchServerKeys(apiKey, baseUrl);
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
- // No API key local-only mode
183
- if (stored) {
184
- cachedKeyPair = await importKeyPair(stored);
185
- cachedExportedPublicKey = stored.publicKey;
186
- cachedOwnPublicKey = cachedKeyPair.publicKey;
187
- return stored.publicKey;
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 keyPair = await generateKeyPair();
190
- const exported = await exportKeyPair(keyPair);
191
- storeKeys(exported);
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 fetchServerKeys = async (apiKey, baseUrl) => {
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
- const keys = json.data?.encryptionKeys;
211
- const encryptionEnabled = json.data?.encryptionEnabled ?? (keys ? true : false);
170
+ cachedLegacyPublicKey = json.data?.encryptionKeys?.publicKey ?? null;
212
171
  return {
213
- keys: keys?.publicKey && keys?.privateKey ? keys : null,
214
- encryptionEnabled,
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
- // SECURITY: only the PUBLIC key is ever sent to the server. The server
222
- // rejects private-key uploads outright (per-device E2E escrow removed),
223
- // and a private key must never leave this host. Sending the full
224
- // ExportedKeyPair previously leaked the private key onto the wire on every
225
- // init and the rejection was swallowed silently. The per-device migration
226
- // (see ADR-0007) reworks this path; until then, register the public key only.
227
- const uploadServerKeys = async (keys, apiKey, baseUrl) => {
228
- try {
229
- const url = `${(baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, '')}/users/me/keys`;
230
- await fetch(url, {
231
- method: 'PUT',
232
- headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
233
- body: JSON.stringify({ publicKey: keys.publicKey }),
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
- catch { /* non-critical */ }
221
+ if (Object.keys(keyMap).length === 0)
222
+ throw new Error('No recipient device accepted the wrapped key');
223
+ return keyMap;
237
224
  };
238
- const getKeyPair = () => cachedKeyPair;
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 = () => cachedExportedPublicKey;
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
- cachedKeyPair = null;
255
- cachedExportedPublicKey = null;
256
- cachedOwnPublicKey = null;
244
+ pushCryptoEnabled = false;
257
245
  };
258
246
  exports.disableCrypto = disableCrypto;
259
247
  /**
260
- * Encrypt push body for a recipient.
261
- * Returns fields ready to merge into the sendPush payload.
262
- */
263
- const encryptPushBody = async (input, recipientPublicKeyRaw) => {
264
- if (!cachedKeyPair || !cachedExportedPublicKey)
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 encryptPushBodyForSelf = async (input) => {
280
- if (!cachedKeyPair || !cachedExportedPublicKey || !cachedOwnPublicKey)
254
+ const encryptPushBodyForDevices = async (input, recipients) => {
255
+ if (!deviceKeyPair || !deviceExportedPublicKey)
281
256
  throw new Error('Crypto not initialized');
282
- const payload = await encrypt(JSON.stringify({ title: input.title, body: input.body, url: input.url }), cachedKeyPair.privateKey, cachedOwnPublicKey);
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: payload.ciphertext, iv: payload.iv }),
285
- encryptedKey: JSON.stringify({ encryptedKey: payload.encryptedKey, keyIv: payload.keyIv }),
286
- senderPublicKey: cachedExportedPublicKey,
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.encryptPushBodyForSelf = encryptPushBodyForSelf;
268
+ exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
291
269
  /**
292
- * Encrypt file content for a recipient.
293
- * Returns encrypted buffer + key material for file attachment metadata.
270
+ * Encrypt file content for the given recipient devices.
294
271
  */
295
- const encryptFileForRecipient = async (content, recipientPublicKeyRaw) => {
296
- if (!cachedKeyPair)
272
+ const encryptFileForDevices = async (content, recipients) => {
273
+ if (!deviceKeyPair)
297
274
  throw new Error('Crypto not initialized');
298
- const recipientKey = await importPublicKey(recipientPublicKeyRaw);
299
- const result = await encryptFileContent(content, cachedKeyPair.privateKey, recipientKey);
300
- return {
301
- ciphertext: result.ciphertext,
302
- iv: result.iv,
303
- encryptedKey: JSON.stringify({ encryptedKey: result.encryptedKey, keyIv: result.keyIv }),
304
- };
305
- };
306
- exports.encryptFileForRecipient = encryptFileForRecipient;
307
- /**
308
- * Encrypt file content for self (all own devices).
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: result.ciphertext,
316
- iv: result.iv,
317
- encryptedKey: JSON.stringify({ encryptedKey: result.encryptedKey, keyIv: result.keyIv }),
287
+ ciphertext: Buffer.from(ciphertext),
288
+ iv: toBase64(iv.buffer),
289
+ deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
318
290
  };
319
291
  };
320
- exports.encryptFileForSelf = encryptFileForSelf;
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
- /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
45
- export declare const readPushMode: (dir: string) => GatePushMode;
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
@@ -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;;;;;GAKG;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,oFAAoF;AACpF,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,YAU1C,CAAC"}
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
- /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
137
- const readPushMode = (dir) => {
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 'normal';
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;