Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
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>
2578 lines
123 KiB
Markdown
2578 lines
123 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to Shade are documented in this file.
|
||
|
||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
## [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` (reuses
|
||
`initSenderSession`/`initReceiverSession` verbatim), and
|
||
`StreamRatchet` — an in-memory `seal`/`open`/`close` holder on its
|
||
own op-mutex, **never persisted**, zeroized on close.
|
||
- `ShadeSessionManager.beginStream` / `acceptStream` custody 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.closed` events.
|
||
|
||
**`@shade/proto`**
|
||
|
||
- Wire types `STREAM_OPEN` (0x31), `STREAM_OPEN_ACK` (0x32),
|
||
`STREAM_FRAME` (0x33) with encode/decode + `inspectEnvelopeType`
|
||
extension. A `STREAM_FRAME` carries 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)`
|
||
returning `ShadeStream` (`handshakeFrame` / `handleHandshake` /
|
||
`seal` / `open` / `close`). Transport-agnostic like `send`/`receive`;
|
||
auto-establishes the parent session if missing. Independent of the
|
||
per-peer encrypt/decrypt queues and the stored parent session (R5).
|
||
Identical on the `sqlite:` 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||tag`
|
||
format with `shade-profile-aad-v1:<slotIdHex>` AAD),
|
||
`ed25519PublicKeyFromSeed`, `slotIdToHex`. Lives under `no.zyon.shade.blob`.
|
||
- `BlobClient` HTTP 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 TS `signPayload` output.
|
||
- `Profile` high-level namespace (`createProfileNamespace`) — bundles
|
||
KDF + AEAD seal/open + BlobClient calls into the same shape as
|
||
`@shade/sdk`'s `createProfileNamespace`.
|
||
- V4.10 approval helpers ported: canonical profile schema
|
||
(`CanonicalProfileBlob`, `ProfileHostEntry`, `ProfileClientEntry`)
|
||
with parse/serialize/upsert/setTrustedApprover mutators that
|
||
re-derive `trustedApproverFingerprints[]` invariantly.
|
||
`buildApprovalRequest`, `signProxyApproval`, `verifyProxyApproval`,
|
||
and the load-bearing `canonicalApprovalSigningBytes` (length-prefixed
|
||
u16 BE UTF-8). All under `no.zyon.shade.approval`.
|
||
- Password KDFs: `deriveMasterKey` (scrypt) + `deriveMasterKeyArgon2id`
|
||
via Bouncy Castle. NFKC-normalize string inputs to match TS.
|
||
- New `SessionStateJson` serializer for at-rest persistence of
|
||
`IdentityKeyPair` / `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 `CrossPlatformVectorTest` so 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_STRONG` gating only (Class 3 assurance) — explicitly
|
||
excludes `DEVICE_CREDENTIAL` so 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 around `BiometricPrompt`.
|
||
Tagged exceptions (`BiometricCancelledException` /
|
||
`BiometricFailedException`) so callers handle UX without writing
|
||
callback boilerplate.
|
||
- `KeystoreStorage` — `StorageProvider` impl over `SharedPreferences`
|
||
with each row AES-GCM-encrypted under the keystore key. AAD = the
|
||
pref key string so a substituted-prefs swap fails to open. Exposes
|
||
`unlock(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-android` for protocol types and on
|
||
`androidx.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: `CanonicalProfileBlob` with
|
||
`hosts[]`, `clients[]`, and a denormalized
|
||
`trustedApproverFingerprints[]`. `parseCanonicalProfile` /
|
||
`serializeCanonicalProfile` round-trip JSON; mutators
|
||
(`upsertHost`, `upsertClient`, `setTrustedApprover`,
|
||
`removeClient`, ...) are immutable and re-derive the
|
||
denormalized list on every change so it can't drift.
|
||
- `ProfileClientEntry` stores both `identityPublicKey` (32-byte hex,
|
||
used by `verifyProxyApproval`) and `identityFingerprint` (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'`)
|
||
and `ProxyApprovalFrame` (`kind: 'linkApproveByProxy'`).
|
||
`buildApprovalRequest` mints a 128-bit hex `requestId` and a
|
||
configurable expiry (default 5 min).
|
||
- `signProxyApproval` / `verifyProxyApproval` use 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 uses
|
||
`prism-link-approve-v1`) override via `domain`. The frame carries
|
||
the domain so a verifier rejects mismatch before signature check.
|
||
- `verifyProxyApproval` returns 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.
|
||
- `isTrustedApprover` cross-checks the per-client `trustedApprover`
|
||
flag AND the denormalized `trustedApproverFingerprints[]`. 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`. The `verifyProxyApproval`
|
||
signature accepts the blob as a parameter — caller controls
|
||
freshness. One extra `Profile.get` RTT 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 `BlobStore` interface + `MemoryBlobStore` reference impl. Per-slot
|
||
layout: `(slotId, ownerPubkey, blob, etag, updatedAt)`. ETag is
|
||
monotonic per process, clamped against `Date.now()` so values are
|
||
unique and roughly time-ordered for ops.
|
||
- New `createBlobRoutes(store, crypto, options)` mounting
|
||
`GET / 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.
|
||
- `createInboxServer` now also mounts the blob primitive on the
|
||
same Hono app — pass `{ blobStore: null }` to opt out.
|
||
|
||
**Storage backends**
|
||
|
||
- `SqliteBlobStore` (`@shade/storage-sqlite`) — single-table
|
||
`shade_blob_slots` with 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_PATH` overrides (default `/data/shade-blob.db`).
|
||
- `PostgresBlobStore` (`@shade/storage-postgres`) — uses
|
||
`nextval('shade_blob_seq')` so etag ordering is strict across
|
||
multi-instance deployments. CAS path holds `FOR UPDATE` on the
|
||
read so the txn serializes against concurrent writers. New
|
||
`ensureBlobServerTables()` exposed for ops.
|
||
|
||
**SDK (`@shade/sdk`)**
|
||
|
||
- New `createProfileNamespace({ baseUrl, crypto, masterKey, app })`
|
||
high-level wrapper. Computes slotId, blobKey, signing seed
|
||
deterministically; AEAD-seals plaintext with `AAD = "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 / deriveBlobSigningSeed`
|
||
exposed from `@shade/storage-encrypted/crypto` so apps that want a
|
||
custom flow (skip the AEAD wrapper, hit a non-Shade relay) don't
|
||
reimplement the info-string conventions.
|
||
- `app` namespace 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 `BlobStore` mirroring the inbox-store selection chain:
|
||
`SHADE_BLOB_PG_URL` > `SHADE_BLOB_DB_PATH` > shared
|
||
`SHADE_PREKEY_PG_URL` > memory. `SHADE_DISABLE_BLOB=1` opts out.
|
||
Honors `SHADE_DISABLE_RATE_LIMIT` for 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=0` when `delivered > 0`. The 15 s timer is reserved for
|
||
rounds where every attempt failed (no progress, avoid tight retry
|
||
loop).
|
||
- Concurrent `scheduleFlush` calls during an in-flight flush are
|
||
detected via `flushOnce` returning `null`; 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
|
||
via `Promise.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` /
|
||
`maxAttempts` semantics are identical to V4.8.4.
|
||
|
||
### Tests
|
||
- `packages/shade-inbox/tests/client.test.ts`:
|
||
1. "burst enqueued during a flush drains immediately, not after
|
||
15 s backoff" — slow first PUT (100 ms), pile 24 more during,
|
||
assert `pendingCount === 0` within 1 s.
|
||
2. "per-recipient parallel drain — slow POST to A does not block
|
||
POSTs to B" — `bob` PUT stalls 200 ms; `carol` envelope queued
|
||
after; assert `inbox.message_delivered` for carol fires within
|
||
150 ms (would be ≥200 ms pre-fix).
|
||
|
||
### 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`
|
||
- `BridgeDeliveryLog` class — in-memory `Map<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 via
|
||
`BridgeRoutesOptions.bridgeDeliveryLog` when you need to share an
|
||
instance across multiple bridge mounts.
|
||
- `InboxRoutesOptions.bridgeDeliveryLog` — when provided, the
|
||
`/v1/inbox/:addr/fetch` route 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 `BridgeDeliveryLog` is auto-wired between
|
||
`createBridgeRoutes` and `createInboxRoutes` so self-hosted
|
||
relays inherit the cross-channel dedup with zero configuration.
|
||
|
||
### Tests
|
||
- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
|
||
"BridgeDeliveryLog" describe block:
|
||
1. WS push then `/v1/inbox/:addr/fetch` returns 0 blobs but the
|
||
cursor has advanced.
|
||
2. SSE push records into the log identically (transport parity).
|
||
3. 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.
|
||
- `bootstrap()` in the test file now wires the bridge's
|
||
auto-created log into `createInboxRoutes`, 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 as `pollOnce`. Returns `true` when newly dispatched,
|
||
`false` for a duplicate or a handler-rejected blob. Wire-up
|
||
pattern documented inline.
|
||
- A 4096-entry FIFO msgId LRU (`deliveredIds` + `deliveredOrder`) is
|
||
shared between `acceptBridgeFrame` and `pollOnce` so cross-channel
|
||
duplicates are skipped (and acked) without re-running
|
||
`incomingHandler`.
|
||
- `Inbox.handleBlob` now records every successfully-dispatched
|
||
msgId before issuing the ack, eliminating the
|
||
ack-in-flight window where a parallel `pollOnce` could 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 to `Inbox.acceptBridgeFrame` without inventing a TTL.
|
||
`decodeWireMessage` populates 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 backing `Shade.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 one
|
||
`incomingHandler` dispatch per `inbox.send`.
|
||
- `packages/shade-sdk/tests/sdk.test.ts` — new `aliasSession`
|
||
cases: happy-path canonicalization (Bob initiates as `alice`, Alice
|
||
receives under `fp:bobfp`, aliases to `bob`, 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
|
||
- `Shade` gains a private `decryptChains: Map<string, Promise<unknown>>`
|
||
mirroring the existing `encryptChains` on the send path.
|
||
- `Shade.receive(from, env)` chains its `manager.decrypt(from, env)`
|
||
call off the prior decrypt promise for the same `from`. The
|
||
post-decrypt control-plaintext check and user `messageHandlers` run
|
||
*outside* the chain so nested `shade.receive` calls 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. `flushTo` skips emit when a row's `msgId` is
|
||
already in the LRU. Long-poll handlers don't need it (each request is
|
||
isolated).
|
||
- `pendingFlushPromise` chains 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: storms `inbox.blob_stored` 10× 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 same `from` does not race the
|
||
ratchet" exercises 8 parallel `bob.receive('alice', env)` for the
|
||
same envelope and asserts:
|
||
1. at least one fulfills with the right plaintext;
|
||
2. no rejection mentions `database is locked`;
|
||
3. 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=1` env var disables IP rate-limits on every
|
||
prekey + inbox route in `standalone.ts`. Logged as a `WARN` on 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.md`](./docs/DEPLOYMENT.md) under "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 exercises
|
||
`createPrekeyServer({ disableRateLimit: true })` and confirms 12
|
||
consecutive register calls from the same IP all return 200 (no 429).
|
||
The env-var → option conversion in `standalone.ts` is 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`), and `PostgresInboxStore`
|
||
(`@shade/storage-postgres`) all persist + return it.
|
||
- `InboxStore.fetchBlobs(...)` rows expose `senderFp?: string`.
|
||
Undefined for legacy rows persisted by a pre-4.8 relay.
|
||
- `POST /v1/inbox/:address` route computes `shortHash(senderSigningKey)`
|
||
after the sender's signature is verified and forwards it to
|
||
`store.putBlob({ ..., senderFp })`. The signature verification path
|
||
authorizes the same fingerprint that gets persisted — no new trust
|
||
surface.
|
||
- `POST /v1/inbox/:address/fetch` response includes `from` per blob
|
||
when the row has a fingerprint. Absent on legacy rows.
|
||
- Bridge endpoints (`/v1/bridge/{stream,poll,ws}`) now populate
|
||
`BridgeWireMessage.from` from the row's `senderFp`. The
|
||
`transport-bridge` wire format already accepted `from`; v4.7 just
|
||
never filled it.
|
||
|
||
#### `@shade/inbox`
|
||
- `FetchedBlob.from?: string` — relay-supplied sender fingerprint hint,
|
||
parsed from the fetch response.
|
||
- `DecryptHandler` raw arg gains `from?: 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 does
|
||
`PRAGMA table_info(inbox_blobs)` and runs
|
||
`ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT` if the column is
|
||
missing. Fresh databases get the column from the `CREATE TABLE IF
|
||
NOT EXISTS` directly.
|
||
- **Postgres** (`@shade/storage-postgres`): `ensureInboxServerTables`
|
||
runs `ALTER 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 `register` and `fetch`
|
||
requests; first `fetch` (if any) must follow `register`. Pre-fix
|
||
the recording fetch threw "fetch fired before register completed
|
||
(race not fixed)".
|
||
- **Fetch attribution**: `FetchedBlob.from` matches
|
||
`SHA-256(senderSigningKey)[:8]` in hex.
|
||
- **DecryptHandler propagation**: `raw.from` arrives in the app's
|
||
handler.
|
||
- `packages/shade-transport-bridge/tests/bridge.test.ts`: same
|
||
fingerprint regression for SSE, WS, and long-poll bridges
|
||
(`IncomingMessage.from` non-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_blobs` schema (no `sender_fp` column), reopen via
|
||
`SqliteInboxStore`, 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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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`
|
||
- `InboxServerEventMap` gains 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 `PresenceTracker` class (`packages/shade-inbox-server/src/presence.ts`)
|
||
— per-address connection-count map; emits transitions into a wired
|
||
`InboxServerEvents`. Two parallel bridges (WS + SSE during a fallback
|
||
handover) collapse into one `peer_connected` / `peer_disconnected`
|
||
pair so consumers don't see flicker.
|
||
- `createBridgeRoutes` now returns `{ app, websocket, presence }` so
|
||
operators / tests can read the live presence map. A `presenceTracker`
|
||
option lets multiple route mounts share state.
|
||
- New `GET /v1/bridge/presence` SSE 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: presence` SSE 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 = 64` per subscription.
|
||
|
||
#### `@shade/transport-bridge`
|
||
- New `PresenceBridge` class with `subscribe({ watch, onPresenceChange,
|
||
onError? })` returning `{ addPeer, removePeer, watching, unsubscribe }`.
|
||
- `addPeer` / `removePeer` mutate 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: true` for tests.
|
||
- `signPresenceQuery` helper exported from `@shade/transport-bridge/auth`
|
||
for 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()` then `disconnect()` → operator's
|
||
`events.on(...)` sees `inbox.peer_connected` then
|
||
`inbox.peer_disconnected` with `address: 'alice'`, `bridgeKind: 'ws'`.
|
||
- **(2A)** Bob subscribes presence on `[alice]`; alice opens a
|
||
WsBridge → bob's `onPresenceChange` fires `online` within 2s.
|
||
- **(3)** Bob's `[alice]` subscription must NOT receive frames for
|
||
an unrelated `carol` address opening her own bridge.
|
||
- **(4)** Alice's bridge reopens after a drop → bob sees `online`
|
||
again on the same subscription.
|
||
- Plus an `addPeer` / `removePeer` regression that verifies the
|
||
reconnect-on-mutation path delivers a fresh snapshot for the new
|
||
address and stops delivering for the removed one.
|
||
|
||
### 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-receiver
|
||
`globalThis.fetch` that mimics the WebIDL "Illegal invocation" check,
|
||
constructs `InboxClient` with no `fetch` override, runs `register()`,
|
||
and asserts the strict fetch saw `globalThis` as `this`. Pre-fix this
|
||
throws; post-fix it passes.
|
||
- `packages/shade-transport-bridge/tests/bridge.test.ts` — same regression
|
||
for both `LongPollBridge.connect()` (probe call) and `SseBridge.connect()`
|
||
(open-once call).
|
||
|
||
### Migration
|
||
|
||
None. Existing `options.fetch` overrides keep working unchanged. Apps
|
||
shipping a workaround like
|
||
|
||
```ts
|
||
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 across `shutdown()` / 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
|
||
(fresh `chainKey` + 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 to `onMessage` handlers with
|
||
`meta = { kind: 'broadcast', channelId, sender, generation, iteration }`.
|
||
- `Shade.onMessage` handler signature gained an optional third arg
|
||
`meta?: MessageMeta` — back-compat: handlers that ignore it keep
|
||
working unchanged for direct messages.
|
||
|
||
#### `@shade/proto`
|
||
- `encodeBroadcast(BroadcastWire)` / `decodeBroadcast(bytes)` — wire
|
||
type `0x21`. Length-prefixed channelId + senderAddress, u32
|
||
generation/iteration, 12-byte AES-GCM nonce, 64-byte Ed25519
|
||
signature, length-prefixed ciphertext.
|
||
- `inspectEnvelopeType` recognises `'broadcast'`.
|
||
|
||
#### `@shade/core`
|
||
- `BroadcastChannelRecord` — persisted channel state (chainKey,
|
||
iteration, signing keys, generation, role).
|
||
- `BroadcastMemberRecord` — sender-side membership row with `joinedAt`
|
||
+ nullable `removedAt`.
|
||
- `StorageProvider` gained six optional methods:
|
||
`saveBroadcastChannel`, `getBroadcastChannel`, `listBroadcastChannels`,
|
||
`removeBroadcastChannel`, `saveBroadcastMember`, `getBroadcastMembers`,
|
||
`removeBroadcastMember`. Backends < 4.6 throw a clear error when an
|
||
app tries to call `createBroadcastChannel` against them.
|
||
|
||
#### Storage backends
|
||
- `MemoryStorage`, `SQLiteStorage`, `IndexedDBStorage` — plaintext
|
||
`broadcast_channels` + `broadcast_members` tables. IDB schema bumps
|
||
to v2 with an upgrade-path that creates the new stores idempotently.
|
||
- `EncryptedSQLiteStorage`, `EncryptedIndexedDBStorage`,
|
||
`EncryptedPostgresStorage` — `broadcast_channels_enc` +
|
||
`broadcast_members_enc` schemas. The chain key, iteration, and
|
||
signing-key bundle live in a sealed `ciphertext` blob 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
|
||
helpers `sealBroadcastChannelSensitive` / `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 with
|
||
`meta.kind === 'broadcast'`, (1*) revocation rotates + receiver A
|
||
drops while B keeps working, (2) persistence — channel id, members,
|
||
and chain advance survive `shutdown()` + re-open from the SQLite
|
||
path, (3) `listBroadcastChannels` surfaces 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 without `signingPrivateKey`.
|
||
|
||
### Compatibility
|
||
- Wire-protocol additive: existing peers ignore the `0x21` envelope
|
||
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 in `upgrade`.
|
||
|
||
## [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-backed `StorageProvider`
|
||
exposed via `@shade/storage-encrypted/idb`. One object store per
|
||
`_enc` table from the SQLite schema, sealed payloads as `Uint8Array`,
|
||
routing/timestamp fields kept plaintext for query efficiency. Reuses
|
||
`aeadSeal`/`aeadOpen` and the `row-codec` sealers verbatim — a row
|
||
sealed under the SQLite or Postgres backend decrypts under IDB given
|
||
the same `KeyManager`. `bumpPeerIdentityVersion` is 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_ARGON2ID` exported (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 and `open()` 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). The `browser` condition 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 hitting `bun:sqlite` resolution
|
||
errors.
|
||
- 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,
|
||
explicit `info` domain separation, nested-composite rejection.
|
||
- `packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts`
|
||
— full round-trip coverage of all 28 `StorageProvider` methods,
|
||
fingerprint-mismatch rejection on wrong key, atomic peer-identity
|
||
bump, plus cross-impl roundtrip with `EncryptedSQLiteStorage`
|
||
proving 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 the
|
||
`fingerprint` accessor shape. Throws if accessed before
|
||
`initialize()`. Reflects the current key after `rotate()`; the
|
||
previous key remains in retired-identities storage for the
|
||
configured grace period. Use `fingerprint` (12-group safety number)
|
||
for human side-channel comparison; use `identityPublicKey` when
|
||
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 rotation` covers the round-trip
|
||
match against the underlying storage and that the value updates
|
||
after `rotate()`.
|
||
|
||
## [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 per `StorageProvider` category) with
|
||
schema version 1. `dbName` defaults 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 `StorageProvider` conformance: identity, signed/one-time prekeys,
|
||
sessions, trusted identities, retired identities (with prune by
|
||
`retiredAt`), stream-state save/get/list/prune, peer verifications,
|
||
and the per-peer identity-version counter.
|
||
- `bumpPeerIdentityVersion` is wrapped in a single IDB `readwrite`
|
||
transaction — atomic read-modify-write, closing the race window the
|
||
SQLite adapter currently has on parallel `acceptIdentityChange`
|
||
calls. (SQL adapters will be brought in line in a follow-up.)
|
||
- Implementation dependency: `idb` (Jake Archibald's typed wrapper).
|
||
Tests run against `fake-indexeddb` for 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 named `StorageSpec` type
|
||
reused by `ResolvedConfig`, 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-end
|
||
`ShadeSessionManager` conversation 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 parallel `httpClient` RPCs while the drainer runs, plus a mixed
|
||
workload of 50 RPCs + 50 raw `shade.send` deliveries 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 waiter `since` cursor.
|
||
- `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 with `id > since` (blocking up
|
||
to `blockMs` if none are ready). Idle-eviction GC drops peers
|
||
that haven't polled in `idleEvictionMs` (default 10 min). Ring-
|
||
buffered to `maxEventsPerPeer` (default 1000) — overflow drops
|
||
oldest, receivers pick up the gap via re-resume from `since=0`.
|
||
- `QueuedEvent` discriminated union: `{ kind: 'envelope', bytes }`
|
||
or `{ kind: 'chunk', bytes, meta: { streamId, laneId, seq } }`.
|
||
- `QueueTransferTransport` (implements `ITransferTransport`) —
|
||
enqueues outbound chunks instead of POSTing. Returns optimistic
|
||
`ChunkAck` because 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-configures `shade.configureTransfers(...)` with the queue
|
||
transport + `QueueEnvelopeTransport` if not already configured.
|
||
- `Shade.configureTransfers(opts)` extended: `resolveBaseUrl` is now
|
||
optional when `transport` and `envelopeTransport` are both supplied
|
||
(lets pure-queue servers omit the baseUrl entirely). New
|
||
`transport?: ITransferTransport` override slot.
|
||
- `QueueEnvelopeTransport` — `ControlEnvelopeTransport` impl that
|
||
enqueues outbound envelopes for browser receivers.
|
||
|
||
#### `@shade/files`
|
||
- `createFilesHttpClient` (and `shade.files.httpClient`) accept new
|
||
options:
|
||
- `outboundQueueUrl` — `/queue` endpoint 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
|
||
at `maxBlockMs`).
|
||
When set, the client:
|
||
1. Configures `shade.configureTransfers({ resolveBaseUrl })` so
|
||
outbound chunks POST to `<transferBaseUrl>/v1/transfer/...`.
|
||
2. Builds a `ClientStreamsBridge` eagerly so the engine's
|
||
incoming-transfer subscription is in place before the drainer
|
||
dispatches the first envelope.
|
||
3. Starts a long-poll `startQueueDrainer(...)` that pulls queued
|
||
events and dispatches them via `shade.acceptTransferEnvelope`.
|
||
- Streamed reads (`fs.read` of files > 256 KiB) and streamed writes
|
||
(`fs.write` of 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 multiple `httpClient`s).
|
||
- `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)
|
||
- `ClientStreamsBridge` uses a TransformStream with `highWaterMark:
|
||
64` instead of the default `0` so 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's `getReader()` 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
|
||
`blockMs` pathway).
|
||
|
||
### 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:
|
||
|
||
```ts
|
||
// 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 exposing
|
||
`POST /rpc`. Reads `X-Shade-Sender-Address`, decrypts the envelope
|
||
via the existing ratchet session, dispatches through the attached
|
||
`FileHandler`, 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 encrypted `RpcError` envelopes.
|
||
- `createFilesHttpClient(shade, peer, options)` — request-response
|
||
`FileClient` for 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 via `shade.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 need `configureTransfers({ resolveBaseUrl })`).
|
||
In `inlineOnly` mode 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 a `receive(peer, envelope)` member
|
||
matching `Shade.receive` so 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). `write` of larger inputs
|
||
throws `ConflictError` directing callers to `shade.files.client(peer)`
|
||
on a server-to-server deployment. Streamed `read` results throw
|
||
`InternalFileError` for the same reason.
|
||
- The X3DH first-message must ride the same RPC route — set
|
||
`acceptFirstMessage: true` on `rpcRoute({ 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 past `maxBodyBytes`, `rpcRoute()` without `serve()`).
|
||
|
||
### 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:
|
||
|
||
```ts
|
||
// 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 local `MinimalReader<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`),
|
||
and `node:stream/web`. The previous flat shape was rejected by
|
||
consumer projects with `exactOptionalPropertyTypes: true` because
|
||
the present-branch required `value: T`. **Fixes "Type
|
||
ReadableStreamReadResult<Uint8Array> is not assignable to
|
||
{ value: Uint8Array | undefined; done: boolean }".**
|
||
- `client/streams-bridge.ts`, `server/streams-bridge.ts`: stash the
|
||
`setTimeout(...)` return value in a local before calling `.unref?.()`
|
||
through an explicit `{ unref?: () => void }` cast. The previous
|
||
fluent `.unref?.()` failed under `lib: ["DOM"]` because DOM types
|
||
`setTimeout` to `number`, which has no `.unref` even as an optional
|
||
property.
|
||
|
||
#### `@shade/sdk`
|
||
- `background.ts`: same `setTimeout` / `setInterval` `.unref?.()` fix.
|
||
|
||
### Tooling
|
||
|
||
- New `tests/consumer-strict/` — a tiny "as if I were a downstream app"
|
||
TypeScript project with its own `tsconfig.json`:
|
||
`lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: ["bun-types"]`,
|
||
`exactOptionalPropertyTypes: true`, `strict: true`,
|
||
`paths`-mapped to the workspace's `packages/*/src/index.ts`.
|
||
Three smoke files exercise `@shade/files`, `@shade/sdk`, and
|
||
`@shade/key-transparency` against the consumer-strict tsconfig.
|
||
- `scripts/typecheck-all.ts` now runs the consumer-strict smoke after
|
||
the per-package internal type-check. Both must pass before
|
||
`prepublish:check` (and therefore `publish: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`).
|
||
- `IndexProofWire` is now exported (was a private type that
|
||
`noUnusedLocals` flagged).
|
||
- Added missing `tsconfig.json` so the package can be type-checked
|
||
in isolation.
|
||
|
||
#### `@shade/sdk`
|
||
- KT verifier wiring: `fetchLatestSTH()` and `fetchConsistencyProof()`
|
||
now have explicit return types (`Promise<STHWire>` and
|
||
`Promise<{ proof: string[] }>`) so consumers don't see
|
||
`Promise<unknown>` from `res.json()`.
|
||
- `STHWire` type is now imported from `@shade/key-transparency`.
|
||
- `thumbnail.ts`: cast `globalThis` through `unknown` first when
|
||
reading optional DOM globals (`OffscreenCanvas`, `createImageBitmap`)
|
||
so consumer projects that include `lib.dom` don't reject our
|
||
narrower local types as "insufficiently overlapping".
|
||
|
||
#### `@shade/files`
|
||
- **Broke the `@shade/sdk` ↔ `@shade/files` dependency cycle.**
|
||
`@shade/files` no longer imports `Shade` from `@shade/sdk` — every
|
||
callsite uses a new local `ShadeBridge` interface defined in
|
||
`src/integration/shade-bridge.ts`. This is the structural surface
|
||
Shade must satisfy: `myAddress`, `send`, `onMessage`, `upload`,
|
||
`onIncomingTransfer`, `getFingerprintFor` (required) plus
|
||
`getObservability`, `deliverControlEnvelope` (optional). The Shade
|
||
class structurally implements every member, so
|
||
`createFilesNamespace(this)` from the SDK side compiles regardless
|
||
of how many copies of `@shade/sdk` a consumer's package manager
|
||
hoists. **Fixes "this is not assignable to type 'Shade'"** in
|
||
consumer builds.
|
||
- `<ShadeFilesProvider>` now takes `files: FilesNamespace` as an
|
||
explicit prop instead of reading `shade.files`. Consumers pass
|
||
`shade.files` (or any `createFilesNamespace(...)` result for tests)
|
||
directly.
|
||
- `ShadeFileRpcChannel.send` now raises a clear error when
|
||
`deliverControlEnvelope` is undefined instead of producing an
|
||
implicit-undefined-call error at compile time.
|
||
|
||
#### `@shade/storage-encrypted`
|
||
- Replaced `KeyUsage` (a `lib.dom` type) with a local
|
||
`WebCryptoKeyUsage` union so the package compiles under
|
||
`lib: ["ES2022"]` without DOM.
|
||
- Fixed `tsconfig.json` `rootDir` so package-level `bunx tsc` works.
|
||
|
||
#### `@shade/transport-bridge`
|
||
- `sse-bridge.ts`: cast `res.body.getReader()` to
|
||
`ReadableStreamDefaultReader<Uint8Array>` so the strict reader-type
|
||
parity check in the consume loop passes.
|
||
|
||
#### `@shade/keychain` / `@shade/dashboard`
|
||
- Fixed `tsconfig.json` `rootDir` and `include` so the packages can
|
||
type-check standalone (and so `vite.config.ts` doesn't get pulled
|
||
into the dashboard's `rootDir`).
|
||
|
||
#### `@shade/widgets`
|
||
- Removed unused `ThumbnailMime` import in
|
||
`components/transfer/ThumbnailPreview.tsx`.
|
||
|
||
### Tooling
|
||
|
||
- New `scripts/typecheck-all.ts` — runs `bunx tsc --noEmit` against
|
||
every workspace package's `tsconfig.json` and fails if any reports
|
||
errors.
|
||
- New `bun run typecheck` script.
|
||
- `publish:dry` and `publish:all` now run `prepublish:check`
|
||
(`typecheck` + `test`) before any package is packed or published.
|
||
- `scripts/publish-shade.sh` calls 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 a `files` prop. 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.md` documents the exact
|
||
0.3.x → 4.0 path, including the optional opt-ins, the schema
|
||
superset, and the `shade migrate-storage` workflow.
|
||
- **Cross-platform parity gated in CI.** `.gitea/workflows/cross-vectors.yml`
|
||
runs 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.md` through `docs/V3.12.md`
|
||
and the original V2.1/V2.2/V2.3 backlog now live under
|
||
`docs/archive/` with `Status: Done`. Active planning continues in
|
||
`docs/V5.0.md` (Voice & Video).
|
||
- **Operator-facing OpenAPI is complete.** `packages/shade-server/openapi.yaml`
|
||
now covers prekey, transfer, KT, inbox, bridge (SSE / long-poll / WS),
|
||
observer, and the `/metrics`, `/healthz`, `/ready` operations
|
||
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
|
||
|
||
- [x] V3.1 → V3.12 merged into `main`.
|
||
- [x] No open critical / high-severity security issues at the time of
|
||
tagging.
|
||
- [x] Cross-platform test vectors green: TS (1000 / 1000) and
|
||
Kotlin (11 / 11).
|
||
- [x] Production-checklist (`docs/PRODUCTION-CHECKLIST.md`) is the
|
||
canonical operator gate.
|
||
- [x] OpenAPI covers every HTTP surface (`/v1/keys/*`,
|
||
`/v1/transfer/*`, `/v1/kt/*`, `/v1/inbox/*`, `/v1/bridge/*`,
|
||
`/metrics`, `/healthz`, `/ready`).
|
||
- [x] Threat model reflects every new V3.x surface.
|
||
- [x] `0.3.x → 4.0` migration documented in `MIGRATION.md` and
|
||
validated against the `shade migrate-storage` CLI on a real
|
||
SQLite DB.
|
||
- [ ] **Pending external review.** A `docs/audit/REVIEW-BUNDLE.md`
|
||
pointer is shipped; the actual external review window opens
|
||
after tag.
|
||
|
||
### Migration
|
||
|
||
See [MIGRATION.md § Migrating from 0.3.x to 4.0 (GA)](./MIGRATION.md#migrating-from-03x-to-40-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 so `address → bundle_hash` is 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 wires `MerkleLog`,
|
||
`AddressIndex`, persistent `KTLogStore`, and STH signing under one
|
||
serial-mutation API (`recordRegister`, `recordReplenish`,
|
||
`recordDelete`, `publishSTH`, `buildBundleInclusionProof`,
|
||
`buildBundleAbsenceProof`, `buildConsistencyProof`).
|
||
- `KTLogStore` interface + `MemoryKTLogStore` reference impl. The
|
||
interface is append-only by contract (no `update()` or `delete()`
|
||
on historical leaves).
|
||
- `LightWitness` — passive observer that polls a server's `/v1/kt/sth`
|
||
endpoint, verifies signature + freshness + consistency, stores
|
||
observed STHs, and exposes `compare(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 to
|
||
`SHADE_KT_*` codes.
|
||
- Wire-format helpers: `ktProofToWire` / `ktProofFromWire` /
|
||
`sthToWire` / `sthFromWire` for 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 around
|
||
`KTLogManager` with 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/register` and `DELETE /v1/keys/:address` now commit
|
||
to the KT log (when enabled). `GET /v1/keys/bundle/:address`
|
||
returns a `ktProof` field on success and on 404 (absence/tombstone).
|
||
- KT is fully opt-in. Existing deployments are byte-compatible until
|
||
`keyTransparency` is configured.
|
||
|
||
#### `@shade/storage-postgres`
|
||
- `PostgresKTLogStore` — durable KTLogStore on Postgres. Uses three
|
||
tables (`shade_kt_leaves`, `shade_kt_index`, `shade_kt_sths`) with
|
||
an `BEFORE UPDATE/DELETE/TRUNCATE` trigger on `shade_kt_leaves`
|
||
that blocks any mutation — defense-in-depth against operator error.
|
||
- `ensureKTLogTables(sql)` exported for embedding.
|
||
|
||
#### `@shade/transport`
|
||
- `ShadeFetchTransport` accepts `keyTransparency: 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 a `LightWitness`.
|
||
- 404 responses are also verified (absence or tombstone proof) under
|
||
strict mode.
|
||
|
||
#### `@shade/sdk`
|
||
- `ShadeConfig.keyTransparency` — opt-in client config:
|
||
```ts
|
||
createShade({
|
||
prekeyServer: 'https://shade.example.com',
|
||
keyTransparency: { mode: 'observe-strict', logPublicKey: KEY_BYTES_32 },
|
||
});
|
||
```
|
||
- `Shade.getKTWitness()` returns the auto-wired `LightWitness` so 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 `KTSplitViewError` is 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 an `IPeerConnection`
|
||
plus the single bidirectional `RTCDataChannel` (label
|
||
`shade-transfer/v1`). Drives offer/answer/ICE through a
|
||
`WebRtcSignalingChannel`; handles the receiver-side dispatch loop
|
||
for chunk-ack / resume-state / ping-pong / error frames; exposes
|
||
per-request reqId-correlated `request()` 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 any `ShadeBridge`
|
||
(real `Shade.send`/`onMessage`, or `MemoryShadeBridge` for tests).
|
||
Non-signaling plaintext is forwarded to a configurable `passthrough`
|
||
hook so consumer `onMessage` handlers stay untouched.
|
||
- `WebRtcTransferTransport` — implements
|
||
`@shade/transfer`'s `ITransferTransport` over 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 polling `bufferedAmount`
|
||
(default threshold 4 MiB).
|
||
- `IRtcFactory` interface + `nativeRtcFactory()` adapter wrapping
|
||
`globalThis.RTCPeerConnection` for browsers / Deno / Cloudflare
|
||
Workers. `MemoryRtcFactory` ships an in-process WebRTC simulator
|
||
used by the package's own tests and by `@shade/sdk` integration
|
||
tests.
|
||
- `createShadeBridgeFromShade(shade)` — turns any `Shade`-shaped
|
||
object into a `ShadeBridge`. Calls `shade.send(plaintext)` to
|
||
ratchet-encrypt the JSON, then `shade.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` / `decodeFrame` helpers exported for adapters that
|
||
want to interoperate with `ShadeTransferWsTransport` (the wire
|
||
matches frame-for-frame).
|
||
- Errors: `WebRtcConnectError`, `WebRtcDataChannelError`,
|
||
`WebRtcSignalingError`, `WebRtcTimeoutError` — all extend
|
||
`TransferTransportError` so `MultiTransportFallback` automatically
|
||
demotes on failure.
|
||
|
||
#### `@shade/transfer`
|
||
- `MultiTransportFallback` — N-ary generalisation of the existing
|
||
two-arg `FallbackTransferTransport`. Constructor takes
|
||
`[{ name: 'webrtc', transport }, { name: 'ws', transport }, ...]`;
|
||
layers are tried in order and demote sticky on
|
||
`TransferTransportError`. Exposes `activeName`, `hasFallenBack`,
|
||
`failures` (diagnostic log), and `onSwitch((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 first `upload()`,
|
||
`onIncomingTransfer()`, or `transferRoute()` call). When
|
||
configured, the engine is wired with
|
||
`MultiTransportFallback([webrtc, http])` and the WebRTC manager
|
||
receives receiver-hooks pointing at `engine.receiveChunk` /
|
||
`engine.getResumeState`.
|
||
- `Shade.getWebRtcRuntime(): ShadeWebRtcRuntime | null` — diagnostic
|
||
accessor returning the live signaling channel, manager, transport,
|
||
and `MultiTransportFallback` after `engine()` builds.
|
||
- `@shade/transport-webrtc` is a (optional) peer-dep — projects that
|
||
don't call `configureWebRTC()` 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
|
||
picks `webrtc` and 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-byte `recoveryKey`,
|
||
encrypts an identity backup under the recoveryKey-derived
|
||
passphrase via `Shade.exportBackup`, Shamir-splits the key into
|
||
`n` shares, and ships one `share-deposit` envelope 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 a `Shade.onMessage` handler that
|
||
persists incoming deposits in a caller-supplied `RecoveryStore`
|
||
and gates `recovery-request` envelopes behind a user-driven
|
||
`approve` callback. Auto-declines requests for unknown
|
||
`(originalAddress, setupId)` pairs.
|
||
- `requestRecovery({ shade, originalAddress, setupId, threshold,
|
||
guardians, deliver })` — new-device flow. Sends one
|
||
`recovery-request` per guardian, collects `share-grant` /
|
||
`share-decline` replies, Shamir-combines the threshold-many
|
||
grants, and atomically swaps in the restored identity via
|
||
`Shade.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.
|
||
- `MemoryRecoveryStore` for tests + a `RecoveryStore` interface
|
||
apps implement against IndexedDB / SQLite / AsyncStorage / etc.
|
||
- Errors: `RecoveryError`, `RecoveryDeclinedError`,
|
||
`RecoveryTimeoutError`, `RecoveryReconstructionError`,
|
||
`RecoveryProtocolError`, `RecoveryGuardianRejectedError`.
|
||
- Wire protocol: `share-deposit`, `recovery-request`,
|
||
`share-grant`, `share-decline` JSON envelopes carried over
|
||
Double-Ratchet plaintext.
|
||
|
||
#### `@shade/widgets`
|
||
- `<RecoverySetup />` — primary-device guardian-picker + threshold
|
||
slider, drives `setupRecovery` and exposes `formatRecoveryCard`
|
||
for the user's offline copy.
|
||
- `<RecoveryRequest />` — new-device widget that displays the
|
||
temporary fingerprint prominently, drives `requestRecovery`,
|
||
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 the `attachGuardian.approve`
|
||
callback into a deferred queue the widget can consume.
|
||
|
||
#### `@shade/core`
|
||
- **Bug fix.** `initReceiverSession` now copies the
|
||
`localDHKeyPair` into 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 in `packages/shade-core/tests/ratchet.test.ts`.
|
||
|
||
### Acceptance criteria (V3.10)
|
||
- [x] 3-of-5 recovery works end-to-end on two separate Shade
|
||
instances. (`packages/shade-recovery/tests/integration.test.ts`)
|
||
- [x] No coalition of `(k-1)` guardians can reconstruct the
|
||
`recoveryKey` (verified with `fast-check` property tests).
|
||
(`packages/shade-recovery/tests/shamir.test.ts`,
|
||
`tests/adversarial.test.ts`)
|
||
- [x] 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-in `CryptoProvider` proxy that forwards
|
||
every async op to a dedicated Web Worker via the `worker-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 on
|
||
`StreamSender` / `StreamReceiver` instances that live entirely inside
|
||
the worker. Plaintext is shipped via transferable `ArrayBuffer`s; lane
|
||
keys + running sha256 stay worker-side.
|
||
- `createEncryptStream` / `createDecryptStream` — TransformStream
|
||
factories. `pipeThrough(encryptStream)` consumes plaintext and emits
|
||
one wire-encoded `stream-chunk` envelope per write. Both expose a
|
||
`laneSha256` promise that resolves once the stream finishes.
|
||
- New subpath export: `@shade/crypto-web/worker` is the dedicated
|
||
module-worker entrypoint. Bundle with the standard
|
||
`new URL('@shade/crypto-web/worker', import.meta.url)` idiom.
|
||
- `rotate()` and `destroy()` 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` / `decryptStream` throw 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-backed
|
||
`CryptoProvider` for one-off heavy ops.
|
||
- `shade.shutdown()` now also `destroy()`s the worker provider.
|
||
|
||
### Acceptance criteria (V3.8)
|
||
- [x] 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`).
|
||
- [x] Safari works at default chunk-size — every `postMessage` carries
|
||
≤ 256 KiB + AEAD overhead, far below Safari's transferable cap.
|
||
- [x] 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.
|
||
Exposes `activeKind` and `attempts` for observability.
|
||
- `signBridgeQuery` — Ed25519-signed query-string builder (the only
|
||
carrier that survives `EventSource`'s no-headers restriction).
|
||
- Auto-reconnect with exponential backoff for WS + SSE; `Last-Event-ID`
|
||
cursor 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 per `event:
|
||
envelope`. Heartbeats every 15 s as `: ping` comments.
|
||
- `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: `kind` is bound into the canonical
|
||
signed payload so a `/poll` signature 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)
|
||
- [x] Same "send 100 small messages" suite passes on WS, SSE, and
|
||
long-poll.
|
||
- [x] 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.
|
||
- [x] 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. See `docs/inbox.md` and `docs/archive/V3.6.md`.
|
||
|
||
### Added
|
||
|
||
#### `@shade/inbox` (new)
|
||
- `Inbox` — high-level orchestrator. Buffers outgoing PUTs in a durable
|
||
queue, polls + acks incoming blobs, and exposes
|
||
`onMessageQueued(handler)` (the vendor-neutral push-trigger hook
|
||
mandated by V3.6) and `onIncoming(handler)`.
|
||
- `InboxClient` — low-level HTTP client (`register`, `put`, `fetch`,
|
||
`ack`, `unregister`).
|
||
- `OutgoingQueueStore` interface + `MemoryOutgoingQueueStore` default —
|
||
swap in a SQLite/IDB backend so queue survives a process restart.
|
||
- `CursorStore` interface + `MemoryCursorStore` default 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 mismatched `msgId !== sha256(ciphertext)` and bodies past
|
||
`maxBlobBytes` (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.
|
||
- `InboxStore` interface + `MemoryInboxStore` default.
|
||
- `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_PATH` env var for the file path.
|
||
|
||
#### `@shade/storage-postgres`
|
||
- `PostgresInboxStore` — concurrent-safe via `INSERT … ON CONFLICT` and a
|
||
per-row `nextval('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`. If `SHADE_INBOX_PG_URL` is unset
|
||
the inbox falls back to `SHADE_PREKEY_PG_URL` (single Postgres deploy).
|
||
|
||
### Acceptance criteria (V3.6)
|
||
- [x] Sender → recipient with no online overlap; payload < 1 MiB; first
|
||
poll after recipient startup pulls the queued message.
|
||
- [x] 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).
|
||
- [x] Replay of PUT with the same `msgId` returns 200 with
|
||
`idempotent: true` instead 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 in
|
||
`upload()` 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 from
|
||
`Shade.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 `FingerprintGateRegistry` exported for advanced integrations.
|
||
|
||
#### `@shade/core`
|
||
- `FingerprintNotVerifiedError` (HTTP 403) — raised when a gate handler
|
||
returns `false`, throws, or is missing in environments that policy-
|
||
forbid TOFU.
|
||
- `PeerVerification` + `PeerVerificationSource` types and storage
|
||
methods on `StorageProvider`: `savePeerVerification`,
|
||
`getPeerVerification`, `removePeerVerification`,
|
||
`getPeerIdentityVersion`, `bumpPeerIdentityVersion`.
|
||
|
||
#### Storage backends
|
||
- `MemoryStorage`, `SQLiteStorage`, `PostgresStorage`,
|
||
`EncryptedSQLiteStorage`, `EncryptedPostgresStorage` all carry the new
|
||
`peer_verifications` + `peer_identity_versions` tables.
|
||
|
||
#### `@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/sdk` version 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 on `CustomOpsMap` + per-op Zod schemas
|
||
registered server-side.
|
||
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams (> 256 KiB)
|
||
ride `@shade/transfer` with automatic correlation via
|
||
`userMetadata.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` / `QuotaExceededError` with `retryAfterMs`.
|
||
- **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, `extra` predicate.
|
||
- **Fingerprint gate**: `requireFingerprintVerifiedFor(ctx)` →
|
||
`'required' | 'optional' | 'reject'` + `isFingerprintVerified(sender)`.
|
||
- **Signature verification**: pluggable `verifySender(sender, canonical, sig)`
|
||
with replay-window enforcement (±5 min `signedAt` skew 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`).
|
||
- 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)` and `Shade.files.client(peer)`
|
||
in `@shade/sdk`. Lazy + memoized; one handler per Shade instance.
|
||
- Drop-in adapter: `createMemoryDirectory()` for tests; structurally
|
||
compatible with browser `FileSystemDirectoryHandle`.
|
||
|
||
#### Wire format bump
|
||
- `@shade/proto` wire VERSION bumped from `0x01` to `0x02`. 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` / `.decrypt` now run under per-peer mutex.
|
||
Previously, concurrent decryptions of the same peer raced ratchet state
|
||
(manifested as sporadic `Failed to decrypt — wrong key or tampered data`
|
||
under load). Encrypt was already serialized via `Shade.send`'s
|
||
`encryptChains`; decrypt is now serialized at the manager layer too.
|
||
|
||
#### `@shade/streams` extension
|
||
- `StreamMetadata` gets optional `userMetadata?: Record<string, string>` —
|
||
application-level key/value pairs that round-trip verbatim through
|
||
`stream-init` plaintext. Used by `@shade/files` for write/read correlation
|
||
but available to any consumer.
|
||
|
||
#### `@shade/sdk` extension
|
||
- `Shade.files` getter (lazy + memoized).
|
||
- `BackgroundHooks.onPruneFiles?: () => void` + periodic timer (default 5 min)
|
||
for `@shade/files` retention.
|
||
- `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.js` for memory-bounded integrity
|
||
- `StreamSender` / `StreamReceiver` per-lane state machines with strict
|
||
in-order seq + replay detection (`StreamReplayError`,
|
||
`StreamOutOfOrderError`, `StreamDecryptionError`, `StreamProtocolError`)
|
||
- `MultiLaneSender` / `MultiLaneReceiver` coordinators 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 existing `0x02` ratchet messages with JSON `kind` discriminator
|
||
|
||
#### Transfer orchestration (`@shade/transfer`)
|
||
- `TransferEngine` — single class wrapping outgoing + incoming lifecycle
|
||
- Default `ShadeTransferHttpTransport` for chunk POSTs, opt-in
|
||
`ShadeTransferWsTransport` with `FallbackTransferTransport` for auto-fallback
|
||
- `createTransferRoutes()` Hono factory mounts `/v1/transfer/*` routes
|
||
(`chunk`, `state`, `health`)
|
||
- `IControlChannel` + `MemoryControlChannel` for in-process testing;
|
||
the SDK provides `ShadeControlChannel` over `Shade.send`/`receive`
|
||
- Resume protocol: `MemoryResumeStore`, `StorageBackedResumeStore`,
|
||
`deriveDeviceKey()` for at-rest streamSecret encryption,
|
||
`engine.resumeUpload(streamId, freshInput)` for kill-restart-verify flows
|
||
- `ProgressTracker` with 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 + ships
|
||
- `Shade.onIncomingTransfer(handler)` — receiver-side subscription
|
||
- `Shade.transferRoute()` — Hono router to mount on the consumer's HTTP server
|
||
- `Shade.acceptTransferEnvelope(from, env)` — low-level entry for custom transports
|
||
- `Shade.resumeUpload(streamId, freshInput)` — pick up an interrupted transfer
|
||
- `Shade.listTransfers(filter?)` — list resumable / active transfers from storage
|
||
- `ShadeTransferAuthenticator` — Ed25519-signing authenticator for HTTP/WS transports
|
||
- `Shade.onMessage(handler)` now accepts `Promise<void>`-returning handlers
|
||
(awaited in sequence) — supports flow-control over the control plane
|
||
|
||
#### Storage (all backends)
|
||
- New optional `StorageProvider` methods: `saveStreamState`,
|
||
`getStreamState`, `removeStreamState`, `listActiveStreamStates`,
|
||
`pruneStreamStates`. Existing v0.1.x providers compile cleanly (optional methods)
|
||
- SQLite (`stream_state` table) and Postgres (`shade_stream_state` table)
|
||
schemas with at-rest encrypted streamSecret
|
||
- `MemoryStorage` extended 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 || isLast` so 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 `deviceKey` derived 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`/`rotate` unchanged
|
||
(`onMessage` widened to support async handlers — sync handlers still work)
|
||
- Existing wire types `0x01` (PreKeyMessage) / `0x02` (RatchetMessage) unchanged
|
||
- `StorageProvider` interface extension uses optional methods
|
||
- `@shade/streams` and `@shade/transfer` are 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-safe
|
||
- `PostgresStorage` (`@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`)
|
||
- `ShadeSessionManager` high-level API (`encrypt`, `decrypt`, `initSessionFromBundle`)
|
||
- `getIdentityFingerprint()` — Signal-style 60-digit safety numbers
|
||
- `ensurePreKeyStock()` — auto-replenish when below threshold
|
||
- `resetSession()` and `acceptIdentityChange()` for recovery scenarios
|
||
- `rotateIdentity()` with archived previous identities
|
||
|
||
#### Transport (`@shade/transport`)
|
||
- `ShadeFetchTransport` — HTTP client for the prekey server with auto-signing
|
||
- `ShadeWebSocket` — 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)
|
||
- `randomUint32` via crypto.getRandomValues (no Math.random)
|
||
- Timing-attack regression test
|
||
- Constant-time trust verification in all storage backends
|
||
|
||
#### Errors
|
||
- Stable `SHADE_*` error codes
|
||
- `errorToHttpStatus` for consistent HTTP mapping
|
||
- `toJSON()` 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)
|