Files
Shade/docs/storage-encryption.md
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

161 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. `<db>.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/<pid>/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`.