# At-Rest Storage Encryption (V3.2) **Status:** Implemented in `@shade/storage-encrypted` 0.4.0 **Adresses:** THREAT-MODEL §4 — Compromised device storage Shade's default `SQLiteStorage` and `PostgresStorage` write private keys and session state to disk *unencrypted* — the threat model assumes the DB lives inside a trusted environment. For deployments that need defence in depth, `@shade/storage-encrypted` adds opt-in at-rest encryption: a stolen DB file alone yields no usable private key material. ## At a glance ```ts import { KeyManager, EncryptedSQLiteStorage } from '@shade/storage-encrypted'; const km = await KeyManager.open({ kind: 'passphrase', passphrase: process.env.SHADE_STORAGE_PASSPHRASE!, salt: loadSaltFromDisk(), // 16+ bytes, persisted alongside the DB }); const storage = await EncryptedSQLiteStorage.open({ dbPath: '/data/shade-client.db', keyManager: km, }); // Use it exactly like SQLiteStorage — implements the same StorageProvider. const manager = new ShadeSessionManager(crypto, storage); ``` ## What is encrypted Per-row AEAD over the sensitive payload of every row: | Table | Encrypted | |--------------------------------|-----------| | `identity_enc` | the entire keypair (4× 32-byte keys) | | `config_enc` | `registrationId` | | `signed_prekeys_enc` | full `SignedPreKey` (incl. private half) | | `one_time_prekeys_enc` | full `OneTimePreKey` | | `sessions_enc` | the Double-Ratchet `SessionState` JSON | | `trusted_identities_enc` | the trusted peer identity key | | `retired_identities_enc` | full retired keypair | | `stream_state_enc.ciphertext` | partition / lane / IO descriptor / streamSecret | Routing fields on `stream_state_enc` (`stream_id`, `direction`, `peer_address`, `status`, timestamps) stay plaintext so `listActiveStreamStates()` remains an indexed query. ## Cryptographic design ``` masterKey (passphrase / keychain / app-injected) │ ├─ HKDF-SHA-256("shade-storage-v1") → storageKey (32 bytes) │ └─ HKDF-SHA-256(storageKey, "shade-field-v1:{table}:{column}") → fieldKey (32 bytes) │ └─ Used (transitively) for fingerprint checks ``` For each encrypted blob: - `nonce = HKDF(fieldKey, "shade-row-nonce-v1:{table}:{pk}")[..12]` — deterministic per (key, row), safe because the per-(table, column) fieldKey is unique. AES-GCM nonce reuse is catastrophic only if the *same* key is reused with the *same* nonce on different plaintexts; here every (key, row) pair has a unique nonce. - `aad = "shade-aad-v1|{table}|{column}|{pk}"` — binds the ciphertext to its row identity so a row swap or column move triggers decrypt failure. - `wire = nonce(12) || ciphertext || tag(16)` — stored as a single `BLOB`/`BYTEA` column. ## Key sources `KeyManager.open(...)` accepts three sources: 1. **Passphrase + KDF** — scrypt over `(passphrase, salt)`. Default parameters: `N=2^17, r=8, p=1, dkLen=32` (~250 ms on a modern laptop). The salt MUST be persisted alongside the DB (e.g. `.salt`). 2. **OS keychain** — via `@shade/keychain`. Backends: - macOS: `security` CLI (Keychain). - Linux: `secret-tool` (libsecret). - Windows: PowerShell + `CredentialManager` module. No native deps; `createIfMissing: true` generates and stores a fresh 32-byte key. 3. **App-injected** — caller supplies a 32-byte raw key. Most flexible; plug your own KMS / HSM / Vault path here. Wrong-passphrase detection is built in: a fingerprint of the storageKey is persisted in `shade_meta_enc` on first open and compared on every subsequent open. A mismatch raises with a clear error — never silently writing under the wrong key. ## Migration CLI: ```bash # Encrypt an existing unencrypted DB (atomic per row, .bak written first). shade migrate-storage \ --key-source passphrase \ --passphrase "$SHADE_STORAGE_PASSPHRASE" \ --salt-file /data/shade-client.db.salt # Validate without writing. shade migrate-storage ... --dry-run # Keychain mode. shade migrate-storage --key-source keychain \ --keychain-service shade.storage --keychain-account default # Inject a raw key (e.g. from your KMS). shade migrate-storage --key-source injected \ --key-hex "$(cat ~/.shade/storage.key.hex)" ``` The migration is *resumable*: re-running it on a partially-migrated DB re-writes the same rows under the same key (idempotent). On clean completion, the unencrypted tables are dropped (use `--keep-original` to preserve them). ## Rotation ```bash shade rotate-storage-key \ --key-source passphrase --passphrase "$OLD_PASS" \ --new-key-source passphrase --new-passphrase "$NEW_PASS" \ --new-salt-file /data/shade-client.db.salt.new ``` Reads each encrypted row under the old key, re-seals under the new key. The DB stays online; brief read-after-write inconsistency for in-flight readers is acceptable for the supported deployments (CLI tools, single-process servers). On completion the fingerprint is updated and the old key no longer opens the DB. ## What this does *not* protect Even with at-rest enabled: - A live process holds the storageKey and fieldKeys in memory. An attacker who can dump process memory (`/proc//mem`, swap, hibernation, coredump) recovers the keys. - Swap is not encrypted by Shade. Use an encrypted swap device. - The `.bak` file produced during migration is plaintext during the migration window. Treat it like the original DB and store securely. - Lost master key = lost DB. V3.10 (Social Recovery) is the long-term mitigation. See `THREAT-MODEL.md` §4 for the full list, including the "with at-rest enabled" boundary. ## Cross-implementation parity `test-vectors/storage-encryption.json` pins KDF parameters, info strings, nonce derivation, and AAD format. The Android implementation (V3.5) MUST produce byte-identical outputs for the same inputs — covered by `packages/shade-storage-encrypted/tests/test-vectors.test.ts`.