@cleocode/core 2026.4.11 → 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/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
- package/dist/codebase-map/analyzers/architecture.js +0 -1
- package/dist/codebase-map/analyzers/architecture.js.map +1 -1
- package/dist/conduit/local-transport.d.ts +18 -8
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +23 -13
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.js +175 -68950
- package/dist/index.js.map +1 -7
- package/dist/init.d.ts +1 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +8 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -6
- package/dist/internal.js.map +1 -1
- package/dist/memory/learnings.d.ts +2 -2
- package/dist/memory/patterns.d.ts +6 -6
- package/dist/output.d.ts +32 -11
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +67 -67
- package/dist/output.js.map +1 -1
- package/dist/paths.js +80 -14
- package/dist/paths.js.map +1 -1
- package/dist/skills/dynamic-skill-generator.d.ts +0 -2
- package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
- package/dist/skills/dynamic-skill-generator.js.map +1 -1
- package/dist/store/agent-registry-accessor.d.ts +203 -12
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +618 -100
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +73 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -0
- package/dist/store/api-key-kdf.js +84 -0
- package/dist/store/api-key-kdf.js.map +1 -0
- package/dist/store/cleanup-legacy.js +171 -0
- package/dist/store/cleanup-legacy.js.map +1 -0
- package/dist/store/conduit-sqlite.d.ts +184 -0
- package/dist/store/conduit-sqlite.d.ts.map +1 -0
- package/dist/store/conduit-sqlite.js +570 -0
- package/dist/store/conduit-sqlite.js.map +1 -0
- package/dist/store/global-salt.d.ts +78 -0
- package/dist/store/global-salt.d.ts.map +1 -0
- package/dist/store/global-salt.js +147 -0
- package/dist/store/global-salt.js.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
- package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
- package/dist/store/migrate-signaldock-to-conduit.js +555 -0
- package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
- package/dist/store/nexus-sqlite.js +28 -3
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/signaldock-sqlite.d.ts +122 -19
- package/dist/store/signaldock-sqlite.d.ts.map +1 -1
- package/dist/store/signaldock-sqlite.js +401 -251
- package/dist/store/signaldock-sqlite.js.map +1 -1
- package/dist/store/sqlite-backup.js +122 -4
- package/dist/store/sqlite-backup.js.map +1 -1
- package/dist/system/backup.d.ts +0 -26
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/runtime.d.ts +0 -2
- package/dist/system/runtime.d.ts.map +1 -1
- package/dist/system/runtime.js +3 -3
- package/dist/system/runtime.js.map +1 -1
- package/dist/tasks/add.d.ts +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +98 -23
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +4 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +4 -1
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/labels.d.ts.map +1 -1
- package/dist/tasks/labels.js +4 -1
- package/dist/tasks/labels.js.map +1 -1
- package/dist/tasks/relates.d.ts.map +1 -1
- package/dist/tasks/relates.js +16 -4
- package/dist/tasks/relates.js.map +1 -1
- package/dist/tasks/show.d.ts.map +1 -1
- package/dist/tasks/show.js +4 -1
- package/dist/tasks/show.js.map +1 -1
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +32 -6
- package/dist/tasks/update.js.map +1 -1
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/engine.js +16 -4
- package/dist/validation/engine.js.map +1 -1
- package/dist/validation/param-utils.d.ts +5 -3
- package/dist/validation/param-utils.d.ts.map +1 -1
- package/dist/validation/param-utils.js +8 -6
- package/dist/validation/param-utils.js.map +1 -1
- package/dist/validation/protocols/_shared.d.ts.map +1 -1
- package/dist/validation/protocols/_shared.js +13 -6
- package/dist/validation/protocols/_shared.js.map +1 -1
- package/package.json +9 -7
- package/src/adapters/__tests__/manager.test.ts +0 -1
- package/src/codebase-map/analyzers/architecture.ts +0 -1
- package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
- package/src/conduit/__tests__/local-transport.test.ts +14 -12
- package/src/conduit/local-transport.ts +23 -13
- package/src/config.ts +0 -1
- package/src/errors.ts +24 -0
- package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
- package/src/init.ts +1 -2
- package/src/internal.ts +96 -2
- package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
- package/src/memory/__tests__/engine-compat.test.ts +2 -2
- package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
- package/src/observability/__tests__/index.test.ts +4 -4
- package/src/observability/__tests__/log-filter.test.ts +4 -4
- package/src/output.ts +73 -75
- package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
- package/src/sessions/__tests__/session-grade.test.ts +2 -2
- package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
- package/src/skills/dynamic-skill-generator.ts +0 -2
- package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
- package/src/store/__tests__/api-key-kdf.test.ts +113 -0
- 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__/conduit-sqlite.test.ts +413 -0
- package/src/store/__tests__/global-salt.test.ts +195 -0
- package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -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__/signaldock-sqlite.test.ts +652 -0
- package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
- package/src/store/__tests__/sqlite-backup.test.ts +5 -1
- package/src/store/__tests__/t310-integration.test.ts +1150 -0
- package/src/store/__tests__/t310-readiness.test.ts +111 -0
- package/src/store/__tests__/t311-integration.test.ts +661 -0
- package/src/store/agent-registry-accessor.ts +847 -140
- package/src/store/api-key-kdf.ts +104 -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/conduit-sqlite.ts +655 -0
- package/src/store/global-salt.ts +175 -0
- package/src/store/migrate-signaldock-to-conduit.ts +669 -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/signaldock-sqlite.ts +431 -254
- package/src/store/sqlite-backup.ts +185 -10
- package/src/store/t310-readiness.ts +119 -0
- package/src/system/backup.ts +2 -62
- package/src/system/runtime.ts +4 -6
- package/src/tasks/__tests__/error-hints.test.ts +256 -0
- package/src/tasks/add.ts +99 -9
- package/src/tasks/complete.ts +4 -1
- package/src/tasks/find.ts +4 -1
- package/src/tasks/labels.ts +4 -1
- package/src/tasks/relates.ts +16 -4
- package/src/tasks/show.ts +4 -1
- package/src/tasks/update.ts +32 -3
- package/src/validation/__tests__/error-hints.test.ts +97 -0
- package/src/validation/engine.ts +16 -1
- package/src/validation/param-utils.ts +10 -7
- package/src/validation/protocols/_shared.ts +14 -6
- package/src/validation/protocols/cant/architecture-decision.cant +80 -0
- package/src/validation/protocols/cant/artifact-publish.cant +95 -0
- package/src/validation/protocols/cant/consensus.cant +74 -0
- package/src/validation/protocols/cant/contribution.cant +82 -0
- package/src/validation/protocols/cant/decomposition.cant +92 -0
- package/src/validation/protocols/cant/implementation.cant +67 -0
- package/src/validation/protocols/cant/provenance.cant +88 -0
- package/src/validation/protocols/cant/release.cant +96 -0
- package/src/validation/protocols/cant/research.cant +66 -0
- package/src/validation/protocols/cant/specification.cant +67 -0
- package/src/validation/protocols/cant/testing.cant +88 -0
- package/src/validation/protocols/cant/validation.cant +65 -0
- package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
- package/templates/config.template.json +0 -1
- package/templates/global-config.template.json +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key derivation functions for the T310 conduit/signaldock separation.
|
|
3
|
+
*
|
|
4
|
+
* New KDF (T310):
|
|
5
|
+
* HMAC-SHA256(machineKey || globalSalt, utf8(agentId)) → 32 bytes
|
|
6
|
+
*
|
|
7
|
+
* Legacy KDF (pre-T310, migration use only):
|
|
8
|
+
* HMAC-SHA256(machineKey, utf8(projectPath)) → 32 bytes
|
|
9
|
+
*
|
|
10
|
+
* The new scheme binds each key to machine + per-machine salt + agent identity,
|
|
11
|
+
* replacing the project-path scheme that does not generalise to global-tier identity.
|
|
12
|
+
*
|
|
13
|
+
* T355 will compose these primitives with `getGlobalSalt()` (T348).
|
|
14
|
+
* This module has NO runtime dependency on global-salt.ts.
|
|
15
|
+
*
|
|
16
|
+
* @task T349
|
|
17
|
+
* @epic T310
|
|
18
|
+
* @see ADR-037 §5 — KDF contract
|
|
19
|
+
* @module store/api-key-kdf
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHmac } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Input parameters for the new T310 KDF.
|
|
26
|
+
*
|
|
27
|
+
* @task T349
|
|
28
|
+
* @epic T310
|
|
29
|
+
*/
|
|
30
|
+
export interface DeriveApiKeyInput {
|
|
31
|
+
/** Machine-local key from the existing machine-key mechanism (v2026.4.11). */
|
|
32
|
+
machineKey: Buffer;
|
|
33
|
+
/** 32-byte global salt from global-salt.ts (T348). */
|
|
34
|
+
globalSalt: Buffer;
|
|
35
|
+
/** Agent ID that the API key belongs to. */
|
|
36
|
+
agentId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derives a 32-byte API key using the new T310 KDF:
|
|
41
|
+
*
|
|
42
|
+
* HMAC-SHA256(machineKey || globalSalt, utf8(agentId)) → 32 bytes
|
|
43
|
+
*
|
|
44
|
+
* Binds the key to machine + per-machine salt + agent identity. Replaces
|
|
45
|
+
* the pre-T310 `HMAC-SHA256(machineKey, projectPath)` scheme, which does
|
|
46
|
+
* not generalise to global-tier identity.
|
|
47
|
+
*
|
|
48
|
+
* @param input - The derivation input parameters.
|
|
49
|
+
* @returns A 32-byte Buffer containing the derived API key.
|
|
50
|
+
* @throws {Error} If machineKey is empty, globalSalt is not exactly 32 bytes,
|
|
51
|
+
* or agentId is empty.
|
|
52
|
+
*
|
|
53
|
+
* @task T349
|
|
54
|
+
* @epic T310
|
|
55
|
+
*/
|
|
56
|
+
export function deriveApiKey(input: DeriveApiKeyInput): Buffer {
|
|
57
|
+
if (input.machineKey.length === 0) {
|
|
58
|
+
throw new Error('deriveApiKey: machineKey cannot be empty');
|
|
59
|
+
}
|
|
60
|
+
if (input.globalSalt.length !== 32) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`deriveApiKey: globalSalt must be exactly 32 bytes, got ${input.globalSalt.length}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (input.agentId.length === 0) {
|
|
66
|
+
throw new Error('deriveApiKey: agentId cannot be empty');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const key = Buffer.concat([input.machineKey, input.globalSalt]);
|
|
70
|
+
const hmac = createHmac('sha256', key);
|
|
71
|
+
hmac.update(input.agentId, 'utf8');
|
|
72
|
+
return hmac.digest();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derives the LEGACY pre-T310 API key using the old project-path scheme:
|
|
77
|
+
*
|
|
78
|
+
* HMAC-SHA256(machineKey, utf8(projectPath)) → 32 bytes
|
|
79
|
+
*
|
|
80
|
+
* Used ONLY by the migration executor (T358) to identify old keys that need
|
|
81
|
+
* invalidation. Not for ongoing use after T310.
|
|
82
|
+
*
|
|
83
|
+
* @param machineKey - Machine-local key from the existing machine-key mechanism.
|
|
84
|
+
* @param projectPath - Project path used as the HMAC message in the old scheme.
|
|
85
|
+
* @returns A 32-byte Buffer containing the derived legacy key.
|
|
86
|
+
* @throws {Error} If machineKey is empty or projectPath is empty.
|
|
87
|
+
*
|
|
88
|
+
* @deprecated Use deriveApiKey() for all new code. This exists for migration
|
|
89
|
+
* identification only (T358). Any new caller is a bug.
|
|
90
|
+
*
|
|
91
|
+
* @task T349
|
|
92
|
+
* @epic T310
|
|
93
|
+
*/
|
|
94
|
+
export function deriveLegacyProjectKey(machineKey: Buffer, projectPath: string): Buffer {
|
|
95
|
+
if (machineKey.length === 0) {
|
|
96
|
+
throw new Error('deriveLegacyProjectKey: machineKey cannot be empty');
|
|
97
|
+
}
|
|
98
|
+
if (projectPath.length === 0) {
|
|
99
|
+
throw new Error('deriveLegacyProjectKey: projectPath cannot be empty');
|
|
100
|
+
}
|
|
101
|
+
const hmac = createHmac('sha256', machineKey);
|
|
102
|
+
hmac.update(projectPath, 'utf8');
|
|
103
|
+
return hmac.digest();
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|