@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.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +34 -249
- package/api-client.ts +17 -9
- package/batch-gate.ts +42 -0
- package/billing-cache.ts +25 -20
- package/claims-helper.ts +7 -1
- package/config.ts +54 -12
- package/consolidation.ts +6 -13
- package/contradiction-sync.ts +19 -14
- package/credential-provider.ts +184 -0
- package/crypto.ts +27 -160
- package/dist/api-client.js +17 -9
- package/dist/batch-gate.js +40 -0
- package/dist/billing-cache.js +19 -21
- package/dist/claims-helper.js +7 -1
- package/dist/config.js +54 -12
- package/dist/consolidation.js +6 -15
- package/dist/contradiction-sync.js +15 -15
- package/dist/credential-provider.js +145 -0
- package/dist/crypto.js +17 -137
- package/dist/download-ux.js +11 -7
- package/dist/embedder-loader.js +266 -0
- package/dist/embedding.js +36 -3
- package/dist/entry.js +123 -0
- package/dist/extractor.js +134 -0
- package/dist/fs-helpers.js +116 -241
- package/dist/import-adapters/chatgpt-adapter.js +14 -0
- package/dist/import-adapters/claude-adapter.js +14 -0
- package/dist/import-adapters/gemini-adapter.js +43 -159
- package/dist/import-adapters/mcp-memory-adapter.js +14 -0
- package/dist/import-state-manager.js +100 -0
- package/dist/index.js +1130 -2520
- package/dist/llm-client.js +69 -1
- package/dist/memory-runtime.js +459 -0
- package/dist/native-memory.js +123 -0
- package/dist/onboarding-cli.js +3 -2
- package/dist/pair-cli.js +1 -1
- package/dist/pair-crypto.js +16 -358
- package/dist/pair-http.js +147 -4
- package/dist/relay.js +140 -0
- package/dist/reranker.js +13 -8
- package/dist/semantic-dedup.js +5 -7
- package/dist/skill-register.js +97 -0
- package/dist/subgraph-search.js +3 -1
- package/dist/subgraph-store.js +315 -282
- package/dist/tool-gating.js +39 -26
- package/dist/tools.js +367 -0
- package/dist/tr-cli-export-helper.js +103 -0
- package/dist/tr-cli.js +220 -127
- package/dist/trajectory-poller.js +155 -9
- package/dist/vault-crypto.js +551 -0
- package/download-ux.ts +12 -6
- package/embedder-loader.ts +293 -1
- package/embedding.ts +43 -3
- package/entry.ts +132 -0
- package/extractor.ts +167 -0
- package/fs-helpers.ts +166 -292
- package/import-adapters/chatgpt-adapter.ts +18 -0
- package/import-adapters/claude-adapter.ts +18 -0
- package/import-adapters/gemini-adapter.ts +56 -183
- package/import-adapters/mcp-memory-adapter.ts +18 -0
- package/import-adapters/types.ts +1 -1
- package/import-state-manager.ts +139 -0
- package/index.ts +1432 -3002
- package/llm-client.ts +74 -1
- package/memory-runtime.ts +723 -0
- package/native-memory.ts +196 -0
- package/onboarding-cli.ts +3 -2
- package/openclaw.plugin.json +5 -17
- package/package.json +7 -4
- package/pair-cli.ts +1 -1
- package/pair-crypto.ts +41 -483
- package/pair-http.ts +194 -5
- package/postinstall.mjs +138 -0
- package/relay.ts +172 -0
- package/reranker.ts +13 -8
- package/semantic-dedup.ts +5 -6
- package/skill-register.ts +146 -0
- package/skill.json +1 -1
- package/subgraph-search.ts +3 -1
- package/subgraph-store.ts +334 -299
- package/tool-gating.ts +39 -26
- package/tools.ts +499 -0
- package/tr-cli-export-helper.ts +138 -0
- package/tr-cli.ts +263 -133
- package/trajectory-poller.ts +162 -10
- package/vault-crypto.ts +705 -0
package/pair-crypto.ts
CHANGED
|
@@ -1,484 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pair-crypto —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* - Raw 32-byte public/private keys are used (not DER/SPKI).
|
|
44
|
-
* - HKDF parameters are (hash=SHA-256, salt=sid bytes, info fixed
|
|
45
|
-
* ASCII string "totalreclaw-pair-v2", length=32 bytes).
|
|
46
|
-
* - AEAD uses a 12-byte random nonce + 16-byte tag, AD = sid bytes.
|
|
47
|
-
* See tests for fixed test vectors.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
import {
|
|
51
|
-
createPrivateKey,
|
|
52
|
-
createPublicKey,
|
|
53
|
-
diffieHellman,
|
|
54
|
-
generateKeyPairSync,
|
|
55
|
-
hkdfSync,
|
|
56
|
-
createCipheriv,
|
|
57
|
-
createDecipheriv,
|
|
58
|
-
randomBytes,
|
|
59
|
-
timingSafeEqual,
|
|
60
|
-
} from 'node:crypto';
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// Constants
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* HKDF "info" parameter — fixes the domain separation for this protocol.
|
|
68
|
-
* MUST match the browser-side constant in the pair-page bundle + the
|
|
69
|
-
* relay-served pair-html page.
|
|
70
|
-
*
|
|
71
|
-
* Versioned so we can roll to a new KDF or cipher suite without silently
|
|
72
|
-
* producing garbage with old ciphertexts. rc.12: bumped from v1 to v2
|
|
73
|
-
* after cipher-suite swap from ChaCha20-Poly1305 → AES-256-GCM (see
|
|
74
|
-
* module header comment for context).
|
|
75
|
-
*/
|
|
76
|
-
export const HKDF_INFO = 'totalreclaw-pair-v2';
|
|
77
|
-
|
|
78
|
-
/** HKDF output length — 32 bytes = 256-bit AES-256-GCM key. */
|
|
79
|
-
export const AEAD_KEY_BYTES = 32;
|
|
80
|
-
|
|
81
|
-
/** AES-GCM nonce length — 12 bytes (SP 800-38D recommendation). */
|
|
82
|
-
export const AEAD_NONCE_BYTES = 12;
|
|
83
|
-
|
|
84
|
-
/** AES-GCM auth tag length — 16 bytes (128 bits, standard). */
|
|
85
|
-
export const AEAD_TAG_BYTES = 16;
|
|
86
|
-
|
|
87
|
-
/** Raw x25519 public/private key length — 32 bytes per RFC 7748. */
|
|
88
|
-
export const X25519_KEY_BYTES = 32;
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// Types
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
|
|
94
|
-
/** Raw 32-byte x25519 public key, base64url-encoded. */
|
|
95
|
-
export type PublicKeyB64 = string;
|
|
96
|
-
|
|
97
|
-
/** Raw 32-byte x25519 private key, base64url-encoded. */
|
|
98
|
-
export type PrivateKeyB64 = string;
|
|
99
|
-
|
|
100
|
-
/** Raw 12-byte AEAD nonce, base64url-encoded. */
|
|
101
|
-
export type NonceB64 = string;
|
|
102
|
-
|
|
103
|
-
/** AEAD ciphertext + appended tag, base64url-encoded. */
|
|
104
|
-
export type CiphertextB64 = string;
|
|
105
|
-
|
|
106
|
-
/** An ephemeral gateway keypair, both halves base64url-encoded. */
|
|
107
|
-
export interface GatewayKeypair {
|
|
108
|
-
skB64: PrivateKeyB64;
|
|
109
|
-
pkB64: PublicKeyB64;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Fully-derived session keys — caller uses kEnc for AEAD ops. */
|
|
113
|
-
export interface SessionKeys {
|
|
114
|
-
/** 32-byte AES-256-GCM key. */
|
|
115
|
-
kEnc: Buffer;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Inputs to the gateway-side decryption happy path. */
|
|
119
|
-
export interface DecryptInputs {
|
|
120
|
-
/** Gateway's base64url private key (from pair-session-store). */
|
|
121
|
-
skGatewayB64: PrivateKeyB64;
|
|
122
|
-
/** Device's ephemeral public key, base64url. */
|
|
123
|
-
pkDeviceB64: PublicKeyB64;
|
|
124
|
-
/** Session id, used as HKDF salt AND AEAD AD. */
|
|
125
|
-
sid: string;
|
|
126
|
-
/** Base64url nonce (12 bytes). */
|
|
127
|
-
nonceB64: NonceB64;
|
|
128
|
-
/** Base64url ciphertext (plaintext || 16-byte tag). */
|
|
129
|
-
ciphertextB64: CiphertextB64;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Inputs for the gateway-side encrypt helper. Only used by tests (the
|
|
134
|
-
* actual encrypt side lives in the browser bundle) — exposed because
|
|
135
|
-
* the test vectors need a round-trip, and because future "gateway
|
|
136
|
-
* replies with an encrypted ACK" flows (4.x) could reuse it.
|
|
137
|
-
*/
|
|
138
|
-
export interface EncryptInputs {
|
|
139
|
-
/** Gateway's private key (or ephemeral-side's private key). */
|
|
140
|
-
skLocalB64: PrivateKeyB64;
|
|
141
|
-
/** Peer's public key. */
|
|
142
|
-
pkRemoteB64: PublicKeyB64;
|
|
143
|
-
sid: string;
|
|
144
|
-
plaintext: Buffer | Uint8Array;
|
|
145
|
-
/** Override the 12-byte nonce. Used for tests that need deterministic output. */
|
|
146
|
-
nonceB64?: NonceB64;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export interface EncryptOutput {
|
|
150
|
-
nonceB64: NonceB64;
|
|
151
|
-
ciphertextB64: CiphertextB64;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
// Key generation / conversion
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Generate a fresh ephemeral x25519 keypair for a pairing session.
|
|
160
|
-
*
|
|
161
|
-
* Returns raw 32-byte values base64url-encoded. The caller persists the
|
|
162
|
-
* private half in pair-session-store (under the session record's 0600
|
|
163
|
-
* file) and embeds the public half in the QR URL fragment.
|
|
164
|
-
*
|
|
165
|
-
* The returned keys are raw x25519 — NOT DER/SPKI/PEM. The browser
|
|
166
|
-
* side's WebCrypto needs raw bytes to import.
|
|
167
|
-
*/
|
|
168
|
-
export function generateGatewayKeypair(): GatewayKeypair {
|
|
169
|
-
const { publicKey, privateKey } = generateKeyPairSync('x25519');
|
|
170
|
-
return {
|
|
171
|
-
skB64: extractRawPrivate(privateKey),
|
|
172
|
-
pkB64: extractRawPublic(publicKey),
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Re-constitute a Node `KeyObject` from raw base64url public-key bytes.
|
|
178
|
-
* Uses the JWK OKP encoding because Node doesn't accept raw-format
|
|
179
|
-
* inputs to `createPublicKey` directly.
|
|
180
|
-
*/
|
|
181
|
-
function publicKeyFromB64(pkB64: PublicKeyB64): ReturnType<typeof createPublicKey> {
|
|
182
|
-
const raw = Buffer.from(pkB64, 'base64url');
|
|
183
|
-
if (raw.length !== X25519_KEY_BYTES) {
|
|
184
|
-
throw new Error(`pair-crypto: public key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
|
|
185
|
-
}
|
|
186
|
-
return createPublicKey({
|
|
187
|
-
key: { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') },
|
|
188
|
-
format: 'jwk',
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Re-constitute a Node `KeyObject` from raw base64url private-key bytes.
|
|
194
|
-
*
|
|
195
|
-
* JWK OKP private keys require the `x` (public) field too; we derive it
|
|
196
|
-
* by first constructing a temporary KeyObject from `d` alone, then
|
|
197
|
-
* exporting its public half. This is cheap (one scalarmult) and keeps
|
|
198
|
-
* the call sites clean.
|
|
199
|
-
*
|
|
200
|
-
* Mirror of the browser-side WebCrypto `importKey('raw', ...)` path.
|
|
201
|
-
*/
|
|
202
|
-
function privateKeyFromB64(skB64: PrivateKeyB64): ReturnType<typeof createPrivateKey> {
|
|
203
|
-
const raw = Buffer.from(skB64, 'base64url');
|
|
204
|
-
if (raw.length !== X25519_KEY_BYTES) {
|
|
205
|
-
throw new Error(`pair-crypto: private key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// The JWK OKP format requires `x` (public) alongside `d` (private).
|
|
209
|
-
// Derive `x` by first constructing a public KeyObject from the scalar
|
|
210
|
-
// via the OKP JWK path — Node accepts `d` alone and returns a usable
|
|
211
|
-
// private KeyObject from which we can derive the public half.
|
|
212
|
-
const tempPriv = createPrivateKey({
|
|
213
|
-
key: { kty: 'OKP', crv: 'X25519', d: raw.toString('base64url'), x: '' },
|
|
214
|
-
format: 'jwk',
|
|
215
|
-
});
|
|
216
|
-
// Derive the public half.
|
|
217
|
-
const pubObj = createPublicKey(tempPriv);
|
|
218
|
-
const pubJwk = pubObj.export({ format: 'jwk' }) as { x: string };
|
|
219
|
-
|
|
220
|
-
// Re-construct with the full (x, d) pair. This is the canonical form
|
|
221
|
-
// the rest of the code holds onto.
|
|
222
|
-
return createPrivateKey({
|
|
223
|
-
key: { kty: 'OKP', crv: 'X25519', d: raw.toString('base64url'), x: pubJwk.x },
|
|
224
|
-
format: 'jwk',
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Extract the raw 32-byte public-key bytes from a Node KeyObject → base64url. */
|
|
229
|
-
function extractRawPublic(pk: ReturnType<typeof createPublicKey>): PublicKeyB64 {
|
|
230
|
-
const jwk = pk.export({ format: 'jwk' }) as { x?: string };
|
|
231
|
-
if (!jwk.x) throw new Error('pair-crypto: public key JWK is missing the x field');
|
|
232
|
-
return jwk.x; // JWK `x` is already base64url-encoded raw bytes.
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Extract the raw 32-byte private-key bytes from a Node KeyObject → base64url. */
|
|
236
|
-
function extractRawPrivate(sk: ReturnType<typeof createPrivateKey>): PrivateKeyB64 {
|
|
237
|
-
const jwk = sk.export({ format: 'jwk' }) as { d?: string };
|
|
238
|
-
if (!jwk.d) throw new Error('pair-crypto: private key JWK is missing the d field');
|
|
239
|
-
return jwk.d;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Derive the public key from a raw base64url private key. Exposed for
|
|
244
|
-
* tests and for the session-store's self-consistency checks — the
|
|
245
|
-
* default `createPairSession` doesn't call this (it generates both
|
|
246
|
-
* halves at once via `generateGatewayKeypair`).
|
|
247
|
-
*/
|
|
248
|
-
export function derivePublicFromPrivate(skB64: PrivateKeyB64): PublicKeyB64 {
|
|
249
|
-
const sk = privateKeyFromB64(skB64);
|
|
250
|
-
const pk = createPublicKey(sk);
|
|
251
|
-
return extractRawPublic(pk);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
// ECDH + HKDF
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Perform x25519 ECDH between (local private key, remote public key),
|
|
260
|
-
* producing a 32-byte shared secret.
|
|
261
|
-
*
|
|
262
|
-
* BOTH sides running this with swapped halves MUST produce the same
|
|
263
|
-
* shared secret. This is the foundation of the pairing key agreement.
|
|
264
|
-
*
|
|
265
|
-
* Validation: throws if either key is the wrong byte length, or if
|
|
266
|
-
* the underlying `diffieHellman` call fails (which Node will do for
|
|
267
|
-
* invalid curve points).
|
|
268
|
-
*/
|
|
269
|
-
export function computeSharedSecret(opts: {
|
|
270
|
-
skLocalB64: PrivateKeyB64;
|
|
271
|
-
pkRemoteB64: PublicKeyB64;
|
|
272
|
-
}): Buffer {
|
|
273
|
-
const sk = privateKeyFromB64(opts.skLocalB64);
|
|
274
|
-
const pk = publicKeyFromB64(opts.pkRemoteB64);
|
|
275
|
-
const shared = diffieHellman({ privateKey: sk, publicKey: pk });
|
|
276
|
-
if (shared.length !== X25519_KEY_BYTES) {
|
|
277
|
-
throw new Error(
|
|
278
|
-
`pair-crypto: ECDH output wrong length (got ${shared.length}, expected ${X25519_KEY_BYTES})`,
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
return shared;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Derive the AEAD key from a shared secret via HKDF-SHA256. Uses the
|
|
286
|
-
* session id as the salt and the fixed protocol tag as the info.
|
|
287
|
-
*
|
|
288
|
-
* Mathematical sketch (per RFC 5869):
|
|
289
|
-
* PRK = HMAC-SHA256(salt, shared)
|
|
290
|
-
* OKM = HMAC-SHA256(PRK, info || 0x01)[:L]
|
|
291
|
-
*
|
|
292
|
-
* Where L = 32 bytes = AEAD_KEY_BYTES.
|
|
293
|
-
*
|
|
294
|
-
* The sid binding means a ciphertext encrypted for session A is
|
|
295
|
-
* DECRYPTABLE only under session A's derived key. Replaying ct from
|
|
296
|
-
* session A into session B's decrypt path produces a different key →
|
|
297
|
-
* AEAD tag fails → rejected.
|
|
298
|
-
*/
|
|
299
|
-
export function deriveSessionKeys(opts: {
|
|
300
|
-
sharedSecret: Buffer;
|
|
301
|
-
sid: string;
|
|
302
|
-
}): SessionKeys {
|
|
303
|
-
if (opts.sharedSecret.length !== X25519_KEY_BYTES) {
|
|
304
|
-
throw new Error('pair-crypto: shared secret must be 32 bytes');
|
|
305
|
-
}
|
|
306
|
-
if (typeof opts.sid !== 'string' || opts.sid.length === 0) {
|
|
307
|
-
throw new Error('pair-crypto: sid is required for HKDF salt binding');
|
|
308
|
-
}
|
|
309
|
-
const salt = Buffer.from(opts.sid, 'utf-8');
|
|
310
|
-
const info = Buffer.from(HKDF_INFO, 'utf-8');
|
|
311
|
-
const okm = hkdfSync('sha256', opts.sharedSecret, salt, info, AEAD_KEY_BYTES);
|
|
312
|
-
return { kEnc: Buffer.from(okm) };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* One-shot convenience: ECDH + HKDF in a single call. Used by both the
|
|
317
|
-
* HTTP respond handler (decrypt side) and the tests.
|
|
318
|
-
*/
|
|
319
|
-
export function deriveAeadKeyFromEcdh(opts: {
|
|
320
|
-
skLocalB64: PrivateKeyB64;
|
|
321
|
-
pkRemoteB64: PublicKeyB64;
|
|
322
|
-
sid: string;
|
|
323
|
-
}): SessionKeys {
|
|
324
|
-
const shared = computeSharedSecret({
|
|
325
|
-
skLocalB64: opts.skLocalB64,
|
|
326
|
-
pkRemoteB64: opts.pkRemoteB64,
|
|
327
|
-
});
|
|
328
|
-
return deriveSessionKeys({ sharedSecret: shared, sid: opts.sid });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
// AEAD
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Decrypt an AES-256-GCM AEAD ciphertext. Returns the plaintext on
|
|
337
|
-
* success; throws if the tag is invalid (which includes both tampering
|
|
338
|
-
* and wrong-key attempts).
|
|
339
|
-
*
|
|
340
|
-
* Ciphertext is expected in the combined form `plaintext || tag`, where
|
|
341
|
-
* tag is the trailing 16 bytes. The caller MUST supply the same
|
|
342
|
-
* (kEnc, nonce, sid-as-AD) used for encryption.
|
|
343
|
-
*/
|
|
344
|
-
export function aeadDecrypt(opts: {
|
|
345
|
-
kEnc: Buffer;
|
|
346
|
-
nonceB64: NonceB64;
|
|
347
|
-
sid: string;
|
|
348
|
-
ciphertextB64: CiphertextB64;
|
|
349
|
-
}): Buffer {
|
|
350
|
-
const nonce = Buffer.from(opts.nonceB64, 'base64url');
|
|
351
|
-
if (nonce.length !== AEAD_NONCE_BYTES) {
|
|
352
|
-
throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes (got ${nonce.length})`);
|
|
353
|
-
}
|
|
354
|
-
if (opts.kEnc.length !== AEAD_KEY_BYTES) {
|
|
355
|
-
throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
|
|
356
|
-
}
|
|
357
|
-
const combined = Buffer.from(opts.ciphertextB64, 'base64url');
|
|
358
|
-
if (combined.length < AEAD_TAG_BYTES) {
|
|
359
|
-
throw new Error('pair-crypto: ciphertext too short to contain tag');
|
|
360
|
-
}
|
|
361
|
-
const ct = combined.subarray(0, combined.length - AEAD_TAG_BYTES);
|
|
362
|
-
const tag = combined.subarray(combined.length - AEAD_TAG_BYTES);
|
|
363
|
-
|
|
364
|
-
const decipher = createDecipheriv('aes-256-gcm', opts.kEnc, nonce, {
|
|
365
|
-
authTagLength: AEAD_TAG_BYTES,
|
|
366
|
-
});
|
|
367
|
-
decipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: ct.length });
|
|
368
|
-
decipher.setAuthTag(tag);
|
|
369
|
-
|
|
370
|
-
const pt1 = decipher.update(ct);
|
|
371
|
-
const pt2 = decipher.final();
|
|
372
|
-
return Buffer.concat([pt1, pt2]);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* One-shot decrypt: ECDH + HKDF + AEAD in one call. This is what the
|
|
377
|
-
* HTTP respond handler calls. Returns the plaintext Buffer on success.
|
|
378
|
-
*
|
|
379
|
-
* Throws on ANY failure (wrong key length, invalid curve point, tag
|
|
380
|
-
* mismatch). The caller catches and returns 400 to the device.
|
|
381
|
-
*/
|
|
382
|
-
export function decryptPairingPayload(inputs: DecryptInputs): Buffer {
|
|
383
|
-
const { kEnc } = deriveAeadKeyFromEcdh({
|
|
384
|
-
skLocalB64: inputs.skGatewayB64,
|
|
385
|
-
pkRemoteB64: inputs.pkDeviceB64,
|
|
386
|
-
sid: inputs.sid,
|
|
387
|
-
});
|
|
388
|
-
return aeadDecrypt({
|
|
389
|
-
kEnc,
|
|
390
|
-
nonceB64: inputs.nonceB64,
|
|
391
|
-
sid: inputs.sid,
|
|
392
|
-
ciphertextB64: inputs.ciphertextB64,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Encrypt a plaintext payload. Used by tests; also used by any future
|
|
398
|
-
* gateway-to-device encrypted ACK path.
|
|
399
|
-
*
|
|
400
|
-
* Emits (nonce, ciphertext||tag) both base64url-encoded. If the caller
|
|
401
|
-
* passes an explicit `nonceB64`, it is used verbatim; otherwise a
|
|
402
|
-
* fresh 12-byte random nonce is generated.
|
|
403
|
-
*/
|
|
404
|
-
export function aeadEncryptWithSessionKey(opts: {
|
|
405
|
-
kEnc: Buffer;
|
|
406
|
-
sid: string;
|
|
407
|
-
plaintext: Buffer | Uint8Array;
|
|
408
|
-
nonceB64?: NonceB64;
|
|
409
|
-
}): EncryptOutput {
|
|
410
|
-
if (opts.kEnc.length !== AEAD_KEY_BYTES) {
|
|
411
|
-
throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
|
|
412
|
-
}
|
|
413
|
-
const nonceBuf =
|
|
414
|
-
opts.nonceB64 !== undefined
|
|
415
|
-
? Buffer.from(opts.nonceB64, 'base64url')
|
|
416
|
-
: randomBytes(AEAD_NONCE_BYTES);
|
|
417
|
-
if (nonceBuf.length !== AEAD_NONCE_BYTES) {
|
|
418
|
-
throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes`);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const pt = Buffer.isBuffer(opts.plaintext) ? opts.plaintext : Buffer.from(opts.plaintext);
|
|
422
|
-
const cipher = createCipheriv('aes-256-gcm', opts.kEnc, nonceBuf, {
|
|
423
|
-
authTagLength: AEAD_TAG_BYTES,
|
|
424
|
-
});
|
|
425
|
-
cipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: pt.length });
|
|
426
|
-
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
427
|
-
const tag = cipher.getAuthTag();
|
|
428
|
-
return {
|
|
429
|
-
nonceB64: nonceBuf.toString('base64url'),
|
|
430
|
-
ciphertextB64: Buffer.concat([ct, tag]).toString('base64url'),
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* One-shot encrypt: ECDH + HKDF + AEAD (caller supplies both sides).
|
|
436
|
-
* Used by the test vectors and future 4.x features. The real device
|
|
437
|
-
* side does this in the browser, not here.
|
|
438
|
-
*/
|
|
439
|
-
export function encryptPairingPayload(inputs: EncryptInputs): EncryptOutput {
|
|
440
|
-
const { kEnc } = deriveAeadKeyFromEcdh({
|
|
441
|
-
skLocalB64: inputs.skLocalB64,
|
|
442
|
-
pkRemoteB64: inputs.pkRemoteB64,
|
|
443
|
-
sid: inputs.sid,
|
|
444
|
-
});
|
|
445
|
-
return aeadEncryptWithSessionKey({
|
|
446
|
-
kEnc,
|
|
447
|
-
sid: inputs.sid,
|
|
448
|
-
plaintext: inputs.plaintext,
|
|
449
|
-
nonceB64: inputs.nonceB64,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ---------------------------------------------------------------------------
|
|
454
|
-
// Constant-time secondary-code comparison
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Constant-time compare two 6-digit numeric strings. Used by the HTTP
|
|
459
|
-
* start/respond handlers to check the user-supplied secondary code
|
|
460
|
-
* against the session's expected code.
|
|
461
|
-
*
|
|
462
|
-
* Returns true on match, false otherwise. Length mismatch returns
|
|
463
|
-
* false without short-circuit leak of which side was shorter.
|
|
464
|
-
*
|
|
465
|
-
* Uses Node `timingSafeEqual`, which requires equal-length inputs.
|
|
466
|
-
* We pad the SHORTER input with NULs and always compare a fixed
|
|
467
|
-
* 6-byte window — the length check happens BEFORE the actual compare
|
|
468
|
-
* and uses boolean AND at the end so both branches run to completion.
|
|
469
|
-
*/
|
|
470
|
-
export function compareSecondaryCodesCT(a: string, b: string): boolean {
|
|
471
|
-
const aBuf = Buffer.from(a, 'utf-8');
|
|
472
|
-
const bBuf = Buffer.from(b, 'utf-8');
|
|
473
|
-
const lenMatch = aBuf.length === bBuf.length;
|
|
474
|
-
// Always compare a constant window so the total work is independent
|
|
475
|
-
// of the actual input lengths. Pad the shorter to the longer with
|
|
476
|
-
// zero bytes; if lengths diverge we force lenMatch=false below.
|
|
477
|
-
const max = Math.max(aBuf.length, bBuf.length, 6);
|
|
478
|
-
const aPad = Buffer.alloc(max);
|
|
479
|
-
const bPad = Buffer.alloc(max);
|
|
480
|
-
aBuf.copy(aPad);
|
|
481
|
-
bBuf.copy(bPad);
|
|
482
|
-
const byteMatch = timingSafeEqual(aPad, bPad);
|
|
483
|
-
return lenMatch && byteMatch;
|
|
484
|
-
}
|
|
2
|
+
* pair-crypto.ts — legacy re-export shim.
|
|
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
|
+
* the gateway-side pair primitives (x25519 ECDH, HKDF-SHA256, AES-256-GCM
|
|
7
|
+
* AEAD, constant-time secondary-code comparison) have moved to
|
|
8
|
+
* `vault-crypto.ts`. This file remains as a thin re-export so existing
|
|
9
|
+
* importers (`pair-cli.ts`, `pair-remote-client.ts`, `pair-http.ts`) do
|
|
10
|
+
* not break in this pass — a big-bang rewrite of the import graph is out
|
|
11
|
+
* of scope for the scanner-clean split.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here reads the environment or hits the network; all key
|
|
14
|
+
* material and nonces are passed in by callers. See vault-crypto.ts for
|
|
15
|
+
* the implementation.
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
HKDF_INFO,
|
|
19
|
+
AEAD_KEY_BYTES,
|
|
20
|
+
AEAD_NONCE_BYTES,
|
|
21
|
+
AEAD_TAG_BYTES,
|
|
22
|
+
X25519_KEY_BYTES,
|
|
23
|
+
type PublicKeyB64,
|
|
24
|
+
type PrivateKeyB64,
|
|
25
|
+
type NonceB64,
|
|
26
|
+
type CiphertextB64,
|
|
27
|
+
type GatewayKeypair,
|
|
28
|
+
type SessionKeys,
|
|
29
|
+
type DecryptInputs,
|
|
30
|
+
type EncryptInputs,
|
|
31
|
+
type EncryptOutput,
|
|
32
|
+
generateGatewayKeypair,
|
|
33
|
+
derivePublicFromPrivate,
|
|
34
|
+
computeSharedSecret,
|
|
35
|
+
deriveSessionKeys,
|
|
36
|
+
deriveAeadKeyFromEcdh,
|
|
37
|
+
aeadDecrypt,
|
|
38
|
+
decryptPairingPayload,
|
|
39
|
+
aeadEncryptWithSessionKey,
|
|
40
|
+
encryptPairingPayload,
|
|
41
|
+
compareSecondaryCodesCT,
|
|
42
|
+
} from './vault-crypto.js';
|