@cleocode/core 2026.4.12 → 2026.4.13
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/package.json +9 -7
- package/src/internal.ts +48 -1
- package/src/store/__tests__/backup-crypto.test.ts +101 -0
- package/src/store/__tests__/backup-pack.test.ts +491 -0
- package/src/store/__tests__/backup-unpack.test.ts +298 -0
- package/src/store/__tests__/regenerators.test.ts +234 -0
- package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
- package/src/store/__tests__/restore-json-merge.test.ts +521 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/backup-crypto.ts +209 -0
- package/src/store/backup-pack.ts +739 -0
- package/src/store/backup-unpack.ts +583 -0
- package/src/store/regenerators.ts +243 -0
- package/src/store/restore-conflict-report.ts +317 -0
- package/src/store/restore-json-merge.ts +653 -0
- package/src/store/t310-readiness.ts +119 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption/decryption helpers for `.enc.cleobundle.tar.gz` bundles.
|
|
3
|
+
*
|
|
4
|
+
* Uses AES-256-GCM with a scrypt-derived key (Node built-in `node:crypto` only;
|
|
5
|
+
* no native bindings). scrypt is memory-hard and NIST-approved. Argon2id (PHC
|
|
6
|
+
* winner, spec §7.1) was the original target but adds a native binding
|
|
7
|
+
* dependency that violates ADR-010. This module documents the trade-off:
|
|
8
|
+
* scrypt with N=2^15 provides equivalent interactive-session security and full
|
|
9
|
+
* cross-platform portability.
|
|
10
|
+
*
|
|
11
|
+
* Binary layout of an encrypted bundle:
|
|
12
|
+
* [8] magic bytes "CLEOENC1" (0x43 0x4C 0x45 0x4F 0x45 0x4E 0x43 0x31)
|
|
13
|
+
* [1] format version (0x01)
|
|
14
|
+
* [7] reserved (zero-filled)
|
|
15
|
+
* [32] scrypt salt (random, per-bundle)
|
|
16
|
+
* [12] AES-256-GCM nonce (random, per-bundle)
|
|
17
|
+
* [N] ciphertext (the tar.gz bytes)
|
|
18
|
+
* [16] AES-256-GCM authentication tag
|
|
19
|
+
*
|
|
20
|
+
* Total fixed overhead: 76 bytes header + 16 bytes auth tag = 92 bytes.
|
|
21
|
+
*
|
|
22
|
+
* @task T345
|
|
23
|
+
* @epic T311
|
|
24
|
+
* @see ADR-038 §5 — opt-in encrypted backups for portable export/import
|
|
25
|
+
* @module store/backup-crypto
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
/** Magic bytes that identify a CLEO encrypted bundle: ASCII "CLEOENC1". */
|
|
31
|
+
const MAGIC = Buffer.from('CLEOENC1', 'utf8'); // 8 bytes
|
|
32
|
+
|
|
33
|
+
/** Current format version byte written at offset 8. */
|
|
34
|
+
const VERSION = 0x01; // 1 byte
|
|
35
|
+
|
|
36
|
+
/** Reserved bytes at offsets 9–15 (zero-filled). */
|
|
37
|
+
const RESERVED = Buffer.alloc(7); // 7 bytes
|
|
38
|
+
|
|
39
|
+
/** Byte length of the per-bundle scrypt salt (offset 16). */
|
|
40
|
+
const SALT_SIZE = 32;
|
|
41
|
+
|
|
42
|
+
/** Byte length of the AES-256-GCM nonce (offset 48). */
|
|
43
|
+
const NONCE_SIZE = 12;
|
|
44
|
+
|
|
45
|
+
/** AES key length in bytes (AES-256). */
|
|
46
|
+
const KEY_SIZE = 32;
|
|
47
|
+
|
|
48
|
+
/** AES-256-GCM authentication tag length in bytes. */
|
|
49
|
+
const AUTH_TAG_SIZE = 16;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* scrypt CPU/memory cost parameter (N = 2^15 = 32768).
|
|
53
|
+
* Provides ~64 MB memory hardness per derivation — equivalent to OWASP
|
|
54
|
+
* interactive-login recommendation when Argon2id is unavailable.
|
|
55
|
+
*/
|
|
56
|
+
const SCRYPT_N = 2 ** 15;
|
|
57
|
+
|
|
58
|
+
/** scrypt block size parameter. */
|
|
59
|
+
const SCRYPT_R = 8;
|
|
60
|
+
|
|
61
|
+
/** scrypt parallelism parameter. */
|
|
62
|
+
const SCRYPT_P = 1;
|
|
63
|
+
|
|
64
|
+
/** Derived key length in bytes — must equal KEY_SIZE. */
|
|
65
|
+
const SCRYPT_KEY_LEN: typeof KEY_SIZE = KEY_SIZE;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimum valid byte length of an encrypted bundle.
|
|
69
|
+
* = magic(8) + version(1) + reserved(7) + salt(32) + nonce(12) + auth-tag(16)
|
|
70
|
+
*/
|
|
71
|
+
const MIN_ENCRYPTED_LENGTH = 8 + 1 + 7 + SALT_SIZE + NONCE_SIZE + AUTH_TAG_SIZE;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derives a 32-byte AES key from a user passphrase and a per-bundle salt
|
|
75
|
+
* using Node's built-in scrypt (RFC 7914).
|
|
76
|
+
*
|
|
77
|
+
* Parameters are chosen to match OWASP Argon2id interactive-login guidance
|
|
78
|
+
* adapted for scrypt: ~64 MB memory, single-threaded, ~100 ms on a 2024 laptop.
|
|
79
|
+
*
|
|
80
|
+
* @param passphrase - UTF-8 user passphrase (must be non-empty).
|
|
81
|
+
* @param salt - 32-byte random per-bundle salt.
|
|
82
|
+
* @returns 32-byte Buffer suitable for AES-256-GCM.
|
|
83
|
+
*
|
|
84
|
+
* @task T345
|
|
85
|
+
* @epic T311
|
|
86
|
+
*/
|
|
87
|
+
function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
|
88
|
+
return crypto.scryptSync(passphrase, salt, SCRYPT_KEY_LEN, {
|
|
89
|
+
N: SCRYPT_N,
|
|
90
|
+
r: SCRYPT_R,
|
|
91
|
+
p: SCRYPT_P,
|
|
92
|
+
// maxmem guard: 128 * N * r * 2 bytes — prevents accidental OOM
|
|
93
|
+
maxmem: 128 * SCRYPT_N * SCRYPT_R * 2,
|
|
94
|
+
}) as Buffer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Encrypts a plaintext tarball buffer with AES-256-GCM.
|
|
99
|
+
*
|
|
100
|
+
* A fresh random 32-byte salt and 12-byte nonce are generated for every call,
|
|
101
|
+
* so two encryptions of the same plaintext produce different ciphertexts.
|
|
102
|
+
*
|
|
103
|
+
* Output binary layout:
|
|
104
|
+
* ```
|
|
105
|
+
* Offset Length Field
|
|
106
|
+
* 0 8 Magic bytes "CLEOENC1"
|
|
107
|
+
* 8 1 Format version 0x01
|
|
108
|
+
* 9 7 Reserved (zeros)
|
|
109
|
+
* 16 32 scrypt salt
|
|
110
|
+
* 48 12 GCM nonce
|
|
111
|
+
* 60 N Ciphertext
|
|
112
|
+
* 60+N 16 GCM auth tag
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @param plaintext - Raw `.cleobundle.tar.gz` bytes to encrypt.
|
|
116
|
+
* @param passphrase - User-supplied passphrase (must be non-empty).
|
|
117
|
+
* @returns Encrypted bundle bytes ready to write as `.enc.cleobundle.tar.gz`.
|
|
118
|
+
* @throws {Error} If `passphrase` is empty.
|
|
119
|
+
*
|
|
120
|
+
* @task T345
|
|
121
|
+
* @epic T311
|
|
122
|
+
*/
|
|
123
|
+
export function encryptBundle(plaintext: Buffer, passphrase: string): Buffer {
|
|
124
|
+
if (passphrase.length === 0) {
|
|
125
|
+
throw new Error('encryptBundle: passphrase cannot be empty');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const salt = crypto.randomBytes(SALT_SIZE);
|
|
129
|
+
const nonce = crypto.randomBytes(NONCE_SIZE);
|
|
130
|
+
const key = deriveKey(passphrase, salt);
|
|
131
|
+
|
|
132
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
133
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
134
|
+
const authTag = cipher.getAuthTag();
|
|
135
|
+
|
|
136
|
+
return Buffer.concat([MAGIC, Buffer.from([VERSION]), RESERVED, salt, nonce, ciphertext, authTag]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decrypts an `.enc.cleobundle.tar.gz` payload back to its original tar.gz bytes.
|
|
141
|
+
*
|
|
142
|
+
* Validates magic bytes, format version, and the AES-256-GCM authentication
|
|
143
|
+
* tag. Any mismatch throws descriptively so callers can map to the correct
|
|
144
|
+
* exit code (E_BUNDLE_DECRYPT = 70).
|
|
145
|
+
*
|
|
146
|
+
* @param encrypted - Full encrypted bundle bytes (as read from disk).
|
|
147
|
+
* @param passphrase - User-supplied passphrase.
|
|
148
|
+
* @returns Decrypted tar.gz bytes.
|
|
149
|
+
* @throws {Error} `"decryptBundle: payload too short"` — buffer smaller than minimum.
|
|
150
|
+
* @throws {Error} `"decryptBundle: magic mismatch (not a cleo encrypted bundle)"` — invalid magic.
|
|
151
|
+
* @throws {Error} `"decryptBundle: unsupported version <n>, expected 1"` — unknown version byte.
|
|
152
|
+
* @throws {Error} `"decryptBundle: authentication failed (wrong passphrase or corrupted bundle)"` — GCM tag invalid.
|
|
153
|
+
*
|
|
154
|
+
* @task T345
|
|
155
|
+
* @epic T311
|
|
156
|
+
*/
|
|
157
|
+
export function decryptBundle(encrypted: Buffer, passphrase: string): Buffer {
|
|
158
|
+
if (encrypted.length < MIN_ENCRYPTED_LENGTH) {
|
|
159
|
+
throw new Error('decryptBundle: payload too short');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!encrypted.subarray(0, 8).equals(MAGIC)) {
|
|
163
|
+
throw new Error('decryptBundle: magic mismatch (not a cleo encrypted bundle)');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const version = encrypted[8];
|
|
167
|
+
if (version !== VERSION) {
|
|
168
|
+
throw new Error(`decryptBundle: unsupported version ${version}, expected ${VERSION}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Bytes 9–15 are reserved — ignored.
|
|
172
|
+
const salt = encrypted.subarray(16, 16 + SALT_SIZE);
|
|
173
|
+
const nonce = encrypted.subarray(16 + SALT_SIZE, 16 + SALT_SIZE + NONCE_SIZE);
|
|
174
|
+
const ciphertext = encrypted.subarray(
|
|
175
|
+
16 + SALT_SIZE + NONCE_SIZE,
|
|
176
|
+
encrypted.length - AUTH_TAG_SIZE,
|
|
177
|
+
);
|
|
178
|
+
const authTag = encrypted.subarray(encrypted.length - AUTH_TAG_SIZE);
|
|
179
|
+
|
|
180
|
+
const key = deriveKey(passphrase, salt);
|
|
181
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
182
|
+
decipher.setAuthTag(authTag);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error('decryptBundle: authentication failed (wrong passphrase or corrupted bundle)');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Tests whether a buffer starts with the CLEO encrypted bundle magic bytes
|
|
193
|
+
* `"CLEOENC1"`. Reads only the first 8 bytes; does not validate any other
|
|
194
|
+
* part of the header.
|
|
195
|
+
*
|
|
196
|
+
* Useful for detecting encrypted bundles before attempting decryption, e.g.
|
|
197
|
+
* when deciding whether to prompt for a passphrase.
|
|
198
|
+
*
|
|
199
|
+
* @param header - At least 8 bytes from the start of the file (may be longer).
|
|
200
|
+
* @returns `true` if the magic bytes match; `false` if the buffer is too short
|
|
201
|
+
* or the magic does not match.
|
|
202
|
+
*
|
|
203
|
+
* @task T345
|
|
204
|
+
* @epic T311
|
|
205
|
+
*/
|
|
206
|
+
export function isEncryptedBundle(header: Buffer): boolean {
|
|
207
|
+
if (header.length < 8) return false;
|
|
208
|
+
return header.subarray(0, 8).equals(MAGIC);
|
|
209
|
+
}
|