spd-lib 1.3.5 → 1.3.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/README.md CHANGED
@@ -6,16 +6,41 @@ Encrypt any JavaScript value (strings, numbers, objects, typed arrays, Maps, Set
6
6
 
7
7
  ---
8
8
 
9
- ## Security model
9
+ ## Security model (v29)
10
10
 
11
- | Layer | Algorithm | Post-quantum strength |
11
+ | Layer | Algorithm | Notes |
12
12
  |---|---|---|
13
13
  | Key derivation | Argon2id (128 MiB, 6 iterations) | Raises brute-force cost against quantum search |
14
- | Encryption | XChaCha20-Poly1305 (256-bit key) | 128-bit PQ security via Grover's algorithm |
15
- | Authentication | HMAC-SHA3-512 (256-bit key, domain-separated) | 128-bit PQ security |
16
- | Salt wrapping | Argon2id-derived key + XChaCha20-Poly1305 | Same as above |
14
+ | Key expansion | HKDF-SHA3-512 (domain-separated) | Separate AEAD and MAC keys from one master secret |
15
+ | Encryption | XChaCha20-Poly1305 (256-bit key, per-entry) | 128-bit PQ security via Grover's algorithm |
16
+ | Key commitment | CMT-4: SHA3-256(key nonce) prefix | Prevents partitioning oracle / multi-key attacks |
17
+ | Authentication | HMAC-SHA3-512 (256-bit key, full-file) | 128-bit PQ security, domain-separated |
18
+ | Name encryption | XChaCha20-Poly1305 (dedicated name key) | Entry names are never stored in plaintext |
19
+ | Compression privacy | Pad-to-256B blocks before deflate | Mitigates CRIME/BREACH length-based oracle |
20
+ | Salt wrapping | Argon2id + XChaCha20-Poly1305 | Encrypts the KDF salt under the passcode |
21
+
22
+ **Key derivation chain:**
23
+ 1. Argon2id(passcode, random 16-byte salt) → 96-byte master secret
24
+ 2. HKDF-SHA3-512(master, `'spd-aead-key-v1'`) → 32-byte AEAD key
25
+ 3. HKDF-SHA3-512(master, `'spd-mac-key-v1'`) → 64-byte MAC key
26
+ 4. Per-entry key: HKDF-SHA3-512(AEAD key, entry name) → 32-byte entry key
17
27
 
18
- Keys are domain-separated: the 512-bit Argon2id master secret is split — the first 32 bytes go to encryption, the last 32 bytes go to authentication. They are never used interchangeably.
28
+ ---
29
+
30
+ ## Performance (Node.js 22, Apple M3, standard profile)
31
+
32
+ | Operation | Throughput / Latency |
33
+ |---|---|
34
+ | Key derivation (Argon2id, 128 MiB) | ~380 ms (one-time per session) |
35
+ | addData ~36 B | ~10k ops/s |
36
+ | addData 10 KB | ~3k ops/s |
37
+ | addData 100 KB | ~750 ops/s |
38
+ | addData 1 MB | ~100 ops/s |
39
+ | saveToFileStreaming / loadFromFileStreaming 10×10 KB | ~400 ms (dominated by KDF) |
40
+ | getEntry (random-access lookup, binary file) | ~400 ms (dominated by KDF) |
41
+ | addMany 1000 items (parallel workers) | ~1–2 s |
42
+
43
+ The dominant cost in all file operations is the single Argon2id call (~380 ms). CMT-4 commitment, compression padding, HKDF, and name encryption add < 5 µs per entry.
19
44
 
20
45
  ---
21
46
 
@@ -41,13 +66,14 @@ await spd.addData('username', 'alice');
41
66
  await spd.addData('apiKey', 'sk-abc123');
42
67
  await spd.addData('config', { theme: 'dark', retries: 3 });
43
68
 
44
- // Save to file
45
- await spd.saveToFile('./vault.spd', 'MyStr0ng!Passphrase#2024');
69
+ // Save to file (binary streaming format — recommended)
70
+ await spd.saveToFileStreaming('./vault.spd', 'MyStr0ng!Passphrase#2024');
46
71
 
47
72
  // Load from file
48
- const loaded = await SPD.loadFromFile('./vault.spd', 'MyStr0ng!Passphrase#2024');
73
+ const loaded = await SPD.loadFromFileStreaming('./vault.spd', 'MyStr0ng!Passphrase#2024');
49
74
  const data = await loaded.extractData();
50
75
  console.log(data.username); // 'alice'
76
+ loaded.destroy();
51
77
  ```
52
78
 
53
79
  ---
@@ -62,9 +88,9 @@ Creates a new empty SPD instance. You must call `setPassKey()` before adding or
62
88
 
63
89
  ### `spd.setPassKey(passcode: string): Promise<void>`
64
90
 
65
- Derives encryption and authentication keys from the passcode using Argon2id. Must be called before any data operation.
91
+ Derives encryption and authentication keys from the passcode using Argon2id + HKDF. Must be called before any data operation.
66
92
 
67
- **Passcode requirements:** minimum 12 characters, must contain at least 3 of: lowercase, uppercase, digits, special characters.
93
+ **Passcode requirements:** minimum 12 characters, must contain at least 3 of: lowercase, uppercase, digits, special characters. Alternatively, a passphrase of 5+ space-separated lowercase words (e.g. from `generatePassphrase()`) is accepted.
68
94
 
69
95
  ```ts
70
96
  await spd.setPassKey('MyStr0ng!Passphrase#2024');
@@ -72,9 +98,36 @@ await spd.setPassKey('MyStr0ng!Passphrase#2024');
72
98
 
73
99
  ---
74
100
 
101
+ ### `spd.setKeyProfile(profile: 'standard' | 'paranoid'): void`
102
+
103
+ Sets the Argon2id memory and time parameters before calling `setPassKey`.
104
+
105
+ | Profile | Memory | Iterations | Use case |
106
+ |---|---|---|---|
107
+ | `'standard'` | 128 MiB | 6 | Default — interactive applications |
108
+ | `'paranoid'` | 2048 MiB | 16 | Long-term archive, server-side, offline |
109
+
110
+ ```ts
111
+ spd.setKeyProfile('standard'); // default
112
+ spd.setKeyProfile('paranoid'); // maximum resistance
113
+ ```
114
+
115
+ ---
116
+
117
+ ### `SPD.generatePassphrase(wordCount?: number): string`
118
+
119
+ Generates a cryptographically random space-separated word passphrase using the EFF large wordlist. Default is 7 words.
120
+
121
+ ```ts
122
+ const pass = SPD.generatePassphrase(); // 7 words, ~56 bits entropy
123
+ const pass = SPD.generatePassphrase(10); // 10 words, ~80 bits entropy
124
+ ```
125
+
126
+ ---
127
+
75
128
  ### `spd.addData(name: string, value: unknown): Promise<void>`
76
129
 
77
- Encrypts and stores a single value. The name is sanitized (lowercased, non-alphanumeric chars replaced with `_`).
130
+ Encrypts and stores a single value. The name is sanitized (lowercased, non-alphanumeric chars replaced with `_`). Entry names are encrypted and never stored in plaintext (v29+).
78
131
 
79
132
  **Supported types:** `string`, `number`, `boolean`, `null`, `object`, `Array`, `Uint8Array`, `Uint16Array`, `Uint32Array`, `BigInt64Array`, `BigUint64Array`, `Float32Array`, `Float64Array`, `Map`, `Set`, `Date`, `RegExp`, `Error`
80
133
 
@@ -90,7 +143,7 @@ await spd.addData('lookup', new Map([['key', 'val']]));
90
143
 
91
144
  ### `spd.addMany(items: { name: string; data: unknown }[]): Promise<void>`
92
145
 
93
- Batch version of `addData`. All items are encrypted in parallel.
146
+ Batch version of `addData`. Large batches (≥ 256 items) are encrypted in parallel using worker threads.
94
147
 
95
148
  ```ts
96
149
  await spd.addMany([
@@ -113,34 +166,38 @@ console.log(data.firstName); // 'Alice'
113
166
 
114
167
  ---
115
168
 
116
- ### `spd.saveToFile(path: string, passcode: string): Promise<void>`
169
+ ### `spd.saveToFileStreaming(path: string, passcode: string): Promise<void>`
170
+ ### `SPD.loadFromFileStreaming(path: string, passcode: string): Promise<SPD>`
117
171
 
118
- Saves the encrypted payload to a file (mode `0600`). Suitable for files up to ~1–2 GB.
172
+ **Recommended for all file I/O.** Writes/reads the binary SPD wire format. Heap usage stays bounded regardless of file size. Required format for `getEntry` random-access lookups.
173
+
174
+ The file embeds an offset index tail (`SPDx`) for O(1) entry lookups without a sidecar file.
119
175
 
120
176
  ```ts
121
- await spd.saveToFile('./secrets.spd', 'MyStr0ng!Passphrase#2024');
177
+ await spd.saveToFileStreaming('./large.spd', 'MyStr0ng!Passphrase#2024');
178
+ const spd = await SPD.loadFromFileStreaming('./large.spd', 'MyStr0ng!Passphrase#2024');
122
179
  ```
123
180
 
124
181
  ---
125
182
 
183
+ ### `spd.saveToFile(path: string, passcode: string): Promise<void>`
126
184
  ### `SPD.loadFromFile(path: string, passcode: string): Promise<SPD>`
127
185
 
128
- Loads and verifies an SPD file. The hash algorithm and Argon2 parameters are read from the file itself — no need to pass them.
186
+ JSON-envelope file format. Suitable for files up to ~1–2 GB. Not compatible with `getEntry`.
129
187
 
130
188
  ```ts
189
+ await spd.saveToFile('./secrets.spd', 'MyStr0ng!Passphrase#2024');
131
190
  const spd = await SPD.loadFromFile('./secrets.spd', 'MyStr0ng!Passphrase#2024');
132
191
  ```
133
192
 
134
193
  ---
135
194
 
136
- ### `spd.saveToFileStreaming(path: string, passcode: string): Promise<void>`
137
- ### `SPD.loadFromFileStreaming(path: string, passcode: string): Promise<SPD>`
195
+ ### `SPD.getEntry(filePath: string, passcode: string, entryName: string): Promise<unknown>`
138
196
 
139
- Streaming variants for files larger than 2 GB. Uses a binary wire format with 64 MB write chunks heap usage stays bounded regardless of file size.
197
+ Random-access lookup of a single entry from a binary SPD file (written by `saveToFileStreaming`). Uses the embedded `SPDx` index tail to jump directly to the entry no full scan. MAC-verified before decryption.
140
198
 
141
199
  ```ts
142
- await spd.saveToFileStreaming('./large.spd', 'MyStr0ng!Passphrase#2024');
143
- const spd = await SPD.loadFromFileStreaming('./large.spd', 'MyStr0ng!Passphrase#2024');
200
+ const value = await SPD.getEntry('./vault.spd', 'MyStr0ng!Passphrase#2024', 'username');
144
201
  ```
145
202
 
146
203
  ---
@@ -173,12 +230,6 @@ await spd.extractDataStreaming('/tmp/spd_scratch', async (name, value) => {
173
230
 
174
231
  True constant-memory extraction from a binary `.spd` file (written by `saveToFileStreaming`). Processes one entry at a time using `.ssf` skeleton files — the full plaintext never lives in RAM.
175
232
 
176
- Steps per entry:
177
- 1. Writes encrypted blob to `<name>_enc.ssf`
178
- 2. Decrypts into `<name>_dec.ssf`
179
- 3. Reads the `.ssf` file and calls your callback
180
- 4. Deletes both `.ssf` files before the next entry
181
-
182
233
  Peak RAM usage is proportional to the largest single entry, not the file size.
183
234
 
184
235
  ```ts
@@ -232,7 +283,7 @@ const spd = await SPD.loadFromChunks(chunks, 'MyStr0ng!Passphrase#2024');
232
283
 
233
284
  The last element of the array is always a JSON manifest (`{ totalChunks, chunkSize, totalBytes, version }`). The receiver validates chunk count and byte count before decrypting.
234
285
 
235
- **Auto chunk size** (when `chunkSize` is omitted): targets ~32 chunks, clamped between 64 KB and 8 MB, rounded to the nearest 64 KB boundary. Pass an explicit `chunkSize` to override (e.g. `256 * 1024` for strict API body limits).
286
+ **Auto chunk size** (when `chunkSize` is omitted): targets ~32 chunks, clamped between 64 KB and 8 MB, rounded to the nearest 64 KB boundary.
236
287
 
237
288
  ---
238
289
 
@@ -258,14 +309,41 @@ spd.setHash('sha3-512'); // default, recommended
258
309
 
259
310
  ### `spd.setCompressionLevel(level: number): void`
260
311
 
261
- Sets the zlib deflate compression level (1–9). Default is `9` (maximum compression).
312
+ Sets the zlib deflate compression level (1–9). Default is `6` (balanced speed/size).
262
313
 
263
314
  ```ts
264
- spd.setCompressionLevel(6); // faster, slightly larger
315
+ spd.setCompressionLevel(9); // maximum compression
316
+ spd.setCompressionLevel(1); // fastest, largest output
265
317
  ```
266
318
 
267
319
  ---
268
320
 
321
+ ### `spd.setSigningKey(privateKeyPem: string): void` / `spd.setVerifyKey(publicKeyPem: string): void`
322
+
323
+ Attach an Ed25519 signing key to the session. When set, `saveToFile` / `saveData` / `saveToFileStreaming` append an Ed25519 signature over the entire payload, and `loadFromFile` / `loadFromString` / `loadFromFileStreaming` verify it before decryption.
324
+
325
+ ```ts
326
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
327
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
328
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
329
+ });
330
+
331
+ spd.setSigningKey(privateKey);
332
+ await spd.saveToFileStreaming('./signed.spd', PASS);
333
+
334
+ const loaded = await SPD.loadFromFileStreaming('./signed.spd', PASS);
335
+ loaded.setVerifyKey(publicKey);
336
+ const data = await loaded.extractData(); // throws if signature invalid
337
+ ```
338
+
339
+ ---
340
+
341
+ ### `spd.setEpochSize(bytes: number): void`
342
+
343
+ Controls the epoch size (in bytes) for the key-ratcheting mechanism used in chunked exports. Defaults to 4 MB. Smaller values ratchet keys more frequently (higher security, lower throughput).
344
+
345
+ ---
346
+
269
347
  ### `spd.destroy()` / `spd.clearCache()`
270
348
 
271
349
  Securely zeros all key material in place using `sodium.memzero()`, then clears all stored data. Call this when you are done with a session.
@@ -276,9 +354,35 @@ spd.destroy();
276
354
 
277
355
  ---
278
356
 
279
- ### `SPD.deriveKeys(passcode, salt)` / `SPD.derivePBK(passcode, salt)`
357
+ ## SPDWriter streaming disk-backed writer
358
+
359
+ `SPDWriter` encrypts and writes entries one at a time to disk as they are produced — the full plaintext never exists in RAM simultaneously. Suitable for constructing large SPD files from a stream of records.
360
+
361
+ ```ts
362
+ import { SPDWriter } from 'spd-lib-ts';
363
+
364
+ const writer = new SPDWriter('./output.spd', 'MyStr0ng!Passphrase#2024');
365
+ await writer.init();
280
366
 
281
- Low-level static helpers exposed for advanced use cases (e.g. deriving keys outside of an SPD container).
367
+ await writer.addEntry('username', 'alice');
368
+ await writer.addEntry('config', { theme: 'dark' });
369
+ // ... add as many entries as needed
370
+
371
+ await writer.finalize();
372
+ ```
373
+
374
+ After `finalize()`, the file contains an embedded `SPDx` index tail compatible with `SPD.getEntry()` — no sidecar file needed.
375
+
376
+ **Options** (passed as third argument to constructor):
377
+
378
+ | Option | Default | Description |
379
+ |---|---|---|
380
+ | `compressionLevel` | `9` | zlib deflate level |
381
+ | `hashAlgorithm` | `'sha3-512'` | Per-entry hash |
382
+ | `argon2Memory` | `131072` (128 MiB) | Argon2id memory cost |
383
+ | `argon2Time` | `6` | Argon2id time cost |
384
+
385
+ Call `writer.destroy()` to zero keys and delete the partial file if `finalize()` was never called.
282
386
 
283
387
  ---
284
388
 
@@ -343,12 +447,12 @@ async function saveConfig(config: Record<string, unknown>) {
343
447
  const spd = new SPD();
344
448
  await spd.setPassKey(PASS);
345
449
  await spd.addMany(Object.entries(config).map(([name, data]) => ({ name, data })));
346
- await spd.saveToFile(FILE, PASS);
450
+ await spd.saveToFileStreaming(FILE, PASS);
347
451
  spd.destroy();
348
452
  }
349
453
 
350
454
  async function loadConfig(): Promise<Record<string, unknown>> {
351
- const spd = await SPD.loadFromFile(FILE, PASS);
455
+ const spd = await SPD.loadFromFileStreaming(FILE, PASS);
352
456
  const data = await spd.extractData();
353
457
  spd.destroy();
354
458
  return data;
@@ -360,7 +464,7 @@ async function loadConfig(): Promise<Record<string, unknown>> {
360
464
  ## Full example — chunked HTTP upload
361
465
 
362
466
  ```ts
363
- // server sends:
467
+ // sender:
364
468
  const spd = new SPD();
365
469
  await spd.setPassKey(PASS);
366
470
  await spd.addData('payload', largeObject);
@@ -374,7 +478,7 @@ for (let i = 0; i < chunks.length; i++) {
374
478
  });
375
479
  }
376
480
 
377
- // receiver collects all chunks in order and loads:
481
+ // receiver:
378
482
  const spd = await SPD.loadFromChunks(receivedChunks, PASS);
379
483
  const data = await spd.extractData();
380
484
  spd.destroy();
@@ -386,11 +490,27 @@ spd.destroy();
386
490
 
387
491
  | npm version | SPD format version | Notes |
388
492
  |---|---|---|
493
+ | 1.4.0 | v29 | CMT-4 key commitment, HKDF key expansion, encrypted entry names, 256-byte compression padding |
389
494
  | 1.3.1 | v26 | Hash algo + Argon2 params embedded in payload, secure key zeroing, `null` type support |
390
495
  | 1.3.0 | v25 | 512-bit Argon2id master secret, domain-separated AEAD + HMAC keys, HMAC-SHA3-512 |
391
496
  | < 1.3.0 | v24 | 256-bit Argon2id, single key for AEAD + MAC |
392
497
 
393
- SPD format versions are not cross-compatible. Use `SPDLegacy` to read v24 and earlier files.
498
+ SPD format versions are not cross-compatible. v29 files cannot be read by older library versions. Use `SPDLegacy` to read v24 and earlier files.
499
+
500
+ ---
501
+
502
+ ## Wire format (v29 binary)
503
+
504
+ Each entry in the binary file uses the following layout (all integers little-endian):
505
+
506
+ ```
507
+ [2B encNameLen][encName][1B typeLen][type][2B hashLen][hash][3B nonceLen][nonce][8B dataLen][data]
508
+ ```
509
+
510
+ - `encName` = `[24B XChaCha20 nonce || CMT-4 ciphertext of entry name]`
511
+ - `data` = `[32B CMT block || XChaCha20-Poly1305 ciphertext of padded+compressed plaintext]`
512
+
513
+ The full file is zlib-deflated and HMAC-SHA3-512 authenticated. A 72-byte header stores the plaintext length and MAC. An `SPDx` index tail is appended after the compressed stream for O(1) lookups.
394
514
 
395
515
  ---
396
516