@totalreclaw/totalreclaw 3.3.12-rc.2 → 3.3.12-rc.21

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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +34 -249
  3. package/api-client.ts +17 -9
  4. package/batch-gate.ts +42 -0
  5. package/billing-cache.ts +25 -20
  6. package/claims-helper.ts +7 -1
  7. package/config.ts +54 -12
  8. package/consolidation.ts +6 -13
  9. package/contradiction-sync.ts +19 -14
  10. package/credential-provider.ts +184 -0
  11. package/crypto.ts +27 -160
  12. package/dist/api-client.js +17 -9
  13. package/dist/batch-gate.js +40 -0
  14. package/dist/billing-cache.js +19 -21
  15. package/dist/claims-helper.js +7 -1
  16. package/dist/config.js +54 -12
  17. package/dist/consolidation.js +6 -15
  18. package/dist/contradiction-sync.js +15 -15
  19. package/dist/credential-provider.js +145 -0
  20. package/dist/crypto.js +17 -137
  21. package/dist/download-ux.js +11 -7
  22. package/dist/embedder-loader.js +266 -0
  23. package/dist/embedding.js +36 -3
  24. package/dist/entry.js +123 -0
  25. package/dist/extractor.js +134 -0
  26. package/dist/fs-helpers.js +116 -241
  27. package/dist/import-adapters/chatgpt-adapter.js +14 -0
  28. package/dist/import-adapters/claude-adapter.js +14 -0
  29. package/dist/import-adapters/gemini-adapter.js +43 -159
  30. package/dist/import-adapters/mcp-memory-adapter.js +14 -0
  31. package/dist/import-state-manager.js +100 -0
  32. package/dist/index.js +1130 -2520
  33. package/dist/llm-client.js +69 -1
  34. package/dist/memory-runtime.js +459 -0
  35. package/dist/native-memory.js +123 -0
  36. package/dist/onboarding-cli.js +3 -2
  37. package/dist/pair-cli.js +1 -1
  38. package/dist/pair-crypto.js +16 -358
  39. package/dist/pair-http.js +147 -4
  40. package/dist/relay.js +140 -0
  41. package/dist/reranker.js +13 -8
  42. package/dist/semantic-dedup.js +5 -7
  43. package/dist/skill-register.js +97 -0
  44. package/dist/subgraph-search.js +3 -1
  45. package/dist/subgraph-store.js +315 -282
  46. package/dist/tool-gating.js +39 -26
  47. package/dist/tools.js +367 -0
  48. package/dist/tr-cli-export-helper.js +103 -0
  49. package/dist/tr-cli.js +220 -127
  50. package/dist/trajectory-poller.js +155 -9
  51. package/dist/vault-crypto.js +551 -0
  52. package/download-ux.ts +12 -6
  53. package/embedder-loader.ts +293 -1
  54. package/embedding.ts +43 -3
  55. package/entry.ts +132 -0
  56. package/extractor.ts +167 -0
  57. package/fs-helpers.ts +166 -292
  58. package/import-adapters/chatgpt-adapter.ts +18 -0
  59. package/import-adapters/claude-adapter.ts +18 -0
  60. package/import-adapters/gemini-adapter.ts +56 -183
  61. package/import-adapters/mcp-memory-adapter.ts +18 -0
  62. package/import-adapters/types.ts +1 -1
  63. package/import-state-manager.ts +139 -0
  64. package/index.ts +1432 -3002
  65. package/llm-client.ts +74 -1
  66. package/memory-runtime.ts +723 -0
  67. package/native-memory.ts +196 -0
  68. package/onboarding-cli.ts +3 -2
  69. package/openclaw.plugin.json +5 -17
  70. package/package.json +7 -4
  71. package/pair-cli.ts +1 -1
  72. package/pair-crypto.ts +41 -483
  73. package/pair-http.ts +194 -5
  74. package/postinstall.mjs +138 -0
  75. package/relay.ts +172 -0
  76. package/reranker.ts +13 -8
  77. package/semantic-dedup.ts +5 -6
  78. package/skill-register.ts +146 -0
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +3 -1
  81. package/subgraph-store.ts +334 -299
  82. package/tool-gating.ts +39 -26
  83. package/tools.ts +499 -0
  84. package/tr-cli-export-helper.ts +138 -0
  85. package/tr-cli.ts +263 -133
  86. package/trajectory-poller.ts +162 -10
  87. package/vault-crypto.ts +705 -0
