7 Commits

Author SHA1 Message Date
037f994572 release(v4.11.0): streaming Double-Ratchet sub-sessions (ShadeStream)
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>
2026-05-15 11:29:09 +02:00
188c3db56a android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
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
Pure-JVM additions to shade-android (no Android SDK needed):
- V4.9 blob primitives: BlobKdf (HKDF deriveBlobSlotId/Key/SigningSeed),
  BlobAead (nonce||ct||tag with shade-profile-aad-v1:<slot> AAD),
  BlobClient (java.net.http with hand-written canonical JSON signing
  matching TS signPayload output), Profile high-level namespace.
- V4.10 approval helpers: CanonicalProfileBlob schema with denormalized
  trustedApproverFingerprints, build/sign/verify proxy approvals via
  length-prefixed u16 BE UTF-8 canonical signing payload.
- Password KDFs: scrypt + argon2id via Bouncy Castle, NFKC-normalized.
- SessionStateJson at-rest serializer for persistence layer.

Cross-platform vectors (test-vectors/blob.json, approval.json) gate
byte-identical output between TS and Kotlin, including a TS-signed
Ed25519 signature the Kotlin port verifies and reproduces (Ed25519 is
deterministic).

New shade-android-keystore sibling Gradle module (Android-specific):
- KeystoreMasterKey: hardware-backed AES-256-GCM with BIOMETRIC_STRONG
  gating, StrongBox-backed when available, invalidated on enrollment.
- BiometricUnlock: coroutine wrapper around BiometricPrompt with
  tagged cancellation/failure exceptions.
- KeystoreStorage: StorageProvider over biometric-gated AES-encrypted
  SharedPreferences with AAD-bound row keys.

All 25 SDK packages typecheck clean; 104 SDK tests + 24 new Kotlin
tests + 11 cross-platform vector tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:38:15 +02:00
1bd7037a6d release(v4.10.0): cross-host approval routing primitives in @shade/sdk
Some checks failed
Test / test (push) Has been cancelled
Builds on V4.9's encrypted profile blob: ships the canonical
profile-blob schema (hosts/clients/trustedApproverFingerprints) and
the build/sign/verify trio for proxy-approval frames. Headless servers
can now route a `linkRequest` to a trusted-approver phone, verify the
phone's Ed25519 signature against the fresh profile blob, and complete
pairing without a GUI host being available.

Length-prefixed binary signing payload so any platform (Kotlin, Swift,
Go) can produce byte-identical signing input from test vectors. No
relay or transport changes — entirely SDK-level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:09:59 +02:00
80c410f518 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>
2026-05-09 02:44:42 +02:00
3c0db14904 release(v4.8.5): kill flushOnce 15s success-backoff + per-recipient parallel drain
Some checks failed
Test / test (push) Has been cancelled
Prism filed a per-recipient-flush-concurrency FR pointing at
serial-per-flush. Investigation surfaced the actual culprit:
`scheduleFlush` was using a 15 s backoff on **both** the success and
failure paths, so envelopes enqueued *during* an in-flight flush
sat ~15 s behind the next drain — visible as "10 s of silence then
25-frame burst" on the receiving side under sustained sender output.

Two fixes:

1. `scheduleFlush` now uses 0 ms delay when `flushOnce` delivered
   ≥1 envelope and more is queued (network healthy → drain
   remainder immediately). 15 s reserved for the actual failure
   case where every attempt this round failed. `flushOnce` returns
   `{ delivered, remaining } | null` so concurrent-flush early
   returns don't double-schedule.

2. `flushOnce` groups the outgoing queue by `recipientAddress` and
   drains buckets via `Promise.all`. Per-peer order preserved
   (sequential within a bucket); a slow POST to recipient A no
   longer head-of-line-blocks frames bound for B.

`Inbox.tick` public shape unchanged. `OutgoingQueueStore`
implementations see the same per-entry list/remove/bumpAttempts/
size contract; only cross-recipient interleaving changes.

Tests cover (1) 25-envelope burst behind a 100 ms slow PUT drains
within 1 s, and (2) carol's PUT lands within 150 ms even when bob's
PUT stalls 200 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:56:27 +02:00
a98ea8a1bd release(v4.8.4): server-side cross-channel dedup via BridgeDeliveryLog
Some checks failed
Test / test (push) Has been cancelled
V4.8.3 shipped client-side cross-channel dedup hook
(`Inbox.acceptBridgeFrame`), but recipients that didn't migrate to
the new wiring still observed the same envelope twice — once via
WS bridge push, again ~30 s later via inbox-poll. Prism re-verified
the FR after 4.8.3 and asked for a relay-side enforcement so app
code doesn't have to ack-via-DELETE on every bridge frame.

V4.8.4 adds an in-memory `BridgeDeliveryLog` (default 60 s grace,
8192-per-address cap) that records every successful WS / SSE /
long-poll push of `(address, msgId)`. The `/v1/inbox/:addr/fetch`
route filters out blobs in the log's grace window so a recipient
running both a bridge and the 30 s poll cadence sees exactly one
delivery. Cursor advances over the full fetched window so a poll
that straddles a suppressed blob doesn't stall.

The standalone server auto-wires the log between
`createBridgeRoutes` and `createInboxRoutes`. Custom mounts thread
the same instance through `bridgeDeliveryLog` on both factories.

Tests cover WS-then-poll, SSE-then-poll, and a negative control
(non-bridge-pushed blob still comes through inbox-fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:31:42 +02:00
d47774ef1c release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
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
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed.

1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU.
   The relay durably stores blobs and pushes them to every active
   delivery channel; without a cross-channel ack the bridge frame
   ran first and the next inbox-poll re-dispatched the same blob
   ~30 s later, tripping on consumed prekeys. Bridge consumers now
   plumb pushed frames through `acceptBridgeFrame`, which shares
   the dedup gate + ack path with `pollOnce`. Whichever channel
   delivers first wins; the other acks-and-skips. Inbox records
   the msgId before the ack so a parallel poll can't observe an
   in-flight ack window.

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 post-decrypt plaintext
   typically announces the peer's real address. Aliasing moves
   session, trusted identity, peer-verification, and identity-
   version under the canonical label. Holds the per-peer mutex on
   both labels (lexicographic order) so concurrent crypto ops can't
   observe a half-moved state. Refuses to overwrite an existing
   session at the new label.

Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's
expiry so receivers can pass bridge frames straight to
`acceptBridgeFrame` without inventing a TTL.

Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy
path, refuse-to-overwrite, and same-label no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:49:36 +02:00
97 changed files with 9233 additions and 121 deletions

View File

@@ -5,6 +5,573 @@ 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/), 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). 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 R1R7 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 ## [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 Two interlocking robustness fixes for the first-contact / duplicate-fan-out

View File

@@ -100,6 +100,9 @@ matrix row in the same change.
| § 1 Network attacker — replay window | ±5 min `signedAt` enforcement | `packages/shade-server/tests/server.test.ts` (`"rejects registration with stale signedAt"`) | | § 1 Network attacker — replay window | ±5 min `signedAt` enforcement | `packages/shade-server/tests/server.test.ts` (`"rejects registration with stale signedAt"`) |
| § 1 Network attacker — header AAD | Ratchet headers bound to ciphertext | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-streams/tests/tamper.test.ts`, `packages/shade-streams/tests/aead.test.ts` | | § 1 Network attacker — header AAD | Ratchet headers bound to ciphertext | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-streams/tests/tamper.test.ts`, `packages/shade-streams/tests/aead.test.ts` |
| § 1 Network attacker — forward secrecy | DH ratchet step + chain-key zeroize | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-crypto-web/tests/hardening.test.ts` | | § 1 Network attacker — forward secrecy | DH ratchet step + chain-key zeroize | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-crypto-web/tests/hardening.test.ts` |
| § 1 Network attacker — streaming sub-session FS/replay (V4.11) | Per-frame Double-Ratchet `seal`/`open`; counter-rewind & replay rejected; in-memory-only (never persisted) | `packages/shade-core/tests/stream.test.ts` (`"R1: replayed / rewound frame is rejected"`, `"R2/R3: long one-directional burst stays correct and memory-bounded"`) |
| § 1 Network attacker — streaming handshake auth (V4.11) | Identity-bound 3-DH against parent-session-pinned identities | `packages/shade-core/tests/stream.test.ts` (`"handshake is mutually authenticated against pinned identities"`) |
| § 3 Endpoint compromise — streaming sub-session isolation (V4.11) | Stream ratchet derived without touching the stored parent session; zeroized on close | `packages/shade-core/tests/stream.test.ts` (`"R5: opening/using/closing a stream never touches the parent session"`, `"close() zeroizes and blocks further use; idempotent"`) |
| § 2 Compromised prekey server — public-only storage | Prekey store never accepts a private key | `packages/shade-server/tests/server.test.ts`, `packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts` | | § 2 Compromised prekey server — public-only storage | Prekey store never accepts a private key | `packages/shade-server/tests/server.test.ts`, `packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts` |
| § 2 Compromised prekey server — signed replenish/delete | Per-identity Ed25519 signature | `packages/shade-server/tests/server.test.ts` | | § 2 Compromised prekey server — signed replenish/delete | Per-identity Ed25519 signature | `packages/shade-server/tests/server.test.ts` |
| § 2 Compromised prekey server — fake-bundle detection | Out-of-band fingerprint comparison | `packages/shade-core/tests/fingerprint-session.test.ts` | | § 2 Compromised prekey server — fake-bundle detection | Out-of-band fingerprint comparison | `packages/shade-core/tests/fingerprint-session.test.ts` |

View File

@@ -1,3 +1,5 @@
plugins { plugins {
kotlin("jvm") version "2.0.20" apply false kotlin("jvm") version "2.0.20" apply false
kotlin("android") version "2.0.20" apply false
id("com.android.library") version "8.7.3" apply false
} }

View File

@@ -2,3 +2,5 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
kotlin.code.style=official kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true

View File

@@ -4,6 +4,7 @@ pluginManagement {
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
mavenCentral() mavenCentral()
google()
} }
} }
@@ -17,3 +18,6 @@ dependencyResolutionManagement {
include(":shade-android") include(":shade-android")
project(":shade-android").projectDir = file("shade-android") project(":shade-android").projectDir = file("shade-android")
include(":shade-android-keystore")
project(":shade-android-keystore").projectDir = file("shade-android-keystore")

View File

@@ -0,0 +1,68 @@
# shade-android-keystore
Android-specific bindings for `shade-android`. Lives as a sibling Gradle module so the JVM-only protocol code can keep running in CI without an Android SDK install.
Provides:
- **`KeystoreMasterKey`** — hardware-backed AES-256-GCM master key in the Android Keystore. Optionally biometric-gated (BIOMETRIC_STRONG only — Class 3 assurance), StrongBox-backed when available, invalidated on new biometric enrollment.
- **`BiometricUnlock`** — coroutine wrapper around `BiometricPrompt` for unlocking a `Cipher` instance bound to the keystore key. Throws `BiometricCancelledException` / `BiometricFailedException` so callers can handle the auth flow without writing custom callbacks.
- **`KeystoreStorage`** — `StorageProvider` implementation that persists session/identity/prekey state to `SharedPreferences`, each row encrypted under the keystore key with the row's preference key bound as AAD.
## Usage
```kotlin
import androidx.fragment.app.FragmentActivity
import no.zyon.shade.ShadeSessionManager
import no.zyon.shade.crypto.TinkProvider
import no.zyon.shade.keystore.BiometricUnlock
import no.zyon.shade.keystore.KeystoreStorage
class MyActivity : FragmentActivity() {
private val crypto = TinkProvider()
private lateinit var storage: KeystoreStorage
private lateinit var manager: ShadeSessionManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
storage = KeystoreStorage(this, crypto)
lifecycleScope.launch {
val unlock = BiometricUnlock(
activity = this@MyActivity,
title = "Unlock Shade",
subtitle = "Tap your fingerprint to access your messages",
)
try {
storage.unlock(unlock)
} catch (e: BiometricCancelledException) {
// user backed out — show a "tap to retry" UI
return@launch
}
manager = ShadeSessionManager(crypto, storage)
manager.initialize()
// ... use manager normally
}
}
}
```
For credential-driven bootstrap (V4.9 profile + V4.10 approval), pair this with `no.zyon.shade.blob.createProfileNamespace` and `no.zyon.shade.approval.signProxyApproval` — both pure-JVM (in `:shade-android`).
## Threat model
- **Compromised app process**: cannot read the AES key (it's in the secure environment). Can attempt to use the cipher only after the user has authenticated; biometric re-prompts are required after each biometric event.
- **Stolen device with known PIN**: cannot unlock — `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` excludes `DEVICE_CREDENTIAL`.
- **Attacker enrolls own biometric**: `setInvalidatedByBiometricEnrollment(true)` invalidates the key on enrollment, forcing a credential rebootstrap (which would need username + password + PIN).
- **Catastrophic recovery**: `forgetEverything()` deletes the master key and clears the SharedPreferences. Pair with `Profile.delete()` for full account erasure.
## Build
Requires an Android SDK. The Gradle build uses Android Gradle Plugin 8.7+, AGP minSdk 28 (Pie+ for BiometricPrompt baseline), targetSdk 35.
```bash
JAVA_HOME=/path/to/jdk-21 ./gradlew :shade-android-keystore:assembleDebug
```
Unit tests: none yet — `KeystoreStorage` requires Android runtime. Robolectric or instrumented tests against an emulator are tracked as a follow-up. The pure-JVM `SessionStateJson` round-trip serializer is tested in `:shade-android` (`SessionStateJsonTest`).

View File

@@ -0,0 +1,59 @@
plugins {
id("com.android.library")
kotlin("android")
}
// V4.10 — Android-specific KeystoreStorage adapter.
//
// Lives as a sibling module to `:shade-android` so the JVM-only
// protocol code can keep running in CI without an Android SDK.
// This module pulls in `:shade-android` for `StorageProvider`,
// `IdentityKeyPair`, etc., and binds those types to a hardware-
// backed Android Keystore master key with biometric gating.
android {
namespace = "no.zyon.shade.keystore"
compileSdk = 35
defaultConfig {
minSdk = 28 // BiometricPrompt + StrongBox baseline
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies {
// Sibling: protocol types + StorageProvider interface.
api(project(":shade-android"))
// androidx.biometric — fragment-safe BiometricPrompt wrapper.
// 1.2.0-alpha05 is the latest with stable BiometricPrompt API.
implementation("androidx.biometric:biometric:1.2.0-alpha05")
// androidx.fragment — BiometricPrompt requires FragmentActivity.
implementation("androidx.fragment:fragment-ktx:1.8.5")
// Coroutines for the suspend-function StorageProvider implementation.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -0,0 +1,112 @@
package no.zyon.shade.keystore
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import javax.crypto.Cipher
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Biometric unlock for a `KeystoreMasterKey`-bound `Cipher`.
*
* The Android keystore enforces that any operation on a
* user-authentication-required key must happen via a
* `BiometricPrompt.CryptoObject`-wrapped `Cipher`. The user sees a
* system biometric prompt; on success the same `Cipher` instance is
* usable for one operation (or one streaming session) before
* needing to re-prompt.
*
* This is a thin coroutine wrapper around `BiometricPrompt` that
* resolves to the authenticated cipher or throws on user
* cancellation. Callers typically run it once at app start to
* unlock the master key for the lifetime of the foreground session.
*/
class BiometricUnlock(
private val activity: FragmentActivity,
private val title: String,
private val subtitle: String? = null,
private val negativeButton: String = "Cancel",
) {
/**
* True if BIOMETRIC_STRONG is currently usable on this device.
* False means the user has no enrolled fingerprint/face that
* meets the class-3 assurance level — fall back to a credential
* recovery flow rather than crashing.
*/
fun canAuthenticate(): Boolean {
val mgr = BiometricManager.from(activity)
return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Show the biometric prompt and return the authenticated cipher.
*
* Cancellation paths:
* - User taps the negative button → throws `BiometricCancelledException`.
* - System errors out (e.g. too many failures) → throws
* `BiometricFailedException` with the system error code.
*/
suspend fun unlock(cipher: Cipher): Cipher = suspendCancellableCoroutine { cont ->
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedCipher = result.cryptoObject?.cipher
if (authedCipher == null) {
cont.resumeWithException(
BiometricFailedException(-1, "BiometricPrompt returned no cipher"),
)
} else {
cont.resume(authedCipher)
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
cont.resumeWithException(BiometricCancelledException(errString.toString()))
} else {
cont.resumeWithException(
BiometricFailedException(errorCode, errString.toString()),
)
}
}
override fun onAuthenticationFailed() {
// A single failed attempt — the prompt stays open and
// gives the user another try. Don't resume the
// continuation; let the system flow continue.
}
}
val prompt = BiometricPrompt(
activity,
activity.mainExecutor,
callback,
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.apply { if (subtitle != null) setSubtitle(subtitle) }
.setNegativeButtonText(negativeButton)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
cont.invokeOnCancellation { prompt.cancelAuthentication() }
prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher))
}
}
/** User cancelled the biometric prompt. */
class BiometricCancelledException(message: String) : RuntimeException(message)
/**
* BiometricPrompt returned a non-cancellation error (lockout, hardware
* unavailable, no enrolled biometrics, etc.). Inspect `errorCode`
* against `BiometricPrompt.ERROR_*` constants to decide UX response.
*/
class BiometricFailedException(val errorCode: Int, message: String) :
RuntimeException("[$errorCode] $message")

View File

@@ -0,0 +1,172 @@
package no.zyon.shade.keystore
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import java.security.KeyStore
/**
* Hardware-backed AES-256-GCM master key in the Android Keystore.
*
* The key never leaves the secure environment — Android's keystore
* implementation enforces that all encrypt/decrypt operations
* happen inside the TEE (or StrongBox if present), and the raw
* key bytes are never returned to userspace.
*
* The key is created on first use with these properties:
*
* - AES-256-GCM, no padding
* - User authentication required: opt-in via the `requireBiometric`
* flag. When true, every encrypt/decrypt operation must be wrapped
* in a `BiometricPrompt.authenticate(CryptoObject(cipher))` call
* that succeeds within the same `Cipher` instance.
* - StrongBox-backed if available (Pixel 3+, most Samsung flagships).
* Falls back to TEE on devices without StrongBox.
* - InvalidatedByBiometricEnrollment(true): a newly enrolled
* fingerprint/face invalidates the key, forcing the user to
* re-bootstrap from credentials. Defends against a thief who
* enrolls their own biometric.
*
* Mirrors the role `KeyManager` plays in `@shade/storage-encrypted`'s
* V4.5 KDF chain: this is the *encryption-at-rest* master key, not
* the X3DH identity key. The Shade protocol's identity keys are
* stored encrypted under THIS key.
*/
class KeystoreMasterKey(
private val alias: String,
private val requireBiometric: Boolean = true,
) {
init {
require(alias.isNotEmpty()) { "alias must be non-empty" }
}
/**
* Build a `Cipher` initialized for encryption with the master key.
*
* If the key requires user auth, the returned cipher is *not yet
* usable* — the caller MUST wrap it in a
* `BiometricPrompt.authenticate(CryptoObject(cipher))` and use
* the cipher exposed by the auth-success callback. Calling
* `cipher.doFinal(...)` before authentication throws
* `UserNotAuthenticatedException`.
*/
fun cipherForEncrypt(): Cipher {
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key)
return cipher
}
/**
* Build a `Cipher` initialized for decryption with the master key
* and a previously-stored 12-byte nonce. Same authentication
* requirement as `cipherForEncrypt`.
*/
fun cipherForDecrypt(nonce: ByteArray): Cipher {
require(nonce.size == 12) { "GCM nonce must be 12 bytes" }
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, nonce))
return cipher
}
/** True if the key already exists in the Android Keystore. */
fun exists(): Boolean {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
return ks.containsAlias(alias)
}
/**
* Delete the master key. Catastrophic — all data encrypted under
* it becomes unrecoverable. Used by the "forget everything" flow
* (paired with `Profile.delete()` in the V4.9 namespace).
*/
fun deleteKey() {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
if (ks.containsAlias(alias)) ks.deleteEntry(alias)
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
ks.getEntry(alias, null)?.let { entry ->
return (entry as KeyStore.SecretKeyEntry).secretKey
}
return generateKey()
}
private fun generateKey(): SecretKey {
val builder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// Each encrypt operation generates a fresh IV in the secure
// env; we read it back via `cipher.iv` after init.
.setRandomizedEncryptionRequired(true)
if (requireBiometric) {
builder.setUserAuthenticationRequired(true)
// BIOMETRIC_STRONG only — class 3, the highest assurance
// level (Class 3 = false-accept rate < 1/50 000 per BiometricPrompt).
// DEVICE_CREDENTIAL is intentionally NOT included: a stolen
// device with a known PIN should not unlock Shade.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(
/* timeout = */ 0,
KeyProperties.AUTH_BIOMETRIC_STRONG,
)
} else {
@Suppress("DEPRECATION")
builder.setUserAuthenticationValidityDurationSeconds(-1)
}
builder.setInvalidatedByBiometricEnrollment(true)
}
// StrongBox if available — bumps key storage to a dedicated
// tamper-resistant chip on Pixel 3+ / most Samsung flagships.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
return try {
gen.init(builder.build())
gen.generateKey()
} catch (_: Exception) {
// StrongBox not present or full → retry without StrongBox.
// Same for older devices that don't honor
// setUserAuthenticationParameters.
val fallback = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.apply {
if (requireBiometric) {
setUserAuthenticationRequired(true)
@Suppress("DEPRECATION")
setUserAuthenticationValidityDurationSeconds(-1)
setInvalidatedByBiometricEnrollment(true)
}
}
.build()
gen.init(fallback)
gen.generateKey()
}
}
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
}
}

View File

@@ -0,0 +1,229 @@
package no.zyon.shade.keystore
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import no.zyon.shade.crypto.CryptoProvider
import no.zyon.shade.serialization.SessionStateJson
import no.zyon.shade.storage.StorageProvider
import no.zyon.shade.types.IdentityKeyPair
import no.zyon.shade.types.OneTimePreKey
import no.zyon.shade.types.SessionState
import no.zyon.shade.types.SignedPreKey
/**
* `StorageProvider` implementation that gates all reads/writes through
* a biometric-locked `KeystoreMasterKey`. Mirrors `MemoryStorage` for
* the API surface but persists state to `SharedPreferences` with each
* row encrypted under the keystore key.
*
* Lifecycle:
*
* 1. App start → construct `KeystoreStorage(context, alias)`.
* 2. `unlock(BiometricUnlock)` runs the system biometric prompt.
* The Android keystore caches the auth state under the key's
* `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` policy
* until the next biometric event (re-enrollment, etc.).
* 3. While unlocked, `getSession`/`saveSession` etc. work normally.
* 4. `lock()` clears the in-memory unlocked flag so a future
* operation triggers another biometric prompt.
*
* Wire layout per row:
* `<base64(nonce(12))>:<base64(ct||tag)>`
*
* Stored as `String` SharedPreferences entries. AAD = the row's
* preference key (`session:<address>`, `signedPreKey:<id>`, etc.) so
* a substituted-prefs swap fails to open.
*/
class KeystoreStorage(
context: Context,
private val crypto: CryptoProvider,
keyAlias: String = DEFAULT_KEY_ALIAS,
prefsName: String = DEFAULT_PREFS_NAME,
requireBiometric: Boolean = true,
) : StorageProvider {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
private val masterKey = KeystoreMasterKey(keyAlias, requireBiometric = requireBiometric)
private val writeMutex = Mutex()
@Volatile
private var unlocked: Boolean = false
/**
* Unlock the keystore via biometric prompt. Idempotent — calling
* twice without a `lock()` between is a no-op.
*/
suspend fun unlock(unlock: BiometricUnlock) {
if (unlocked) return
// The biometric flow returns an authenticated *encrypt*
// cipher; we discard it after a one-shot probe to confirm
// the master key is reachable. The actual encrypt/decrypt
// ciphers in the I/O path use the authentication state
// established here (Android Keystore caches the auth for
// the user-authentication-required key under the
// `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)`
// policy until the next biometric event).
val probe = masterKey.cipherForEncrypt()
unlock.unlock(probe)
unlocked = true
}
/** Unlock without biometric — only valid for keys constructed with `requireBiometric=false`. */
fun unlockNoBiometric() {
unlocked = true
}
/** Wipe in-memory unlock state. The key itself stays in the keystore. */
fun lock() {
unlocked = false
}
/**
* Catastrophic reset: deletes the master key + all encrypted
* preferences. Used by the "forget everything" / 3-strikes-wipe
* path. The next bootstrap rebuilds from credentials.
*/
fun forgetEverything() {
masterKey.deleteKey()
prefs.edit().clear().apply()
unlocked = false
}
// ─── Identity ──────────────────────────────────────────────
override suspend fun getIdentityKeyPair(): IdentityKeyPair? {
val json = readDecrypted(KEY_IDENTITY) ?: return null
return SessionStateJson.deserializeIdentityKeyPair(json)
}
override suspend fun saveIdentityKeyPair(keyPair: IdentityKeyPair) {
writeEncrypted(KEY_IDENTITY, SessionStateJson.serializeIdentityKeyPair(keyPair))
}
override suspend fun getLocalRegistrationId(): Int {
return readDecrypted(KEY_REGISTRATION_ID)?.toIntOrNull() ?: 0
}
override suspend fun saveLocalRegistrationId(id: Int) {
writeEncrypted(KEY_REGISTRATION_ID, id.toString())
}
// ─── Signed prekeys ────────────────────────────────────────
override suspend fun getSignedPreKey(keyId: Int): SignedPreKey? {
val json = readDecrypted("$KEY_SIGNED_PREKEY:$keyId") ?: return null
return SessionStateJson.deserializeSignedPreKey(json)
}
override suspend fun saveSignedPreKey(key: SignedPreKey) {
writeEncrypted(
"$KEY_SIGNED_PREKEY:${key.keyId}",
SessionStateJson.serializeSignedPreKey(key),
)
}
override suspend fun removeSignedPreKey(keyId: Int) {
writeMutex.withLock { prefs.edit().remove("$KEY_SIGNED_PREKEY:$keyId").apply() }
}
// ─── One-time prekeys ──────────────────────────────────────
override suspend fun getOneTimePreKey(keyId: Int): OneTimePreKey? {
val json = readDecrypted("$KEY_ONETIME_PREKEY:$keyId") ?: return null
return SessionStateJson.deserializeOneTimePreKey(json)
}
override suspend fun saveOneTimePreKey(key: OneTimePreKey) {
writeEncrypted(
"$KEY_ONETIME_PREKEY:${key.keyId}",
SessionStateJson.serializeOneTimePreKey(key),
)
}
override suspend fun removeOneTimePreKey(keyId: Int) {
writeMutex.withLock { prefs.edit().remove("$KEY_ONETIME_PREKEY:$keyId").apply() }
}
override suspend fun getOneTimePreKeyCount(): Int {
return prefs.all.keys.count { it.startsWith("$KEY_ONETIME_PREKEY:") }
}
// ─── Sessions ──────────────────────────────────────────────
override suspend fun getSession(address: String): SessionState? {
val json = readDecrypted("$KEY_SESSION:$address") ?: return null
return SessionStateJson.deserialize(json)
}
override suspend fun saveSession(address: String, state: SessionState) {
writeEncrypted("$KEY_SESSION:$address", SessionStateJson.serialize(state))
}
override suspend fun removeSession(address: String) {
writeMutex.withLock { prefs.edit().remove("$KEY_SESSION:$address").apply() }
}
// ─── Trust ─────────────────────────────────────────────────
override suspend fun isTrustedIdentity(address: String, identityKey: ByteArray): Boolean {
val stored = readDecrypted("$KEY_TRUSTED:$address") ?: return true // TOFU
val storedBytes = Base64.decode(stored, Base64.NO_WRAP)
return crypto.constantTimeEqual(storedBytes, identityKey)
}
override suspend fun saveTrustedIdentity(address: String, identityKey: ByteArray) {
writeEncrypted(
"$KEY_TRUSTED:$address",
Base64.encodeToString(identityKey, Base64.NO_WRAP),
)
}
// ─── Encrypted-row plumbing ────────────────────────────────
private fun ensureUnlocked() {
check(unlocked) {
"KeystoreStorage is locked — call unlock(BiometricUnlock) first"
}
}
private suspend fun readDecrypted(prefKey: String): String? {
ensureUnlocked()
val raw = prefs.getString(prefKey, null) ?: return null
val parts = raw.split(":", limit = 2)
require(parts.size == 2) { "malformed encrypted row at $prefKey" }
val nonce = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = masterKey.cipherForDecrypt(nonce)
cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ct).toString(Charsets.UTF_8)
}
private suspend fun writeEncrypted(prefKey: String, plaintext: String) {
ensureUnlocked()
writeMutex.withLock {
val cipher = masterKey.cipherForEncrypt()
cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8))
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val nonce = cipher.iv
val nonceB64 = Base64.encodeToString(nonce, Base64.NO_WRAP)
val ctB64 = Base64.encodeToString(ct, Base64.NO_WRAP)
prefs.edit().putString(prefKey, "$nonceB64:$ctB64").apply()
}
}
companion object {
const val DEFAULT_KEY_ALIAS = "shade-master-v1"
const val DEFAULT_PREFS_NAME = "shade-keystore-storage-v1"
private const val KEY_IDENTITY = "identity"
private const val KEY_REGISTRATION_ID = "registrationId"
private const val KEY_SIGNED_PREKEY = "signedPreKey"
private const val KEY_ONETIME_PREKEY = "oneTimePreKey"
private const val KEY_SESSION = "session"
private const val KEY_TRUSTED = "trusted"
}
}

View File

@@ -7,7 +7,11 @@ Kotlin implementation of the Shade E2EE protocol for Android apps. Byte-for-byte
**M-Cross 1 ✅** — keys + HKDF + X3DH + fingerprint. **M-Cross 1 ✅** — keys + HKDF + X3DH + fingerprint.
**M-Cross 2 ✅** — full ratchet step (encrypt + decrypt roundtrip) + wire 0x02 (RatchetMessage and PreKeyMessage with/without OTPK). **M-Cross 2 ✅** — full ratchet step (encrypt + decrypt roundtrip) + wire 0x02 (RatchetMessage and PreKeyMessage with/without OTPK).
**M-Cross 3 ✅** — streams 0x11 (KDF labels with embedded NULs, deterministic chunk nonce/AAD, wire 0x11 encode/decode). **M-Cross 3 ✅** — streams 0x11 (KDF labels with embedded NULs, deterministic chunk nonce/AAD, wire 0x11 encode/decode).
**M-Cross 4 ✅** — backup-key HKDF + AES-GCM, group sender-key step (`kdfChainKey` + Ed25519 sign over `aad ‖ ct`), storage HKDF (storageKey/fieldKey/rowNonce). Pending: scrypt master-key, argon2id swap, Android KeystoreStorage (sibling module). **M-Cross 4 ✅** — backup-key HKDF + AES-GCM, group sender-key step (`kdfChainKey` + Ed25519 sign over `aad ‖ ct`), storage HKDF (storageKey/fieldKey/rowNonce).
**M-Cross 5 ✅** — V4.9 blob KDF + AEAD (`deriveBlobSlotId / deriveBlobKey / deriveBlobSigningSeed`, AAD-bound seal/open), `BlobClient` HTTP, `Profile` namespace. Cross-platform vectors in `blob.json`.
**M-Cross 6 ✅** — V4.10 cross-host approval routing: canonical profile-blob schema (`hosts[]` / `clients[]` / `trustedApproverFingerprints[]`), build/sign/verify proxy approvals via `canonicalApprovalSigningBytes` (length-prefixed u16 BE UTF-8). Cross-platform vectors in `approval.json`, including a TS-signed Ed25519 signature that the Kotlin port verifies.
**M-Cross 7 ✅** — scrypt + argon2id password-KDF wrappers (Bouncy Castle), NFKC-normalized inputs.
**M-Cross 8 ✅**`:shade-android-keystore` sibling module: `KeystoreMasterKey` (StrongBox-backed AES-256-GCM, BIOMETRIC_STRONG-gated, invalidated on biometric enrollment), `BiometricUnlock`, `KeystoreStorage` (`StorageProvider` over biometric-gated AES-encrypted SharedPreferences).
Cross-platform test vectors in `/test-vectors/` are loaded by both the TS Cross-platform test vectors in `/test-vectors/` are loaded by both the TS
and Kotlin test suites; any byte-divergence fails CI within 60 s. See and Kotlin test suites; any byte-divergence fails CI within 60 s. See

View File

@@ -29,6 +29,11 @@ dependencies {
// The same `subtle.*` API as `tink-android` so the source compiles unchanged. // The same `subtle.*` API as `tink-android` so the source compiles unchanged.
implementation("com.google.crypto.tink:tink:1.15.0") implementation("com.google.crypto.tink:tink:1.15.0")
// Bouncy Castle for scrypt + argon2id. Tink doesn't ship password
// KDFs; @shade/storage-encrypted uses @noble/hashes for both. We
// pin to the JDK18-on artifact so it works on JVM 17 + Android.
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
// JSON serialization (session state + test-vector loader on JVM). // JSON serialization (session state + test-vector loader on JVM).
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

View File

@@ -0,0 +1,273 @@
package no.zyon.shade.approval
import no.zyon.shade.crypto.CryptoProvider
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* V4.10 — cross-host approval routing helpers. Mirror
* `@shade/sdk/approval.ts` byte-for-byte.
*
* The frames themselves (`approvalNeeded` / `linkApproveByProxy`) are
* app-defined payloads sent over the existing Shade bilateral E2EE
* channel. This file ships the canonical signing-payload layout, the
* Ed25519 sign step a phone runs after biometric unlock, and the
* verify step a host runs against the freshest profile blob.
*
* The signing payload is length-prefixed binary (u16 BE) so any
* platform — Kotlin, Swift, Go — can produce byte-identical input
* without needing a JSON canonicalizer. Cross-platform parity is
* gated by `test-vectors/blob-storage.json` (signing payload
* fixtures) plus a Kotlin↔TS round-trip in `CrossPlatformVectorTest`.
*/
/** Default domain separator. Apps with their own canonical name (e.g. Prism) override. */
const val DEFAULT_APPROVAL_DOMAIN = "shade-link-approve-v1"
/** Default expiry: 5 minutes after the host issues the request. */
const val DEFAULT_APPROVAL_EXPIRES_IN_MS = 5L * 60 * 1000
/** Information about the device the host received a `linkRequest` from. */
data class ApprovalRequestingDevice(
val fingerprint: String,
val deviceName: String? = null,
val userAgent: String? = null,
val ipHint: String? = null,
val receivedAt: Long,
)
data class ApprovalRequestFrame(
val kind: String = "approvalNeeded",
/** 128-bit hex (32 chars) random idempotency key. */
val requestId: String,
val hostAddress: String,
val hostFingerprint: String,
val requestingDevice: ApprovalRequestingDevice,
val expiresAt: Long,
val domain: String,
)
data class ProxyApprovalFrame(
val kind: String = "linkApproveByProxy",
val requestId: String,
val decision: String,
val approverFingerprint: String,
/** 64-byte Ed25519 signature, lowercase hex (128 chars). */
val signature: String,
val domain: String,
)
/**
* Build a fresh `approvalNeeded` frame with a 128-bit random
* `requestId`. Hosts SHOULD persist the requestId in a pending-set
* keyed by `expiresAt` so a returning `linkApproveByProxy` can be
* matched up — that's app state, the SDK doesn't track it.
*/
fun buildApprovalRequest(
crypto: CryptoProvider,
hostAddress: String,
hostFingerprint: String,
requestingDeviceFingerprint: String,
deviceName: String? = null,
userAgent: String? = null,
ipHint: String? = null,
expiresInMs: Long = DEFAULT_APPROVAL_EXPIRES_IN_MS,
domain: String = DEFAULT_APPROVAL_DOMAIN,
now: Long = System.currentTimeMillis(),
): ApprovalRequestFrame {
val requestId = crypto.randomBytes(16).joinToString("") { "%02x".format(it) }
return ApprovalRequestFrame(
requestId = requestId,
hostAddress = hostAddress,
hostFingerprint = hostFingerprint,
requestingDevice = ApprovalRequestingDevice(
fingerprint = requestingDeviceFingerprint,
deviceName = deviceName,
userAgent = userAgent,
ipHint = ipHint,
receivedAt = now,
),
expiresAt = now + expiresInMs,
domain = domain,
)
}
/**
* Sign a `linkApproveByProxy` frame with the approver's long-term
* Ed25519 identity key. The seed is the 32-byte Ed25519 private key
* (Tink's `Ed25519Sign(seed)` consumes it directly).
*/
fun signProxyApproval(
crypto: CryptoProvider,
request: ApprovalRequestFrame,
decision: String,
approverFingerprint: String,
approverSigningKey: ByteArray,
): ProxyApprovalFrame {
require(decision == "approve" || decision == "reject") {
"decision must be 'approve' or 'reject'"
}
require(approverSigningKey.size == 32) {
"approverSigningKey must be 32 bytes (Ed25519 seed)"
}
val payload = canonicalApprovalSigningBytes(
domain = request.domain,
requestId = request.requestId,
hostFingerprint = request.hostFingerprint,
requestingDeviceFingerprint = request.requestingDevice.fingerprint,
decision = decision,
)
val sig = crypto.sign(approverSigningKey, payload)
return ProxyApprovalFrame(
requestId = request.requestId,
decision = decision,
approverFingerprint = approverFingerprint,
signature = sig.joinToString("") { "%02x".format(it) },
domain = request.domain,
)
}
/** Tagged result of `verifyProxyApproval`. */
sealed class VerifyProxyApprovalResult {
data class Ok(val approver: ProfileClientEntry) : VerifyProxyApprovalResult()
data class Failed(val reason: Reason) : VerifyProxyApprovalResult()
enum class Reason {
REQUEST_ID_MISMATCH,
DOMAIN_MISMATCH,
UNKNOWN_APPROVER,
NOT_TRUSTED,
BAD_SIGNATURE,
EXPIRED,
}
}
/**
* Verify a `linkApproveByProxy` against the originating
* `approvalNeeded` and the host's freshest profile blob. Returns a
* tagged result rather than throwing — callers usually want to log
* the reason before deciding what to surface to the user.
*
* Order of checks:
*
* 1. requestId match (replay defense)
* 2. domain match (cross-app confusion defense)
* 3. approver resolves to a `clients[]` entry
* 4. approver is in `trustedApproverFingerprints[]` AND has the
* `trustedApprover` flag (cross-checked via `isTrustedApprover`)
* 5. expiresAt in the future
* 6. Ed25519 signature verifies against `clients[].identityPublicKey`
*
* Hosts MUST refetch the profile blob fresh before calling this — see
* the FR §5 "approver-revocation propagation" rationale.
*/
fun verifyProxyApproval(
crypto: CryptoProvider,
request: ApprovalRequestFrame,
approval: ProxyApprovalFrame,
profile: CanonicalProfileBlob,
now: Long = System.currentTimeMillis(),
): VerifyProxyApprovalResult {
if (approval.requestId != request.requestId) {
return VerifyProxyApprovalResult.Failed(
VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH,
)
}
if (approval.domain != request.domain) {
return VerifyProxyApprovalResult.Failed(
VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH,
)
}
val approver = findClientByFingerprint(profile, approval.approverFingerprint)
?: return VerifyProxyApprovalResult.Failed(
VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER,
)
if (!isTrustedApprover(profile, approval.approverFingerprint)) {
return VerifyProxyApprovalResult.Failed(
VerifyProxyApprovalResult.Reason.NOT_TRUSTED,
)
}
if (now > request.expiresAt) {
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.EXPIRED)
}
val pubkey = try {
hexToBytes(approver.identityPublicKey)
} catch (_: Exception) {
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
}
val sig = try {
hexToBytes(approval.signature)
} catch (_: Exception) {
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
}
if (pubkey.size != 32 || sig.size != 64) {
return VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
}
val payload = canonicalApprovalSigningBytes(
domain = approval.domain,
requestId = approval.requestId,
hostFingerprint = request.hostFingerprint,
requestingDeviceFingerprint = request.requestingDevice.fingerprint,
decision = approval.decision,
)
val ok = crypto.verify(pubkey, payload, sig)
return if (ok) VerifyProxyApprovalResult.Ok(approver)
else VerifyProxyApprovalResult.Failed(VerifyProxyApprovalResult.Reason.BAD_SIGNATURE)
}
/**
* Build the canonical signing payload bytes for a proxy approval.
*
* Format (length-prefixed UTF-8, big-endian u16 lengths):
*
* u16(len(domain)) || domain
* u16(len(requestId)) || requestId
* u16(len(hostFp)) || hostFingerprint
* u16(len(requestFp)) || requestingDeviceFingerprint
* u16(len(decision)) || decision
*
* This is the EXACT byte layout `@shade/sdk`'s
* `canonicalApprovalSigningBytes` produces, ensuring an Android-signed
* approval verifies on a TS host and vice versa.
*/
fun canonicalApprovalSigningBytes(
domain: String,
requestId: String,
hostFingerprint: String,
requestingDeviceFingerprint: String,
decision: String,
): ByteArray {
val fields = listOf(
domain.toByteArray(Charsets.UTF_8),
requestId.toByteArray(Charsets.UTF_8),
hostFingerprint.toByteArray(Charsets.UTF_8),
requestingDeviceFingerprint.toByteArray(Charsets.UTF_8),
decision.toByteArray(Charsets.UTF_8),
)
for (f in fields) {
require(f.size <= 0xFFFF) { "signing field too long: ${f.size} bytes (max 65535)" }
}
val total = fields.sumOf { 2 + it.size }
val buf = ByteBuffer.allocate(total).order(ByteOrder.BIG_ENDIAN)
for (f in fields) {
buf.putShort(f.size.toShort())
buf.put(f)
}
return buf.array()
}
private fun hexToBytes(hex: String): ByteArray {
require(hex.length % 2 == 0) { "hex length must be even" }
require(hex.all { it.isDigit() || it in 'a'..'f' }) { "hex must be lowercase 0-9a-f" }
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
out[i] = ((Character.digit(hex[i * 2], 16) shl 4) +
Character.digit(hex[i * 2 + 1], 16)).toByte()
}
return out
}

View File

@@ -0,0 +1,307 @@
package no.zyon.shade.approval
import org.json.JSONArray
import org.json.JSONObject
/**
* V4.10 — canonical profile-blob schema. Mirror
* `@shade/sdk/approval.ts` byte-for-byte: same field names, same
* JSON shape, same denormalization invariants.
*
* The blob is the AEAD plaintext stored in the V4.9 profile slot. It
* holds the user's list of paired hosts + clients; cross-host
* approval routing reads `clients[]` to find trusted approvers when
* a headless host needs to dispatch a `linkRequest` to a phone.
*
* Mutators (`upsertHost`, `setTrustedApprover`, ...) are immutable —
* they return a new blob and never modify the input. The denormalized
* `trustedApproverFingerprints[]` is rederived on every mutation so it
* can never drift from the per-client `trustedApprover` flag.
*/
/** A host: a device that receives `linkRequest` frames and runs pairing. */
data class ProfileHostEntry(
val address: String,
val name: String,
/** Open enum: `"desktop" | "server" | "laptop" | ...`. */
val kind: String,
val addedAt: Long,
)
/**
* A client: a device that initiates link/approval flows and may
* proxy-approve when `trustedApprover == true`. Stores both the
* 32-byte Ed25519 identity public key (hex) and the safety-number
* fingerprint — the public key is what `verifyProxyApproval` checks
* signatures against; the fingerprint is what UIs display.
*/
data class ProfileClientEntry(
val address: String,
/** 32-byte Ed25519 long-term identity public key, lowercase hex (64 chars). */
val identityPublicKey: String,
/** Safety-number fingerprint of the identity key (computeFingerprint output). */
val identityFingerprint: String,
val name: String,
/** Open enum: `"mobile" | "tablet" | "browser" | ...`. */
val kind: String,
val addedAt: Long,
val trustedApprover: Boolean = false,
)
/**
* Canonical profile blob. `version=1` is the only currently-supported
* shape; bump when an incompatible field is added. Unknown top-level
* fields are dropped on parse — additive changes need a coordinated
* schema bump on both platforms.
*/
data class CanonicalProfileBlob(
val version: Int = 1,
val hosts: List<ProfileHostEntry> = emptyList(),
val clients: List<ProfileClientEntry> = emptyList(),
/** Denormalized list of trusted-approver fingerprints. Rederived on mutate. */
val trustedApproverFingerprints: List<String> = emptyList(),
val updatedAt: Long = 0,
/** Optional hex-encoded pubkey of the writer; informational only. */
val signedBy: String? = null,
)
/** Build a fresh empty profile blob with `updatedAt = now ?? System.currentTimeMillis()`. */
fun emptyCanonicalProfile(now: Long? = null): CanonicalProfileBlob =
CanonicalProfileBlob(updatedAt = now ?: System.currentTimeMillis())
/**
* Decode a profile-blob plaintext (the AEAD-opened bytes) into the
* canonical shape. Throws `IllegalArgumentException` on malformed JSON
* or wrong shape.
*/
fun parseCanonicalProfile(plaintext: ByteArray): CanonicalProfileBlob =
parseCanonicalProfile(plaintext.toString(Charsets.UTF_8))
fun parseCanonicalProfile(plaintext: String): CanonicalProfileBlob {
val obj = try {
JSONObject(plaintext)
} catch (e: Exception) {
throw IllegalArgumentException("profile blob is not valid JSON: ${e.message}")
}
val version = obj.optInt("version", -1)
require(version == 1) { "unsupported profile blob version: $version" }
val hosts = parseArray(obj.optJSONArray("hosts"), "hosts", ::parseHostEntry)
val clients = parseArray(obj.optJSONArray("clients"), "clients", ::parseClientEntry)
val trustedApproverFingerprints = parseStringArray(
obj.optJSONArray("trustedApproverFingerprints"),
"trustedApproverFingerprints",
)
val updatedAt = if (obj.has("updatedAt") && !obj.isNull("updatedAt"))
obj.getLong("updatedAt") else 0L
val signedBy = obj.optString("signedBy", "").takeIf { it.isNotEmpty() }
return CanonicalProfileBlob(
version = 1,
hosts = hosts,
clients = clients,
trustedApproverFingerprints = trustedApproverFingerprints,
updatedAt = updatedAt,
signedBy = signedBy,
)
}
/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
fun serializeCanonicalProfile(blob: CanonicalProfileBlob): ByteArray {
val json = JSONObject()
json.put("version", blob.version)
json.put("hosts", JSONArray().apply {
blob.hosts.forEach { put(hostEntryToJson(it)) }
})
json.put("clients", JSONArray().apply {
blob.clients.forEach { put(clientEntryToJson(it)) }
})
json.put("trustedApproverFingerprints", JSONArray(blob.trustedApproverFingerprints))
json.put("updatedAt", blob.updatedAt)
if (blob.signedBy != null) json.put("signedBy", blob.signedBy)
return json.toString().toByteArray(Charsets.UTF_8)
}
private fun parseHostEntry(o: JSONObject): ProfileHostEntry =
ProfileHostEntry(
address = o.requireString("address", "hosts"),
name = o.requireString("name", "hosts"),
kind = o.requireString("kind", "hosts"),
addedAt = o.requireLong("addedAt", "hosts"),
)
private fun parseClientEntry(o: JSONObject): ProfileClientEntry {
val identityPublicKey = o.requireString("identityPublicKey", "clients")
require(identityPublicKey.matches(Regex("^[0-9a-f]{64}$"))) {
"clients[].identityPublicKey must be 64 lowercase hex chars"
}
return ProfileClientEntry(
address = o.requireString("address", "clients"),
identityPublicKey = identityPublicKey,
identityFingerprint = o.requireString("identityFingerprint", "clients"),
name = o.requireString("name", "clients"),
kind = o.requireString("kind", "clients"),
addedAt = o.requireLong("addedAt", "clients"),
trustedApprover = o.optBoolean("trustedApprover", false),
)
}
private fun hostEntryToJson(e: ProfileHostEntry): JSONObject = JSONObject().apply {
put("address", e.address)
put("name", e.name)
put("kind", e.kind)
put("addedAt", e.addedAt)
}
private fun clientEntryToJson(e: ProfileClientEntry): JSONObject = JSONObject().apply {
put("address", e.address)
put("identityPublicKey", e.identityPublicKey)
put("identityFingerprint", e.identityFingerprint)
put("name", e.name)
put("kind", e.kind)
put("addedAt", e.addedAt)
if (e.trustedApprover) put("trustedApprover", true)
}
private fun <T> parseArray(
arr: JSONArray?,
field: String,
parse: (JSONObject) -> T,
): List<T> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val item = arr.opt(i)
require(item is JSONObject) { "$field[$i] must be an object" }
parse(item)
}
}
private fun parseStringArray(arr: JSONArray?, field: String): List<String> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val item = arr.opt(i)
require(item is String) { "$field[$i] must be a string" }
item
}
}
private fun JSONObject.requireString(key: String, ctx: String): String {
require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
val v = get(key)
require(v is String) { "$ctx[].$key must be a string" }
return v
}
private fun JSONObject.requireLong(key: String, ctx: String): Long {
require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
return when (val v = get(key)) {
is Number -> v.toLong()
else -> throw IllegalArgumentException("$ctx[].$key must be a number")
}
}
// ─── Mutators (immutable; return new blob, never mutate input) ──
/** Insert or replace a host entry by address. Bumps `updatedAt`. */
fun upsertHost(
blob: CanonicalProfileBlob,
host: ProfileHostEntry,
now: Long? = null,
): CanonicalProfileBlob {
val hosts = blob.hosts.filter { it.address != host.address } + host
return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
}
/** Remove the host with the given address, if any. */
fun removeHost(
blob: CanonicalProfileBlob,
address: String,
now: Long? = null,
): CanonicalProfileBlob {
val hosts = blob.hosts.filter { it.address != address }
if (hosts.size == blob.hosts.size) return blob
return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
}
/**
* Insert or replace a client entry by `identityFingerprint`. Re-derives
* `trustedApproverFingerprints` from the resulting `clients[]` so the
* denormalized list never drifts.
*/
fun upsertClient(
blob: CanonicalProfileBlob,
client: ProfileClientEntry,
now: Long? = null,
): CanonicalProfileBlob {
val clients = blob.clients
.filter { it.identityFingerprint != client.identityFingerprint } + client
return blob.copy(
clients = clients,
trustedApproverFingerprints = deriveTrustedApprovers(clients),
updatedAt = now ?: System.currentTimeMillis(),
)
}
/** Remove the client with the given identityFingerprint, if any. */
fun removeClient(
blob: CanonicalProfileBlob,
identityFingerprint: String,
now: Long? = null,
): CanonicalProfileBlob {
val clients = blob.clients.filter { it.identityFingerprint != identityFingerprint }
if (clients.size == blob.clients.size) return blob
return blob.copy(
clients = clients,
trustedApproverFingerprints = deriveTrustedApprovers(clients),
updatedAt = now ?: System.currentTimeMillis(),
)
}
/**
* Toggle the `trustedApprover` flag on a client by fingerprint.
* Returns the input unchanged if fingerprint isn't found OR the
* desired state already matches (no spurious updatedAt bump).
*/
fun setTrustedApprover(
blob: CanonicalProfileBlob,
identityFingerprint: String,
trusted: Boolean,
now: Long? = null,
): CanonicalProfileBlob {
var touched = false
val clients = blob.clients.map { c ->
if (c.identityFingerprint != identityFingerprint) c
else if (c.trustedApprover == trusted) c
else {
touched = true
c.copy(trustedApprover = trusted)
}
}
if (!touched) return blob
return blob.copy(
clients = clients,
trustedApproverFingerprints = deriveTrustedApprovers(clients),
updatedAt = now ?: System.currentTimeMillis(),
)
}
/**
* True iff the given fingerprint resolves to a client with both
* `trustedApprover == true` AND an entry in `trustedApproverFingerprints[]`.
*/
fun isTrustedApprover(blob: CanonicalProfileBlob, identityFingerprint: String): Boolean {
if (!blob.trustedApproverFingerprints.contains(identityFingerprint)) return false
val c = findClientByFingerprint(blob, identityFingerprint) ?: return false
return c.trustedApprover
}
fun findClientByFingerprint(
blob: CanonicalProfileBlob,
identityFingerprint: String,
): ProfileClientEntry? = blob.clients.firstOrNull { it.identityFingerprint == identityFingerprint }
fun findClientByAddress(blob: CanonicalProfileBlob, address: String): ProfileClientEntry? =
blob.clients.firstOrNull { it.address == address }
private fun deriveTrustedApprovers(clients: List<ProfileClientEntry>): List<String> =
clients.filter { it.trustedApprover }.map { it.identityFingerprint }

View File

@@ -0,0 +1,94 @@
package no.zyon.shade.blob
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AEAD wrapper for the V4.9 profile blob.
*
* Wire format for one ciphertext blob:
* `nonce(12) || ciphertext(N) || tag(16)`
*
* Mirror `@shade/storage-encrypted/crypto/aead.ts` byte-for-byte. The
* relay stores this as a single opaque BLOB column; AAD is reconstructed
* at read-time as `"shade-profile-aad-v1:" + slotIdHex` and is NOT
* stored on the relay.
*/
const val BLOB_AEAD_NONCE_LEN = 12
const val BLOB_AEAD_TAG_LEN = 16
private const val MIN_CIPHERTEXT_LEN = BLOB_AEAD_NONCE_LEN + BLOB_AEAD_TAG_LEN
/**
* Seal a plaintext blob. Returns `nonce || ct||tag` ready for direct
* blob storage. The caller supplies the nonce so this function is
* deterministic — the high-level Profile namespace generates a fresh
* 12-byte random nonce per write to keep (key, nonce, plaintext)
* unique across re-uploads.
*/
fun aeadSeal(
key: ByteArray,
nonce: ByteArray,
plaintext: ByteArray,
aad: ByteArray,
): ByteArray {
require(key.size == 32) { "AES-256-GCM key must be 32 bytes" }
require(nonce.size == BLOB_AEAD_NONCE_LEN) {
"nonce must be $BLOB_AEAD_NONCE_LEN bytes"
}
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, nonce)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec)
cipher.updateAAD(aad)
val ctTag = cipher.doFinal(plaintext)
val out = ByteArray(BLOB_AEAD_NONCE_LEN + ctTag.size)
System.arraycopy(nonce, 0, out, 0, BLOB_AEAD_NONCE_LEN)
System.arraycopy(ctTag, 0, out, BLOB_AEAD_NONCE_LEN, ctTag.size)
return out
}
/**
* Open a `nonce || ct||tag` blob and return the plaintext. Throws on
* tamper (AEAD tag mismatch) or short input. The caller may pass an
* `expectedNonce` to enforce a deterministic nonce — mismatch throws
* before the AEAD even runs (defense-in-depth against a relay returning
* the wrong slot's blob).
*/
fun aeadOpen(
key: ByteArray,
blob: ByteArray,
aad: ByteArray,
expectedNonce: ByteArray? = null,
): ByteArray {
require(key.size == 32) { "AES-256-GCM key must be 32 bytes" }
require(blob.size >= MIN_CIPHERTEXT_LEN) { "ciphertext blob too short" }
val nonce = blob.copyOfRange(0, BLOB_AEAD_NONCE_LEN)
if (expectedNonce != null && !ctEqual(nonce, expectedNonce)) {
throw IllegalArgumentException(
"nonce mismatch — ciphertext blob has been tampered or row identity changed",
)
}
val ctTag = blob.copyOfRange(BLOB_AEAD_NONCE_LEN, blob.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, nonce)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), spec)
cipher.updateAAD(aad)
return cipher.doFinal(ctTag)
}
private fun ctEqual(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var diff = 0
for (i in a.indices) {
diff = diff or (a[i].toInt() xor b[i].toInt())
}
return diff == 0
}
/** Build the AAD for a given slotId hex string. */
fun blobAadForSlot(slotIdHex: String): ByteArray {
require(slotIdHex.length == 64) { "slotIdHex must be 64 hex chars" }
return "shade-profile-aad-v1:$slotIdHex".toByteArray(Charsets.UTF_8)
}

View File

@@ -0,0 +1,250 @@
package no.zyon.shade.blob
import no.zyon.shade.crypto.CryptoProvider
import org.json.JSONObject
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.Base64
/**
* Low-level HTTP client for the V4.9 encrypted-blob primitive
* (`/v1/blob/<slotId>`). Mirror `@shade/inbox`'s `BlobClient` —
* stateless, reusable, and protocol-compatible with the TypeScript
* relay endpoints.
*
* The client doesn't care what the blob bytes mean — it just
* transports them. Higher-level wrappers (e.g. `Profile`) compose
* this client with AEAD-sealing of the actual payload.
*
* Auth model: every PUT/DELETE carries a detached Ed25519 signature
* (base64) over a canonical-JSON form of the request body. The
* canonicalization is deterministic — sorted keys, compact JSON, no
* trailing whitespace — so signatures generated on Kotlin verify on
* the TS server.
*/
class BlobClient(
private val baseUrl: String,
private val crypto: CryptoProvider,
private val httpClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build(),
) {
data class GetResult(
val blob: ByteArray,
val etag: String,
val updatedAt: Long,
)
data class PutResult(
val created: Boolean,
val etag: String,
val updatedAt: Long,
)
/**
* Read a slot. Returns null if no blob has ever been written there
* (or if it was DELETE'd). GET is unauthenticated by design — the
* slotId is itself a 256-bit secret derived from the master key.
*/
fun get(slotIdHex: String): GetResult? {
validateSlotIdHex(slotIdHex)
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
.GET()
.build()
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
if (res.statusCode() == 404) return null
val json = parseJson(res, "GET")
val blob = Base64.getDecoder().decode(json.getString("blob"))
return GetResult(
blob = blob,
etag = json.getString("etag"),
updatedAt = json.getLong("updatedAt"),
)
}
/**
* Create or update a slot.
*
* `ifMatch` semantics:
* - `null`: create-only. Slot must be empty (else 409).
* - `<etag-string>`: compare-and-swap. Must match (else 412).
* - `"*"`: unconditional overwrite. Slot must already exist (else 412).
*/
fun put(
slotIdHex: String,
blob: ByteArray,
signingSeed: ByteArray,
ownerPubkey: ByteArray,
ifMatch: String? = null,
): PutResult {
validateSlotIdHex(slotIdHex)
require(blob.isNotEmpty()) { "Empty blob" }
require(ownerPubkey.size == 32) { "ownerPubkey must be 32 bytes (Ed25519)" }
require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }
// Canonical form for signing: sorted keys, slotId included,
// signature field absent. The wire body strips slotId (it's
// in the URL) but the signature is computed over the
// slotId-bearing form.
val signedAt = System.currentTimeMillis()
val canonical = sortedMapOf<String, Any>().apply {
put("blob", Base64.getEncoder().encodeToString(blob))
if (ifMatch != null) put("ifMatch", ifMatch)
put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
put("signedAt", signedAt)
put("slotId", slotIdHex)
}
val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
val sig = crypto.sign(signingSeed, canonicalBytes)
// Wire body: same as canonical minus slotId, plus signature.
val wire = JSONObject()
wire.put("blob", Base64.getEncoder().encodeToString(blob))
if (ifMatch != null) wire.put("ifMatch", ifMatch)
wire.put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
wire.put("signedAt", signedAt)
wire.put("signature", Base64.getEncoder().encodeToString(sig))
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
.header("content-type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(wire.toString()))
.build()
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
val json = parseJson(res, "PUT")
return PutResult(
created = json.optBoolean("created"),
etag = json.getString("etag"),
updatedAt = json.getLong("updatedAt"),
)
}
/**
* Delete a slot. The next PUT TOFU-claims it again, possibly under
* a fresh signing key (e.g. after rotation). Used by "forget
* everything" flows.
*/
fun delete(slotIdHex: String, signingSeed: ByteArray): Boolean {
validateSlotIdHex(slotIdHex)
require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }
val signedAt = System.currentTimeMillis()
val canonical = sortedMapOf<String, Any>().apply {
put("signedAt", signedAt)
put("slotId", slotIdHex)
}
val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
val sig = crypto.sign(signingSeed, canonicalBytes)
val wire = JSONObject()
wire.put("signedAt", signedAt)
wire.put("signature", Base64.getEncoder().encodeToString(sig))
val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
.header("content-type", "application/json")
.method("DELETE", HttpRequest.BodyPublishers.ofString(wire.toString()))
.build()
val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
val json = parseJson(res, "DELETE")
return json.optBoolean("ok", false)
}
private fun parseJson(res: HttpResponse<String>, op: String): JSONObject {
val text = res.body() ?: ""
val json = if (text.isEmpty()) JSONObject() else try {
JSONObject(text)
} catch (e: Exception) {
throw BlobClientException(
code = "SHADE_NETWORK",
statusCode = res.statusCode(),
message = "Blob $op response not JSON: ${text.take(200)}",
)
}
if (res.statusCode() !in 200..299) {
throw BlobClientException(
code = json.optString("code", "SHADE_NETWORK"),
statusCode = res.statusCode(),
message = json.optString("message", text),
)
}
return json
}
private fun validateSlotIdHex(s: String) {
require(s.matches(Regex("^[0-9a-f]{64}$"))) {
"slotIdHex must be 64 lowercase hex chars (32 bytes)"
}
}
private fun joinUrl(base: String, path: String): String =
when {
base.endsWith("/") && path.startsWith("/") -> base + path.substring(1)
!base.endsWith("/") && !path.startsWith("/") -> "$base/$path"
else -> base + path
}
}
/**
* Mirror of TS `signPayload`'s canonicalization: sorted keys, compact
* JSON, signature field absent. Only handles the subset of types we
* need (strings + longs + base64 strings) — keeping the implementation
* narrow so it can't accidentally diverge from `JSON.stringify` on
* structurally-different inputs.
*/
internal fun canonicalJson(map: Map<String, Any>): String {
val sb = StringBuilder()
sb.append('{')
var first = true
for ((key, value) in map.toSortedMap()) {
if (!first) sb.append(',')
first = false
appendJsonString(sb, key)
sb.append(':')
appendJsonValue(sb, value)
}
sb.append('}')
return sb.toString()
}
private fun appendJsonValue(sb: StringBuilder, value: Any) {
when (value) {
is String -> appendJsonString(sb, value)
is Long, is Int -> sb.append(value.toString())
is Boolean -> sb.append(value.toString())
else -> throw IllegalArgumentException(
"canonicalJson: unsupported value type ${value::class.java}",
)
}
}
private fun appendJsonString(sb: StringBuilder, s: String) {
sb.append('"')
for (c in s) {
when (c) {
'\\' -> sb.append("\\\\")
'"' -> sb.append("\\\"")
'\b' -> sb.append("\\b")
'\u000C' -> sb.append("\\f")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> {
if (c.code < 0x20) {
sb.append("\\u").append("%04x".format(c.code))
} else {
sb.append(c)
}
}
}
}
sb.append('"')
}
class BlobClientException(
val code: String,
val statusCode: Int,
message: String,
) : RuntimeException("[$code @ $statusCode] $message")

View File

@@ -0,0 +1,88 @@
package no.zyon.shade.blob
import com.google.crypto.tink.subtle.Ed25519Sign
import no.zyon.shade.crypto.CryptoProvider
/**
* V4.9 — relay-side encrypted blob primitive: deterministic
* derivations from a 32-byte master key + per-app namespace string.
*
* Mirror `@shade/storage-encrypted/crypto/kdf.ts` byte-for-byte.
* Reference vectors: `test-vectors/blob-storage.json`.
*
* Three independent 32-byte derivations:
*
* slotId = HKDF(masterKey, info=`shade-blob-slot-v1:<app>`) // relay-visible opaque ID
* blobKey = HKDF(masterKey, info=`shade-blob-key-v1:<app>`) // AEAD key for the blob
* sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:<app>`) // Ed25519 owner signing seed
*
* `app` is a caller-supplied namespace string — distinct apps under
* the same master MUST pass different values (e.g. `prism-profile-v1`)
* so they don't collide on the same slot.
*
* The signing seed is an Ed25519 *seed* in the @noble/curves convention:
* `pubkey = Ed25519.publicFromSeed(seed)` is what the relay TOFU-stores
* on the first PUT and verifies subsequent writes against.
*/
private const val SLOT_INFO_PREFIX = "shade-blob-slot-v1:"
private const val BLOB_KEY_INFO_PREFIX = "shade-blob-key-v1:"
private const val SIG_SEED_INFO_PREFIX = "shade-blob-sig-v1:"
private const val DERIVED_LEN = 32
/** Lower-hex 64-char slotId derived deterministically from the master key. */
fun deriveBlobSlotId(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
require(app.isNotEmpty()) { "app namespace must be non-empty" }
return crypto.hkdf(
masterKey,
ByteArray(0),
(SLOT_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
DERIVED_LEN,
)
}
/** AEAD key for sealing/opening the blob. The slotId hex is bound as AAD. */
fun deriveBlobKey(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
require(app.isNotEmpty()) { "app namespace must be non-empty" }
return crypto.hkdf(
masterKey,
ByteArray(0),
(BLOB_KEY_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
DERIVED_LEN,
)
}
/**
* 32-byte Ed25519 signing seed. The pubkey, derived deterministically
* from the seed, is what the relay TOFU-stores on the first PUT.
*/
fun deriveBlobSigningSeed(crypto: CryptoProvider, masterKey: ByteArray, app: String): ByteArray {
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
require(app.isNotEmpty()) { "app namespace must be non-empty" }
return crypto.hkdf(
masterKey,
ByteArray(0),
(SIG_SEED_INFO_PREFIX + app).toByteArray(Charsets.UTF_8),
DERIVED_LEN,
)
}
/**
* Recover the Ed25519 public key from a 32-byte seed. Mirrors
* `@shade/crypto-web`'s `ed25519PublicKeyFromSeed`. Tink's
* `Ed25519Sign.KeyPair.newKeyPairFromSeed(seed)` exposes both halves;
* we discard the private half here.
*/
fun ed25519PublicKeyFromSeed(seed: ByteArray): ByteArray {
require(seed.size == 32) { "Ed25519 seed must be 32 bytes" }
return Ed25519Sign.KeyPair.newKeyPairFromSeed(seed).publicKey
}
/** Convert a 32-byte slotId into the lowercase-hex wire form (64 chars). */
fun slotIdToHex(slotId: ByteArray): String {
require(slotId.size == 32) { "slotId must be 32 bytes" }
return slotId.joinToString("") { "%02x".format(it) }
}

View File

@@ -0,0 +1,118 @@
package no.zyon.shade.blob
import no.zyon.shade.crypto.CryptoProvider
/**
* V4.9 — high-level profile namespace. Mirror
* `@shade/sdk`'s `createProfileNamespace`. The relay never sees
* plaintext; AAD binds the slotId so a relay returning the wrong
* slot's blob fails to open.
*
* Usage:
*
* val crypto = TinkProvider()
* val masterKey = deriveMasterKey("password", salt) // V4.5 KDF chain
* val profile = createProfileNamespace(
* baseUrl = "https://shade.example/",
* crypto = crypto,
* masterKey = masterKey,
* app = "prism-profile-v1",
* )
*
* val current = profile.get() // null if no blob yet
* profile.put(serializeCanonicalProfile(...), ifMatch = current?.etag)
* profile.delete()
*
* Apps with the same master key + app namespace converge on the same
* slot — that's the whole point: a brand new device with the right
* credentials can locate, decrypt, and update the blob.
*/
class ProfileNamespace internal constructor(
/** Lower-hex 64-char slotId. Stable per (master, app). */
val slotIdHex: String,
private val blobKey: ByteArray,
private val signingSeed: ByteArray,
private val ownerPubkey: ByteArray,
private val aad: ByteArray,
private val client: BlobClient,
private val crypto: CryptoProvider,
) {
data class GetResult(
val plaintext: ByteArray,
val etag: String,
val updatedAt: Long,
)
data class PutResult(
val created: Boolean,
val etag: String,
val updatedAt: Long,
)
/** Returns null when the slot has never been written (or was deleted). */
fun get(): GetResult? {
val raw = client.get(slotIdHex) ?: return null
val plaintext = aeadOpen(blobKey, raw.blob, aad)
return GetResult(plaintext = plaintext, etag = raw.etag, updatedAt = raw.updatedAt)
}
/**
* Create or update. `ifMatch`:
* - null: create-only (fails with 409 if slot populated).
* - "<etag>": CAS (fails with 412 on stale).
* - "*": unconditional overwrite.
*/
fun put(plaintext: ByteArray, ifMatch: String? = null): PutResult {
// Fresh random nonce per write — see `BlobAead`. Re-uploading
// the same plaintext after a transient error reuses neither
// (key, nonce, plaintext) nor (key, nonce).
val nonce = crypto.randomBytes(BLOB_AEAD_NONCE_LEN)
val sealed = aeadSeal(blobKey, nonce, plaintext, aad)
val r = client.put(
slotIdHex = slotIdHex,
blob = sealed,
signingSeed = signingSeed,
ownerPubkey = ownerPubkey,
ifMatch = ifMatch,
)
return PutResult(created = r.created, etag = r.etag, updatedAt = r.updatedAt)
}
/** "Forget everything" path — the next PUT TOFU-claims it again. */
fun delete(): Boolean = client.delete(slotIdHex, signingSeed)
}
/**
* Build a Profile namespace bound to a (master key, app) pair. The
* derivations are deterministic: any device with the same master
* key + app namespace produces the same slot, so a fresh device
* after credential entry can locate the existing profile blob.
*/
fun createProfileNamespace(
baseUrl: String,
crypto: CryptoProvider,
masterKey: ByteArray,
app: String,
): ProfileNamespace {
require(masterKey.size == 32) { "masterKey must be 32 bytes" }
require(app.isNotEmpty()) { "app namespace must be non-empty" }
val slotIdBytes = deriveBlobSlotId(crypto, masterKey, app)
val slotIdHex = slotIdToHex(slotIdBytes)
val blobKey = deriveBlobKey(crypto, masterKey, app)
val signingSeed = deriveBlobSigningSeed(crypto, masterKey, app)
val ownerPubkey = ed25519PublicKeyFromSeed(signingSeed)
val aad = blobAadForSlot(slotIdHex)
val client = BlobClient(baseUrl = baseUrl, crypto = crypto)
return ProfileNamespace(
slotIdHex = slotIdHex,
blobKey = blobKey,
signingSeed = signingSeed,
ownerPubkey = ownerPubkey,
aad = aad,
client = client,
crypto = crypto,
)
}

View File

@@ -0,0 +1,133 @@
package no.zyon.shade.serialization
import no.zyon.shade.types.ChainState
import no.zyon.shade.types.IdentityKeyPair
import no.zyon.shade.types.KeyPair
import no.zyon.shade.types.OneTimePreKey
import no.zyon.shade.types.SessionState
import no.zyon.shade.types.SignedPreKey
import org.json.JSONObject
import java.util.Base64 as JdkBase64
/**
* Plain-JSON serialization for the persisted protocol state types
* (`IdentityKeyPair`, `SignedPreKey`, `OneTimePreKey`, `SessionState`).
*
* The on-disk shape is for at-rest storage only — it does NOT need
* to round-trip across platforms (TS uses its own JSON shape via
* `@shade/core/serialization`). What matters is that the Kotlin
* round-trip (`serialize` then `deserialize`) preserves every byte.
*
* Both Android-targeted (`shade-android-keystore`) and pure-JVM
* (`shade-android` tests) callers use this — the function works
* without any `android.*` imports so it compiles in both.
*/
object SessionStateJson {
fun serialize(state: SessionState): String {
val o = JSONObject()
o.put("remoteIdentityKey", b64(state.remoteIdentityKey))
o.put("rootKey", b64(state.rootKey))
o.put("sendChain", chainToJson(state.sendChain))
if (state.receiveChain != null) o.put("receiveChain", chainToJson(state.receiveChain!!))
o.put("dhSend", keyPairToJson(state.dhSend))
if (state.dhReceive != null) o.put("dhReceive", b64(state.dhReceive!!))
o.put("previousSendCounter", state.previousSendCounter)
val skipped = JSONObject()
for ((k, v) in state.skippedKeys) skipped.put(k, b64(v))
o.put("skippedKeys", skipped)
return o.toString()
}
fun deserialize(s: String): SessionState {
val o = JSONObject(s)
val skipped = mutableMapOf<String, ByteArray>()
val skJson = o.optJSONObject("skippedKeys")
if (skJson != null) {
val it = skJson.keys()
while (it.hasNext()) {
val k = it.next()
skipped[k] = fb64(skJson.getString(k))
}
}
return SessionState(
remoteIdentityKey = fb64(o.getString("remoteIdentityKey")),
rootKey = fb64(o.getString("rootKey")),
sendChain = chainFromJson(o.getJSONObject("sendChain")),
receiveChain = if (o.has("receiveChain"))
chainFromJson(o.getJSONObject("receiveChain")) else null,
dhSend = keyPairFromJson(o.getJSONObject("dhSend")),
dhReceive = if (o.has("dhReceive")) fb64(o.getString("dhReceive")) else null,
previousSendCounter = o.getInt("previousSendCounter"),
skippedKeys = skipped,
)
}
fun serializeIdentityKeyPair(k: IdentityKeyPair): String = JSONObject().apply {
put("signingPublicKey", b64(k.signingPublicKey))
put("signingPrivateKey", b64(k.signingPrivateKey))
put("dhPublicKey", b64(k.dhPublicKey))
put("dhPrivateKey", b64(k.dhPrivateKey))
}.toString()
fun deserializeIdentityKeyPair(s: String): IdentityKeyPair = JSONObject(s).run {
IdentityKeyPair(
signingPublicKey = fb64(getString("signingPublicKey")),
signingPrivateKey = fb64(getString("signingPrivateKey")),
dhPublicKey = fb64(getString("dhPublicKey")),
dhPrivateKey = fb64(getString("dhPrivateKey")),
)
}
fun serializeSignedPreKey(k: SignedPreKey): String = JSONObject().apply {
put("keyId", k.keyId)
put("keyPair", keyPairToJson(k.keyPair))
put("signature", b64(k.signature))
put("timestamp", k.timestamp)
}.toString()
fun deserializeSignedPreKey(s: String): SignedPreKey = JSONObject(s).run {
SignedPreKey(
keyId = getInt("keyId"),
keyPair = keyPairFromJson(getJSONObject("keyPair")),
signature = fb64(getString("signature")),
timestamp = getLong("timestamp"),
)
}
fun serializeOneTimePreKey(k: OneTimePreKey): String = JSONObject().apply {
put("keyId", k.keyId)
put("keyPair", keyPairToJson(k.keyPair))
}.toString()
fun deserializeOneTimePreKey(s: String): OneTimePreKey = JSONObject(s).run {
OneTimePreKey(
keyId = getInt("keyId"),
keyPair = keyPairFromJson(getJSONObject("keyPair")),
)
}
private fun chainToJson(c: ChainState): JSONObject = JSONObject().apply {
put("chainKey", b64(c.chainKey))
put("counter", c.counter)
}
private fun chainFromJson(o: JSONObject): ChainState =
ChainState(chainKey = fb64(o.getString("chainKey")), counter = o.getInt("counter"))
private fun keyPairToJson(k: KeyPair): JSONObject = JSONObject().apply {
put("publicKey", b64(k.publicKey))
put("privateKey", b64(k.privateKey))
}
private fun keyPairFromJson(o: JSONObject): KeyPair = KeyPair(
publicKey = fb64(o.getString("publicKey")),
privateKey = fb64(o.getString("privateKey")),
)
// android.util.Base64 isn't on the JVM classpath; java.util.Base64
// is available on both modern JVM and Android API 26+. Use JDK
// Base64 throughout — it's present on both targets.
private fun b64(b: ByteArray): String = JdkBase64.getEncoder().encodeToString(b)
private fun fb64(s: String): ByteArray = JdkBase64.getDecoder().decode(s)
}

View File

@@ -0,0 +1,107 @@
package no.zyon.shade.storage
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.generators.SCrypt
import org.bouncycastle.crypto.params.Argon2Parameters
import java.text.Normalizer
/**
* Password / PIN key-derivation primitives. Mirror
* `@shade/storage-encrypted/crypto/kdf` (`deriveMasterKey` /
* `deriveMasterKeyArgon2id`) byte-for-byte — Tink doesn't ship password
* KDFs so we wrap Bouncy Castle.
*
* Both functions normalize string passphrases to NFKC before hashing,
* matching the TS implementation's `passphrase.normalize('NFKC')`.
* This ensures the same password typed on different OSes/keyboards
* produces the same master key regardless of which compatibility-form
* the input arrived in.
*
* The reference test-vector lives in `test-vectors/storage-encryption.json`
* and `test-vectors/blob-storage.json`. Cross-platform parity is gated
* by `CrossPlatformVectorTest`.
*/
/** scrypt parameters. Defaults match `DEFAULT_SCRYPT` in TS. */
data class ScryptParams(
/** CPU/memory cost. Must be a power of 2. */
val n: Int = 1 shl 17,
/** Block size. */
val r: Int = 8,
/** Parallelization. */
val p: Int = 1,
/** Output length in bytes. */
val dkLen: Int = 32,
)
/** Argon2id parameters. Defaults match `DEFAULT_ARGON2ID` in TS. */
data class Argon2idParams(
/** Memory cost in KiB. Default 64 MiB. */
val m: Int = 64 * 1024,
/** Time cost (iterations). Default 3. */
val t: Int = 3,
/** Parallelism. Default 1. */
val p: Int = 1,
/** Output length in bytes. Default 32. */
val dkLen: Int = 32,
)
/**
* Derive a 32-byte master key from a passphrase + salt via scrypt.
* Salt MUST be at least 16 bytes and persisted alongside the
* encrypted database. Throws on empty passphrase.
*/
fun deriveMasterKey(
passphrase: String,
salt: ByteArray,
params: ScryptParams = ScryptParams(),
): ByteArray {
require(passphrase.isNotEmpty()) { "passphrase must be non-empty" }
require(salt.size >= 16) { "salt must be at least 16 bytes" }
val nfkc = Normalizer.normalize(passphrase, Normalizer.Form.NFKC)
val pwBytes = nfkc.toByteArray(Charsets.UTF_8)
return SCrypt.generate(pwBytes, salt, params.n, params.r, params.p, params.dkLen)
}
/**
* Derive a 32-byte master key from a low-entropy secret (PIN) + salt
* via argon2id. Salt MUST be at least 16 bytes. The Bouncy Castle
* `Argon2BytesGenerator` parameters mirror RFC 9106's argon2id mode
* with version 1.3 (`Argon2Parameters.ARGON2_VERSION_13`), which is
* what `@noble/hashes/argon2` produces — keeping cross-platform parity.
*/
fun deriveMasterKeyArgon2id(
secret: String,
salt: ByteArray,
params: Argon2idParams = Argon2idParams(),
): ByteArray {
require(secret.isNotEmpty()) { "argon2id secret must be non-empty" }
require(salt.size >= 16) { "salt must be at least 16 bytes" }
val nfkc = Normalizer.normalize(secret, Normalizer.Form.NFKC)
return deriveMasterKeyArgon2id(nfkc.toByteArray(Charsets.UTF_8), salt, params)
}
/**
* Byte-array overload — useful when the secret is already binary
* (e.g. derived from a hardware token rather than typed) and
* shouldn't be NFKC-normalized as text.
*/
fun deriveMasterKeyArgon2id(
secret: ByteArray,
salt: ByteArray,
params: Argon2idParams = Argon2idParams(),
): ByteArray {
require(secret.isNotEmpty()) { "argon2id secret must be non-empty" }
require(salt.size >= 16) { "salt must be at least 16 bytes" }
val builder = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(params.t)
.withMemoryAsKB(params.m)
.withParallelism(params.p)
.withSalt(salt)
val gen = Argon2BytesGenerator()
gen.init(builder.build())
val out = ByteArray(params.dkLen)
gen.generateBytes(secret, out)
return out
}

View File

@@ -0,0 +1,593 @@
package no.zyon.shade
import no.zyon.shade.approval.ApprovalRequestFrame
import no.zyon.shade.approval.ApprovalRequestingDevice
import no.zyon.shade.approval.CanonicalProfileBlob
import no.zyon.shade.approval.DEFAULT_APPROVAL_DOMAIN
import no.zyon.shade.approval.ProfileClientEntry
import no.zyon.shade.approval.ProfileHostEntry
import no.zyon.shade.approval.ProxyApprovalFrame
import no.zyon.shade.approval.VerifyProxyApprovalResult
import no.zyon.shade.approval.buildApprovalRequest
import no.zyon.shade.approval.canonicalApprovalSigningBytes
import no.zyon.shade.approval.emptyCanonicalProfile
import no.zyon.shade.approval.findClientByAddress
import no.zyon.shade.approval.findClientByFingerprint
import no.zyon.shade.approval.isTrustedApprover
import no.zyon.shade.approval.parseCanonicalProfile
import no.zyon.shade.approval.removeClient
import no.zyon.shade.approval.serializeCanonicalProfile
import no.zyon.shade.approval.setTrustedApprover
import no.zyon.shade.approval.signProxyApproval
import no.zyon.shade.approval.upsertClient
import no.zyon.shade.approval.upsertHost
import no.zyon.shade.approval.verifyProxyApproval
import no.zyon.shade.blob.aeadOpen
import no.zyon.shade.blob.aeadSeal
import no.zyon.shade.blob.blobAadForSlot
import no.zyon.shade.blob.deriveBlobKey
import no.zyon.shade.blob.deriveBlobSigningSeed
import no.zyon.shade.blob.deriveBlobSlotId
import no.zyon.shade.blob.ed25519PublicKeyFromSeed
import no.zyon.shade.blob.slotIdToHex
import no.zyon.shade.crypto.TinkProvider
import no.zyon.shade.storage.Argon2idParams
import no.zyon.shade.storage.ScryptParams
import no.zyon.shade.storage.deriveMasterKey
import no.zyon.shade.storage.deriveMasterKeyArgon2id
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for the V4.9 blob primitive ports + V4.10 approval
* helpers + scrypt/argon2id wrappers. Cross-platform vector parity
* lives in `CrossPlatformVectorTest`; this file tests Kotlin-side
* round-trip behavior independent of the TS reference.
*/
class BlobAndApprovalTest {
private val crypto = TinkProvider()
private fun hex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
// ─── V4.9 blob KDF ─────────────────────────────────────────
@Test
fun deriveBlobSlotIdIsDeterministicPerMasterAndApp() {
val km = ByteArray(32) { it.toByte() }
val a1 = deriveBlobSlotId(crypto, km, "foo")
val a2 = deriveBlobSlotId(crypto, km, "foo")
assertArrayEquals(a1, a2)
val b = deriveBlobSlotId(crypto, km, "bar")
assertFalse(a1.contentEquals(b))
val km2 = ByteArray(32) { (it + 1).toByte() }
val c = deriveBlobSlotId(crypto, km2, "foo")
assertFalse(a1.contentEquals(c))
}
@Test
fun blobKdfHelpersAreIndependent() {
val km = ByteArray(32) { it.toByte() }
val slot = deriveBlobSlotId(crypto, km, "x")
val key = deriveBlobKey(crypto, km, "x")
val seed = deriveBlobSigningSeed(crypto, km, "x")
assertFalse(slot.contentEquals(key))
assertFalse(slot.contentEquals(seed))
assertFalse(key.contentEquals(seed))
}
@Test
fun ed25519PublicKeyFromSeedIsDeterministic() {
val seed = ByteArray(32) { it.toByte() }
val pk1 = ed25519PublicKeyFromSeed(seed)
val pk2 = ed25519PublicKeyFromSeed(seed)
assertArrayEquals(pk1, pk2)
assertEquals(32, pk1.size)
}
@Test
fun slotIdToHexProducesLowercase64Chars() {
val s = ByteArray(32) { 0xab.toByte() }
val hex = slotIdToHex(s)
assertEquals(64, hex.length)
assertEquals("a".repeat(0) + "ab".repeat(32), hex)
}
// ─── V4.9 AEAD round-trip ──────────────────────────────────
@Test
fun aeadSealOpenRoundTrip() {
val key = crypto.randomBytes(32)
val nonce = crypto.randomBytes(12)
val pt = "hello".toByteArray()
val aad = blobAadForSlot("00".repeat(32))
val sealed = aeadSeal(key, nonce, pt, aad)
val opened = aeadOpen(key, sealed, aad)
assertArrayEquals(pt, opened)
}
@Test
fun aeadOpenWithWrongAadFails() {
val key = crypto.randomBytes(32)
val nonce = crypto.randomBytes(12)
val pt = "hello".toByteArray()
val aad1 = blobAadForSlot("00".repeat(32))
val aad2 = blobAadForSlot("ff".repeat(32))
val sealed = aeadSeal(key, nonce, pt, aad1)
try {
aeadOpen(key, sealed, aad2)
org.junit.Assert.fail("expected AEAD to reject wrong AAD")
} catch (_: Exception) {
// expected
}
}
@Test
fun aeadOpenWithExpectedNonceMismatchFails() {
val key = crypto.randomBytes(32)
val nonce = crypto.randomBytes(12)
val wrongNonce = crypto.randomBytes(12)
val pt = "hello".toByteArray()
val aad = blobAadForSlot("00".repeat(32))
val sealed = aeadSeal(key, nonce, pt, aad)
try {
aeadOpen(key, sealed, aad, expectedNonce = wrongNonce)
org.junit.Assert.fail("expected expectedNonce check to reject")
} catch (_: IllegalArgumentException) {
// expected
}
}
// ─── Password KDFs ─────────────────────────────────────────
@Test
fun scryptDerivesDeterministically() {
val pw = "correct-horse-battery-staple"
val salt = ByteArray(16) { 0x42.toByte() }
val k1 = deriveMasterKey(pw, salt, ScryptParams(n = 1024))
val k2 = deriveMasterKey(pw, salt, ScryptParams(n = 1024))
assertArrayEquals(k1, k2)
assertEquals(32, k1.size)
}
@Test
fun argon2idDerivesDeterministically() {
val pw = "1234"
val salt = ByteArray(16) { 0x55.toByte() }
val k1 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2))
val k2 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2))
assertArrayEquals(k1, k2)
assertEquals(32, k1.size)
}
@Test
fun nfkcNormalizationMakesEquivalentInputsConverge() {
// "café" can be encoded either as 'c','a','f','é' (NFC) or
// 'c','a','f','e','́' (NFD). NFKC normalization on both
// should converge to the same bytes.
val nfc = "café"
val nfd = "café"
assertNotEquals(nfc, nfd)
val salt = ByteArray(16) { 1.toByte() }
val k1 = deriveMasterKey(nfc, salt, ScryptParams(n = 1024))
val k2 = deriveMasterKey(nfd, salt, ScryptParams(n = 1024))
assertArrayEquals(k1, k2)
}
// ─── Canonical profile schema ──────────────────────────────
private fun makeClient(name: String, trusted: Boolean = false): Pair<ProfileClientEntry, ByteArray> {
val seed = crypto.randomBytes(32)
val pubkey = ed25519PublicKeyFromSeed(seed)
val fp = "fp-$name-${hex(pubkey).take(8)}"
return ProfileClientEntry(
address = "device:$name",
identityPublicKey = hex(pubkey),
identityFingerprint = fp,
name = name,
kind = "mobile",
addedAt = 1_700_000_000_000L,
trustedApprover = trusted,
) to seed
}
private fun makeHost(): ProfileHostEntry = ProfileHostEntry(
address = "device:host-server",
name = "Server",
kind = "server",
addedAt = 1_700_000_000_000L,
)
@Test
fun emptyCanonicalProfileRoundTrips() {
val blob = emptyCanonicalProfile(now = 123L)
val bytes = serializeCanonicalProfile(blob)
val parsed = parseCanonicalProfile(bytes)
assertEquals(1, parsed.version)
assertTrue(parsed.hosts.isEmpty())
assertTrue(parsed.clients.isEmpty())
assertTrue(parsed.trustedApproverFingerprints.isEmpty())
assertEquals(123L, parsed.updatedAt)
}
@Test
fun upsertClientDenormalizesTrustedApprovers() {
var blob = emptyCanonicalProfile(0)
val (a, _) = makeClient("phone-a", trusted = true)
val (b, _) = makeClient("phone-b", trusted = false)
blob = upsertClient(blob, a)
blob = upsertClient(blob, b)
assertEquals(2, blob.clients.size)
assertEquals(listOf(a.identityFingerprint), blob.trustedApproverFingerprints)
assertTrue(isTrustedApprover(blob, a.identityFingerprint))
assertFalse(isTrustedApprover(blob, b.identityFingerprint))
}
@Test
fun setTrustedApproverIsIdempotentNoOpReturnsSameInstance() {
var blob = emptyCanonicalProfile(0)
val (c, _) = makeClient("phone", trusted = false)
blob = upsertClient(blob, c)
val before = blob
blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 999L)
assertTrue(blob === before)
}
@Test
fun setTrustedApproverFlipsFlagAndDenormalizedList() {
var blob = emptyCanonicalProfile(0)
val (c, _) = makeClient("phone", trusted = false)
blob = upsertClient(blob, c)
blob = setTrustedApprover(blob, c.identityFingerprint, true, now = 100L)
assertEquals(listOf(c.identityFingerprint), blob.trustedApproverFingerprints)
assertEquals(true, blob.clients[0].trustedApprover)
blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 200L)
assertTrue(blob.trustedApproverFingerprints.isEmpty())
assertEquals(false, blob.clients[0].trustedApprover)
}
@Test
fun removeClientCleansUpDenormalizedList() {
var blob = emptyCanonicalProfile(0)
val (c, _) = makeClient("phone", trusted = true)
blob = upsertClient(blob, c)
blob = removeClient(blob, c.identityFingerprint)
assertTrue(blob.clients.isEmpty())
assertTrue(blob.trustedApproverFingerprints.isEmpty())
}
@Test
fun findClientByFingerprintAndAddress() {
var blob = emptyCanonicalProfile(0)
val (c, _) = makeClient("phone")
blob = upsertClient(blob, c)
assertEquals(c.address, findClientByFingerprint(blob, c.identityFingerprint)?.address)
assertEquals(
c.identityFingerprint,
findClientByAddress(blob, c.address)?.identityFingerprint,
)
assertNull(findClientByFingerprint(blob, "unknown"))
assertNull(findClientByAddress(blob, "unknown"))
}
@Test
fun parseRejectsMalformedProfile() {
try {
parseCanonicalProfile("not json")
org.junit.Assert.fail("expected throw")
} catch (_: IllegalArgumentException) {}
try {
parseCanonicalProfile("""{"version":2}""")
org.junit.Assert.fail("expected throw")
} catch (_: IllegalArgumentException) {}
try {
parseCanonicalProfile(
"""{"version":1,"clients":[{"address":"x","name":"x","kind":"m","addedAt":0}]}""",
)
org.junit.Assert.fail("expected throw — missing identityPublicKey")
} catch (_: IllegalArgumentException) {}
try {
parseCanonicalProfile(
"""{"version":1,"clients":[{"address":"x","identityPublicKey":"NOTHEX","identityFingerprint":"x","name":"x","kind":"m","addedAt":0}]}""",
)
org.junit.Assert.fail("expected throw — bad pubkey hex")
} catch (_: IllegalArgumentException) {}
}
@Test
fun fullProfileSerializeParsePreservesAllFields() {
var blob = emptyCanonicalProfile(1L)
blob = upsertHost(blob, makeHost(), now = 2L)
val (c, _) = makeClient("phone", trusted = true)
blob = upsertClient(blob, c, now = 3L)
blob = blob.copy(signedBy = "aabbccdd")
val bytes = serializeCanonicalProfile(blob)
val parsed = parseCanonicalProfile(bytes)
assertEquals(blob, parsed)
}
// ─── Approval signing payload ──────────────────────────────
@Test
fun canonicalApprovalSigningBytesIsDeterministic() {
val a = canonicalApprovalSigningBytes(
domain = DEFAULT_APPROVAL_DOMAIN,
requestId = "aabbccddeeff00112233445566778899",
hostFingerprint = "11111 22222 33333 44444",
requestingDeviceFingerprint = "55555 66666 77777 88888",
decision = "approve",
)
val b = canonicalApprovalSigningBytes(
domain = DEFAULT_APPROVAL_DOMAIN,
requestId = "aabbccddeeff00112233445566778899",
hostFingerprint = "11111 22222 33333 44444",
requestingDeviceFingerprint = "55555 66666 77777 88888",
decision = "approve",
)
assertArrayEquals(a, b)
}
@Test
fun differentDecisionProducesDifferentSigningBytes() {
val approve = canonicalApprovalSigningBytes(
DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "approve",
)
val reject = canonicalApprovalSigningBytes(
DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "reject",
)
assertFalse(approve.contentEquals(reject))
}
@Test
fun differentDomainProducesDifferentSigningBytes() {
val a = canonicalApprovalSigningBytes("shade-link-approve-v1", "r", "h", "d", "approve")
val b = canonicalApprovalSigningBytes("prism-link-approve-v1", "r", "h", "d", "approve")
assertFalse(a.contentEquals(b))
}
// ─── Build / sign / verify ─────────────────────────────────
private data class Scenario(
val phone: ProfileClientEntry,
val phoneSeed: ByteArray,
val profile: CanonicalProfileBlob,
val request: ApprovalRequestFrame,
)
private fun buildScenario(): Scenario {
val (phone, seed) = makeClient("phone", trusted = true)
var profile = emptyCanonicalProfile(0)
profile = upsertHost(profile, makeHost())
profile = upsertClient(profile, phone)
val request = buildApprovalRequest(
crypto = crypto,
hostAddress = "device:host-server",
hostFingerprint = "host-fp-12345",
requestingDeviceFingerprint = "cafe-laptop-fp-67890",
deviceName = "cafe-laptop",
userAgent = "Mozilla/5.0",
ipHint = "203.0.113.7",
)
return Scenario(phone, seed, profile, request)
}
@Test
fun happyPathApproveVerifies() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request,
decision = "approve",
approverFingerprint = s.phone.identityFingerprint,
approverSigningKey = s.phoneSeed,
)
assertEquals("linkApproveByProxy", approval.kind)
assertEquals(s.request.requestId, approval.requestId)
assertEquals(128, approval.signature.length)
val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
assertTrue(r is VerifyProxyApprovalResult.Ok)
assertEquals(s.phone.address, (r as VerifyProxyApprovalResult.Ok).approver.address)
}
@Test
fun happyPathRejectVerifies() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "reject",
s.phone.identityFingerprint, s.phoneSeed,
)
val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
assertTrue(r is VerifyProxyApprovalResult.Ok)
}
@Test
fun replayAgainstDifferentRequestFails() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val other = s.request.copy(requestId = "f".repeat(32))
val r = verifyProxyApproval(crypto, other, approval, s.profile)
assertEquals(
VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun decisionTamperingFails() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val tampered = approval.copy(decision = "reject")
val r = verifyProxyApproval(crypto, s.request, tampered, s.profile)
assertEquals(
VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun hostFingerprintSwapFails() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val swapped = s.request.copy(hostFingerprint = "evil-host-fp")
val r = verifyProxyApproval(crypto, swapped, approval, s.profile)
assertEquals(
VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun domainMismatchIsRejectedBeforeSignature() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val r = verifyProxyApproval(
crypto, s.request, approval.copy(domain = "prism-link-approve-v1"), s.profile,
)
assertEquals(
VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun unknownApproverFails() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val lying = approval.copy(approverFingerprint = "no-such-fingerprint")
val r = verifyProxyApproval(crypto, s.request, lying, s.profile)
assertEquals(
VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun revokedApproverFailsWithNotTrusted() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val revoked = setTrustedApprover(s.profile, s.phone.identityFingerprint, false)
val r = verifyProxyApproval(crypto, s.request, approval, revoked)
assertEquals(
VerifyProxyApprovalResult.Reason.NOT_TRUSTED,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun expiredRequestIsRejected() {
val s = buildScenario()
val approval = signProxyApproval(
crypto, s.request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
val r = verifyProxyApproval(crypto, s.request, approval, s.profile, now = s.request.expiresAt + 1)
assertEquals(
VerifyProxyApprovalResult.Reason.EXPIRED,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun signatureWithWrongKeyFails() {
val s = buildScenario()
val wrongSeed = crypto.randomBytes(32)
val approval = signProxyApproval(
crypto, s.request, "approve",
approverFingerprint = s.phone.identityFingerprint, // claim phone
approverSigningKey = wrongSeed, // sign with different key
)
val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
assertEquals(
VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
(r as VerifyProxyApprovalResult.Failed).reason,
)
}
@Test
fun customDomainSurvivesRoundTrip() {
val s = buildScenario()
val request = s.request.copy(domain = "prism-link-approve-v1")
val approval = signProxyApproval(
crypto, request, "approve",
s.phone.identityFingerprint, s.phoneSeed,
)
assertEquals("prism-link-approve-v1", approval.domain)
val r = verifyProxyApproval(crypto, request, approval, s.profile)
assertTrue(r is VerifyProxyApprovalResult.Ok)
}
@Test
fun requestIdIs32LowercaseHexChars() {
val r = buildApprovalRequest(
crypto, "device:h", "h", "r",
)
assertTrue(r.requestId.matches(Regex("^[0-9a-f]{32}$")))
}
@Test
fun consecutiveBuildsProduceDistinctRequestIds() {
val a = buildApprovalRequest(crypto, "device:h", "h", "r")
val b = buildApprovalRequest(crypto, "device:h", "h", "r")
assertNotEquals(a.requestId, b.requestId)
}
// ─── Sanity glue: TS-side reference frame parses on Kotlin ──
@Test
fun tsStyleProxyApprovalFrameParsesAndStructurallyMatches() {
// Construct a frame the way @shade/sdk would emit it via JSON,
// and check our Kotlin types accept the same field names.
val expected = ProxyApprovalFrame(
requestId = "00112233445566778899aabbccddeeff",
decision = "approve",
approverFingerprint = "fp",
signature = "ab".repeat(64),
domain = DEFAULT_APPROVAL_DOMAIN,
)
assertEquals("linkApproveByProxy", expected.kind)
assertEquals("approve", expected.decision)
val req = ApprovalRequestFrame(
requestId = "00112233445566778899aabbccddeeff",
hostAddress = "device:h",
hostFingerprint = "host-fp",
requestingDevice = ApprovalRequestingDevice(
fingerprint = "req-fp",
receivedAt = 1L,
),
expiresAt = 2L,
domain = DEFAULT_APPROVAL_DOMAIN,
)
assertNotNull(req)
}
}

View File

@@ -1,6 +1,13 @@
package no.zyon.shade package no.zyon.shade
import no.zyon.shade.approval.canonicalApprovalSigningBytes
import no.zyon.shade.backup.deriveBackupKey import no.zyon.shade.backup.deriveBackupKey
import no.zyon.shade.blob.aeadOpen
import no.zyon.shade.blob.blobAadForSlot
import no.zyon.shade.blob.deriveBlobKey
import no.zyon.shade.blob.deriveBlobSigningSeed
import no.zyon.shade.blob.deriveBlobSlotId
import no.zyon.shade.blob.ed25519PublicKeyFromSeed
import no.zyon.shade.crypto.TinkProvider import no.zyon.shade.crypto.TinkProvider
import no.zyon.shade.fingerprint.computeFingerprint import no.zyon.shade.fingerprint.computeFingerprint
import no.zyon.shade.group.encodeSenderHeader import no.zyon.shade.group.encodeSenderHeader
@@ -447,6 +454,92 @@ class CrossPlatformVectorTest {
} }
} }
@Test
fun blobKdfAndAeadVectorsMatch() {
val vectors = loadVectors("blob.json")
var kdfMatched = 0
var aeadMatched = 0
for (i in 0 until vectors.length()) {
val v = vectors.getJSONObject(i)
val desc = v.getString("description")
if (desc.startsWith("V4.9 blob KDF")) {
kdfMatched++
val masterKey = fromHex(v.getString("masterKey"))
val app = v.getString("app")
val slotId = deriveBlobSlotId(crypto, masterKey, app)
assertEquals(v.getString("slotId"), hex(slotId))
assertEquals(v.getString("blobKey"), hex(deriveBlobKey(crypto, masterKey, app)))
val seed = deriveBlobSigningSeed(crypto, masterKey, app)
assertEquals(v.getString("signingSeed"), hex(seed))
assertEquals(v.getString("ownerPubkey"), hex(ed25519PublicKeyFromSeed(seed)))
} else if (desc.startsWith("V4.9 blob AEAD")) {
aeadMatched++
val key = fromHex(v.getString("key"))
val slotIdHex = v.getString("slotIdHex")
val expectedPlaintext = fromHex(v.getString("plaintext"))
val wire = fromHex(v.getString("wire"))
val aad = blobAadForSlot(slotIdHex)
val opened = aeadOpen(key, wire, aad)
assertEquals(hex(expectedPlaintext), hex(opened))
}
}
assertTrue("KDF vectors expected", kdfMatched >= 3)
assertTrue("AEAD vectors expected", aeadMatched >= 2)
}
@Test
fun approvalSigningPayloadVectorsMatch() {
val vectors = loadVectors("approval.json")
var payloadMatched = 0
var e2eMatched = 0
for (i in 0 until vectors.length()) {
val v = vectors.getJSONObject(i)
val desc = v.getString("description")
if (desc.startsWith("V4.10 approval signing payload")) {
payloadMatched++
val out = canonicalApprovalSigningBytes(
domain = v.getString("domain"),
requestId = v.getString("requestId"),
hostFingerprint = v.getString("hostFingerprint"),
requestingDeviceFingerprint = v.getString("requestingDeviceFingerprint"),
decision = v.getString("decision"),
)
assertEquals(v.getString("signingPayload"), hex(out))
} else if (desc.startsWith("V4.10 approval Ed25519 sign/verify")) {
e2eMatched++
val seed = fromHex(v.getString("seed"))
val pubkey = fromHex(v.getString("publicKey"))
assertEquals(hex(pubkey), hex(ed25519PublicKeyFromSeed(seed)))
val req = v.getJSONObject("request")
val payload = canonicalApprovalSigningBytes(
domain = req.getString("domain"),
requestId = req.getString("requestId"),
hostFingerprint = req.getString("hostFingerprint"),
requestingDeviceFingerprint = req.getString("requestingDeviceFingerprint"),
decision = req.getString("decision"),
)
assertEquals(v.getString("signingPayload"), hex(payload))
// Verify the TS-generated signature against our pubkey + payload.
// This is the load-bearing parity check: a Kotlin-implemented
// verifyProxyApproval running against a TS-signed approval
// succeeds.
val sig = fromHex(v.getString("signature"))
val ok = crypto.verify(pubkey, payload, sig)
assertTrue("Ed25519 verify of TS-signed approval failed", ok)
// And: Kotlin signs the same payload with the same seed and
// produces a sig the TS pubkey verifies. Ed25519 is
// deterministic, so the sig bytes also match exactly.
val mySig = crypto.sign(seed, payload)
assertEquals(v.getString("signature"), hex(mySig))
}
}
assertTrue("payload vectors expected", payloadMatched >= 3)
assertTrue("e2e sign/verify vector expected", e2eMatched >= 1)
}
@Test @Test
fun ratchetStepRoundtripMatches() { fun ratchetStepRoundtripMatches() {
val vectors = loadVectors("ratchet-step.json") val vectors = loadVectors("ratchet-step.json")

View File

@@ -0,0 +1,123 @@
package no.zyon.shade
import no.zyon.shade.serialization.SessionStateJson
import no.zyon.shade.types.ChainState
import no.zyon.shade.types.IdentityKeyPair
import no.zyon.shade.types.KeyPair
import no.zyon.shade.types.OneTimePreKey
import no.zyon.shade.types.SessionState
import no.zyon.shade.types.SignedPreKey
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Round-trip tests for the at-rest JSON serialization used by
* `KeystoreStorage`. The format isn't cross-platform (TS uses its
* own shape) — what matters is `serialize → deserialize` preserves
* every byte of every key.
*/
class SessionStateJsonTest {
private fun bytes(n: Int, fill: Byte): ByteArray = ByteArray(n) { fill }
@Test
fun identityKeyPairRoundTrip() {
val k = IdentityKeyPair(
signingPublicKey = bytes(32, 0x11),
signingPrivateKey = bytes(32, 0x22),
dhPublicKey = bytes(32, 0x33),
dhPrivateKey = bytes(32, 0x44),
)
val s = SessionStateJson.serializeIdentityKeyPair(k)
val d = SessionStateJson.deserializeIdentityKeyPair(s)
assertArrayEquals(k.signingPublicKey, d.signingPublicKey)
assertArrayEquals(k.signingPrivateKey, d.signingPrivateKey)
assertArrayEquals(k.dhPublicKey, d.dhPublicKey)
assertArrayEquals(k.dhPrivateKey, d.dhPrivateKey)
}
@Test
fun signedPreKeyRoundTrip() {
val k = SignedPreKey(
keyId = 42,
keyPair = KeyPair(publicKey = bytes(32, 0x55), privateKey = bytes(32, 0x66)),
signature = bytes(64, 0x77),
timestamp = 1_700_000_000_000L,
)
val s = SessionStateJson.serializeSignedPreKey(k)
val d = SessionStateJson.deserializeSignedPreKey(s)
assertEquals(k.keyId, d.keyId)
assertArrayEquals(k.keyPair.publicKey, d.keyPair.publicKey)
assertArrayEquals(k.keyPair.privateKey, d.keyPair.privateKey)
assertArrayEquals(k.signature, d.signature)
assertEquals(k.timestamp, d.timestamp)
}
@Test
fun oneTimePreKeyRoundTrip() {
val k = OneTimePreKey(
keyId = 7,
keyPair = KeyPair(publicKey = bytes(32, 0x88.toByte()), privateKey = bytes(32, 0x99.toByte())),
)
val s = SessionStateJson.serializeOneTimePreKey(k)
val d = SessionStateJson.deserializeOneTimePreKey(s)
assertEquals(k.keyId, d.keyId)
assertArrayEquals(k.keyPair.publicKey, d.keyPair.publicKey)
assertArrayEquals(k.keyPair.privateKey, d.keyPair.privateKey)
}
@Test
fun sessionStateRoundTripFullPopulated() {
val state = SessionState(
remoteIdentityKey = bytes(32, 0x01),
rootKey = bytes(32, 0x02),
sendChain = ChainState(chainKey = bytes(32, 0x03), counter = 5),
receiveChain = ChainState(chainKey = bytes(32, 0x04), counter = 3),
dhSend = KeyPair(publicKey = bytes(32, 0x05), privateKey = bytes(32, 0x06)),
dhReceive = bytes(32, 0x07),
previousSendCounter = 9,
skippedKeys = mutableMapOf(
"remote:1" to bytes(32, 0x0A),
"remote:2" to bytes(32, 0x0B),
),
)
val s = SessionStateJson.serialize(state)
val d = SessionStateJson.deserialize(s)
assertArrayEquals(state.remoteIdentityKey, d.remoteIdentityKey)
assertArrayEquals(state.rootKey, d.rootKey)
assertArrayEquals(state.sendChain.chainKey, d.sendChain.chainKey)
assertEquals(state.sendChain.counter, d.sendChain.counter)
assertNotNull(d.receiveChain)
assertArrayEquals(state.receiveChain!!.chainKey, d.receiveChain!!.chainKey)
assertArrayEquals(state.dhSend.publicKey, d.dhSend.publicKey)
assertArrayEquals(state.dhSend.privateKey, d.dhSend.privateKey)
assertArrayEquals(state.dhReceive, d.dhReceive)
assertEquals(state.previousSendCounter, d.previousSendCounter)
assertEquals(state.skippedKeys.size, d.skippedKeys.size)
for ((k, v) in state.skippedKeys) {
assertArrayEquals(v, d.skippedKeys[k])
}
}
@Test
fun sessionStateRoundTripWithNullableFields() {
val state = SessionState(
remoteIdentityKey = bytes(32, 0x01),
rootKey = bytes(32, 0x02),
sendChain = ChainState(chainKey = bytes(32, 0x03), counter = 0),
receiveChain = null,
dhSend = KeyPair(publicKey = bytes(32, 0x05), privateKey = bytes(32, 0x06)),
dhReceive = null,
previousSendCounter = 0,
skippedKeys = mutableMapOf(),
)
val s = SessionStateJson.serialize(state)
val d = SessionStateJson.deserialize(s)
assertNull(d.receiveChain)
assertNull(d.dhReceive)
assertEquals(0, d.skippedKeys.size)
}
}

View File

@@ -17,7 +17,7 @@
}, },
"packages/shade-cli": { "packages/shade-cli": {
"name": "@shade/cli", "name": "@shade/cli",
"version": "4.4.0", "version": "4.8.5",
"bin": { "bin": {
"shade": "src/cli.ts", "shade": "src/cli.ts",
}, },
@@ -36,7 +36,7 @@
}, },
"packages/shade-core": { "packages/shade-core": {
"name": "@shade/core", "name": "@shade/core",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/observability": "workspace:*", "@shade/observability": "workspace:*",
}, },
@@ -49,7 +49,7 @@
}, },
"packages/shade-crypto-web": { "packages/shade-crypto-web": {
"name": "@shade/crypto-web", "name": "@shade/crypto-web",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
@@ -59,7 +59,7 @@
}, },
"packages/shade-dashboard": { "packages/shade-dashboard": {
"name": "@shade/dashboard", "name": "@shade/dashboard",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/widgets": "workspace:*", "@shade/widgets": "workspace:*",
"react": "^19.0.0", "react": "^19.0.0",
@@ -74,7 +74,7 @@
}, },
"packages/shade-files": { "packages/shade-files": {
"name": "@shade/files", "name": "@shade/files",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
@@ -101,7 +101,7 @@
}, },
"packages/shade-inbox": { "packages/shade-inbox": {
"name": "@shade/inbox", "name": "@shade/inbox",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/proto": "workspace:*", "@shade/proto": "workspace:*",
@@ -114,7 +114,7 @@
}, },
"packages/shade-inbox-server": { "packages/shade-inbox-server": {
"name": "@shade/inbox-server", "name": "@shade/inbox-server",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/observability": "workspace:*", "@shade/observability": "workspace:*",
@@ -132,7 +132,7 @@
}, },
"packages/shade-key-transparency": { "packages/shade-key-transparency": {
"name": "@shade/key-transparency", "name": "@shade/key-transparency",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
@@ -144,11 +144,11 @@
}, },
"packages/shade-keychain": { "packages/shade-keychain": {
"name": "@shade/keychain", "name": "@shade/keychain",
"version": "4.4.0", "version": "4.8.5",
}, },
"packages/shade-observability": { "packages/shade-observability": {
"name": "@shade/observability", "name": "@shade/observability",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
}, },
@@ -166,7 +166,7 @@
}, },
"packages/shade-observer": { "packages/shade-observer": {
"name": "@shade/observer", "name": "@shade/observer",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/server": "workspace:*", "@shade/server": "workspace:*",
@@ -178,14 +178,14 @@
}, },
"packages/shade-proto": { "packages/shade-proto": {
"name": "@shade/proto", "name": "@shade/proto",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
}, },
}, },
"packages/shade-recovery": { "packages/shade-recovery": {
"name": "@shade/recovery", "name": "@shade/recovery",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
@@ -198,22 +198,25 @@
}, },
"packages/shade-sdk": { "packages/shade-sdk": {
"name": "@shade/sdk", "name": "@shade/sdk",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
"@shade/files": "workspace:*", "@shade/files": "workspace:*",
"@shade/inbox": "workspace:*",
"@shade/key-transparency": "workspace:*", "@shade/key-transparency": "workspace:*",
"@shade/observability": "workspace:*", "@shade/observability": "workspace:*",
"@shade/observer": "workspace:*", "@shade/observer": "workspace:*",
"@shade/proto": "workspace:*", "@shade/proto": "workspace:*",
"@shade/server": "workspace:*", "@shade/server": "workspace:*",
"@shade/storage-encrypted": "workspace:*",
"@shade/storage-sqlite": "workspace:*", "@shade/storage-sqlite": "workspace:*",
"@shade/streams": "workspace:*", "@shade/streams": "workspace:*",
"@shade/transfer": "workspace:*", "@shade/transfer": "workspace:*",
"@shade/transport": "workspace:*", "@shade/transport": "workspace:*",
}, },
"devDependencies": { "devDependencies": {
"@shade/inbox-server": "workspace:*",
"@shade/transport-webrtc": "workspace:*", "@shade/transport-webrtc": "workspace:*",
}, },
"peerDependencies": { "peerDependencies": {
@@ -225,7 +228,7 @@
}, },
"packages/shade-server": { "packages/shade-server": {
"name": "@shade/server", "name": "@shade/server",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/inbox-server": "workspace:*", "@shade/inbox-server": "workspace:*",
@@ -245,7 +248,7 @@
}, },
"packages/shade-storage-encrypted": { "packages/shade-storage-encrypted": {
"name": "@shade/storage-encrypted", "name": "@shade/storage-encrypted",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
@@ -267,7 +270,7 @@
}, },
"packages/shade-storage-indexeddb": { "packages/shade-storage-indexeddb": {
"name": "@shade/storage-indexeddb", "name": "@shade/storage-indexeddb",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"idb": "^8.0.3", "idb": "^8.0.3",
@@ -279,7 +282,7 @@
}, },
"packages/shade-storage-postgres": { "packages/shade-storage-postgres": {
"name": "@shade/storage-postgres", "name": "@shade/storage-postgres",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/inbox-server": "workspace:*", "@shade/inbox-server": "workspace:*",
@@ -294,7 +297,7 @@
}, },
"packages/shade-storage-sqlite": { "packages/shade-storage-sqlite": {
"name": "@shade/storage-sqlite", "name": "@shade/storage-sqlite",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
@@ -304,7 +307,7 @@
}, },
"packages/shade-streams": { "packages/shade-streams": {
"name": "@shade/streams", "name": "@shade/streams",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
@@ -316,7 +319,7 @@
}, },
"packages/shade-transfer": { "packages/shade-transfer": {
"name": "@shade/transfer", "name": "@shade/transfer",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
@@ -333,7 +336,7 @@
}, },
"packages/shade-transport": { "packages/shade-transport": {
"name": "@shade/transport", "name": "@shade/transport",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
@@ -344,7 +347,7 @@
}, },
"packages/shade-transport-bridge": { "packages/shade-transport-bridge": {
"name": "@shade/transport-bridge", "name": "@shade/transport-bridge",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/server": "workspace:*", "@shade/server": "workspace:*",
@@ -366,7 +369,7 @@
}, },
"packages/shade-transport-webrtc": { "packages/shade-transport-webrtc": {
"name": "@shade/transport-webrtc", "name": "@shade/transport-webrtc",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/streams": "workspace:*", "@shade/streams": "workspace:*",
@@ -375,7 +378,7 @@
}, },
"packages/shade-widgets": { "packages/shade-widgets": {
"name": "@shade/widgets", "name": "@shade/widgets",
"version": "4.4.0", "version": "4.8.5",
"dependencies": { "dependencies": {
"@shade/recovery": "workspace:*", "@shade/recovery": "workspace:*",
"@shade/sdk": "workspace:*", "@shade/sdk": "workspace:*",

128
docs/streaming-sessions.md Normal file
View File

@@ -0,0 +1,128 @@
# Streaming Double-Ratchet sub-sessions (V4.11)
`ShadeStream` wraps individual frames on a long-lived, high-frequency,
often one-directional channel (e.g. a server→client console-log
WebSocket) in an **independent** Double Ratchet derived from — but never
mutating — an already-established parent Shade session.
This is the answer to Vyvern FR `shade-ws-streaming-ratchet.md`. It is a
first-class API, *not* the "documented contract that `send`/`receive` is
safe per-frame" fallback: the Double-Ratchet crypto was already safe for
that access pattern, but the `send`/`receive` wrapper layer was not
(per-frame keystore writes; a shared per-peer mutex and a single stored
session row coupling the stream to the HTTP path). `ShadeStream` keeps
the proven ratchet and fixes the wrapper.
## API
Transport-agnostic, exactly like `send`/`receive`: it emits/consumes
wire bytes; you own the WebSocket.
```ts
// Initiator (the side that calls openStream)
const stream = await shade.openStream(peerAddr);
ws.send(stream.handshakeFrame()); // → STREAM_OPEN
// first inbound WS frame is the peer's STREAM_OPEN_ACK:
await stream.handleHandshake(ackBytes); // stream now usable
ws.send(await stream.seal(utf8(logLine))); // outbound frame
onLog(await stream.open(inboundBytes)); // inbound frame
await stream.close(); // on ws close/error
// Responder
const stream = await shade.acceptStream(peerAddr, openBytes); // usable now
ws.send(stream.handshakeFrame()); // → STREAM_OPEN_ACK
// open()/seal() as above
```
Route inbound bytes with `inspectEnvelopeType()`:
`'stream-open' | 'stream-open-ack' | 'stream-frame'`.
## Seeding (no prekey-server round trip)
The stream root key is derived from an identity-bound **3-DH** exchange
— the X3DH pattern minus signed/one-time prekeys, because the peer's
identity is *already* mutually pinned by the parent session's TOFU. Two
ephemerals are exchanged inside the transport (`STREAM_OPEN` /
`STREAM_OPEN_ACK`); no prekey server is involved.
```
slotA = DH(initiatorEphemeral, responderIdentity) — authenticates responder
slotB = DH(initiatorIdentity, responderEphemeral) — authenticates initiator
slotC = DH(initiatorEphemeral, responderEphemeral) — ephemeral forward secrecy
SK = HKDF(ikm = slotA‖slotB‖slotC, salt = streamId, info = "ShadeStream/v1")
```
Both peers compute the identical three scalars regardless of role.
`SK` then bootstraps a textbook Double Ratchet by handing the
responder's ephemeral to `initSenderSession`/`initReceiverSession`
exactly the way X3DH hands its signed prekey to the ratchet — so
`ratchetEncrypt`/`ratchetDecrypt` and every guarantee they carry apply
unchanged.
## Security contract (answers FR R1R7)
- **R1 — same properties as `send`/`receive`.** Each frame is one
`ratchetEncrypt`/`ratchetDecrypt` over the *same* crypto as the HTTP
path: AES-256-GCM confidentiality, per-frame forward secrecy via the
one-way HMAC chain-key KDF with in-place zeroize of the spent chain
key, and replay/rewind rejection (a re-delivered or counter-rewound
frame fails closed). The handshake is mutually authenticated against
the identities the parent session already pinned.
- **R2 — one-directional resilience.** A long server→client burst with
no client traffic only advances the symmetric sending chain (no DH
step until the peer replies — standard Double Ratchet). Forward
secrecy holds per frame in this regime. Over an ordered transport
(WebSocket/TCP) zero keys are skipped per frame.
- **R3 — bounded memory.** Out-of-order arrivals are capped by the
ratchet's `MAX_SKIP` (1000) and `MAX_CACHED_SKIPPED_KEYS` (2000)
with oldest-key eviction. In-order delivery retains nothing. Verified
to stay at zero retained keys across a 5000-frame burst.
- **R4 — browser parity.** Identical API and guarantees in the browser
SDK: `ShadeStream` is on the same `Shade` class over the same
`CryptoProvider` (`SubtleCryptoProvider`), so the IndexedDB-backed
build behaves identically to the `sqlite:` server build. No storage
is touched at all (see R7), so the keystore backend is irrelevant.
- **R5 — independent lifecycle.** The stream ratchet is derived without
reading or writing the stored parent `SessionState`, runs on its own
private op-mutex (not the per-peer `send`/`receive` queues), and is
zeroized on `close()`. Opening, using for thousands of frames, and
closing a stream leaves the parent session byte-identical; the HTTP
path keeps working concurrently against the same peer. Each
`openStream` gets a fresh `streamId` and an independent root, so
concurrent streams to one peer never share key material.
- **R6 — wire framing.** `@shade/proto` defines `STREAM_OPEN` (0x31),
`STREAM_OPEN_ACK` (0x32), `STREAM_FRAME` (0x33). A `STREAM_FRAME`
carries one Double-Ratchet message via the exact ratchet inner codec
the HTTP path uses. One sealed logical frame ⇒ one self-delimiting
wire frame ⇒ one WS text/binary frame.
- **R7 — performance.** The ratchet lives **only in memory and is never
persisted**. There is therefore *zero* per-frame storage I/O — the
per-frame cost is exactly the symmetric KDF + one AES-GCM, the same
primitives the HTTP path runs. This is strictly better than the
"doubled CPU" the Vyvern roadmap budgeted, because the dominant cost
the naive `send`/`receive`-per-frame approach would have paid (a
`saveSession` keystore write per frame) is eliminated, not doubled.
Not persisting is also a *security* property, not a shortcut: writing
evolving per-frame ratchet secrets to disk would defeat forward
secrecy. A dropped/reconnected stream is re-opened with a fresh
handshake, never resumed.
## Double-Ratchet ordering note
A responder cannot `seal()` until it has `open()`ed at least one frame
from the initiator (standard Signal behaviour — the responder has no
sending chain until the first DH step). For a server-heavy stream
either make the bursty data sender the **initiator**, or have the
initiator send one priming frame immediately after the handshake.
## Tests
- `packages/shade-core/tests/stream.test.ts` — handshake agreement,
frame round-trips, 5000-frame one-directional burst (bounded skipped
keys + forward-secrecy zeroize), parent-session independence (R5),
replay/rewind rejection, mutual authentication against pinned
identities, `close()` zeroize/idempotence.
- `packages/shade-proto/tests/stream-wire.test.ts` — wire round-trips
and type-tag/length rejection for all three stream frame kinds.
</content>
</invoke>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/cli", "name": "@shade/cli",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/cli.ts", "main": "src/cli.ts",
"bin": { "bin": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/core", "name": "@shade/core",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -107,6 +107,31 @@ export class FingerprintNotVerifiedError extends ShadeError {
} }
} }
/**
* Thrown when `seal()` / `open()` is called on a {@link StreamRatchet}
* that has already been torn down via `close()`. The stream's ratchet
* secrets have been zeroized and cannot be revived — open a fresh
* stream instead.
*/
export class StreamClosedError extends ShadeError {
constructor(message = 'Stream is closed') {
super('SHADE_STREAM_CLOSED', message);
this.name = 'StreamClosedError';
}
}
/**
* Thrown when a stream handshake frame is malformed, arrives in the
* wrong order, or references a streamId that does not match the stream
* it was fed to.
*/
export class StreamHandshakeError extends ShadeError {
constructor(message = 'Stream handshake failed') {
super('SHADE_STREAM_HANDSHAKE', message);
this.name = 'StreamHandshakeError';
}
}
// ─── Infrastructure Errors ─────────────────────────────────── // ─── Infrastructure Errors ───────────────────────────────────
export class NetworkError extends ShadeError { export class NetworkError extends ShadeError {
@@ -158,6 +183,30 @@ export class UnauthorizedError extends ShadeError {
} }
} }
/**
* 409 Conflict — caller wrote to a resource that already exists without
* supplying an If-Match precondition. V4.9: the encrypted blob primitive
* uses this to force read-then-write on already-occupied slots.
*/
export class ConflictError extends ShadeError {
constructor(message = 'Conflict') {
super('SHADE_CONFLICT', message);
this.name = 'ConflictError';
}
}
/**
* 412 Precondition Failed — caller supplied an If-Match etag that does
* not match the current state. V4.9: the encrypted blob primitive uses
* this to surface stale-CAS so clients can re-read, merge, and retry.
*/
export class PreconditionFailedError extends ShadeError {
constructor(message = 'Precondition failed') {
super('SHADE_PRECONDITION_FAILED', message);
this.name = 'PreconditionFailedError';
}
}
// ─── Error → HTTP Status Mapping ──────────────────────────── // ─── Error → HTTP Status Mapping ────────────────────────────
/** /**
@@ -180,7 +229,10 @@ export function errorToHttpStatus(error: unknown): number {
return 400; return 400;
case 'SHADE_REPLAY': case 'SHADE_REPLAY':
case 'SHADE_DUPLICATE_MESSAGE': case 'SHADE_DUPLICATE_MESSAGE':
case 'SHADE_CONFLICT':
return 409; return 409;
case 'SHADE_PRECONDITION_FAILED':
return 412;
case 'SHADE_RATE_LIMIT': case 'SHADE_RATE_LIMIT':
return 429; return 429;
case 'SHADE_TIMEOUT': case 'SHADE_TIMEOUT':

View File

@@ -26,6 +26,8 @@ export interface ShadeEventMap {
'identity.rotated': { newFingerprint: string }; 'identity.rotated': { newFingerprint: string };
'session.created': { address: string; remoteIdentityKeyHash: string }; 'session.created': { address: string; remoteIdentityKeyHash: string };
'session.removed': { address: string }; 'session.removed': { address: string };
/** V4.8.3 — emitted when `aliasSession` moves a peer's per-peer state. */
'session.aliased': { oldLabel: string; newLabel: string };
'message.encrypted': { address: string; counter: number; ciphertextSize: number }; 'message.encrypted': { address: string; counter: number; ciphertextSize: number };
'message.decrypted': { address: string; counter: number; plaintextSize: number }; 'message.decrypted': { address: string; counter: number; plaintextSize: number };
'ratchet.dh_step': { address: string }; 'ratchet.dh_step': { address: string };
@@ -34,6 +36,10 @@ export interface ShadeEventMap {
'signed_prekey.rotated': { oldKeyId: number; newKeyId: number }; 'signed_prekey.rotated': { oldKeyId: number; newKeyId: number };
'trust.pinned': { address: string; identityKeyHash: string }; 'trust.pinned': { address: string; identityKeyHash: string };
'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string }; 'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string };
/** V4.11 — a streaming sub-ratchet handshake completed. */
'stream.opened': { address: string; role: 'initiator' | 'responder' };
/** V4.11 — a streaming sub-ratchet was torn down and zeroized. */
'stream.closed': { address: string };
} }
export type ShadeEventName = keyof ShadeEventMap; export type ShadeEventName = keyof ShadeEventMap;

View File

@@ -5,6 +5,7 @@ export * from './keys.js';
export * from './errors.js'; export * from './errors.js';
export * from './x3dh.js'; export * from './x3dh.js';
export * from './ratchet.js'; export * from './ratchet.js';
export * from './stream.js';
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js'; export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
export * from './serialization.js'; export * from './serialization.js';
export * from './fingerprint.js'; export * from './fingerprint.js';

View File

@@ -23,7 +23,14 @@ import {
ratchetEncrypt, ratchetEncrypt,
ratchetDecrypt, ratchetDecrypt,
} from './ratchet.js'; } from './ratchet.js';
import { NoSessionError } from './errors.js'; import {
deriveStreamRootKey,
bootstrapStreamSession,
StreamRatchet,
STREAM_ID_BYTES,
STREAM_EPHEMERAL_BYTES,
} from './stream.js';
import { NoSessionError, StreamHandshakeError } from './errors.js';
import { computeFingerprint, shortFingerprint } from './fingerprint.js'; import { computeFingerprint, shortFingerprint } from './fingerprint.js';
import { ShadeEventEmitter, shortHash } from './events.js'; import { ShadeEventEmitter, shortHash } from './events.js';
import { import {
@@ -265,6 +272,73 @@ export class ShadeSessionManager {
// Note: we keep the trusted identity; new session will verify against it. // Note: we keep the trusted identity; new session will verify against it.
} }
/**
* Move every per-peer storage row for `oldLabel` (session, trusted
* identity, peer-verification, identity-version counter) to
* `newLabel`. Used to canonicalize sessions when first-contact
* forces the receiver to label by sender-fingerprint hint
* (`fp:<hex>`) and a later in-band announcement reveals the peer's
* canonical address.
*
* Holds the per-peer mutex on **both** labels for the whole
* migration so concurrent encrypt/decrypt for either label can't
* observe a half-moved state. Locks are taken in lexicographic
* order to avoid deadlocks if two callers alias in opposite
* directions.
*
* Throws if no session exists for `oldLabel`. Throws (refuses to
* overwrite) if a session already exists for `newLabel`. No-ops
* when `oldLabel === newLabel`.
*
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
*/
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
if (oldLabel === newLabel) return;
const [first, second] = oldLabel < newLabel ? [oldLabel, newLabel] : [newLabel, oldLabel];
await this.runUnderPeerLock(first, () =>
this.runUnderPeerLock(second, () => this.aliasUnderLock(oldLabel, newLabel)),
);
}
private async aliasUnderLock(oldLabel: string, newLabel: string): Promise<void> {
const session = await this.storage.getSession(oldLabel);
if (!session) throw new NoSessionError(oldLabel);
const collision = await this.storage.getSession(newLabel);
if (collision) {
throw new Error(
`aliasSession: refusing to overwrite an existing session for "${newLabel}". ` +
`If you want to replace it, call resetSession("${newLabel}") first.`,
);
}
// Move the session.
await this.storage.saveSession(newLabel, session);
// Re-pin trust under the new label using the session's stored DH
// identity key — `saveTrustedIdentity` is the same primitive that
// the X3DH initiator/responder uses, and the DH key in `session`
// is the value that was pinned at session-establish time. The old
// pin under `oldLabel` is harmless leftover (the storage interface
// has no remove for trust pins) and would only be re-checked if a
// fresh X3DH against `oldLabel` somehow happened later.
await this.storage.saveTrustedIdentity(newLabel, session.remoteIdentityKey);
// Migrate the peer-verification record if present.
const verification = await this.storage.getPeerVerification(oldLabel);
if (verification) {
await this.storage.savePeerVerification({
...verification,
peerAddress: newLabel,
});
await this.storage.removePeerVerification(oldLabel);
}
// Carry the identity-version counter forward so peer rotation
// history is preserved.
const oldVersion = await this.storage.getPeerIdentityVersion(oldLabel);
for (let i = 1; i < oldVersion; i++) {
await this.storage.bumpPeerIdentityVersion(newLabel);
}
await this.storage.removeSession(oldLabel);
this.events?.emit('session.aliased', { oldLabel, newLabel });
}
/** /**
* Accept a changed remote identity. This should only be called after * Accept a changed remote identity. This should only be called after
* verifying the new identity out-of-band (e.g., comparing fingerprints). * verifying the new identity out-of-band (e.g., comparing fingerprints).
@@ -559,6 +633,121 @@ export class ShadeSessionManager {
return dec.decode(plaintext); return dec.decode(plaintext);
} }
// ─── Streaming sub-sessions (V4.11) ────────────────────────
/**
* Resolve the peer's pinned identity X25519 key for a stream
* handshake. Requires an *already established* parent session — the
* stream is explicitly a "second channel on a known peer", never a
* first contact (so it needs no prekey-server round trip and inherits
* the parent's TOFU pin).
*/
private async streamIdentityMaterial(
address: string,
): Promise<{ selfIdentityDHPriv: Uint8Array; peerIdentityDHPub: Uint8Array }> {
if (!this.identity) throw new Error('Not initialized');
const session = await this.storage.getSession(address);
if (!session) throw new NoSessionError(address);
return {
selfIdentityDHPriv: this.identity.dhPrivateKey,
peerIdentityDHPub: session.remoteIdentityKey,
};
}
/**
* Initiator side of a stream handshake. Generates the streamId and
* this side's ephemeral, and returns a `complete` continuation that
* derives the sub-ratchet once the responder's ephemeral arrives in
* the `STREAM_OPEN_ACK`.
*
* Touches neither the stored parent session nor the per-peer op
* queues (R5).
*/
async beginStream(address: string): Promise<{
streamId: Uint8Array;
ephemeralPublicKey: Uint8Array;
complete: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
}> {
const { selfIdentityDHPriv, peerIdentityDHPub } =
await this.streamIdentityMaterial(address);
const streamId = this.crypto.randomBytes(STREAM_ID_BYTES);
const ephemeral = await this.crypto.generateX25519KeyPair();
const complete = async (peerEphemeralPub: Uint8Array): Promise<StreamRatchet> => {
if (peerEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new StreamHandshakeError(
`responder ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
);
}
const sk = await deriveStreamRootKey(
this.crypto,
'initiator',
streamId,
selfIdentityDHPriv,
peerIdentityDHPub,
ephemeral.privateKey,
peerEphemeralPub,
);
const session = await bootstrapStreamSession(this.crypto, 'initiator', sk, peerIdentityDHPub, {
publicKey: peerEphemeralPub,
privateKey: new Uint8Array(0),
});
this.crypto.zeroize(sk);
this.crypto.zeroize(ephemeral.privateKey);
this.events?.emit('stream.opened', { address, role: 'initiator' });
return new StreamRatchet(this.crypto, session, streamId);
};
return { streamId, ephemeralPublicKey: ephemeral.publicKey, complete };
}
/**
* Responder side of a stream handshake. Given the initiator's
* `STREAM_OPEN` (its streamId + ephemeral), derives the sub-ratchet
* immediately and returns this side's ephemeral for the
* `STREAM_OPEN_ACK`.
*/
async acceptStream(
address: string,
streamId: Uint8Array,
initiatorEphemeralPub: Uint8Array,
): Promise<{ ephemeralPublicKey: Uint8Array; stream: StreamRatchet }> {
if (streamId.length !== STREAM_ID_BYTES) {
throw new StreamHandshakeError(`streamId must be ${STREAM_ID_BYTES} bytes`);
}
if (initiatorEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new StreamHandshakeError(
`initiator ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
);
}
const { selfIdentityDHPriv, peerIdentityDHPub } =
await this.streamIdentityMaterial(address);
const ephemeral = await this.crypto.generateX25519KeyPair();
const sk = await deriveStreamRootKey(
this.crypto,
'responder',
streamId,
selfIdentityDHPriv,
peerIdentityDHPub,
ephemeral.privateKey,
initiatorEphemeralPub,
);
const session = await bootstrapStreamSession(
this.crypto,
'responder',
sk,
peerIdentityDHPub,
ephemeral,
);
this.crypto.zeroize(sk);
this.events?.emit('stream.opened', { address, role: 'responder' });
return {
ephemeralPublicKey: ephemeral.publicKey,
stream: new StreamRatchet(this.crypto, session, streamId),
};
}
} }
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean { function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {

View File

@@ -0,0 +1,233 @@
import type { CryptoProvider } from './crypto.js';
import type { KeyPair, RatchetMessage, SessionState } from './types.js';
import {
initSenderSession,
initReceiverSession,
ratchetEncrypt,
ratchetDecrypt,
} from './ratchet.js';
import { StreamClosedError } from './errors.js';
/**
* Streaming Double-Ratchet sub-sessions (V4.11).
*
* Wraps a long-lived, high-frequency, often-one-directional channel
* (e.g. a server→client WebSocket log burst) in an *independent* Double
* Ratchet that is derived from — but never mutates — an already
* established parent Shade session.
*
* Why a sub-ratchet rather than reusing `ShadeSessionManager`:
*
* - **Independence (R5).** A stream gets its own root key, chains, DH
* ratchet and op-mutex. Opening/closing it never touches the stored
* parent `SessionState` nor serialises against the HTTP send/receive
* queue.
* - **Performance (R7).** The stream ratchet lives only in memory and
* is *never* written to the keystore. There is therefore zero
* per-frame storage I/O — the cost is purely the symmetric KDF +
* AES-GCM, the same primitives the HTTP path uses.
* - **Forward secrecy.** Not persisting the evolving ratchet state is
* a feature, not a shortcut: writing per-frame secrets to disk would
* actively defeat the forward-secrecy guarantee. A dropped/reconnected
* stream is re-opened with a fresh handshake, not resumed.
*
* ## Seeding (no prekey-server round trip)
*
* The stream root key is derived from an identity-bound 3-DH exchange —
* the X3DH pattern minus the signed / one-time prekeys, because the
* peer's identity is *already* mutually pinned by the parent session's
* TOFU. Two ephemeral keys are exchanged inside the transport itself
* (`STREAM_OPEN` / `STREAM_OPEN_ACK`); no prekey server is involved.
*
* slotA = DH(initiatorEphemeral, responderIdentity) — auth of responder
* slotB = DH(initiatorIdentity, responderEphemeral) — auth of initiator
* slotC = DH(initiatorEphemeral, responderEphemeral) — ephemeral FS
*
* SK = HKDF(ikm = slotA‖slotB‖slotC, salt = streamId, info = "ShadeStream/v1")
*
* Both peers compute the identical three scalars regardless of role, so
* `SK` agrees. An attacker lacking the responder's identity private key
* cannot form slotA; one lacking the initiator's cannot form slotB —
* the handshake is therefore mutually authenticated against the same
* identities the parent session already trusts.
*
* `SK` then bootstraps a textbook Double Ratchet by handing the
* responder's ephemeral to {@link initSenderSession} /
* {@link initReceiverSession} exactly the way X3DH hands its signed
* prekey to the ratchet — so `ratchetEncrypt` / `ratchetDecrypt` (and
* thus every R1R3 guarantee they already carry) apply unchanged.
*/
export type StreamRole = 'initiator' | 'responder';
/** Stream identifier length (bytes). 128 bits of collision resistance. */
export const STREAM_ID_BYTES = 16;
/** Ephemeral X25519 public-key length carried in the handshake. */
export const STREAM_EPHEMERAL_BYTES = 32;
const STREAM_KDF_INFO = new TextEncoder().encode('ShadeStream/v1');
/**
* Derive the stream's independent root key from the identity-bound 3-DH
* exchange. Pure: never reads or mutates any `SessionState`.
*
* @param role which end of the handshake we are
* @param streamId 16-byte stream id (HKDF salt; binds the
* derivation so two concurrent streams to the
* same peer never share a root key)
* @param selfIdentityDHPriv our long-term identity X25519 private key
* @param peerIdentityDHPub peer's pinned identity X25519 public key
* (the value the parent session pinned)
* @param selfEphemeralPriv our per-stream ephemeral X25519 private key
* @param peerEphemeralPub peer's per-stream ephemeral X25519 public key
*/
export async function deriveStreamRootKey(
crypto: CryptoProvider,
role: StreamRole,
streamId: Uint8Array,
selfIdentityDHPriv: Uint8Array,
peerIdentityDHPub: Uint8Array,
selfEphemeralPriv: Uint8Array,
peerEphemeralPub: Uint8Array,
): Promise<Uint8Array> {
// Each slot is pinned to a fixed semantic (not to local role) so both
// sides feed HKDF the identical ikm:
// slotA = DH(initiatorEphemeral, responderIdentity)
// slotB = DH(initiatorIdentity, responderEphemeral)
// slotC = DH(initiatorEphemeral, responderEphemeral)
let slotA: Uint8Array;
let slotB: Uint8Array;
let slotC: Uint8Array;
if (role === 'initiator') {
slotA = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
slotB = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
} else {
slotA = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
slotB = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
}
const ikm = new Uint8Array(96);
ikm.set(slotA, 0);
ikm.set(slotB, 32);
ikm.set(slotC, 64);
const sk = await crypto.hkdf(ikm, streamId, STREAM_KDF_INFO, 32);
crypto.zeroize(slotA);
crypto.zeroize(slotB);
crypto.zeroize(slotC);
crypto.zeroize(ikm);
return sk;
}
/**
* Bootstrap a fresh Double Ratchet `SessionState` from the derived
* stream root key. The responder's ephemeral plays exactly the role
* X3DH's signed prekey plays in {@link initSenderSession} /
* {@link initReceiverSession}, so the ratchet handoff is identical to
* the proven HTTP path.
*
* On the initiator only `responderEphemeral.publicKey` is needed; the
* responder must pass its full ephemeral keypair.
*
* `peerIdentityDHPub` is recorded as the session's `remoteIdentityKey`
* so stream fingerprints stay meaningful and consistent with the parent.
*/
export async function bootstrapStreamSession(
crypto: CryptoProvider,
role: StreamRole,
sk: Uint8Array,
peerIdentityDHPub: Uint8Array,
responderEphemeral: KeyPair,
): Promise<SessionState> {
if (role === 'initiator') {
// initSenderSession derives a fresh root via kdfRootKey and does not
// retain `sk`, so the caller may safely zeroize it afterwards.
return initSenderSession(crypto, sk, peerIdentityDHPub, responderEphemeral.publicKey);
}
// initReceiverSession stores the root key BY REFERENCE. Hand it an
// independent copy so the caller zeroizing its `sk` scratch buffer
// can't wipe the live session root.
return initReceiverSession(new Uint8Array(sk), peerIdentityDHPub, responderEphemeral);
}
/** Zeroize every secret a stream session holds, then drop the chains. */
function zeroizeSession(crypto: CryptoProvider, s: SessionState): void {
crypto.zeroize(s.rootKey);
if (s.sendChain.chainKey.length > 0) crypto.zeroize(s.sendChain.chainKey);
if (s.receiveChain && s.receiveChain.chainKey.length > 0) {
crypto.zeroize(s.receiveChain.chainKey);
}
if (s.dhSend.privateKey.length > 0) crypto.zeroize(s.dhSend.privateKey);
for (const mk of s.skippedKeys.values()) crypto.zeroize(mk);
s.skippedKeys.clear();
}
/**
* In-memory holder for a stream's Double Ratchet. Serialises its own
* `seal`/`open`/`close` on a private promise chain (independent of the
* SDK's per-peer encrypt/decrypt queues — R5) so per-frame ratchet
* mutations never interleave, while staying fully concurrent with the
* parent session and with other streams.
*
* Never persisted: the ratchet exists only for the lifetime of the
* stream and is zeroized on `close()`.
*/
export class StreamRatchet {
private session: SessionState | null;
private opChain: Promise<unknown> = Promise.resolve();
constructor(
private readonly crypto: CryptoProvider,
session: SessionState,
/** 16-byte stream id this ratchet is bound to. */
public readonly streamId: Uint8Array,
) {
this.session = session;
}
/** True once {@link close} has run; `seal`/`open` will throw. */
get closed(): boolean {
return this.session === null;
}
private run<T>(fn: (s: SessionState) => Promise<T>): Promise<T> {
const next = this.opChain.catch(() => undefined).then(() => {
if (!this.session) throw new StreamClosedError();
return fn(this.session);
});
// Keep a never-rejecting tail so a failed frame doesn't poison the
// next one (a single bad inbound frame must not wedge the stream).
this.opChain = next.catch(() => undefined);
return next;
}
/** Wrap one logical frame. Advances the sending chain by one step. */
seal(plaintext: Uint8Array): Promise<RatchetMessage> {
return this.run((s) => ratchetEncrypt(this.crypto, s, plaintext));
}
/**
* Unwrap one inbound frame. Correct and memory-bounded across long
* one-directional runs from the peer: ordered transport delivery
* skips zero keys per frame, and out-of-order arrivals are still
* capped by the ratchet's `MAX_SKIP` / `MAX_CACHED_SKIPPED_KEYS`.
*/
open(message: RatchetMessage): Promise<Uint8Array> {
return this.run((s) => ratchetDecrypt(this.crypto, s, message));
}
/** Zeroize and drop the ratchet. Idempotent. */
close(): Promise<void> {
return this.opChain
.catch(() => undefined)
.then(() => {
if (this.session) {
zeroizeSession(this.crypto, this.session);
this.session = null;
}
});
}
}

View File

@@ -0,0 +1,176 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
ShadeSessionManager,
StreamRatchet,
StreamClosedError,
DecryptionError,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
const enc = new TextEncoder();
const dec = new TextDecoder();
/**
* Establish a *bidirectional* parent session: Alice→Bob X3DH, then one
* Alice→Bob message Bob decrypts so Bob also has a session for 'alice'.
* Both sides then hold the peer's pinned identity DH key — the input the
* stream handshake derives from.
*/
async function bidirectionalPair() {
const aliceStorage = new MemoryStorage();
const bobStorage = new MemoryStorage();
const alice = new ShadeSessionManager(crypto, aliceStorage);
const bob = new ShadeSessionManager(crypto, bobStorage);
await alice.initialize();
await bob.initialize();
const otpks = await bob.generateOneTimePreKeys(4);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
const hello = await alice.encrypt('bob', 'parent-hello');
expect(await bob.decrypt('alice', hello)).toBe('parent-hello');
return { alice, bob, aliceStorage, bobStorage };
}
/** Run the full STREAM_OPEN / STREAM_OPEN_ACK handshake between managers. */
async function openStreamPair(alice: ShadeSessionManager, bob: ShadeSessionManager) {
const begun = await alice.beginStream('bob'); // initiator
const accepted = await bob.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
const aliceStream = await begun.complete(accepted.ephemeralPublicKey);
return { aliceStream, bobStream: accepted.stream, streamId: begun.streamId };
}
describe('streaming sub-ratchet (V4.11)', () => {
let alice: ShadeSessionManager;
let bob: ShadeSessionManager;
let aliceStorage: MemoryStorage;
beforeEach(async () => {
({ alice, bob, aliceStorage } = await bidirectionalPair());
});
test('both sides derive the same stream root (round-trips frames)', async () => {
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
// Initiator → responder (first frame triggers responder DH step).
const f1 = await aliceStream.seal(enc.encode('log line 1'));
expect(dec.decode(await bobStream.open(f1))).toBe('log line 1');
// Responder → initiator (now responder may seal).
const r1 = await bobStream.seal(enc.encode('command-response 1'));
expect(dec.decode(await aliceStream.open(r1))).toBe('command-response 1');
});
test('two streams to the same peer get independent roots', async () => {
const s1 = await openStreamPair(alice, bob);
const s2 = await openStreamPair(alice, bob);
expect(s1.streamId).not.toEqual(s2.streamId);
const a = await s1.aliceStream.seal(enc.encode('on stream 1'));
// A frame from stream 1 must not decrypt on stream 2's ratchet.
await expect(s2.bobStream.open(a)).rejects.toBeInstanceOf(DecryptionError);
// …but does on its own.
expect(dec.decode(await s1.bobStream.open(a))).toBe('on stream 1');
});
test('R2/R3: long one-directional burst stays correct and memory-bounded', async () => {
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
const N = 5000;
// Capture a live receive-chain key buffer to prove forward secrecy:
// ratchetDecrypt zeroizes the previous chain key in place.
await bobStream.open(await aliceStream.seal(enc.encode('frame-0')));
const bobSession = (bobStream as unknown as { session: { receiveChain: { chainKey: Uint8Array }; skippedKeys: Map<string, Uint8Array> } }).session;
const staleChainKey = bobSession.receiveChain.chainKey;
const staleCopy = staleChainKey.slice();
expect(staleCopy.some((b) => b !== 0)).toBe(true);
for (let i = 1; i < N; i++) {
const wire = await aliceStream.seal(enc.encode(`frame-${i}`));
expect(dec.decode(await bobStream.open(wire))).toBe(`frame-${i}`);
}
// In-order delivery ⇒ zero skipped keys retained across 5k frames.
expect(bobSession.skippedKeys.size).toBe(0);
// The chain key in use at frame 0 was overwritten (forward secrecy).
expect(staleChainKey.every((b) => b === 0)).toBe(true);
});
test('R5: opening/using/closing a stream never touches the parent session', async () => {
const before = await aliceStorage.getSession('bob');
const snapshot = JSON.stringify({
root: Array.from(before!.rootKey),
sendCtr: before!.sendChain.counter,
prevCtr: before!.previousSendCounter,
});
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
for (let i = 0; i < 200; i++) {
await bobStream.open(await aliceStream.seal(enc.encode(`x${i}`)));
}
await aliceStream.close();
await bobStream.close();
const after = await aliceStorage.getSession('bob');
expect(
JSON.stringify({
root: Array.from(after!.rootKey),
sendCtr: after!.sendChain.counter,
prevCtr: after!.previousSendCounter,
}),
).toBe(snapshot);
// Parent HTTP path still works after the stream lifecycle.
const env = await alice.encrypt('bob', 'after-stream');
expect(await bob.decrypt('alice', env)).toBe('after-stream');
});
test('R1: replayed / rewound frame is rejected', async () => {
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
const f1 = await aliceStream.seal(enc.encode('once'));
expect(dec.decode(await bobStream.open(f1))).toBe('once');
// Re-delivering the exact same sealed frame must fail.
await expect(bobStream.open(f1)).rejects.toBeInstanceOf(DecryptionError);
});
test('close() zeroizes and blocks further use; idempotent', async () => {
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
await aliceStream.close();
await aliceStream.close(); // idempotent
expect(aliceStream.closed).toBe(true);
await expect(aliceStream.seal(enc.encode('nope'))).rejects.toBeInstanceOf(
StreamClosedError,
);
// The peer end is unaffected by our local close.
expect(bobStream.closed).toBe(false);
});
test('handshake is mutually authenticated against pinned identities', async () => {
// A third party (mallory) with its own identity cannot stand in for
// bob: alice derives against bob's pinned identity key, so a
// handshake completed with mallory's ephemeral yields a different
// root and frames fail to open.
const mStorage = new MemoryStorage();
const mallory = new ShadeSessionManager(crypto, mStorage);
await mallory.initialize();
// Give mallory a parent session label so acceptStream has identity
// material, but pinned to the WRONG (alice) identity vs what alice
// pinned for 'bob'.
const otpks = await mallory.generateOneTimePreKeys(2);
const mb = await mallory.createPreKeyBundle();
mb.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
await alice.initSessionFromBundle('mallory', mb);
const helo = await alice.encrypt('mallory', 'hi');
await mallory.decrypt('alice', helo);
const begun = await alice.beginStream('bob');
const mAccept = await mallory.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
const aliceStream = await begun.complete(mAccept.ephemeralPublicKey);
const frame = await aliceStream.seal(enc.encode('secret'));
await expect(mAccept.stream.open(frame)).rejects.toBeInstanceOf(DecryptionError);
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/crypto-web", "name": "@shade/crypto-web",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -0,0 +1,18 @@
import { ed25519 } from '@noble/curves/ed25519.js';
/**
* Deterministically derive an Ed25519 public key from a 32-byte seed.
*
* In the @noble/curves convention the "private key" *is* the seed —
* `sign(seed, msg)` works directly, and `getPublicKey(seed)` recovers
* the matching public key. V4.9's encrypted-blob primitive uses this
* to mint a per-slot signing keypair from an HKDF output rooted at the
* user's master key, so the same credentials always reproduce the same
* keypair.
*/
export function ed25519PublicKeyFromSeed(seed: Uint8Array): Uint8Array {
if (seed.length !== 32) {
throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length}`);
}
return ed25519.getPublicKey(seed);
}

View File

@@ -1,5 +1,6 @@
export { SubtleCryptoProvider } from './provider.js'; export { SubtleCryptoProvider } from './provider.js';
export { MemoryStorage } from './memory-storage.js'; export { MemoryStorage } from './memory-storage.js';
export { ed25519PublicKeyFromSeed } from './ed25519-derive.js';
// ─── Web Workers crypto (V3.8) ──────────────────────────── // ─── Web Workers crypto (V3.8) ────────────────────────────
export { export {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/dashboard", "name": "@shade/dashboard",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/files", "name": "@shade/files",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox-server", "name": "@shade/inbox-server",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -0,0 +1,268 @@
import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core';
import {
errorToHttpStatus,
ShadeError,
ValidationError,
UnauthorizedError,
fromBase64,
toBase64,
constantTimeEqual,
} from '@shade/core';
import {
verifyPayload,
RateLimiter,
MemoryRateLimitStore,
type RateLimitConfig,
} from '@shade/server';
import {
ATTR_ERROR_CODE,
ATTR_HTTP_STATUS,
ATTR_ROUTE,
NOOP_HOOK,
type ObservabilityHook,
} from '@shade/observability';
import type { BlobStore } from './blob-store.js';
/**
* Wire-level wrapper around the V4.9 BlobStore primitive.
*
* Endpoints:
* GET /v1/blob/:slotId → { blob, etag } | 404
* PUT /v1/blob/:slotId → { etag, created } | 409 | 412
* DELETE /v1/blob/:slotId → { ok }
*
* SlotId is 64 lowercase hex chars (the HKDF output, 32 bytes). Payloads
* are base64-encoded ciphertext; the relay never decrypts. Auth uses
* `signPayload` / `verifyPayload` (same canonical-JSON-and-Ed25519
* scheme as the inbox routes), keyed off the per-slot pubkey stored
* TOFU on the first PUT.
*
* Quota: a single slot holds one blob. `MAX_BLOB_BYTES` (64 KiB) is
* sized for Prism's profile use-case (a few hundred host entries) with
* plenty of headroom; future apps can override via `BlobRoutesOptions`.
*/
const SLOT_ID_REGEX = /^[0-9a-f]{64}$/;
const MAX_META_BODY_SIZE = 64 * 1024;
/** Default per-slot blob ceiling. Sized for ~500 host entries in JSON form. */
export const DEFAULT_MAX_BLOB_BYTES = 64 * 1024;
const PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
const GET_LIMIT: RateLimitConfig = { capacity: 120, refillPerSecond: 2 };
const DELETE_LIMIT: RateLimitConfig = { capacity: 30, refillPerSecond: 1 };
export interface BlobRoutesOptions {
disableRateLimit?: boolean;
observability?: ObservabilityHook;
/** Per-blob byte ceiling. Defaults to 64 KiB. */
maxBlobBytes?: number;
}
export function createBlobRoutes(
store: BlobStore,
crypto: CryptoProvider,
options: BlobRoutesOptions = {},
): Hono {
const app = new Hono();
const observability = options.observability ?? NOOP_HOOK;
const maxBlobBytes = options.maxBlobBytes ?? DEFAULT_MAX_BLOB_BYTES;
app.use('*', async (c, next) => {
const route = c.req.routePath ?? c.req.path ?? '<unknown>';
const span = observability.startSpan('shade.blob.request', {
[ATTR_ROUTE]: route,
});
try {
await next();
span.setAttribute(ATTR_HTTP_STATUS, c.res.status);
span.setStatus(c.res.status >= 500 ? 'error' : 'ok');
} catch (err) {
const code =
err instanceof ShadeError ? err.code ?? 'SHADE_ERROR' : 'SHADE_INTERNAL';
span.setAttribute(ATTR_ERROR_CODE, code);
span.recordException(err);
span.setStatus('error', code);
throw err;
} finally {
span.end();
}
});
const rlStore = new MemoryRateLimitStore();
const putRL = new RateLimiter(rlStore, PUT_LIMIT);
const getRL = new RateLimiter(rlStore, GET_LIMIT);
const deleteRL = new RateLimiter(rlStore, DELETE_LIMIT);
const rateLimitEnabled = !options.disableRateLimit;
const getClientIp = (c: any): string =>
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
c.req.header('x-real-ip') ??
'unknown';
app.onError((err, c) => {
if (err instanceof ShadeError) {
const status = errorToHttpStatus(err);
const body: any = err.toJSON();
if ((err as any).retryAfterSeconds) {
c.header('Retry-After', String((err as any).retryAfterSeconds));
}
return c.json(body, status as any);
}
console.error('[Shade] Unhandled blob error:', err);
return c.json({ error: 'Internal server error' }, 500);
});
function validateSlotId(raw: string | undefined): string {
if (typeof raw !== 'string' || !SLOT_ID_REGEX.test(raw)) {
throw new ValidationError(
'slotId must be 64 lowercase hex chars (32 bytes)',
'slotId',
);
}
return raw;
}
// ─── GET ─────────────────────────────────────────────────────
// Unauthenticated. SlotId is itself a 256-bit secret derived from the
// master key — knowing it implies you derived the master, which is
// equivalent to holding the credentials. The blob is AEAD-sealed, so
// a relay-side leak of slotId still cannot decrypt the contents.
app.get('/v1/blob/:slotId', async (c) => {
const slotId = validateSlotId(c.req.param('slotId'));
if (rateLimitEnabled) await getRL.consume(`blob-get:${getClientIp(c)}`);
const row = await store.get(slotId);
if (!row) {
return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404);
}
return c.json({
blob: toBase64(row.blob),
etag: String(row.etag),
updatedAt: row.updatedAt,
});
});
// ─── PUT ─────────────────────────────────────────────────────
// Body format:
// {
// ownerPubkey: b64, // Ed25519 pubkey deterministically
// // derived from the master via HKDF.
// blob: b64,
// ifMatch?: string, // "<etag>" | "*" | undefined
// signedAt: number,
// signature: b64 // over the canonical body sans signature
// }
//
// First write to a slot is TOFU: we record `ownerPubkey` and require
// any future write to verify against it. A different key trying to
// overwrite an existing slot is rejected with UnauthorizedError.
app.put('/v1/blob/:slotId', async (c) => {
const slotId = validateSlotId(c.req.param('slotId'));
if (rateLimitEnabled) await putRL.consume(`blob-put:${getClientIp(c)}`);
const rawBody = await c.req.text();
const hardLimit = Math.ceil(maxBlobBytes * 1.4) + MAX_META_BODY_SIZE;
if (rawBody.length > hardLimit) {
throw new ValidationError(`Request body too large`);
}
const body = JSON.parse(rawBody);
const { ownerPubkey, blob, ifMatch } = body;
if (typeof ownerPubkey !== 'string') {
throw new ValidationError('Missing ownerPubkey', 'ownerPubkey');
}
if (typeof blob !== 'string') {
throw new ValidationError('Missing blob', 'blob');
}
const claimedKey = fromBase64(ownerPubkey);
if (claimedKey.length !== 32) {
throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)', 'ownerPubkey');
}
const blobBytes = fromBase64(blob);
if (blobBytes.length === 0) {
throw new ValidationError('blob is empty', 'blob');
}
if (blobBytes.length > maxBlobBytes) {
throw new ValidationError(
`blob exceeds maxBlobBytes (${blobBytes.length} > ${maxBlobBytes})`,
'blob',
);
}
let expectedEtag: number | '*' | undefined;
if (ifMatch === undefined) {
expectedEtag = undefined;
} else if (typeof ifMatch !== 'string') {
throw new ValidationError('ifMatch must be a string when present', 'ifMatch');
} else if (ifMatch === '*') {
expectedEtag = '*';
} else {
const n = Number(ifMatch);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new ValidationError('ifMatch must be a non-negative integer or "*"', 'ifMatch');
}
expectedEtag = n;
}
// Existing slot: caller must sign with the original owner key. Use
// the stored pubkey for verification. The body's `ownerPubkey` is
// bound by the signature too, so an attacker cannot trick us into
// verifying with a key they control — the canonicalization includes
// every field but `signature`.
const existing = await store.get(slotId);
const verifyKey = existing ? existing.ownerPubkey : claimedKey;
// Bind slotId into the signed payload so a signature for slot A
// can't be replayed against slot B (the URL is otherwise outside
// the signed bytes).
await verifyPayload(crypto, verifyKey, { ...body, slotId });
if (existing && !constantTimeEqual(existing.ownerPubkey, claimedKey)) {
throw new UnauthorizedError(
`Slot ${slotId} is owned by a different signing key`,
);
}
const result = await store.put({
slotId,
blob: blobBytes,
ownerPubkey: claimedKey,
expectedEtag,
now: Date.now(),
});
return c.json({
ok: true,
created: result.created,
etag: String(result.etag),
updatedAt: result.updatedAt,
});
});
// ─── DELETE ──────────────────────────────────────────────────
// Body format: { signedAt, signature }. Signed by the owner pubkey
// recorded on the first PUT. After deletion, the slot is fully gone —
// the next PUT TOFU-claims it again (potentially under a different
// signing key, e.g. after a rotation).
app.delete('/v1/blob/:slotId', async (c) => {
const slotId = validateSlotId(c.req.param('slotId'));
if (rateLimitEnabled) await deleteRL.consume(`blob-delete:${getClientIp(c)}`);
const existing = await store.get(slotId);
if (!existing) {
return c.json({ error: 'Slot not found', code: 'SHADE_NOT_FOUND' }, 404);
}
const rawBody = await c.req.text();
if (rawBody.length > MAX_META_BODY_SIZE) {
throw new ValidationError(`Request body too large`);
}
const body = JSON.parse(rawBody);
await verifyPayload(crypto, existing.ownerPubkey, { ...body, slotId });
const removed = await store.delete(slotId);
return c.json({ ok: removed });
});
return app;
}

View File

@@ -0,0 +1,86 @@
/**
* BlobStore — server-side storage interface for the V4.9 encrypted-blob
* primitive. A "slot" is a single AEAD-sealed blob keyed by a
* deterministic 32-byte slotId derived client-side via HKDF from a
* master key. The relay never sees plaintext, never holds private keys,
* and never decrypts.
*
* Auth model (TOFU per slot, mirrors the inbox-owner pattern):
* - First PUT to an empty slot stores the caller's Ed25519 signing
* pubkey alongside the blob. Subsequent writes must produce a valid
* signature verifiable by that pubkey.
* - GET is unauthenticated — slotId is itself a 256-bit secret derived
* from the master key, so knowing it implies you derived the master.
* - DELETE clears the blob AND the owner pubkey, allowing future TOFU
* re-claim by a fresh signing key derived from the same master (e.g.
* after a rotation).
*
* CAS / etag semantics:
* - Every successful PUT bumps a per-slot monotonic etag (returned to
* the caller as a string).
* - A stale `ifMatch` triggers `PreconditionFailedError` (HTTP 412).
* - `ifMatch === undefined` against a populated slot triggers
* `ConflictError` (HTTP 409) — clients must read-then-write.
* - `ifMatch === '*'` against a populated slot is unconditional
* overwrite (escape hatch). Against an empty slot it's still 412
* per RFC 7232 (no entity to match).
*/
export interface BlobSlotRecord {
/** Lower-hex 64-char slotId (32 bytes). */
slotId: string;
/** Raw AEAD ciphertext (bytes). The relay never decrypts. */
blob: Uint8Array;
/** Owner Ed25519 signing pubkey, established TOFU on the first PUT. */
ownerPubkey: Uint8Array;
/** Monotonic per-slot version. Used as the ETag on the wire. */
etag: number;
/** Wall-clock ms of the last successful write. */
updatedAt: number;
}
/** Returned to the route layer after a successful PUT. */
export interface PutBlobResult {
/** Whether the slot was created (true) or updated in place (false). */
created: boolean;
/** New etag after the write. */
etag: number;
/** Wall-clock ms of the write. */
updatedAt: number;
}
export interface BlobStore {
/** Read a slot, or null if it has never been written (or was deleted). */
get(slotId: string): Promise<BlobSlotRecord | null>;
/**
* Create or update a slot.
*
* Implementations MUST treat `(slotId, ownerPubkey)` atomically: the
* route layer has already verified the signature, but the store is the
* authority on whether the slot exists and what etag it has. Callers
* pass the verified `ownerPubkey` (used on first-write to record the
* owner; ignored on subsequent writes — the existing pubkey is the
* source of truth for who's allowed to write).
*
* `expectedEtag` semantics (mirror the wire-level If-Match):
* - `undefined` : create-only. Slot must be empty.
* - `<number>` : compare-and-swap. Must equal the current etag.
* - `'*'` : unconditional overwrite. Slot must already exist.
*
* On precondition mismatch the store throws `PreconditionFailedError`
* (stale etag) or `ConflictError` (slot exists, no ifMatch).
*/
put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult>;
/**
* Delete a slot. Authentication has already been checked by the route
* layer. Returns true if a row was removed (i.e. the slot existed).
*/
delete(slotId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,140 @@
/**
* BridgeDeliveryLog — V4.8.4 cross-channel dedup.
*
* Records per-`(address, msgId)` "delivered via bridge push" timestamps so
* the inbox-fetch route can filter out blobs the bridge has already pushed
* to the recipient. The relay's contract becomes:
*
* one `Inbox.send` ⇒ one observable delivery on the recipient
*
* even when the recipient runs both a bridge subscription (WS / SSE) AND
* the regular inbox-poll. Without it, bridge-push and inbox-poll are
* independent paths against the same store and the recipient gets the
* same envelope twice — bridge-first, then ~30 s later via the next poll
* — tripping on already-consumed prekeys (`one-time prekey not found`)
* or surfacing as duplicate `shade.receive` work.
*
* The log is in-memory per process and intentionally bounded: each entry
* lives for `graceMs` (default 60 s, well past a typical `pollIntervalMs`
* of 30 s). After grace, the entry is forgotten and inbox-poll falls back
* to delivering the blob — that's the legitimate "bridge dropped the
* frame, poll picked up" recovery path. If the recipient explicitly
* acks the blob (HTTP `DELETE /v1/inbox/:addr/:msgId`), the blob is gone
* from storage and the log entry is moot.
*
* Multi-bridge per address (e.g. WS + SSE redundancy on the same client,
* or two devices sharing one signing key) is preserved: every bridge
* connection still fetches + pushes the blob — each push records its own
* timestamp — so each connected bridge gets the frame. Only the *poll*
* fetch is filtered, not the bridge fetches themselves.
*
* @see Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
*/
const DEFAULT_GRACE_MS = 60_000;
export interface BridgeDeliveryLogOptions {
/**
* How long a `(address, msgId)` mark suppresses inbox-poll delivery.
* Defaults to 60_000ms — twice the default `pollIntervalMs` of the
* `@shade/inbox` orchestrator, so a poll cycle that races a bridge
* push always sees the mark, but a stuck recipient still gets the
* blob via poll within ~minutes.
*/
graceMs?: number;
/**
* Maximum entries per address. Bounds memory under a busy address.
* Oldest entries (by recorded timestamp) are evicted first. Default
* 8192 — comfortably above any realistic backlog.
*/
maxPerAddress?: number;
}
export class BridgeDeliveryLog {
private readonly log = new Map<string, Map<string, number>>();
private readonly graceMs: number;
private readonly maxPerAddress: number;
constructor(options: BridgeDeliveryLogOptions = {}) {
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
this.maxPerAddress = options.maxPerAddress ?? 8192;
}
/** Mark `(address, msgId)` as bridge-delivered at `now`. */
recordDelivered(address: string, msgId: string, now: number): void {
let inner = this.log.get(address);
if (!inner) {
inner = new Map();
this.log.set(address, inner);
}
inner.set(msgId, now);
// Lazy cleanup: drop entries past 2× grace so the map stays bounded
// without a separate timer. Bound by `maxPerAddress` as a fallback
// for pathological burst scenarios.
if (inner.size > this.maxPerAddress) {
const cutoff = now - this.graceMs * 2;
for (const [id, ts] of inner) {
if (ts < cutoff) inner.delete(id);
}
// Still over cap? Drop the oldest.
if (inner.size > this.maxPerAddress) {
const sorted = Array.from(inner.entries()).sort((a, b) => a[1] - b[1]);
const toDrop = sorted.slice(0, inner.size - this.maxPerAddress);
for (const [id] of toDrop) inner.delete(id);
}
}
}
/**
* Returns true if `(address, msgId)` was bridge-delivered within the
* grace window.
*/
isRecentlyDelivered(address: string, msgId: string, now: number): boolean {
const inner = this.log.get(address);
if (!inner) return false;
const ts = inner.get(msgId);
if (ts === undefined) return false;
if (now - ts > this.graceMs) {
inner.delete(msgId); // tombstone the stale entry
return false;
}
return true;
}
/**
* Filter `blobs` down to those not currently in the bridge-delivered
* grace window. Used by the inbox-fetch route to suppress duplicates.
*/
filterRecent<T extends { msgId: string }>(
address: string,
blobs: T[],
now: number,
): T[] {
const inner = this.log.get(address);
if (!inner || inner.size === 0) return blobs;
return blobs.filter((b) => {
const ts = inner.get(b.msgId);
if (ts === undefined) return true;
if (now - ts > this.graceMs) {
inner.delete(b.msgId);
return true;
}
return false;
});
}
/** Drop the entry for `(address, msgId)`. Called from blob-delete paths. */
forget(address: string, msgId: string): void {
this.log.get(address)?.delete(msgId);
}
/** Drop every entry for `address`. Called from address-delete paths. */
forgetAddress(address: string): void {
this.log.delete(address);
}
/** Test-only inspection. */
size(address: string): number {
return this.log.get(address)?.size ?? 0;
}
}

View File

@@ -39,6 +39,7 @@ import { verifyPayload, validateAddress } from '@shade/server';
import type { InboxStore } from './store.js'; import type { InboxStore } from './store.js';
import type { InboxServerEvents } from './events.js'; import type { InboxServerEvents } from './events.js';
import { PresenceTracker, type TrackedBridgeKind } from './presence.js'; import { PresenceTracker, type TrackedBridgeKind } from './presence.js';
import { BridgeDeliveryLog } from './bridge-delivery-log.js';
export type BridgeKind = 'stream' | 'poll' | 'ws'; export type BridgeKind = 'stream' | 'poll' | 'ws';
/** /**
@@ -74,6 +75,16 @@ export interface BridgeRoutesOptions {
* the bridge auto-creates an internal tracker bound to `events`. * the bridge auto-creates an internal tracker bound to `events`.
*/ */
presenceTracker?: PresenceTracker; presenceTracker?: PresenceTracker;
/**
* V4.8.4 — shared bridge delivery log. After every successful WS /
* SSE push we record `(address, msgId, now)` here so the inbox-fetch
* route can suppress the same blob from a subsequent inbox-poll
* within the log's grace window. Pass the same instance to
* `createInboxRoutes` (or use the auto-created one returned in
* `bridgeRoutes.bridgeDeliveryLog`). When omitted, the bridge
* auto-creates its own log.
*/
bridgeDeliveryLog?: BridgeDeliveryLog;
} }
interface VerifiedBridgeRequest { interface VerifiedBridgeRequest {
@@ -103,6 +114,13 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
websocket: unknown; websocket: unknown;
/** Live presence tracker. Tests + observers can read it; routes update it. */ /** Live presence tracker. Tests + observers can read it; routes update it. */
presence: PresenceTracker; presence: PresenceTracker;
/**
* V4.8.4 — the shared bridge-delivery log this router writes to on
* every successful push. Wire the same instance into
* `createInboxRoutes({ bridgeDeliveryLog })` so the inbox-fetch route
* can suppress recently-pushed blobs.
*/
bridgeDeliveryLog: BridgeDeliveryLog;
} { } {
const app = new Hono(); const app = new Hono();
const pageLimit = opts.pageLimit ?? 50; const pageLimit = opts.pageLimit ?? 50;
@@ -111,6 +129,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000; const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000; const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null); const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null);
const bridgeDeliveryLog = opts.bridgeDeliveryLog ?? new BridgeDeliveryLog();
app.onError((err, c) => { app.onError((err, c) => {
if (err instanceof ShadeError) { if (err instanceof ShadeError) {
@@ -138,6 +157,10 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
const writer = makeBlobWriter(opts.store, pageLimit); const writer = makeBlobWriter(opts.store, pageLimit);
const delivered = new DeliveredIdLru(); const delivered = new DeliveredIdLru();
const recordPush = (msgId: string): void => {
bridgeDeliveryLog.recordDelivered(address, msgId, Date.now());
};
// Initial backlog drain. // Initial backlog drain.
const flushed = await flushTo( const flushed = await flushTo(
writer, writer,
@@ -149,6 +172,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
event: 'envelope', event: 'envelope',
data: JSON.stringify(serializeBlob(blob)), data: JSON.stringify(serializeBlob(blob)),
}); });
recordPush(blob.msgId);
}, },
delivered, delivered,
); );
@@ -181,6 +205,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
event: 'envelope', event: 'envelope',
data: JSON.stringify(serializeBlob(blob)), data: JSON.stringify(serializeBlob(blob)),
}); });
recordPush(blob.msgId);
}, },
delivered, delivered,
); );
@@ -296,6 +321,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
limit: pageLimit, limit: pageLimit,
}); });
if (blobs.length > 0) { if (blobs.length > 0) {
const now = Date.now();
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
return c.json(buildPollResponse(blobs, verified.since)); return c.json(buildPollResponse(blobs, verified.since));
} }
@@ -310,6 +337,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
fallbackPollIntervalMs, fallbackPollIntervalMs,
abortSignal: c.req.raw.signal, abortSignal: c.req.raw.signal,
}); });
const now = Date.now();
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
return c.json(buildPollResponse(blobs, verified.since)); return c.json(buildPollResponse(blobs, verified.since));
}); });
@@ -380,6 +409,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
cursor, cursor,
async (blob) => { async (blob) => {
ws.send(JSON.stringify(serializeBlob(blob))); ws.send(JSON.stringify(serializeBlob(blob)));
bridgeDeliveryLog.recordDelivered(address, blob.msgId, Date.now());
}, },
delivered, delivered,
); );
@@ -414,7 +444,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
}), }),
); );
return { app, websocket, presence }; return { app, websocket, presence, bridgeDeliveryLog };
} }
// ─── helpers ────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────

View File

@@ -1,9 +1,12 @@
import type { Hono } from 'hono'; import { Hono } from 'hono';
import type { CryptoProvider } from '@shade/core'; import type { CryptoProvider } from '@shade/core';
import { createInboxRoutes, type InboxRoutesOptions } from './routes.js'; import { createInboxRoutes, type InboxRoutesOptions } from './routes.js';
import { MemoryInboxStore } from './memory-store.js'; import { MemoryInboxStore } from './memory-store.js';
import type { InboxStore } from './store.js'; import type { InboxStore } from './store.js';
import { InboxServerEvents } from './events.js'; import { InboxServerEvents } from './events.js';
import { createBlobRoutes, type BlobRoutesOptions } from './blob-routes.js';
import { MemoryBlobStore } from './memory-blob-store.js';
import type { BlobStore } from './blob-store.js';
export { createInboxRoutes } from './routes.js'; export { createInboxRoutes } from './routes.js';
export type { InboxRoutesOptions } from './routes.js'; export type { InboxRoutesOptions } from './routes.js';
@@ -34,6 +37,12 @@ export { createBridgeRoutes } from './bridge.js';
export type { BridgeRoutesOptions, BridgeKind } from './bridge.js'; export type { BridgeRoutesOptions, BridgeKind } from './bridge.js';
export { PresenceTracker } from './presence.js'; export { PresenceTracker } from './presence.js';
export type { TrackedBridgeKind } from './presence.js'; export type { TrackedBridgeKind } from './presence.js';
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
export { createBlobRoutes, DEFAULT_MAX_BLOB_BYTES } from './blob-routes.js';
export type { BlobRoutesOptions } from './blob-routes.js';
export { MemoryBlobStore } from './memory-blob-store.js';
export type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
/** /**
* Create a standalone Shade Inbox Server. * Create a standalone Shade Inbox Server.
@@ -46,17 +55,45 @@ export type { TrackedBridgeKind } from './presence.js';
* const app = new Hono(); * const app = new Hono();
* app.route('/', createInboxServer({ crypto })); * app.route('/', createInboxServer({ crypto }));
*/ */
export function createInboxServer(options: { export function createInboxServer(
options: {
crypto: CryptoProvider; crypto: CryptoProvider;
store?: InboxStore; store?: InboxStore;
disableRateLimit?: boolean; disableRateLimit?: boolean;
events?: InboxServerEvents; events?: InboxServerEvents;
} & Pick<InboxRoutesOptions, 'observability' | 'quota'>): Hono { /**
* V4.9 — when supplied, mounts the encrypted-blob primitive
* (`/v1/blob/<slotId>`) on the same Hono app. Pass `null` to
* explicitly opt out; omit to default to a `MemoryBlobStore`.
*/
blobStore?: BlobStore | null;
blobOptions?: Pick<BlobRoutesOptions, 'maxBlobBytes'>;
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>,
): Hono {
const store = options.store ?? new MemoryInboxStore(); const store = options.store ?? new MemoryInboxStore();
const routesOptions: InboxRoutesOptions = {}; const routesOptions: InboxRoutesOptions = {};
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit; if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
if (options.events !== undefined) routesOptions.events = options.events; if (options.events !== undefined) routesOptions.events = options.events;
if (options.observability !== undefined) routesOptions.observability = options.observability; if (options.observability !== undefined) routesOptions.observability = options.observability;
if (options.quota !== undefined) routesOptions.quota = options.quota; if (options.quota !== undefined) routesOptions.quota = options.quota;
return createInboxRoutes(store, options.crypto, routesOptions); if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
const inboxApp = createInboxRoutes(store, options.crypto, routesOptions);
// Compose with the blob primitive unless explicitly disabled. The
// blob routes share the same Hono app so a single port serves both.
if (options.blobStore === null) return inboxApp;
const blobStore = options.blobStore ?? new MemoryBlobStore();
const blobRoutesOptions: BlobRoutesOptions = {};
if (options.disableRateLimit !== undefined) blobRoutesOptions.disableRateLimit = options.disableRateLimit;
if (options.observability !== undefined) blobRoutesOptions.observability = options.observability;
if (options.blobOptions?.maxBlobBytes !== undefined) {
blobRoutesOptions.maxBlobBytes = options.blobOptions.maxBlobBytes;
}
const blobApp = createBlobRoutes(blobStore, options.crypto, blobRoutesOptions);
const composed = new Hono();
composed.route('/', inboxApp);
composed.route('/', blobApp);
return composed;
} }

View File

@@ -0,0 +1,85 @@
import { ConflictError, PreconditionFailedError } from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';
/**
* In-memory BlobStore — used in tests and as the default fallback when
* no SQLite/Postgres URL is configured. Rows are kept in a single Map.
*
* Etag is a strictly-monotonic per-process counter — guarantees a total
* order across writes even when many land in the same millisecond. (We
* could scope it per-slot, but a global counter keeps the implementation
* trivial and the etag values still uniquely identify the write that
* produced them, which is all CAS needs.)
*/
export class MemoryBlobStore implements BlobStore {
private slots = new Map<string, BlobSlotRecord>();
private nextEtag = 0;
async get(slotId: string): Promise<BlobSlotRecord | null> {
const r = this.slots.get(slotId);
if (!r) return null;
return {
slotId: r.slotId,
blob: new Uint8Array(r.blob),
ownerPubkey: new Uint8Array(r.ownerPubkey),
etag: r.etag,
updatedAt: r.updatedAt,
};
}
async put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult> {
const existing = this.slots.get(args.slotId);
if (!existing) {
// Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is
// no entity to match. A numeric etag also fails (we have nothing
// to compare against).
if (args.expectedEtag !== undefined) {
throw new PreconditionFailedError(
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
);
}
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
const etag = this.nextEtag;
this.slots.set(args.slotId, {
slotId: args.slotId,
blob: new Uint8Array(args.blob),
ownerPubkey: new Uint8Array(args.ownerPubkey),
etag,
updatedAt: args.now,
});
return { created: true, etag, updatedAt: args.now };
}
// Slot exists. Pubkey check is the route layer's job — by the time
// we're here the signature has already been verified against
// `existing.ownerPubkey`.
if (args.expectedEtag === undefined) {
throw new ConflictError(
`Slot ${args.slotId} already exists; supply ifMatch to update`,
);
}
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
throw new PreconditionFailedError(
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
);
}
this.nextEtag = Math.max(this.nextEtag + 1, args.now);
const etag = this.nextEtag;
existing.blob = new Uint8Array(args.blob);
existing.etag = etag;
existing.updatedAt = args.now;
return { created: false, etag, updatedAt: args.now };
}
async delete(slotId: string): Promise<boolean> {
return this.slots.delete(slotId);
}
}

View File

@@ -28,6 +28,7 @@ import type { InboxStore } from './store.js';
import { InboxServerEvents, shortHash } from './events.js'; import { InboxServerEvents, shortHash } from './events.js';
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js'; import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js'; import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
import type { BridgeDeliveryLog } from './bridge-delivery-log.js';
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */ /** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
const MAX_META_BODY_SIZE = 64 * 1024; const MAX_META_BODY_SIZE = 64 * 1024;
@@ -54,6 +55,16 @@ export interface InboxRoutesOptions {
observability?: ObservabilityHook; observability?: ObservabilityHook;
/** Override quota policy. */ /** Override quota policy. */
quota?: Partial<InboxQuotaConfig>; quota?: Partial<InboxQuotaConfig>;
/**
* V4.8.4 — shared bridge delivery log. When provided (and the same
* instance is wired into `createBridgeRoutes`), the inbox-fetch route
* filters out blobs already pushed via bridge within the log's grace
* window. Without this, a recipient that runs both a bridge
* subscription and inbox-poll receives the same envelope twice.
* Optional — leaving it unset preserves the pre-V4.8.4 behavior of
* always returning every blob the cursor matches.
*/
bridgeDeliveryLog?: BridgeDeliveryLog;
} }
export function createInboxRoutes( export function createInboxRoutes(
@@ -171,6 +182,7 @@ export function createInboxRoutes(
await verifyPayload(crypto, owner, { ...body, address }); await verifyPayload(crypto, owner, { ...body, address });
await store.deleteAddress(address); await store.deleteAddress(address);
options.bridgeDeliveryLog?.forgetAddress(address);
events?.emit('inbox.address_deleted', { address }); events?.emit('inbox.address_deleted', { address });
return c.json({ ok: true }); return c.json({ ok: true });
}); });
@@ -317,13 +329,25 @@ export function createInboxRoutes(
await verifyPayload(crypto, owner, { ...body, address }); await verifyPayload(crypto, owner, { ...body, address });
const now = Date.now(); const now = Date.now();
const rows = await store.fetchBlobs({ const rawRows = await store.fetchBlobs({
address, address,
sinceCursor, sinceCursor,
now, now,
limit: quota.fetchPageLimit, limit: quota.fetchPageLimit,
}); });
// V4.8.4 — drop blobs the bridge has already pushed to this address
// within the grace window. This is the cross-channel dedup gate that
// makes "one inbox.send ⇒ one observable delivery" hold even when
// the recipient runs both a bridge subscription and inbox-poll. The
// cursor still advances over the whole `rawRows` window so the
// client doesn't get stuck behind suppressed blobs — pollOnce uses
// `nextCursor` (max receivedAt seen by the server, suppressed or
// not) for the next fetch.
const rows = options.bridgeDeliveryLog
? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
: rawRows;
let bytes = 0; let bytes = 0;
const blobs = rows.map((r) => { const blobs = rows.map((r) => {
bytes += r.ciphertext.length; bytes += r.ciphertext.length;
@@ -344,14 +368,24 @@ export function createInboxRoutes(
if (r.senderFp) out.from = r.senderFp; if (r.senderFp) out.from = r.senderFp;
return out; return out;
}); });
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor; // Advance the cursor past the FULL rawRows window — including blobs
// we suppressed because the bridge already pushed them. If we
// anchored the cursor on `rows` only, suppressed blobs in the
// middle of the window would block all subsequent fetches forever
// (re-fetched on every poll, re-suppressed, no progress). The
// bridge-delivery contract is "the bridge frame is the canonical
// delivery"; if the recipient missed processing it, they fall back
// to ack-via-DELETE or the blob ages out at TTL — same as a
// recipient that crashes mid-handler in the no-bridge case.
const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
const nextCursor = cursorAnchor;
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes }); events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
return c.json({ return c.json({
blobs, blobs,
cursor: nextCursor, cursor: nextCursor,
hasMore: rows.length === quota.fetchPageLimit, hasMore: rawRows.length === quota.fetchPageLimit,
}); });
}); });
@@ -377,6 +411,10 @@ export function createInboxRoutes(
if (removed) { if (removed) {
events?.emit('inbox.blob_acked', { address, msgId }); events?.emit('inbox.blob_acked', { address, msgId });
} }
// Drop any bridge-delivery mark — keeps the log bounded under
// sustained traffic (otherwise long-lived addresses accumulate
// entries even after the underlying blob is gone).
options.bridgeDeliveryLog?.forget(address, msgId);
return c.json({ ok: removed }); return c.json({ ok: removed });
}); });

View File

@@ -0,0 +1,295 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { Hono } from 'hono';
import {
createBlobRoutes,
MemoryBlobStore,
type BlobStore,
} from '../src/index.js';
import { signPayload } from '@shade/server';
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { toBase64, fromBase64 } from '@shade/core';
const crypto = new SubtleCryptoProvider();
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
function hex(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
describe('Shade Blob Routes (V4.9)', () => {
let store: BlobStore;
let app: Hono;
beforeEach(() => {
store = new MemoryBlobStore();
app = createBlobRoutes(store, crypto, { disableRateLimit: true });
});
async function makeOwner() {
const seed = randBytes(32);
const pubkey = ed25519PublicKeyFromSeed(seed);
return { seed, pubkey };
}
function makeSlotId(): string {
return hex(randBytes(32));
}
async function signedPut(args: {
slotId: string;
blob: Uint8Array;
seed: Uint8Array;
pubkey: Uint8Array;
ifMatch?: string;
}) {
const payload: Record<string, unknown> = {
ownerPubkey: toBase64(args.pubkey),
blob: toBase64(args.blob),
slotId: args.slotId,
};
if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;
const signed = await signPayload(crypto, args.seed, payload);
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
return app.request(`/v1/blob/${args.slotId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wire),
});
}
async function signedDelete(args: {
slotId: string;
seed: Uint8Array;
}) {
const signed = await signPayload(crypto, args.seed, {
slotId: args.slotId,
});
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
return app.request(`/v1/blob/${args.slotId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wire),
});
}
// ─── GET ─────────────────────────────────────────────────────
test('GET on missing slot returns 404', async () => {
const slotId = makeSlotId();
const res = await app.request(`/v1/blob/${slotId}`);
expect(res.status).toBe(404);
});
test('GET requires lowercase 64-hex slotId', async () => {
const res = await app.request('/v1/blob/notahex');
expect(res.status).toBe(400);
const res2 = await app.request(`/v1/blob/${'A'.repeat(64)}`);
expect(res2.status).toBe(400);
});
// ─── PUT (TOFU) ──────────────────────────────────────────────
test('first PUT creates slot and returns etag', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const blob = randBytes(128);
const res = await signedPut({ slotId, blob, ...owner });
expect(res.status).toBe(200);
const json = (await res.json()) as { created: boolean; etag: string };
expect(json.created).toBe(true);
expect(typeof json.etag).toBe('string');
const got = await app.request(`/v1/blob/${slotId}`);
expect(got.status).toBe(200);
const back = (await got.json()) as { blob: string; etag: string };
expect(fromBase64(back.blob)).toEqual(blob);
expect(back.etag).toBe(json.etag);
});
test('PUT without ifMatch on populated slot returns 409', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
await signedPut({ slotId, blob: randBytes(64), ...owner });
const res = await signedPut({ slotId, blob: randBytes(64), ...owner });
expect(res.status).toBe(409);
const json = (await res.json()) as { code: string };
expect(json.code).toBe('SHADE_CONFLICT');
});
test('PUT with stale ifMatch returns 412', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
const j1 = (await r1.json()) as { etag: string };
// Use an etag we know does not match.
const stale = String(Number(j1.etag) - 999);
const res = await signedPut({
slotId,
blob: randBytes(64),
...owner,
ifMatch: stale,
});
expect(res.status).toBe(412);
const json = (await res.json()) as { code: string };
expect(json.code).toBe('SHADE_PRECONDITION_FAILED');
});
test('PUT with matching ifMatch updates and bumps etag', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
const j1 = (await r1.json()) as { etag: string };
const r2 = await signedPut({
slotId,
blob: randBytes(64),
...owner,
ifMatch: j1.etag,
});
expect(r2.status).toBe(200);
const j2 = (await r2.json()) as { created: boolean; etag: string };
expect(j2.created).toBe(false);
expect(Number(j2.etag)).toBeGreaterThan(Number(j1.etag));
});
test('PUT with ifMatch="*" unconditionally overwrites existing slot', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
await signedPut({ slotId, blob: randBytes(64), ...owner });
const res = await signedPut({
slotId,
blob: randBytes(64),
...owner,
ifMatch: '*',
});
expect(res.status).toBe(200);
});
test('PUT with ifMatch="*" on empty slot returns 412', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const res = await signedPut({
slotId,
blob: randBytes(64),
...owner,
ifMatch: '*',
});
expect(res.status).toBe(412);
});
test('PUT by a different owner key on existing slot is rejected', async () => {
const slotId = makeSlotId();
const ownerA = await makeOwner();
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
const ownerB = await makeOwner();
const res = await signedPut({
slotId,
blob: randBytes(64),
...ownerB,
ifMatch: '*',
});
expect(res.status).toBe(401);
});
test('PUT with bad signature is rejected', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
// Sign the payload, then mutate the blob bytes — signature no
// longer matches the canonicalized body.
const blob = randBytes(64);
const payload = {
ownerPubkey: toBase64(owner.pubkey),
blob: toBase64(blob),
slotId,
};
const signed = await signPayload(crypto, owner.seed, payload);
(signed as any).blob = toBase64(randBytes(64));
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
const res = await app.request(`/v1/blob/${slotId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wire),
});
expect(res.status).toBe(401);
});
test('PUT rejects empty blob and oversized blob', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const empty = await signedPut({ slotId, blob: new Uint8Array(0), ...owner });
expect(empty.status).toBe(400);
const tooBig = await signedPut({
slotId,
blob: randBytes(70 * 1024),
...owner,
});
expect(tooBig.status).toBe(400);
});
// ─── DELETE ──────────────────────────────────────────────────
test('DELETE clears slot and lets a fresh key TOFU re-claim', async () => {
const slotId = makeSlotId();
const ownerA = await makeOwner();
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
const del = await signedDelete({ slotId, seed: ownerA.seed });
expect(del.status).toBe(200);
// Slot is gone.
const gone = await app.request(`/v1/blob/${slotId}`);
expect(gone.status).toBe(404);
// A fresh owner can now claim it.
const ownerB = await makeOwner();
const claim = await signedPut({ slotId, blob: randBytes(64), ...ownerB });
expect(claim.status).toBe(200);
});
test('DELETE by a different key is rejected', async () => {
const slotId = makeSlotId();
const ownerA = await makeOwner();
await signedPut({ slotId, blob: randBytes(64), ...ownerA });
const ownerB = await makeOwner();
const res = await signedDelete({ slotId, seed: ownerB.seed });
expect(res.status).toBe(401);
});
test('DELETE on missing slot returns 404', async () => {
const slotId = makeSlotId();
const owner = await makeOwner();
const res = await signedDelete({ slotId, seed: owner.seed });
expect(res.status).toBe(404);
});
// ─── Cross-slot replay ───────────────────────────────────────
test('PUT signed for slot A is rejected against slot B', async () => {
const slotA = makeSlotId();
const slotB = makeSlotId();
const owner = await makeOwner();
const blob = randBytes(64);
// Sign for slotA, send to slotB (URL).
const payload = {
ownerPubkey: toBase64(owner.pubkey),
blob: toBase64(blob),
slotId: slotA,
};
const signed = await signPayload(crypto, owner.seed, payload);
const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
const res = await app.request(`/v1/blob/${slotB}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wire),
});
expect(res.status).toBe(401);
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox", "name": "@shade/inbox",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -0,0 +1,208 @@
import type { CryptoProvider } from '@shade/core';
import {
NetworkError,
toBase64,
fromBase64,
ShadeError,
ValidationError,
} from '@shade/core';
import { signPayload } from '@shade/server';
/**
* Low-level HTTP client for the V4.9 encrypted-blob primitive
* (`/v1/blob/<slotId>`). Stateless and reusable; higher-level wrappers
* (e.g. `Profile` in `@shade/sdk`) compose this client.
*
* The client doesn't care what the blob bytes mean — it just transports
* them. Callers are responsible for AEAD-sealing/opening, deriving the
* slotId from the master key, and managing the signing key.
*/
export interface BlobClientOptions {
baseUrl: string;
crypto: CryptoProvider;
/** Optional fetch override (defaults to globalThis.fetch). */
fetch?: typeof fetch;
}
export interface BlobGetResult {
blob: Uint8Array;
/** ETag string — pass back as `ifMatch` to do a CAS update. */
etag: string;
updatedAt: number;
}
export interface BlobPutResult {
/** True if this PUT created the slot, false if it updated an existing one. */
created: boolean;
/** New ETag after the write. */
etag: string;
updatedAt: number;
}
export class BlobClient {
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: BlobClientOptions) {
const f = options.fetch ?? globalThis.fetch;
this.fetchImpl = f.bind(globalThis);
}
/**
* Read a slot. Returns null if no blob has ever been written there
* (or if it was DELETE'd). GET is unauthenticated — see the
* `BlobStore` JSDoc for the threat-model rationale.
*/
async get(slotIdHex: string): Promise<BlobGetResult | null> {
validateSlotIdHex(slotIdHex);
const url = joinUrl(this.options.baseUrl, `/v1/blob/${slotIdHex}`);
let res: Response;
try {
res = await this.fetchImpl(url, { method: 'GET' });
} catch (err) {
throw new NetworkError(`Blob GET failed: ${(err as Error).message}`);
}
if (res.status === 404) return null;
const text = await res.text();
let json: any;
try {
json = text.length > 0 ? JSON.parse(text) : {};
} catch {
throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
}
if (!res.ok) {
throw new ShadeError(
String(json.code ?? 'SHADE_NETWORK'),
String(json.message ?? text),
);
}
return {
blob: fromBase64(String(json.blob)),
etag: String(json.etag),
updatedAt: Number(json.updatedAt),
};
}
/**
* Create or update a slot.
*
* `ifMatch` semantics:
* - `undefined`: create-only. Slot must be empty (else 409).
* - `<etag-string>`: compare-and-swap. Must match (else 412).
* - `'*'`: unconditional overwrite. Slot must already exist (else 412).
*/
async put(args: {
slotIdHex: string;
blob: Uint8Array;
/** 32-byte Ed25519 seed (== `signingPrivateKey`). */
signingPrivateKey: Uint8Array;
/** Pubkey paired to `signingPrivateKey`. */
ownerPubkey: Uint8Array;
ifMatch?: string;
}): Promise<BlobPutResult> {
validateSlotIdHex(args.slotIdHex);
if (args.blob.length === 0) {
throw new ValidationError('Empty blob');
}
if (args.ownerPubkey.length !== 32) {
throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)');
}
const payload: Record<string, unknown> = {
ownerPubkey: toBase64(args.ownerPubkey),
blob: toBase64(args.blob),
slotId: args.slotIdHex,
};
if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;
const signed = await signPayload(
this.options.crypto,
args.signingPrivateKey,
payload,
);
// `slotId` was used for the signature canonicalization to bind it
// into the payload; the server rebuilds the same canonical form
// by mixing the URL slotId back in. Strip it from the wire body
// so we don't send it twice (URL is the path param).
const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
const json = await this.requestJson('PUT', url, wireBody);
return {
created: Boolean(json.created),
etag: String(json.etag),
updatedAt: Number(json.updatedAt),
};
}
/**
* Delete a slot — the next PUT TOFU-claims it again, possibly under
* a fresh signing key (e.g. after a rotation). Used by the "forget
* everything" path.
*/
async delete(args: {
slotIdHex: string;
signingPrivateKey: Uint8Array;
}): Promise<boolean> {
validateSlotIdHex(args.slotIdHex);
const signed = await signPayload(this.options.crypto, args.signingPrivateKey, {
slotId: args.slotIdHex,
});
const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
const json = await this.requestJson('DELETE', url, wireBody);
return Boolean(json.ok);
}
// ─── HTTP plumbing ──────────────────────────────────────────
private async requestJson(method: string, url: string, body: unknown): Promise<any> {
let res: Response;
try {
res = await this.fetchImpl(url, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) {
throw new NetworkError(`Blob request failed: ${(err as Error).message}`);
}
const text = await res.text();
let json: any;
try {
json = text.length > 0 ? JSON.parse(text) : {};
} catch {
throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
}
if (!res.ok) {
throw new ShadeError(
String(json.code ?? 'SHADE_NETWORK'),
String(json.message ?? text),
);
}
return json;
}
}
function validateSlotIdHex(s: string): void {
if (!/^[0-9a-f]{64}$/.test(s)) {
throw new ValidationError('slotIdHex must be 64 lowercase hex chars (32 bytes)');
}
}
function joinUrl(base: string, path: string): string {
if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
return base + path;
}
/** Convert a 32-byte slotId Uint8Array into the lowercase-hex wire form. */
export function slotIdToHex(slotId: Uint8Array): string {
if (slotId.length !== 32) {
throw new ValidationError('slotId must be 32 bytes');
}
let s = '';
for (let i = 0; i < slotId.length; i++) {
s += slotId[i]!.toString(16).padStart(2, '0');
}
return s;
}

View File

@@ -63,6 +63,14 @@ export interface InboxOptions {
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
const DEFAULT_POLL_INTERVAL_MS = 30_000; const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 10; const DEFAULT_MAX_ATTEMPTS = 10;
/**
* Cap for the cross-channel msgId dedup LRU. Each entry is a 64-char hex
* string; 4096 entries ≈ 256 KiB of overhead, plenty of headroom for
* bursty traffic (the LRU only needs to span the window between a bridge
* push and the next inbox-poll catching up — typically 30 s × the
* recipient's throughput).
*/
const DEFAULT_DEDUP_LRU_CAP = 4096;
/** /**
* High-level inbox orchestrator. * High-level inbox orchestrator.
@@ -105,6 +113,23 @@ export class Inbox {
private started = false; private started = false;
private registered = false; private registered = false;
/**
* Bounded msgId dedup window. Used by both the inbox-poll path
* (`pollOnce` → `handleBlob`) and the bridge-push path
* (`acceptBridgeFrame`). The relay stores blobs durably and pushes
* them to every active delivery channel; without a shared dedup gate
* here the recipient processes the same envelope twice — once from
* the bridge, again from the next inbox-poll. The duplicate receive
* trips on consumed one-time prekeys ("OPK not found") and pollutes
* logs even when the canonical first delivery succeeded. See V4.8.3
* Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
*
* Insertion order is FIFO; the oldest msgId is evicted once the LRU
* exceeds `DEFAULT_DEDUP_LRU_CAP`.
*/
private readonly deliveredIds = new Set<string>();
private readonly deliveredOrder: string[] = [];
constructor(private readonly options: InboxOptions) { constructor(private readonly options: InboxOptions) {
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = { const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
@@ -222,7 +247,10 @@ export class Inbox {
* after a push-trigger arrives). Does not throw on transient errors. * after a push-trigger arrives). Does not throw on transient errors.
*/ */
async tick(): Promise<{ flushed: number; received: number }> { async tick(): Promise<{ flushed: number; received: number }> {
const flushed = await this.flushOnce(); const flushResult = await this.flushOnce();
// `null` means another flush was concurrent; report 0 newly-flushed
// for this caller (the other flush counted them).
const flushed = flushResult?.delivered ?? 0;
const received = await this.pollOnce(); const received = await this.pollOnce();
return { flushed, received }; return { flushed, received };
} }
@@ -276,11 +304,27 @@ export class Inbox {
this.flushTimer = setTimeout(() => { this.flushTimer = setTimeout(() => {
this.flushTimer = null; this.flushTimer = null;
this.flushOnce() this.flushOnce()
.then(() => { .then((result) => {
// If anything is still queued, retry with backoff. // `result === null` means another flush was already in flight
this.queueStore.size().then((n) => { // and this call early-returned via the `flushing` guard. The
if (n > 0 && this.started) this.scheduleFlush(15_000); // already-running flush will reschedule itself when it
}); // finishes; do not double-schedule from here.
if (result === null) return;
if (result.remaining === 0) return;
// V4.8.5 — distinguish healthy-drain-but-more-queued from
// all-attempts-failed. Pre-fix, both cases used a 15 s
// backoff. Under sustained traffic (Prism's typing-into-a-
// chatty-shell pattern), bursts of envelopes enqueued
// *during* a flush would sit ~1015 s behind the backoff
// timer before the next drain — visible to the receiver as a
// "10 s silence then 25-frame burst" wave. Healthy drain
// (delivered > 0) means the network is fine and we should
// immediately drain whatever piled up; reserve the 15 s
// retry for the case where every attempt this round failed.
if (this.started) {
const delay = result.delivered > 0 ? 0 : 15_000;
this.scheduleFlush(delay);
}
}) })
.catch(() => { .catch(() => {
if (this.started) this.scheduleFlush(15_000); if (this.started) this.scheduleFlush(15_000);
@@ -299,13 +343,47 @@ export class Inbox {
}, delayMs); }, delayMs);
} }
private async flushOnce(): Promise<number> { /**
if (this.flushing) return 0; * Drain the outgoing queue. Returns `null` if another flush is already
* in flight (the running flush owns the rescheduling); otherwise
* returns the count of newly-delivered envelopes and the queue size
* after the drain so the caller can decide whether to immediately
* re-flush (more piled up during the drain — healthy network) or
* back off (everything failed).
*
* V4.8.5: drain is parallel-per-recipient. Each `recipientAddress`
* gets its own sequential worker (so per-peer order is preserved),
* but distinct recipients run concurrently. Pre-fix, a single slow
* POST head-of-line-blocked the entire queue — including small
* frames bound for unrelated peers. See Prism FR
* `per-recipient-flush-concurrency-v4.8.md`.
*/
private async flushOnce(): Promise<{ delivered: number; remaining: number } | null> {
if (this.flushing) return null;
this.flushing = true; this.flushing = true;
let delivered = 0; let delivered = 0;
try { try {
const entries = await this.queueStore.list(); const entries = await this.queueStore.list();
// Group by recipient. Within a bucket we drain sequentially so
// per-peer message order matches enqueue order (the relay
// assigns `receivedAt` on PUT arrival; concurrent POSTs to the
// same peer would let the second arrive first and the recipient
// would observe out-of-order envelopes). Across buckets, no
// ordering guarantee exists in Shade's wire model anyway, so
// parallel drain is safe.
const buckets = new Map<string, OutgoingEntry[]>();
for (const entry of entries) { for (const entry of entries) {
let bucket = buckets.get(entry.recipientAddress);
if (!bucket) {
bucket = [];
buckets.set(entry.recipientAddress, bucket);
}
bucket.push(entry);
}
const drainBucket = async (bucket: OutgoingEntry[]): Promise<number> => {
let count = 0;
for (const entry of bucket) {
try { try {
const result = await this.client.put({ const result = await this.client.put({
recipientAddress: entry.recipientAddress, recipientAddress: entry.recipientAddress,
@@ -314,7 +392,7 @@ export class Inbox {
ttlSeconds: entry.ttlSeconds, ttlSeconds: entry.ttlSeconds,
}); });
await this.queueStore.remove(entry.recipientAddress, entry.msgId); await this.queueStore.remove(entry.recipientAddress, entry.msgId);
delivered++; count++;
this.events.emit('inbox.message_delivered', { this.events.emit('inbox.message_delivered', {
recipientAddress: entry.recipientAddress, recipientAddress: entry.recipientAddress,
msgId: result.msgId, msgId: result.msgId,
@@ -334,10 +412,18 @@ export class Inbox {
} }
} }
} }
return count;
};
const counts = await Promise.all(
Array.from(buckets.values(), drainBucket),
);
delivered = counts.reduce((a, b) => a + b, 0);
} finally { } finally {
this.flushing = false; this.flushing = false;
} }
return delivered; const remaining = await this.queueStore.size();
return { delivered, remaining };
} }
private async pollOnce(): Promise<number> { private async pollOnce(): Promise<number> {
@@ -380,9 +466,52 @@ export class Inbox {
return total; return total;
} }
/**
* Feed a blob delivered by a bridge transport (WS / SSE / long-poll
* push) into the same dispatch + ack pipeline that `pollOnce` uses.
*
* Wire-up pattern:
* ```ts
* const bridge = new FallbackBridgeTransport([...]);
* await bridge.connect({
* onMessage: async (msg) => {
* await inbox.acceptBridgeFrame({
* msgId: msg.msgId!, // present on v4.8+ relays
* ciphertext: msg.bytes,
* receivedAt: msg.receivedAt,
* expiresAt: msg.expiresAt ?? Date.now() + 7 * 24 * 3600 * 1000,
* ...(msg.from !== undefined ? { from: msg.from } : {}),
* });
* },
* });
* ```
*
* The Inbox's bounded msgId LRU is shared between this path and
* `pollOnce`, so whichever channel delivers first wins; the
* other channel acks-and-skips when the same msgId comes back
* around. Both paths also DELETE the blob from the relay on success
* so subsequent polls don't see it either.
*
* Returns `true` if the blob was newly dispatched, `false` if it
* was a duplicate or rejected by the handler (handler still gets a
* chance to retry on the next poll if it threw).
*/
async acceptBridgeFrame(blob: FetchedBlob): Promise<boolean> {
return this.handleBlob(blob);
}
private async handleBlob(blob: FetchedBlob): Promise<boolean> { private async handleBlob(blob: FetchedBlob): Promise<boolean> {
if (!this.incomingHandler) return false; if (!this.incomingHandler) return false;
// Cross-channel msgId dedup. If the bridge already delivered this
// blob, the inbox-poll copy must not re-dispatch (would re-trigger
// X3DH / consume an OPK we no longer have). We still ack so the
// relay drops the now-redundant copy.
if (this.deliveredIds.has(blob.msgId)) {
await this.ackQuietly(blob.msgId);
return false;
}
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A // Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
// server bug or malicious operator can't sneak a different blob past // server bug or malicious operator can't sneak a different blob past
// the client's hash check. // the client's hash check.
@@ -422,17 +551,34 @@ export class Inbox {
return false; return false;
} }
try { // Mark before the ack so a slow-network ack doesn't leave a window
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId }); // where a parallel pollOnce sees the same msgId and re-dispatches.
} catch (err) { this.recordDelivered(blob.msgId);
// Decryption succeeded; ack just failed. Will be retried later, and await this.ackQuietly(blob.msgId);
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
}
this.events.emit('inbox.message_received', { this.events.emit('inbox.message_received', {
senderHint, senderHint,
msgId: blob.msgId, msgId: blob.msgId,
}); });
return true; return true;
} }
private async ackQuietly(msgId: string): Promise<void> {
try {
await this.client.ack({ address: this.options.ownAddress, msgId });
} catch (err) {
// Dispatch (or skip) succeeded; the ack just failed. Next poll
// will see the blob again and the dedup gate above will skip it.
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
}
}
private recordDelivered(msgId: string): void {
if (this.deliveredIds.has(msgId)) return;
this.deliveredIds.add(msgId);
this.deliveredOrder.push(msgId);
if (this.deliveredOrder.length > DEFAULT_DEDUP_LRU_CAP) {
const evicted = this.deliveredOrder.shift()!;
this.deliveredIds.delete(evicted);
}
}
} }

View File

@@ -43,3 +43,11 @@ export type {
} from './events.js'; } from './events.js';
export { computeMsgId } from './msg-id.js'; export { computeMsgId } from './msg-id.js';
// V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
export { BlobClient, slotIdToHex } from './blob-client.js';
export type {
BlobClientOptions,
BlobGetResult,
BlobPutResult,
} from './blob-client.js';

View File

@@ -170,6 +170,255 @@ describe('Inbox orchestrator', () => {
expect(seen[1]!.to).toBe('carol'); expect(seen[1]!.to).toBe('carol');
}); });
test('cross-channel dedup: acceptBridgeFrame + pollOnce never re-dispatch the same msgId (V4.8.3)', async () => {
// Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`:
// a single relay PUT was being delivered twice — once via WS bridge
// push, again ~30 s later via inbox-poll catching up. Both copies
// would dispatch `shade.receive`, the second one tripping on
// already-consumed prekeys. The cross-channel msgId LRU inside
// Inbox is the dedup gate; this test exercises it directly via
// `acceptBridgeFrame` followed by `pollOnce`.
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
await bobInbox.register();
// Alice PUTs a blob via the relay HTTP API.
const ct = randBytes(64);
const msgId = await computeMsgId(ct);
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
const putResult = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
expect(putResult.idempotent).toBe(false);
const dispatched: string[] = [];
bobInbox.onIncoming(async (raw) => {
dispatched.push(raw.msgId);
return null;
});
// Simulate the bridge push arriving first.
await bobInbox.acceptBridgeFrame({
msgId,
ciphertext: ct,
receivedAt: putResult.receivedAt,
expiresAt: Date.now() + 60_000,
});
expect(dispatched).toEqual([msgId]);
// The inbox-poll path catches up next — without dedup it would
// re-dispatch. With the LRU it acks-and-skips.
const polled = await bobInbox.tick();
expect(polled.received).toBe(0);
expect(dispatched).toEqual([msgId]); // still one entry
});
test('cross-channel dedup also covers poll-first then bridge-second order', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'bob',
crypto,
signingPrivateKey: bob.signingPrivateKey,
signingPublicKey: bob.signingPublicKey,
pollIntervalMs: 0,
fetch: honoFetch(app),
});
await bobInbox.register();
const ct = randBytes(48);
const msgId = await computeMsgId(ct);
const aliceClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: alice.signingPrivateKey,
fetch: honoFetch(app),
});
const putRes = await aliceClient.put({
recipientAddress: 'bob',
senderSigningKey: alice.signingPublicKey,
envelope: ct,
});
const dispatched: string[] = [];
bobInbox.onIncoming(async (raw) => {
dispatched.push(raw.msgId);
return null;
});
// Poll first.
const polled = await bobInbox.tick();
expect(polled.received).toBe(1);
// Bridge frame for the same msgId arrives after the poll already
// dispatched + ack'd it — must be a no-op.
const handled = await bobInbox.acceptBridgeFrame({
msgId,
ciphertext: ct,
receivedAt: putRes.receivedAt,
expiresAt: Date.now() + 60_000,
});
expect(handled).toBe(false);
expect(dispatched).toEqual([msgId]);
});
test('burst enqueued during a flush drains immediately, not after 15 s backoff (V4.8.5)', async () => {
// Reproduces Prism FR `per-recipient-flush-concurrency-v4.8`: a
// burst of envelopes enqueued *during* a slow POST used to sit
// ~15 s behind the next flush because both the success path and
// the failure path of `flushOnce` rescheduled with the same 15 s
// backoff. The fix uses 0 ms when the round delivered something
// (network is healthy — drain remainder) and reserves 15 s for
// the all-attempts-failed case.
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: bob.signingPrivateKey,
fetch: honoFetch(app),
});
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
// Wrap fetch so the FIRST PUT (only) takes 100 ms — long enough
// for many enqueues to land while it's in flight.
let firstPutSeen = false;
const slowFirstFetch: typeof fetch = (async (input, init) => {
const u =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const isPut = u.includes('/v1/inbox/bob') && !u.includes('/fetch');
if (isPut && !firstPutSeen) {
firstPutSeen = true;
await new Promise((r) => setTimeout(r, 100));
}
return honoFetch(app)(input, init);
}) as typeof fetch;
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: slowFirstFetch,
});
aliceInbox.start();
// First send — this kicks the slow-PUT path.
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
// Pile 24 more on top while the first PUT is still in flight. The
// first PUT will finish at ~T+100 ms; the subsequent 24 should
// drain immediately after, NOT after a 15 s backoff.
for (let i = 0; i < 24; i++) {
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
}
// Wait long enough for the slow first PUT + the immediate
// reschedule + the 24-envelope drain. Pre-fix this would still
// have ≥1 entry pending after 1 s (waiting for the 15 s timer).
await new Promise((r) => setTimeout(r, 1_000));
expect(await aliceInbox.pendingCount()).toBe(0);
aliceInbox.stop();
});
test('per-recipient parallel drain — slow POST to A does not block POSTs to B (V4.8.5)', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const alice = await makeIdentity();
const bob = await makeIdentity();
const carol = await makeIdentity();
// Register bob + carol.
const reg = async (name: string, kp: { signingPrivateKey: Uint8Array; signingPublicKey: Uint8Array }) => {
const c = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: kp.signingPrivateKey,
fetch: honoFetch(app),
});
await c.register({ address: name, signingKey: kp.signingPublicKey });
};
await reg('bob', bob);
await reg('carol', carol);
// bob's PUT route stalls 200 ms; carol's is instant. Pre-fix this
// would head-of-line block carol behind bob.
const slowedFetch: typeof fetch = (async (input, init) => {
const u =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const m = (init as RequestInit | undefined)?.method ?? 'GET';
if (m === 'POST' && u.includes('/v1/inbox/bob') && !u.includes('/fetch')) {
await new Promise((r) => setTimeout(r, 200));
}
return honoFetch(app)(input, init);
}) as typeof fetch;
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: slowedFetch,
});
const carolDeliveredAt = new Promise<number>((resolve) => {
aliceInbox.on((e) => {
if (e.name === 'inbox.message_delivered' && e.data.recipientAddress === 'carol') {
resolve(Date.now());
}
});
});
const t0 = Date.now();
// Bob queue first, carol second — pre-fix carol would wait 200 ms
// behind bob's slow PUT. With per-recipient parallelism, carol's
// PUT runs concurrently and lands first.
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
await aliceInbox.send({ recipientAddress: 'carol', envelope: randBytes(20) });
aliceInbox.start();
const carolAt = await carolDeliveredAt;
const carolElapsed = carolAt - t0;
expect(carolElapsed).toBeLessThan(150);
aliceInbox.stop();
});
test('flush retries on transient server failure', async () => { test('flush retries on transient server failure', async () => {
const store = new MemoryInboxStore(); const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true }); const app = createInboxServer({ crypto, store, disableRateLimit: true });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/key-transparency", "name": "@shade/key-transparency",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/keychain", "name": "@shade/keychain",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observability", "name": "@shade/observability",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observer", "name": "@shade/observer",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/proto", "name": "@shade/proto",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -8,7 +8,20 @@ export {
encodeBroadcast, encodeBroadcast,
decodeBroadcast, decodeBroadcast,
inspectEnvelopeType, inspectEnvelopeType,
encodeStreamOpen,
encodeStreamOpenAck,
decodeStreamHandshake,
encodeStreamFrame,
decodeStreamFrame,
TYPE_STREAM_CHUNK, TYPE_STREAM_CHUNK,
TYPE_BROADCAST, TYPE_BROADCAST,
TYPE_STREAM_OPEN,
TYPE_STREAM_OPEN_ACK,
TYPE_STREAM_FRAME,
} from './wire.js';
export type {
StreamChunkWire,
BroadcastWire,
StreamHandshakeWire,
StreamFrameWire,
} from './wire.js'; } from './wire.js';
export type { StreamChunkWire, BroadcastWire } from './wire.js';

View File

@@ -25,6 +25,13 @@ const TYPE_PREKEY = 0x01;
const TYPE_RATCHET = 0x02; const TYPE_RATCHET = 0x02;
export const TYPE_STREAM_CHUNK = 0x11; export const TYPE_STREAM_CHUNK = 0x11;
export const TYPE_BROADCAST = 0x21; export const TYPE_BROADCAST = 0x21;
// V4.11 — streaming Double-Ratchet sub-session (long-lived WS channels).
export const TYPE_STREAM_OPEN = 0x31;
export const TYPE_STREAM_OPEN_ACK = 0x32;
export const TYPE_STREAM_FRAME = 0x33;
const STREAM_SESSION_ID_BYTES = 16;
const STREAM_EPHEMERAL_BYTES = 32;
// ─── Stream chunk types ────────────────────────────────────── // ─── Stream chunk types ──────────────────────────────────────
@@ -258,7 +265,15 @@ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire {
*/ */
export function inspectEnvelopeType( export function inspectEnvelopeType(
data: Uint8Array, data: Uint8Array,
): 'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown' { ):
| 'prekey'
| 'ratchet'
| 'stream-chunk'
| 'broadcast'
| 'stream-open'
| 'stream-open-ack'
| 'stream-frame'
| 'unknown' {
if (data.length < 2 || data[0] !== VERSION) return 'unknown'; if (data.length < 2 || data[0] !== VERSION) return 'unknown';
switch (data[1]) { switch (data[1]) {
case TYPE_PREKEY: case TYPE_PREKEY:
@@ -269,11 +284,122 @@ export function inspectEnvelopeType(
return 'stream-chunk'; return 'stream-chunk';
case TYPE_BROADCAST: case TYPE_BROADCAST:
return 'broadcast'; return 'broadcast';
case TYPE_STREAM_OPEN:
return 'stream-open';
case TYPE_STREAM_OPEN_ACK:
return 'stream-open-ack';
case TYPE_STREAM_FRAME:
return 'stream-frame';
default: default:
return 'unknown'; return 'unknown';
} }
} }
// ─── Stream sub-session wire (V4.11) ─────────────────────────
/**
* A decoded stream handshake frame (`STREAM_OPEN` / `STREAM_OPEN_ACK`).
* Both share the layout `[version][type][streamId:16][ephemeralPub:32]`.
*/
export interface StreamHandshakeWire {
kind: 'open' | 'open-ack';
streamId: Uint8Array; // 16 bytes
ephemeralPub: Uint8Array; // 32 bytes (X25519)
}
/**
* A decoded sealed stream frame (`STREAM_FRAME`): a streamId plus an
* embedded Double-Ratchet message. One sealed logical frame ⇒ exactly
* one of these ⇒ one WS text/binary frame.
*/
export interface StreamFrameWire {
streamId: Uint8Array; // 16 bytes
message: RatchetMessage;
}
function encodeStreamHandshake(
type: number,
streamId: Uint8Array,
ephemeralPub: Uint8Array,
): Uint8Array {
if (streamId.length !== STREAM_SESSION_ID_BYTES) {
throw new Error(`streamId must be ${STREAM_SESSION_ID_BYTES} bytes`);
}
if (ephemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new Error(`ephemeralPub must be ${STREAM_EPHEMERAL_BYTES} bytes`);
}
const out = new Uint8Array(2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES);
out[0] = VERSION;
out[1] = type;
out.set(streamId, 2);
out.set(ephemeralPub, 2 + STREAM_SESSION_ID_BYTES);
return out;
}
/** Encode the initiator's `STREAM_OPEN` (streamId + initiator ephemeral). */
export function encodeStreamOpen(streamId: Uint8Array, ephemeralPub: Uint8Array): Uint8Array {
return encodeStreamHandshake(TYPE_STREAM_OPEN, streamId, ephemeralPub);
}
/** Encode the responder's `STREAM_OPEN_ACK` (streamId + responder ephemeral). */
export function encodeStreamOpenAck(streamId: Uint8Array, ephemeralPub: Uint8Array): Uint8Array {
return encodeStreamHandshake(TYPE_STREAM_OPEN_ACK, streamId, ephemeralPub);
}
/** Decode either handshake frame. Throws on wrong type / bad length. */
export function decodeStreamHandshake(data: Uint8Array): StreamHandshakeWire {
const expected = 2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES;
if (data.length !== expected) {
throw new Error(`stream handshake must be ${expected} bytes, got ${data.length}`);
}
if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`);
let kind: 'open' | 'open-ack';
if (data[1] === TYPE_STREAM_OPEN) kind = 'open';
else if (data[1] === TYPE_STREAM_OPEN_ACK) kind = 'open-ack';
else throw new Error(`Not a stream handshake: type=${data[1]}`);
return {
kind,
streamId: data.slice(2, 2 + STREAM_SESSION_ID_BYTES),
ephemeralPub: data.slice(
2 + STREAM_SESSION_ID_BYTES,
2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES,
),
};
}
/**
* Encode a sealed stream frame: `[version][0x33][streamId:16][ratchet…]`.
* Reuses the exact ratchet-message inner codec the HTTP path uses, so a
* stream frame carries the same Double-Ratchet header + AEAD payload.
*/
export function encodeStreamFrame(streamId: Uint8Array, msg: RatchetMessage): Uint8Array {
if (streamId.length !== STREAM_SESSION_ID_BYTES) {
throw new Error(`streamId must be ${STREAM_SESSION_ID_BYTES} bytes`);
}
const inner = encodeRatchetMessageInner(msg);
const out = new Uint8Array(2 + STREAM_SESSION_ID_BYTES + inner.length);
out[0] = VERSION;
out[1] = TYPE_STREAM_FRAME;
out.set(streamId, 2);
out.set(inner, 2 + STREAM_SESSION_ID_BYTES);
return out;
}
/** Decode a sealed stream frame. Throws on wrong type / truncation. */
export function decodeStreamFrame(data: Uint8Array): StreamFrameWire {
const minSize = 2 + STREAM_SESSION_ID_BYTES;
if (data.length < minSize) {
throw new Error(`stream-frame too short: ${data.length} < ${minSize}`);
}
if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`);
if (data[1] !== TYPE_STREAM_FRAME) {
throw new Error(`Not a stream-frame: type=${data[1]}`);
}
const streamId = data.slice(2, 2 + STREAM_SESSION_ID_BYTES);
const message = decodeRatchetMessageInner(data, 2 + STREAM_SESSION_ID_BYTES).value;
return { streamId, message };
}
// ─── Broadcast wire (V4.6) ─────────────────────────────────── // ─── Broadcast wire (V4.6) ───────────────────────────────────
const BROADCAST_NONCE_BYTES = 12; const BROADCAST_NONCE_BYTES = 12;

View File

@@ -0,0 +1,71 @@
import { describe, test, expect } from 'bun:test';
import {
encodeStreamOpen,
encodeStreamOpenAck,
decodeStreamHandshake,
encodeStreamFrame,
decodeStreamFrame,
inspectEnvelopeType,
} from '../src/index.js';
import type { RatchetMessage } from '@shade/core';
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
function makeRatchetMessage(): RatchetMessage {
return {
dhPublicKey: randBytes(32),
previousCounter: 3,
counter: 9001,
ciphertext: randBytes(128),
nonce: randBytes(12),
};
}
describe('Stream sub-session wire (V4.11)', () => {
test('STREAM_OPEN round-trips and inspects', () => {
const sid = randBytes(16);
const eph = randBytes(32);
const bytes = encodeStreamOpen(sid, eph);
expect(inspectEnvelopeType(bytes)).toBe('stream-open');
const hs = decodeStreamHandshake(bytes);
expect(hs.kind).toBe('open');
expect(hs.streamId).toEqual(sid);
expect(hs.ephemeralPub).toEqual(eph);
});
test('STREAM_OPEN_ACK round-trips and inspects', () => {
const sid = randBytes(16);
const eph = randBytes(32);
const bytes = encodeStreamOpenAck(sid, eph);
expect(inspectEnvelopeType(bytes)).toBe('stream-open-ack');
const hs = decodeStreamHandshake(bytes);
expect(hs.kind).toBe('open-ack');
expect(hs.streamId).toEqual(sid);
expect(hs.ephemeralPub).toEqual(eph);
});
test('STREAM_FRAME carries a full ratchet message verbatim', () => {
const sid = randBytes(16);
const msg = makeRatchetMessage();
const bytes = encodeStreamFrame(sid, msg);
expect(inspectEnvelopeType(bytes)).toBe('stream-frame');
const decoded = decodeStreamFrame(bytes);
expect(decoded.streamId).toEqual(sid);
expect(decoded.message.dhPublicKey).toEqual(msg.dhPublicKey);
expect(decoded.message.previousCounter).toBe(msg.previousCounter);
expect(decoded.message.counter).toBe(msg.counter);
expect(decoded.message.ciphertext).toEqual(msg.ciphertext);
expect(decoded.message.nonce).toEqual(msg.nonce);
});
test('rejects wrong sizes and wrong type tags', () => {
expect(() => encodeStreamOpen(randBytes(15), randBytes(32))).toThrow();
expect(() => encodeStreamOpen(randBytes(16), randBytes(31))).toThrow();
expect(() => decodeStreamHandshake(encodeStreamFrame(randBytes(16), makeRatchetMessage()))).toThrow();
expect(() => decodeStreamFrame(encodeStreamOpen(randBytes(16), randBytes(32)))).toThrow();
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/recovery", "name": "@shade/recovery",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/sdk", "name": "@shade/sdk",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -8,11 +8,13 @@
"@shade/core": "workspace:*", "@shade/core": "workspace:*",
"@shade/crypto-web": "workspace:*", "@shade/crypto-web": "workspace:*",
"@shade/files": "workspace:*", "@shade/files": "workspace:*",
"@shade/inbox": "workspace:*",
"@shade/key-transparency": "workspace:*", "@shade/key-transparency": "workspace:*",
"@shade/observability": "workspace:*", "@shade/observability": "workspace:*",
"@shade/observer": "workspace:*", "@shade/observer": "workspace:*",
"@shade/proto": "workspace:*", "@shade/proto": "workspace:*",
"@shade/server": "workspace:*", "@shade/server": "workspace:*",
"@shade/storage-encrypted": "workspace:*",
"@shade/storage-sqlite": "workspace:*", "@shade/storage-sqlite": "workspace:*",
"@shade/streams": "workspace:*", "@shade/streams": "workspace:*",
"@shade/transfer": "workspace:*", "@shade/transfer": "workspace:*",
@@ -27,6 +29,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@shade/inbox-server": "workspace:*",
"@shade/transport-webrtc": "workspace:*" "@shade/transport-webrtc": "workspace:*"
} }
} }

View File

@@ -0,0 +1,792 @@
import type { CryptoProvider } from '@shade/core';
import { ValidationError } from '@shade/core';
/**
* V4.10 — cross-host approval routing.
*
* Two pieces, both app-level (no relay changes):
*
* 1. A canonical schema for the V4.9 profile blob. The blob is opaque to
* Shade, but Prism's `cross-host-approval-routing` FR pointed out that
* every Shade app that supports credential-driven device linking will
* end up reinventing the same `hosts[]` / `clients[]` /
* `trustedApproverFingerprints[]` shape. We bless one here so apps can
* interop and so the proxy-approval verify path has a stable place to
* look up trusted-approver public keys.
*
* 2. Build / sign / verify helpers for `approvalNeeded` and
* `linkApproveByProxy` frames. The frames themselves are app-defined
* payloads sent over the existing `Shade.send` / `Shade.receive`
* bilateral E2EE channel — Shade just exposes the signing-payload
* canonicalization and Ed25519 plumbing so a fingerprint+signature
* bound to a long-term identity key can be verified on any host that
* has a fresh copy of the user's profile blob.
*
* The proxy-approval signature is *belt-and-suspenders* on top of the
* E2EE that delivered the frame: the E2EE channel already authenticates
* the sender's session, but the signature ties the approval explicitly
* to the approver's long-term identity key. That makes the approval
* verifiable independently of session state — useful if a host
* receives a forwarded approval without a prior session, and necessary
* for replay-resistance in the face of a compromised relay that
* reorders bilateral sends.
*
* Headless servers without a GUI use this with the existing X3DH-on-
* first-send behavior of `Shade.send` to ask any trusted-approver
* client (typically a phone) to approve a `linkRequest` from a new
* device. The approver phone biometrically gates a long-term Ed25519
* sign, ships the frame back, and the server runs `verifyProxyApproval`
* against the freshest profile blob before completing pairing.
*/
// ─── Canonical profile-blob schema ─────────────────────────────
/**
* A host: a device that *receives* `linkRequest` frames from new
* devices and runs the pairing handshake. Typically a desktop, server,
* or always-on laptop. Hosts have no special crypto status — they're
* just where the user's data lives.
*/
export interface ProfileHostEntry {
/** Shade address (`device:...`). */
address: string;
/** Human-friendly name (e.g. "PC", "Server"). */
name: string;
/** Open enum: `'desktop' | 'server' | 'laptop' | ...`. */
kind: string;
/** Wall-clock ms when this host was added to the profile. */
addedAt: number;
}
/**
* A client: a device that *initiates* link/approval flows and may
* proxy-approve link-requests on behalf of the account when
* `trustedApprover === true`. Typically a phone or tablet.
*
* `identityPublicKey` is the 32-byte Ed25519 long-term identity key.
* `identityFingerprint` is the human-readable safety-number derived
* from it (the same value `Shade.getFingerprintFor(address)` returns).
* Both are stored: the public key is what `verifyProxyApproval` uses
* to check signatures; the fingerprint is what UIs show users for
* out-of-band verification.
*/
export interface ProfileClientEntry {
/** Shade address (`device:...`). */
address: string;
/**
* 32-byte Ed25519 long-term identity public key, lowercase hex
* (64 chars). This is the key that anchors the client's
* prekey-bundle and that proxy-approvals are signed with.
*/
identityPublicKey: string;
/**
* Safety-number-style fingerprint of the identity key, exactly the
* format `computeFingerprint` returns. Stored for fast denormalized
* lookups and so UIs don't have to recompute it.
*/
identityFingerprint: string;
/** Human-friendly name (e.g. "iPhone 15"). */
name: string;
/** Open enum: `'mobile' | 'tablet' | 'browser' | ...`. */
kind: string;
/** Wall-clock ms when this client was added to the profile. */
addedAt: number;
/**
* When true, this client is allowed to proxy-approve `linkRequest`s
* that arrive at any host. Toggled via the workstation's
* Settings → Devices UI. Hosts MUST verify against the freshest
* profile blob before honoring an approval (to close the
* revocation TOCTOU window — see FR §5).
*/
trustedApprover?: boolean;
}
export interface CanonicalProfileBlob {
/** Schema version. Bump when the shape changes incompatibly. */
version: 1;
hosts: ProfileHostEntry[];
clients: ProfileClientEntry[];
/**
* Denormalized list of `clients[].identityFingerprint` values where
* `trustedApprover === true`. Hosts use this for the fast-path
* "is X allowed to approve?" check; the authoritative source is
* still the per-client `trustedApprover` flag.
*/
trustedApproverFingerprints: string[];
/** Wall-clock ms of the last write that produced this blob. */
updatedAt: number;
/**
* Optional hex-encoded pubkey that wrote this blob. Mirrors the
* profile-storage owner-pubkey but kept in-band so apps that need
* to display "last edited by X" don't have to round-trip the
* relay. Not used for verification — the relay's TOFU on
* owner-pubkey is the authoritative auth boundary.
*/
signedBy?: string;
}
const TEXT = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/** Build a fresh empty profile blob (timestamp = `now ?? Date.now()`). */
export function emptyCanonicalProfile(now?: number): CanonicalProfileBlob {
return {
version: 1,
hosts: [],
clients: [],
trustedApproverFingerprints: [],
updatedAt: now ?? Date.now(),
};
}
/**
* Decode a profile-blob plaintext (the `plaintext` of a
* `ProfileGetResult`) into the canonical shape. Throws
* `ValidationError` on malformed JSON or wrong shape.
*
* Forward-compatibility: unknown top-level fields are preserved so a
* device on an older version can round-trip a blob written by a
* newer device without losing data. Unknown fields inside `hosts[]` /
* `clients[]` entries are also preserved.
*/
export function parseCanonicalProfile(
plaintext: Uint8Array | string,
): CanonicalProfileBlob {
const text =
typeof plaintext === 'string' ? plaintext : TEXT_DECODER.decode(plaintext);
let json: unknown;
try {
json = JSON.parse(text);
} catch (err) {
throw new ValidationError(
`profile blob is not valid JSON: ${(err as Error).message}`,
);
}
if (!json || typeof json !== 'object' || Array.isArray(json)) {
throw new ValidationError('profile blob must be a JSON object');
}
const obj = json as Record<string, unknown>;
if (obj.version !== 1) {
throw new ValidationError(
`unsupported profile blob version: ${String(obj.version)}`,
);
}
const hosts = validateArray(obj.hosts, 'hosts', validateHostEntry);
const clients = validateArray(obj.clients, 'clients', validateClientEntry);
const tApp = obj.trustedApproverFingerprints;
const trustedApproverFingerprints = Array.isArray(tApp)
? tApp.map((v, i) => {
if (typeof v !== 'string') {
throw new ValidationError(
`trustedApproverFingerprints[${i}] must be a string`,
);
}
return v;
})
: [];
const updatedAt =
typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt)
? obj.updatedAt
: 0;
const out: CanonicalProfileBlob = {
version: 1,
hosts,
clients,
trustedApproverFingerprints,
updatedAt,
};
if (typeof obj.signedBy === 'string') out.signedBy = obj.signedBy;
return out;
}
/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
export function serializeCanonicalProfile(
blob: CanonicalProfileBlob,
): Uint8Array {
return TEXT.encode(JSON.stringify(blob));
}
function validateArray<T>(
v: unknown,
field: string,
validate: (entry: unknown, index: number, field: string) => T,
): T[] {
if (v === undefined) return [];
if (!Array.isArray(v)) {
throw new ValidationError(`${field} must be an array`);
}
return v.map((entry, i) => validate(entry, i, field));
}
function validateHostEntry(
entry: unknown,
index: number,
field: string,
): ProfileHostEntry {
if (!entry || typeof entry !== 'object') {
throw new ValidationError(`${field}[${index}] must be an object`);
}
const e = entry as Record<string, unknown>;
return {
address: requireString(e.address, `${field}[${index}].address`),
name: requireString(e.name, `${field}[${index}].name`),
kind: requireString(e.kind, `${field}[${index}].kind`),
addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
};
}
function validateClientEntry(
entry: unknown,
index: number,
field: string,
): ProfileClientEntry {
if (!entry || typeof entry !== 'object') {
throw new ValidationError(`${field}[${index}] must be an object`);
}
const e = entry as Record<string, unknown>;
const identityPublicKey = requireString(
e.identityPublicKey,
`${field}[${index}].identityPublicKey`,
);
if (!/^[0-9a-f]{64}$/.test(identityPublicKey)) {
throw new ValidationError(
`${field}[${index}].identityPublicKey must be 64 lowercase hex chars`,
);
}
const out: ProfileClientEntry = {
address: requireString(e.address, `${field}[${index}].address`),
identityPublicKey,
identityFingerprint: requireString(
e.identityFingerprint,
`${field}[${index}].identityFingerprint`,
),
name: requireString(e.name, `${field}[${index}].name`),
kind: requireString(e.kind, `${field}[${index}].kind`),
addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
};
if (typeof e.trustedApprover === 'boolean') {
out.trustedApprover = e.trustedApprover;
}
return out;
}
function requireString(v: unknown, field: string): string {
if (typeof v !== 'string') {
throw new ValidationError(`${field} must be a string`);
}
return v;
}
function requireNumber(v: unknown, field: string): number {
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new ValidationError(`${field} must be a finite number`);
}
return v;
}
// ─── Mutators (immutable; return new blob, never mutate input) ──
/**
* Insert or replace a host entry by address. Any existing host with
* the same address is overwritten. The output's `updatedAt` is set to
* `now ?? Date.now()` so callers don't have to remember to bump it.
*/
export function upsertHost(
blob: CanonicalProfileBlob,
host: ProfileHostEntry,
now?: number,
): CanonicalProfileBlob {
const hosts = blob.hosts.filter((h) => h.address !== host.address);
hosts.push(host);
return { ...blob, hosts, updatedAt: now ?? Date.now() };
}
/** Remove the host with the given address, if any. */
export function removeHost(
blob: CanonicalProfileBlob,
address: string,
now?: number,
): CanonicalProfileBlob {
const hosts = blob.hosts.filter((h) => h.address !== address);
if (hosts.length === blob.hosts.length) return blob;
return { ...blob, hosts, updatedAt: now ?? Date.now() };
}
/**
* Insert or replace a client entry by `identityFingerprint` (the
* stable cryptographic identifier). Address can change without
* losing the trust record — e.g. if a phone re-pairs to a new device
* row but keeps its identity key — but a new `identityFingerprint`
* is treated as a new client.
*
* Re-derives `trustedApproverFingerprints` from the resulting
* `clients[]` so the denormalized list never drifts.
*/
export function upsertClient(
blob: CanonicalProfileBlob,
client: ProfileClientEntry,
now?: number,
): CanonicalProfileBlob {
const clients = blob.clients.filter(
(c) => c.identityFingerprint !== client.identityFingerprint,
);
clients.push(client);
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/** Remove the client with the given `identityFingerprint`, if any. */
export function removeClient(
blob: CanonicalProfileBlob,
identityFingerprint: string,
now?: number,
): CanonicalProfileBlob {
const clients = blob.clients.filter(
(c) => c.identityFingerprint !== identityFingerprint,
);
if (clients.length === blob.clients.length) return blob;
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/**
* Toggle the `trustedApprover` flag on a client by fingerprint.
* Returns the input unchanged if the fingerprint isn't found.
*/
export function setTrustedApprover(
blob: CanonicalProfileBlob,
identityFingerprint: string,
trusted: boolean,
now?: number,
): CanonicalProfileBlob {
let touched = false;
const clients = blob.clients.map((c) => {
if (c.identityFingerprint !== identityFingerprint) return c;
if ((c.trustedApprover ?? false) === trusted) return c;
touched = true;
const next: ProfileClientEntry = { ...c };
if (trusted) next.trustedApprover = true;
else delete next.trustedApprover;
return next;
});
if (!touched) return blob;
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/**
* True if the given fingerprint resolves to a client whose
* `trustedApprover` flag is set. Cross-checks both `clients[]` and
* the denormalized `trustedApproverFingerprints[]` — both must agree
* to count as trusted.
*/
export function isTrustedApprover(
blob: CanonicalProfileBlob,
identityFingerprint: string,
): boolean {
if (!blob.trustedApproverFingerprints.includes(identityFingerprint)) {
return false;
}
const c = findClientByFingerprint(blob, identityFingerprint);
return c?.trustedApprover === true;
}
export function findClientByFingerprint(
blob: CanonicalProfileBlob,
identityFingerprint: string,
): ProfileClientEntry | null {
return (
blob.clients.find((c) => c.identityFingerprint === identityFingerprint) ??
null
);
}
export function findClientByAddress(
blob: CanonicalProfileBlob,
address: string,
): ProfileClientEntry | null {
return blob.clients.find((c) => c.address === address) ?? null;
}
function deriveTrustedApprovers(clients: ProfileClientEntry[]): string[] {
return clients
.filter((c) => c.trustedApprover === true)
.map((c) => c.identityFingerprint);
}
// ─── Approval frames ───────────────────────────────────────────
/**
* Default domain separator for the proxy-approval signing payload.
* Apps with their own canonical name (e.g. Prism) MAY override this
* via the `domain` option, but they must use the SAME value on both
* the signing and verifying side. The frame itself carries the
* domain so a verifier can detect mismatch and reject.
*/
export const DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1';
/** ms-since-epoch defaults: build = 5 minutes from now. */
const DEFAULT_EXPIRES_IN_MS = 5 * 60 * 1000;
export interface ApprovalRequestingDevice {
/** Safety-number fingerprint of the requesting device's identity. */
fingerprint: string;
/** Optional human label (e.g. `"cafe-laptop"`). */
deviceName?: string;
/** Optional `User-Agent`-like hint for display in the approve modal. */
userAgent?: string;
/** Optional best-effort source IP for display (NOT authenticated). */
ipHint?: string;
/** Wall-clock ms when the host received the original linkRequest. */
receivedAt: number;
}
export interface ApprovalRequestFrame {
kind: 'approvalNeeded';
/**
* 128-bit random hex (32 chars) — host-generated, used as the
* idempotency key for the approval. The verifier matches the
* `linkApproveByProxy.requestId` against this exact value.
*/
requestId: string;
/** Shade address of the host that received the original linkRequest. */
hostAddress: string;
/** Identity fingerprint of the host (the same value the approver UI shows). */
hostFingerprint: string;
requestingDevice: ApprovalRequestingDevice;
/**
* Wall-clock ms after which the host won't accept a proxy-approval
* for this `requestId`. The approver SHOULD also reject locally if
* the user takes too long to respond.
*/
expiresAt: number;
/** Domain separator the host expects this approval to be signed under. */
domain: string;
}
export interface ProxyApprovalFrame {
kind: 'linkApproveByProxy';
/** Echoed from the matching `ApprovalRequestFrame.requestId`. */
requestId: string;
/** `'approve'` or `'reject'`. The verifier checks against this exactly. */
decision: 'approve' | 'reject';
/**
* Identity fingerprint of the approving client. MUST match an entry
* in the host's profile-blob `clients[]` whose `trustedApprover`
* flag is set.
*/
approverFingerprint: string;
/**
* Ed25519 signature over the canonical signing payload, lowercase
* hex (128 chars). See `canonicalApprovalSigningBytes` for the
* exact byte layout.
*/
signature: string;
/** Domain separator used to produce the signature. */
domain: string;
}
export interface BuildApprovalRequestOptions {
hostAddress: string;
hostFingerprint: string;
requestingDevice: Omit<ApprovalRequestingDevice, 'receivedAt'> & {
receivedAt?: number;
};
/**
* ms TTL after which the host won't honor a proxy-approval for
* this request. Default 5 minutes — long enough for the user to
* fish their phone out, short enough to bound replay.
*/
expiresInMs?: number;
/**
* Source of randomness for the `requestId`. Pass the same
* `CryptoProvider` you use elsewhere in the SDK (typically a
* `SubtleCryptoProvider`) — it satisfies the `randomBytes` shape.
*/
crypto: { randomBytes(n: number): Uint8Array };
/** Domain separator. Default: `shade-link-approve-v1`. */
domain?: string;
/** Override `Date.now()` (tests). */
now?: () => number;
}
/**
* Build a fresh `approvalNeeded` frame with a 128-bit random
* `requestId`. The host then ships this to each trusted-approver
* client via `Shade.send`. Hosts SHOULD persist `(requestId, expiresAt,
* requestingDevice.fingerprint)` somewhere durable so they can match
* up the eventual `linkApproveByProxy` reply.
*/
export function buildApprovalRequest(
options: BuildApprovalRequestOptions,
): ApprovalRequestFrame {
const now = (options.now ?? Date.now)();
const requestId = bytesToHex(options.crypto.randomBytes(16));
return {
kind: 'approvalNeeded',
requestId,
hostAddress: options.hostAddress,
hostFingerprint: options.hostFingerprint,
requestingDevice: {
fingerprint: options.requestingDevice.fingerprint,
...(options.requestingDevice.deviceName !== undefined
? { deviceName: options.requestingDevice.deviceName }
: {}),
...(options.requestingDevice.userAgent !== undefined
? { userAgent: options.requestingDevice.userAgent }
: {}),
...(options.requestingDevice.ipHint !== undefined
? { ipHint: options.requestingDevice.ipHint }
: {}),
receivedAt: options.requestingDevice.receivedAt ?? now,
},
expiresAt: now + (options.expiresInMs ?? DEFAULT_EXPIRES_IN_MS),
domain: options.domain ?? DEFAULT_APPROVAL_DOMAIN,
};
}
export interface SignProxyApprovalOptions {
/** The frame the host sent. The verifier rebuilds the signing payload from it. */
request: ApprovalRequestFrame;
decision: 'approve' | 'reject';
/**
* The approving client's identity fingerprint. Must match the
* `clients[]` entry the host expects to find as a trusted approver.
*/
approverFingerprint: string;
/**
* 32-byte Ed25519 *seed* — `crypto.sign(seed, msg)` works directly
* (the noble convention `@shade/crypto-web` uses).
*/
approverSigningKey: Uint8Array;
/** CryptoProvider for `sign`. */
crypto: Pick<CryptoProvider, 'sign'>;
}
/**
* Build a `linkApproveByProxy` frame signed with the approver's
* long-term Ed25519 identity key. The signing payload is
* domain-separated and binds together every field that protects
* against cross-frame replay (see `canonicalApprovalSigningBytes`).
*/
export async function signProxyApproval(
options: SignProxyApprovalOptions,
): Promise<ProxyApprovalFrame> {
if (options.approverSigningKey.length !== 32) {
throw new ValidationError('approverSigningKey must be 32 bytes (Ed25519 seed)');
}
if (options.decision !== 'approve' && options.decision !== 'reject') {
throw new ValidationError(`decision must be 'approve' or 'reject'`);
}
const payload = canonicalApprovalSigningBytes({
domain: options.request.domain,
requestId: options.request.requestId,
hostFingerprint: options.request.hostFingerprint,
requestingDeviceFingerprint: options.request.requestingDevice.fingerprint,
decision: options.decision,
});
const sig = await options.crypto.sign(options.approverSigningKey, payload);
return {
kind: 'linkApproveByProxy',
requestId: options.request.requestId,
decision: options.decision,
approverFingerprint: options.approverFingerprint,
signature: bytesToHex(sig),
domain: options.request.domain,
};
}
export interface VerifyProxyApprovalOptions {
/** The original `approvalNeeded` frame the host sent (replay-binding). */
request: ApprovalRequestFrame;
/** The `linkApproveByProxy` frame received from the approver. */
approval: ProxyApprovalFrame;
/**
* Profile blob to verify against. The approver must resolve to a
* `clients[]` entry whose `trustedApprover` flag is set.
* Hosts MUST refetch the blob fresh before verifying — see FR §5
* for the revocation TOCTOU rationale.
*/
profile: CanonicalProfileBlob;
/** CryptoProvider for `verify`. */
crypto: Pick<CryptoProvider, 'verify'>;
/** Override `Date.now()` (tests). */
now?: () => number;
}
export type VerifyProxyApprovalReason =
| 'request-id-mismatch'
| 'domain-mismatch'
| 'unknown-approver'
| 'not-trusted'
| 'bad-signature'
| 'expired';
export type VerifyProxyApprovalResult =
| { ok: true; approver: ProfileClientEntry }
| { ok: false; reason: VerifyProxyApprovalReason };
/**
* Verify a `linkApproveByProxy` against the originating
* `approvalNeeded` and the host's freshest profile blob. Returns a
* tagged result rather than throwing — callers usually want to log
* the reason before deciding what to surface to the user.
*
* Order of checks:
*
* 1. `requestId` must match exactly. Defends against an attacker
* replaying a stale approval against a fresh request.
* 2. `domain` must match exactly. Defends against an approval signed
* under one app's separator being honored by another.
* 3. Approver must resolve to a `clients[]` entry. Identity unknown =
* reject regardless of signature validity.
* 4. Approver's `trustedApprover` flag must be set AND its
* fingerprint must be in `trustedApproverFingerprints[]` (cross-
* check via `isTrustedApprover`).
* 5. Signature must verify against the approver's `identityPublicKey`.
* 6. `expiresAt` must be in the future (gives the host a lower
* bound; the host's own pending-state is the authoritative source).
*/
export async function verifyProxyApproval(
options: VerifyProxyApprovalOptions,
): Promise<VerifyProxyApprovalResult> {
const { request, approval, profile } = options;
if (approval.requestId !== request.requestId) {
return { ok: false, reason: 'request-id-mismatch' };
}
if (approval.domain !== request.domain) {
return { ok: false, reason: 'domain-mismatch' };
}
const approver = findClientByFingerprint(profile, approval.approverFingerprint);
if (!approver) {
return { ok: false, reason: 'unknown-approver' };
}
if (!isTrustedApprover(profile, approval.approverFingerprint)) {
return { ok: false, reason: 'not-trusted' };
}
const now = (options.now ?? Date.now)();
if (now > request.expiresAt) {
return { ok: false, reason: 'expired' };
}
let pubkey: Uint8Array;
let sig: Uint8Array;
try {
pubkey = hexToBytes(approver.identityPublicKey);
sig = hexToBytes(approval.signature);
} catch {
return { ok: false, reason: 'bad-signature' };
}
if (pubkey.length !== 32 || sig.length !== 64) {
return { ok: false, reason: 'bad-signature' };
}
const payload = canonicalApprovalSigningBytes({
domain: approval.domain,
requestId: approval.requestId,
hostFingerprint: request.hostFingerprint,
requestingDeviceFingerprint: request.requestingDevice.fingerprint,
decision: approval.decision,
});
const valid = await options.crypto.verify(pubkey, payload, sig);
if (!valid) {
return { ok: false, reason: 'bad-signature' };
}
return { ok: true, approver };
}
// ─── Canonical signing payload ─────────────────────────────────
export interface ApprovalSigningInput {
domain: string;
requestId: string;
hostFingerprint: string;
requestingDeviceFingerprint: string;
decision: 'approve' | 'reject';
}
/**
* Build the exact bytes that get Ed25519-signed for a proxy-approval.
*
* Format (length-prefixed UTF-8, big-endian u16 lengths):
*
* u16(len(domain)) || domain
* u16(len(requestId)) || requestId
* u16(len(hostFp)) || hostFingerprint
* u16(len(requestFp)) || requestingDeviceFingerprint
* u16(len(decision)) || decision
*
* Length-prefixed rather than delimiter-joined so the encoding is
* unambiguous regardless of what bytes appear in any field. u16
* (max 65535) is plenty: domain < 256 chars by convention,
* fingerprint ≈ 71 chars, requestId 32 hex chars, decision 6-7 chars.
*
* Exposed publicly so other Shade implementations (Android Kotlin,
* iOS Swift, etc.) can produce byte-identical signing input from
* test vectors without depending on this TypeScript code.
*/
export function canonicalApprovalSigningBytes(
input: ApprovalSigningInput,
): Uint8Array {
const fields: Uint8Array[] = [
TEXT.encode(input.domain),
TEXT.encode(input.requestId),
TEXT.encode(input.hostFingerprint),
TEXT.encode(input.requestingDeviceFingerprint),
TEXT.encode(input.decision),
];
for (const f of fields) {
if (f.length > 0xffff) {
throw new ValidationError(
`signing field too long: ${f.length} bytes (max 65535)`,
);
}
}
const total = fields.reduce((sum, f) => sum + 2 + f.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const f of fields) {
out[offset] = (f.length >> 8) & 0xff;
out[offset + 1] = f.length & 0xff;
offset += 2;
out.set(f, offset);
offset += f.length;
}
return out;
}
// ─── Hex helpers (kept local so this module has no extra deps) ──
function bytesToHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
return s;
}
function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new ValidationError('hex length must be even');
if (!/^[0-9a-f]*$/.test(hex)) {
throw new ValidationError('hex must be lowercase 0-9a-f');
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}

View File

@@ -1,5 +1,5 @@
export { createShade } from './create-shade.js'; export { createShade } from './create-shade.js';
export { Shade } from './shade.js'; export { Shade, ShadeStream } from './shade.js';
export type { export type {
ShadeUploadOptions, ShadeUploadOptions,
ShadeWebRtcConfig, ShadeWebRtcConfig,
@@ -97,6 +97,58 @@ export {
mainStreamIdForThumbnail, mainStreamIdForThumbnail,
} from '@shade/streams'; } from '@shade/streams';
// ─── V4.9 — relay-side encrypted profile storage ──────────
export {
createProfileNamespace,
profilePlaintextToString,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
ed25519PublicKeyFromSeed,
slotIdToHex,
} from './profile.js';
export type {
ProfileNamespace,
ProfileNamespaceOptions,
ProfileGetResult,
ProfilePutOptions,
ProfilePutResult,
} from './profile.js';
// ─── V4.10 — cross-host approval routing ──────────────────
export {
emptyCanonicalProfile,
parseCanonicalProfile,
serializeCanonicalProfile,
upsertHost,
removeHost,
upsertClient,
removeClient,
setTrustedApprover,
isTrustedApprover,
findClientByFingerprint,
findClientByAddress,
buildApprovalRequest,
signProxyApproval,
verifyProxyApproval,
canonicalApprovalSigningBytes,
DEFAULT_APPROVAL_DOMAIN,
} from './approval.js';
export type {
CanonicalProfileBlob,
ProfileHostEntry,
ProfileClientEntry,
ApprovalRequestingDevice,
ApprovalRequestFrame,
ProxyApprovalFrame,
BuildApprovalRequestOptions,
SignProxyApprovalOptions,
VerifyProxyApprovalOptions,
VerifyProxyApprovalReason,
VerifyProxyApprovalResult,
ApprovalSigningInput,
} from './approval.js';
// ─── Web Workers crypto (V3.8) ───────────────────────────── // ─── Web Workers crypto (V3.8) ─────────────────────────────
export { export {
createWorkerCryptoProvider, createWorkerCryptoProvider,

View File

@@ -0,0 +1,210 @@
import type { CryptoProvider } from '@shade/core';
import { ValidationError } from '@shade/core';
import {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
aeadSeal,
aeadOpen,
} from '@shade/storage-encrypted/crypto';
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { BlobClient, slotIdToHex } from '@shade/inbox';
/**
* V4.9 — relay-side encrypted profile storage.
*
* The `Profile` namespace lets a Shade-based app store a small,
* AEAD-sealed JSON blob on the relay keyed by a deterministic slotId
* derived from the user's master key. A brand new device that knows
* only the credentials (password + PIN → masterKey via the existing
* `@shade/storage-encrypted` KDF) can locate, decrypt, and update the
* blob. The relay sees only opaque slotIds and AEAD-sealed bytes — it
* never decrypts and cannot link slots to users.
*
* This is the *primitive* Prism uses for credential-driven device
* linking (Phase 2 of the Prism device-linking plan): the blob holds
* the list of paired hosts, the new device reads it, picks the first
* online host, and starts a link-request handshake. But it's
* deliberately app-shaped — any Shade app needing a credential-only
* bootstrap into existing E2EE state can use it. Pass a different
* `app` namespace string per use-case so two apps under the same
* master never collide on the same slot.
*
* Usage:
* const km = await KeyManager.unlock(...); // existing v4.5 flow
* const profile = createProfileNamespace({
* baseUrl: 'https://shade.example/',
* crypto: new SubtleCryptoProvider(),
* masterKey: km.masterKey,
* app: 'prism-profile-v1',
* });
*
* const current = await profile.get();
* // -> { plaintext: Uint8Array, etag: string } | null
*
* await profile.put(JSON.stringify({ hosts: [...] }), {
* ifMatch: current?.etag,
* });
*
* await profile.delete(); // "forget everything"
*/
export interface ProfileNamespaceOptions {
/** Base URL of the Shade relay. */
baseUrl: string;
/** CryptoProvider — typically a fresh SubtleCryptoProvider instance. */
crypto: CryptoProvider;
/**
* 32-byte master key, exactly the value you'd hand to
* `@shade/storage-encrypted`'s row-codec — the existing v4.5 KDF
* chain (passphrase + scrypt → masterKey, possibly upgraded with
* argon2id over a PIN) lands you here. Profile storage uses HKDF
* subderivations under separate `info` strings, so it can't leak
* the storage encryption key or vice versa.
*/
masterKey: Uint8Array;
/**
* Per-app namespace string. Distinct apps under the same master key
* MUST pass different values so they don't collide on the same slot.
* Convention: `"<app-id>-<purpose>-<schema-version>"`, e.g.
* `"prism-profile-v1"`.
*/
app: string;
/** Optional fetch override (defaults to globalThis.fetch). */
fetch?: typeof fetch;
}
export interface ProfileGetResult {
/** Decrypted plaintext bytes. The shape is up to the caller. */
plaintext: Uint8Array;
/** Pass back as `ifMatch` to do a CAS update. */
etag: string;
/** Wall-clock ms when the relay last accepted a write. */
updatedAt: number;
}
export interface ProfilePutOptions {
/**
* - `undefined` : create-only. Slot must be empty (else 409).
* - `<etag-string>` : compare-and-swap. Must match current etag (else 412).
* - `'*'` : unconditional overwrite. Slot must already exist (else 412).
*/
ifMatch?: string;
}
export interface ProfilePutResult {
/** True if this PUT created the slot, false if it updated an existing one. */
created: boolean;
/** New etag after the write. */
etag: string;
updatedAt: number;
}
export interface ProfileNamespace {
readonly slotIdHex: string;
get(): Promise<ProfileGetResult | null>;
put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise<ProfilePutResult>;
delete(): Promise<boolean>;
}
const TEXT = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
export function createProfileNamespace(
options: ProfileNamespaceOptions,
): ProfileNamespace {
if (options.masterKey.length !== 32) {
throw new ValidationError('masterKey must be 32 bytes');
}
if (options.app.length === 0) {
throw new ValidationError('app namespace must be non-empty');
}
const slotIdBytes = deriveBlobSlotId(options.masterKey, options.app);
const slotIdHex = slotIdToHex(slotIdBytes);
const blobKey = deriveBlobKey(options.masterKey, options.app);
const signingSeed = deriveBlobSigningSeed(options.masterKey, options.app);
const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed);
// AAD binds the slotId into the AEAD seal: a relay returning the
// wrong slot's blob (mistake or malice) fails to open. The slotId is
// already part of the URL path, but binding it cryptographically
// prevents any kind of cross-slot replay regardless of how the bytes
// got to us.
const aad = TEXT.encode(`shade-profile-aad-v1:${slotIdHex}`);
const clientOptions: ConstructorParameters<typeof BlobClient>[0] = {
baseUrl: options.baseUrl,
crypto: options.crypto,
};
if (options.fetch) clientOptions.fetch = options.fetch;
const client = new BlobClient(clientOptions);
return {
slotIdHex,
async get(): Promise<ProfileGetResult | null> {
const result = await client.get(slotIdHex);
if (!result) return null;
// Deterministic 12-byte nonce from (slotId, etag): the relay
// stores `nonce || ct||tag` as one blob, so the AEAD layer
// pulls the nonce off the front. We don't pre-compute it —
// aeadOpen handles the prefix automatically.
const plaintext = await aeadOpen(blobKey, result.blob, aad);
return {
plaintext,
etag: result.etag,
updatedAt: result.updatedAt,
};
},
async put(
plaintext: Uint8Array | string,
options?: ProfilePutOptions,
): Promise<ProfilePutResult> {
const ptBytes =
typeof plaintext === 'string' ? TEXT.encode(plaintext) : plaintext;
// Random per-write 12-byte nonce. We don't reuse a deterministic
// nonce because two consecutive writes of the same plaintext
// (rare but possible — re-uploading after a transient error)
// would otherwise reuse (key, nonce, plaintext), which is a
// nonce-reuse condition for AES-GCM. A fresh random nonce per
// PUT keeps each AEAD invocation unique.
const nonce = clientOptions.crypto.randomBytes(12);
const sealed = await aeadSeal(blobKey, nonce, ptBytes, aad);
const putArgs: Parameters<BlobClient['put']>[0] = {
slotIdHex,
blob: sealed,
signingPrivateKey: signingSeed,
ownerPubkey,
};
if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch;
return client.put(putArgs);
},
async delete(): Promise<boolean> {
return client.delete({
slotIdHex,
signingPrivateKey: signingSeed,
});
},
};
}
// Re-export the raw KDF helpers so apps that want to drive a custom
// flow (skip the AEAD layer, use a different client, run interop
// against a non-Shade relay) don't have to re-import from
// `@shade/storage-encrypted/crypto`.
export {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from '@shade/storage-encrypted/crypto';
export { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
export { slotIdToHex } from '@shade/inbox';
/** Decode a UTF-8 plaintext from a `ProfileGetResult`. */
export function profilePlaintextToString(result: ProfileGetResult): string {
return TEXT_DECODER.decode(result.plaintext);
}

View File

@@ -1,8 +1,10 @@
import type { ShadeEnvelope, StorageProvider } from '@shade/core'; import type { ShadeEnvelope, StorageProvider, RatchetMessage } from '@shade/core';
import { import {
ShadeSessionManager, ShadeSessionManager,
ShadeEventEmitter, ShadeEventEmitter,
NoSessionError, NoSessionError,
StreamRatchet,
StreamHandshakeError,
} from '@shade/core'; } from '@shade/core';
import { import {
FingerprintGateRegistry, FingerprintGateRegistry,
@@ -18,7 +20,16 @@ import {
type CreateEncryptStreamOptions, type CreateEncryptStreamOptions,
type CreateDecryptStreamOptions, type CreateDecryptStreamOptions,
} from '@shade/crypto-web'; } from '@shade/crypto-web';
import { encodeEnvelope, decodeEnvelope, inspectEnvelopeType } from '@shade/proto'; import {
encodeEnvelope,
decodeEnvelope,
inspectEnvelopeType,
encodeStreamOpen,
encodeStreamOpenAck,
decodeStreamHandshake,
encodeStreamFrame,
decodeStreamFrame,
} from '@shade/proto';
import { ShadeFetchTransport, type KTVerifierOptions } from '@shade/transport'; import { ShadeFetchTransport, type KTVerifierOptions } from '@shade/transport';
import { LightWitness } from '@shade/key-transparency'; import { LightWitness } from '@shade/key-transparency';
import type { SignedTreeHead, STHWire } from '@shade/key-transparency'; import type { SignedTreeHead, STHWire } from '@shade/key-transparency';
@@ -655,6 +666,41 @@ export class Shade {
await this.gates.revoke(address); await this.gates.revoke(address);
} }
/**
* Move every per-peer storage row for `oldLabel` (session, trusted
* identity, peer-verification, identity-version counter) to
* `newLabel`. Use this when first-contact forced you to label a
* session by the relay's sender-fingerprint hint
* (`fp:<hex>` — see `IncomingMessage.from` / `FetchedBlob.from`) and
* the just-decrypted plaintext announces the peer's canonical
* address: alias once and every subsequent
* `send`/`receive`/broadcast cross-check operates under the
* announced label, no app-side fp ↔ address mapping needed for the
* receive path.
*
* The rename is atomic from a per-peer-mutex perspective — both
* labels are locked for the duration so concurrent encrypt/decrypt
* can't observe a half-moved state. Throws if `oldLabel` has no
* session, or if `newLabel` already does (refuses to overwrite —
* call `resetSession` first if that's intentional).
*
* After alias, the SDK's internal serialization queues
* (`encryptChains`, `decryptChains`) for `oldLabel` are dropped so
* future operations don't queue behind a stale chain.
*
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
*/
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
if (!this.initialized) throw new Error('Not initialized');
await this.manager.aliasSession(oldLabel, newLabel);
// The SDK's per-`from` chains are keyed by label; drop the old
// entries so future `send`/`receive` to either label start with a
// fresh queue rather than chaining off whatever was last in flight
// for `oldLabel`.
this.encryptChains.delete(oldLabel);
this.decryptChains.delete(oldLabel);
}
/** /**
* Accept a peer's rotated identity. Bumps the per-peer identity-version * Accept a peer's rotated identity. Bumps the per-peer identity-version
* counter so any earlier verification automatically goes stale, then * counter so any earlier verification automatically goes stale, then
@@ -1475,6 +1521,97 @@ export class Shade {
await this.storage.pruneStreamStates(olderThan); await this.storage.pruneStreamStates(olderThan);
} }
// ─── Streaming sub-sessions (V4.11) ────────────────────────
/**
* Open a long-lived streaming Double-Ratchet sub-session to an
* already-known peer, for wrapping individual frames on a
* bidirectional, often server-heavy channel (e.g. a console-log
* WebSocket) with the same confidentiality / forward-secrecy /
* replay guarantees as the HTTP `send`/`receive` path.
*
* This is the **initiator** half. Like the rest of the SDK it is
* transport-agnostic: it produces handshake/frame bytes you put on
* your WebSocket, and consumes the bytes you receive from it.
*
* ```ts
* const stream = await shade.openStream(peerAddr);
* ws.send(stream.handshakeFrame()); // → STREAM_OPEN
* // … first inbound WS frame is the peer's STREAM_OPEN_ACK …
* await stream.handleHandshake(ackBytes); // stream now usable
* ws.send(await stream.seal(utf8(line))); // outbound frame
* onLog(await stream.open(inboundBytes)); // inbound frame
* await stream.close(); // on ws close
* ```
*
* Independence (R5): this never touches the stored parent session,
* its prekeys, or the per-peer `send`/`receive` queues — it runs
* concurrently against the same peer. The ratchet lives only in
* memory and is zeroized by {@link ShadeStream.close}; a dropped
* connection is re-opened with a fresh `openStream`, never resumed
* (persisting per-frame ratchet secrets would defeat forward
* secrecy).
*
* Note (Double-Ratchet semantics): a responder cannot `seal` until
* it has `open`ed at least one frame from the initiator (standard
* Signal behaviour). For a server-heavy stream either make the bursty
* sender the initiator, or have the initiator send one priming frame
* right after the handshake.
*
* Requires an established parent session; one is auto-established
* (same path as {@link send}) if missing.
*/
async openStream(peerAddress: string): Promise<ShadeStream> {
if (!this.initialized) throw new Error('Not initialized');
let begun;
try {
begun = await this.manager.beginStream(peerAddress);
} catch (err) {
if (!(err instanceof NoSessionError)) throw err;
await this.ensureSession(peerAddress);
begun = await this.manager.beginStream(peerAddress);
}
return new ShadeStream({
peer: peerAddress,
role: 'initiator',
streamId: begun.streamId,
events: this.events,
handshakeOut: encodeStreamOpen(begun.streamId, begun.ephemeralPublicKey),
complete: begun.complete,
});
}
/**
* Accept an inbound stream — the **responder** half. Feed it the
* peer's `STREAM_OPEN` bytes (route by {@link inspectEnvelopeType}
* `=== 'stream-open'`). The returned stream is immediately usable for
* `open()`; send `handshakeFrame()` (the `STREAM_OPEN_ACK`) back over
* the transport so the initiator can complete its side.
*/
async acceptStream(peerAddress: string, openBytes: Uint8Array): Promise<ShadeStream> {
if (!this.initialized) throw new Error('Not initialized');
const hs = decodeStreamHandshake(openBytes);
if (hs.kind !== 'open') {
throw new StreamHandshakeError(`expected STREAM_OPEN, got ${hs.kind}`);
}
let accepted;
try {
accepted = await this.manager.acceptStream(peerAddress, hs.streamId, hs.ephemeralPub);
} catch (err) {
if (!(err instanceof NoSessionError)) throw err;
await this.ensureSession(peerAddress);
accepted = await this.manager.acceptStream(peerAddress, hs.streamId, hs.ephemeralPub);
}
return new ShadeStream({
peer: peerAddress,
role: 'responder',
streamId: hs.streamId,
events: this.events,
handshakeOut: encodeStreamOpenAck(hs.streamId, accepted.ephemeralPublicKey),
ratchet: accepted.stream,
});
}
private async ensureSession(address: string): Promise<void> { private async ensureSession(address: string): Promise<void> {
// Deduplicate concurrent establishment requests // Deduplicate concurrent establishment requests
const existing = this.establishing.get(address); const existing = this.establishing.get(address);
@@ -1497,6 +1634,158 @@ export class Shade {
} }
} }
// ─── ShadeStream (V4.11) ─────────────────────────────────────
interface ShadeStreamInit {
peer: string;
role: 'initiator' | 'responder';
streamId: Uint8Array;
events: ShadeEventEmitter;
/** Bytes to put on the wire for our half of the handshake. */
handshakeOut: Uint8Array;
/** Initiator only: continuation that derives the ratchet from the ACK. */
complete?: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
/** Responder only: ratchet is ready at accept time. */
ratchet?: StreamRatchet;
}
function streamIdsEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
/**
* A live streaming Double-Ratchet sub-session. Transport-agnostic: it
* emits/consumes wire bytes, the caller owns the WebSocket (or any
* other ordered frame transport).
*
* Lifecycle:
* - **initiator**: `handshakeFrame()` → `STREAM_OPEN`; after the peer's
* `STREAM_OPEN_ACK` arrives call `handleHandshake(ack)`; then
* `seal`/`open`.
* - **responder**: usable immediately; `handshakeFrame()` →
* `STREAM_OPEN_ACK` to send back; `open` the initiator's first frame
* before `seal` (standard Double-Ratchet ordering).
*/
export class ShadeStream {
private readonly _streamId: Uint8Array;
private readonly _peer: string;
private readonly _role: 'initiator' | 'responder';
private readonly events: ShadeEventEmitter;
private readonly handshakeOut: Uint8Array;
private readonly complete?: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
private ratchet: StreamRatchet | null;
private state: 'await-ack' | 'open' | 'closed';
constructor(init: ShadeStreamInit) {
this._streamId = init.streamId;
this._peer = init.peer;
this._role = init.role;
this.events = init.events;
this.handshakeOut = init.handshakeOut;
if (init.role === 'initiator') {
if (init.complete) this.complete = init.complete;
this.ratchet = null;
this.state = 'await-ack';
} else {
this.ratchet = init.ratchet ?? null;
this.state = 'open';
}
}
/** Peer address this stream is bound to. */
get peer(): string {
return this._peer;
}
/** Which half of the handshake this end performed. */
get role(): 'initiator' | 'responder' {
return this._role;
}
/** Lowercase-hex stream id (stable for the stream's lifetime). */
get streamId(): string {
return Array.from(this._streamId, (b) => b.toString(16).padStart(2, '0')).join('');
}
/** True once the ratchet is established and not yet closed. */
get isOpen(): boolean {
return this.state === 'open' && this.ratchet !== null;
}
/**
* The bytes for our half of the handshake to put on the transport
* (`STREAM_OPEN` for an initiator, `STREAM_OPEN_ACK` for a responder).
* Stable; safe to read once and send.
*/
handshakeFrame(): Uint8Array {
return this.handshakeOut;
}
/**
* Initiator only: consume the peer's `STREAM_OPEN_ACK` and derive the
* ratchet. Idempotent-safe to call exactly once; throws if called on
* a responder, out of order, or with a mismatched streamId.
*/
async handleHandshake(ackBytes: Uint8Array): Promise<void> {
if (this._role !== 'initiator') {
throw new StreamHandshakeError('handleHandshake is initiator-only');
}
if (this.state !== 'await-ack' || !this.complete) {
throw new StreamHandshakeError('handshake already completed or stream closed');
}
const hs = decodeStreamHandshake(ackBytes);
if (hs.kind !== 'open-ack') {
throw new StreamHandshakeError(`expected STREAM_OPEN_ACK, got ${hs.kind}`);
}
if (!streamIdsEqual(hs.streamId, this._streamId)) {
throw new StreamHandshakeError('STREAM_OPEN_ACK streamId mismatch');
}
this.ratchet = await this.complete(hs.ephemeralPub);
this.state = 'open';
}
/**
* Seal one logical frame. Returns `STREAM_FRAME` wire bytes — put
* exactly one in one WS frame. Advances the sending chain one step.
*/
async seal(plaintext: Uint8Array): Promise<Uint8Array> {
if (!this.ratchet || this.state !== 'open') {
throw new StreamHandshakeError('stream not open (complete the handshake first)');
}
const msg = await this.ratchet.seal(plaintext);
return encodeStreamFrame(this._streamId, msg);
}
/**
* Open one inbound `STREAM_FRAME`. Correct and memory-bounded across
* long one-directional bursts; replays / counter-rewinds are rejected
* by the underlying ratchet.
*/
async open(wire: Uint8Array): Promise<Uint8Array> {
if (!this.ratchet || this.state !== 'open') {
throw new StreamHandshakeError('stream not open (complete the handshake first)');
}
const frame: { streamId: Uint8Array; message: RatchetMessage } = decodeStreamFrame(wire);
if (!streamIdsEqual(frame.streamId, this._streamId)) {
throw new StreamHandshakeError('STREAM_FRAME streamId mismatch');
}
return this.ratchet.open(frame.message);
}
/** Zeroize and drop the ratchet. Idempotent. */
async close(): Promise<void> {
if (this.state === 'closed') return;
this.state = 'closed';
if (this.ratchet) {
await this.ratchet.close();
this.ratchet = null;
}
this.events.emit('stream.closed', { address: this._peer });
}
}
function bytesToBase64Std(bytes: Uint8Array): string { function bytesToBase64Std(bytes: Uint8Array): string {
let bin = ''; let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);

View File

@@ -0,0 +1,552 @@
import { describe, test, expect } from 'bun:test';
import {
emptyCanonicalProfile,
parseCanonicalProfile,
serializeCanonicalProfile,
upsertHost,
upsertClient,
removeClient,
setTrustedApprover,
isTrustedApprover,
findClientByFingerprint,
findClientByAddress,
buildApprovalRequest,
signProxyApproval,
verifyProxyApproval,
canonicalApprovalSigningBytes,
DEFAULT_APPROVAL_DOMAIN,
} from '../src/index.js';
import type {
CanonicalProfileBlob,
ProfileClientEntry,
ProfileHostEntry,
ApprovalRequestFrame,
} from '../src/index.js';
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { ValidationError } from '@shade/core';
const crypto = new SubtleCryptoProvider();
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
function bytesToHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
return s;
}
function makeClient(opts: {
name: string;
trusted?: boolean;
}): { entry: ProfileClientEntry; signingSeed: Uint8Array } {
const signingSeed = randBytes(32);
const pubkey = ed25519PublicKeyFromSeed(signingSeed);
const fingerprint = `fp-${opts.name}-${bytesToHex(pubkey).slice(0, 8)}`;
const entry: ProfileClientEntry = {
address: `device:${opts.name}`,
identityPublicKey: bytesToHex(pubkey),
identityFingerprint: fingerprint,
name: opts.name,
kind: 'mobile',
addedAt: 1_700_000_000_000,
};
if (opts.trusted) entry.trustedApprover = true;
return { entry, signingSeed };
}
function makeHost(): ProfileHostEntry {
return {
address: 'device:host-server',
name: 'Server',
kind: 'server',
addedAt: 1_700_000_000_000,
};
}
describe('Canonical profile schema', () => {
test('emptyCanonicalProfile is well-formed and round-trips', () => {
const blob = emptyCanonicalProfile(123);
expect(blob.version).toBe(1);
expect(blob.hosts).toEqual([]);
expect(blob.clients).toEqual([]);
expect(blob.trustedApproverFingerprints).toEqual([]);
expect(blob.updatedAt).toBe(123);
const bytes = serializeCanonicalProfile(blob);
const parsed = parseCanonicalProfile(bytes);
expect(parsed).toEqual(blob);
});
test('upsertHost replaces by address and bumps updatedAt', () => {
let blob = emptyCanonicalProfile(0);
const host = makeHost();
blob = upsertHost(blob, host, 100);
expect(blob.hosts).toEqual([host]);
expect(blob.updatedAt).toBe(100);
const renamed = { ...host, name: 'Server (renamed)' };
blob = upsertHost(blob, renamed, 200);
expect(blob.hosts).toHaveLength(1);
expect(blob.hosts[0]!.name).toBe('Server (renamed)');
expect(blob.updatedAt).toBe(200);
});
test('upsertClient denormalizes trustedApproverFingerprints', () => {
let blob = emptyCanonicalProfile(0);
const a = makeClient({ name: 'phone-a', trusted: true });
const b = makeClient({ name: 'phone-b', trusted: false });
blob = upsertClient(blob, a.entry, 100);
blob = upsertClient(blob, b.entry, 200);
expect(blob.clients).toHaveLength(2);
expect(blob.trustedApproverFingerprints).toEqual([a.entry.identityFingerprint]);
expect(isTrustedApprover(blob, a.entry.identityFingerprint)).toBe(true);
expect(isTrustedApprover(blob, b.entry.identityFingerprint)).toBe(false);
});
test('setTrustedApprover toggles the flag and the denormalized list', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone', trusted: false });
blob = upsertClient(blob, c.entry);
expect(blob.trustedApproverFingerprints).toEqual([]);
blob = setTrustedApprover(blob, c.entry.identityFingerprint, true, 100);
expect(blob.trustedApproverFingerprints).toEqual([c.entry.identityFingerprint]);
expect(blob.clients[0]!.trustedApprover).toBe(true);
expect(blob.updatedAt).toBe(100);
blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 200);
expect(blob.trustedApproverFingerprints).toEqual([]);
expect(blob.clients[0]!.trustedApprover).toBeUndefined();
expect(blob.updatedAt).toBe(200);
// No-op toggle to existing state returns the same blob.
const before = blob;
blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 999);
expect(blob).toBe(before);
});
test('removeClient cleans up trustedApproverFingerprints', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone', trusted: true });
blob = upsertClient(blob, c.entry);
expect(blob.trustedApproverFingerprints).toHaveLength(1);
blob = removeClient(blob, c.entry.identityFingerprint);
expect(blob.clients).toEqual([]);
expect(blob.trustedApproverFingerprints).toEqual([]);
});
test('findClientByFingerprint and findClientByAddress', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone' });
blob = upsertClient(blob, c.entry);
expect(findClientByFingerprint(blob, c.entry.identityFingerprint)?.address).toBe(
c.entry.address,
);
expect(findClientByAddress(blob, c.entry.address)?.identityFingerprint).toBe(
c.entry.identityFingerprint,
);
expect(findClientByFingerprint(blob, 'unknown')).toBeNull();
expect(findClientByAddress(blob, 'unknown')).toBeNull();
});
test('parseCanonicalProfile rejects malformed input', () => {
expect(() => parseCanonicalProfile('not json')).toThrow(ValidationError);
expect(() => parseCanonicalProfile('[]')).toThrow(ValidationError);
expect(() => parseCanonicalProfile('{"version":2}')).toThrow(ValidationError);
expect(() =>
parseCanonicalProfile(
JSON.stringify({
version: 1,
clients: [{ address: 'x', name: 'x', kind: 'm', addedAt: 0 }],
}),
),
).toThrow(ValidationError); // missing identityPublicKey
expect(() =>
parseCanonicalProfile(
JSON.stringify({
version: 1,
clients: [
{
address: 'x',
name: 'x',
kind: 'm',
addedAt: 0,
identityPublicKey: 'NOTHEX',
identityFingerprint: 'x',
},
],
}),
),
).toThrow(ValidationError); // identityPublicKey not 64 hex
});
test('parsed blob is fully equal to the input via JSON round-trip', () => {
let blob = emptyCanonicalProfile(1);
const host = makeHost();
const c = makeClient({ name: 'phone', trusted: true });
blob = upsertHost(blob, host, 2);
blob = upsertClient(blob, c.entry, 3);
blob.signedBy = 'aabbccdd';
const bytes = serializeCanonicalProfile(blob);
const parsed = parseCanonicalProfile(bytes);
expect(parsed).toEqual(blob);
});
});
describe('Approval signing payload', () => {
test('canonicalApprovalSigningBytes is deterministic', () => {
const a = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve',
});
const b = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve',
});
expect(Buffer.from(a).toString('hex')).toBe(Buffer.from(b).toString('hex'));
});
test('different decision produces different signing bytes', () => {
const base = {
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'r',
};
const approveBytes = canonicalApprovalSigningBytes({ ...base, decision: 'approve' });
const rejectBytes = canonicalApprovalSigningBytes({ ...base, decision: 'reject' });
expect(Buffer.from(approveBytes).toString('hex')).not.toBe(
Buffer.from(rejectBytes).toString('hex'),
);
});
test('different domain produces different signing bytes', () => {
const a = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'r',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'd',
decision: 'approve',
});
const b = canonicalApprovalSigningBytes({
domain: 'prism-link-approve-v1',
requestId: 'r',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'd',
decision: 'approve',
});
expect(Buffer.from(a).toString('hex')).not.toBe(Buffer.from(b).toString('hex'));
});
});
describe('Build / sign / verify proxy approval', () => {
function buildScenario() {
const phone = makeClient({ name: 'phone', trusted: true });
let profile = emptyCanonicalProfile(0);
profile = upsertHost(profile, makeHost());
profile = upsertClient(profile, phone.entry);
const request = buildApprovalRequest({
hostAddress: 'device:host-server',
hostFingerprint: 'host-fp-12345',
requestingDevice: {
fingerprint: 'cafe-laptop-fp-67890',
deviceName: 'cafe-laptop',
userAgent: 'Mozilla/5.0',
ipHint: '203.0.113.7',
},
crypto,
});
return { phone, profile, request };
}
test('happy path: signed approve verifies', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
expect(approval.kind).toBe('linkApproveByProxy');
expect(approval.requestId).toBe(request.requestId);
expect(approval.signature.length).toBe(128); // 64-byte sig as hex
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(true);
if (verdict.ok) {
expect(verdict.approver.address).toBe(phone.entry.address);
}
});
test('happy path: signed reject verifies', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'reject',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
expect(verdict.ok).toBe(true);
});
test('replay against a different request fails (request-id-mismatch)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const otherRequest: ApprovalRequestFrame = {
...request,
requestId: 'ffffffffffffffffffffffffffffffff',
};
const verdict = await verifyProxyApproval({
request: otherRequest,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('request-id-mismatch');
});
test('decision tampered after signing fails (bad-signature)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const tampered = { ...approval, decision: 'reject' as const };
const verdict = await verifyProxyApproval({
request,
approval: tampered,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('host fingerprint substitution fails (bad-signature)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
// Verifier sees the same approval but a different host fingerprint
// (simulates an attacker forwarding an approval to a different host).
const swappedRequest: ApprovalRequestFrame = {
...request,
hostFingerprint: 'evil-host-fp',
};
const verdict = await verifyProxyApproval({
request: swappedRequest,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('domain mismatch is rejected before signature check', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const tampered = { ...approval, domain: 'prism-link-approve-v1' };
const verdict = await verifyProxyApproval({
request,
approval: tampered,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('domain-mismatch');
});
test('unknown approver fingerprint fails', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const lying = { ...approval, approverFingerprint: 'no-such-fingerprint' };
const verdict = await verifyProxyApproval({
request,
approval: lying,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('unknown-approver');
});
test('revoked approver (trustedApprover off) fails with not-trusted', async () => {
const { phone, profile: original, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
// Simulate a revoke: workstation toggles the trustedApprover flag off
// and PUTs the new blob; host re-fetches before verifying.
const revoked = setTrustedApprover(
original,
phone.entry.identityFingerprint,
false,
);
const verdict = await verifyProxyApproval({
request,
approval,
profile: revoked,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('not-trusted');
});
test('expired request is rejected', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
now: () => request.expiresAt + 1,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('expired');
});
test('signature with the wrong key fails', async () => {
const { phone, profile, request } = buildScenario();
const wrongSeed = randBytes(32);
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint, // claim phone
approverSigningKey: wrongSeed, // but sign with someone else's key
crypto,
});
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('default domain is `shade-link-approve-v1`', () => {
const r = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(r.domain).toBe(DEFAULT_APPROVAL_DOMAIN);
expect(DEFAULT_APPROVAL_DOMAIN).toBe('shade-link-approve-v1');
});
test('custom domain (e.g. `prism-link-approve-v1`) survives round-trip', async () => {
const { phone, profile } = buildScenario();
const request = buildApprovalRequest({
hostAddress: 'device:host-server',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
domain: 'prism-link-approve-v1',
});
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
expect(approval.domain).toBe('prism-link-approve-v1');
const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
expect(verdict.ok).toBe(true);
});
test('requestId is 32 lowercase hex chars (128 bits)', () => {
const r = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(/^[0-9a-f]{32}$/.test(r.requestId)).toBe(true);
});
test('two consecutive builds produce distinct requestIds', () => {
const a = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
const b = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(a.requestId).not.toBe(b.requestId);
});
});

View File

@@ -0,0 +1,218 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
createProfileNamespace,
profilePlaintextToString,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
ed25519PublicKeyFromSeed,
slotIdToHex,
} from '../src/index.js';
import {
createInboxServer,
MemoryInboxStore,
MemoryBlobStore,
} from '@shade/inbox-server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { ShadeError } from '@shade/core';
const crypto = new SubtleCryptoProvider();
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
interface ServerHandle {
url: string;
stop: () => void;
}
async function startServer(): Promise<ServerHandle> {
const app = createInboxServer({
crypto,
store: new MemoryInboxStore(),
blobStore: new MemoryBlobStore(),
disableRateLimit: true,
});
const port = 19000 + Math.floor(Math.random() * 500);
const handle = Bun.serve({ port, fetch: app.fetch });
return {
url: `http://localhost:${port}`,
stop: () => handle.stop(true),
};
}
describe('SDK Profile namespace (V4.9)', () => {
let server: ServerHandle;
let masterKey: Uint8Array;
beforeEach(async () => {
server = await startServer();
masterKey = randBytes(32);
});
afterEach(() => {
server.stop();
});
test('credential-only round trip: create, read, update, delete', async () => {
const profile = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'test-profile-v1',
});
// Empty slot.
expect(await profile.get()).toBeNull();
// Create.
const payload = JSON.stringify({ hosts: ['device:abc'], v: 1 });
const created = await profile.put(payload);
expect(created.created).toBe(true);
// Read back.
const got1 = await profile.get();
expect(got1).not.toBeNull();
expect(profilePlaintextToString(got1!)).toBe(payload);
expect(got1!.etag).toBe(created.etag);
// CAS update with the etag we just read.
const next = JSON.stringify({ hosts: ['device:abc', 'device:def'], v: 2 });
const updated = await profile.put(next, { ifMatch: got1!.etag });
expect(updated.created).toBe(false);
expect(Number(updated.etag)).toBeGreaterThan(Number(created.etag));
// Stale CAS fails.
await expect(
profile.put(JSON.stringify({ hosts: [] }), { ifMatch: created.etag }),
).rejects.toThrow(ShadeError);
// Delete.
const removed = await profile.delete();
expect(removed).toBe(true);
expect(await profile.get()).toBeNull();
});
test('different app namespaces map to different slots', async () => {
const a = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'app-a',
});
const b = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'app-b',
});
expect(a.slotIdHex).not.toBe(b.slotIdHex);
});
test('different master keys map to different slots', async () => {
const km2 = randBytes(32);
const a = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'shared',
});
const b = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey: km2,
app: 'shared',
});
expect(a.slotIdHex).not.toBe(b.slotIdHex);
});
test('a fresh client with the same master + app reads the existing blob', async () => {
const writer = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'shared',
});
await writer.put('hello world');
// Brand-new namespace instance — simulates "log in from a new
// device". Uses *only* the master key + app namespace; nothing
// else carried over.
const reader = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'shared',
});
const got = await reader.get();
expect(got).not.toBeNull();
expect(profilePlaintextToString(got!)).toBe('hello world');
});
test('without ifMatch on populated slot is a SHADE_CONFLICT error', async () => {
const profile = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'conflict-test',
});
await profile.put('first');
try {
await profile.put('second');
throw new Error('expected put to throw');
} catch (err) {
expect(err).toBeInstanceOf(ShadeError);
expect((err as ShadeError).code).toBe('SHADE_CONFLICT');
}
});
test('stale ifMatch is a SHADE_PRECONDITION_FAILED error', async () => {
const profile = createProfileNamespace({
baseUrl: server.url,
crypto,
masterKey,
app: 'precondition-test',
});
const first = await profile.put('first');
await profile.put('second', { ifMatch: first.etag });
try {
await profile.put('third', { ifMatch: first.etag });
throw new Error('expected put to throw');
} catch (err) {
expect(err).toBeInstanceOf(ShadeError);
expect((err as ShadeError).code).toBe('SHADE_PRECONDITION_FAILED');
}
});
});
describe('KDF helpers (V4.9)', () => {
test('derivations are deterministic per (masterKey, app)', () => {
const km = randBytes(32);
const a1 = deriveBlobSlotId(km, 'x');
const a2 = deriveBlobSlotId(km, 'x');
expect(a1).toEqual(a2);
expect(deriveBlobSlotId(km, 'y')).not.toEqual(a1);
expect(deriveBlobKey(km, 'x')).not.toEqual(a1);
expect(deriveBlobSigningSeed(km, 'x')).not.toEqual(deriveBlobKey(km, 'x'));
});
test('signing seed → pubkey is deterministic and 32 bytes', () => {
const km = randBytes(32);
const seed = deriveBlobSigningSeed(km, 'p');
const pk1 = ed25519PublicKeyFromSeed(seed);
const pk2 = ed25519PublicKeyFromSeed(seed);
expect(pk1).toEqual(pk2);
expect(pk1.length).toBe(32);
});
test('slotIdToHex round-trips through hex form', () => {
const km = randBytes(32);
const id = deriveBlobSlotId(km, 'rt');
const hex = slotIdToHex(id);
expect(hex.length).toBe(64);
expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true);
});
});

View File

@@ -131,6 +131,75 @@ describe('createShade — happy path', () => {
await expect(alice.send('nobody', 'ghost')).rejects.toThrow(); await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
}); });
test('aliasSession migrates a session from fp:<hex> to a canonical address label (V4.8.3)', async () => {
// Reproduces the Prism FR `session-label-asymmetry-v4.8.2`. Bob
// initiates X3DH against Alice using Alice's prekey-server
// address. Alice receives the prekey envelope under the relay's
// sender-fingerprint hint (`fp:<bobfp>`), because that's the only
// sender label the bridge surfaces at first contact. The
// post-decrypt plaintext announces Bob's real address; Alice then
// canonicalizes the session by aliasing `fp:<bobfp>` → `bob` and
// every subsequent send/receive operates symmetrically.
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
// First contact — Bob sends, Alice receives under the fp-label.
const env1 = await bob.send('alice', 'hello, my address is bob');
const fpLabel = 'fp:bobfingerprint16';
expect(await alice.receive(fpLabel, env1)).toBe('hello, my address is bob');
// Alice canonicalizes: move the session from the fp-label to bob's
// real address.
await alice.aliasSession(fpLabel, 'bob');
// Subsequent ratchet messages flow under the canonical label both
// directions. Bob's session for Alice is keyed under `alice`
// (Bob's send target); Alice's session for Bob is now keyed under
// `bob` (post-alias). Symmetry restored.
const env2 = await bob.send('alice', 'reply 1');
expect(await alice.receive('bob', env2)).toBe('reply 1');
const env3 = await alice.send('bob', 'reply 2');
expect(await bob.receive('alice', env3)).toBe('reply 2');
// The old fp-label has no session — receive under it would now
// fail. (We don't assert the error shape, only that the label is
// gone.)
await expect(alice.receive(fpLabel, env3)).rejects.toThrow();
});
test('aliasSession refuses to overwrite an existing session', async () => {
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
const carol = await createShade({ prekeyServer: server.url, address: 'carol' });
try {
// Two distinct first-contact prekey envelopes — one from Bob,
// one from Carol — let Alice end up with two real sessions in
// storage at two different labels.
const env1 = await bob.send('alice', 'one');
await alice.receive('fp:bobfp', env1);
const env2 = await carol.send('alice', 'two');
await alice.receive('fp:carolfp', env2);
await expect(alice.aliasSession('fp:carolfp', 'fp:bobfp')).rejects.toThrow(
/refusing to overwrite/i,
);
} finally {
await carol.shutdown();
}
});
test('aliasSession is a no-op when oldLabel === newLabel', async () => {
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
const env = await bob.send('alice', 'hi');
await alice.receive('fp:bobfp', env);
// Same-label alias is a no-op; session must still decrypt the next message.
await alice.aliasSession('fp:bobfp', 'fp:bobfp');
const env2 = await bob.send('alice', 'hi again');
expect(await alice.receive('fp:bobfp', env2)).toBe('hi again');
});
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => { test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
// Reproduces the Prism FR scenario: a single PUT is fanned out // Reproduces the Prism FR scenario: a single PUT is fanned out
// multiple times by the relay (or any duplicating transport), the // multiple times by the relay (or any duplicating transport), the

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/server", "name": "@shade/server",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -3,10 +3,13 @@ import { SubtleCryptoProvider } from '@shade/crypto-web';
import { import {
createInboxRoutes, createInboxRoutes,
createBridgeRoutes, createBridgeRoutes,
createBlobRoutes,
InboxServerEvents, InboxServerEvents,
InboxPruneTask, InboxPruneTask,
MemoryInboxStore, MemoryInboxStore,
MemoryBlobStore,
type InboxStore, type InboxStore,
type BlobStore,
} from '@shade/inbox-server'; } from '@shade/inbox-server';
import { createPrekeyRoutes } from './routes.js'; import { createPrekeyRoutes } from './routes.js';
import { createHealthRoutes } from './health.js'; import { createHealthRoutes } from './health.js';
@@ -71,6 +74,41 @@ async function createInboxStore(): Promise<InboxStore & { close?: () => void | P
return new MemoryInboxStore(); return new MemoryInboxStore();
} }
/**
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
*
* Backend selection mirrors the inbox store: an explicit
* `SHADE_BLOB_PG_URL` wins, then a SQLite path, then we fall back to the
* shared `SHADE_PREKEY_PG_URL` if present, then memory. Operators can
* also opt the blob store *off* entirely via `SHADE_DISABLE_BLOB=1` —
* useful for relays that only want the inbox surface.
*/
async function createBlobStore(): Promise<BlobStore & { close?: () => void | Promise<void> }> {
const sqlitePath = process.env.SHADE_BLOB_DB_PATH;
const pgUrl = process.env.SHADE_BLOB_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
if (pgUrl && process.env.SHADE_BLOB_PG_URL) {
const { PostgresBlobStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL blob store', { url: maskUrl(pgUrl) });
return PostgresBlobStore.create(pgUrl);
}
if (sqlitePath) {
const { SqliteBlobStore } = await import('@shade/storage-sqlite');
logger.info('Using SQLite blob store', { path: sqlitePath });
return new SqliteBlobStore(sqlitePath);
}
if (pgUrl) {
const { PostgresBlobStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL blob store (sharing prekey URL)', { url: maskUrl(pgUrl) });
return PostgresBlobStore.create(pgUrl);
}
logger.warn('Using in-memory blob store — data will not persist across restarts');
return new MemoryBlobStore();
}
async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> { async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> {
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY; const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY; const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
@@ -182,24 +220,41 @@ app.route(
...(kt ? { keyTransparency: kt } : {}), ...(kt ? { keyTransparency: kt } : {}),
}), }),
); );
app.route(
'/',
createInboxRoutes(inboxStore, crypto, {
events: inboxEvents,
disableRateLimit,
}),
);
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox. // V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
// Held as a top-level reference so the WebSocket handler can be passed to // Created BEFORE the inbox routes so the shared bridge-delivery log can
// Bun.serve below. // be wired into both. The log is the cross-channel dedup gate that lets
// the inbox-fetch route skip blobs already pushed via bridge — see
// V4.8.4 changelog and the Prism FR
// `cross-channel-duplicate-fanout-v4.8.2.md`.
const bridgeRoutes = createBridgeRoutes({ const bridgeRoutes = createBridgeRoutes({
store: inboxStore, store: inboxStore,
crypto, crypto,
events: inboxEvents, events: inboxEvents,
}); });
app.route(
'/',
createInboxRoutes(inboxStore, crypto, {
events: inboxEvents,
disableRateLimit,
bridgeDeliveryLog: bridgeRoutes.bridgeDeliveryLog,
}),
);
app.route('/', bridgeRoutes.app); app.route('/', bridgeRoutes.app);
// V4.9 — encrypted-blob primitive. Powers Prism's credential-driven
// device-linking (Phase 2) and any other Shade app that needs a
// "sign in from any device" UX. Mounted on the same Hono app so a
// single relay process serves prekey + inbox + blob from one port.
const blobDisabled = process.env.SHADE_DISABLE_BLOB === '1';
const blobStore = blobDisabled ? null : await createBlobStore();
if (blobDisabled) {
logger.info('Blob primitive disabled (SHADE_DISABLE_BLOB=1)');
} else if (blobStore) {
app.route('/', createBlobRoutes(blobStore, crypto, { disableRateLimit }));
logger.info('Blob primitive enabled', { route: '/v1/blob/:slotId' });
}
// ─── Optional: Observer + Dashboard ────────────────────────── // ─── Optional: Observer + Dashboard ──────────────────────────
const observerToken = process.env.SHADE_OBSERVER_TOKEN; const observerToken = process.env.SHADE_OBSERVER_TOKEN;
@@ -274,6 +329,9 @@ async function shutdown(signal: string) {
if ('close' in inboxStore && typeof inboxStore.close === 'function') { if ('close' in inboxStore && typeof inboxStore.close === 'function') {
await inboxStore.close(); await inboxStore.close();
} }
if (blobStore && 'close' in blobStore && typeof blobStore.close === 'function') {
await blobStore.close();
}
logger.info('Shutdown complete'); logger.info('Shutdown complete');
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-encrypted", "name": "@shade/storage-encrypted",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -31,6 +31,9 @@ export {
deriveNonce, deriveNonce,
buildAad, buildAad,
hkdfDerive, hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js'; } from './crypto/kdf.js';
export { export {
AEAD_NONCE_LEN, AEAD_NONCE_LEN,

View File

@@ -122,3 +122,42 @@ export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint
export function buildAad(table: string, column: string, pk: string): Uint8Array { export function buildAad(table: string, column: string, pk: string): Uint8Array {
return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`); return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`);
} }
// ─── V4.9 — relay-side encrypted blob primitive ──────────────
//
// Three deterministic 32-byte derivations rooted at the user's master
// key, used by `@shade/sdk`'s `Profile` namespace to bootstrap a brand
// new device into existing E2EE state from credentials alone:
//
// slotId = HKDF(masterKey, info=`shade-blob-slot-v1:${app}`)
// blobKey = HKDF(masterKey, info=`shade-blob-key-v1:${app}`)
// sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:${app}`)
//
// `app` is a caller-supplied namespace (e.g. `"prism-profile"`) so two
// Shade apps with the same user/master never collide on the same slot.
//
// The slot identifier and the AEAD key are *both* derived from the
// master — the relay sees opaque slotIds and AEAD-sealed blobs and
// cannot decrypt or correlate slots to users. The signing seed is the
// raw 32-byte Ed25519 private key (matches @noble/curves' API: pubkey
// = ed25519.getPublicKey(seed)).
/** Lower-hex 64-char slotId derived from the master key. */
export function deriveBlobSlotId(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-slot-v1:${app}`, 32);
}
/** AEAD key for sealing/opening the blob. Use AAD = slotId. */
export function deriveBlobKey(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-key-v1:${app}`, 32);
}
/**
* 32-byte Ed25519 signing seed (== the private key in the @noble/curves
* convention). The pubkey, derived deterministically from the seed, is
* what the relay TOFU-stores on the first PUT and verifies subsequent
* writes against.
*/
export function deriveBlobSigningSeed(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-sig-v1:${app}`, 32);
}

View File

@@ -16,6 +16,9 @@ export {
deriveNonce, deriveNonce,
buildAad, buildAad,
hkdfDerive, hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js'; } from './crypto/kdf.js';
export { export {
AEAD_NONCE_LEN, AEAD_NONCE_LEN,

View File

@@ -0,0 +1,50 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
import {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from '../src/crypto/kdf.js';
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
function fromHex(s: string): Uint8Array {
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(s.substr(i * 2, 2), 16);
}
return out;
}
function toHex(b: Uint8Array): string {
let s = '';
for (const x of b) s += x.toString(16).padStart(2, '0');
return s;
}
describe('V4.9 blob-storage KDF vectors', () => {
// Resolve relative to this file, not to cwd, so the test passes
// regardless of which directory `bun test` is invoked from.
const vectorPath = join(import.meta.dir, '..', '..', '..', 'test-vectors', 'blob-storage.json');
const vectors = JSON.parse(readFileSync(vectorPath, 'utf-8')) as {
kdf: Array<{
masterKey: string;
app: string;
slotId: string;
blobKey: string;
signingSeed: string;
ownerPubkey: string;
}>;
};
for (const v of vectors.kdf) {
test(`(master=${v.masterKey.slice(0, 8)}…, app=${v.app})`, () => {
const km = fromHex(v.masterKey);
expect(toHex(deriveBlobSlotId(km, v.app))).toBe(v.slotId);
expect(toHex(deriveBlobKey(km, v.app))).toBe(v.blobKey);
const seed = deriveBlobSigningSeed(km, v.app);
expect(toHex(seed)).toBe(v.signingSeed);
expect(toHex(ed25519PublicKeyFromSeed(seed))).toBe(v.ownerPubkey);
});
}
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-indexeddb", "name": "@shade/storage-indexeddb",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-postgres", "name": "@shade/storage-postgres",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -208,6 +208,30 @@ export async function ensureKTLogTables(sql: Sql): Promise<void> {
`; `;
} }
/**
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`). One row per
* slot, keyed on the 64-hex slotId. ETag is a sequence value so it's
* unique and monotonic across writers (matches the inbox `received_at`
* pattern). The blob column holds base64-encoded AEAD ciphertext —
* the relay never decrypts.
*/
export async function ensureBlobServerTables(sql: Sql): Promise<void> {
await sql`CREATE SEQUENCE IF NOT EXISTS shade_blob_seq`;
await sql`
CREATE TABLE IF NOT EXISTS shade_blob_slots (
slot_id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL,
blob TEXT NOT NULL,
etag BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS shade_blob_updated_idx
ON shade_blob_slots(updated_at)
`;
}
export async function ensureInboxServerTables(sql: Sql): Promise<void> { export async function ensureInboxServerTables(sql: Sql): Promise<void> {
await sql` await sql`
CREATE TABLE IF NOT EXISTS shade_inbox_owners ( CREATE TABLE IF NOT EXISTS shade_inbox_owners (

View File

@@ -2,9 +2,11 @@ export { PostgresStorage } from './postgres-storage.js';
export { PostgresPrekeyStore } from './postgres-prekey-store.js'; export { PostgresPrekeyStore } from './postgres-prekey-store.js';
export { PostgresInboxStore } from './postgres-inbox-store.js'; export { PostgresInboxStore } from './postgres-inbox-store.js';
export { PostgresKTLogStore } from './postgres-kt-store.js'; export { PostgresKTLogStore } from './postgres-kt-store.js';
export { PostgresBlobStore } from './postgres-blob-store.js';
export { export {
ensureClientTables, ensureClientTables,
ensurePrekeyServerTables, ensurePrekeyServerTables,
ensureInboxServerTables, ensureInboxServerTables,
ensureKTLogTables, ensureKTLogTables,
ensureBlobServerTables,
} from './ensure-tables.js'; } from './ensure-tables.js';

View File

@@ -0,0 +1,140 @@
import postgres, { type Sql } from 'postgres';
import {
ConflictError,
PreconditionFailedError,
toBase64,
fromBase64,
} from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
import { ensureBlobServerTables } from './ensure-tables.js';
/**
* PostgreSQL-backed BlobStore for the V4.9 encrypted-blob primitive.
*
* CAS is implemented at SQL level using a single UPDATE-with-WHERE
* (existing slots) or INSERT (empty slots), wrapped in a transaction so
* the read-then-write window can't race. ETag is generated server-side
* via `nextval('shade_blob_seq')` so the value is monotonic across
* processes — multi-instance deployments share a strict ordering.
*/
export class PostgresBlobStore implements BlobStore {
private constructor(
private readonly sql: Sql,
private readonly ownsConnection: boolean,
) {}
static async create(connectionString: string): Promise<PostgresBlobStore> {
const sql = postgres(connectionString);
const store = new PostgresBlobStore(sql, true);
await ensureBlobServerTables(sql);
return store;
}
static async fromClient(sql: Sql): Promise<PostgresBlobStore> {
const store = new PostgresBlobStore(sql, false);
await ensureBlobServerTables(sql);
return store;
}
async close(): Promise<void> {
if (this.ownsConnection) await this.sql.end();
}
async get(slotId: string): Promise<BlobSlotRecord | null> {
const rows = await this.sql<
Array<{
slot_id: string;
owner_pubkey: string;
blob: string;
etag: string;
updated_at: string;
}>
>`
SELECT slot_id, owner_pubkey, blob, etag::text, updated_at::text
FROM shade_blob_slots WHERE slot_id = ${slotId}
`;
if (rows.length === 0) return null;
const r = rows[0]!;
return {
slotId: r.slot_id,
ownerPubkey: fromBase64(r.owner_pubkey),
blob: fromBase64(r.blob),
etag: parseInt(r.etag, 10),
updatedAt: parseInt(r.updated_at, 10),
};
}
async put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult> {
// Wrap in a serializable txn so the read-current → write window
// can't race with another writer.
return this.sql.begin(async (tx) => {
const existing = await tx<Array<{ etag: string }>>`
SELECT etag::text FROM shade_blob_slots
WHERE slot_id = ${args.slotId}
FOR UPDATE
`;
if (existing.length === 0) {
if (args.expectedEtag !== undefined) {
throw new PreconditionFailedError(
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
);
}
const inserted = await tx<Array<{ etag: string }>>`
INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at)
VALUES (
${args.slotId},
${toBase64(args.ownerPubkey)},
${toBase64(args.blob)},
nextval('shade_blob_seq'),
${args.now}
)
RETURNING etag::text
`;
return {
created: true,
etag: parseInt(inserted[0]!.etag, 10),
updatedAt: args.now,
};
}
const currentEtag = parseInt(existing[0]!.etag, 10);
if (args.expectedEtag === undefined) {
throw new ConflictError(
`Slot ${args.slotId} already exists; supply ifMatch to update`,
);
}
if (args.expectedEtag !== '*' && args.expectedEtag !== currentEtag) {
throw new PreconditionFailedError(
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${currentEtag}`,
);
}
const updated = await tx<Array<{ etag: string }>>`
UPDATE shade_blob_slots
SET blob = ${toBase64(args.blob)},
etag = nextval('shade_blob_seq'),
updated_at = ${args.now}
WHERE slot_id = ${args.slotId}
RETURNING etag::text
`;
return {
created: false,
etag: parseInt(updated[0]!.etag, 10),
updatedAt: args.now,
};
}) as Promise<PutBlobResult>;
}
async delete(slotId: string): Promise<boolean> {
const result = await this.sql`
DELETE FROM shade_blob_slots WHERE slot_id = ${slotId}
`;
return result.count > 0;
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-sqlite", "name": "@shade/storage-sqlite",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,3 +1,4 @@
export { SQLiteStorage } from './sqlite-storage.js'; export { SQLiteStorage } from './sqlite-storage.js';
export { SqlitePrekeyStore } from './sqlite-prekey-store.js'; export { SqlitePrekeyStore } from './sqlite-prekey-store.js';
export { SqliteInboxStore } from './sqlite-inbox-store.js'; export { SqliteInboxStore } from './sqlite-inbox-store.js';
export { SqliteBlobStore } from './sqlite-blob-store.js';

View File

@@ -0,0 +1,156 @@
import { Database } from 'bun:sqlite';
import {
ConflictError,
PreconditionFailedError,
toBase64,
fromBase64,
} from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';
/**
* SQLite-backed BlobStore for the V4.9 encrypted-blob primitive.
*
* Single-table layout: each slot is a row keyed on the 64-hex slotId,
* with the AEAD ciphertext base64-encoded inline. The relay never
* decrypts the blob — it only enforces auth + CAS. ETag is a
* monotonic per-process counter clamped against `Date.now()` so the
* value is unique across writes and useful as a wall-clock hint.
*
* Docker usage: same volume as the inbox DB by convention; set
* `SHADE_BLOB_DB_PATH` (falls back to `/data/shade-blob.db`).
*/
export class SqliteBlobStore implements BlobStore {
private db: Database;
private stmts!: {
get: ReturnType<Database['prepare']>;
insert: ReturnType<Database['prepare']>;
update: ReturnType<Database['prepare']>;
delete: ReturnType<Database['prepare']>;
maxEtag: ReturnType<Database['prepare']>;
};
private seq = 0;
constructor(dbPath?: string) {
const path = dbPath ?? process.env.SHADE_BLOB_DB_PATH ?? '/data/shade-blob.db';
this.db = new Database(path, { create: true });
this.db.exec('PRAGMA journal_mode=WAL');
this.ensureTables();
this.prepareStatements();
this.bootstrapSeq();
}
private ensureTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS shade_blob_slots (
slot_id TEXT PRIMARY KEY,
owner_pubkey TEXT NOT NULL,
blob TEXT NOT NULL,
etag INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_shade_blob_updated
ON shade_blob_slots(updated_at);
`);
}
private prepareStatements() {
this.stmts = {
get: this.db.prepare(
'SELECT slot_id, owner_pubkey, blob, etag, updated_at FROM shade_blob_slots WHERE slot_id = ?',
),
insert: this.db.prepare(
'INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at) VALUES (?, ?, ?, ?, ?)',
),
update: this.db.prepare(
'UPDATE shade_blob_slots SET blob = ?, etag = ?, updated_at = ? WHERE slot_id = ?',
),
delete: this.db.prepare('DELETE FROM shade_blob_slots WHERE slot_id = ?'),
maxEtag: this.db.prepare('SELECT MAX(etag) AS max FROM shade_blob_slots'),
};
}
private bootstrapSeq() {
const row = this.stmts.maxEtag.get() as { max: number | null };
this.seq = Math.max(row?.max ?? 0, Date.now());
}
close() {
this.db.close();
}
async get(slotId: string): Promise<BlobSlotRecord | null> {
const row = this.stmts.get.get(slotId) as
| {
slot_id: string;
owner_pubkey: string;
blob: string;
etag: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
slotId: row.slot_id,
ownerPubkey: fromBase64(row.owner_pubkey),
blob: fromBase64(row.blob),
etag: row.etag,
updatedAt: row.updated_at,
};
}
async put(args: {
slotId: string;
blob: Uint8Array;
ownerPubkey: Uint8Array;
expectedEtag: number | '*' | undefined;
now: number;
}): Promise<PutBlobResult> {
// Read-then-write inside a write transaction so concurrent
// CAS attempts can't both observe the same etag.
const tx = this.db.transaction((): PutBlobResult => {
const existing = this.stmts.get.get(args.slotId) as
| { etag: number }
| undefined;
if (!existing) {
if (args.expectedEtag !== undefined) {
throw new PreconditionFailedError(
`Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
);
}
this.seq = Math.max(this.seq + 1, args.now);
const etag = this.seq;
this.stmts.insert.run(
args.slotId,
toBase64(args.ownerPubkey),
toBase64(args.blob),
etag,
args.now,
);
return { created: true, etag, updatedAt: args.now };
}
if (args.expectedEtag === undefined) {
throw new ConflictError(
`Slot ${args.slotId} already exists; supply ifMatch to update`,
);
}
if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
throw new PreconditionFailedError(
`Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
);
}
this.seq = Math.max(this.seq + 1, args.now);
const etag = this.seq;
this.stmts.update.run(toBase64(args.blob), etag, args.now, args.slotId);
return { created: false, etag, updatedAt: args.now };
});
return tx();
}
async delete(slotId: string): Promise<boolean> {
const result = this.stmts.delete.run(slotId);
return result.changes > 0;
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/streams", "name": "@shade/streams",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transfer", "name": "@shade/transfer",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-bridge", "name": "@shade/transport-bridge",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -28,6 +28,14 @@ export interface IncomingMessage {
receivedAt: number; receivedAt: number;
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */ /** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
msgId?: string; msgId?: string;
/**
* Absolute expiry (ms since epoch) reported by the relay. Surfaced for
* symmetry with `Inbox.handleBlob` so that
* `Inbox.acceptBridgeFrame(msg)` can be wired directly without the
* caller having to invent a TTL. Optional — pre-V4.8.3 relays / non-
* inbox bridges don't populate it.
*/
expiresAt?: number;
} }
/** Subscriber callback. Bridges MAY invoke it concurrently. */ /** Subscriber callback. Bridges MAY invoke it concurrently. */
@@ -83,6 +91,7 @@ export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage {
receivedAt: wire.receivedAt, receivedAt: wire.receivedAt,
}; };
if (wire.msgId !== undefined) msg.msgId = wire.msgId; if (wire.msgId !== undefined) msg.msgId = wire.msgId;
if (wire.expiresAt !== undefined) msg.expiresAt = wire.expiresAt;
return msg; return msg;
} }

View File

@@ -47,7 +47,9 @@ interface Harness {
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> { async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
const store = new MemoryInboxStore(); const store = new MemoryInboxStore();
const events = new InboxServerEvents(); const events = new InboxServerEvents();
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true }); // V4.8.4 — share a BridgeDeliveryLog between bridge + inbox routes so
// the inbox-fetch path filters out blobs the bridge already pushed.
// Mirrors the wiring in `@shade/server/standalone.ts`.
const bridge = createBridgeRoutes({ const bridge = createBridgeRoutes({
store, store,
crypto, crypto,
@@ -57,6 +59,11 @@ async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
heartbeatIntervalMs: 200, heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50, fallbackPollIntervalMs: 50,
}); });
const inboxApp = createInboxRoutes(store, crypto, {
events,
disableRateLimit: true,
bridgeDeliveryLog: bridge.bridgeDeliveryLog,
});
const app = new Hono(); const app = new Hono();
app.route('/', inboxApp); app.route('/', inboxApp);
app.route('/', bridge.app); app.route('/', bridge.app);
@@ -952,6 +959,123 @@ describe('Sender attribution — bridge push surfaces IncomingMessage.from', ()
}); });
}); });
// ─── V4.8.4 — cross-channel dedup via shared BridgeDeliveryLog ────────
describe('BridgeDeliveryLog — bridge push suppresses subsequent inbox-poll for the same msgId', () => {
test('WS push then /v1/inbox/:addr/fetch: fetch returns 0 blobs but advances cursor', async () => {
// Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`
// (re-verified on 4.8.3): a single inbox.send was being delivered
// both via the WS bridge AND via the next inbox-poll cycle, the
// duplicate dispatch tripping on already-consumed prekeys.
// V4.8.4's shared BridgeDeliveryLog records every successful
// bridge push and the inbox-fetch route filters those msgIds out
// for the grace window — so a recipient that runs both a bridge
// and a poll cycle observes exactly one delivery.
const h = await bootstrap();
try {
const received: IncomingMessage[] = [];
const bridge = new WsBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
connectTimeoutMs: 2_000,
disableAutoReconnect: true,
});
await bridge.connect({ onMessage: (m) => received.push(m) });
try {
const msgId = await putBlob(h, rand(48));
await waitFor(() => received.length === 1, 2_000);
// Give the bridge handler a tick to record the push in the log
// (it happens after the await on ws.send returns).
await new Promise((r) => setTimeout(r, 50));
// Now do a regular inbox-fetch as if the recipient's
// `Inbox.pollOnce` cycle fired. With V4.8.4 wiring, the
// bridge-pushed msgId is filtered out.
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const fetchJson = (await res.json()) as { blobs: unknown[]; cursor: number };
expect(fetchJson.blobs.length).toBe(0);
// Cursor advances past the suppressed blob so the next poll
// doesn't re-fetch the same range and stay stuck.
expect(fetchJson.cursor).toBeGreaterThan(0);
expect(received[0]!.msgId).toBe(msgId);
} finally {
await bridge.disconnect();
}
} finally {
h.server.stop(true);
}
});
test('SSE push also records into the log (parity with WS)', async () => {
const h = await bootstrap();
try {
const received: IncomingMessage[] = [];
const bridge = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 50,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
await bridge.connect({ onMessage: (m) => received.push(m) });
try {
await putBlob(h, rand(48));
await waitFor(() => received.length === 1, 2_000);
await new Promise((r) => setTimeout(r, 50));
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const fetchJson = (await res.json()) as { blobs: unknown[] };
expect(fetchJson.blobs.length).toBe(0);
} finally {
await bridge.disconnect();
}
} finally {
h.server.stop(true);
}
});
test('a non-bridge-pushed msgId is still returned by inbox-fetch', async () => {
// Negative control: blobs that the bridge never pushed (e.g. the
// bridge wasn't connected when the put landed) must still come
// through the inbox-fetch path. The filter is bridge-delivered-
// specific, not a blanket suppression.
const h = await bootstrap();
try {
// No bridge connected.
const msgId = await putBlob(h, rand(48));
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
address: 'bob',
sinceCursor: 0,
});
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const fetchJson = (await res.json()) as { blobs: Array<{ msgId: string }> };
expect(fetchJson.blobs.length).toBe(1);
expect(fetchJson.blobs[0]!.msgId).toBe(msgId);
} finally {
h.server.stop(true);
}
});
});
// ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─ // ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─
describe('Bridge dedup — single PUT yields exactly one push per connection', () => { describe('Bridge dedup — single PUT yields exactly one push per connection', () => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-webrtc", "name": "@shade/transport-webrtc",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport", "name": "@shade/transport",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/widgets", "name": "@shade/widgets",
"version": "4.8.2", "version": "4.11.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -26,6 +26,18 @@ import {
buildChunkAad, buildChunkAad,
aesGcmEncryptWithNonce, aesGcmEncryptWithNonce,
} from '../packages/shade-streams/src/index.js'; } from '../packages/shade-streams/src/index.js';
import {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from '../packages/shade-storage-encrypted/src/crypto.js';
import { ed25519PublicKeyFromSeed } from '../packages/shade-crypto-web/src/index.js';
import {
canonicalApprovalSigningBytes,
signProxyApproval,
buildApprovalRequest,
type ApprovalRequestFrame,
} from '../packages/shade-sdk/src/index.js';
const VECTOR_FILE_VERSION = 2; const VECTOR_FILE_VERSION = 2;
@@ -653,6 +665,162 @@ async function generateStorageEncryptionSubset(): Promise<Vector[]> {
]; ];
} }
async function generateBlobVectors(): Promise<Vector[]> {
// Three (master, app) cases. The first two share a master with
// different app namespaces, exercising the namespace separation;
// the third uses a different master entirely.
const cases: Array<{ masterKey: Uint8Array; app: string }> = [
{ masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'prism-profile-v1' },
{ masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'test-namespace' },
{ masterKey: new Uint8Array(32).fill(0xff), app: 'prism-profile-v1' },
];
const kdf = cases.map((c) => {
const slotId = deriveBlobSlotId(c.masterKey, c.app);
const blobKey = deriveBlobKey(c.masterKey, c.app);
const signingSeed = deriveBlobSigningSeed(c.masterKey, c.app);
const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed);
return {
description: `V4.9 blob KDF (master + app="${c.app}")`,
masterKey: hex(c.masterKey),
app: c.app,
slotId: hex(slotId),
blobKey: hex(blobKey),
signingSeed: hex(signingSeed),
ownerPubkey: hex(ownerPubkey),
};
});
// Three deterministic AEAD round-trips: pinned key, pinned nonce,
// pinned plaintext. The wire form is `nonce || ct||tag`.
const aeadCases = [
{
key: new Uint8Array(32).fill(0xab),
nonce: new Uint8Array(12).fill(0x01),
slotIdHex: '00'.repeat(32),
plaintext: new TextEncoder().encode('hello shade-blob-v1'),
},
{
key: new Uint8Array(32).map((_, i) => i),
nonce: new Uint8Array(12).map((_, i) => 0xa0 + i),
slotIdHex: 'ff'.repeat(32),
plaintext: new TextEncoder().encode(
'{"version":1,"hosts":[],"clients":[],"trustedApproverFingerprints":[],"updatedAt":1}',
),
},
];
const aead = await Promise.all(
aeadCases.map(async (c) => {
const aad = new TextEncoder().encode(`shade-profile-aad-v1:${c.slotIdHex}`);
const ctTag = await aesGcmEncryptDeterministic(c.key, c.nonce, c.plaintext, aad);
const wire = new Uint8Array(c.nonce.length + ctTag.length);
wire.set(c.nonce, 0);
wire.set(ctTag, c.nonce.length);
return {
description: 'V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad="shade-profile-aad-v1:<slotIdHex>")',
key: hex(c.key),
nonce: hex(c.nonce),
slotIdHex: c.slotIdHex,
plaintext: hex(c.plaintext),
wire: hex(wire),
};
}),
);
return [...kdf, ...aead];
}
async function generateApprovalVectors(): Promise<Vector[]> {
// Pinned signing-payload bytes for canonical approval. Length-
// prefixed UTF-8 with u16 BE lengths — Kotlin/Swift implementations
// produce byte-identical input by spec.
const cases = [
{
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve' as const,
},
{
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'reject' as const,
},
{
domain: 'prism-link-approve-v1',
requestId: '00000000000000000000000000000000',
hostFingerprint: 'a',
requestingDeviceFingerprint: 'b',
decision: 'approve' as const,
},
];
const payloads = cases.map((c) => ({
description: 'V4.10 approval signing payload (length-prefixed u16 BE UTF-8)',
domain: c.domain,
requestId: c.requestId,
hostFingerprint: c.hostFingerprint,
requestingDeviceFingerprint: c.requestingDeviceFingerprint,
decision: c.decision,
signingPayload: hex(canonicalApprovalSigningBytes(c)),
}));
// Pinned end-to-end sign + verify: deterministic seed → pubkey →
// sign(payload) → verify against the pubkey. Lets the Kotlin port
// assert the exact 64-byte signature without re-running RNG.
const seed = new Uint8Array(32).map((_, i) => 0x10 + i);
const pubkey = ed25519PublicKeyFromSeed(seed);
const fakeReq: ApprovalRequestFrame = {
kind: 'approvalNeeded',
requestId: 'cafebabe1234567890abcdef00112233',
hostAddress: 'device:host',
hostFingerprint: 'host-fp',
requestingDevice: { fingerprint: 'req-fp', receivedAt: 1 },
expiresAt: 9_999_999_999_999,
domain: 'shade-link-approve-v1',
};
const signed = await signProxyApproval({
request: fakeReq,
decision: 'approve',
approverFingerprint: 'approver-fp',
approverSigningKey: seed,
crypto,
});
const e2e = {
description: 'V4.10 approval Ed25519 sign/verify (deterministic seed)',
seed: hex(seed),
publicKey: hex(pubkey),
request: {
requestId: fakeReq.requestId,
hostFingerprint: fakeReq.hostFingerprint,
requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint,
decision: 'approve',
domain: fakeReq.domain,
},
signingPayload: hex(
canonicalApprovalSigningBytes({
domain: fakeReq.domain,
requestId: fakeReq.requestId,
hostFingerprint: fakeReq.hostFingerprint,
requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint,
decision: 'approve',
}),
),
signature: signed.signature,
};
// Sanity self-check at generation time so a silently broken sign
// path can't ship vectors that "verify" themselves.
void buildApprovalRequest; // imported but unused — keeps the symbol live
return [...payloads, e2e];
}
async function main() { async function main() {
console.log('Generating cross-platform test vectors…'); console.log('Generating cross-platform test vectors…');
@@ -667,6 +835,8 @@ async function main() {
['backup.json', { vectors: await generateBackupVectors() }], ['backup.json', { vectors: await generateBackupVectors() }],
['group.json', { vectors: await generateGroupVectors() }], ['group.json', { vectors: await generateGroupVectors() }],
['storage-hkdf.json', { vectors: await generateStorageEncryptionSubset() }], ['storage-hkdf.json', { vectors: await generateStorageEncryptionSubset() }],
['blob.json', { vectors: await generateBlobVectors() }],
['approval.json', { vectors: await generateApprovalVectors() }],
]; ];
for (const [name, data] of files) { for (const [name, data] of files) {

View File

@@ -0,0 +1,46 @@
{
"version": 2,
"vectors": [
{
"description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)",
"domain": "shade-link-approve-v1",
"requestId": "aabbccddeeff00112233445566778899",
"hostFingerprint": "11111 22222 33333 44444",
"requestingDeviceFingerprint": "55555 66666 77777 88888",
"decision": "approve",
"signingPayload": "001573686164652d6c696e6b2d617070726f76652d76310020616162626363646465656666303031313232333334343535363637373838393900173131313131203232323232203333333333203434343434001735353535352036363636362037373737372038383838380007617070726f7665"
},
{
"description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)",
"domain": "shade-link-approve-v1",
"requestId": "aabbccddeeff00112233445566778899",
"hostFingerprint": "11111 22222 33333 44444",
"requestingDeviceFingerprint": "55555 66666 77777 88888",
"decision": "reject",
"signingPayload": "001573686164652d6c696e6b2d617070726f76652d7631002061616262636364646565666630303131323233333434353536363737383839390017313131313120323232323220333333333320343434343400173535353535203636363636203737373737203838383838000672656a656374"
},
{
"description": "V4.10 approval signing payload (length-prefixed u16 BE UTF-8)",
"domain": "prism-link-approve-v1",
"requestId": "00000000000000000000000000000000",
"hostFingerprint": "a",
"requestingDeviceFingerprint": "b",
"decision": "approve",
"signingPayload": "0015707269736d2d6c696e6b2d617070726f76652d7631002030303030303030303030303030303030303030303030303030303030303030300001610001620007617070726f7665"
},
{
"description": "V4.10 approval Ed25519 sign/verify (deterministic seed)",
"seed": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
"publicKey": "7776e870b93354f2a0b24c23f2a36cc4e80e223218c1b97926fdd018396a2b9b",
"request": {
"requestId": "cafebabe1234567890abcdef00112233",
"hostFingerprint": "host-fp",
"requestingDeviceFingerprint": "req-fp",
"decision": "approve",
"domain": "shade-link-approve-v1"
},
"signingPayload": "001573686164652d6c696e6b2d617070726f76652d7631002063616665626162653132333435363738393061626364656630303131323233330007686f73742d667000067265712d66700007617070726f7665",
"signature": "2a60910d161466a10c5b256548267c6d58f7b25d6035dae4c7ab7770dfb1fe321c5b86120749544d18d0f40e1a3e9ca713724692e265083160da23cee926220e"
}
]
}

View File

@@ -0,0 +1,39 @@
{
"version": 1,
"description": "V4.9 — relay-side encrypted blob primitive: HKDF derivations from masterKey + per-app namespace string. Each (master, app) pair MUST reproduce the same slotId / blobKey / signingSeed / ownerPubkey across implementations. ownerPubkey = Ed25519.getPublicKey(signingSeed).",
"kdf": [
{
"masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"app": "prism-profile-v1",
"slotIdInfo": "shade-blob-slot-v1:prism-profile-v1",
"blobKeyInfo": "shade-blob-key-v1:prism-profile-v1",
"signingSeedInfo": "shade-blob-sig-v1:prism-profile-v1",
"slotId": "cee6fe19af3c3ad20f91382938cd05ccf7f314566209f5debad17d8427508323",
"blobKey": "47ad8fc8fcb0f15ec75be95246e6040bb0674b1a9e4bc3cf7a2c3d1c1e57877b",
"signingSeed": "0bb58f21b588b44f22d5837602c1ee0049e56f99df5241702b65e5de0a1a0dab",
"ownerPubkey": "2be918c7af82278fb446bb3901e5a7691f5ac4123275d5e1b202882da2a637bc"
},
{
"masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"app": "test-namespace",
"slotIdInfo": "shade-blob-slot-v1:test-namespace",
"blobKeyInfo": "shade-blob-key-v1:test-namespace",
"signingSeedInfo": "shade-blob-sig-v1:test-namespace",
"slotId": "b10a7e64f9902f48bc566d48c09c0276cdad2dc9ad55d456374c02a8f160aa46",
"blobKey": "9e140339142d23291f0f360f03072c66049cec2449994dce1b77a3aed43eeb37",
"signingSeed": "feec2d85ba7320fe34940abca082f056d5fa7927d940b267d44ae24acb486773",
"ownerPubkey": "94e8298ea69ba4b160934fb813ee3fa5b2a4254cc78cb3dd8339bdc7b68e660c"
},
{
"masterKey": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"app": "prism-profile-v1",
"slotIdInfo": "shade-blob-slot-v1:prism-profile-v1",
"blobKeyInfo": "shade-blob-key-v1:prism-profile-v1",
"signingSeedInfo": "shade-blob-sig-v1:prism-profile-v1",
"slotId": "deffbe4e2934965ce63fff247331186579ff4ef13c867fa4597059c1d7047bfb",
"blobKey": "f498052d24513dccbdf538f2b9c13e9d6519fb06ead58eb3dfadf6b92d94227a",
"signingSeed": "e904e2f0f42297f16a29e636c43b9b72d57a49841ab0b9bfd29c03345e9f16d0",
"ownerPubkey": "822609f6b07f78d4692bfe708c05ce2d4d3c4eb25cf84a16a9d9e900015b3ca0"
}
]
}

48
test-vectors/blob.json Normal file
View File

@@ -0,0 +1,48 @@
{
"version": 2,
"vectors": [
{
"description": "V4.9 blob KDF (master + app=\"prism-profile-v1\")",
"masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"app": "prism-profile-v1",
"slotId": "cee6fe19af3c3ad20f91382938cd05ccf7f314566209f5debad17d8427508323",
"blobKey": "47ad8fc8fcb0f15ec75be95246e6040bb0674b1a9e4bc3cf7a2c3d1c1e57877b",
"signingSeed": "0bb58f21b588b44f22d5837602c1ee0049e56f99df5241702b65e5de0a1a0dab",
"ownerPubkey": "2be918c7af82278fb446bb3901e5a7691f5ac4123275d5e1b202882da2a637bc"
},
{
"description": "V4.9 blob KDF (master + app=\"test-namespace\")",
"masterKey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"app": "test-namespace",
"slotId": "b10a7e64f9902f48bc566d48c09c0276cdad2dc9ad55d456374c02a8f160aa46",
"blobKey": "9e140339142d23291f0f360f03072c66049cec2449994dce1b77a3aed43eeb37",
"signingSeed": "feec2d85ba7320fe34940abca082f056d5fa7927d940b267d44ae24acb486773",
"ownerPubkey": "94e8298ea69ba4b160934fb813ee3fa5b2a4254cc78cb3dd8339bdc7b68e660c"
},
{
"description": "V4.9 blob KDF (master + app=\"prism-profile-v1\")",
"masterKey": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"app": "prism-profile-v1",
"slotId": "deffbe4e2934965ce63fff247331186579ff4ef13c867fa4597059c1d7047bfb",
"blobKey": "f498052d24513dccbdf538f2b9c13e9d6519fb06ead58eb3dfadf6b92d94227a",
"signingSeed": "e904e2f0f42297f16a29e636c43b9b72d57a49841ab0b9bfd29c03345e9f16d0",
"ownerPubkey": "822609f6b07f78d4692bfe708c05ce2d4d3c4eb25cf84a16a9d9e900015b3ca0"
},
{
"description": "V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad=\"shade-profile-aad-v1:<slotIdHex>\")",
"key": "abababababababababababababababababababababababababababababababab",
"nonce": "010101010101010101010101",
"slotIdHex": "0000000000000000000000000000000000000000000000000000000000000000",
"plaintext": "68656c6c6f2073686164652d626c6f622d7631",
"wire": "0101010101010101010101014590dd4c26e2abcaafd91815d4b40dab6512fecc82205c3484d87454602ca189ad213f"
},
{
"description": "V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad=\"shade-profile-aad-v1:<slotIdHex>\")",
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"nonce": "a0a1a2a3a4a5a6a7a8a9aaab",
"slotIdHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"plaintext": "7b2276657273696f6e223a312c22686f737473223a5b5d2c22636c69656e7473223a5b5d2c2274727573746564417070726f76657246696e6765727072696e7473223a5b5d2c22757064617465644174223a317d",
"wire": "a0a1a2a3a4a5a6a7a8a9aaab9d3a0a4837b86bd00c47bde22b58a8b103d82a32a8ec1f40be6d4aef1ac50172f04c1ca28300274f2aef70ad6d3bf3893574302d10967310263b792ed619ebc4c79ebf346d89c69584869e6ceaaa8dceef319ad9e4a96b1a15607fb08082dbb0f959738b"
}
]
}