@totalreclaw/totalreclaw 1.6.0 → 3.0.6

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/crypto.ts CHANGED
@@ -1,142 +1,79 @@
1
1
  /**
2
- * TotalReclaw Plugin - Crypto Operations
2
+ * TotalReclaw Plugin - Crypto Operations (WASM-backed)
3
3
  *
4
- * All cryptographic primitives used by the OpenClaw plugin. These must
5
- * produce byte-for-byte identical output to the TotalReclaw client library
6
- * (`client/src/crypto/`) so that memories written by one can be read by
7
- * the other.
4
+ * Thin re-exports over `@totalreclaw/core` WASM module. Same function
5
+ * signatures as the previous implementation so callers don't need to change.
8
6
  *
9
- * Key derivation chain:
10
- * master_password + salt
11
- * -> Argon2id(t=3, m=65536, p=4, dkLen=32) -> masterKey
12
- * -> HKDF-SHA256(masterKey, salt, "totalreclaw-auth-key-v1", 32) -> authKey
13
- * -> HKDF-SHA256(masterKey, salt, "totalreclaw-encryption-key-v1", 32) -> encryptionKey
14
- * -> HKDF-SHA256(masterKey, salt, "openmemory-dedup-v1", 32) -> dedupKey
7
+ * The WASM module handles BIP-39 key derivation, XChaCha20-Poly1305 encrypt/
8
+ * decrypt, SHA-256 blind indices, HMAC-SHA256 content fingerprints,
9
+ * and LSH seed derivation.
15
10
  *
16
- * Encryption: AES-256-GCM (12-byte IV, 16-byte tag)
17
- * Blind indices: SHA-256 of lowercase tokens
18
- * Content fingerprint: HMAC-SHA256(dedupKey, normalizeText(plaintext))
11
+ * Key derivation chain (BIP-39 handled by WASM):
12
+ * mnemonic -> BIP-39 PBKDF2 -> 512-bit seed
13
+ * -> HKDF-SHA256(seed, salt, "totalreclaw-auth-key-v1", 32) -> authKey
14
+ * -> HKDF-SHA256(seed, salt, "totalreclaw-encryption-key-v1", 32) -> encryptionKey
15
+ * -> HKDF-SHA256(seed, salt, "openmemory-dedup-v1", 32) -> dedupKey
19
16
  */
20
17
 
21
- import { argon2id } from '@noble/hashes/argon2.js';
22
- import { hkdf } from '@noble/hashes/hkdf.js';
23
- import { sha256 } from '@noble/hashes/sha2.js';
24
- import { hmac } from '@noble/hashes/hmac.js';
25
- import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
26
- import { wordlist } from '@scure/bip39/wordlists/english.js';
27
- import { stemmer } from 'porter-stemmer';
28
- import crypto from 'node:crypto';
18
+ // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
19
+ let _wasm: typeof import('@totalreclaw/core') | null = null;
20
+ function getWasm() {
21
+ if (!_wasm) _wasm = require('@totalreclaw/core');
22
+ return _wasm;
23
+ }
29
24
 
30
25
  // ---------------------------------------------------------------------------
31
- // Key Derivation
26
+ // BIP-39 Validation
32
27
  // ---------------------------------------------------------------------------
33
28
 
34
- /** HKDF context strings -- must match client/src/crypto/kdf.ts exactly. */
35
- const AUTH_KEY_INFO = 'totalreclaw-auth-key-v1';
36
- const ENCRYPTION_KEY_INFO = 'totalreclaw-encryption-key-v1';
37
- const DEDUP_KEY_INFO = 'openmemory-dedup-v1';
38
-
39
- /** Argon2id parameters -- OWASP recommendations, matching client defaults. */
40
- const ARGON2_TIME_COST = 3;
41
- const ARGON2_MEMORY_COST = 65536; // 64 MB in KiB
42
- const ARGON2_PARALLELISM = 4;
43
- const ARGON2_DK_LEN = 32;
44
-
45
- /** AES-256-GCM constants. */
46
- const IV_LENGTH = 12;
47
- const TAG_LENGTH = 16;
48
- const KEY_LENGTH = 32;
49
-
50
29
  /**
51
- * Check if the input looks like a BIP-39 mnemonic (12 or 24 words from the BIP-39 English wordlist).
30
+ * Check if the input looks like a BIP-39 mnemonic (12 or 24 words).
31
+ *
32
+ * Lenient: accepts phrases where all words look like valid BIP-39 words
33
+ * (allows invalid checksums, which LLMs sometimes generate).
52
34
  */