@@ -0,0 +1,705 @@
1
+ /**
2
+ * vault-crypto — pure-compute cryptographic primitives for the gateway.
3
+ *
4
+ * Phase 1 (Task 1.1) of the OpenClaw native integration
5
+ * (docs/plans/2026-06-21-openclaw-native-integration-plan.md, 2026-06-21):
6
+ * physically separate the crypto primitives that take ALL key material and
7
+ * nonces as parameters into one file so that NO single source file
8
+ * co-contains environment-variable reads and outbound-network primitives.
9
+ * OpenClaw's runtime scanner refuses to install plugins whose source
10
+ * trips the env-harvesting rule (the rule's two halves both present on the
11
+ * SAME file, comments included — see skill/scripts/check-scanner.mjs for
12
+ * the exact regex pair).
13
+ *
14
+ * Hard contract enforced by vault-crypto.test.ts:
15
+ * - This file reads the environment NOWHERE and performs network I/O
16
+ * NOWHERE. All key material and nonces are passed IN as parameters.
17
+ * - decrypt(encrypt(x, key), key) round-trips for both cipher suites.
18
+ *
19
+ * Scope — TWO cipher suites consolidated here from the legacy
20
+ * crypto.ts + pair-crypto.ts:
21
+ *
22
+ * 1. Vault (mnemonic-derived) crypto — XChaCha20-Poly1305 AEAD via the
23
+ * `@totalreclaw/core` WASM module. BIP-39 → 512-bit seed →
24
+ * HKDF-SHA256 auth/encryption/dedup keys. Used to encrypt facts at
25
+ * rest + derive blind indices + content fingerprints.
26
+ *
27
+ * 2. Pair crypto — x25519 ECDH + HKDF-SHA256 + AES-256-GCM AEAD via
28
+ * Node's built-in `node:crypto`. Used by the relay-brokered QR pair
29
+ * flow to establish an ephemeral session key with the device.
30
+ *
31
+ * ERC-4337 UserOp signing (`signUserOp`) lives here too — moved in as the
32
+ * deferred pickup of Task 1.1 once Task 1.2 (`relay.ts`) extracted the
33
+ * bundler network call it was intertwined with. It is a pure WASM call
34
+ * (hash + private key → signature) with no env or network dependency.
35
+ *
36
+ * Legacy `crypto.ts` and `pair-crypto.ts` are kept as thin re-exports so
37
+ * the 331KB `index.ts` and other importers are not broken in this pass.
38
+ */
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // WASM loader — vault (mnemonic-derived) crypto path
42
+ // ---------------------------------------------------------------------------
43
+
44
+ // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
45
+ // Node ESM — the shipped `dist/index.js` declares `"type":"module"`, where
46
+ // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
47
+ // this file called bare `require('@totalreclaw/core')` and every consumer
48
+ // died with `require is not defined`. Matches the pattern already used by
49
+ // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
50
+ // retype-setscope. See issue #124.
51
+ import { createRequire } from 'node:module';
52
+ const requireWasm = createRequire(import.meta.url);
53
+ let _wasm: typeof import('@totalreclaw/core') | null = null;
54
+ function getWasm() {
55
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
56
+ return _wasm;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // BIP-39 Validation
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Check if the input looks like a BIP-39 mnemonic (12 or 24 words).
65
+ *
66
+ * Lenient: accepts phrases where all words look like valid BIP-39 words
67
+ * (allows invalid checksums, which LLMs sometimes generate).
68
+ */
69
+ export function isBip39Mnemonic(input: string): boolean {
70
+ const words = input.trim().split(/\s+/);
71
+ return words.length === 12 || words.length === 24;
72
+ }
73
+
74
+ // Re-export for backward compatibility
75
+ export const validateMnemonic = isBip39Mnemonic;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Key Derivation (vault path)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Derive auth, encryption, and dedup keys from a recovery phrase.
83
+ *
84
+ * Delegates to the WASM module for BIP-39 seed derivation and HKDF
85
+ * key separation. Uses the lenient variant for phrases where all words
86
+ * are valid but the checksum fails.
87
+ *
88
+ * @param password - BIP-39 12/24-word mnemonic
89
+ * @param existingSalt - Ignored for BIP-39 path (salt is deterministic)
90
+ */
91
+ export function deriveKeys(
92
+ password: string,
93
+ existingSalt?: Buffer,
94
+ ): { authKey: Buffer; encryptionKey: Buffer; dedupKey: Buffer; salt: Buffer } {
95
+ const trimmed = password.trim();
96
+
97
+ // Try strict validation first, fall back to lenient
98
+ let result: { auth_key: string; encryption_key: string; dedup_key: string; salt: string };
99
+ try {
100
+ result = getWasm().deriveKeysFromMnemonic(trimmed);
101
+ } catch {
102
+ result = getWasm().deriveKeysFromMnemonicLenient(trimmed);
103
+ }
104
+
105
+ return {
106
+ authKey: Buffer.from(result.auth_key, 'hex'),
107
+ encryptionKey: Buffer.from(result.encryption_key, 'hex'),
108
+ dedupKey: Buffer.from(result.dedup_key, 'hex'),
109
+ salt: Buffer.from(result.salt, 'hex'),
110
+ };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // LSH Seed Derivation
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Derive a 32-byte seed for the LSH hasher.
119
+ *
120
+ * Delegates to the WASM module.
121
+ */
122
+ export function deriveLshSeed(
123
+ password: string,
124
+ salt: Buffer,
125
+ ): Uint8Array {
126
+ const seedHex = getWasm().deriveLshSeed(password.trim(), salt.toString('hex'));
127
+ return new Uint8Array(Buffer.from(seedHex, 'hex'));
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Auth Key Hash
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Compute the SHA-256 hash of the auth key.
136
+ */
137
+ export function computeAuthKeyHash(authKey: Buffer): string {
138
+ return getWasm().computeAuthKeyHash(authKey.toString('hex'));
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // XChaCha20-Poly1305 Encrypt / Decrypt
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Encrypt a UTF-8 plaintext string with XChaCha20-Poly1305.
147
+ *
148
+ * Wire format (base64-encoded):
149
+ * [nonce: 24 bytes][tag: 16 bytes][ciphertext: variable]
150
+ */
151
+ export function encrypt(plaintext: string, encryptionKey: Buffer): string {
152
+ return getWasm().encrypt(plaintext, encryptionKey.toString('hex'));
153
+ }
154
+
155
+ /**
156
+ * Decrypt a base64-encoded XChaCha20-Poly1305 blob back to a UTF-8 string.
157
+ */
158
+ export function decrypt(encryptedBase64: string, encryptionKey: Buffer): string {
159
+ return getWasm().decrypt(encryptedBase64, encryptionKey.toString('hex'));
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Blind Indices
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Generate blind indices (SHA-256 hashes of tokens) for a text string.
168
+ *
169
+ * Delegates to the WASM module which performs tokenization, stemming,
170
+ * and SHA-256 hashing.
171
+ */
172
+ export function generateBlindIndices(text: string): string[] {
173
+ return getWasm().generateBlindIndices(text);
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Content Fingerprint (Dedup)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Compute an HMAC-SHA256 content fingerprint for exact-duplicate detection.
182
+ *
183
+ * @returns 64-character hex string.
184
+ */
185
+ export function generateContentFingerprint(plaintext: string, dedupKey: Buffer): string {
186
+ return getWasm().generateContentFingerprint(plaintext, dedupKey.toString('hex'));
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // ERC-4337 UserOp signing
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Sign an ERC-4337 v0.7 UserOp hash with an EOA private key (EIP-191
195
+ * prefixed ECDSA).
196
+ *
197
+ * Pure compute: takes the hash + private key as parameters, returns the
198
+ * hex-encoded signature. No environment reads, no network I/O. The hash
199
+ * itself is produced by the WASM `hashUserOp` call at the call site
200
+ * (kept in `subgraph-store.ts` because it depends on the chain id +
201
+ * entryPoint address that are submission-path concerns, not crypto
202
+ * primitives).
203
+ *
204
+ * Moved here from `subgraph-store.ts` as the deferred pickup of Task 1.1
205
+ * once Task 1.2 (`relay.ts`) extracted the bundler network call that the
206
+ * signing was intertwined with. `vault-crypto.ts` remains scanner-clean
207
+ * (env=N, net=N).
208
+ *
209
+ * @param hashHex Hex-encoded ERC-4337 UserOp hash.
210
+ * @param privateKeyHex Hex-encoded EOA private key.
211
+ * @returns Hex-encoded ECDSA signature (no `0x` prefix — caller adds it).
212
+ */
213
+ export function signUserOp(hashHex: string, privateKeyHex: string): string {
214
+ return getWasm().signUserOp(hashHex, privateKeyHex);
215
+ }
216
+
217
+ // ===========================================================================
218
+ // Pair crypto (x25519 ECDH + HKDF + AES-256-GCM)
219
+ // ===========================================================================
220
+ //
221
+ // Gateway-side cryptographic primitives for the v3.3.x relay-brokered pair
222
+ // flow. Cipher suite (design doc 3a-3b, cipher swap ratified 2026-04-23 /
223
+ // rc.12):
224
+ // - ECDH on x25519 for key agreement.
225
+ // - HKDF-SHA256 for symmetric-key derivation from the shared secret.
226
+ // - AES-256-GCM AEAD for the ciphertext payload, with the sid bound as
227
+ // associated data (AD = sid UTF-8 bytes, 12-byte nonce, 16-byte tag).
228
+ //
229
+ // rc.4..rc.11 used ChaCha20-Poly1305, but the Web Crypto API does NOT
230
+ // implement ChaCha20-Poly1305 in Chrome / Safari / Edge. The pair-page
231
+ // submit path silently threw `Algorithm: Unrecognized name` before
232
+ // reaching the network. rc.12 swaps the cipher suite to AES-256-GCM
233
+ // (universally supported in WebCrypto) and bumps HKDF_INFO to v2 so
234
+ // cross-version mis-pairs fail closed rather than garble.
235
+ //
236
+ // Every primitive is provided by the Node built-in `node:crypto` module
237
+ // on Node 18.19+ and above. NO third-party crypto dependency is added
238
+ // to the plugin for the gateway side. (The BROWSER side of the flow uses
239
+ // WebCrypto's AES-GCM directly — no shim needed.)
240
+ //
241
+ // Interoperability with browser WebCrypto:
242
+ // The WebCrypto x25519 + HKDF + AES-GCM APIs are bit-for-bit compatible
243
+ // with Node's `crypto` as long as:
244
+ // - Raw 32-byte public/private keys are used (not DER/SPKI).
245
+ // - HKDF parameters are (hash=SHA-256, salt=sid bytes, info fixed
246
+ // ASCII string "totalreclaw-pair-v2", length=32 bytes).
247
+ // - AEAD uses a 12-byte random nonce + 16-byte tag, AD = sid bytes.
248
+
249
+ import {
250
+ createPrivateKey,
251
+ createPublicKey,
252
+ diffieHellman,
253
+ generateKeyPairSync,
254
+ hkdfSync,
255
+ createCipheriv,
256
+ createDecipheriv,
257
+ randomBytes,
258
+ timingSafeEqual,
259
+ } from 'node:crypto';
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Pair constants
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * HKDF "info" parameter — fixes the domain separation for this protocol.
267
+ * MUST match the browser-side constant in the pair-page bundle + the
268
+ * relay-served pair-html page.
269
+ *
270
+ * Versioned so we can roll to a new KDF or cipher suite without silently
271
+ * producing garbage with old ciphertexts. rc.12: bumped from v1 to v2
272
+ * after cipher-suite swap from ChaCha20-Poly1305 to AES-256-GCM (see
273
+ * module header comment for context).
274
+ */
275
+ export const HKDF_INFO = 'totalreclaw-pair-v2';
276
+
277
+ /** HKDF output length — 32 bytes = 256-bit AES-256-GCM key. */
278
+ export const AEAD_KEY_BYTES = 32;
279
+
280
+ /** AES-GCM nonce length — 12 bytes (SP 800-38D recommendation). */
281
+ export const AEAD_NONCE_BYTES = 12;
282
+
283
+ /** AES-GCM auth tag length — 16 bytes (128 bits, standard). */
284
+ export const AEAD_TAG_BYTES = 16;
285
+
286
+ /** Raw x25519 public/private key length — 32 bytes per RFC 7748. */
287
+ export const X25519_KEY_BYTES = 32;
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Pair types
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /** Raw 32-byte x25519 public key, base64url-encoded. */
294
+ export type PublicKeyB64 = string;
295
+
296
+ /** Raw 32-byte x25519 private key, base64url-encoded. */
297
+ export type PrivateKeyB64 = string;
298
+
299
+ /** Raw 12-byte AEAD nonce, base64url-encoded. */
300
+ export type NonceB64 = string;
301
+
302
+ /** AEAD ciphertext + appended tag, base64url-encoded. */
303
+ export type CiphertextB64 = string;
304
+
305
+ /** An ephemeral gateway keypair, both halves base64url-encoded. */
306
+ export interface GatewayKeypair {
307
+ skB64: PrivateKeyB64;
308
+ pkB64: PublicKeyB64;
309
+ }
310
+
311
+ /** Fully-derived session keys — caller uses kEnc for AEAD ops. */
312
+ export interface SessionKeys {
313
+ /** 32-byte AES-256-GCM key. */
314
+ kEnc: Buffer;
315
+ }
316
+
317
+ /** Inputs to the gateway-side decryption happy path. */
318
+ export interface DecryptInputs {
319
+ /** Gateway's base64url private key (from pair-session-store). */
320
+ skGatewayB64: PrivateKeyB64;
321
+ /** Device's ephemeral public key, base64url. */
322
+ pkDeviceB64: PublicKeyB64;
323
+ /** Session id, used as HKDF salt AND AEAD AD. */
324
+ sid: string;
325
+ /** Base64url nonce (12 bytes). */
326
+ nonceB64: NonceB64;
327
+ /** Base64url ciphertext (plaintext || 16-byte tag). */
328
+ ciphertextB64: CiphertextB64;
329
+ }
330
+
331
+ /**
332
+ * Inputs for the gateway-side encrypt helper. Only used by tests (the
333
+ * actual encrypt side lives in the browser bundle) — exposed because
334
+ * the test vectors need a round-trip, and because future "gateway
335
+ * replies with an encrypted ACK" flows (4.x) could reuse it.
336
+ */
337
+ export interface EncryptInputs {
338
+ /** Gateway's private key (or ephemeral-side's private key). */
339
+ skLocalB64: PrivateKeyB64;
340
+ /** Peer's public key. */
341
+ pkRemoteB64: PublicKeyB64;
342
+ sid: string;
343
+ plaintext: Buffer | Uint8Array;
344
+ /** Override the 12-byte nonce. Used for tests that need deterministic output. */
345
+ nonceB64?: NonceB64;
346
+ }
347
+
348
+ export interface EncryptOutput {
349
+ nonceB64: NonceB64;
350
+ ciphertextB64: CiphertextB64;
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Pair key generation / conversion
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /**
358
+ * Generate a fresh ephemeral x25519 keypair for a pairing session.
359
+ *
360
+ * Returns raw 32-byte values base64url-encoded. The caller persists the
361
+ * private half in pair-session-store (under the session record's 0600
362
+ * file) and embeds the public half in the QR URL fragment.
363
+ *
364
+ * The returned keys are raw x25519 — NOT DER/SPKI/PEM. The browser
365
+ * side's WebCrypto needs raw bytes to import.
366
+ */
367
+ export function generateGatewayKeypair(): GatewayKeypair {
368
+ const { publicKey, privateKey } = generateKeyPairSync('x25519');
369
+ return {
370
+ skB64: extractRawPrivate(privateKey),
371
+ pkB64: extractRawPublic(publicKey),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Re-constitute a Node `KeyObject` from raw base64url public-key bytes.
377
+ * Uses the JWK OKP encoding because Node doesn't accept raw-format
378
+ * inputs to `createPublicKey` directly.
379
+ */
380
+ function publicKeyFromB64(pkB64: PublicKeyB64): ReturnType<typeof createPublicKey> {
381
+ const raw = Buffer.from(pkB64, 'base64url');
382
+ if (raw.length !== X25519_KEY_BYTES) {
383
+ throw new Error(`pair-crypto: public key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
384
+ }
385
+ return createPublicKey({
386
+ key: { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') },
387
+ format: 'jwk',
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Re-constitute a Node `KeyObject` from raw base64url private-key bytes.
393
+ *
394
+ * Builds the X25519 private KeyObject via the canonical RFC 8410 PKCS#8
395
+ * DER envelope (fixed 16-byte ASN.1 prefix + the raw 32-byte scalar),
396
+ * passed to `createPrivateKey` with `format: 'der', type: 'pkcs8'`. The
397
+ * resulting KeyObject's private material is byte-for-byte the 32 bytes
398
+ * we passed in — verified by the RFC 7748 test vector 1 ECDH assertion
399
+ * in pair-crypto.test.ts (Alice's known private scalar → the known
400
+ * shared secret with Bob's known public).
401
+ *
402
+ * WHY NOT JWK: a JWK OKP private key nominally requires the `x`
403
+ * (public) field alongside `d` (private) per RFC 8037. The legacy path
404
+ * worked around that by constructing a throwaway KeyObject with an
405
+ * EMPTY `x: ''` placeholder, deriving the real public half, then
406
+ * rebuilding the JWK with the real `x`. Node ≤ 24 tolerated the empty
407
+ * `x`; **Node ≥ 26 validates OKP JWK completeness and rejects the
408
+ * placeholder with `ERR_CRYPTO_INVALID_JWK`** — breaking QR pairing
409
+ * for any user on Node 26. The PKCS#8 DER path avoids JWK entirely and
410
+ * is accepted by every Node version that supports X25519 (18.19+); it
411
+ * is also how Node itself encodes X25519 private keys on export.
412
+ *
413
+ * Key-material invariant: the X25519 private scalar IS the `d` value
414
+ * (the 32 raw bytes we accept as input). The DER envelope carries it
415
+ * verbatim with no additional salt/derivation step, so any ECDH shared
416
+ * secret derived from the resulting KeyObject matches the shared
417
+ * secret the OLD JWK path produced for the same input bytes — both
418
+ * paths feed the identical scalar to `diffieHellman`. This equivalence
419
+ * is asserted at the top of vault-crypto.test.ts (RFC 7748 vector) and
420
+ * in pair-crypto.test.ts (round-trip + RFC 7748).
421
+ *
422
+ * Mirror of the browser-side WebCrypto `importKey('raw', ...)` path.
423
+ */
424
+ function privateKeyFromB64(skB64: PrivateKeyB64): ReturnType<typeof createPrivateKey> {
425
+ const raw = Buffer.from(skB64, 'base64url');
426
+ if (raw.length !== X25519_KEY_BYTES) {
427
+ throw new Error(`pair-crypto: private key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
428
+ }
429
+
430
+ // Canonical RFC 8410 PKCS#8 envelope for a CurvePrivateKey (X25519):
431
+ // SEQUENCE (46 bytes) {
432
+ // INTEGER 0 -- PKCS#8 version
433
+ // SEQUENCE { OID 1.3.101.110 } -- id-X25519
434
+ // OCTET STRING (34 bytes) {
435
+ // OCTET STRING (32 bytes) { <raw scalar> }
436
+ // }
437
+ // }
438
+ // The 16-byte prefix is the constant ASN.1 scaffolding; the trailing
439
+ // 32 bytes are the raw private scalar. Hardcoding the prefix is safe
440
+ // because the X25519 OID and PKCS#8 structure are fixed by RFC 8410.
441
+ const PKCS8_X25519_PREFIX = Buffer.from(
442
+ '302e020100300506032b656e04220420',
443
+ 'hex',
444
+ );
445
+ const pkcs8Der = Buffer.concat([PKCS8_X25519_PREFIX, raw]);
446
+ return createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' });
447
+ }
448
+
449
+ /** Extract the raw 32-byte public-key bytes from a Node KeyObject to base64url. */
450
+ function extractRawPublic(pk: ReturnType<typeof createPublicKey>): PublicKeyB64 {
451
+ const jwk = pk.export({ format: 'jwk' }) as { x?: string };
452
+ if (!jwk.x) throw new Error('pair-crypto: public key JWK is missing the x field');
453
+ return jwk.x; // JWK `x` is already base64url-encoded raw bytes.
454
+ }
455
+
456
+ /** Extract the raw 32-byte private-key bytes from a Node KeyObject to base64url. */
457
+ function extractRawPrivate(sk: ReturnType<typeof createPrivateKey>): PrivateKeyB64 {
458
+ const jwk = sk.export({ format: 'jwk' }) as { d?: string };
459
+ if (!jwk.d) throw new Error('pair-crypto: private key JWK is missing the d field');
460
+ return jwk.d;
461
+ }
462
+
463
+ /**
464
+ * Derive the public key from a raw base64url private key. Exposed for
465
+ * tests and for the session-store's self-consistency checks — the
466
+ * default `createPairSession` doesn't call this (it generates both
467
+ * halves at once via `generateGatewayKeypair`).
468
+ */
469
+ export function derivePublicFromPrivate(skB64: PrivateKeyB64): PublicKeyB64 {
470
+ const sk = privateKeyFromB64(skB64);
471
+ const pk = createPublicKey(sk);
472
+ return extractRawPublic(pk);
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Pair ECDH + HKDF
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Perform x25519 ECDH between (local private key, remote public key),
481
+ * producing a 32-byte shared secret.
482
+ *
483
+ * BOTH sides running this with swapped halves MUST produce the same
484
+ * shared secret. This is the foundation of the pairing key agreement.
485
+ *
486
+ * Validation: throws if either key is the wrong byte length, or if
487
+ * the underlying `diffieHellman` call fails (which Node will do for
488
+ * invalid curve points).
489
+ */
490
+ export function computeSharedSecret(opts: {
491
+ skLocalB64: PrivateKeyB64;
492
+ pkRemoteB64: PublicKeyB64;
493
+ }): Buffer {
494
+ const sk = privateKeyFromB64(opts.skLocalB64);
495
+ const pk = publicKeyFromB64(opts.pkRemoteB64);
496
+ const shared = diffieHellman({ privateKey: sk, publicKey: pk });
497
+ if (shared.length !== X25519_KEY_BYTES) {
498
+ throw new Error(
499
+ `pair-crypto: ECDH output wrong length (got ${shared.length}, expected ${X25519_KEY_BYTES})`,
500
+ );
501
+ }
502
+ return shared;
503
+ }
504
+
505
+ /**
506
+ * Derive the AEAD key from a shared secret via HKDF-SHA256. Uses the
507
+ * session id as the salt and the fixed protocol tag as the info.
508
+ *
509
+ * Mathematical sketch (per RFC 5869):
510
+ * PRK = HMAC-SHA256(salt, shared)
511
+ * OKM = HMAC-SHA256(PRK, info || 0x01)[:L]
512
+ *
513
+ * Where L = 32 bytes = AEAD_KEY_BYTES.
514
+ *
515
+ * The sid binding means a ciphertext encrypted for session A is
516
+ * DECRYPTABLE only under session A's derived key. Replaying ct from
517
+ * session A into session B's decrypt path produces a different key to
518
+ * AEAD tag failure, hence rejected.
519
+ */
520
+ export function deriveSessionKeys(opts: {
521
+ sharedSecret: Buffer;
522
+ sid: string;
523
+ }): SessionKeys {
524
+ if (opts.sharedSecret.length !== X25519_KEY_BYTES) {
525
+ throw new Error('pair-crypto: shared secret must be 32 bytes');
526
+ }
527
+ if (typeof opts.sid !== 'string' || opts.sid.length === 0) {
528
+ throw new Error('pair-crypto: sid is required for HKDF salt binding');
529
+ }
530
+ const salt = Buffer.from(opts.sid, 'utf-8');
531
+ const info = Buffer.from(HKDF_INFO, 'utf-8');
532
+ const okm = hkdfSync('sha256', opts.sharedSecret, salt, info, AEAD_KEY_BYTES);
533
+ return { kEnc: Buffer.from(okm) };
534
+ }
535
+
536
+ /**
537
+ * One-shot convenience: ECDH + HKDF in a single call. Used by both the
538
+ * HTTP respond handler (decrypt side) and the tests.
539
+ */
540
+ export function deriveAeadKeyFromEcdh(opts: {
541
+ skLocalB64: PrivateKeyB64;
542
+ pkRemoteB64: PublicKeyB64;
543
+ sid: string;
544
+ }): SessionKeys {
545
+ const shared = computeSharedSecret({
546
+ skLocalB64: opts.skLocalB64,
547
+ pkRemoteB64: opts.pkRemoteB64,
548
+ });
549
+ return deriveSessionKeys({ sharedSecret: shared, sid: opts.sid });
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Pair AEAD
554
+ // ---------------------------------------------------------------------------
555
+
556
+ /**
557
+ * Decrypt an AES-256-GCM AEAD ciphertext. Returns the plaintext on
558
+ * success; throws if the tag is invalid (which includes both tampering
559
+ * and wrong-key attempts).
560
+ *
561
+ * Ciphertext is expected in the combined form `plaintext || tag`, where
562
+ * tag is the trailing 16 bytes. The caller MUST supply the same
563
+ * (kEnc, nonce, sid-as-AD) used for encryption.
564
+ */
565
+ export function aeadDecrypt(opts: {
566
+ kEnc: Buffer;
567
+ nonceB64: NonceB64;
568
+ sid: string;
569
+ ciphertextB64: CiphertextB64;
570
+ }): Buffer {
571
+ const nonce = Buffer.from(opts.nonceB64, 'base64url');
572
+ if (nonce.length !== AEAD_NONCE_BYTES) {
573
+ throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes (got ${nonce.length})`);
574
+ }
575
+ if (opts.kEnc.length !== AEAD_KEY_BYTES) {
576
+ throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
577
+ }
578
+ const combined = Buffer.from(opts.ciphertextB64, 'base64url');
579
+ if (combined.length < AEAD_TAG_BYTES) {
580
+ throw new Error('pair-crypto: ciphertext too short to contain tag');
581
+ }
582
+ const ct = combined.subarray(0, combined.length - AEAD_TAG_BYTES);
583
+ const tag = combined.subarray(combined.length - AEAD_TAG_BYTES);
584
+
585
+ const decipher = createDecipheriv('aes-256-gcm', opts.kEnc, nonce, {
586
+ authTagLength: AEAD_TAG_BYTES,
587
+ });
588
+ decipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: ct.length });
589
+ decipher.setAuthTag(tag);
590
+
591
+ const pt1 = decipher.update(ct);
592
+ const pt2 = decipher.final();
593
+ return Buffer.concat([pt1, pt2]);
594
+ }
595
+
596
+ /**
597
+ * One-shot decrypt: ECDH + HKDF + AEAD in one call. This is what the
598
+ * HTTP respond handler calls. Returns the plaintext Buffer on success.
599
+ *
600
+ * Throws on ANY failure (wrong key length, invalid curve point, tag
601
+ * mismatch). The caller catches and returns 400 to the device.
602
+ */
603
+ export function decryptPairingPayload(inputs: DecryptInputs): Buffer {
604
+ const { kEnc } = deriveAeadKeyFromEcdh({
605
+ skLocalB64: inputs.skGatewayB64,
606
+ pkRemoteB64: inputs.pkDeviceB64,
607
+ sid: inputs.sid,
608
+ });
609
+ return aeadDecrypt({
610
+ kEnc,
611
+ nonceB64: inputs.nonceB64,
612
+ sid: inputs.sid,
613
+ ciphertextB64: inputs.ciphertextB64,
614
+ });
615
+ }
616
+
617
+ /**
618
+ * Encrypt a plaintext payload. Used by tests; also used by any future
619
+ * gateway-to-device encrypted ACK path.
620
+ *
621
+ * Emits (nonce, ciphertext||tag) both base64url-encoded. If the caller
622
+ * passes an explicit `nonceB64`, it is used verbatim; otherwise a
623
+ * fresh 12-byte random nonce is generated.
624
+ */
625
+ export function aeadEncryptWithSessionKey(opts: {
626
+ kEnc: Buffer;
627
+ sid: string;
628
+ plaintext: Buffer | Uint8Array;
629
+ nonceB64?: NonceB64;
630
+ }): EncryptOutput {
631
+ if (opts.kEnc.length !== AEAD_KEY_BYTES) {
632
+ throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
633
+ }
634
+ const nonceBuf =
635
+ opts.nonceB64 !== undefined
636
+ ? Buffer.from(opts.nonceB64, 'base64url')
637
+ : randomBytes(AEAD_NONCE_BYTES);
638
+ if (nonceBuf.length !== AEAD_NONCE_BYTES) {
639
+ throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes`);
640
+ }
641
+
642
+ const pt = Buffer.isBuffer(opts.plaintext) ? opts.plaintext : Buffer.from(opts.plaintext);
643
+ const cipher = createCipheriv('aes-256-gcm', opts.kEnc, nonceBuf, {
644
+ authTagLength: AEAD_TAG_BYTES,
645
+ });
646
+ cipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: pt.length });
647
+ const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
648
+ const tag = cipher.getAuthTag();
649
+ return {
650
+ nonceB64: nonceBuf.toString('base64url'),
651
+ ciphertextB64: Buffer.concat([ct, tag]).toString('base64url'),
652
+ };
653
+ }
654
+
655
+ /**
656
+ * One-shot encrypt: ECDH + HKDF + AEAD (caller supplies both sides).
657
+ * Used by the test vectors and future 4.x features. The real device
658
+ * side does this in the browser, not here.
659
+ */
660
+ export function encryptPairingPayload(inputs: EncryptInputs): EncryptOutput {
661
+ const { kEnc } = deriveAeadKeyFromEcdh({
662
+ skLocalB64: inputs.skLocalB64,
663
+ pkRemoteB64: inputs.pkRemoteB64,
664
+ sid: inputs.sid,
665
+ });
666
+ return aeadEncryptWithSessionKey({
667
+ kEnc,
668
+ sid: inputs.sid,
669
+ plaintext: inputs.plaintext,
670
+ nonceB64: inputs.nonceB64,
671
+ });
672
+ }
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // Pair constant-time secondary-code comparison
676
+ // ---------------------------------------------------------------------------
677
+
678
+ /**
679
+ * Constant-time compare two 6-digit numeric strings. Used by the HTTP
680
+ * start/respond handlers to check the user-supplied secondary code
681
+ * against the session's expected code.
682
+ *
683
+ * Returns true on match, false otherwise. Length mismatch returns
684
+ * false without short-circuit leak of which side was shorter.
685
+ *
686
+ * Uses Node `timingSafeEqual`, which requires equal-length inputs.
687
+ * We pad the SHORTER input with NULs and always compare a fixed
688
+ * 6-byte window — the length check happens BEFORE the actual compare
689
+ * and uses boolean AND at the end so both branches run to completion.
690
+ */
691
+ export function compareSecondaryCodesCT(a: string, b: string): boolean {
692
+ const aBuf = Buffer.from(a, 'utf-8');
693
+ const bBuf = Buffer.from(b, 'utf-8');
694
+ const lenMatch = aBuf.length === bBuf.length;
695
+ // Always compare a constant window so the total work is independent
696
+ // of the actual input lengths. Pad the shorter to the longer with
697
+ // zero bytes; if lengths diverge we force lenMatch=false below.
698
+ const max = Math.max(aBuf.length, bBuf.length, 6);
699
+ const aPad = Buffer.alloc(max);
700
+ const bPad = Buffer.alloc(max);
701
+ aBuf.copy(aPad);
702
+ bBuf.copy(bPad);
703
+ const byteMatch = timingSafeEqual(aPad, bPad);
704
+ return lenMatch && byteMatch;
705
+ }