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

5.9 KiB
Raw Permalink Blame History

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 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:

# 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 .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.