release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic
relay-side encrypted blob primitive: deterministically-located,
AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via
HKDF from the user's master key. Unlocks credential-only bootstrap
of new devices into existing E2EE state — no QR, no physical access.

Server: BlobStore interface + Memory/Sqlite/Postgres impls,
createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey
auth and If-Match CAS (409/412 semantics). Mounted on the same Hono
app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH /
SHADE_DISABLE_BLOB env-var plumbing in standalone.

SDK: createProfileNamespace high-level wrapper (HKDF derivation,
random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient.
Cross-platform test vectors in test-vectors/blob-storage.json.

New errors: ConflictError (409), PreconditionFailedError (412).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 02:44:42 +02:00
parent 3c0db14904
commit 80c410f518
51 changed files with 2138 additions and 58 deletions

View File

@@ -5,6 +5,93 @@ 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.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