Answers Vyvern FR shade-ws-streaming-ratchet.md with a first-class
streaming-session API rather than the documented-contract fallback.
The Double-Ratchet crypto was already safe for high-frequency
one-directional use; the send/receive wrapper was not (per-frame
saveSession keystore write; shared per-peer mutex + single stored
session row coupling reuse to the HTTP path).
- @shade/core: stream.ts — identity-bound 3-DH seeding (X3DH-minus-
prekeys, no prekey-server round trip, mutually authenticated against
the parent session's pinned identities), bootstrapStreamSession
reusing init{Sender,Receiver}Session verbatim, in-memory-only
StreamRatchet (own op-mutex, never persisted, zeroized on close).
beginStream/acceptStream on ShadeSessionManager; Stream{Closed,
Handshake}Error; stream.opened/closed events.
- @shade/proto: STREAM_OPEN/OPEN_ACK/FRAME wire (0x31/0x32/0x33),
additive; inspectEnvelopeType extended.
- @shade/sdk: Shade.openStream/acceptStream → ShadeStream
(handshakeFrame/handleHandshake/seal/open/close), transport-
agnostic, independent of encrypt/decrypt queues + parent session,
identical server (sqlite:) and browser (IndexedDB) — touches no
storage.
- Tests: 5000-frame one-directional burst (bounded skipped keys + FS
zeroize), parent-session independence, replay/rewind rejection,
mutual-auth, proto wire round-trips. Full suite green (1159 pass).
- docs/streaming-sessions.md (R1–R7 contract); SECURITY.md matrix rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 KiB
Changelog
All notable changes to Shade are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
Answers Vyvern FR shade-ws-streaming-ratchet.md (the last Phase-2
blocker) with a first-class streaming-session API rather than the
"documented contract" fallback: the Double-Ratchet crypto was already
safe for high-frequency one-directional use; the send/receive
wrapper was not (a saveSession keystore write per frame; a shared
per-peer mutex + single stored session row coupling any reuse to the
HTTP path). ShadeStream keeps the proven ratchet and fixes the
wrapper.
@shade/core
- New
stream.ts:deriveStreamRootKey(identity-bound 3-DH — X3DH-minus-prekeys, no prekey-server round trip; mutually authenticated against the parent session's already-pinned identities),bootstrapStreamSession(reusesinitSenderSession/initReceiverSessionverbatim), andStreamRatchet— an in-memoryseal/open/closeholder on its own op-mutex, never persisted, zeroized on close. ShadeSessionManager.beginStream/acceptStreamcustody the identity keys for the handshake without exposing private material; both require an established parent session (no first-contact).- New
StreamClosedError/StreamHandshakeError;stream.opened/stream.closedevents.
@shade/proto
- Wire types
STREAM_OPEN(0x31),STREAM_OPEN_ACK(0x32),STREAM_FRAME(0x33) with encode/decode +inspectEnvelopeTypeextension. ASTREAM_FRAMEcarries one ratchet message via the exact inner codec the HTTP path uses — one sealed frame ⇒ one WS frame.
@shade/sdk
Shade.openStream(peer)/Shade.acceptStream(peer, openBytes)returningShadeStream(handshakeFrame/handleHandshake/seal/open/close). Transport-agnostic likesend/receive; auto-establishes the parent session if missing. Independent of the per-peer encrypt/decrypt queues and the stored parent session (R5). Identical on thesqlite:server build and the IndexedDB browser build (R4) — it touches no storage at all.
Security / perf
- Per-frame cost is exactly one symmetric KDF + one AES-GCM (no keystore I/O) — strictly better than the budgeted "doubled CPU". In-memory-only is a forward-secrecy property, not a shortcut; a dropped stream is re-opened, never resumed.
- New
docs/streaming-sessions.md(full R1–R7 contract); SECURITY.md threat-matrix rows added with tests (packages/shade-core/tests/stream.test.ts,packages/shade-proto/tests/stream-wire.test.ts).
[Unreleased — 2026-05-09] — Android: V4.9/V4.10 ports + KeystoreStorage adapter
The Kotlin side of the v4.10 cross-host approval routing FR. With this release every primitive Prism Plan 04 needs on the phone has a Kotlin implementation that produces byte-identical output to the TS reference.
shade-android (pure-JVM, no Android SDK needed)
- V4.9 blob primitives ported:
deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeed,aeadSeal / aeadOpen(nonce(12) || ct||tagformat withshade-profile-aad-v1:<slotIdHex>AAD),ed25519PublicKeyFromSeed,slotIdToHex. Lives underno.zyon.shade.blob. BlobClientHTTP wrapper for/v1/blob/<slotId>(java.net.http) — GET/PUT/DELETE with the same canonical-JSON-sorted-keys signing form the TS server expects. Hand-written JSON canonicalizer keeps Ed25519 signing-input bytes identical to TSsignPayloadoutput.Profilehigh-level namespace (createProfileNamespace) — bundles KDF + AEAD seal/open + BlobClient calls into the same shape as@shade/sdk'screateProfileNamespace.- V4.10 approval helpers ported: canonical profile schema
(
CanonicalProfileBlob,ProfileHostEntry,ProfileClientEntry) with parse/serialize/upsert/setTrustedApprover mutators that re-derivetrustedApproverFingerprints[]invariantly.buildApprovalRequest,signProxyApproval,verifyProxyApproval, and the load-bearingcanonicalApprovalSigningBytes(length-prefixed u16 BE UTF-8). All underno.zyon.shade.approval. - Password KDFs:
deriveMasterKey(scrypt) +deriveMasterKeyArgon2idvia Bouncy Castle. NFKC-normalize string inputs to match TS. - New
SessionStateJsonserializer for at-rest persistence ofIdentityKeyPair/SignedPreKey/OneTimePreKey/SessionState.
Cross-platform vectors
test-vectors/blob.json— V4.9 blob KDF + AEAD round-trip. Three (master, app) cases plus two pinned AEAD seal/open round-trips.test-vectors/approval.json— V4.10 approval signing-payload bytes- a TS-signed Ed25519 signature the Kotlin port verifies. Ed25519 is deterministic, so the Kotlin sign-with-the-same-seed produces the same 64 bytes back — that's checked too.
- Both wired into
CrossPlatformVectorTestso any byte-divergence fails Gradle within the existing 60-second parity gate.
shade-android-keystore (new sibling module — Android-specific)
KeystoreMasterKey— hardware-backed AES-256-GCM master key.BIOMETRIC_STRONGgating only (Class 3 assurance) — explicitly excludesDEVICE_CREDENTIALso a stolen-device-with-known-PIN can't unlock Shade.- StrongBox-backed when available; transparent fallback to TEE.
setInvalidatedByBiometricEnrollment(true): a newly enrolled fingerprint/face wipes the key, forcing credential rebootstrap.
BiometricUnlock— coroutine wrapper aroundBiometricPrompt. Tagged exceptions (BiometricCancelledException/BiometricFailedException) so callers handle UX without writing callback boilerplate.KeystoreStorage—StorageProviderimpl overSharedPreferenceswith each row AES-GCM-encrypted under the keystore key. AAD = the pref key string so a substituted-prefs swap fails to open. Exposesunlock(BiometricUnlock)/lock()/forgetEverything()for app-lifetime gating (single biometric prompt at start, in-memory unlock thereafter).- Builds as a standard AAR (
com.android.library, AGP 8.7.3), depends transitively on:shade-androidfor protocol types and onandroidx.biometric:biometric:1.2.0-alpha05.
Threat model
The keystore key never leaves the secure environment — encrypt/decrypt
operations happen in the TEE/StrongBox. A compromised app process can
ask the TEE to use the cipher only after biometric authentication
within the same Cipher instance. Combined with Profile.delete()
forgetEverything(), this gives a credible erase path: zero recoverable plaintext after a rebootstrap.
Instrumented tests for KeystoreStorage are deferred (need an
emulator/device); the pure-JVM SessionStateJson round-trip is unit-
tested in :shade-android.
[4.10.0] — 2026-05-09 — cross-host approval routing primitives
Prism filed a follow-up feature request
(cross-host-approval-routing.md) building on V4.9: now that the
encrypted profile blob is shipped, headless servers and away-from-PC
scenarios still can't approve a linkRequest from a new device
because there's no GUI to pop a dialog. Solution: a trusted-approver
phone signs an Ed25519 approval that any host can verify against the
freshest profile blob, even if the host has never spoken to the phone
before (X3DH-on-first-send via the existing Shade.send handles
session bootstrap).
The FR's questions (1) "is X3DH-on-first-send supported?" and
(5) "is there a broadcast helper to N addresses with X3DH-on-first?"
are both answered by yes, Shade.send already does this per call
— no new relay primitives needed. What this release ships is the
shape every Shade app would otherwise reinvent: a canonical
profile-blob schema and the build/sign/verify trio for proxy approvals.
SDK (@shade/sdk)
- Canonical profile-blob schema:
CanonicalProfileBlobwithhosts[],clients[], and a denormalizedtrustedApproverFingerprints[].parseCanonicalProfile/serializeCanonicalProfileround-trip JSON; mutators (upsertHost,upsertClient,setTrustedApprover,removeClient, ...) are immutable and re-derive the denormalized list on every change so it can't drift. ProfileClientEntrystores bothidentityPublicKey(32-byte hex, used byverifyProxyApproval) andidentityFingerprint(safety- number for display). The FR sketched only the fingerprint; storing the public key in-band drops the prekey-server dependency at verify-time and lets any host check signatures from a fresh profile read alone.- Approval frames:
ApprovalRequestFrame(kind: 'approvalNeeded') andProxyApprovalFrame(kind: 'linkApproveByProxy').buildApprovalRequestmints a 128-bit hexrequestIdand a configurable expiry (default 5 min). signProxyApproval/verifyProxyApprovaluse a length-prefixed binary signing payload (canonicalApprovalSigningBytes) that binds domain, requestId, host fingerprint, requesting-device fingerprint, and decision. Length-prefixed (u16 BE) so any platform — Kotlin, Swift, Go — can produce byte-identical bytes from test vectors without a JSON canonicalizer.- Domain separator:
DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1'. Apps with their own canonical name (Prism usesprism-link-approve-v1) override viadomain. The frame carries the domain so a verifier rejects mismatch before signature check. verifyProxyApprovalreturns a tagged result instead of throwing. Reasons:request-id-mismatch,domain-mismatch,unknown-approver,not-trusted,bad-signature,expired. Hosts log the reason and decide what to surface to the user.isTrustedApprovercross-checks the per-clienttrustedApproverflag AND the denormalizedtrustedApproverFingerprints[]. Both must agree — defends against a partially-written blob.
Threat model — what's new
- Compromised relay still can't read or forge approvals. The approval signature is verified against the approver's long-term Ed25519 identity key stored in the profile blob, which the relay can't decrypt or rewrite (profile-blob TOFU on owner-pubkey from V4.9 still applies).
- Revocation TOCTOU: hosts MUST refetch the profile blob fresh
before honoring
linkApproveByProxy. TheverifyProxyApprovalsignature accepts the blob as a parameter — caller controls freshness. One extraProfile.getRTT per approval. - The signature is belt-and-suspenders on top of the bilateral E2EE that delivers the frame. The E2EE channel already authenticates the sender's session, but the long-term identity binding means an approval is verifiable independently of session state.
No relay or transport changes. All app-level. Hosts persist their
own pending-requestId set for replay protection; that's app state
the SDK doesn't need to track.
[4.9.0] — 2026-05-09 — relay-side encrypted blob primitive + SDK Profile namespace
Prism filed a feature request
(encrypted-profile-storage-v4.9.md) for relay-side encrypted profile
storage as the missing cryptographic primitive for Phase 2 of their
device-linking work: a brand new browser/device must be able to
locate a user's existing E2EE state from credentials alone — no QR,
no physical access to a paired device.
Ships as a generic primitive: a deterministically-located, AEAD-sealed blob keyed by a 32-byte slotId derived client-side via HKDF from the user's master key. The relay sees opaque slotIds and opaque ciphertext; it never decrypts and cannot link slots to users. Compare-and-swap via a per-slot etag prevents two devices from silently clobbering each other's writes when a user adds a new paired peer concurrently from two existing devices.
Server (@shade/inbox-server)
- New
BlobStoreinterface +MemoryBlobStorereference impl. Per-slot layout:(slotId, ownerPubkey, blob, etag, updatedAt). ETag is monotonic per process, clamped againstDate.now()so values are unique and roughly time-ordered for ops. - New
createBlobRoutes(store, crypto, options)mountingGET / PUT / DELETE /v1/blob/:slotId. SlotId is validated as 64 lowercase hex chars (32 bytes); URL-bound into the signed payload to prevent cross-slot signature replay. Owner pubkey is recorded TOFU on the first PUT — subsequent writes verify against it; a different key trying to overwrite an existing slot returns 401. - CAS via
ifMatch: omitted = create-only (409 on populated slot), numeric etag = strict CAS (412 on mismatch),'*'= unconditional overwrite when populated (412 on empty per RFC 7232). - Default per-slot ceiling: 64 KiB. Sized for ~500 host entries in
Prism's
hosts[]JSON form with plenty of headroom. createInboxServernow also mounts the blob primitive on the same Hono app — pass{ blobStore: null }to opt out.
Storage backends
SqliteBlobStore(@shade/storage-sqlite) — single-tableshade_blob_slotswith WAL journal mode. Reads CAS state and writes inside a transaction so concurrent CAS attempts can't both observe the same etag. Volume: same path convention as the inbox store;SHADE_BLOB_DB_PATHoverrides (default/data/shade-blob.db).PostgresBlobStore(@shade/storage-postgres) — usesnextval('shade_blob_seq')so etag ordering is strict across multi-instance deployments. CAS path holdsFOR UPDATEon the read so the txn serializes against concurrent writers. NewensureBlobServerTables()exposed for ops.
SDK (@shade/sdk)
- New
createProfileNamespace({ baseUrl, crypto, masterKey, app })high-level wrapper. Computes slotId, blobKey, signing seed deterministically; AEAD-seals plaintext withAAD = "shade-profile-aad-v1:<slotIdHex>"on every PUT (fresh random nonce); verifies AEAD on every GET. - New low-level
BlobClient(@shade/inbox) — caller-supplied signing key; transports already-sealed ciphertext. - New
ed25519PublicKeyFromSeed(seed)(@shade/crypto-web) for deterministic Ed25519 keypair derivation from a 32-byte seed. - KDF helpers
deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeedexposed from@shade/storage-encrypted/cryptoso apps that want a custom flow (skip the AEAD wrapper, hit a non-Shade relay) don't reimplement the info-string conventions. appnamespace string mandatory — distinct apps under the same master MUST pass different values (e.g.prism-profile-v1) so they don't collide on the same slot.
Standalone server
- Boots a
BlobStoremirroring the inbox-store selection chain:SHADE_BLOB_PG_URL>SHADE_BLOB_DB_PATH> sharedSHADE_PREKEY_PG_URL> memory.SHADE_DISABLE_BLOB=1opts out. HonorsSHADE_DISABLE_RATE_LIMITfor single-tenant deployments.
Errors
- New
ConflictError→SHADE_CONFLICT→ HTTP 409. - New
PreconditionFailedError→SHADE_PRECONDITION_FAILED→ HTTP 412.
Test vectors
test-vectors/blob-storage.json— three (masterKey, app) cases with expected slotId/blobKey/signingSeed/ownerPubkey. Lets Prism (and future non-JS implementations) verify HKDF-info-string interop without spinning up a relay.
[4.8.5] — 2026-05-08 — Inbox.flushOnce: kill the 15 s success-backoff + per-recipient parallel drain
Prism filed a "typing-into-a-chatty-shell" UX FR pointing at
serial-per-flush behavior. The investigation surfaced a more
important latent bug: scheduleFlush was using a 15 s backoff timer
on both the success and failure paths, so any envelopes enqueued
during an in-flight flush had to wait ~15 s for the next drain to
fire — visible to Prism's web client as "10 s of silence then a
25-frame burst" whenever the PC sidecar was emitting steady output.
Two fixes ship together:
(1) scheduleFlush distinguishes healthy-drain from all-failed.
After flushOnce returns, if the round delivered ≥1 envelope and
items are still queued, the next flush fires with 0 ms delay
(network is fine — drain whatever piled up immediately). The 15 s
backoff is reserved for the actual failure case (every attempt this
round threw / was rejected). flushOnce now returns
{ delivered, remaining } | null so the scheduler can also tell
"someone else is flushing, don't double-schedule" apart from
"queue is empty, idle." Externally-visible API unchanged
(Inbox.tick() still returns { flushed, received }).
(2) Per-recipient parallel drain inside flushOnce. The queue
is grouped by recipientAddress; each bucket is drained
sequentially (preserves per-peer enqueue order — the relay assigns
receivedAt on PUT arrival, so concurrent PUTs to the same peer
would let the second one land first), but distinct buckets run
concurrently via Promise.all. Pre-fix, a slow POST to recipient A
head-of-line-blocked every other recipient's frames. Future N-peer
broadcast fan-outs (multiple devices viewing the same Prism PTY)
benefit immediately; single-recipient deployments are unaffected
since N=1 is the trivial parallel case.
Reported by Prism (multi-device E2EE terminal). Acceptance: under
sustained typing, web's recv rate is roughly proportional to PC's
emit rate, no multi-second silences punctuated by burst catch-ups.
Fixed
@shade/inbox — scheduleFlush 15 s success-backoff
- After a successful drain, the next flush is rescheduled with
delayMs=0whendelivered > 0. The 15 s timer is reserved for rounds where every attempt failed (no progress, avoid tight retry loop). - Concurrent
scheduleFlushcalls during an in-flight flush are detected viaflushOncereturningnull; the no-op early return no longer double-schedules a 15 s retry for a flush that's already running.
@shade/inbox — flushOnce per-recipient parallelism
- Outgoing queue is grouped by
recipientAddress; buckets drain viaPromise.all. Per-peer order preserved (sequential within a bucket); cross-peer order has no guarantee in Shade's wire model to begin with. - Failure handling unchanged: per-entry
bumpAttempts/maxAttemptssemantics are identical to V4.8.4.
Tests
packages/shade-inbox/tests/client.test.ts:- "burst enqueued during a flush drains immediately, not after
15 s backoff" — slow first PUT (100 ms), pile 24 more during,
assert
pendingCount === 0within 1 s. - "per-recipient parallel drain — slow POST to A does not block
POSTs to B" —
bobPUT stalls 200 ms;carolenvelope queued after; assertinbox.message_deliveredfor carol fires within 150 ms (would be ≥200 ms pre-fix).
- "burst enqueued during a flush drains immediately, not after
15 s backoff" — slow first PUT (100 ms), pile 24 more during,
assert
Migration
None. Inbox.flushOnce is a private method; the
{ delivered, remaining } | null shape is internal. Inbox.tick
public return { flushed, received } is unchanged. Apps that hand
custom OutgoingQueueStore implementations to Inbox see no
contract change — list() / remove() / bumpAttempts() / size()
are called the same way per entry; only the order of remove()
calls across distinct recipients changes (interleaved instead of
strictly sequential).
[4.8.4] — 2026-05-08 — Server-side cross-channel dedup via BridgeDeliveryLog
V4.8.3 shipped the client-side cross-channel dedup hook
(Inbox.acceptBridgeFrame), but a recipient that didn't migrate to
the new wiring kept observing the same envelope twice — once via the
WS bridge push, again ~30 s later when the next inbox-poll cycle
fetched it. Prism re-verified the FR after 4.8.3 and called this out:
they wanted the relay itself to enforce the
"one Inbox.send ⇒ one observable delivery" contract so app code
doesn't have to ack-via-DELETE on every bridge frame.
V4.8.4 adds the server-side dedup gate. A new in-memory
BridgeDeliveryLog (default 60 s grace, 8192-entry-per-address cap)
records every successful WS / SSE / long-poll push of
(address, msgId). The inbox-fetch route reads the log and filters
out blobs the bridge has already pushed within the grace window. The
cursor advances over the full fetched window so a poll cycle that
straddles a suppressed blob doesn't get stuck — the bridge frame is
the canonical delivery; a recipient that crashed before processing it
falls back to ack-via-DELETE or waits for TTL the same way it would
in a no-bridge deployment.
The standalone server (@shade/server) auto-wires the log between
createBridgeRoutes and createInboxRoutes, so self-hosted relays
get the fix without configuration. Custom mounts thread the same
instance through bridgeDeliveryLog on both factories.
Reported by Prism (multi-device E2EE terminal). Acceptance: a single
inbox.send from sender A produces exactly one observable receive
on recipient B even when B runs both a bridge subscription and the
30 s Inbox.pollOnce cadence — confirmed by bun test packages/shade-transport-bridge/tests/bridge.test.ts's new
"BridgeDeliveryLog" describe block (WS + SSE coverage).
Added
@shade/inbox-server
BridgeDeliveryLogclass — in-memoryMap<address, Map<msgId, deliveredAt>>with grace-window filtering. Bounded per address (default 8192, oldest-first eviction); lazy cleanup on insert. Exported from the package root.BridgeDeliveryLogOptions—{ graceMs?, maxPerAddress? }.createBridgeRoutes(...).bridgeDeliveryLog— the auto-created log the bridge handlers write to. Inject one explicitly viaBridgeRoutesOptions.bridgeDeliveryLogwhen you need to share an instance across multiple bridge mounts.InboxRoutesOptions.bridgeDeliveryLog— when provided, the/v1/inbox/:addr/fetchroute filters out blobs in the log's grace window. Cursor advances over the full unsuppressed-plus- suppressed page so successive polls don't stall.createInboxServer({ bridgeDeliveryLog })— opt-in for the high-level factory.
@shade/server — standalone.ts
- The shared
BridgeDeliveryLogis auto-wired betweencreateBridgeRoutesandcreateInboxRoutesso self-hosted relays inherit the cross-channel dedup with zero configuration.
Tests
packages/shade-transport-bridge/tests/bridge.test.ts— new "BridgeDeliveryLog" describe block:- WS push then
/v1/inbox/:addr/fetchreturns 0 blobs but the cursor has advanced. - SSE push records into the log identically (transport parity).
- A blob the bridge never pushed (e.g. bridge wasn't connected) still comes through inbox-fetch — the filter is bridge- delivered-specific, not a blanket suppression.
- WS push then
bootstrap()in the test file now wires the bridge's auto-created log intocreateInboxRoutes, mirroring the standalone-server wiring.
Migration
For self-hosted operators of the standalone server: drop-in.
For consumers that mount createInboxRoutes + createBridgeRoutes
themselves: pass the same bridgeDeliveryLog to both factories. The
bridge auto-creates one and exposes it as
bridgeRoutes.bridgeDeliveryLog; the inbox routes accept it as
InboxRoutesOptions.bridgeDeliveryLog. Without the wiring, the bridge
push still works as before but the cross-channel dedup is off.
For client-side consumers, V4.8.3's Inbox.acceptBridgeFrame is
still the recommended path (instant ack, no grace-window wait), but
clients that only consume bridge pushes via their own dispatcher now
get the dedup for free as long as the relay is on V4.8.4.
[4.8.3] — 2026-05-08 — Cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
against. V4.8.2 closed the same-channel duplicate (8× via WS bridge
became 1×); V4.8.3 closes the cross-channel duplicate (bridge push +
inbox-poll catching up still delivered the same msgId twice) and
adds the missing Shade.aliasSession primitive that lets receivers
canonicalize their first-contact fp:<senderfp> label to the peer's
real address once the plaintext announces it.
(1) Inbox.acceptBridgeFrame(blob) + shared msgId LRU. The
relay's Inbox.send durably stores the blob and pushes it to every
active delivery channel. Without a client-side cross-channel ack, a
recipient running both a bridge and an inbox-poll cycle processed the
same blob twice — the bridge frame ran first, the
30 s-cadence inbox-poll fetched it again, and the duplicate dispatch
tripped on already-consumed one-time prekeys
(one-time prekey not found: <id>) or surfaced as duplicate
shade.receive work even when the canonical first delivery had
succeeded. The new Inbox.acceptBridgeFrame(blob) plumbs bridge
deliveries through the same dispatch + ack pipeline that pollOnce
uses; both paths share a 4096-entry msgId LRU so whichever channel
delivers first wins, and the other channel acks-and-skips when the
same msgId comes back around. The relay drops the blob on either
ack so subsequent polls don't see it.
(2) Shade.aliasSession(oldLabel, newLabel). First-contact
forces the receiver to label the new session by the relay's
sender-fingerprint hint (fp:<senderfp> — the only sender label
visible at receive-time per the V4.8 sender-attribution feature),
because the receiver doesn't yet know the sender's prekey-server
address. The post-decrypt plaintext typically announces the
sender's address; without an SDK primitive to canonicalize, every
subsequent send/receive would either fail
(Failed to decrypt message — wrong key or tampered data) or
require app-level fp ↔ address translation around every call.
aliasSession moves the per-peer storage rows (session, trusted
identity, peer-verification record, identity-version counter) under
the new label, holding the per-peer mutex on both labels for the
duration so concurrent encrypt/decrypt can't observe a half-moved
state. The send/receive encryptChains + decryptChains queues for
the old label are also dropped so future operations start fresh.
Refuses to overwrite an existing session under the new label
(call resetSession(newLabel) first if that's intentional).
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake's web-side paired reply now decrypts cleanly,
BroadcastChannel.addMember accepts the sender-address-vs-bilateral
cross-check, and steady-state heartbeats / terminal traffic don't
log a duplicate "OPK not found" per envelope.
Added
@shade/inbox
Inbox.acceptBridgeFrame(blob: FetchedBlob): Promise<boolean>— feed a bridge-pushed envelope through the same dispatch + ack + dedup pipeline aspollOnce. Returnstruewhen newly dispatched,falsefor a duplicate or a handler-rejected blob. Wire-up pattern documented inline.- A 4096-entry FIFO msgId LRU (
deliveredIds+deliveredOrder) is shared betweenacceptBridgeFrameandpollOnceso cross-channel duplicates are skipped (and acked) without re-runningincomingHandler. Inbox.handleBlobnow records every successfully-dispatched msgId before issuing the ack, eliminating the ack-in-flight window where a parallelpollOncecould see the blob and re-dispatch.
@shade/transport-bridge
IncomingMessage.expiresAt?: number— relay-assigned absolute expiry, surfaced from the wire envelope so receivers can pass it straight toInbox.acceptBridgeFramewithout inventing a TTL.decodeWireMessagepopulates it when the wire message includes one (V4.8.3 relay onward).
@shade/sdk
Shade.aliasSession(oldLabel: string, newLabel: string): Promise<void>— rename a session and its companion per-peer rows. Throws on no-such-session-for-oldLabel and refuses-to-overwrite-newLabel.
@shade/core
ShadeSessionManager.aliasSession(oldLabel, newLabel)— the primitive backingShade.aliasSession. Holds the per-peer mutex on both labels (acquired in lexicographic order) so the rename is atomic w.r.t. concurrent crypto ops.ShadeEventMap['session.aliased']: { oldLabel, newLabel }— emitted on a successful rename. Surfaced for observability dashboards.
Tests
packages/shade-inbox/tests/client.test.ts— two new cases: bridge-then-poll and poll-then-bridge, both asserting exactly oneincomingHandlerdispatch perinbox.send.packages/shade-sdk/tests/sdk.test.ts— newaliasSessioncases: happy-path canonicalization (Bob initiates asalice, Alice receives underfp:bobfp, aliases tobob, subsequent ratchet exchange in both directions decrypts cleanly), refuses-to-overwrite, same-label no-op.
Migration
None. acceptBridgeFrame and aliasSession are additive. Existing
bridge consumers that don't call acceptBridgeFrame keep working as
before — they just don't get cross-channel dedup, and the same
duplicate-on-poll behavior persists. aliasSession callers are
opt-in.
[4.8.2] — 2026-05-08 — Per-from decrypt serialization + per-connection bridge dedup
Two interlocking robustness fixes for the first-contact / duplicate-fan-out class of failures Prism reported. Either fix on its own would help; together they make the receiver path tolerant of any combination of relay duplicates and concurrent dispatchers.
(1) Shade.receive(from, env) now serializes its ratchet/storage
step per from. The send path has had a per-address encryptChains
mutex since V1 — receive did not. Concurrent decrypts for the same peer
raced the SessionManager ratchet (mutated in place) and the
StorageProvider (which is not required to be a concurrent-safe
writer — bun:sqlite throws database is locked, IndexedDB throws
transaction conflicts). Symptom in production: a single relay PUT that
fans out 8× over a WS bridge gets dispatched as 8 parallel
shade.receive calls; one wins the X3DH prekey race, the other 7 fail
with database is locked or one-time prekey not found: <id>, and the
post-decrypt side effects (markPeerVerified,
BroadcastChannel.addMember, paired-reply inbox.send) get lost in
the rubble. The decrypt step is now chained off a per-from promise
queue. Crucially, the user-facing message handlers run outside the
queue — streams + file-RPC issue nested shade.receive calls for the
same peer from inside their handlers (e.g. stream-end arrives while a
write-RPC is still waiting on chunks), and holding the queue across the
handler would self-deadlock. Only the atomic ratchet+storage step is
protected.
(2) Bridge handlers (WS + SSE) now run a per-connection msgId
LRU dedup. Cursor-based delivery already de-duplicates in the happy
path, but the gate is a defense-in-depth against any subtle re-entry of
flushTo (event-storm, future refactor, fallback-timer race). The chain
that drives flush is now also wrapped in .catch(() => {}) so a
transient ws.send / SSE write rejection doesn't poison every future
push on the connection.
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked even when the receiver runs multiple bridges or
the relay double-fires inbox.blob_stored.
Fixed
@shade/sdk — Shade.receive per-from serialization
Shadegains a privatedecryptChains: Map<string, Promise<unknown>>mirroring the existingencryptChainson the send path.Shade.receive(from, env)chains itsmanager.decrypt(from, env)call off the prior decrypt promise for the samefrom. The post-decrypt control-plaintext check and usermessageHandlersrun outside the chain so nestedshade.receivecalls from inside a handler don't self-deadlock (streams + file-RPC depend on this).- The stored chain is
decryptPromise.catch(() => undefined)so a rejection in one decrypt doesn't sabotage the next; this caller still sees its own rejection through the original promise. - External signature unchanged.
@shade/inbox-server — bridge per-connection msgId dedup
- New internal
DeliveredIdLru(4096-entry bounded set, FIFO eviction) per WS / SSE connection.flushToskips emit when a row'smsgIdis already in the LRU. Long-poll handlers don't need it (each request is isolated). pendingFlushPromisechains in both WS and SSE handlers now terminate in.catch(() => {})so a transient emit failure doesn't silently kill the connection's flush loop.
Tests
packages/shade-transport-bridge/tests/bridge.test.ts— new "Bridge dedup" describe block: stormsinbox.blob_stored10× for one PUT and asserts WS / SSE both deliver exactly one frame.packages/shade-sdk/tests/sdk.test.ts— new "concurrent receive(from, env) for samefromdoes not race the ratchet" exercises 8 parallelbob.receive('alice', env)for the same envelope and asserts:- at least one fulfills with the right plaintext;
- no rejection mentions
database is locked; - the next legitimate message still decrypts (ratchet intact).
Migration
None. Drop-in. Bridges and receivers behave identically on non- duplicate paths; the new gates only kick in when a duplicate would otherwise have been emitted / dispatched.
[4.8.1] — 2026-05-08 — SHADE_DISABLE_RATE_LIMIT env var for single-tenant deploys
The standalone server's routes.ts and inbox-server's
createInboxRoutes already accepted a disableRateLimit?: boolean
option, but the standalone entry just didn't read it from environment.
Self-hosted single-tenant deploys (Prism's relay is a typical case —
only Prism PC clients + their paired browsers) tripped the
REGISTER_LIMIT (5/hour per IP) every dev iteration: ~6 pair attempts
in an hour from the same IP plus the sidecar's register call killed
the dev loop until the bucket refilled (~1 token per 12 minutes).
Reported by Prism. Two-line plumbing fix: standalone.ts now reads
SHADE_DISABLE_RATE_LIMIT=1 and forwards disableRateLimit to both
createPrekeyRoutes and createInboxRoutes.
Added
@shade/server
SHADE_DISABLE_RATE_LIMIT=1env var disables IP rate-limits on every prekey + inbox route instandalone.ts. Logged as aWARNon startup (SHADE_DISABLE_RATE_LIMIT=1 — IP rate limits OFF on prekey + inbox routes) so operators see it in stderr/log aggregation.- Single-tenant deployments only — multi-tenant relays must leave
this unset. The rate-limit defends multi-tenant relays against abuse;
flipping it off is appropriate for self-hosted single-team setups
where every caller is a known client. Documented in
docs/DEPLOYMENT.mdunder "Environment variable reference".
Tests
packages/shade-server/tests/rate-limit.test.ts— the existing "register endpoint rate-limits per IP" test verifies the default-on path; a new sister test exercisescreatePrekeyServer({ disableRateLimit: true })and confirms 12 consecutive register calls from the same IP all return 200 (no 429). The env-var → option conversion instandalone.tsis a one-liner verified by inspection.
Migration
None. Default is unchanged (rate limits stay ON). Self-hosted
single-tenant operators add SHADE_DISABLE_RATE_LIMIT=1 to their
deployment env to flip it off.
[4.8.0] — 2026-05-08 — Sender-fingerprint attribution + Inbox.start() race fix
Two unblocking changes for first-contact flows. First, the relay now
captures the sender's signing-key fingerprint at PUT time and surfaces
it on every downstream delivery — bridge push (IncomingMessage.from)
and inbox-fetch response (FetchedBlob.from). Without it, an app
receiving a prekey envelope from a never-before-seen peer cannot
decrypt it: shade.receive(from, env) requires a sender address and
the wire envelope itself doesn't authenticate the sender. The
fingerprint is the same 8-byte hex of SHA-256(senderSigningKey) that
IncomingMessage.from was already documented as carrying; the field
just wasn't populated.
Second, Inbox.start() no longer races register vs the first poll.
Pre-fix, a fresh address calling start() saw the very first
/v1/inbox/{addr}/fetch POST race the register HTTP RTT and return
SHADE_NOT_FOUND — confusing 404 in DevTools, ~30s gap until the next
scheduled poll, and inbox-fetch silently dark for the gap (bridge push
covered for it, which is why this slipped through). start() now
defers the first poll; register() success kicks schedulePoll(0).
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked: web POSTs pair frame to PC inbox, PC's
onIncoming gets raw.from = "fp:<hex>", calls
shade.receive('fp:<hex>', env), parses plaintext, learns real
address, sends paired-reply.
Added
@shade/inbox-server
InboxStore.putBlob({ ..., senderFp? })— store interface accepts an optional 8-byte hex fingerprint.MemoryInboxStore,SqliteInboxStore(@shade/storage-sqlite), andPostgresInboxStore(@shade/storage-postgres) all persist + return it.InboxStore.fetchBlobs(...)rows exposesenderFp?: string. Undefined for legacy rows persisted by a pre-4.8 relay.POST /v1/inbox/:addressroute computesshortHash(senderSigningKey)after the sender's signature is verified and forwards it tostore.putBlob({ ..., senderFp }). The signature verification path authorizes the same fingerprint that gets persisted — no new trust surface.POST /v1/inbox/:address/fetchresponse includesfromper blob when the row has a fingerprint. Absent on legacy rows.- Bridge endpoints (
/v1/bridge/{stream,poll,ws}) now populateBridgeWireMessage.fromfrom the row'ssenderFp. Thetransport-bridgewire format already acceptedfrom; v4.7 just never filled it.
@shade/inbox
FetchedBlob.from?: string— relay-supplied sender fingerprint hint, parsed from the fetch response.DecryptHandlerraw arg gainsfrom?: string. Apps that ignore it keep working unchanged (back-compat: the field is optional).
Fixed
@shade/inbox — Inbox.start() register/poll race
start() no longer schedules the first poll synchronously alongside
the fire-and-forget register(). Instead, register() success kicks
schedulePoll(0), so the first poll fires after the server has
acknowledged the address. Already-registered instances (where the
local this.registered flag is true at start() time, e.g. after a
restart that hydrated state) get an immediate poll as before.
Storage migrations
Idempotent ALTER TABLE for live deployments:
- SQLite (
@shade/storage-sqlite): on open, the store doesPRAGMA table_info(inbox_blobs)and runsALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXTif the column is missing. Fresh databases get the column from theCREATE TABLE IF NOT EXISTSdirectly. - Postgres (
@shade/storage-postgres):ensureInboxServerTablesrunsALTER TABLE shade_inbox_blobs ADD COLUMN IF NOT EXISTS sender_fp TEXT.
Both leave existing rows with sender_fp = NULL. The fetch path emits
from only when the column is non-empty, so legacy blobs surface as
from: undefined (acceptance criterion (4): inter-version compat).
Tests
packages/shade-inbox/tests/client.test.ts:- Race fix: spy fetch records the order of
registerandfetchrequests; firstfetch(if any) must followregister. Pre-fix the recording fetch threw "fetch fired before register completed (race not fixed)". - Fetch attribution:
FetchedBlob.frommatchesSHA-256(senderSigningKey)[:8]in hex. - DecryptHandler propagation:
raw.fromarrives in the app's handler.
- Race fix: spy fetch records the order of
packages/shade-transport-bridge/tests/bridge.test.ts: same fingerprint regression for SSE, WS, and long-poll bridges (IncomingMessage.fromnon-empty + matches the expected digest).packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts:- senderFp round-trip through put + fetch.
- senderFp omitted on put → fetched row has
senderFp: undefined. - Pre-4.8 schema migration: open a DB seeded with a v4.7
inbox_blobsschema (nosender_fpcolumn), reopen viaSqliteInboxStore, verify the legacy row survives + new writes carry the new field.
Migration
None required for app code. Existing handlers that ignore
raw.from / IncomingMessage.from keep working unchanged. Apps that
want sender-attributed first-contact:
inbox.onIncoming(async (raw) => {
const tentativeAddr = raw.from ? `fp:${raw.from}` : null;
if (!tentativeAddr) return null; // legacy relay; drop
const env = decodeEnvelope(raw.ciphertext);
const plaintext = await shade.receive(tentativeAddr, env);
// pair frame announces real address; reconcile fp:<hex> → real
return null;
});
For Prism specifically: drop the await this.inbox.register()
workaround in apps/web/src/shade/transport.ts and
packages/shade-sidecar/src/transport.ts. inbox.start() on 4.8+
no longer races and the explicit pre-register is redundant.
[4.7.0] — 2026-05-07 — Peer-presence events for instant BroadcastChannel revoke
BroadcastChannel.removeMember (v4.6) is the right primitive for revoking a
paired peer's sender-key membership when, say, a tab closes or a laptop
locks — but until now there was no signal saying "this peer's bridge just
went away". Apps had to fall back to client-side heartbeats:
apps/web/src/shade/heartbeat.ts-style 20s pings + a 10s GC sweep, with a
~45s worst-case revoke window. For a terminal-mirroring product whose
threat model includes "someone takes the unattended laptop", 45s of
legitimate broadcast access for the attacker is too long.
This release surfaces the bridge-connection-lifecycle signal that
createBridgeRoutes already had internally. The inbox event bus now emits
inbox.peer_connected / inbox.peer_disconnected on the 0↔1 boundary
across WS + SSE bridges, and a new /v1/bridge/presence SSE endpoint plus
the PresenceBridge client class let any authenticated SDK subscribe to
presence transitions for a watcher-declared address list. The SDK glue
collapses to ~5 lines:
const sub = await new PresenceBridge({ baseUrl, crypto, signingPrivateKey, address }).subscribe({
watch: paired_peers,
onPresenceChange: (e) => {
if (e.status === 'offline') void channel.removeMember(e.address);
},
});
Reported by Prism — collapses Prism's wave-3 heartbeat-based revoke from ~45s to ~50ms (one network round-trip) for the overwhelmingly common case of a clean WS close.
Added
@shade/inbox-server
InboxServerEventMapgains two new event names:inbox.peer_connected—{ address, bridgeKind: 'ws' | 'sse' }— fires when an address transitions from zero to ≥1 active push-bridge connections.inbox.peer_disconnected—{ address, bridgeKind, reason: 'closed' | 'error' }— fires when the last push-bridge connection for the address closes.
- New
PresenceTrackerclass (packages/shade-inbox-server/src/presence.ts) — per-address connection-count map; emits transitions into a wiredInboxServerEvents. Two parallel bridges (WS + SSE during a fallback handover) collapse into onepeer_connected/peer_disconnectedpair so consumers don't see flicker. createBridgeRoutesnow returns{ app, websocket, presence }so operators / tests can read the live presence map. ApresenceTrackeroption lets multiple route mounts share state.- New
GET /v1/bridge/presenceSSE endpoint:- Auth: signed query
{ address, kind: 'presence', watched: string[], signedAt, signature }against the watcher's registered owner key.kind: 'presence'is bound into the canonical signed payload to prevent cross-endpoint replay against/v1/bridge/{stream,poll,ws}. - On open: emits one
event: presenceSSE frame per watched address with the current online/offline snapshot. - On change: streams
{ address, status, at, via: 'ws'|'sse' }frames filtered server-side to the watcher's address list. - Subscribing does NOT itself count as a peer-bridge connection — a PresenceBridge open will not make the watcher appear online to other watchers.
MAX_WATCHED_ADDRESSES = 64per subscription.
- Auth: signed query
@shade/transport-bridge
- New
PresenceBridgeclass withsubscribe({ watch, onPresenceChange, onError? })returning{ addPeer, removePeer, watching, unsubscribe }. addPeer/removePeermutate the watched set by aborting the current SSE connection so the run loop reopens with a fresh signed query. Mutations are expected to be rare (only on pair / unpair) so the brief reconnect gap is acceptable.- Auto-reconnect with exponential backoff (250ms → 10s, same defaults
as
SseBridge);disableAutoReconnect: truefor tests. signPresenceQueryhelper exported from@shade/transport-bridge/authfor non-PresenceBridge consumers (manual EventSource, observability scrapers, etc.).
Why long-poll is NOT tracked
A long-poll client toggles in/out of /v1/bridge/poll every few seconds,
and treating each request boundary as a presence transition would
dominate the event stream with flapping. Push transports are also the
only ones where a ~50ms revoke window matters — long-poll users are
already on a slow path. Apps that need presence over long-poll continue
to use client-side heartbeats.
Tests
packages/shade-transport-bridge/tests/bridge.test.ts— four blocks covering all acceptance criteria from the request:- (1)
WsBridge.connect()thendisconnect()→ operator'sevents.on(...)seesinbox.peer_connectedtheninbox.peer_disconnectedwithaddress: 'alice',bridgeKind: 'ws'. - (2A) Bob subscribes presence on
[alice]; alice opens a WsBridge → bob'sonPresenceChangefiresonlinewithin 2s. - (3) Bob's
[alice]subscription must NOT receive frames for an unrelatedcaroladdress opening her own bridge. - (4) Alice's bridge reopens after a drop → bob sees
onlineagain on the same subscription. - Plus an
addPeer/removePeerregression that verifies the reconnect-on-mutation path delivers a fresh snapshot for the new address and stops delivering for the removed one.
- (1)
Migration
None. Strict additive — existing InboxServerEvents consumers keep
working unchanged. createBridgeRoutes's return type added a
presence field; destructuring code that names only app, websocket
keeps compiling.
For Prism specifically: drop the wave-3 heartbeat module
(apps/web/src/shade/heartbeat.ts) on the PC sidecar and replace with
a PresenceBridge subscription on the paired-peer set. Keep the
heartbeat as a network-partition fallback if you want a belt-and-
braces revoke story; with presence-events the worst-case revoke window
drops from ~45s to one server→PC round-trip.
[4.6.1] — 2026-05-07 — Browser fetch receiver lost in Inbox and HTTP bridges
Every browser consumer of the v4.6.0 transport stack crashed on the first network call with:
Failed to execute 'fetch' on 'Window': Illegal invocation
@shade/inbox, @shade/transport-bridge (SseBridge, LongPollBridge)
each cached the default globalThis.fetch reference as a class property
and later invoked it as this.fetchImpl(url, …) / this.fetchFn(url, …).
The browser's fetch is a WebIDL bound operation: calling it as a
method on any object other than the Window rejects with the error
above. Node/Bun fetch tolerates a free receiver, so the bug only
manifested in actual browsers and slipped through the SDK test suite.
Reported by Prism (multi-device E2EE terminal) — inbox.start() →
register() → client.register() → this.fetchImpl(url, …) threw on
the first /v1/inbox/register POST, so transport.start() never sent
the pair handshake and the web side timed out after 30s with "PC did
not reply".
Fixed
@shade/inbox — InboxClient constructor
fetchImpl is now (options.fetch ?? globalThis.fetch).bind(globalThis).
A consumer-supplied options.fetch is bound too — a custom fetch with
its own receiver requirements must bind itself; binding to globalThis
is otherwise a no-op for free functions.
@shade/transport-bridge — LongPollBridge and SseBridge constructors
Same binding fix in both. WsBridge was unaffected (uses WebSocket).
Tests
packages/shade-inbox/tests/client.test.ts— installs a strict-receiverglobalThis.fetchthat mimics the WebIDL "Illegal invocation" check, constructsInboxClientwith nofetchoverride, runsregister(), and asserts the strict fetch sawglobalThisasthis. Pre-fix this throws; post-fix it passes.packages/shade-transport-bridge/tests/bridge.test.ts— same regression for bothLongPollBridge.connect()(probe call) andSseBridge.connect()(open-once call).
Migration
None. Existing options.fetch overrides keep working unchanged. Apps
shipping a workaround like
new Inbox({ ..., fetch: globalThis.fetch.bind(globalThis) });
can drop the .bind(globalThis) and the redundant fetch: option once
they're on 4.6.1.
[4.6.0] — 2026-05-07 — Broadcast channels (Signal sender-keys for one-to-many fan-out)
Prism's PC desktop is the sender in a one-to-many fan-out — one PTY
output frame, N paired-device deliveries — and bilateral for (peer of peers) shade.send(peer, frame) works for N ≤ 5 but starts hurting once
the paired fleet grows (3 laptops + phone + tablet + watch = N = 7) and
once mobile cellular is in the loop. The crypto pattern that solves it
is Signal's sender-key: the sender holds a per-channel symmetric
chain key shared with all members, encrypts each message once with
it, and the relay (or the SDK fan-out loop) ships the same ciphertext
to every recipient.
This release lands sender-key broadcast as a scoped "broadcast channel"
primitive in @shade/sdk, with the persistence + wire format + receiver-
side meta.kind === 'broadcast' plumbing wired through every backend.
The crypto in @shade/core/sender-keys.ts was already in place;
v4.6 turns it into a first-class app-facing API.
Added
@shade/sdk
shade.createBroadcastChannel({ label? })→BroadcastChannel— opaque, persisted channel id stable acrossshutdown()/ re-open. Owner role:sender(only the channel creator can broadcast).BroadcastChannel.addMember(peerAddress)— distributes the current sender-key to a paired peer over the existing bilateral ratchet. Returns the wrapped envelope the app delivers; the SDK does the framing inline (no new wire-format changes visible to apps — acceptance criterion (3)).BroadcastChannel.removeMember(peerAddress)— rotates the chain (freshchainKey+ new Ed25519 signing keypair,generation++), destroys the old key material, and returns one envelope per surviving member with the new sender-key. Stale broadcasts at lower generations are silently dropped on receive.BroadcastChannel.broadcast(plaintext)— single AES-256-GCM encrypt with the current chain message key + Ed25519 signature; the SAME envelope is delivered to every member. Returns{ envelope: Uint8Array, members: readonly string[] }so the app's transport handles the per-peer fan-out.BroadcastChannel.members()— snapshot of currently-active members (excludes revoked).shade.getBroadcastChannel(channelId)/shade.listBroadcastChannels()for reconciling app-level pairing state with persisted channel state.shade.acceptBroadcast(envelope)— decrypt an inbound broadcast wire envelope; dispatches toonMessagehandlers withmeta = { kind: 'broadcast', channelId, sender, generation, iteration }.Shade.onMessagehandler signature gained an optional third argmeta?: MessageMeta— back-compat: handlers that ignore it keep working unchanged for direct messages.
@shade/proto
encodeBroadcast(BroadcastWire)/decodeBroadcast(bytes)— wire type0x21. Length-prefixed channelId + senderAddress, u32 generation/iteration, 12-byte AES-GCM nonce, 64-byte Ed25519 signature, length-prefixed ciphertext.inspectEnvelopeTyperecognises'broadcast'.
@shade/core
BroadcastChannelRecord— persisted channel state (chainKey, iteration, signing keys, generation, role).BroadcastMemberRecord— sender-side membership row withjoinedAt- nullable
removedAt.
- nullable
StorageProvidergained six optional methods:saveBroadcastChannel,getBroadcastChannel,listBroadcastChannels,removeBroadcastChannel,saveBroadcastMember,getBroadcastMembers,removeBroadcastMember. Backends < 4.6 throw a clear error when an app tries to callcreateBroadcastChannelagainst them.
Storage backends
MemoryStorage,SQLiteStorage,IndexedDBStorage— plaintextbroadcast_channels+broadcast_memberstables. IDB schema bumps to v2 with an upgrade-path that creates the new stores idempotently.EncryptedSQLiteStorage,EncryptedIndexedDBStorage,EncryptedPostgresStorage—broadcast_channels_enc+broadcast_members_encschemas. The chain key, iteration, and signing-key bundle live in a sealedciphertextblob bound to(table='broadcast_channels', column='broadcast_channel_sensitive', pk=channelId)AAD; routing fields (channelId, ownerRole, ownerAddress, label, generation, timestamps) stay plaintext for queries. New row-codec helperssealBroadcastChannelSensitive/openBroadcastChannelSensitive. IDB schema bumps to v2 the same way.
Tests
packages/shade-sdk/tests/broadcast.test.ts— Prism's three acceptance tests verbatim: (1) two-member receive withmeta.kind === 'broadcast', (1*) revocation rotates + receiver A drops while B keeps working, (2) persistence — channel id, members, and chain advance surviveshutdown()+ re-open from the SQLite path, (3)listBroadcastChannelssurfaces both sender + receiver records correctly.packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts— channel + member round-trip under sealed storage; receiver-side rows correctly persist withoutsigningPrivateKey.
Compatibility
- Wire-protocol additive: existing peers ignore the
0x21envelope type. Apps not using broadcast channels see no behavior change. - Storage schemas additive: the new
broadcast_*tables / object stores are created on first open; migrations from a 4.5 database are no-ops. IDB schema-version bump happens transparently inupgrade.
[4.5.0] — 2026-05-07 — Browser-side encrypted storage + multi-factor unlock
Browser-based Shade clients (Prism's web client being the first) needed
the same at-rest encryption story as the desktop SQLite path: identity,
prekeys, sessions and stream-resume state persisted across reloads,
unwrapped from a user-supplied passphrase — and on browsers, optionally
gated behind a second factor (PIN) since there is no OS-session boundary
to lean on. The existing barrel of @shade/storage-encrypted also
transitively imported bun:sqlite and postgres, which prevented Vite/
webpack/esbuild from producing a clean browser bundle.
This release adds an encrypted IndexedDB backend that mirrors
EncryptedSQLiteStorage byte-for-byte at the AAD/nonce level, exposes
browser-safe subpath imports, and lets KeyManager derive its master
key from low-entropy secrets (argon2id) and from N composed factors
(every factor mandatory).
Added
@shade/storage-encrypted
EncryptedIndexedDBStorage— IndexedDB-backedStorageProviderexposed via@shade/storage-encrypted/idb. One object store per_enctable from the SQLite schema, sealed payloads asUint8Array, routing/timestamp fields kept plaintext for query efficiency. ReusesaeadSeal/aeadOpenand therow-codecsealers verbatim — a row sealed under the SQLite or Postgres backend decrypts under IDB given the sameKeyManager.bumpPeerIdentityVersionis atomic under one IDB transaction (closes the read-then-upsert race the SQLite version has).KeyManager.open({ kind: 'argon2id', ... })— memory-hard KDF for low-entropy secrets (PINs, short passwords). Backed by@noble/hashes/argon2(already a transitive dep — pure JS, browser safe).DEFAULT_ARGON2IDexported (m=64 MiB, t=3, p=1, 32-byte output; ~250–400 ms in modern browsers).KeyManager.open({ kind: 'composite', sources, info? })— HKDF-combine N sub-sources into one master key. Every source is required: omitting or substituting any source yields a different master key andopen()fails on the storage-key-fingerprint check. Order is significant by design ([pwd, pin]≠[pin, pwd]). Composite-of-composite is rejected.- Subpath exports:
@shade/storage-encrypted/crypto(KeyManager + KDF- AEAD + row-codec, no SQLite/Postgres bindings),
/sqlite(Bun),/postgres(Node),/idb(browser). Thebrowsercondition on the default import resolves to a barrel that excludes Bun/Postgres imports —import { KeyManager } from '@shade/storage-encrypted'now bundles cleanly under Vite without hittingbun:sqliteresolution errors.
- AEAD + row-codec, no SQLite/Postgres bindings),
- Dependency:
idb^8.0.3.
Tests
packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts— argon2id determinism + reject paths, composite same-factors → same master, wrong-PIN/wrong-passphrase/order-swap → different master, explicitinfodomain separation, nested-composite rejection.packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts— full round-trip coverage of all 28StorageProvidermethods, fingerprint-mismatch rejection on wrong key, atomic peer-identity bump, plus cross-impl roundtrip withEncryptedSQLiteStorageproving the AAD/nonce derivation is implementation-agnostic.
[4.4.0] — 2026-05-05 — Public accessor for the device's identity public key
Browser-based Shade consumers building enrollment flows had no way to
hand the device's actual Ed25519 identity public key to their own
backend — the key was reachable only via the private
storage.getIdentityKeyPair() call inside Shade. Apps shipped with
placeholder bytes (crypto.getRandomValues(new Uint8Array(32))) that
the backend stored but couldn't verify against, deferring real
cryptographic device binding until the SDK exposed the key.
Added
@shade/sdk
Shade.identityPublicKey: Promise<Uint8Array>— getter returning the local device's 32-byte Ed25519 identity public key. Mirrors thefingerprintaccessor shape. Throws if accessed beforeinitialize(). Reflects the current key afterrotate(); the previous key remains in retired-identities storage for the configured grace period. Usefingerprint(12-group safety number) for human side-channel comparison; useidentityPublicKeywhen handing the raw key to a backend for signature verification or pinning.
Tests
packages/shade-sdk/tests/sdk.test.ts—identityPublicKey exposes the device Ed25519 key and tracks rotationcovers the round-trip match against the underlying storage and that the value updates afterrotate().
[4.3.0] — 2026-05-05 — Browser persistence via @shade/storage-indexeddb
Browser-based Shade consumers had no path to session persistence: the only
storage option that worked outside Node was "memory", so the identity
keypair regenerated on every page load and device:${registrationId}
churned to a fresh address each refresh. Building a StorageProvider
in consumer-land meant 25+ method re-implementations per app and no
shared conformance surface.
4.3.0 ships an official IndexedDB adapter alongside SQLite and Postgres
so any browser-based Shade SDK consumer (dashboards, contact-list apps,
browser-extension messengers) gets persistent identity, prekeys, sessions,
retired identities, peer-verification state and stream-resume rows for
free, surviving tab refresh and browser restart.
Added
@shade/storage-indexeddb (new package)
IndexedDBStorage.create({ dbName? })— async open of an IDB database (one object store perStorageProvidercategory) with schema version 1.dbNamedefaults to"shade"; consumers that run multiple Shade-backed apps on the same origin pass distinct names ("my-app-shade") so the IDB inspector groups them sensibly.- Full
StorageProviderconformance: identity, signed/one-time prekeys, sessions, trusted identities, retired identities (with prune byretiredAt), stream-state save/get/list/prune, peer verifications, and the per-peer identity-version counter. bumpPeerIdentityVersionis wrapped in a single IDBreadwritetransaction — atomic read-modify-write, closing the race window the SQLite adapter currently has on parallelacceptIdentityChangecalls. (SQL adapters will be brought in line in a follow-up.)- Implementation dependency:
idb(Jake Archibald's typed wrapper). Tests run againstfake-indexeddbfor parity with the SQLite test layout.
@shade/sdk
resolveStorage()accepts a fourth spec form:{ type: 'indexeddb', dbName?: string }. Resolution goes through a dynamic import so Node-only consumers don't pull a browser-only adapter into their bundle (same pattern as@shade/storage-postgres).ShadeConfig['storage']now exports a namedStorageSpectype reused byResolvedConfig, replacing the duplicated inline union.
Tests
packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts— full StorageProvider surface (identity, prekeys, sessions, trust, retired identities, persistence across close+reopen) plus an end-to-endShadeSessionManagerconversation that survives a simulated tab reload mid-session.packages/shade-storage-indexeddb/tests/peer-verifications.test.ts— CRUD round-trip, upsert-on-duplicate, identity-version increment invariants, persistence across reopen.
[4.2.1] — 2026-05-04 — Concurrent-ratchet desync under pull-mode drainer
A consumer running shade.files.httpClient(server, { outboundQueueUrl, ... })
alongside parallel RPC traffic against the same peer would, after ~10s of
load, see every subsequent message fail with
DecryptionError: Failed to decrypt message — wrong key or tampered data.
Two bugs combined to cause this; both are fixed in 4.2.1 with regression
coverage.
Fixed
@shade/transfer — OutboundQueue waiter cursor
enqueue woke pending drain waiters with a since=0 snapshot — the
full event log — instead of using the waiter's own since. A poll that
parked at the head and was woken by a fresh enqueue therefore replayed
every event the waiter had already processed. Downstream the queue
fed Shade.acceptTransferEnvelope, so the duplicate replayed an
envelope into manager.decrypt twice. The second decrypt consumed an
already-used skipped key and corrupted the Double Ratchet receive
chain. Each PendingWaiter now records its since cursor and is
delivered only events with id > since.
@shade/core — ratchetDecrypt defense-in-depth
A same-DH message whose counter was already behind the chain — and
that did NOT match a cached skipped key — fell through to a path that
called kdfChainKey on the current (ahead) chain key and then set
chain.counter = message.counter + 1, permanently desyncing the
ratchet so every subsequent decrypt returned wrong-key. Such messages
are now rejected with DecryptionError without any state mutation, so
a downstream replay (transport bug, retry, intermitent network) cannot
poison the session.
Tests
packages/shade-files/tests/integration/concurrent-ratchet.test.ts— 100 parallelhttpClientRPCs while the drainer runs, plus a mixed workload of 50 RPCs + 50 rawshade.senddeliveries with Bob echoing replies through the queue. Both surface the bug pre-fix.packages/shade-transfer/tests/outbound-queue.test.ts— direct regression on the waitersincecursor.packages/shade-core/tests/ratchet.test.ts— replay of an already-decrypted message must throw cleanly without breaking subsequent decrypts on the same chain.
[4.2.0] — 2026-05-03 — Pull-mode streams for browser @shade/files
4.1.0 shipped HTTP RPC for browser clients but capped them at inline
payloads (≤ 256 KiB). Larger reads/writes — mod-jars (1–50 MB),
world-backups (100+ MB), the things that actually need streaming —
threw ConflictError directing callers to the server-to-server
pathway. That made browser-side @shade/files insufficient for
admin-panel-style apps where the client is a browser tab and the
server is a Bun process.
4.2.0 flips the direction: when the browser supplies
outboundQueueUrl + transferBaseUrl, server-to-browser chunks +
control envelopes ride a per-peer queue that the browser long-polls,
and browser-to-server chunks POST directly to the server's existing
chunk-receive routes. No WebSockets, no SSE, no inbound listener on
the browser. Long-polling + a request-response inbound queue is
the entire wire surface.
Added
@shade/transfer
OutboundQueue— per-peer monotonic event log with long-poll semantics.enqueue(peer, event)appends,drain(peer, since, blockMs, signal)returns events withid > since(blocking up toblockMsif none are ready). Idle-eviction GC drops peers that haven't polled inidleEvictionMs(default 10 min). Ring- buffered tomaxEventsPerPeer(default 1000) — overflow drops oldest, receivers pick up the gap via re-resume fromsince=0.QueuedEventdiscriminated union:{ kind: 'envelope', bytes }or{ kind: 'chunk', bytes, meta: { streamId, laneId, seq } }.QueueTransferTransport(implementsITransferTransport) — enqueues outbound chunks instead of POSTing. Returns optimisticChunkAckbecause the queue is the delivery; chunk-resume picks up dropped events on receiver-side reconnect.
@shade/sdk
Shade.transferQueueRoute(opts?)— Hono app with all five routes a pull-mode receiver needs:POST /queue— long-poll the per-peer outbound queue.POST /v1/transfer/:streamId/chunk— receive incoming chunks (browser → server writes).GET /v1/transfer/:streamId/state— resume-state lookup.POST /v1/transfer/control— receive incoming control envelopes (browser → server stream-init / abort).GET /v1/transfer/health— peer reachability probe. Auto-configuresshade.configureTransfers(...)with the queue transport +QueueEnvelopeTransportif not already configured.
Shade.configureTransfers(opts)extended:resolveBaseUrlis now optional whentransportandenvelopeTransportare both supplied (lets pure-queue servers omit the baseUrl entirely). Newtransport?: ITransferTransportoverride slot.QueueEnvelopeTransport—ControlEnvelopeTransportimpl that enqueues outbound envelopes for browser receivers.
@shade/files
createFilesHttpClient(andshade.files.httpClient) accept new options:outboundQueueUrl—/queueendpoint to long-poll.transferBaseUrl— base URL for outbound chunk POSTs and control envelope POSTs (browser → server writes).queueBlockMs— long-poll timeout (default 30 s; server clamps atmaxBlockMs). When set, the client:
- Configures
shade.configureTransfers({ resolveBaseUrl })so outbound chunks POST to<transferBaseUrl>/v1/transfer/.... - Builds a
ClientStreamsBridgeeagerly so the engine's incoming-transfer subscription is in place before the drainer dispatches the first envelope. - Starts a long-poll
startQueueDrainer(...)that pulls queued events and dispatches them viashade.acceptTransferEnvelope.
- Streamed reads (
fs.readof files > 256 KiB) and streamed writes (fs.writeof large inputs) now work end-to-end on the browser client when the queue options are set. startQueueDrainer(shade, opts)exported for advanced consumers that want to drive their own drainer (e.g. service-worker setups that want a single shared drainer across multiplehttpClients).client.close()now stops the drainer and tears down the streams- bridge — important on tab unload to free the long-poll socket.
@shade/files (internal)
ClientStreamsBridgeuses a TransformStream withhighWaterMark: 64instead of the default0so the receive-side write loop doesn't stall on backpressure before the consumer attaches its reader (default HWM stalled at chunk 4 in pull-mode where the drainer races the consumer'sgetReader()call).
Wire contract
POST <base>/queue HTTP/1.1
X-Shade-Sender-Address: alice@example.com
{ "since": 42, "blockMs": 30000 }
────
200 OK
{
"events": [
{ "id": 43, "kind": "envelope", "bytesB64": "...", "timestampMs": 1730... },
{ "id": 44, "kind": "chunk", "bytesB64": "...", "meta": { "streamId": "...", "laneId": 0, "seq": 0 } },
...
],
"nextSince": 47
}
Tests
tests/integration/http-rpc-streams.test.ts — three integration tests:
- 4 MiB streamed read end-to-end via long-poll queue (verifies bytes match the source).
- Inline-only client throws clear error on streamed read.
- Long-poll returns empty events on idle timeout (verifies the
blockMspathway).
Migration
4.1.0 → 4.2.0 is wire-compatible and source-compatible — the
queue route is purely additive. To enable streamed transfers in a
browser app:
// Server
const queue = await shade.transferQueueRoute({ blockMs: 30_000 });
await shade.files.serve(handler);
const rpc = shade.files.rpcRoute({ acceptFirstMessage: true });
const app = new Hono();
app.route('/api/v1/shade-files', queue);
app.route('/api/v1/shade-files', rpc);
// Browser
const fs = shade.files.httpClient(serverAddress, {
rpcUrl: 'https://server/api/v1/shade-files/rpc',
outboundQueueUrl: 'https://server/api/v1/shade-files/queue',
transferBaseUrl: 'https://server/api/v1/shade-files',
});
await fs.write('/mods/some-mod.jar', new Uint8Array(/* 50 MB */));
const result = await fs.read('/backups/world.tar.gz'); // streamed
shade.files.serve(handler, { inlineOnly: true }) is still supported
for HTTP-RPC-without-streams deployments — it skips the streams-bridge
setup entirely.
[4.1.0] — 2026-05-03 — Browser-friendly HTTP RPC for @shade/files
The default shade.files.client(peer) requires both peers to be
mutually addressable over HTTP — the response to a list / read /
etc. round-trips through Shade.deliverControlEnvelope, which POSTs
to the peer's /v1/transfer/control endpoint. That doesn't work
for browsers — a tab can't host an HTTP server, so the server
cannot call back outbound.
This release ships a parallel request-response transport. One POST per
RPC, encrypted envelope in the request body, encrypted response in the
same HTTP response. Mirrors the way @shade/server's
shade-auth-middleware works for prekey writes.
Added
@shade/files
createFilesRpcRoute(shade, handler, options?)— Hono app exposingPOST /rpc. ReadsX-Shade-Sender-Address, decrypts the envelope via the existing ratchet session, dispatches through the attachedFileHandler, encrypts the result, and returns it in the same HTTP response. Transport-level failures (no session, undecryptable, body too big) return JSON{ error }with appropriate 4xx; application- level failures ship encryptedRpcErrorenvelopes.createFilesHttpClient(shade, peer, options)— request-responseFileClientfor browser-style consumers. Each method (list / stat / mkdir / delete / move / getThumbnail / custom / write inline / read inline) does one HTTP POST and parses the encrypted response. No inbound channel required.shade.files.rpcRoute(opts?)— namespace-side getter for the route. Throws if no handler has been attached viashade.files.serve(...)first.shade.files.httpClient(peer, opts)— namespace-side getter for the client.FilesNamespace.serve(handler, { inlineOnly: true })— opt-out flag that skips the streams-bridge setup. Required for HTTP-RPC-only servers (which don't needconfigureTransfers({ resolveBaseUrl })). IninlineOnlymode the channel-based dispatcher is also not attached, so requests are dispatched only by the rpc-route — avoids double-dispatch when a browser client and a server-to-server client share the same Shade instance.ShadeBridge(exported) gains areceive(peer, envelope)member matchingShade.receiveso server-side rpc-route can decrypt inbound envelopes through the structural surface.
Wire contract
POST /rpc HTTP/1.1
Content-Type: application/octet-stream
X-Shade-Sender-Address: alice@example.com
<wire-encoded ShadeEnvelope wrapping JSON-encoded RpcRequest>
────
200 OK
Content-Type: application/octet-stream
<wire-encoded ShadeEnvelope wrapping JSON-encoded RpcResponse | RpcError>
Limitations (v1)
- Inline payloads only (≤ 256 KiB).
writeof larger inputs throwsConflictErrordirecting callers toshade.files.client(peer)on a server-to-server deployment. Streamedreadresults throwInternalFileErrorfor the same reason. - The X3DH first-message must ride the same RPC route — set
acceptFirstMessage: trueonrpcRoute({ acceptFirstMessage: true })when the browser client's first-ever call doubles as the handshake.
Tests
tests/integration/http-rpc.test.ts— round-trip via HTTP (list / mkdir / stat / write / read / delete) plus negative cases (streamed write rejected, missing sender header, empty body, garbage body, body pastmaxBodyBytes,rpcRoute()withoutserve()).
Migration
4.0.x → 4.1.0 is wire-compatible and source-compatible. The HTTP
RPC route is purely additive — no existing code path changes. To
adopt:
// server (was)
await shade.files.serve(handlerConfig);
// server (HTTP-RPC mode)
await shade.files.serve(handlerConfig, { inlineOnly: true });
app.route('/api/v1/shade-files', shade.files.rpcRoute());
// browser client
const fs = shade.files.httpClient(serverAddress, { rpcUrl: '...' });
[4.0.2] — 2026-05-03 — Consumer-strict reader-shape fixes
4.0.1 shipped the tsc --noEmit gate that compiles each package
internally against lib: ["ES2022"]. That gate did not catch types
that only fail when consumer code (running with lib: ["DOM"] +
exactOptionalPropertyTypes) tries to assign a native browser type
into one of our locally-defined narrower types.
This release adds a consumer-strict smoke test to the pre-publish gate and fixes every collision that smoke uncovered.
Fixed
@shade/files
inline-threshold.ts: rewrote the localMinimalReader<T>interface as an explicit disjoint union ({ done: false; value: T } | { done: true; value?: T | undefined }) so it accepts every native reader shape —bun-types(value?: undefined),lib.dom(value?: T), andnode:stream/web. The previous flat shape was rejected by consumer projects withexactOptionalPropertyTypes: truebecause the present-branch requiredvalue: T. Fixes "Type ReadableStreamReadResult is not assignable to { value: Uint8Array | undefined; done: boolean }".client/streams-bridge.ts,server/streams-bridge.ts: stash thesetTimeout(...)return value in a local before calling.unref?.()through an explicit{ unref?: () => void }cast. The previous fluent.unref?.()failed underlib: ["DOM"]because DOM typessetTimeouttonumber, which has no.unrefeven as an optional property.
@shade/sdk
background.ts: samesetTimeout/setInterval.unref?.()fix.
Tooling
- New
tests/consumer-strict/— a tiny "as if I were a downstream app" TypeScript project with its owntsconfig.json:lib: ["ES2022", "DOM", "DOM.Iterable"],types: ["bun-types"],exactOptionalPropertyTypes: true,strict: true,paths-mapped to the workspace'spackages/*/src/index.ts. Three smoke files exercise@shade/files,@shade/sdk, and@shade/key-transparencyagainst the consumer-strict tsconfig. scripts/typecheck-all.tsnow runs the consumer-strict smoke after the per-package internal type-check. Both must pass beforeprepublish:check(and thereforepublish:dry/publish:all) succeeds.
Migration
4.0.1 → 4.0.2 is wire-compatible and source-compatible. No API shape
changed; only internal typing was tightened.
[4.0.1] — 2026-05-03 — Strict-TS publishability fixes
4.0.0 shipped TypeScript source files as the published main /
types, which meant every consumer's tsc had to compile our code
under their own strict settings. Several files only compiled inside
the monorepo (where peer-dep cycles resolve via workspace links and
the lib array doesn't include DOM). This release makes all 24
packages compile cleanly under the strict-flagged tsconfig that ships
with the repo, and wires a bun run typecheck gate into both the
publish:dry and publish:all flows so this category of bug cannot
recur.
Fixed
@shade/key-transparency
- Removed unused imports
IndexAbsenceProof,IndexInclusionProof(src/manager.ts),nodeHash(src/index-tree.ts). IndexProofWireis now exported (was a private type thatnoUnusedLocalsflagged).- Added missing
tsconfig.jsonso the package can be type-checked in isolation.
@shade/sdk
- KT verifier wiring:
fetchLatestSTH()andfetchConsistencyProof()now have explicit return types (Promise<STHWire>andPromise<{ proof: string[] }>) so consumers don't seePromise<unknown>fromres.json(). STHWiretype is now imported from@shade/key-transparency.thumbnail.ts: castglobalThisthroughunknownfirst when reading optional DOM globals (OffscreenCanvas,createImageBitmap) so consumer projects that includelib.domdon't reject our narrower local types as "insufficiently overlapping".
@shade/files
- Broke the
@shade/sdk↔@shade/filesdependency cycle.@shade/filesno longer importsShadefrom@shade/sdk— every callsite uses a new localShadeBridgeinterface defined insrc/integration/shade-bridge.ts. This is the structural surface Shade must satisfy:myAddress,send,onMessage,upload,onIncomingTransfer,getFingerprintFor(required) plusgetObservability,deliverControlEnvelope(optional). The Shade class structurally implements every member, socreateFilesNamespace(this)from the SDK side compiles regardless of how many copies of@shade/sdka consumer's package manager hoists. Fixes "this is not assignable to type 'Shade'" in consumer builds. <ShadeFilesProvider>now takesfiles: FilesNamespaceas an explicit prop instead of readingshade.files. Consumers passshade.files(or anycreateFilesNamespace(...)result for tests) directly.ShadeFileRpcChannel.sendnow raises a clear error whendeliverControlEnvelopeis undefined instead of producing an implicit-undefined-call error at compile time.
@shade/storage-encrypted
- Replaced
KeyUsage(alib.domtype) with a localWebCryptoKeyUsageunion so the package compiles underlib: ["ES2022"]without DOM. - Fixed
tsconfig.jsonrootDirso package-levelbunx tscworks.
@shade/transport-bridge
sse-bridge.ts: castres.body.getReader()toReadableStreamDefaultReader<Uint8Array>so the strict reader-type parity check in the consume loop passes.
@shade/keychain / @shade/dashboard
- Fixed
tsconfig.jsonrootDirandincludeso the packages can type-check standalone (and sovite.config.tsdoesn't get pulled into the dashboard'srootDir).
@shade/widgets
- Removed unused
ThumbnailMimeimport incomponents/transfer/ThumbnailPreview.tsx.
Tooling
- New
scripts/typecheck-all.ts— runsbunx tsc --noEmitagainst every workspace package'stsconfig.jsonand fails if any reports errors. - New
bun run typecheckscript. publish:dryandpublish:allnow runprepublish:check(typecheck+test) before any package is packed or published.scripts/publish-shade.shcalls the typecheck-all gate before invoking the publisher.
Migration
4.0.0 → 4.0.1 is wire-compatible and source-compatible with one
exception:
<ShadeFilesProvider>requires afilesprop. Previously<ShadeFilesProvider shade={shade}>...</ShadeFilesProvider>worked; it now must be<ShadeFilesProvider shade={shade} files={shade.files}>.
No on-disk schema changes. No package-version-pin changes outside
the lockstep 4.0.0 → 4.0.1 bump.
[4.0.0] — 2026-05-03 — General Availability
Shade 4.0 is the first GA-marked release: every plan from V3.1 through V3.12 is merged, the cross-platform vector suite is green on TS + Kotlin, the threat model has been updated to reflect every new surface, and the core stack (X3DH, Double Ratchet, storage encryption, recovery, WebRTC P2P, Key Transparency) has been packaged for external review. Voice and video — the only big-ticket V2.x ask — have been moved to V5.0 so the 4.0 audit can focus on a frozen non-realtime core.
The wire format is 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), not breaking. Apps that have
been running 0.4.x in production move forward by bun add @shade/sdk@^4.0.0
and (optionally) wiring any of the new opt-in surfaces.
Highlights
- External crypto-review-ready. A "review-bundle" (
docs/audit/) ships with this release: links to every protocol spec, the threat model, the cross-platform test corpus, the build instructions, and scope guidance for the auditor. - Migration guide locked in.
MIGRATION.mddocuments the exact 0.3.x → 4.0 path, including the optional opt-ins, the schema superset, and theshade migrate-storageworkflow. - Cross-platform parity gated in CI.
.gitea/workflows/cross-vectors.ymlruns the same vector corpus on TS (bun) and Kotlin (gradle). A divergent KDF label, AAD layout, or wire byte fails the build. - All V.md plans archived.*
docs/V3.1.mdthroughdocs/V3.12.mdand the original V2.1/V2.2/V2.3 backlog now live underdocs/archive/withStatus: Done. Active planning continues indocs/V5.0.md(Voice & Video). - Operator-facing OpenAPI is complete.
packages/shade-server/openapi.yamlnow covers prekey, transfer, KT, inbox, bridge (SSE / long-poll / WS), observer, and the/metrics,/healthz,/readyoperations endpoints — every HTTP surface a 4.0 client can talk to. - Threat-model refresh. Sections 10 (V3.3 fingerprint gates), 11 (V3.11 WebRTC), 12 (V3.8 Web-Worker boundary) are new; the residual- risk table updates the §1 / §2 / §6 entries with the 4.0 mitigations now landed.
What's already in 4.0 (consolidated from 0.4.x)
The detailed CHANGELOG entries below list everything that landed in the 0.4.x series and is now part of the GA baseline:
- V3.2 — At-Rest Storage Encryption (
@shade/storage-encrypted,@shade/keychain,shade migrate-storage). - V3.3 — Fingerprint Gates & Trust UX (
Shade.beforeFirstLargeFile/beforeBackupImport/beforeNewDeviceTrust,<FingerprintCompare />,<FingerprintGate />). - V3.4 — Observability v2 (OpenTelemetry-shaped events,
@shade/observability). - V3.5 — Android parity + cross-platform CI gate.
- V3.6 — Async Store-and-Forward (
@shade/inbox,@shade/inbox-server,InboxPruneTask). - V3.7 — Transport Bridge (
@shade/transport-bridge, SSE + long-poll + WS adapters). - V3.8 — Web Workers Crypto (
@shade/crypto-web/worker). - V3.9 — Rich File Metadata + thumbnails (in
@shade/files). - V3.10 — Social Key Recovery (
@shade/recovery,<RecoverySetup />,<RecoveryRequest />,<RecoveryApprove />). - V3.11 — WebRTC P2P Transport (
@shade/transport-webrtc,MultiTransportFallback). - V3.12 — Key Transparency (
@shade/key-transparency,createPrekeyServerWithKT(...),LightWitness).
Acceptance criteria
- V3.1 → V3.12 merged into
main. - No open critical / high-severity security issues at the time of tagging.
- Cross-platform test vectors green: TS (1000 / 1000) and Kotlin (11 / 11).
- Production-checklist (
docs/PRODUCTION-CHECKLIST.md) is the canonical operator gate. - OpenAPI covers every HTTP surface (
/v1/keys/*,/v1/transfer/*,/v1/kt/*,/v1/inbox/*,/v1/bridge/*,/metrics,/healthz,/ready). - Threat model reflects every new V3.x surface.
0.3.x → 4.0migration documented inMIGRATION.mdand validated against theshade migrate-storageCLI on a real SQLite DB.- Pending external review. A
docs/audit/REVIEW-BUNDLE.mdpointer is shipped; the actual external review window opens after tag.
Migration
See MIGRATION.md § Migrating from 0.3.x to 4.0 (GA).
The short version: bump every @shade/* to ^4.0.0, run
bun install, restart, opt in to the V3.x surfaces you actually need.
No on-disk schema is destructive; no peer wire format changes.
[Unreleased] — Key Transparency (V3.12) + WebRTC (V3.11)
V3.12 — Key Transparency
Verifiable prekey distribution. The prekey server can now run in Key-Transparency mode: every register / delete event is committed to an append-only Merkle log (RFC 6962-style), every bundle-fetch includes an inclusion proof, and every Signed Tree Head (STH) is signed with an operator-controlled Ed25519 key that clients pin out-of-band.
A malicious server that swaps a bundle, splits its view between two clients, or rewrites history is detected by the client's KT verifier or by an independent witness. KT is opt-in on both server and client — existing deployments work unchanged until upgraded.
See docs/V3.12-DESIGN.md for the design notat (threat model,
data-structure choices, freshness model, recovery procedures) and
docs/key-transparency.md for operator + client onboarding.
Added
@shade/key-transparency (new package)
MerkleLog— RFC 6962 append-only hash tree over pre-hashed leaves. In-memory mirror with O(N) leaf storage and O(log N) audit-path / consistency-proof generation.auditPath,recomputeRootFromAuditPath,consistencyProof,verifyConsistencyProof— standalone primitives matching RFC 6962 §2.1.1 and §2.1.2.AddressIndex+verifyInclusionProof/verifyAbsenceProof— lexicographically sorted address commitment with both inclusion and neighbor-pair absence proofs. The index commitment becomes part of every STH soaddress → bundle_hashis auditable, not just the raw event log.SignedTreeHead+signSth/verifySthSignature/canonicalSthBytes/computeLogId— Ed25519-signed commitment to the tree state.log_id = SHA-256(public_key)so a forged STH that claims a different log key is rejected.KTLogManager— server-side orchestration that wiresMerkleLog,AddressIndex, persistentKTLogStore, and STH signing under one serial-mutation API (recordRegister,recordReplenish,recordDelete,publishSTH,buildBundleInclusionProof,buildBundleAbsenceProof,buildConsistencyProof).KTLogStoreinterface +MemoryKTLogStorereference impl. The interface is append-only by contract (noupdate()ordelete()on historical leaves).LightWitness— passive observer that polls a server's/v1/kt/sthendpoint, verifies signature + freshness + consistency, stores observed STHs, and exposescompare(otherSth)for split-view detection. Used by both witness CLIs and (transparently) by the SDK.- Bundle-proof verifiers:
verifyBundleInclusion,verifyBundleAbsence,verifyBundleTombstone. Each re-derives the bundle hash, checks the audit path against the STH root, verifies the index commitment, and confirms freshness. - Errors:
KTError,KTVerificationError,KTSplitViewError,KTStaleSTHError,KTLogIdMismatchError. Mapped toSHADE_KT_*codes. - Wire-format helpers:
ktProofToWire/ktProofFromWire/sthToWire/sthFromWirefor JSON-safe transport.
@shade/server
createPrekeyServerWithKT(...)— convenience that builds the KT service and wires it into the prekey routes in one call.KeyTransparencyService— single-writer wrapper aroundKTLogManagerwith mutex-serialized mutations, cached latest STH, and configurable heartbeat interval (default 10 min).- New routes mounted under
/v1/kt/:GET /v1/kt/log_id— operator's signing public key + log_id.GET /v1/kt/sth— latest signed tree head.GET /v1/kt/sth/:treeSize— historical STH lookup.GET /v1/kt/consistency?from=N1&to=N2— RFC 6962 consistency proof.
POST /v1/keys/registerandDELETE /v1/keys/:addressnow commit to the KT log (when enabled).GET /v1/keys/bundle/:addressreturns aktProoffield on success and on 404 (absence/tombstone).- KT is fully opt-in. Existing deployments are byte-compatible until
keyTransparencyis configured.
@shade/storage-postgres
PostgresKTLogStore— durable KTLogStore on Postgres. Uses three tables (shade_kt_leaves,shade_kt_index,shade_kt_sths) with anBEFORE UPDATE/DELETE/TRUNCATEtrigger onshade_kt_leavesthat blocks any mutation — defense-in-depth against operator error.ensureKTLogTables(sql)exported for embedding.
@shade/transport
ShadeFetchTransportacceptskeyTransparency: KTVerifierOptions. Modes:'observe'verifies when proof present,'observe-strict'requires proof on every response.fetchBundleVerified(address)returns{ bundle, ktSth? }so callers can route the verified STH into aLightWitness.- 404 responses are also verified (absence or tombstone proof) under strict mode.
@shade/sdk
ShadeConfig.keyTransparency— opt-in client config:createShade({ prekeyServer: 'https://shade.example.com', keyTransparency: { mode: 'observe-strict', logPublicKey: KEY_BYTES_32 }, });Shade.getKTWitness()returns the auto-wiredLightWitnessso app code can introspect observed STHs or run manual gossip checks.- The SDK transparently feeds every fetched STH into the witness so split-view detection runs by default whenever KT is on.
Tests
- 76 new tests across the KT stack: hash primitives, Merkle audit
paths, consistency proofs, address-index inclusion/absence proofs,
STH signing, manager orchestration, witness ingest, server-side
HTTP routes, transport-side verification, and an end-to-end
acceptance test that simulates two divergent server views and
asserts a
KTSplitViewErroris raised.
V3.11 — WebRTC P2P Transport
Direct peer-to-peer chunk delivery for @shade/transfer (and therefore
@shade/files) via RTCDataChannel. Signaling — SDP offer / answer +
trickle ICE — rides on top of Shade.send / Shade.onMessage so the
same Double Ratchet that authenticates regular messages authenticates
WebRTC negotiation. Throughput-heavy uploads (multi-MB / multi-GB) skip
the HTTP relay entirely when NAT allows; when traversal fails, the new
MultiTransportFallback([webrtc, http]) demotes back to HTTP within
the configured connect-timeout window without losing any chunks already
in flight. See docs/webrtc.md and docs/V3.11.md.
Added
@shade/transport-webrtc (new package)
WebRtcConnection— per-peer wrapper around anIPeerConnectionplus the single bidirectionalRTCDataChannel(labelshade-transfer/v1). Drives offer/answer/ICE through aWebRtcSignalingChannel; handles the receiver-side dispatch loop for chunk-ack / resume-state / ping-pong / error frames; exposes per-request reqId-correlatedrequest()for the transport layer.WebRtcConnectionManager— per-peer pool with deterministic glare resolution (lexicographic address compare).getOrCreate(peer)returns the live connection or initiates a fresh one; following through a glare-yield is automatic so the user-facing promise resolves to whichever role survives.WebRtcSignalingChannel— multiplexes the four signaling kinds (shade.webrtc-offer/v1,shade.webrtc-answer/v1,shade.webrtc-ice/v1,shade.webrtc-bye/v1) over anyShadeBridge(realShade.send/onMessage, orMemoryShadeBridgefor tests). Non-signaling plaintext is forwarded to a configurablepassthroughhook so consumeronMessagehandlers stay untouched.WebRtcTransferTransport— implements@shade/transfer'sITransferTransportover the managed DataChannel. Encodes chunks into the package's binary wire format, awaits chunk-ack frames matched by 16-byte requestId tokens, and enforces SCTP-friendly backpressure by pollingbufferedAmount(default threshold 4 MiB).IRtcFactoryinterface +nativeRtcFactory()adapter wrappingglobalThis.RTCPeerConnectionfor browsers / Deno / Cloudflare Workers.MemoryRtcFactoryships an in-process WebRTC simulator used by the package's own tests and by@shade/sdkintegration tests.createShadeBridgeFromShade(shade)— turns anyShade-shaped object into aShadeBridge. Callsshade.send(plaintext)to ratchet-encrypt the JSON, thenshade.deliverControlEnvelope(...)(when present) to ship the envelope over HTTP — same path the existing control-plane already uses.- Wire-format constants (
WIRE_CHUNK,WIRE_CHUNK_ACK, etc.) +encode*Frame/decodeFramehelpers exported for adapters that want to interoperate withShadeTransferWsTransport(the wire matches frame-for-frame). - Errors:
WebRtcConnectError,WebRtcDataChannelError,WebRtcSignalingError,WebRtcTimeoutError— all extendTransferTransportErrorsoMultiTransportFallbackautomatically demotes on failure.
@shade/transfer
MultiTransportFallback— N-ary generalisation of the existing two-argFallbackTransferTransport. Constructor takes[{ name: 'webrtc', transport }, { name: 'ws', transport }, ...]; layers are tried in order and demote sticky onTransferTransportError. ExposesactiveName,hasFallenBack,failures(diagnostic log), andonSwitch((from, to) => ...)for observability hooks.
@shade/sdk
Shade.configureWebRTC({ factory, iceServers?, iceTransportPolicy?, bundlePolicy?, connectTimeoutMs?, requestTimeoutMs?, backpressureThresholdBytes? })— opt-in entrypoint. MUST be called before the engine is built (i.e. before the firstupload(),onIncomingTransfer(), ortransferRoute()call). When configured, the engine is wired withMultiTransportFallback([webrtc, http])and the WebRTC manager receives receiver-hooks pointing atengine.receiveChunk/engine.getResumeState.Shade.getWebRtcRuntime(): ShadeWebRtcRuntime | null— diagnostic accessor returning the live signaling channel, manager, transport, andMultiTransportFallbackafterengine()builds.@shade/transport-webrtcis a (optional) peer-dep — projects that don't callconfigureWebRTC()don't pay the install or runtime cost.
Tests
packages/shade-transport-webrtc/tests/— wire-format roundtrips, signaling routing, full memory-factory caller/callee handshake, receiver-hook dispatch (chunk + resume-query), glare convergence, TURN-only configuration plumbing, native-adapter availability smoke test.packages/shade-transfer/tests/multi-fallback.test.ts— N-ary demotion, sticky-after-failure, non-transport-error preservation, empty-list rejection.packages/shade-sdk/tests/webrtc-integration.test.ts— two real Shade instances upload via WebRTC primary; verifies the engine pickswebrtcand never demotes during the run.packages/shade-sdk/tests/webrtc-failover.test.ts— broken-RTC factory provokes connect timeout; SDK demotes to HTTP within the V3.11 5-second SLO without losing chunks.packages/shade-sdk/tests/webrtc-throughput.test.ts— 4 MiB / 4 lanes loopback over WebRTC vs HTTP; integrity match across both transports + diagnostic speedup ratio.
Documentation
docs/webrtc.md— full V3.11 guide (NAT-traversal table, TURN config matrix, connection flow, glare resolution, backpressure, multi-fallback wiring, diagnostics, wire format, limits, migration).packages/shade-transport-webrtc/README.md— package quickstart.- README + CHANGELOG + ROADMAP marked V3.11 as Done.
[Earlier Unreleased] — Social Key Recovery (V3.10)
The biggest UX hole in any E2EE system — "what happens if I lose my
phone?" — closed without a centralized recovery agent. Pick n
guardians from your peers, set a threshold k; any k of them
together can rebuild your identity onto a new device, but k-1 or
fewer cannot. Shamir Secret Sharing over GF(2^8) gates the recovery
key; AES-GCM authentication on the backup blob detects forged
shares; an OOB-confirmed fingerprint gate on the guardian side
blocks social-engineering. See docs/recovery.md and
docs/V3.10.md.
Added
@shade/recovery (new package)
setupRecovery({ shade, guardians, threshold, deliver })— primary-device flow. Generates a 32-byterecoveryKey, encrypts an identity backup under the recoveryKey-derived passphrase viaShade.exportBackup, Shamir-splits the key intonshares, and ships oneshare-depositenvelope per guardian over the existing 1:1 Shade session. Returns a per-guardian delivery report so partial-distribution is recoverable.attachGuardian({ shade, store, approve, deliver })— guardian-side receiver. Wires aShade.onMessagehandler that persists incoming deposits in a caller-suppliedRecoveryStoreand gatesrecovery-requestenvelopes behind a user-drivenapprovecallback. Auto-declines requests for unknown(originalAddress, setupId)pairs.requestRecovery({ shade, originalAddress, setupId, threshold, guardians, deliver })— new-device flow. Sends onerecovery-requestper guardian, collectsshare-grant/share-declinereplies, Shamir-combines the threshold-many grants, and atomically swaps in the restored identity viaShade.importBackup. Forged shares are detected by the AES-GCM tag on the backup blob; the loop tries every threshold-sized subset of grants before giving up.- Pure-TS Shamir Secret Sharing primitives (
splitSecret,combineShares,encodeShare,decodeShare) over GF(2^8) with constant-time table lookups. Exported for advanced callers and hardware-token integrations. MemoryRecoveryStorefor tests + aRecoveryStoreinterface apps implement against IndexedDB / SQLite / AsyncStorage / etc.- Errors:
RecoveryError,RecoveryDeclinedError,RecoveryTimeoutError,RecoveryReconstructionError,RecoveryProtocolError,RecoveryGuardianRejectedError. - Wire protocol:
share-deposit,recovery-request,share-grant,share-declineJSON envelopes carried over Double-Ratchet plaintext.
@shade/widgets
<RecoverySetup />— primary-device guardian-picker + threshold slider, drivessetupRecoveryand exposesformatRecoveryCardfor the user's offline copy.<RecoveryRequest />— new-device widget that displays the temporary fingerprint prominently, drivesrequestRecovery, and reports per-guardian progress live.<RecoveryApprove />— guardian-side widget. Renders the pending request with original-vs-new fingerprint side-by-side and enforces a two-checkbox gate ("matches" + "OOB-verified") before the release button is clickable.createApprovalQueue()— turns theattachGuardian.approvecallback into a deferred queue the widget can consume.
@shade/core
- Bug fix.
initReceiverSessionnow copies thelocalDHKeyPairinto the session so the eventual zeroize on DH ratchet step touches a scratch buffer, not the persisted signed prekey. Pre-V3.10 this corrupted the receiver's signed prekey after the first incoming X3DH from any sender — a bug surfaced by V3.10's multi-sender recovery flow but harmful to any user receiving messages from more than one peer. Regression test inpackages/shade-core/tests/ratchet.test.ts.
Acceptance criteria (V3.10)
- 3-of-5 recovery works end-to-end on two separate Shade
instances. (
packages/shade-recovery/tests/integration.test.ts) - No coalition of
(k-1)guardians can reconstruct therecoveryKey(verified withfast-checkproperty tests). (packages/shade-recovery/tests/shamir.test.ts,tests/adversarial.test.ts) - Guardian-side widget requires fingerprint-confirmation before sending a share. Two-checkbox enforcement + symmetric tests of both honest-OOB-confirm and hostile-fingerprint-mismatch paths.
[Unreleased] — Web Workers Crypto (V3.8)
Big in-browser uploads stay smooth: AES-GCM, HKDF, HMAC, X25519, Ed25519
and full per-lane stream state now run in a dedicated Web Worker. The
main thread only buffers and forwards plaintext slices over zero-copy
postMessage; lane keys never cross the thread boundary. Opt-in via
shade.configureWorkerCrypto({ workerUrl }). See docs/web-workers.md
and docs/archive/V3.8.md.
Added
@shade/crypto-web
WorkerCryptoProvider— drop-inCryptoProviderproxy that forwards every async op to a dedicated Web Worker via theworker-protocol. Sync helpers (randomBytes,randomUint32,constantTimeEqual,zeroize) execute on the calling thread — no useless round-trips.createWorkerCryptoProvider({ workerUrl, idleTimeoutMs?, spawn? })factory. Spawns lazily, completes a protocol-version handshake, and self-terminates after 30 s (configurable) of inactivity. Idempotent re-spawn on next call.WorkerStreamSender/WorkerStreamReceiver— main-thread handles onStreamSender/StreamReceiverinstances that live entirely inside the worker. Plaintext is shipped via transferableArrayBuffers; lane keys + running sha256 stay worker-side.createEncryptStream/createDecryptStream— TransformStream factories.pipeThrough(encryptStream)consumes plaintext and emits one wire-encodedstream-chunkenvelope per write. Both expose alaneSha256promise that resolves once the stream finishes.- New subpath export:
@shade/crypto-web/workeris the dedicated module-worker entrypoint. Bundle with the standardnew URL('@shade/crypto-web/worker', import.meta.url)idiom. rotate()anddestroy()lifecycle controls — call after identity rotation to bound the worst-case duration any lane key sits in worker memory.
@shade/sdk
shade.configureWorkerCrypto({ workerUrl, idleTimeoutMs? })— opt-in setup. Without it,encryptStream/decryptStreamthrow a clear error pointing to the docs.shade.encryptStream({ streamId, streamSecret, laneId?, chunkSize? })→{ stream, laneSha256 }— TransformStream with an end-of-stream sha256 promise for end-to-end integrity proofs.shade.decryptStream(...)— inverse. Strict in-order seq, AAD-bound AEAD, replay-rejecting.shade.getWorkerCrypto()— direct access to the worker-backedCryptoProviderfor one-off heavy ops.shade.shutdown()now alsodestroy()s the worker provider.
Acceptance criteria (V3.8)
- 100 MB upload in Chrome without blocking the main thread
> 16 ms in P99 (verification recipe in
docs/web-workers.md#verifying-main-thread-budget). - Safari works at default chunk-size — every
postMessagecarries ≤ 256 KiB + AEAD overhead, far below Safari's transferable cap. - Worker terminates within 30 s of last use (default
idleTimeoutMs), and re-spawns transparently on the next call.
[Unreleased] — Transport Bridge (V3.7)
A canonical fallback chain for clients that cannot or will not run a
WebSocket: SSE primary, long-poll secondary, plus a thin WS adapter for
the happy path. All three transports surface the same IncomingMessage
shape so application code stays portable across browser-extension,
edge-runtime, and proxy-locked environments. See docs/transport.md
and docs/archive/V3.7.md.
Added
@shade/transport-bridge (new)
IncomingMessage—{ from, bytes, receivedAt, msgId? }— single shape across every transport.BridgeTransport—connect({ onMessage }) → disconnect()contract.WsBridge,SseBridge,LongPollBridge— three concrete transports consuming the matching/v1/bridge/{ws,stream,poll}endpoints.FallbackBridgeTransport— sticky-after-first-success priority chain. ExposesactiveKindandattemptsfor observability.signBridgeQuery— Ed25519-signed query-string builder (the only carrier that survivesEventSource's no-headers restriction).- Auto-reconnect with exponential backoff for WS + SSE;
Last-Event-IDcursor resume for SSE; bounded one-outstanding-request loop for long-poll.
@shade/inbox-server
createBridgeRoutes({ store, crypto, events, … })returns{ app, websocket }.GET /v1/bridge/stream— SSE feed, one envelope perevent: envelope. Heartbeats every 15 s as: pingcomments.GET /v1/bridge/poll?timeoutMs=…— long-poll, default 25 s server hold under typical proxy idle cutoffs, hard cap 55 s.GET /v1/bridge/ws— Bun-WebSocket upgrade, JSON frame per envelope.
- Push-style delivery via
InboxServerEvents(inbox.blob_stored); falls back to a 1 s polling timer when no events emitter is wired. - Cross-endpoint replay-protected:
kindis bound into the canonical signed payload so a/pollsignature cannot reach/stream.
@shade/server standalone container
- Bridge routes mount on the same Hono app + Bun.serve as the prekey and inbox routes — no extra port, no extra env vars.
Acceptance criteria (V3.7)
- Same "send 100 small messages" suite passes on WS, SSE, and long-poll.
- Client that starts with WS and is blocked by proxy continues automatically via SSE — and on through to long-poll if SSE is also blocked — without message loss.
- Long-poll fallback uses no more than one outstanding request per client.
[Unreleased] — Async Store-and-Forward (V3.6)
A dedicated relay (@shade/inbox-server) holds ciphertext blobs with TTL
- auth so a sender can deliver to an offline recipient. Server stores
only
address || msgId || ciphertext-bytes || expires_at; the prekey server stays public-keys-only, and the relay never holds plaintext or private keys. Seedocs/inbox.mdanddocs/archive/V3.6.md.
Added
@shade/inbox (new)
Inbox— high-level orchestrator. Buffers outgoing PUTs in a durable queue, polls + acks incoming blobs, and exposesonMessageQueued(handler)(the vendor-neutral push-trigger hook mandated by V3.6) andonIncoming(handler).InboxClient— low-level HTTP client (register,put,fetch,ack,unregister).OutgoingQueueStoreinterface +MemoryOutgoingQueueStoredefault — swap in a SQLite/IDB backend so queue survives a process restart.CursorStoreinterface +MemoryCursorStoredefault for the receive cursor.computeMsgId(ciphertext)helper —lowercase-hex(sha256(ciphertext)).
@shade/inbox-server (new)
createInboxServer({ crypto, store, ... })Hono app exposing:POST /v1/inbox/register— TOFU bind address ↔ signing key.DELETE /v1/inbox/register/:address— signed unregister.POST /v1/inbox/:address— signed PUT, idempotent on(address, msgId), rejects mismatchedmsgId !== sha256(ciphertext)and bodies pastmaxBlobBytes(default 1 MiB) or per-recipient quota (default 1000).POST /v1/inbox/:address/fetch— signed challenge, cursor-paginated.DELETE /v1/inbox/:address/:msgId— signed ack.
InboxStoreinterface +MemoryInboxStoredefault.InboxPruneTask— periodic prune of expired blobs (cron, default 5 min).InboxServerEvents— structural-only event emitter for observability.
@shade/storage-sqlite
SqliteInboxStore—(address, expires_at)+(address, received_at)+(expires_at)indexes.SHADE_INBOX_DB_PATHenv var for the file path.
@shade/storage-postgres
PostgresInboxStore— concurrent-safe viaINSERT … ON CONFLICTand a per-rownextval('shade_inbox_seq').ensureInboxServerTables(sql)is exported for embedded deployments.
@shade/server standalone container
- Inbox routes mount alongside prekey routes on the same Hono app.
- New env vars:
SHADE_INBOX_DB_PATH,SHADE_INBOX_PG_URL,SHADE_INBOX_PRUNE_INTERVAL_MINUTES. IfSHADE_INBOX_PG_URLis unset the inbox falls back toSHADE_PREKEY_PG_URL(single Postgres deploy).
Acceptance criteria (V3.6)
- Sender → recipient with no online overlap; payload < 1 MiB; first poll after recipient startup pulls the queued message.
- Server-DB dump exposes no plaintext and no sender-recipient graph beyond byte-pair sizes (sender pubkey is per-PUT TOFU; only the recipient address is persisted).
- Replay of PUT with the same
msgIdreturns 200 withidempotent: trueinstead of 409, and no second row is written.
[0.4.0] — 2026-05-02 — Fingerprint Gates & Trust UX (V3.3)
Blocking verification gates for the handful of operations where MITM risk
is real. Apps stay alert-fatigue-free for ordinary chat, but upload()
of a large file, importBackup(), and acceptIdentityChange() now run
through user-registered handlers before they touch anything sensitive.
See docs/trust-ux.md and docs/archive/V3.3.md.
Added
@shade/sdk
Shade.beforeFirstLargeFile(threshold, handler)— gate runs inupload()when the file size meets the threshold (default 10 MiB) and the peer is unverified.Shade.beforeBackupImport(handler)— gate receives the fingerprint of the identity embedded in the backup blob, before any state is written.Shade.beforeNewDeviceTrust(handler)— gate runs fromShade.acceptIdentityChange(). The peer's identity-version is bumped first, so any prior verification automatically goes stale.Shade.beforeInboxFanout(handler)— reserved hook for V3.6 fan-out; apps can register today.Shade.markPeerVerified(address)/isPeerVerified(address)/unmarkPeerVerified(address)— manual control over persisted verification state.decryptBackup/applyBackupPayload— split of the backup pipeline so callers can inspect a backup's identity fingerprint before writing.- New
FingerprintGateRegistryexported for advanced integrations.
@shade/core
FingerprintNotVerifiedError(HTTP 403) — raised when a gate handler returnsfalse, throws, or is missing in environments that policy- forbid TOFU.PeerVerification+PeerVerificationSourcetypes and storage methods onStorageProvider:savePeerVerification,getPeerVerification,removePeerVerification,getPeerIdentityVersion,bumpPeerIdentityVersion.
Storage backends
MemoryStorage,SQLiteStorage,PostgresStorage,EncryptedSQLiteStorage,EncryptedPostgresStorageall carry the newpeer_verifications+peer_identity_versionstables.
@shade/widgets
<FingerprintGate peerAddress=... />— render-prop wrapper that blocks children until the peer's safety number is verified at the current identity-version. SSR-safe; ships a default fallback with "Copy OOB text" + "I have verified" actions.<FingerprintCompare onVerified=... />— existing widget extended with the same two actions when wired to a callback.formatOobText(peerAddress, fingerprint)helper exported.
Changed
@shade/sdkversion bumped to 0.4.0 alongside all packages (lockstep per ROADMAP convention).
Migration
- No breaking changes. Apps that don't register gate handlers get
warning-mode TOFU automatically (
'tofu-after-warning'source on the persisted verification). To upgrade to hard gates, register handlers for the operations you use. Existing<FingerprintCompare />calls keep working.
[0.3.0] — 2026-05-02 — Shade Files
E2EE filesystem RPC primitive — drop-in entrypoints for any consumer that wants to expose a filesystem (or filesystem-like surface) over Shade. Apps keep their own UI; this layer ships the typed RPC, the streams bridge for content I/O over 256 KiB, and production hooks (rate limit, retention, fingerprint gate, metrics).
Added
@shade/files (NEW)
- Standard ops:
list,stat,mkdir,delete,move,read,write,getThumbnail— Zod-validated wire schemas + clean user-handler types. - Custom ops:
client.custom('app.foo', {...})with full type-safety via TypeScript declaration merging onCustomOpsMap+ per-op Zod schemas registered server-side. - Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams (> 256 KiB)
ride
@shade/transferwith automatic correlation viauserMetadata.shadeFilesWriteId/shadeFilesReadStreamId. - Directory ops:
walk(path, opts)async-iterable depth-first walker;uploadDirectory()/downloadDirectory()with bounded concurrency pool (default 4, cap 16), aggregated progress events, abort support. - Production hooks (all callback-based, vendor-neutral):
- Rate limit: token-bucket per sender, op-cost + byte-quota,
FsRateLimitError/QuotaExceededErrorwithretryAfterMs. - Idempotency cache: per-sender LRU + TTL, in-flight de-dupe,
periodic prune via
BackgroundHooks.onPruneFiles. - Path policy: built-in traversal hardening, percent-decode,
forbidden-bytes check, root-scope, symlink toggle,
extrapredicate. - Fingerprint gate:
requireFingerprintVerifiedFor(ctx)→'required' | 'optional' | 'reject'+isFingerprintVerified(sender). - Signature verification: pluggable
verifySender(sender, canonical, sig)with replay-window enforcement (±5 minsignedAtskew rejected). - Metrics:
onMetric(name, value, tags)with standard names (shade_files_op_duration_ms,_op_total,_bytes_in/out,_idempotency_hit/conflict_total,_rate_limit_reject_total,_fingerprint_reject_total,_signature_reject_total).
- Rate limit: token-bucket per sender, op-cost + byte-quota,
- React hooks (subpath import
@shade/files/react):<ShadeFilesProvider>,useShadeFiles,useFileList,useFileTransfer/useFileUpload/useFileDownload. SSR-safe; no UI components — apps bring their own. - High-level entry:
Shade.files.serve(handler)andShade.files.client(peer)in@shade/sdk. Lazy + memoized; one handler per Shade instance. - Drop-in adapter:
createMemoryDirectory()for tests; structurally compatible with browserFileSystemDirectoryHandle.
Wire format bump
@shade/protowire VERSION bumped from0x01to0x02. Length prefixes changed from u16 to u32 — previous limit was 64 KiB ratchet payloads, which blocked inline file ops up to 256 KiB. Wire-incompatible with 0.2.x peers. New sessions only.- Cross-platform Kotlin port (
android/shade-android) updated to match.
Concurrency safety
ShadeSessionManager.encrypt/.decryptnow run under per-peer mutex. Previously, concurrent decryptions of the same peer raced ratchet state (manifested as sporadicFailed to decrypt — wrong key or tampered dataunder load). Encrypt was already serialized viaShade.send'sencryptChains; decrypt is now serialized at the manager layer too.
@shade/streams extension
StreamMetadatagets optionaluserMetadata?: Record<string, string>— application-level key/value pairs that round-trip verbatim throughstream-initplaintext. Used by@shade/filesfor write/read correlation but available to any consumer.
@shade/sdk extension
Shade.filesgetter (lazy + memoized).BackgroundHooks.onPruneFiles?: () => void+ periodic timer (default 5 min) for@shade/filesretention.BackgroundTasks.setHook(name, fn)for runtime hook registration.
Examples
examples/08-files-browser/— three-process demo (prekey + Bob server + Alice CLI) covering list/stat/mkdir/delete/upload/download with both inline and streamed paths.
Tests
- 100+ new tests across
tests/{unit,integration,security}/in@shade/files. End-to-end coverage for streams I/O up to 1 MiB, custom-op registration + Zod validation, fingerprint-gate rejection, replay-window enforcement, idempotent retries, rate-limit + quota enforcement, walk- bulk transfer aggregated progress.
[0.2.0] — 2026-05-01 — Shade Streams
E2EE chunked upload/download with parallel lanes, resumable transfers, and a
"magic drop-in" UX for any Shade-using app. Adds two new packages
(@shade/streams, @shade/transfer) and extends @shade/sdk and
@shade/widgets with high-level transfer APIs.
Added
Streams crypto layer (@shade/streams)
- HKDF stream/lane key derivation (
deriveStreamKey,deriveLaneKey) - Deterministic AES-GCM nonce construction
nonce = laneId(4) || seq(8) - Streaming SHA-256 via
@noble/hashes/sha2.jsfor memory-bounded integrity StreamSender/StreamReceiverper-lane state machines with strict in-order seq + replay detection (StreamReplayError,StreamOutOfOrderError,StreamDecryptionError,StreamProtocolError)MultiLaneSender/MultiLaneReceivercoordinators for parallel transfers- Range and round-robin partitioning helpers (
planRangePartition,planRoundRobinPartition,chunkRange) - Wire format: new envelope type
0x11(stream-chunk) in@shade/proto, control envelopes (stream-init/-finish/-abort/-resume-*) ride existing0x02ratchet messages with JSONkinddiscriminator
Transfer orchestration (@shade/transfer)
TransferEngine— single class wrapping outgoing + incoming lifecycle- Default
ShadeTransferHttpTransportfor chunk POSTs, opt-inShadeTransferWsTransportwithFallbackTransferTransportfor auto-fallback createTransferRoutes()Hono factory mounts/v1/transfer/*routes (chunk,state,health)IControlChannel+MemoryControlChannelfor in-process testing; the SDK providesShadeControlChanneloverShade.send/receive- Resume protocol:
MemoryResumeStore,StorageBackedResumeStore,deriveDeviceKey()for at-rest streamSecret encryption,engine.resumeUpload(streamId, freshInput)for kill-restart-verify flows ProgressTrackerwith EMA-smoothed throughput + ETA- Retry/backoff (
withRetry) with exponential delay + jitter - Error hierarchy:
TransferError,TransferAbortError,TransferIntegrityError,TransferProtocolError,TransferOfflineError,TransferResumeError,TransferTransportError
SDK (@shade/sdk)
Shade.upload(opts)— high-level entry; encrypts + chunks + shipsShade.onIncomingTransfer(handler)— receiver-side subscriptionShade.transferRoute()— Hono router to mount on the consumer's HTTP serverShade.acceptTransferEnvelope(from, env)— low-level entry for custom transportsShade.resumeUpload(streamId, freshInput)— pick up an interrupted transferShade.listTransfers(filter?)— list resumable / active transfers from storageShadeTransferAuthenticator— Ed25519-signing authenticator for HTTP/WS transportsShade.onMessage(handler)now acceptsPromise<void>-returning handlers (awaited in sequence) — supports flow-control over the control plane
Storage (all backends)
- New optional
StorageProvidermethods:saveStreamState,getStreamState,removeStreamState,listActiveStreamStates,pruneStreamStates. Existing v0.1.x providers compile cleanly (optional methods) - SQLite (
stream_statetable) and Postgres (shade_stream_statetable) schemas with at-rest encrypted streamSecret MemoryStorageextended with in-memory stream-state map
Widgets (@shade/widgets)
<ShadeRuntimeProvider runtime={shade}>— separate React context for upload/download widgets (distinct from the observer-dashboard<ShadeProvider>)useShadeUpload()/useShadeDownload()headless hooks<ShadeUploader />/<ShadeDownloader />composite components with render-prop pattern for full UI replacement- Sub-components:
<DropZone />,<TransferRow />,<ProgressBar />,<SpeedReadout />,<ETAReadout />,<LaneIndicator /> - Theme-token additions for progress, drop zone, and lane indicator colors
Security properties
- Per-chunk AES-256-GCM with deterministic nonce; AAD binds
streamId || laneId || seq || isLastso any header tamper invalidates AEAD - streamSecret never on the wire in plaintext — shipped via Double Ratchet control envelope; lane keys derived locally and never transmitted
- Resume state encrypted at rest with
deviceKeyderived from identity's signing private key (rotation invalidates in-flight resume — by design) - Receiver enforces strict in-order seq per lane (
StreamOutOfOrderError,StreamReplayError); finish-time integrity check verifies per-lane sha256- overall sha256 over original byte order
Tests added (118 new across 47 files; 444 total)
- Unit: KDF, nonce, AEAD, streaming SHA, sender/receiver, partition
- Integration: 1/4/16-lane parity, range vs round-robin parity, Bun.serve loopback at 100 KiB / 1 MiB / 8 MiB, two real Shade instances end-to-end at 64 KiB / 512 KiB / 4 MiB
- Resume: kill-restart-verify on 256 KiB with 4 lanes
- WS fallback: WS connect failure → transparent HTTP completion
- Tamper: bit-flip ciphertext / tag / header field; replay; out-of-order
- Wire: 0x11 envelope encode/decode roundtrip + edge cases
Backward compatibility
Shade.send/receive/onMessage/fingerprint/rotateunchanged (onMessagewidened to support async handlers — sync handlers still work)- Existing wire types
0x01(PreKeyMessage) /0x02(RatchetMessage) unchanged StorageProviderinterface extension uses optional methods@shade/streamsand@shade/transferare new packages; no migration
[1.0.0] — 2026-04-10
First production release
Shade implements the Signal Protocol (X3DH + Double Ratchet) as a standalone, audit-friendly E2EE library for TypeScript/Bun.
Added
Core protocol
- X3DH key agreement (X25519 + Ed25519, supports asynchronous bundles)
- Double Ratchet with forward secrecy and post-compromise recovery
- Skipped message key cache for out-of-order delivery (max 1000 per chain)
- Header-bound AAD on AES-256-GCM encrypts (tampered headers fail decryption)
- Memory zeroization of message keys, chain keys, root keys, and DH private keys after use
Storage
MemoryStorage(in-memory, for tests/embedded)SQLiteStorage(@shade/storage-sqlite) — bun:sqlite, WAL mode, crash-safePostgresStorage(@shade/storage-postgres) — Drizzle, FOR UPDATE SKIP LOCKED- All backends survive container restarts and SIGKILL
- Identity history with 7-day grace period for rotation
Prekey server (@shade/server)
- Hono-based REST API with self-authenticated registration (Ed25519 signatures)
- Anonymous bundle fetches (read-only)
- Per-IP and per-identity rate limiting (token bucket)
- Address validation (NFKC normalization, alphanumeric +
:_-.) - ±5 minute replay window on signed requests
- Health endpoints (
/health,/healthz,/ready) - Prometheus metrics (
/metrics) - Structured JSON logging
- Graceful shutdown on SIGTERM/SIGINT
- Production Dockerfile with non-root user, healthcheck, multi-stage build
- docker-compose.yml example for Dokploy
Session manager (@shade/core)
ShadeSessionManagerhigh-level API (encrypt,decrypt,initSessionFromBundle)getIdentityFingerprint()— Signal-style 60-digit safety numbersensurePreKeyStock()— auto-replenish when below thresholdresetSession()andacceptIdentityChange()for recovery scenariosrotateIdentity()with archived previous identities
Transport (@shade/transport)
ShadeFetchTransport— HTTP client for the prekey server with auto-signingShadeWebSocket— WebSocket wrapper with transparent encrypt/decrypt
Wire format (@shade/proto)
- Compact binary encoding (significantly smaller than JSON)
- Length-prefixed byte arrays, big-endian integers
- Version-tagged envelopes for forward compatibility
Cryptographic hardening
constantTimeEqual(XOR-accumulator, no early exit)randomUint32via crypto.getRandomValues (no Math.random)- Timing-attack regression test
- Constant-time trust verification in all storage backends
Errors
- Stable
SHADE_*error codes errorToHttpStatusfor consistent HTTP mappingtoJSON()for network serialization- 14 specific error types (Validation, Network, Storage, RateLimit, etc.)
Documentation
- README, SECURITY.md, THREAT-MODEL.md
- 5 runnable examples (basic conversation, prekey server, WebSocket tunnel, identity verification, Dokploy deployment)
- Per-package READMEs
- Inline TSDoc throughout
Testing
- 195+ tests across all packages
- Crash recovery integration test
- Cross-platform PostgreSQL tests (skip without
SHADE_TEST_PG_URL) - CI workflow with PostgreSQL service
- Benchmark suite
Security properties
- Forward secrecy
- Post-compromise security
- Authenticated identity verification
- Replay protection
- Constant-time secret comparisons
- Memory zeroization (best-effort)