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
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:
101
CHANGELOG.md
101
CHANGELOG.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user