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>
5.9 KiB
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
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 singleBLOB/BYTEAcolumn.
Key sources
KeyManager.open(...) accepts three sources:
- 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). - OS keychain — via
@shade/keychain. Backends:- macOS:
securityCLI (Keychain). - Linux:
secret-tool(libsecret). - Windows: PowerShell +
CredentialManagermodule. No native deps;createIfMissing: truegenerates and stores a fresh 32-byte key.
- macOS:
- 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:
# 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
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
.bakfile 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.