fix(cli): report actual flag state when decrypting an encrypted store without a key (#4378)

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
This commit is contained in:
rUvandAniruddha Adak authored and GitHub committed 2026-10-22 01:49:21 -04:00
1 parent 0103362f08
commit c949f62703
3 files changed
+50 -2

No files matched your search

@@ -113,6 +113,34 @@ describe('vault (ADR-096 Phase 1)', () => {
process.env.CLAUDE_FLOW_ENCRYPTION_KEY = 'c'.repeat(64);
expect(getKey().length).toBe(32);
});
it('keeps the original message on the default (encrypt) path', () => {
process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1';
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
expect(() => getKey()).toThrow(/is set but/);
});
it('does not claim the flag is set on the decrypt path (#3212)', () => {
delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
expect(() => getKey('decrypt')).not.toThrow(/is set but/);
expect(() => getKey('decrypt')).toThrow(/is required but not set/);
});
it('echoes the flag actual value and on-disk state on the decrypt path', () => {
delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
expect(() => getKey('decrypt')).toThrow(
/CLAUDE_FLOW_ENCRYPT_AT_REST=unset.*RFE1-encrypted/,
);
process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '0';
expect(() => getKey('decrypt')).toThrow(/CLAUDE_FLOW_ENCRYPT_AT_REST=0/);
});
it('warns a new key will not decrypt the existing store', () => {
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
expect(() => getKey('decrypt')).toThrow(/will not decrypt it/);
});
});
describe('encrypt / decrypt round-trip', () => {
+19 -1
View File
@@ -69,6 +69,15 @@ export function isEncryptionEnabled(): boolean {
* encryption is enabled but no key resolves, this throws with a clear
* message rather than silently falling back to plaintext (fail-closed).
*
* The optional `context` selects which missing-key message to throw:
* - `'encrypt'` (default): the caller is about to encrypt because
* `CLAUDE_FLOW_ENCRYPT_AT_REST` is set, so the original message is
* accurate.
* - `'decrypt'`: the caller hit an RFE1-encrypted blob on read (e.g. a
* store restored from another machine) while the enable flag may be
* unset (issue #3212). The message echoes the flag's actual value and
* the on-disk state instead of asserting the flag is set.
*
* Accepted encodings (auto-detected by length):
* - 64-char hex (32 bytes)
* - 44-char base64 (32 bytes + padding)
@@ -77,9 +86,18 @@ export function isEncryptionEnabled(): boolean {
* Anything else is rejected — we'd rather fail loudly than encrypt with a
* truncated key.
*/
export function getKey(): Buffer {
export function getKey(context: 'encrypt' | 'decrypt' = 'encrypt'): Buffer {
const raw = process.env[ENV_KEY_VAR];
if (!raw) {
if (context === 'decrypt') {
const flag = process.env[ENV_ENABLE_FLAG] ?? 'unset';
throw new Error(
`${ENV_KEY_VAR} is required but not set ` +
`(${ENV_ENABLE_FLAG}=${flag}; the store on disk is RFE1-encrypted). ` +
`Supply the key this store was encrypted with — a newly generated key will not decrypt it. ` +
`Provide a 32-byte key as 64-char hex or 44-char base64.`,
);
}
throw new Error(
`${ENV_ENABLE_FLAG} is set but ${ENV_KEY_VAR} is not. ` +
`Provide a 32-byte key as 64-char hex or 44-char base64. ` +
+3 -1
View File
@@ -244,7 +244,9 @@ export function readFileMaybeEncrypted(
const raw = readFileSync(path);
let plain: Buffer;
if (isEncryptedBlob(raw)) {
plain = decryptBuffer(raw, getKey());
// 'decrypt': the blob on disk is RFE1-encrypted but the enable flag may
// be unset (e.g. store restored from another machine) — see #3212.
plain = decryptBuffer(raw, getKey('decrypt'));
} else {
plain = raw;
}