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
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>
161 lines
5.9 KiB
Markdown
161 lines
5.9 KiB
Markdown
# 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`.
|