@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.
@@ -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
+ }