@hwip/cli 0.0.1 → 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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
package/dist/crypto.js ADDED
@@ -0,0 +1,698 @@
1
+ "use strict";
2
+ /**
3
+ * Per-device encryption for the Hook SDK — 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, imported rather
6
+ * than read off the `crypto` global.
7
+ *
8
+ * How it works (ADR-0007):
9
+ *
10
+ * This host holds its own ECDH keypair in ~/.hwip/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.
16
+ *
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 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
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.openV2 = exports.sealV2 = exports.keyFingerprint = exports.samePublicKey = exports.publicKeySender = exports.publicKeyPoint = exports.isSealedV2 = exports.decryptPushBodyForDevice = exports.ASK_CHOICES_CODE = exports.unwrapDeviceKey = exports.deriveLanSharedSecret = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.isAppDevice = exports.selectRecipients = void 0;
33
+ /// <reference lib="dom" />
34
+ const fs_1 = require("fs");
35
+ const os_1 = require("os");
36
+ const path_1 = require("path");
37
+ const node_crypto_1 = require("node:crypto");
38
+ const private_files_js_1 = require("./private-files.js");
39
+ // The Web Crypto implementation comes from `node:crypto`, not the `crypto`
40
+ // global.
41
+ const crypto = node_crypto_1.webcrypto;
42
+ // ─── Base64 helpers ───
43
+ const toBase64 = (buffer) => {
44
+ const bytes = new Uint8Array(buffer);
45
+ let binary = '';
46
+ for (let i = 0; i < bytes.length; i++) {
47
+ binary += String.fromCharCode(bytes[i]);
48
+ }
49
+ return btoa(binary);
50
+ };
51
+ const fromBase64 = (base64) => {
52
+ const binary = atob(base64);
53
+ const bytes = new Uint8Array(binary.length);
54
+ for (let i = 0; i < binary.length; i++) {
55
+ bytes[i] = binary.charCodeAt(i);
56
+ }
57
+ return bytes.buffer;
58
+ };
59
+ // ─── ECDH key management ───
60
+ const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
61
+ const generateKeyPair = async () => crypto.subtle.generateKey(ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
62
+ const exportKeyPair = async (keyPair) => {
63
+ const [publicRaw, privateRaw] = await Promise.all([
64
+ crypto.subtle.exportKey('spki', keyPair.publicKey),
65
+ crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
66
+ ]);
67
+ return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
68
+ };
69
+ const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
70
+ const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
71
+ const importKeyPair = async (exported) => {
72
+ const [publicKey, privateKey] = await Promise.all([
73
+ importPublicKey(exported.publicKey),
74
+ importPrivateKey(exported.privateKey),
75
+ ]);
76
+ return { publicKey, privateKey };
77
+ };
78
+ const deriveAesKey = async (privateKey, publicKey) => crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
79
+ // ─── Superseded account keystore ───
80
+ //
81
+ // Held the escrowed account keypair. Nothing reads it any more; it is deleted
82
+ // on sight so a private key the server no longer issues stops sitting on disk.
83
+ const LEGACY_KEYS_PATH = (0, path_1.join)((0, os_1.homedir)(), '.config', 'hwip', 'keys.json');
84
+ const deleteLegacyKeys = () => {
85
+ try {
86
+ (0, fs_1.unlinkSync)(LEGACY_KEYS_PATH);
87
+ }
88
+ catch { /* not present — fine */ }
89
+ };
90
+ /**
91
+ * Keep only devices this host can encrypt for: a device without a public key
92
+ * has never run a build that registers one, and wrapping for it produces a push
93
+ * it cannot open.
94
+ */
95
+ const selectRecipients = (devices) => devices
96
+ .filter((d) => !!d.publicKey)
97
+ .map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
98
+ exports.selectRecipients = selectRecipients;
99
+ /** A device a person reads pushes on: the iOS or Android app. A push or question nobody can read on a phone is not sent. */
100
+ const isAppDevice = (d) => d.type === 'ios' || d.type === 'android';
101
+ exports.isAppDevice = isAppDevice;
102
+ /**
103
+ * Wrap one raw AES key for every recipient device.
104
+ *
105
+ * The payload is encrypted once and only the wrapped key repeats, so an
106
+ * attachment costs one S3 object regardless of device count. A recipient
107
+ * whose public key will not import is dropped rather than failing the send —
108
+ * one broken device record must not silence every push.
109
+ */
110
+ const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
111
+ const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
112
+ try {
113
+ const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
114
+ const keyIv = crypto.getRandomValues(new Uint8Array(12));
115
+ const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
116
+ return [
117
+ deviceId,
118
+ JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
119
+ ];
120
+ }
121
+ catch (err) {
122
+ console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
123
+ return null;
124
+ }
125
+ }));
126
+ const keyMap = {};
127
+ for (const entry of entries) {
128
+ if (entry)
129
+ keyMap[entry[0]] = entry[1];
130
+ }
131
+ if (Object.keys(keyMap).length === 0)
132
+ throw new Error('No recipient device accepted the wrapped key');
133
+ return keyMap;
134
+ };
135
+ /**
136
+ * Encrypt a push body for the given recipient devices.
137
+ *
138
+ * Returns the wire fields the API expects: `body` carries the ciphertext and
139
+ * IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
140
+ * recipients need to derive the same secret back.
141
+ */
142
+ const encryptPushBodyForDevices = async (input, recipients) => {
143
+ if (!deviceKeyPair || !deviceExportedPublicKey)
144
+ throw new Error('Crypto not initialized');
145
+ assertAskWithinLimits(input.ask);
146
+ const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
147
+ const iv = crypto.getRandomValues(new Uint8Array(12));
148
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(JSON.stringify({
149
+ title: input.title,
150
+ body: input.body,
151
+ url: input.url,
152
+ cmd: input.cmd,
153
+ ask: input.ask,
154
+ answer: input.answer,
155
+ files: input.files,
156
+ keys: input.keys,
157
+ ifPrompt: input.ifPrompt,
158
+ context: input.context,
159
+ })));
160
+ const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
161
+ return {
162
+ body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
163
+ deviceKeyMap: await wrapForDevices(rawMessageKey, deviceKeyPair.privateKey, recipients),
164
+ senderPublicKey: deviceExportedPublicKey,
165
+ isEncrypted: true,
166
+ };
167
+ };
168
+ exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
169
+ /**
170
+ * Encrypt file content for the given recipient devices.
171
+ */
172
+ const encryptFileForDevices = async (content, recipients) => {
173
+ if (!deviceKeyPair)
174
+ throw new Error('Crypto not initialized');
175
+ // Binary content must be encrypted byte for byte — running a Buffer through
176
+ // TextEncoder would UTF-8 mangle every non-ASCII byte. Today's only caller
177
+ // passes markdown, so this is a guard against the first binary sender rather
178
+ // than a live fix (the MCP twin took that bug in production).
179
+ const buffer = typeof content === 'string'
180
+ ? new TextEncoder().encode(content)
181
+ : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
182
+ const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
183
+ const iv = crypto.getRandomValues(new Uint8Array(12));
184
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
185
+ const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
186
+ return {
187
+ ciphertext: Buffer.from(ciphertext),
188
+ iv: toBase64(iv.buffer),
189
+ deviceKeyMap: await wrapForDevices(rawFileKey, deviceKeyPair.privateKey, recipients),
190
+ };
191
+ };
192
+ exports.encryptFileForDevices = encryptFileForDevices;
193
+ // ─── Per-device keypair (stream E2EE + push/file bodies) ───
194
+ //
195
+ // One keypair per host: generated here, private key never leaves
196
+ // ~/.hwip/device-keys.json, never uploaded. It matches the web app's
197
+ // per-device `'device'` slot, so anything encrypted here is decryptable only
198
+ // by the devices whose public keys it was wrapped for.
199
+ //
200
+ // Stream frames, push bodies and file bodies all use it. It replaced the escrowed
201
+ // account-wide keypair entirely — the file header explains why that one is
202
+ // gone.
203
+ const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.hwip');
204
+ const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
205
+ let deviceKeyPair = null;
206
+ let deviceExportedPublicKey = null;
207
+ let deviceInitPromise = null;
208
+ const loadStoredDeviceKeys = () => {
209
+ try {
210
+ return JSON.parse((0, fs_1.readFileSync)(DEVICE_KEYS_PATH, 'utf-8'));
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ };
216
+ /**
217
+ * Create the keypair file, exclusively. The MCP server creates the same file
218
+ * (one Machine Device keypair per host, ADR-0007), and two processes each
219
+ * writing their own pair would leave one of them holding a key that is not on
220
+ * disk — and not the one registered. Returns what is on disk afterwards: ours,
221
+ * or the pair that won the race.
222
+ */
223
+ const storeDeviceKeys = (exported) => {
224
+ (0, private_files_js_1.mkdirHandedBack)(DEVICE_KEYS_DIR, 0o700);
225
+ // Written whole to a private temp name, then linked into place: `link` is
226
+ // atomic and fails if the name exists, so the file under the real name is
227
+ // never half-written, and a process that loses the race reads a complete one.
228
+ const temp = `${DEVICE_KEYS_PATH}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
229
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(exported, null, 2), { mode: 0o600, flag: 'wx' });
230
+ // `link` below makes a second name for this same file, so its owner is the key's owner (root through sudo: the user).
231
+ (0, private_files_js_1.handBackToSudoUser)(temp);
232
+ try {
233
+ (0, fs_1.linkSync)(temp, DEVICE_KEYS_PATH);
234
+ return exported;
235
+ }
236
+ catch (err) {
237
+ if (err.code !== 'EEXIST')
238
+ throw err;
239
+ const winner = loadStoredDeviceKeys();
240
+ if (!winner?.publicKey || !winner.privateKey)
241
+ throw new Error(`${DEVICE_KEYS_PATH} exists but holds no keypair`);
242
+ return winner;
243
+ }
244
+ finally {
245
+ try {
246
+ (0, fs_1.unlinkSync)(temp);
247
+ }
248
+ catch { /* already gone — fine */ }
249
+ }
250
+ };
251
+ /**
252
+ * Load-or-create the per-device keypair. Local-only — no server round-trip,
253
+ * no upload. Safe to call concurrently (dedupes to one init). Returns the
254
+ * exported public key (Base64 SPKI).
255
+ */
256
+ const initDeviceCrypto = () => {
257
+ if (deviceInitPromise)
258
+ return deviceInitPromise;
259
+ deviceInitPromise = (async () => {
260
+ deleteLegacyKeys();
261
+ const stored = loadStoredDeviceKeys();
262
+ if (stored) {
263
+ deviceKeyPair = await importKeyPair(stored);
264
+ deviceExportedPublicKey = stored.publicKey;
265
+ return stored.publicKey;
266
+ }
267
+ const keyPair = await generateKeyPair();
268
+ const exported = await exportKeyPair(keyPair);
269
+ const onDisk = storeDeviceKeys(exported);
270
+ deviceKeyPair = onDisk === exported ? keyPair : await importKeyPair(onDisk);
271
+ deviceExportedPublicKey = onDisk.publicKey;
272
+ return onDisk.publicKey;
273
+ })().catch((err) => {
274
+ deviceInitPromise = null;
275
+ throw err;
276
+ });
277
+ return deviceInitPromise;
278
+ };
279
+ exports.initDeviceCrypto = initDeviceCrypto;
280
+ const getDevicePublicKey = () => deviceExportedPublicKey;
281
+ exports.getDevicePublicKey = getDevicePublicKey;
282
+ /**
283
+ * Raw ECDH secret between this device and `peerPublicKeyRaw` (Base64
284
+ * SPKI) — the 32-byte x-coordinate WebCrypto's `deriveBits` yields for
285
+ * P-256. Input to the local-transfer MAC key (lan-auth.ts `deriveLanKey`);
286
+ * never used as a key by itself. Requires initDeviceCrypto().
287
+ */
288
+ const deriveLanSharedSecret = async (peerPublicKeyRaw) => {
289
+ if (!deviceKeyPair)
290
+ throw new Error('Device crypto not initialized');
291
+ const peer = await importPublicKey(peerPublicKeyRaw);
292
+ return Buffer.from(await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, deviceKeyPair.privateKey, 256));
293
+ };
294
+ exports.deriveLanSharedSecret = deriveLanSharedSecret;
295
+ /**
296
+ * Open this device's slot in a `deviceKeyMap` (the JSON `{ encryptedKey,
297
+ * keyIv }` `wrapForDevices` writes) and return the raw AES key inside —
298
+ * the per-file key for an attachment, or the message key for a push body.
299
+ * The ECDH secret is derived from this device's private half and the
300
+ * sender's `senderPublicKey`; anything but that pairing fails the GCM tag
301
+ * and rejects. Requires initDeviceCrypto().
302
+ */
303
+ const unwrapDeviceKeyRaw = async (entryJson, senderPublicKeyRaw) => {
304
+ if (!deviceKeyPair)
305
+ throw new Error('Device crypto not initialized');
306
+ const entry = JSON.parse(entryJson);
307
+ if (typeof entry.encryptedKey !== 'string' || typeof entry.keyIv !== 'string') {
308
+ throw new Error('deviceKeyMap entry is not { encryptedKey, keyIv }');
309
+ }
310
+ const sharedKey = await deriveAesKey(deviceKeyPair.privateKey, await importPublicKey(senderPublicKeyRaw));
311
+ return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(entry.keyIv)) }, sharedKey, fromBase64(entry.encryptedKey));
312
+ };
313
+ const unwrapDeviceKey = async (entryJson, senderPublicKeyRaw) => Buffer.from(await unwrapDeviceKeyRaw(entryJson, senderPublicKeyRaw));
314
+ exports.unwrapDeviceKey = unwrapDeviceKey;
315
+ const MAX_ASK_ACTIONS = 10;
316
+ const MAX_ACTION_ID_CHARS = 64;
317
+ const MAX_ACTION_LABEL_CHARS = 100;
318
+ const MAX_ASK_ID_CHARS = 128;
319
+ /** Most files the summary lists, and the longest path it shows (bytes): the summary rides in a size-capped body. */
320
+ const MAX_CONTEXT_LISTED = 20;
321
+ const MAX_CONTEXT_PATH_BYTES = 128;
322
+ /** A device id as the account issues it (`subscriber-trust` DEVICE_ID, kept here so crypto stays free of that import). */
323
+ const ASK_DEVICE_ID = /^[A-Za-z0-9_-]{1,64}$/;
324
+ /**
325
+ * The reader drops a whole label that is over its limits, so a question sealed
326
+ * past them would arrive as one with no `ask` label. Refuse at the seal instead.
327
+ */
328
+ const assertAskWithinLimits = (ask) => {
329
+ if (!ask)
330
+ return;
331
+ if (ask.id.length === 0 || ask.id.length > MAX_ASK_ID_CHARS)
332
+ throw new Error(`ask id must be 1 to ${MAX_ASK_ID_CHARS} characters`);
333
+ if (!ASK_DEVICE_ID.test(ask.device))
334
+ throw new Error('ask device id is not a device id');
335
+ const actions = ask.actions ?? [];
336
+ // The choices are the caller's to shorten: `ASK_CHOICES_CODE` lets `prepareAsk` say so (a property, not a class,
337
+ // so it survives a module registry reset).
338
+ const choices = (message) => Object.assign(new Error(message), { code: exports.ASK_CHOICES_CODE });
339
+ if (actions.length > MAX_ASK_ACTIONS)
340
+ throw choices(`a question takes at most ${MAX_ASK_ACTIONS} choices`);
341
+ if (actions.some((a) => a.id.length > MAX_ACTION_ID_CHARS))
342
+ throw choices(`a choice id is over ${MAX_ACTION_ID_CHARS} characters`);
343
+ if (actions.some((a) => a.label.length > MAX_ACTION_LABEL_CHARS))
344
+ throw choices(`a choice label is over ${MAX_ACTION_LABEL_CHARS} characters`);
345
+ };
346
+ /** The `code` of the errors above that a shorter choice list fixes. */
347
+ exports.ASK_CHOICES_CODE = 'ask-choices-over-limits';
348
+ /** Named keys inside a sealed command: non-empty strings only, like the socket path's
349
+ * `isKeyList` — the listener allowlists the names. */
350
+ const sealedKeys = (value) => {
351
+ if (!Array.isArray(value) || value.length === 0)
352
+ return undefined;
353
+ if (!value.every((item) => typeof item === 'string'))
354
+ return undefined;
355
+ return [...value];
356
+ };
357
+ const fileMetas = (value) => {
358
+ if (!Array.isArray(value))
359
+ return undefined;
360
+ const metas = [];
361
+ for (const item of value) {
362
+ if (typeof item !== 'object' || item === null)
363
+ return undefined;
364
+ if (!('iv' in item) || !('name' in item) || !('type' in item) || !('size' in item))
365
+ return undefined;
366
+ const { iv, name, type, size } = item;
367
+ if (typeof iv !== 'string' || typeof name !== 'string' || typeof type !== 'string' || typeof size !== 'number')
368
+ return undefined;
369
+ const chunk = 'chunk' in item ? item.chunk : undefined;
370
+ if (chunk !== undefined && typeof chunk !== 'number')
371
+ return undefined;
372
+ metas.push(chunk === undefined ? { iv, name, type, size } : { iv, name, type, size, chunk });
373
+ }
374
+ return metas;
375
+ };
376
+ const commandLabel = (value) => {
377
+ if (typeof value !== 'object' || value === null)
378
+ return undefined;
379
+ if (!('n' in value) || !('session' in value) || !('device' in value))
380
+ return undefined;
381
+ const { n, session, device } = value;
382
+ if (typeof n !== 'number' || typeof session !== 'string' || typeof device !== 'string')
383
+ return undefined;
384
+ if (!('from' in value) || value.from === undefined)
385
+ return { n, session, device };
386
+ if (typeof value.from !== 'string')
387
+ return undefined;
388
+ return { n, session, device, from: value.from };
389
+ };
390
+ const askActions = (value) => {
391
+ if (!Array.isArray(value) || value.length > MAX_ASK_ACTIONS)
392
+ return undefined;
393
+ const actions = [];
394
+ for (const item of value) {
395
+ if (typeof item !== 'object' || item === null || !('id' in item) || !('label' in item))
396
+ return undefined;
397
+ const { id, label } = item;
398
+ if (typeof id !== 'string' || id.length > MAX_ACTION_ID_CHARS)
399
+ return undefined;
400
+ if (typeof label !== 'string' || label.length > MAX_ACTION_LABEL_CHARS)
401
+ return undefined;
402
+ if (!('style' in item) || item.style === undefined) {
403
+ actions.push({ id, label });
404
+ continue;
405
+ }
406
+ if (typeof item.style !== 'string')
407
+ return undefined;
408
+ actions.push({ id, label, style: item.style });
409
+ }
410
+ return actions;
411
+ };
412
+ const askLabel = (value) => {
413
+ if (typeof value !== 'object' || value === null)
414
+ return undefined;
415
+ if (!('id' in value) || !('device' in value))
416
+ return undefined;
417
+ const { id, device } = value;
418
+ if (typeof id !== 'string' || typeof device !== 'string')
419
+ return undefined;
420
+ if (id === '' || id.length > MAX_ASK_ID_CHARS || !ASK_DEVICE_ID.test(device))
421
+ return undefined;
422
+ const label = { id, device };
423
+ if ('actions' in value && value.actions !== undefined) {
424
+ const actions = askActions(value.actions);
425
+ if (!actions)
426
+ return undefined;
427
+ label.actions = actions;
428
+ }
429
+ if ('placeholder' in value && value.placeholder !== undefined) {
430
+ if (typeof value.placeholder !== 'string')
431
+ return undefined;
432
+ label.placeholder = value.placeholder;
433
+ }
434
+ return label;
435
+ };
436
+ const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
437
+ const contextFile = (value) => {
438
+ if (typeof value !== 'object' || value === null)
439
+ return undefined;
440
+ const { path, status, added, removed } = value;
441
+ const binary = value.binary;
442
+ if (typeof path !== 'string' || path === '' || Buffer.byteLength(path) > MAX_CONTEXT_PATH_BYTES)
443
+ return undefined;
444
+ if (status !== 'M' && status !== 'A' && status !== 'D' && status !== '?')
445
+ return undefined;
446
+ const a = count(added);
447
+ const r = count(removed);
448
+ if (a === undefined || r === undefined)
449
+ return undefined;
450
+ if (binary === undefined)
451
+ return { path, status, added: a, removed: r };
452
+ return binary === true ? { path, status, added: a, removed: r, binary } : undefined;
453
+ };
454
+ /** The `context` summary of a sealed question, or `undefined` when it is absent or not well formed (the question still opens without it). */
455
+ const askContext = (value) => {
456
+ if (typeof value !== 'object' || value === null)
457
+ return undefined;
458
+ const v = value;
459
+ if (v.diff !== 'ok' && v.diff !== 'no_repo' && v.diff !== 'failed')
460
+ return undefined;
461
+ const files = count(v.files);
462
+ const added = count(v.added);
463
+ const removed = count(v.removed);
464
+ const moreFiles = count(v.moreFiles);
465
+ const screenLines = count(v.screenLines);
466
+ const masked = count(v.masked);
467
+ if (files === undefined || added === undefined || removed === undefined)
468
+ return undefined;
469
+ if (moreFiles === undefined || screenLines === undefined || masked === undefined)
470
+ return undefined;
471
+ if (!Array.isArray(v.listed) || v.listed.length > MAX_CONTEXT_LISTED)
472
+ return undefined;
473
+ const listed = [];
474
+ for (const item of v.listed) {
475
+ const file = contextFile(item);
476
+ if (!file)
477
+ return undefined;
478
+ listed.push(file);
479
+ }
480
+ const cut = v.cut;
481
+ if (typeof cut !== 'object' || cut === null || typeof cut.diff !== 'boolean' || typeof cut.screen !== 'boolean')
482
+ return undefined;
483
+ return { diff: v.diff, files, added, removed, listed, moreFiles, screenLines, masked, cut: { diff: cut.diff, screen: cut.screen } };
484
+ };
485
+ const answerLabel = (value) => {
486
+ if (typeof value !== 'object' || value === null || !('ask' in value))
487
+ return undefined;
488
+ if (typeof value.ask !== 'string' || value.ask === '' || value.ask.length > MAX_ASK_ID_CHARS)
489
+ return undefined;
490
+ const label = { ask: value.ask };
491
+ if ('actionId' in value && value.actionId !== undefined) {
492
+ if (typeof value.actionId !== 'string')
493
+ return undefined;
494
+ label.actionId = value.actionId;
495
+ }
496
+ if ('exit' in value && value.exit !== undefined) {
497
+ if (value.exit !== true)
498
+ return undefined;
499
+ label.exit = true;
500
+ }
501
+ return label;
502
+ };
503
+ /**
504
+ * Decrypt a push body sealed by `encryptPushBodyForDevices` (any client's
505
+ * twin of it) for this device: `body` is the JSON `{ ciphertext, iv }`
506
+ * envelope, the message key comes out of this device's `deviceKeyMap` slot.
507
+ * Returns the `{ title, body, url }` the sender put in, the `cmd` label of a
508
+ * sealed command, the `ask` / `answer` label of a sealed question / answer and
509
+ * the `files` list, the sealed `keys` / `ifPrompt` of a blocked-prompt answer,
510
+ * and the `context` summary — each only when well formed. `context` is read from any body;
511
+ * whether it belongs there (a question) is the caller's to decide.
512
+ */
513
+ const decryptPushBodyForDevice = async (body, entryJson, senderPublicKeyRaw) => {
514
+ const envelope = JSON.parse(body);
515
+ if (typeof envelope.ciphertext !== 'string' || typeof envelope.iv !== 'string') {
516
+ throw new Error('push body is not a { ciphertext, iv } envelope');
517
+ }
518
+ const rawKey = await unwrapDeviceKeyRaw(entryJson, senderPublicKeyRaw);
519
+ const messageKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']);
520
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(envelope.iv)) }, messageKey, fromBase64(envelope.ciphertext));
521
+ // A SyntaxError from JSON.parse quotes the start of the text it could not read, and this text
522
+ // is authenticated plaintext: the message must not carry it. Same for the TypeError of `null`.
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(new TextDecoder().decode(plaintext));
526
+ }
527
+ catch {
528
+ throw new Error('sealed body is not JSON');
529
+ }
530
+ if (typeof parsed !== 'object' || parsed === null)
531
+ throw new Error('sealed body is not a JSON object');
532
+ const str = (v) => (typeof v === 'string' ? v : undefined);
533
+ const plainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) ? v : undefined;
534
+ 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), schedule: plainObject(parsed.schedule), context: askContext(parsed.context) };
535
+ };
536
+ exports.decryptPushBodyForDevice = decryptPushBodyForDevice;
537
+ const V2_LABEL = 'hwip/e2e/v2';
538
+ const V2_SALT_BYTES = 32;
539
+ const V2_IV_BYTES = 12;
540
+ /** Every field of a v2 envelope is relay JSON: check the shape before any of
541
+ * it reaches WebCrypto, so a malformed one is a refusal and not a throw from
542
+ * inside a Base64 decode. */
543
+ const isSealedV2 = (value) => typeof value === 'object' && value !== null
544
+ && 'v' in value && value.v === 2
545
+ && 'salt' in value && typeof value.salt === 'string'
546
+ && 'iv' in value && typeof value.iv === 'string'
547
+ && 'ciphertext' in value && typeof value.ciphertext === 'string'
548
+ && 'senderPublicKey' in value && typeof value.senderPublicKey === 'string';
549
+ exports.isSealedV2 = isSealedV2;
550
+ /** DER header of a P-256 SPKI whose key is an uncompressed point; the 65-byte
551
+ * X9.63 point follows it. */
552
+ const P256_SPKI_PREFIX = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
553
+ const P256_POINT_BYTES = 65;
554
+ /**
555
+ * The X9.63 point of a device public key, or null when the string is not
556
+ * spelled as one. Only the spelling is checked: whether the 65 bytes are a
557
+ * point on the curve is left to the key import that follows, which is where
558
+ * `sealV2` and `openV2` reject one that is not.
559
+ *
560
+ * A device key has exactly one spelling here: standard padded Base64 of the
561
+ * 91-byte SPKI with an uncompressed point — what WebCrypto's
562
+ * `exportKey('spki')` and the app's CryptoKit `derRepresentation` both write.
563
+ * The same key spelled another way (a compressed point, unpadded Base64) is
564
+ * refused rather than normalised. With one spelling, two keys are the same
565
+ * exactly when their strings are, here and in the app, which cannot read the
566
+ * compressed form at all.
567
+ */
568
+ const publicKeyPoint = (spkiBase64) => {
569
+ const der = Buffer.from(spkiBase64, 'base64');
570
+ const isDeviceKey = der.length === P256_SPKI_PREFIX.length + P256_POINT_BYTES
571
+ && der.subarray(0, P256_SPKI_PREFIX.length).equals(P256_SPKI_PREFIX)
572
+ // 0x04 = uncompressed. Node also imports the hybrid forms (0x06, 0x07) of
573
+ // the same point, which the app does not.
574
+ && der[P256_SPKI_PREFIX.length] === 0x04
575
+ && der.toString('base64') === spkiBase64;
576
+ return isDeviceKey ? der.subarray(P256_SPKI_PREFIX.length) : null;
577
+ };
578
+ exports.publicKeyPoint = publicKeyPoint;
579
+ /**
580
+ * Who a sealed message is from, as far as a replay check can tell: the x
581
+ * coordinate of the sender's point, in hex (null for a string that is not a
582
+ * device key). A sealed push opens from the ECDH x coordinate alone — the
583
+ * secret is the same for a point and its mirror (x, p − y) — so a relay can
584
+ * hand the same box to the listener under the mirrored key without the
585
+ * phone's secret. The string of that key is another one, the sender is not.
586
+ * Pins and fingerprints still compare the whole key; only the question "have
587
+ * I taken this number from this sender" asks for x.
588
+ */
589
+ const publicKeySender = (spkiBase64) => {
590
+ const point = (0, exports.publicKeyPoint)(spkiBase64);
591
+ return point ? point.subarray(1, 33).toString('hex') : null;
592
+ };
593
+ exports.publicKeySender = publicKeySender;
594
+ /** Whether two strings are the same device public key. A string that is not
595
+ * in the one spelling `publicKeyPoint` takes equals nothing, itself included. */
596
+ const samePublicKey = (a, b) => a === b && (0, exports.publicKeyPoint)(a) !== null;
597
+ exports.samePublicKey = samePublicKey;
598
+ /**
599
+ * A device public key as something two people can compare: the first 16 bytes
600
+ * of SHA-256 over its X9.63 point, as eight groups of four hex digits. The app
601
+ * prints the same string for the same key (`Envelope.fingerprint`), which is
602
+ * the point — `hwip trust` on this side, the device screen on that one. null
603
+ * for a string that is not a device key.
604
+ */
605
+ const keyFingerprint = (spkiBase64) => {
606
+ const point = (0, exports.publicKeyPoint)(spkiBase64);
607
+ if (!point)
608
+ return null;
609
+ const digest = (0, node_crypto_1.createHash)('sha256').update(point).digest('hex').slice(0, 32);
610
+ return Array.from({ length: 8 }, (_, group) => digest.slice(group * 4, group * 4 + 4)).join(' ');
611
+ };
612
+ exports.keyFingerprint = keyFingerprint;
613
+ // A listener seals for whoever watches its panes: a few phones at most. The
614
+ // cap only stops a relay cycling made-up sender keys from growing this forever.
615
+ const V2_PEER_CACHE_MAX = 8;
616
+ const v2Peers = new Map();
617
+ let devicePoint = null;
618
+ const v2PeerFor = async (spkiBase64, privateKey) => {
619
+ const cached = v2Peers.get(spkiBase64);
620
+ if (cached)
621
+ return cached;
622
+ const point = (0, exports.publicKeyPoint)(spkiBase64);
623
+ if (!point)
624
+ throw new Error('not a device public key');
625
+ const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: await importPublicKey(spkiBase64) }, privateKey, 256);
626
+ const peer = { point, secret: await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']) };
627
+ const oldest = v2Peers.keys().next();
628
+ if (v2Peers.size >= V2_PEER_CACHE_MAX && !oldest.done)
629
+ v2Peers.delete(oldest.value);
630
+ v2Peers.set(spkiBase64, peer);
631
+ return peer;
632
+ };
633
+ const v2MessageKey = (secret, salt, purpose) => crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode(`${V2_LABEL}/${purpose}`) }, secret, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
634
+ const v2Aad = (purpose, sender, recipient, context) => {
635
+ const head = new TextEncoder().encode(`${V2_LABEL}\n${purpose}\n`);
636
+ const tail = new TextEncoder().encode(context);
637
+ const aad = new Uint8Array(head.length + sender.length + recipient.length + tail.length);
638
+ aad.set(head);
639
+ aad.set(sender, head.length);
640
+ aad.set(recipient, head.length + sender.length);
641
+ aad.set(tail, head.length + sender.length + recipient.length);
642
+ return aad;
643
+ };
644
+ const v2Self = () => {
645
+ if (!deviceKeyPair || !deviceExportedPublicKey)
646
+ throw new Error('Device crypto not initialized');
647
+ devicePoint ??= (0, exports.publicKeyPoint)(deviceExportedPublicKey);
648
+ if (!devicePoint)
649
+ throw new Error('this device\'s stored public key is not a device public key');
650
+ return { privateKey: deviceKeyPair.privateKey, publicKey: deviceExportedPublicKey, point: devicePoint };
651
+ };
652
+ /**
653
+ * Seal `plaintext` for one recipient device, for one purpose. Requires
654
+ * initDeviceCrypto().
655
+ */
656
+ const sealV2 = async (purpose, plaintext, recipientPublicKey, context = '') => {
657
+ const self = v2Self();
658
+ const peer = await v2PeerFor(recipientPublicKey, self.privateKey);
659
+ const salt = crypto.getRandomValues(new Uint8Array(V2_SALT_BYTES));
660
+ const iv = crypto.getRandomValues(new Uint8Array(V2_IV_BYTES));
661
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: v2Aad(purpose, self.point, peer.point, context) }, await v2MessageKey(peer.secret, salt, purpose), new TextEncoder().encode(plaintext));
662
+ return {
663
+ v: 2,
664
+ salt: toBase64(salt.buffer),
665
+ iv: toBase64(iv.buffer),
666
+ ciphertext: toBase64(ciphertext),
667
+ senderPublicKey: self.publicKey,
668
+ };
669
+ };
670
+ exports.sealV2 = sealV2;
671
+ /**
672
+ * Open a v2 envelope addressed to this device.
673
+ *
674
+ * Rejects rather than returning null, like every open here: a throw means the
675
+ * envelope was sealed by another key than `expectedSender`, for another
676
+ * device, purpose or context, was tampered with, or is not a v2 envelope.
677
+ *
678
+ * `expectedSender` is not optional: opening alone proves only that the sender
679
+ * holds the private half of `senderPublicKey`, and anyone can mint a keypair.
680
+ * Every caller knows who it is talking to; naming that key here is what turns
681
+ * "it opened" into "the peer sent it". Requires initDeviceCrypto().
682
+ */
683
+ const openV2 = async (purpose, envelope, expectedSender, context = '') => {
684
+ if (!(0, exports.isSealedV2)(envelope))
685
+ throw new Error('not a v2 envelope');
686
+ if (!(0, exports.samePublicKey)(envelope.senderPublicKey, expectedSender)) {
687
+ throw new Error('envelope not sealed by the expected key');
688
+ }
689
+ const salt = new Uint8Array(fromBase64(envelope.salt));
690
+ const iv = new Uint8Array(fromBase64(envelope.iv));
691
+ if (salt.length !== V2_SALT_BYTES || iv.length !== V2_IV_BYTES)
692
+ throw new Error('v2 envelope salt or iv has the wrong length');
693
+ const self = v2Self();
694
+ const peer = await v2PeerFor(envelope.senderPublicKey, self.privateKey);
695
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, additionalData: v2Aad(purpose, peer.point, self.point, context) }, await v2MessageKey(peer.secret, salt, purpose), fromBase64(envelope.ciphertext));
696
+ return new TextDecoder().decode(plaintext);
697
+ };
698
+ exports.openV2 = openV2;