Commit Graph

1 Commits

Author SHA1 Message Date
Reuven cb9a9f346c feat(encryption): ADR-096 Phase 1 — vault primitives + tests
Implements the cipher primitives portion of the encryption-at-rest
design (ADR-096). No call sites wired yet — that's a separate iteration
to keep blast radius small. With this commit alone, the env vars are
inert and the 1865-test baseline stays green.

src/encryption/vault.ts (new):

  isEncryptionEnabled() — env-var gate (CLAUDE_FLOW_ENCRYPT_AT_REST).
    Truthy: "1" / "true" / "yes" / "on" (case+whitespace tolerant).
    Anything else, including unset, leaves the legacy plaintext path.

  getKey() — resolves a 32-byte key from CLAUDE_FLOW_ENCRYPTION_KEY,
    fail-closed when enabled-but-missing. Phase 1 supports env var only;
    keychain (keytar) and passphrase-with-scrypt land in a follow-up.

  decodeKey(raw) — accepts 64-char hex, 44-char padded base64, or
    43-char unpadded base64. Anything else throws. Trims whitespace.

  encryptBuffer(plain, key) — AES-256-GCM with a fresh random 12-byte
    IV per call. Returns wire-format blob:

      magic(4) || iv(12) || ciphertext(N) || tag(16)

    where magic = "RFE1" (Ruflo File Encrypted v1). Validates inputs
    (Buffer + 32-byte key) so a misuse fails loudly.

  decryptBuffer(blob, key) — verifies magic with timingSafeEqual,
    parses iv/ct/tag, runs GCM decrypt. Lets GCM auth failure
    propagate so tampered ciphertext / wrong key / corrupted IV all
    surface as a thrown error rather than silent garbage.

  isEncryptedBlob(blob) — magic-byte sniff. True iff blob ≥ 32 bytes
    AND starts with "RFE1". Used by readers during the incremental
    migration so legacy plaintext .claude-flow/sessions/*.json files
    pass through unchanged.

__tests__/encryption-vault.test.ts (new) — 45 cases pinning the
contract:
  isEncryptionEnabled: env-var gate w/ truthy/falsy table
  decodeKey: hex / padded b64 / unpadded b64 / wrong-length / garbage /
             whitespace
  getKey: missing-env error message; valid hex returns 32 bytes
  round-trip: empty / small text / JSON / 1MB random / fresh-IV
  wire format: starts with RFE1 / >= 32 bytes
  isEncryptedBlob: detects fresh / rejects JSON / rejects SQLite header
                   / rejects too-short / rejects non-Buffer
  tamper detection: flipped ct byte / flipped tag / flipped IV /
                    flipped magic / too-short / wrong key
  input validation: non-Buffer plaintext / wrong-size key (encrypt &
                    decrypt)

Verified:
  tsc --noEmit clean
  vault tests: 45/45 passing
  full vitest suite: 1910/1910 passing, 46 skipped, 0 failures
  (was 1865; +45 from this commit)

Phase 2 wires this into session/terminal/memory writers behind the env
gate. Phase 3 adds keychain + passphrase resolution.
2026-05-03 21:00:43 -04:00