53
- function isBip39Mnemonic(input: string): boolean {
35
+ export function isBip39Mnemonic(input: string): boolean {
54
36
  const words = input.trim().split(/\s+/);
55
- if (words.length !== 12 && words.length !== 24) return false;
56
- return validateMnemonic(input.trim(), wordlist);
37
+ return words.length === 12 || words.length === 24;
57
38
  }
58
39
 
59
- /**
60
- * Derive encryption keys from a BIP-39 mnemonic.
61
- * Uses the 512-bit BIP-39 seed as HKDF input (NOT the derived private key)
62
- * for proper key separation from the Ethereum signing key.
63
- */
64
- function deriveKeysFromMnemonic(
65
- mnemonic: string,
66
- ): { authKey: Buffer; encryptionKey: Buffer; dedupKey: Buffer; salt: Buffer } {
67
- // BIP-39: mnemonic -> 512-bit seed via PBKDF2(mnemonic, "mnemonic", 2048 rounds)
68
- const seed = mnemonicToSeedSync(mnemonic.trim());
69
-
70
- // Use first 32 bytes of seed as deterministic salt for HKDF
71
- // (BIP-39 mnemonics are self-salting via PBKDF2, no random salt needed)
72
- const salt = Buffer.from(seed.slice(0, 32));
73
-
74
- // HKDF-SHA256 from the full 512-bit seed, using distinct info strings
75
- const enc = (s: string) => Buffer.from(s, 'utf8');
76
- const seedBuf = Buffer.from(seed);
40
+ // Re-export for backward compatibility
41
+ export const validateMnemonic = isBip39Mnemonic;
77
42
 
78
- const authKey = Buffer.from(
79
- hkdf(sha256, seedBuf, salt, enc(AUTH_KEY_INFO), 32),
80
- );
81
- const encryptionKey = Buffer.from(
82
- hkdf(sha256, seedBuf, salt, enc(ENCRYPTION_KEY_INFO), 32),
83
- );
84
- const dedupKey = Buffer.from(
85
- hkdf(sha256, seedBuf, salt, enc(DEDUP_KEY_INFO), 32),
86
- );
87
-
88
- return { authKey, encryptionKey, dedupKey, salt };
89
- }
43
+ // ---------------------------------------------------------------------------
44
+ // Key Derivation
45
+ // ---------------------------------------------------------------------------
90
46
 
91
47
  /**
92
48
  * Derive auth, encryption, and dedup keys from a recovery phrase.
93
49
  *
94
- * If the password is a valid BIP-39 mnemonic (12 or 24 words), keys are
95
- * derived from the 512-bit BIP-39 seed via HKDF. Otherwise, the legacy
96
- * Argon2id path is used.
50
+ * Delegates to the WASM module for BIP-39 seed derivation and HKDF
51
+ * key separation. Uses the lenient variant for phrases where all words
52
+ * are valid but the checksum fails.
97
53
  *
98
- * For the Argon2id path: if no salt is provided a fresh 32-byte random salt
99
- * is generated. Pass an existing salt when restoring a previously-registered
100
- * account so that the derived keys match the original registration.
101
- *
102
- * @returns Object containing authKey, encryptionKey, dedupKey, and salt (all Buffers).
54
+ * @param password - BIP-39 12/24-word mnemonic
55
+ * @param existingSalt - Ignored for BIP-39 path (salt is deterministic)
103
56
  */
104
57
  export function deriveKeys(
105
58
  password: string,
106
59
  existingSalt?: Buffer,
107
60
  ): { authKey: Buffer; encryptionKey: Buffer; dedupKey: Buffer; salt: Buffer } {
108
- // Auto-detect BIP-39 mnemonic vs arbitrary password
109
- if (isBip39Mnemonic(password)) {
110
- // BIP-39 path: mnemonic is self-salting, existingSalt is ignored for derivation
111
- // but we still return the deterministic salt for server registration
112
- return deriveKeysFromMnemonic(password);
61
+ const trimmed = password.trim();
62
+
63
+ // Try strict validation first, fall back to lenient
64
+ let result: { auth_key: string; encryption_key: string; dedup_key: string; salt: string };
65
+ try {
66
+ result = getWasm().deriveKeysFromMnemonic(trimmed);
67
+ } catch {
68
+ result = getWasm().deriveKeysFromMnemonicLenient(trimmed);
113
69
  }
114
70
 
115
- // Legacy path: arbitrary password via Argon2id
116
- const salt = existingSalt ?? crypto.randomBytes(32);
117
-
118
- // Step 1 -- Argon2id to derive a 32-byte master key.
119
- // @noble/hashes argon2id accepts Uint8Array for both password and salt.
120
- const masterKey = argon2id(
121
- Buffer.from(password, 'utf8'),
122
- salt,
123
- { t: ARGON2_TIME_COST, m: ARGON2_MEMORY_COST, p: ARGON2_PARALLELISM, dkLen: ARGON2_DK_LEN },
124
- );
125
-
126
- // Step 2 -- HKDF-SHA256 for each sub-key using distinct info strings.
127
- // @noble/hashes v2 requires Uint8Array for info param.
128
- const enc = (s: string) => Buffer.from(s, 'utf8');
129
- const authKey = Buffer.from(
130
- hkdf(sha256, masterKey, salt, enc(AUTH_KEY_INFO), 32),
131
- );
132
- const encryptionKey = Buffer.from(
133
- hkdf(sha256, masterKey, salt, enc(ENCRYPTION_KEY_INFO), 32),
134
- );
135
- const dedupKey = Buffer.from(
136
- hkdf(sha256, masterKey, salt, enc(DEDUP_KEY_INFO), 32),
137
- );
138
-
139
- return { authKey, encryptionKey, dedupKey, salt: Buffer.from(salt) };
71
+ return {
72
+ authKey: Buffer.from(result.auth_key, 'hex'),
73
+ encryptionKey: Buffer.from(result.encryption_key, 'hex'),
74
+ dedupKey: Buffer.from(result.dedup_key, 'hex'),
75
+ salt: Buffer.from(result.salt, 'hex'),
76
+ };
140
77
  }
141
78
 
142
79
  // ---------------------------------------------------------------------------
@@ -144,48 +81,16 @@ export function deriveKeys(
144
81
  // ---------------------------------------------------------------------------
145
82
 
146
83
  /**
147
- * HKDF context string for LSH seed derivation.
148
- *
149
- * The LSH hasher needs a deterministic seed so that the same master key
150
- * always generates the same random hyperplane matrices. We derive this seed
151
- * from the master key using HKDF with a unique info string.
152
- *
153
- * For the BIP-39 path the HKDF input is the 512-bit BIP-39 seed; for the
154
- * Argon2id path it is the 32-byte master key.
155
- */
156
- const LSH_SEED_INFO = 'openmemory-lsh-seed-v1';
157
-
158
- /**
159
- * Derive a 32-byte seed for the LSH hasher from the master key derivation
160
- * chain.
84
+ * Derive a 32-byte seed for the LSH hasher.
161
85
  *
162
- * Call this once during initialization and pass the result to `new LSHHasher(seed, dims)`.
163
- *
164
- * For the BIP-39 path we use the full 512-bit BIP-39 seed as IKM; for the
165
- * Argon2id path we use the 32-byte Argon2id-derived master key. In both
166
- * cases the salt from `deriveKeys()` is reused for domain separation.
86
+ * Delegates to the WASM module.
167
87
  */
168
88
  export function deriveLshSeed(
169
89
  password: string,
170
90
  salt: Buffer,
171
91
  ): Uint8Array {
172
- if (isBip39Mnemonic(password)) {
173
- const seed = mnemonicToSeedSync(password.trim());
174
- return new Uint8Array(
175
- hkdf(sha256, Buffer.from(seed), salt, Buffer.from(LSH_SEED_INFO, 'utf8'), 32),
176
- );
177
- }
178
-
179
- // Argon2id path: re-derive the master key, then HKDF with LSH info string.
180
- const masterKey = argon2id(
181
- Buffer.from(password, 'utf8'),
182
- salt,
183
- { t: ARGON2_TIME_COST, m: ARGON2_MEMORY_COST, p: ARGON2_PARALLELISM, dkLen: ARGON2_DK_LEN },
184
- );
185
-
186
- return new Uint8Array(
187
- hkdf(sha256, masterKey, salt, Buffer.from(LSH_SEED_INFO, 'utf8'), 32),
188
- );
92
+ const seedHex = getWasm().deriveLshSeed(password.trim(), salt.toString('hex'));
93
+ return new Uint8Array(Buffer.from(seedHex, 'hex'));
189
94
  }
190
95
 
191
96
  // ---------------------------------------------------------------------------
@@ -194,72 +99,30 @@ export function deriveLshSeed(
194
99
 
195
100
  /**
196
101
  * Compute the SHA-256 hash of the auth key.
197
- *
198
- * The server stores SHA256(authKey) during registration and uses it to look
199
- * up users on every request. The hex string returned here is what the plugin
200
- * sends to `/v1/register` as `auth_key_hash`.
201
102
  */
202
103
  export function computeAuthKeyHash(authKey: Buffer): string {
203
- return Buffer.from(sha256(authKey)).toString('hex');
104
+ return getWasm().computeAuthKeyHash(authKey.toString('hex'));
204
105
  }
205
106
 
206
107
  // ---------------------------------------------------------------------------
207
- // AES-256-GCM Encrypt / Decrypt
108
+ // XChaCha20-Poly1305 Encrypt / Decrypt
208
109
  // ---------------------------------------------------------------------------
209
110
 
210
111
  /**
211
- * Encrypt a UTF-8 plaintext string with AES-256-GCM.
112
+ * Encrypt a UTF-8 plaintext string with XChaCha20-Poly1305.
212
113
  *
213
114
  * Wire format (base64-encoded):
214
- * [iv: 12 bytes][tag: 16 bytes][ciphertext: variable]
215
- *
216
- * This matches `serializeEncryptedData` in `client/src/crypto/aes.ts`.
115
+ * [nonce: 24 bytes][tag: 16 bytes][ciphertext: variable]
217
116
  */
218
117
  export function encrypt(plaintext: string, encryptionKey: Buffer): string {
219
- if (encryptionKey.length !== KEY_LENGTH) {
220
- throw new Error(`Invalid key length: expected ${KEY_LENGTH}, got ${encryptionKey.length}`);
221
- }
222
-
223
- const iv = crypto.randomBytes(IV_LENGTH);
224
- const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv, {
225
- authTagLength: TAG_LENGTH,
226
- });
227
-
228
- const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
229
- const tag = cipher.getAuthTag();
230
-
231
- // Combine: iv || tag || ciphertext (same order as client library)
232
- const combined = Buffer.concat([iv, tag, ciphertext]);
233
- return combined.toString('base64');
118
+ return getWasm().encrypt(plaintext, encryptionKey.toString('hex'));
234
119
  }
235
120
 
236
121
  /**
237
- * Decrypt a base64-encoded AES-256-GCM blob back to a UTF-8 string.
238
- *
239
- * Expects the wire format produced by `encrypt()` above.
122
+ * Decrypt a base64-encoded XChaCha20-Poly1305 blob back to a UTF-8 string.
240
123
  */
241
124
  export function decrypt(encryptedBase64: string, encryptionKey: Buffer): string {
242
- if (encryptionKey.length !== KEY_LENGTH) {
243
- throw new Error(`Invalid key length: expected ${KEY_LENGTH}, got ${encryptionKey.length}`);
244
- }
245
-
246
- const combined = Buffer.from(encryptedBase64, 'base64');
247
-
248
- if (combined.length < IV_LENGTH + TAG_LENGTH) {
249
- throw new Error('Encrypted data too short');
250
- }
251
-
252
- const iv = combined.subarray(0, IV_LENGTH);
253
- const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
254
- const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
255
-
256
- const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, iv, {
257
- authTagLength: TAG_LENGTH,
258
- });
259
- decipher.setAuthTag(tag);
260
-
261
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
262
- return plaintext.toString('utf8');
125
+ return getWasm().decrypt(encryptedBase64, encryptionKey.toString('hex'));
263
126
  }
264
127
 
265
128
  // ---------------------------------------------------------------------------
@@ -269,83 +132,22 @@ export function decrypt(encryptedBase64: string, encryptionKey: Buffer): string
269
132
  /**
270
133
  * Generate blind indices (SHA-256 hashes of tokens) for a text string.
271
134
  *
272
- * Tokenization rules (must match `client/src/crypto/blind.ts#tokenize`):
273
- * 1. Lowercase
274
- * 2. Remove punctuation (keep Unicode letters, numbers, whitespace)
275
- * 3. Split on whitespace
276
- * 4. Filter tokens shorter than 2 characters
277
- *
278
- * Each surviving token is SHA-256 hashed and returned as a hex string.
279
- * The returned array is deduplicated.
135
+ * Delegates to the WASM module which performs tokenization, stemming,
136
+ * and SHA-256 hashing.
280
137
  */
281
138
  export function generateBlindIndices(text: string): string[] {
282
- const tokens = text
283
- .toLowerCase()
284
- .replace(/[^\p{L}\p{N}\s]/gu, ' ') // Remove punctuation, keep letters/numbers
285
- .split(/\s+/)
286
- .filter((t) => t.length >= 2);
287
-
288
- const seen = new Set<string>();
289
- const indices: string[] = [];
290
-
291
- for (const token of tokens) {
292
- // Exact word hash (unchanged behavior).
293
- const hash = Buffer.from(sha256(Buffer.from(token, 'utf8'))).toString('hex');
294
- if (!seen.has(hash)) {
295
- seen.add(hash);
296
- indices.push(hash);
297
- }
298
-
299
- // Stemmed word hash. The stem is prefixed with "stem:" before hashing
300
- // to avoid collisions between a word that happens to equal another
301
- // word's stem (e.g., the word "commun" vs the stem of "community").
302
- const stem = stemmer(token);
303
- if (stem.length >= 2 && stem !== token) {
304
- const stemHash = Buffer.from(
305
- sha256(Buffer.from(`stem:${stem}`, 'utf8'))
306
- ).toString('hex');
307
- if (!seen.has(stemHash)) {
308
- seen.add(stemHash);
309
- indices.push(stemHash);
310
- }
311
- }
312
- }
313
-
314
- return indices;
139
+ return getWasm().generateBlindIndices(text);
315
140
  }
316
141
 
317
142
  // ---------------------------------------------------------------------------
318
143
  // Content Fingerprint (Dedup)
319
144
  // ---------------------------------------------------------------------------
320
145
 
321
- /**
322
- * Normalize text for deterministic fingerprinting.
323
- *
324
- * Steps (matching `client/src/crypto/fingerprint.ts#normalizeText`):
325
- * 1. Unicode NFC normalization
326
- * 2. Lowercase
327
- * 3. Collapse whitespace (spaces/tabs/newlines to single space)
328
- * 4. Trim leading/trailing whitespace
329
- */
330
- function normalizeText(text: string): string {
331
- return text
332
- .normalize('NFC')
333
- .toLowerCase()
334
- .replace(/\s+/g, ' ')
335
- .trim();
336
- }
337
-
338
146
  /**
339
147
  * Compute an HMAC-SHA256 content fingerprint for exact-duplicate detection.
340
148
  *
341
- * The server stores this fingerprint and uses it to reject duplicate writes
342
- * without ever seeing the plaintext.
343
- *
344
149
  * @returns 64-character hex string.
345
150
  */
346
151
  export function generateContentFingerprint(plaintext: string, dedupKey: Buffer): string {
347
- const normalized = normalizeText(plaintext);
348
- return Buffer.from(
349
- hmac(sha256, dedupKey, Buffer.from(normalized, 'utf8')),
350
- ).toString('hex');
152
+ return getWasm().generateContentFingerprint(plaintext, dedupKey.toString('hex'));
351
153
  }