Section 01 · Encryption at rest
Encryption paths are documented with their boundaries
The repository documents encryption-related fields and admin-only key rotation; this is not a certification or a promise that every payload is covered.
The current implementation has an app-layer AES-256-GCM envelope for selected fields before database persistence. The reviewed paths include Client.taxId, Communication.body, and DeductionSuggestion.reviewNote; this is not a claim that every sensitive or uploaded payload is covered.
The active 32-byte key lives in env.SECURITY_ENCRYPTION_KEY (base64); the active key version is tracked in an EncryptionKey manifest row in the app schema, not pinned only by env var. When ops rotates, a new EncryptionKey row with version=N+1 is written and the prior row is stamped supersededAt=now(); old ciphertexts stamped v=N continue to decrypt under their own row manifest. If a ciphertext stamped with a superseded version outlives SECURITY_ENCRYPTION_ROTATION_GRACE_DAYS (90 days), decryptField raises a typed EncryptionUndecryptableError(grace-expired) — no silent fall-through to plaintext and no fall-through to the active key. A version the system has never seen throws unknown-key.
The app does not claim that every stored or uploaded payload is covered by the same field-level path. Review storage, provider, and deployment controls before using taxpayer data.
- Field-level, not table-level. AES-256-GCM envelope on Client.taxId, Communication.body, DeductionSuggestion.reviewNote — three columns today, each with its own IV + auth tag.
- Version-tracked key manifest. Active key version pinned in an EncryptionKey row; rotations add a successor and stamp the predecessor supersededAt=now().
- 90-day rotation grace window. Old ciphertexts stay decryptable across SECURITY_ENCRYPTION_ROTATION_GRACE_DAYS; beyond that, a typed grace-expired error — never a silent fallback.