aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pull-mode httpClient + drainer + parallel RPCs against the same peer
deteriorated after ~10s with `DecryptionError`. Two bugs combined:
- `OutboundQueue.enqueue` woke `drain` waiters with a `since=0`
snapshot, replaying already-processed events into
`Shade.acceptTransferEnvelope` → `manager.decrypt` twice. The
duplicate consumed an already-used skipped key and corrupted the
Double Ratchet receive chain.
- `ratchetDecrypt` then propagated the corruption: a same-DH
message behind the chain with no cached skipped key fell through
to `kdfChainKey` on the ahead state and rewound `chain.counter`,
permanently desyncing the chain.
Fix `OutboundQueue` to honor each waiter's `since`, and harden
`ratchetDecrypt` so any future duplicate fails cleanly without
mutating state. Adds regression coverage at all three layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase D complete. Shade is now at parity with Signal libsignal's core
feature set.
M-Adv 1: Multi-device support (simplified Sesame)
- DeviceListManager tracks per-user device lists ("user:deviceId" addresses)
- fanOutEncrypt() sends one message to all known devices via independent
1:1 Double Ratchet sessions
- observeIncoming() auto-registers new devices from received messages
- JSON serialization for persistence
- userOfDevice/deviceIdOf address parsers
M-Adv 2: Backup and restore
- @shade/sdk exports BackupBlob format: version + salt + nonce + ciphertext
- Passphrase-derived key via HKDF (note: upgrade path to Argon2id documented)
- exportBackup()/importBackup() handle identity, prekeys, sessions, trust
- backupToString/backupFromString for single-string transport (copy/paste, QR)
- shade.exportBackup()/importBackup() convenience methods on SDK
- CLI: shade backup export <file> / shade backup restore <file>
- Rebuilds manager + transport after restore so ratchet state is consistent
M-Adv 3: Group messaging (Sender Keys)
- Per-sender chain key + Ed25519 signing key per group
- createSenderKey / buildDistribution / installDistribution for key distribution
- senderKeyEncrypt advances chain and signs ciphertext+header
- senderKeyDecrypt verifies signature then advances the sender's chain
- Out-of-order handling with bounded skip
- O(1) per message (once distributions are installed)
- Defensive ByteArray copies in distribution to prevent zeroize-across-refs
276 tests passing, 0 failures. All 13 SDK/tooling/platform/advanced
milestones complete. Shade is feature-complete for v2.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
M-Obs 1: Event bus in @shade/core
- ShadeEventEmitter with typed event union, ring buffer for replay
- 12 event types covering session lifecycle, ratchet operations,
prekey changes, identity rotation, trust changes
- Wired into ShadeSessionManager (zero overhead when not enabled)
- shortHash helper for safe display of public keys
- Security test: regex-checks event payloads contain no key material
M-Obs 2: Prekey server event hooks
- PrekeyServerEvents emitter mirroring core's pattern
- 5 server event types: registered, fetched, replenished, deleted, rate_limited
- Wired into all routes including the rate-limit error handler
- shortHash helper using crypto.subtle directly (no provider dep)
M-Obs 3: @shade/observer package
- StateAggregator subscribes to client + server events, builds rolling snapshot
- Hono routes: GET /api/state (snapshot), GET /api/events (SSE stream)
- Bearer token auth via SHADE_OBSERVER_TOKEN, query string for SSE
- Refuses to start with token < 16 chars (ConfigurationError)
- Static file serving for bundled dashboard at /dashboard/
- Placeholder dashboard renders when no built SPA present
220 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shade sessions and keys now survive server crashes, container restarts,
and power outages via SQLite with WAL mode.
New packages:
- @shade/storage-sqlite: SQLiteStorage (StorageProvider) + SqlitePrekeyStore
(PrekeyStore), both using bun:sqlite with auto-created tables and WAL mode
- Serialization layer in shade-core for SessionState/keys ↔ JSON/base64
Docker usage: mount volume at /data, set SHADE_DB_PATH=/data/shade-client.db
Prekey server auto-detects SHADE_PREKEY_DB_PATH for SQLite persistence
Includes crash recovery integration test: encrypt → close DB → reopen →
conversation continues seamlessly.
129 tests, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>