release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
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
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

Lands the broadcast-channel primitive Prism asked for in
Docs/shade-feature-request-sender-keys.md. The crypto in
@shade/core/sender-keys.ts was already in place; this release wires
it up as a first-class app-facing API, adds the persistence schema
across all six storage backends (memory, sqlite, indexeddb +
encrypted variants), introduces wire type 0x21 in @shade/proto,
and ships Prism's three acceptance tests verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:55:34 +02:00
parent 2b1b4d6630
commit 2c400d7094
42 changed files with 1606 additions and 49 deletions

View File

@@ -5,6 +5,107 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.6.0] — 2026-05-07 — Broadcast channels (Signal sender-keys for one-to-many fan-out)
Prism's PC desktop is the *sender* in a one-to-many fan-out — one PTY
output frame, N paired-device deliveries — and bilateral `for (peer of
peers) shade.send(peer, frame)` works for N ≤ 5 but starts hurting once
the paired fleet grows (3 laptops + phone + tablet + watch = N = 7) and
once mobile cellular is in the loop. The crypto pattern that solves it
is Signal's **sender-key**: the sender holds a per-channel symmetric
chain key shared with all members, encrypts each message *once* with
it, and the relay (or the SDK fan-out loop) ships the same ciphertext
to every recipient.
This release lands sender-key broadcast as a scoped "broadcast channel"
primitive in `@shade/sdk`, with the persistence + wire format + receiver-
side `meta.kind === 'broadcast'` plumbing wired through every backend.
The crypto in `@shade/core/sender-keys.ts` was already in place;
v4.6 turns it into a first-class app-facing API.
### Added
#### `@shade/sdk`
- `shade.createBroadcastChannel({ label? })``BroadcastChannel`
opaque, persisted channel id stable across `shutdown()` / re-open.
Owner role: `sender` (only the channel creator can broadcast).
- `BroadcastChannel.addMember(peerAddress)` — distributes the current
sender-key to a paired peer over the existing bilateral ratchet.
Returns the wrapped envelope the app delivers; the SDK does the
framing inline (no new wire-format changes visible to apps —
acceptance criterion (3)).
- `BroadcastChannel.removeMember(peerAddress)` — rotates the chain
(fresh `chainKey` + new Ed25519 signing keypair, `generation++`),
destroys the old key material, and returns one envelope per surviving
member with the new sender-key. Stale broadcasts at lower generations
are silently dropped on receive.
- `BroadcastChannel.broadcast(plaintext)` — single AES-256-GCM encrypt
with the current chain message key + Ed25519 signature; the SAME
envelope is delivered to every member. Returns
`{ envelope: Uint8Array, members: readonly string[] }` so the app's
transport handles the per-peer fan-out.
- `BroadcastChannel.members()` — snapshot of currently-active members
(excludes revoked).
- `shade.getBroadcastChannel(channelId)` / `shade.listBroadcastChannels()`
for reconciling app-level pairing state with persisted channel state.
- `shade.acceptBroadcast(envelope)` — decrypt an inbound broadcast wire
envelope; dispatches to `onMessage` handlers with
`meta = { kind: 'broadcast', channelId, sender, generation, iteration }`.
- `Shade.onMessage` handler signature gained an optional third arg
`meta?: MessageMeta` — back-compat: handlers that ignore it keep
working unchanged for direct messages.
#### `@shade/proto`
- `encodeBroadcast(BroadcastWire)` / `decodeBroadcast(bytes)` — wire
type `0x21`. Length-prefixed channelId + senderAddress, u32
generation/iteration, 12-byte AES-GCM nonce, 64-byte Ed25519
signature, length-prefixed ciphertext.
- `inspectEnvelopeType` recognises `'broadcast'`.
#### `@shade/core`
- `BroadcastChannelRecord` — persisted channel state (chainKey,
iteration, signing keys, generation, role).
- `BroadcastMemberRecord` — sender-side membership row with `joinedAt`
+ nullable `removedAt`.
- `StorageProvider` gained six optional methods:
`saveBroadcastChannel`, `getBroadcastChannel`, `listBroadcastChannels`,
`removeBroadcastChannel`, `saveBroadcastMember`, `getBroadcastMembers`,
`removeBroadcastMember`. Backends < 4.6 throw a clear error when an
app tries to call `createBroadcastChannel` against them.
#### Storage backends
- `MemoryStorage`, `SQLiteStorage`, `IndexedDBStorage` — plaintext
`broadcast_channels` + `broadcast_members` tables. IDB schema bumps
to v2 with an upgrade-path that creates the new stores idempotently.
- `EncryptedSQLiteStorage`, `EncryptedIndexedDBStorage`,
`EncryptedPostgresStorage``broadcast_channels_enc` +
`broadcast_members_enc` schemas. The chain key, iteration, and
signing-key bundle live in a sealed `ciphertext` blob bound to
`(table='broadcast_channels', column='broadcast_channel_sensitive', pk=channelId)`
AAD; routing fields (channelId, ownerRole, ownerAddress, label,
generation, timestamps) stay plaintext for queries. New row-codec
helpers `sealBroadcastChannelSensitive` / `openBroadcastChannelSensitive`.
IDB schema bumps to v2 the same way.
### Tests
- `packages/shade-sdk/tests/broadcast.test.ts` — Prism's three
acceptance tests verbatim: (1) two-member receive with
`meta.kind === 'broadcast'`, (1*) revocation rotates + receiver A
drops while B keeps working, (2) persistence — channel id, members,
and chain advance survive `shutdown()` + re-open from the SQLite
path, (3) `listBroadcastChannels` surfaces both sender + receiver
records correctly.
- `packages/shade-storage-encrypted/tests/encrypted-sqlite.test.ts`
channel + member round-trip under sealed storage; receiver-side rows
correctly persist without `signingPrivateKey`.
### Compatibility
- Wire-protocol additive: existing peers ignore the `0x21` envelope
type. Apps not using broadcast channels see no behavior change.
- Storage schemas additive: the new `broadcast_*` tables / object
stores are created on first open; migrations from a 4.5 database are
no-ops. IDB schema-version bump happens transparently in `upgrade`.
## [4.5.0] — 2026-05-07 — Browser-side encrypted storage + multi-factor unlock
Browser-based Shade clients (Prism's web client being the first) needed