@totalreclaw/totalreclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/publish.yml +39 -0
- package/README.md +104 -0
- package/SKILL.md +687 -0
- package/api-client.ts +300 -0
- package/crypto.ts +351 -0
- package/embedding.ts +84 -0
- package/extractor.ts +210 -0
- package/generate-mnemonic.ts +14 -0
- package/hot-cache-wrapper.ts +126 -0
- package/index.ts +1885 -0
- package/llm-client.ts +418 -0
- package/lsh.test.ts +463 -0
- package/lsh.ts +257 -0
- package/package.json +40 -0
- package/porter-stemmer.d.ts +4 -0
- package/reranker.test.ts +594 -0
- package/reranker.ts +537 -0
- package/semantic-dedup.test.ts +392 -0
- package/semantic-dedup.ts +100 -0
- package/subgraph-search.ts +278 -0
- package/subgraph-store.ts +342 -0
package/api-client.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotalReclaw Plugin - HTTP API Client
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the TotalReclaw server over JSON/HTTP. Uses Node.js
|
|
5
|
+
* built-in `fetch` (available since Node 18).
|
|
6
|
+
*
|
|
7
|
+
* All authenticated endpoints expect:
|
|
8
|
+
* Authorization: Bearer <hex-encoded-auth-key>
|
|
9
|
+
*
|
|
10
|
+
* The server hashes the auth key with SHA-256 to look up the user.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Request / Response Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single fact payload for the `/v1/store` endpoint.
|
|
19
|
+
*
|
|
20
|
+
* Field naming matches the server's `FactJSON` Pydantic model in
|
|
21
|
+
* `server/src/handlers/store.py`.
|
|
22
|
+
*/
|
|
23
|
+
export interface StoreFactPayload {
|
|
24
|
+
/** UUIDv7 fact identifier */
|
|
25
|
+
id: string;
|
|
26
|
+
/** ISO 8601 timestamp */
|
|
27
|
+
timestamp: string;
|
|
28
|
+
/** Hex-encoded AES-256-GCM ciphertext (iv || tag || ciphertext) */
|
|
29
|
+
encrypted_blob: string;
|
|
30
|
+
/** SHA-256 hashes of tokens for blind search */
|
|
31
|
+
blind_indices: string[];
|
|
32
|
+
/** Importance / decay score (0-10) */
|
|
33
|
+
decay_score: number;
|
|
34
|
+
/** Origin label */
|
|
35
|
+
source: string;
|
|
36
|
+
/** HMAC-SHA256 content fingerprint for dedup (hex) */
|
|
37
|
+
content_fp?: string;
|
|
38
|
+
/** Identifier of the creating agent */
|
|
39
|
+
agent_id?: string;
|
|
40
|
+
/** Hex-encoded AES-256-GCM encrypted embedding vector (PoC v2) */
|
|
41
|
+
encrypted_embedding?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A search result candidate returned by `/v1/search`.
|
|
46
|
+
*
|
|
47
|
+
* Field naming matches the server's `SearchResultJSON` model.
|
|
48
|
+
*/
|
|
49
|
+
export interface SearchCandidate {
|
|
50
|
+
fact_id: string;
|
|
51
|
+
/** Hex-encoded AES-256-GCM ciphertext */
|
|
52
|
+
encrypted_blob: string;
|
|
53
|
+
decay_score: number;
|
|
54
|
+
/** Unix milliseconds */
|
|
55
|
+
timestamp: number;
|
|
56
|
+
version: number;
|
|
57
|
+
/** Hex-encoded AES-256-GCM encrypted embedding vector (PoC v2, optional) */
|
|
58
|
+
encrypted_embedding?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A fact object returned by `/v1/export`.
|
|
63
|
+
*/
|
|
64
|
+
export interface ExportedFact {
|
|
65
|
+
id: string;
|
|
66
|
+
encrypted_blob: string;
|
|
67
|
+
blind_indices: string[];
|
|
68
|
+
decay_score: number;
|
|
69
|
+
version: number;
|
|
70
|
+
source: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
updated_at: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// API Client Factory
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create an API client bound to a specific TotalReclaw server URL.
|
|
81
|
+
*
|
|
82
|
+
* All methods are async and throw descriptive errors on non-2xx responses.
|
|
83
|
+
*/
|
|
84
|
+
export function createApiClient(serverUrl: string) {
|
|
85
|
+
// Normalise URL -- strip trailing slash.
|
|
86
|
+
const baseUrl = serverUrl.replace(/\/+$/, '');
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------------
|
|
89
|
+
// Shared helpers
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Throw a descriptive error when the server returns a non-2xx status.
|
|
94
|
+
*/
|
|
95
|
+
async function assertOk(res: Response, context: string): Promise<void> {
|
|
96
|
+
if (res.ok) return;
|
|
97
|
+
let body: string;
|
|
98
|
+
try {
|
|
99
|
+
body = await res.text();
|
|
100
|
+
} catch {
|
|
101
|
+
body = '(could not read response body)';
|
|
102
|
+
}
|
|
103
|
+
const hint = res.status === 401
|
|
104
|
+
? ' Authentication failed. If using a recovery phrase, check that all 12 words are in the correct order and spelled correctly.'
|
|
105
|
+
: '';
|
|
106
|
+
throw new Error(`${context}: HTTP ${res.status} - ${body}${hint}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
// Public methods
|
|
111
|
+
// ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
// ---- Registration (unauthenticated) ----
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a new user.
|
|
118
|
+
*
|
|
119
|
+
* @param authKeyHash Hex-encoded SHA-256 of the auth key (64 chars).
|
|
120
|
+
* @param saltHex Hex-encoded 32-byte salt (64 chars).
|
|
121
|
+
* @returns `{ user_id }` on success.
|
|
122
|
+
*/
|
|
123
|
+
async register(
|
|
124
|
+
authKeyHash: string,
|
|
125
|
+
saltHex: string,
|
|
126
|
+
): Promise<{ user_id: string }> {
|
|
127
|
+
const res = await fetch(`${baseUrl}/v1/register`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
|
|
131
|
+
});
|
|
132
|
+
await assertOk(res, 'register');
|
|
133
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
134
|
+
if (!json.success && json.error_code !== 'USER_EXISTS') {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`register: server returned success=false - ${json.error_code}: ${json.error_message}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!json.user_id) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`register: server did not return user_id (error_code=${json.error_code})`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return { user_id: json.user_id as string };
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// ---- Store (authenticated) ----
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Store one or more encrypted facts.
|
|
151
|
+
*
|
|
152
|
+
* @param userId The authenticated user's ID.
|
|
153
|
+
* @param facts Array of `StoreFactPayload` objects.
|
|
154
|
+
* @param authKeyHex Hex-encoded raw auth key (64 chars) for Bearer header.
|
|
155
|
+
*/
|
|
156
|
+
async store(
|
|
157
|
+
userId: string,
|
|
158
|
+
facts: StoreFactPayload[],
|
|
159
|
+
authKeyHex: string,
|
|
160
|
+
): Promise<{ ids: string[]; duplicate_ids?: string[] }> {
|
|
161
|
+
const res = await fetch(`${baseUrl}/v1/store`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
Authorization: `Bearer ${authKeyHex}`,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({ user_id: userId, facts }),
|
|
168
|
+
});
|
|
169
|
+
await assertOk(res, 'store');
|
|
170
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
171
|
+
if (!json.success) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`store: server returned success=false - ${json.error_code}: ${json.error_message}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ids: (json.ids as string[]) ?? [],
|
|
178
|
+
duplicate_ids: json.duplicate_ids as string[] | undefined,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// ---- Search (authenticated) ----
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Search for facts using blind trapdoors.
|
|
186
|
+
*
|
|
187
|
+
* @param userId The authenticated user's ID.
|
|
188
|
+
* @param trapdoors SHA-256 hex hashes of query tokens.
|
|
189
|
+
* @param maxCandidates Maximum candidates to retrieve.
|
|
190
|
+
* @param authKeyHex Hex-encoded raw auth key for Bearer header.
|
|
191
|
+
* @returns Array of encrypted search candidates.
|
|
192
|
+
*/
|
|
193
|
+
async search(
|
|
194
|
+
userId: string,
|
|
195
|
+
trapdoors: string[],
|
|
196
|
+
maxCandidates: number,
|
|
197
|
+
authKeyHex: string,
|
|
198
|
+
): Promise<SearchCandidate[]> {
|
|
199
|
+
const res = await fetch(`${baseUrl}/v1/search`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
Authorization: `Bearer ${authKeyHex}`,
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
user_id: userId,
|
|
207
|
+
trapdoors,
|
|
208
|
+
max_candidates: maxCandidates,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
await assertOk(res, 'search');
|
|
212
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
213
|
+
if (!json.success) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`search: server returned success=false - ${json.error_code}: ${json.error_message}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return (json.results as SearchCandidate[]) ?? [];
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
// ---- Delete (authenticated) ----
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Soft-delete a fact by ID.
|
|
225
|
+
*
|
|
226
|
+
* @param factId The fact UUID to delete.
|
|
227
|
+
* @param authKeyHex Hex-encoded raw auth key for Bearer header.
|
|
228
|
+
*/
|
|
229
|
+
async deleteFact(factId: string, authKeyHex: string): Promise<void> {
|
|
230
|
+
const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
|
|
231
|
+
method: 'DELETE',
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${authKeyHex}`,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
await assertOk(res, 'deleteFact');
|
|
237
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
238
|
+
if (!json.success) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`deleteFact: server returned success=false - ${json.error_code}: ${json.error_message}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// ---- Export (authenticated) ----
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Export all active facts (paginated).
|
|
249
|
+
*
|
|
250
|
+
* @param authKeyHex Hex-encoded raw auth key for Bearer header.
|
|
251
|
+
* @param limit Page size (default 1000, max 5000).
|
|
252
|
+
* @param cursor Cursor from previous page (omit for first page).
|
|
253
|
+
* @returns Page of facts with pagination metadata.
|
|
254
|
+
*/
|
|
255
|
+
async exportFacts(
|
|
256
|
+
authKeyHex: string,
|
|
257
|
+
limit: number = 1000,
|
|
258
|
+
cursor?: string,
|
|
259
|
+
): Promise<{ facts: ExportedFact[]; cursor?: string; has_more: boolean; total_count?: number }> {
|
|
260
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
261
|
+
if (cursor) params.set('cursor', cursor);
|
|
262
|
+
|
|
263
|
+
const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
|
|
264
|
+
method: 'GET',
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${authKeyHex}`,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
await assertOk(res, 'exportFacts');
|
|
270
|
+
const json = (await res.json()) as Record<string, unknown>;
|
|
271
|
+
if (!json.success) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`exportFacts: server returned success=false - ${json.error_code}: ${json.error_message}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
facts: (json.facts as ExportedFact[]) ?? [],
|
|
278
|
+
cursor: json.cursor as string | undefined,
|
|
279
|
+
has_more: (json.has_more as boolean) ?? false,
|
|
280
|
+
total_count: json.total_count as number | undefined,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// ---- Health (unauthenticated) ----
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Check server health.
|
|
288
|
+
*
|
|
289
|
+
* @returns `true` if the server responds with HTTP 200.
|
|
290
|
+
*/
|
|
291
|
+
async health(): Promise<boolean> {
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetch(`${baseUrl}/health`, { method: 'GET' });
|
|
294
|
+
return res.status === 200;
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
package/crypto.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotalReclaw Plugin - Crypto Operations
|
|
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.
|
|
8
|
+
*
|
|
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
|
|
15
|
+
*
|
|
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))
|
|
19
|
+
*/
|
|
20
|
+
|
|
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';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Key Derivation
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
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
|
+
/**
|
|
51
|
+
* Check if the input looks like a BIP-39 mnemonic (12 or 24 words from the BIP-39 English wordlist).
|
|
52
|
+
*/
|
|
53
|
+
function isBip39Mnemonic(input: string): boolean {
|
|
54
|
+
const words = input.trim().split(/\s+/);
|
|
55
|
+
if (words.length !== 12 && words.length !== 24) return false;
|
|
56
|
+
return validateMnemonic(input.trim(), wordlist);
|
|
57
|
+
}
|
|
58
|
+
|
|
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);
|
|
77
|
+
|
|
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
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Derive auth, encryption, and dedup keys from a master password.
|
|
93
|
+
*
|
|
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.
|
|
97
|
+
*
|
|
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).
|
|
103
|
+
*/
|
|
104
|
+
export function deriveKeys(
|
|
105
|
+
password: string,
|
|
106
|
+
existingSalt?: Buffer,
|
|
107
|
+
): { 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);
|
|
113
|
+
}
|
|
114
|
+
|
|
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) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// LSH Seed Derivation
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
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.
|
|
161
|
+
*
|
|
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.
|
|
167
|
+
*/
|
|
168
|
+
export function deriveLshSeed(
|
|
169
|
+
password: string,
|
|
170
|
+
salt: Buffer,
|
|
171
|
+
): 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
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Auth Key Hash
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 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
|
+
*/
|
|
202
|
+
export function computeAuthKeyHash(authKey: Buffer): string {
|
|
203
|
+
return Buffer.from(sha256(authKey)).toString('hex');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// AES-256-GCM Encrypt / Decrypt
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Encrypt a UTF-8 plaintext string with AES-256-GCM.
|
|
212
|
+
*
|
|
213
|
+
* Wire format (base64-encoded):
|
|
214
|
+
* [iv: 12 bytes][tag: 16 bytes][ciphertext: variable]
|
|
215
|
+
*
|
|
216
|
+
* This matches `serializeEncryptedData` in `client/src/crypto/aes.ts`.
|
|
217
|
+
*/
|
|
218
|
+
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');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
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.
|
|
240
|
+
*/
|
|
241
|
+
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');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Blind Indices
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generate blind indices (SHA-256 hashes of tokens) for a text string.
|
|
271
|
+
*
|
|
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.
|
|
280
|
+
*/
|
|
281
|
+
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;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Content Fingerprint (Dedup)
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
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
|
+
/**
|
|
339
|
+
* Compute an HMAC-SHA256 content fingerprint for exact-duplicate detection.
|
|
340
|
+
*
|
|
341
|
+
* The server stores this fingerprint and uses it to reject duplicate writes
|
|
342
|
+
* without ever seeing the plaintext.
|
|
343
|
+
*
|
|
344
|
+
* @returns 64-character hex string.
|
|
345
|
+
*/
|
|
346
|
+
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');
|
|
351
|
+
}
|