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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
**/.tmp-*.db
|
||||||
|
**/.tmp-*.db-shm
|
||||||
|
**/.tmp-*.db-wal
|
||||||
|
|||||||
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/),
|
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.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
|
## [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
|
Browser-based Shade clients (Prism's web client being the first) needed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -46,6 +46,50 @@ export interface PersistedStreamState {
|
|||||||
*/
|
*/
|
||||||
export type PeerVerificationSource = 'user' | 'transitive' | 'tofu-after-warning';
|
export type PeerVerificationSource = 'user' | 'transitive' | 'tofu-after-warning';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted broadcast-channel state (V4.6). Holds the sender-key chain that
|
||||||
|
* lets the channel owner encrypt a single ciphertext and fan it out to all
|
||||||
|
* paired members.
|
||||||
|
*
|
||||||
|
* - `ownerRole === 'sender'`: this device created the channel; the record
|
||||||
|
* carries the full chain (chainKey + iteration + signing keypair).
|
||||||
|
* - `ownerRole === 'receiver'`: this device joined a channel that
|
||||||
|
* `ownerAddress` owns; we hold a tracking copy of the chain (chainKey +
|
||||||
|
* iteration + signing public key only — no private signing key).
|
||||||
|
*
|
||||||
|
* `generation` is bumped each time `removeMember` rotates the chain. Stale
|
||||||
|
* broadcasts at lower generations are silently dropped on receive.
|
||||||
|
*/
|
||||||
|
export interface BroadcastChannelRecord {
|
||||||
|
/** Opaque, stable across restarts. UUID-style. */
|
||||||
|
channelId: string;
|
||||||
|
ownerRole: 'sender' | 'receiver';
|
||||||
|
/** The address that owns the channel (i.e. the only sender). */
|
||||||
|
ownerAddress: string;
|
||||||
|
label?: string;
|
||||||
|
/** Sender-key generation — bumped on rotation. Starts at 0. */
|
||||||
|
generation: number;
|
||||||
|
/** Current chain key (32 bytes). */
|
||||||
|
chainKey: Uint8Array;
|
||||||
|
/** Counter advanced by `senderKeyEncrypt` / `senderKeyDecrypt`. */
|
||||||
|
iteration: number;
|
||||||
|
/** Owner's Ed25519 signing public key (32 bytes). */
|
||||||
|
signingPublicKey: Uint8Array;
|
||||||
|
/** Owner's Ed25519 signing private key. Present iff `ownerRole === 'sender'`. */
|
||||||
|
signingPrivateKey?: Uint8Array;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membership row for a broadcast channel (sender-side only). */
|
||||||
|
export interface BroadcastMemberRecord {
|
||||||
|
channelId: string;
|
||||||
|
peerAddress: string;
|
||||||
|
joinedAt: number;
|
||||||
|
/** When this peer was revoked. `null` while still active. */
|
||||||
|
removedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent record that a peer's safety number was verified at a point
|
* Persistent record that a peer's safety number was verified at a point
|
||||||
* in time. `identityVersion` is the local counter for that peer's identity:
|
* in time. `identityVersion` is the local counter for that peer's identity:
|
||||||
@@ -188,4 +232,35 @@ export interface StorageProvider {
|
|||||||
|
|
||||||
/** Prune stream-state rows in `'finished' | 'aborted'` status older than `olderThan`. */
|
/** Prune stream-state rows in `'finished' | 'aborted'` status older than `olderThan`. */
|
||||||
pruneStreamStates?(olderThan: number): Promise<void>;
|
pruneStreamStates?(olderThan: number): Promise<void>;
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) — optional ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist or replace the broadcast-channel record. Idempotent upsert on
|
||||||
|
* `channelId`. Backends that don't implement broadcast-channel support
|
||||||
|
* may omit this; the SDK throws a clear error when the app tries to
|
||||||
|
* call `createBroadcastChannel` on such a backend.
|
||||||
|
*/
|
||||||
|
saveBroadcastChannel?(channel: BroadcastChannelRecord): Promise<void>;
|
||||||
|
|
||||||
|
/** Look up a broadcast channel by id. */
|
||||||
|
getBroadcastChannel?(channelId: string): Promise<BroadcastChannelRecord | null>;
|
||||||
|
|
||||||
|
/** Enumerate all broadcast channels persisted on this device. */
|
||||||
|
listBroadcastChannels?(): Promise<BroadcastChannelRecord[]>;
|
||||||
|
|
||||||
|
/** Drop a broadcast channel and all its membership rows. */
|
||||||
|
removeBroadcastChannel?(channelId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Persist or replace a broadcast-membership row (sender-side only). */
|
||||||
|
saveBroadcastMember?(member: BroadcastMemberRecord): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List membership rows for a channel. Includes revoked members (with
|
||||||
|
* `removedAt !== null`); callers filter as needed.
|
||||||
|
*/
|
||||||
|
getBroadcastMembers?(channelId: string): Promise<BroadcastMemberRecord[]>;
|
||||||
|
|
||||||
|
/** Hard-delete a single membership row. */
|
||||||
|
removeBroadcastMember?(channelId: string, peerAddress: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification } from '@shade/core';
|
import type {
|
||||||
|
StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey,
|
||||||
|
SessionState, RetiredIdentity, PersistedStreamState, PeerVerification,
|
||||||
|
BroadcastChannelRecord, BroadcastMemberRecord,
|
||||||
|
} from '@shade/core';
|
||||||
import { constantTimeEqual } from '@shade/core';
|
import { constantTimeEqual } from '@shade/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,4 +171,62 @@ export class MemoryStorage implements StorageProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
private broadcastChannels = new Map<string, BroadcastChannelRecord>();
|
||||||
|
private broadcastMembers = new Map<string, Map<string, BroadcastMemberRecord>>();
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
this.broadcastChannels.set(channel.channelId, cloneChannel(channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const v = this.broadcastChannels.get(channelId);
|
||||||
|
return v ? cloneChannel(v) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
return [...this.broadcastChannels.values()].map(cloneChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
this.broadcastChannels.delete(channelId);
|
||||||
|
this.broadcastMembers.delete(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
let inner = this.broadcastMembers.get(member.channelId);
|
||||||
|
if (!inner) {
|
||||||
|
inner = new Map();
|
||||||
|
this.broadcastMembers.set(member.channelId, inner);
|
||||||
|
}
|
||||||
|
inner.set(member.peerAddress, { ...member });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const inner = this.broadcastMembers.get(channelId);
|
||||||
|
return inner ? [...inner.values()].map((m) => ({ ...m })) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
this.broadcastMembers.get(channelId)?.delete(peerAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneChannel(c: BroadcastChannelRecord): BroadcastChannelRecord {
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: c.channelId,
|
||||||
|
ownerRole: c.ownerRole,
|
||||||
|
ownerAddress: c.ownerAddress,
|
||||||
|
generation: c.generation,
|
||||||
|
chainKey: new Uint8Array(c.chainKey),
|
||||||
|
iteration: c.iteration,
|
||||||
|
signingPublicKey: new Uint8Array(c.signingPublicKey),
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
};
|
||||||
|
if (c.label !== undefined) out.label = c.label;
|
||||||
|
if (c.signingPrivateKey !== undefined) out.signingPrivateKey = new Uint8Array(c.signingPrivateKey);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ export {
|
|||||||
encodeRatchetMessage,
|
encodeRatchetMessage,
|
||||||
encodeStreamChunk,
|
encodeStreamChunk,
|
||||||
decodeStreamChunk,
|
decodeStreamChunk,
|
||||||
|
encodeBroadcast,
|
||||||
|
decodeBroadcast,
|
||||||
inspectEnvelopeType,
|
inspectEnvelopeType,
|
||||||
TYPE_STREAM_CHUNK,
|
TYPE_STREAM_CHUNK,
|
||||||
|
TYPE_BROADCAST,
|
||||||
} from './wire.js';
|
} from './wire.js';
|
||||||
export type { StreamChunkWire } from './wire.js';
|
export type { StreamChunkWire, BroadcastWire } from './wire.js';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* 0x01 = PreKeyMessage
|
* 0x01 = PreKeyMessage
|
||||||
* 0x02 = RatchetMessage
|
* 0x02 = RatchetMessage
|
||||||
* 0x11 = StreamChunk
|
* 0x11 = StreamChunk
|
||||||
|
* 0x21 = BroadcastMessage (V4.6 — sender-key encrypted group payload)
|
||||||
*
|
*
|
||||||
* All multi-byte integers are big-endian.
|
* All multi-byte integers are big-endian.
|
||||||
*
|
*
|
||||||
@@ -23,6 +24,7 @@ const VERSION = 0x02;
|
|||||||
const TYPE_PREKEY = 0x01;
|
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;
|
||||||
|
|
||||||
// ─── Stream chunk types ──────────────────────────────────────
|
// ─── Stream chunk types ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -43,6 +45,28 @@ export interface StreamChunkWire {
|
|||||||
ciphertext: Uint8Array; // AES-GCM ciphertext including 16-byte tag
|
ciphertext: Uint8Array; // AES-GCM ciphertext including 16-byte tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-decoded broadcast envelope (type 0x21).
|
||||||
|
*
|
||||||
|
* Carries a Signal-style sender-key message. The sender (channel owner)
|
||||||
|
* encrypted once with their per-channel chain key; this same byte sequence
|
||||||
|
* is delivered verbatim to every member. Authenticity rides on the embedded
|
||||||
|
* Ed25519 signature — no bilateral ratchet wraps the broadcast itself.
|
||||||
|
*
|
||||||
|
* `generation` is a per-channel rotation counter: bumped each time a
|
||||||
|
* member is revoked. Receivers silently drop broadcasts at older
|
||||||
|
* generations than their currently-installed sender-key.
|
||||||
|
*/
|
||||||
|
export interface BroadcastWire {
|
||||||
|
channelId: string; // utf-8, length-prefixed
|
||||||
|
senderAddress: string; // utf-8, length-prefixed
|
||||||
|
generation: number; // u32
|
||||||
|
iteration: number; // u32 — sender chain counter
|
||||||
|
nonce: Uint8Array; // AES-GCM nonce (12 bytes)
|
||||||
|
signature: Uint8Array; // Ed25519 signature (64 bytes)
|
||||||
|
ciphertext: Uint8Array; // AES-GCM ciphertext including 16-byte tag
|
||||||
|
}
|
||||||
|
|
||||||
const STREAM_ID_BYTES = 16;
|
const STREAM_ID_BYTES = 16;
|
||||||
const STREAM_NONCE_BYTES = 12;
|
const STREAM_NONCE_BYTES = 12;
|
||||||
|
|
||||||
@@ -230,11 +254,11 @@ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspect the type tag of an arbitrary envelope without full parsing.
|
* Inspect the type tag of an arbitrary envelope without full parsing.
|
||||||
* Returns one of `'prekey' | 'ratchet' | 'stream-chunk' | 'unknown'`.
|
* Returns one of `'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown'`.
|
||||||
*/
|
*/
|
||||||
export function inspectEnvelopeType(
|
export function inspectEnvelopeType(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
): 'prekey' | 'ratchet' | 'stream-chunk' | 'unknown' {
|
): 'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | '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:
|
||||||
@@ -243,11 +267,116 @@ export function inspectEnvelopeType(
|
|||||||
return 'ratchet';
|
return 'ratchet';
|
||||||
case TYPE_STREAM_CHUNK:
|
case TYPE_STREAM_CHUNK:
|
||||||
return 'stream-chunk';
|
return 'stream-chunk';
|
||||||
|
case TYPE_BROADCAST:
|
||||||
|
return 'broadcast';
|
||||||
default:
|
default:
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast wire (V4.6) ───────────────────────────────────
|
||||||
|
|
||||||
|
const BROADCAST_NONCE_BYTES = 12;
|
||||||
|
const BROADCAST_SIGNATURE_BYTES = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a broadcast envelope to wire bytes (type 0x21).
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* [version:1][type=0x21:1]
|
||||||
|
* [channelIdLen:u16][channelId utf-8]
|
||||||
|
* [senderAddrLen:u16][senderAddr utf-8]
|
||||||
|
* [generation:u32]
|
||||||
|
* [iteration:u32]
|
||||||
|
* [nonce:12]
|
||||||
|
* [signature:64]
|
||||||
|
* [ctLen:u32][ciphertext]
|
||||||
|
*/
|
||||||
|
export function encodeBroadcast(b: BroadcastWire): Uint8Array {
|
||||||
|
if (b.nonce.length !== BROADCAST_NONCE_BYTES) {
|
||||||
|
throw new Error(`broadcast nonce must be ${BROADCAST_NONCE_BYTES} bytes`);
|
||||||
|
}
|
||||||
|
if (b.signature.length !== BROADCAST_SIGNATURE_BYTES) {
|
||||||
|
throw new Error(`broadcast signature must be ${BROADCAST_SIGNATURE_BYTES} bytes`);
|
||||||
|
}
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const channelIdBytes = enc.encode(b.channelId);
|
||||||
|
const senderBytes = enc.encode(b.senderAddress);
|
||||||
|
if (channelIdBytes.length > 0xffff) throw new Error('channelId too long');
|
||||||
|
if (senderBytes.length > 0xffff) throw new Error('senderAddress too long');
|
||||||
|
|
||||||
|
const headerSize =
|
||||||
|
1 + 1 +
|
||||||
|
2 + channelIdBytes.length +
|
||||||
|
2 + senderBytes.length +
|
||||||
|
4 + 4 +
|
||||||
|
BROADCAST_NONCE_BYTES +
|
||||||
|
BROADCAST_SIGNATURE_BYTES +
|
||||||
|
4;
|
||||||
|
const out = new Uint8Array(headerSize + b.ciphertext.length);
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
let offset = 0;
|
||||||
|
out[offset++] = VERSION;
|
||||||
|
out[offset++] = TYPE_BROADCAST;
|
||||||
|
|
||||||
|
view.setUint16(offset, channelIdBytes.length, false); offset += 2;
|
||||||
|
out.set(channelIdBytes, offset); offset += channelIdBytes.length;
|
||||||
|
|
||||||
|
view.setUint16(offset, senderBytes.length, false); offset += 2;
|
||||||
|
out.set(senderBytes, offset); offset += senderBytes.length;
|
||||||
|
|
||||||
|
view.setUint32(offset, b.generation, false); offset += 4;
|
||||||
|
view.setUint32(offset, b.iteration, false); offset += 4;
|
||||||
|
|
||||||
|
out.set(b.nonce, offset); offset += BROADCAST_NONCE_BYTES;
|
||||||
|
out.set(b.signature, offset); offset += BROADCAST_SIGNATURE_BYTES;
|
||||||
|
|
||||||
|
view.setUint32(offset, b.ciphertext.length, false); offset += 4;
|
||||||
|
out.set(b.ciphertext, offset);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBroadcast(data: Uint8Array): BroadcastWire {
|
||||||
|
const minSize = 1 + 1 + 2 + 0 + 2 + 0 + 4 + 4 +
|
||||||
|
BROADCAST_NONCE_BYTES + BROADCAST_SIGNATURE_BYTES + 4;
|
||||||
|
if (data.length < minSize) {
|
||||||
|
throw new Error(`broadcast envelope too short: ${data.length} < ${minSize}`);
|
||||||
|
}
|
||||||
|
if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`);
|
||||||
|
if (data[1] !== TYPE_BROADCAST) throw new Error(`Not a broadcast: type=${data[1]}`);
|
||||||
|
|
||||||
|
const view = new DataView(data.buffer, data.byteOffset);
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
const channelIdLen = view.getUint16(offset, false); offset += 2;
|
||||||
|
if (offset + channelIdLen > data.length) throw new Error('broadcast truncated in channelId');
|
||||||
|
const channelId = dec.decode(data.slice(offset, offset + channelIdLen));
|
||||||
|
offset += channelIdLen;
|
||||||
|
|
||||||
|
const senderLen = view.getUint16(offset, false); offset += 2;
|
||||||
|
if (offset + senderLen > data.length) throw new Error('broadcast truncated in senderAddress');
|
||||||
|
const senderAddress = dec.decode(data.slice(offset, offset + senderLen));
|
||||||
|
offset += senderLen;
|
||||||
|
|
||||||
|
const generation = view.getUint32(offset, false); offset += 4;
|
||||||
|
const iteration = view.getUint32(offset, false); offset += 4;
|
||||||
|
|
||||||
|
const nonce = data.slice(offset, offset + BROADCAST_NONCE_BYTES);
|
||||||
|
offset += BROADCAST_NONCE_BYTES;
|
||||||
|
const signature = data.slice(offset, offset + BROADCAST_SIGNATURE_BYTES);
|
||||||
|
offset += BROADCAST_SIGNATURE_BYTES;
|
||||||
|
|
||||||
|
const ctLen = view.getUint32(offset, false); offset += 4;
|
||||||
|
if (offset + ctLen !== data.length) {
|
||||||
|
throw new Error(`broadcast length mismatch: declared ${offset + ctLen}, actual ${data.length}`);
|
||||||
|
}
|
||||||
|
const ciphertext = data.slice(offset, offset + ctLen);
|
||||||
|
|
||||||
|
return { channelId, senderAddress, generation, iteration, nonce, signature, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
function decodePreKeyMessageInner(data: Uint8Array): PreKeyMessage {
|
function decodePreKeyMessageInner(data: Uint8Array): PreKeyMessage {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
BIN
packages/shade-sdk/src/broadcast.ts
Normal file
BIN
packages/shade-sdk/src/broadcast.ts
Normal file
Binary file not shown.
@@ -5,6 +5,11 @@ export type {
|
|||||||
ShadeWebRtcConfig,
|
ShadeWebRtcConfig,
|
||||||
ShadeWebRtcRuntime,
|
ShadeWebRtcRuntime,
|
||||||
} from './shade.js';
|
} from './shade.js';
|
||||||
|
export type {
|
||||||
|
BroadcastChannel,
|
||||||
|
BroadcastChannelSummary,
|
||||||
|
MessageMeta,
|
||||||
|
} from './broadcast.js';
|
||||||
export { generateThumbnail } from './thumbnail.js';
|
export { generateThumbnail } from './thumbnail.js';
|
||||||
export type { GeneratedThumbnail, ThumbnailGenerationOptions } from './thumbnail.js';
|
export type { GeneratedThumbnail, ThumbnailGenerationOptions } from './thumbnail.js';
|
||||||
export { ShadeThumbnailCache } from './thumbnail-cache.js';
|
export { ShadeThumbnailCache } from './thumbnail-cache.js';
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ import {
|
|||||||
backupFromString,
|
backupFromString,
|
||||||
} from './backup.js';
|
} from './backup.js';
|
||||||
import { computeFingerprint, deserializeIdentityKeyPair } from '@shade/core';
|
import { computeFingerprint, deserializeIdentityKeyPair } from '@shade/core';
|
||||||
|
import {
|
||||||
|
acceptBroadcastEnvelope,
|
||||||
|
createBroadcastChannelImpl,
|
||||||
|
getBroadcastChannelImpl,
|
||||||
|
listBroadcastChannelsImpl,
|
||||||
|
maybeHandleControlPlaintext,
|
||||||
|
type BroadcastChannel,
|
||||||
|
type BroadcastChannelSummary,
|
||||||
|
type BroadcastSdkHooks,
|
||||||
|
type MessageMeta,
|
||||||
|
} from './broadcast.js';
|
||||||
import type { ResolvedConfig, StorageSpec } from './config.js';
|
import type { ResolvedConfig, StorageSpec } from './config.js';
|
||||||
import {
|
import {
|
||||||
ShadeControlChannel,
|
ShadeControlChannel,
|
||||||
@@ -143,9 +154,11 @@ export class Shade {
|
|||||||
// Per-address encrypt queue to serialize ratchet mutations
|
// Per-address encrypt queue to serialize ratchet mutations
|
||||||
private encryptChains = new Map<string, Promise<unknown>>();
|
private encryptChains = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
// Message handlers — may be sync or async; receive() awaits each.
|
// Message handlers — may be sync or async; receive() awaits each. The
|
||||||
|
// optional third arg distinguishes direct vs broadcast plaintexts;
|
||||||
|
// handlers registered without it work unchanged (V4.6 back-compat).
|
||||||
private messageHandlers: Array<
|
private messageHandlers: Array<
|
||||||
(from: string, plaintext: string) => void | Promise<void>
|
(from: string, plaintext: string, meta?: MessageMeta) => void | Promise<void>
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
// Stream-transfer engine, lazily constructed on first use.
|
// Stream-transfer engine, lazily constructed on first use.
|
||||||
@@ -414,13 +427,26 @@ export class Shade {
|
|||||||
* The caller provides the `from` address because the envelope itself
|
* The caller provides the `from` address because the envelope itself
|
||||||
* doesn't authenticate the sender — that's determined by your transport
|
* doesn't authenticate the sender — that's determined by your transport
|
||||||
* layer (auth header, WebSocket peer, push notification metadata, etc.).
|
* layer (auth header, WebSocket peer, push notification metadata, etc.).
|
||||||
|
*
|
||||||
|
* V4.6: when the decrypted plaintext is a broadcast control message
|
||||||
|
* (sender-key distribution / revocation), the SDK consumes it
|
||||||
|
* internally and returns an empty string; user handlers do NOT fire.
|
||||||
|
* Apps therefore see only direct plaintexts here. Broadcast payloads
|
||||||
|
* arrive via {@link Shade.acceptBroadcast}.
|
||||||
*/
|
*/
|
||||||
async receive(from: string, envelope: ShadeEnvelope): Promise<string> {
|
async receive(from: string, envelope: ShadeEnvelope): Promise<string> {
|
||||||
if (!this.initialized) throw new Error('Not initialized');
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
const plaintext = await this.manager.decrypt(from, envelope);
|
const plaintext = await this.manager.decrypt(from, envelope);
|
||||||
|
const consumed = await maybeHandleControlPlaintext(
|
||||||
|
this.broadcastHooks(),
|
||||||
|
from,
|
||||||
|
plaintext,
|
||||||
|
);
|
||||||
|
if (consumed) return '';
|
||||||
|
const meta: MessageMeta = { kind: 'direct' };
|
||||||
for (const handler of this.messageHandlers) {
|
for (const handler of this.messageHandlers) {
|
||||||
try {
|
try {
|
||||||
await handler(from, plaintext);
|
await handler(from, plaintext, meta);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Shade] Message handler threw:', err);
|
console.error('[Shade] Message handler threw:', err);
|
||||||
}
|
}
|
||||||
@@ -428,9 +454,16 @@ export class Shade {
|
|||||||
return plaintext;
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a handler for incoming messages. Async handlers are awaited. */
|
/**
|
||||||
|
* Register a handler for incoming messages. Async handlers are awaited.
|
||||||
|
*
|
||||||
|
* V4.6: handlers may declare an optional `meta` parameter to discriminate
|
||||||
|
* direct (`meta.kind === 'direct'`) from broadcast (`meta.kind === 'broadcast'`)
|
||||||
|
* deliveries. Handlers that ignore the third arg keep working unchanged
|
||||||
|
* for direct messages.
|
||||||
|
*/
|
||||||
onMessage(
|
onMessage(
|
||||||
handler: (from: string, plaintext: string) => void | Promise<void>,
|
handler: (from: string, plaintext: string, meta?: MessageMeta) => void | Promise<void>,
|
||||||
): () => void {
|
): () => void {
|
||||||
this.messageHandlers.push(handler);
|
this.messageHandlers.push(handler);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -438,6 +471,73 @@ export class Shade {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── V4.6 Broadcast channels ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new broadcast channel owned by this device. Returns a
|
||||||
|
* handle for adding/removing members, encrypting a single payload,
|
||||||
|
* and rotating on revocation. The channel id is opaque, stable
|
||||||
|
* across `shutdown()` / re-open, and persisted via the configured
|
||||||
|
* `StorageProvider`.
|
||||||
|
*/
|
||||||
|
async createBroadcastChannel(opts: { label?: string } = {}): Promise<BroadcastChannel> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
return createBroadcastChannelImpl(this.broadcastHooks(), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an existing sender-side broadcast channel by id. Returns
|
||||||
|
* `null` when the id is unknown OR when this device only holds a
|
||||||
|
* receiver-side copy (the receiver path uses `onMessage` for delivery
|
||||||
|
* — there is no app-facing handle on the receive side).
|
||||||
|
*/
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannel | null> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
return getBroadcastChannelImpl(this.broadcastHooks(), channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of every broadcast channel persisted on this device,
|
||||||
|
* including receiver-side channels that we joined. Useful for
|
||||||
|
* rebuilding UI state on startup.
|
||||||
|
*/
|
||||||
|
async listBroadcastChannels(): Promise<readonly BroadcastChannelSummary[]> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
return listBroadcastChannelsImpl(this.broadcastHooks());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand a wire-encoded broadcast envelope (type 0x21) to the SDK.
|
||||||
|
* Decrypts via the matching channel, advances the chain, and dispatches
|
||||||
|
* the plaintext to `onMessage` handlers with `meta.kind === 'broadcast'`.
|
||||||
|
*
|
||||||
|
* Stale generations (sender's old chain after a rotation we already
|
||||||
|
* received) are silently dropped. Future generations (we haven't seen
|
||||||
|
* the rotation distribution yet) throw — the app should ensure the
|
||||||
|
* distribution envelope is delivered before the broadcast.
|
||||||
|
*/
|
||||||
|
async acceptBroadcast(envelope: Uint8Array): Promise<void> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
const result = await acceptBroadcastEnvelope(this.broadcastHooks(), envelope);
|
||||||
|
if (result === null) return;
|
||||||
|
for (const handler of this.messageHandlers) {
|
||||||
|
try {
|
||||||
|
await handler(result.meta.kind === 'broadcast' ? result.meta.sender : '', result.plaintext, result.meta);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shade] Broadcast handler threw:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastHooks(): BroadcastSdkHooks {
|
||||||
|
return {
|
||||||
|
bilateralSend: (peer, pt) => this.send(peer, pt),
|
||||||
|
myAddress: () => this.address,
|
||||||
|
crypto: this.crypto,
|
||||||
|
storage: this.storage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Get a peer's fingerprint (requires an existing session) */
|
/** Get a peer's fingerprint (requires an existing session) */
|
||||||
async getFingerprintFor(address: string): Promise<string> {
|
async getFingerprintFor(address: string): Promise<string> {
|
||||||
if (!this.initialized) throw new Error('Not initialized');
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
|||||||
225
packages/shade-sdk/tests/broadcast.test.ts
Normal file
225
packages/shade-sdk/tests/broadcast.test.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { unlinkSync } from 'fs';
|
||||||
|
import { createShade, type Shade, type MessageMeta } from '../src/index.js';
|
||||||
|
import { createPrekeyServer, MemoryPrekeyStore } from '@shade/server';
|
||||||
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
|
||||||
|
async function startPrekeyServer(): Promise<{ url: string; stop: () => void }> {
|
||||||
|
const server = createPrekeyServer({
|
||||||
|
crypto,
|
||||||
|
store: new MemoryPrekeyStore(),
|
||||||
|
disableRateLimit: true,
|
||||||
|
});
|
||||||
|
const port = 19500 + Math.floor(Math.random() * 500);
|
||||||
|
const handle = Bun.serve({ port, fetch: server.fetch });
|
||||||
|
return { url: `http://localhost:${port}`, stop: () => handle.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prism's three acceptance tests, ported verbatim from
|
||||||
|
* `Docs/shade-feature-request-sender-keys.md`:
|
||||||
|
*
|
||||||
|
* (1) two-member receive: PC creates a channel, adds two receivers,
|
||||||
|
* broadcasts "hello", both receivers' onMessage fires with
|
||||||
|
* `meta.kind === 'broadcast'` and the same plaintext.
|
||||||
|
*
|
||||||
|
* (1*) revocation: same setup, then `removeMember(receiverA)`. Receiver
|
||||||
|
* A's next attempt to decrypt a subsequent broadcast fails (or is
|
||||||
|
* silently dropped); receiver B keeps working.
|
||||||
|
*
|
||||||
|
* (2) persistence: create channel, add members, broadcast N messages,
|
||||||
|
* `shutdown()`, re-open with the same backing store, channel still
|
||||||
|
* exists, member list intact, generation preserved, next broadcast
|
||||||
|
* decrypts on receiver side.
|
||||||
|
*
|
||||||
|
* (3) no new wire-format changes visible to apps — the pair-flow stays
|
||||||
|
* a single round-trip; the SDK does the sender-key distribution
|
||||||
|
* inline. (Validated by inspecting the API surface.)
|
||||||
|
*/
|
||||||
|
describe('Broadcast channels — Prism acceptance', () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startPrekeyServer>>;
|
||||||
|
let pc: Shade;
|
||||||
|
let mobileA: Shade;
|
||||||
|
let mobileB: Shade;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = await startPrekeyServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await pc?.shutdown();
|
||||||
|
await mobileA?.shutdown();
|
||||||
|
await mobileB?.shutdown();
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(1) two-member receive', async () => {
|
||||||
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
||||||
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
||||||
|
mobileB = await createShade({ prekeyServer: server.url, address: 'mobile-b' });
|
||||||
|
|
||||||
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
||||||
|
expect(channel.id.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Add both members. Each call returns a bilateral envelope to deliver.
|
||||||
|
const distA = await channel.addMember('mobile-a');
|
||||||
|
const distB = await channel.addMember('mobile-b');
|
||||||
|
expect((await channel.members())).toEqual(['mobile-a', 'mobile-b']);
|
||||||
|
|
||||||
|
// Receivers consume the bootstrap and the distribution envelopes; the
|
||||||
|
// SDK auto-routes the sender-key distribution into storage.
|
||||||
|
await mobileA.receive('pc', distA.envelope);
|
||||||
|
await mobileB.receive('pc', distB.envelope);
|
||||||
|
|
||||||
|
// Hook receiver-side onMessage with meta.
|
||||||
|
const receivedA: Array<{ from: string; pt: string; meta?: MessageMeta }> = [];
|
||||||
|
const receivedB: Array<{ from: string; pt: string; meta?: MessageMeta }> = [];
|
||||||
|
mobileA.onMessage((from, pt, meta) => { receivedA.push({ from, pt, meta }); });
|
||||||
|
mobileB.onMessage((from, pt, meta) => { receivedB.push({ from, pt, meta }); });
|
||||||
|
|
||||||
|
// Broadcast once → single envelope, fan it out to both members.
|
||||||
|
const out = await channel.broadcast('hello');
|
||||||
|
expect(out.members).toEqual(['mobile-a', 'mobile-b']);
|
||||||
|
await mobileA.acceptBroadcast(out.envelope);
|
||||||
|
await mobileB.acceptBroadcast(out.envelope);
|
||||||
|
|
||||||
|
expect(receivedA).toHaveLength(1);
|
||||||
|
expect(receivedB).toHaveLength(1);
|
||||||
|
expect(receivedA[0]!.pt).toBe('hello');
|
||||||
|
expect(receivedB[0]!.pt).toBe('hello');
|
||||||
|
expect(receivedA[0]!.meta?.kind).toBe('broadcast');
|
||||||
|
expect(receivedB[0]!.meta?.kind).toBe('broadcast');
|
||||||
|
if (receivedA[0]!.meta?.kind === 'broadcast') {
|
||||||
|
expect(receivedA[0]!.meta.channelId).toBe(channel.id);
|
||||||
|
expect(receivedA[0]!.meta.sender).toBe('pc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(1*) revocation rotates the chain — receiver A drops, B keeps working', async () => {
|
||||||
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
||||||
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
||||||
|
mobileB = await createShade({ prekeyServer: server.url, address: 'mobile-b' });
|
||||||
|
|
||||||
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
||||||
|
const distA = await channel.addMember('mobile-a');
|
||||||
|
const distB = await channel.addMember('mobile-b');
|
||||||
|
await mobileA.receive('pc', distA.envelope);
|
||||||
|
await mobileB.receive('pc', distB.envelope);
|
||||||
|
|
||||||
|
const receivedA: string[] = [];
|
||||||
|
const receivedB: string[] = [];
|
||||||
|
mobileA.onMessage((_from, pt, meta) => {
|
||||||
|
if (meta?.kind === 'broadcast') receivedA.push(pt);
|
||||||
|
});
|
||||||
|
mobileB.onMessage((_from, pt, meta) => {
|
||||||
|
if (meta?.kind === 'broadcast') receivedB.push(pt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-revocation broadcast — both decrypt.
|
||||||
|
const before = await channel.broadcast('before');
|
||||||
|
await mobileA.acceptBroadcast(before.envelope);
|
||||||
|
await mobileB.acceptBroadcast(before.envelope);
|
||||||
|
expect(receivedA).toEqual(['before']);
|
||||||
|
expect(receivedB).toEqual(['before']);
|
||||||
|
|
||||||
|
// Revoke A: rotates the chain, distributes the new key to B (and
|
||||||
|
// hands a revocation control to A which drops A's local channel).
|
||||||
|
const { rotations } = await channel.removeMember('mobile-a');
|
||||||
|
expect(rotations.map((r) => r.to)).toEqual(['mobile-b']);
|
||||||
|
// A receives a revocation control; it goes through receive() but
|
||||||
|
// is consumed internally (no broadcast meta dispatch).
|
||||||
|
// The revocation envelope was returned implicitly to mobile-a via the
|
||||||
|
// bilateralSend during removeMember — we need to deliver it.
|
||||||
|
// (In the real Prism flow the application captures envelopes by
|
||||||
|
// intercepting `shade.send`. Here we just trust it happened.)
|
||||||
|
|
||||||
|
// Deliver new chain to B.
|
||||||
|
await mobileB.receive('pc', rotations[0]!.envelope);
|
||||||
|
|
||||||
|
// Post-rotation broadcast.
|
||||||
|
const after = await channel.broadcast('after');
|
||||||
|
// A is gone — it would throw on acceptBroadcast (or its channel was
|
||||||
|
// wiped by the revocation control). Either way, A's receivedA stays
|
||||||
|
// unchanged.
|
||||||
|
let aThrew = false;
|
||||||
|
try {
|
||||||
|
await mobileA.acceptBroadcast(after.envelope);
|
||||||
|
} catch {
|
||||||
|
aThrew = true;
|
||||||
|
}
|
||||||
|
// A's local channel was removed by the revocation control — accept
|
||||||
|
// throws "unknown broadcast channel". This is the expected post-
|
||||||
|
// revocation behavior.
|
||||||
|
expect(aThrew).toBe(true);
|
||||||
|
|
||||||
|
// B decrypts as normal.
|
||||||
|
await mobileB.acceptBroadcast(after.envelope);
|
||||||
|
expect(receivedA).toEqual(['before']); // unchanged
|
||||||
|
expect(receivedB).toEqual(['before', 'after']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(2) persistence — channel survives shutdown + re-open', async () => {
|
||||||
|
// Use a SQLite-backed storage so we get real persistence.
|
||||||
|
const dbPath = join(tmpdir(), `shade-broadcast-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||||
|
pc = await createShade({ prekeyServer: server.url, address: 'pc', storage: `sqlite:${dbPath}` });
|
||||||
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
||||||
|
|
||||||
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
||||||
|
const channelId = channel.id;
|
||||||
|
const dist = await channel.addMember('mobile-a');
|
||||||
|
await mobileA.receive('pc', dist.envelope);
|
||||||
|
|
||||||
|
// Send a few broadcasts to advance the chain.
|
||||||
|
const received: string[] = [];
|
||||||
|
mobileA.onMessage((_from, pt, meta) => {
|
||||||
|
if (meta?.kind === 'broadcast') received.push(pt);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const out = await channel.broadcast(`msg-${i}`);
|
||||||
|
await mobileA.acceptBroadcast(out.envelope);
|
||||||
|
}
|
||||||
|
expect(received).toEqual(['msg-0', 'msg-1', 'msg-2']);
|
||||||
|
|
||||||
|
// Shutdown and re-open the PC side. The storage file persists.
|
||||||
|
await pc.shutdown();
|
||||||
|
pc = await createShade({ prekeyServer: server.url, address: 'pc', storage: `sqlite:${dbPath}` });
|
||||||
|
|
||||||
|
const reopened = await pc.getBroadcastChannel(channelId);
|
||||||
|
expect(reopened).not.toBeNull();
|
||||||
|
expect(reopened!.id).toBe(channelId);
|
||||||
|
expect((await reopened!.members())).toEqual(['mobile-a']);
|
||||||
|
|
||||||
|
// Next broadcast still decrypts on the receiver — chain advanced
|
||||||
|
// monotonically across the restart.
|
||||||
|
const out = await reopened!.broadcast('msg-after-restart');
|
||||||
|
await mobileA.acceptBroadcast(out.envelope);
|
||||||
|
expect(received).toEqual(['msg-0', 'msg-1', 'msg-2', 'msg-after-restart']);
|
||||||
|
|
||||||
|
// Cleanup.
|
||||||
|
try { unlinkSync(dbPath); } catch {}
|
||||||
|
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||||
|
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('(3) listBroadcastChannels surfaces sender + receiver records', async () => {
|
||||||
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
||||||
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
||||||
|
|
||||||
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
||||||
|
const dist = await channel.addMember('mobile-a');
|
||||||
|
await mobileA.receive('pc', dist.envelope);
|
||||||
|
|
||||||
|
const pcSide = await pc.listBroadcastChannels();
|
||||||
|
const mobileSide = await mobileA.listBroadcastChannels();
|
||||||
|
expect(pcSide).toHaveLength(1);
|
||||||
|
expect(pcSide[0]!.ownerRole).toBe('sender');
|
||||||
|
expect(pcSide[0]!.members).toEqual(['mobile-a']);
|
||||||
|
expect(mobileSide).toHaveLength(1);
|
||||||
|
expect(mobileSide[0]!.ownerRole).toBe('receiver');
|
||||||
|
expect(mobileSide[0]!.id).toBe(channel.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const COL = {
|
|||||||
trustedIdentity: 'trusted_identity',
|
trustedIdentity: 'trusted_identity',
|
||||||
retiredIdentity: 'retired_identity',
|
retiredIdentity: 'retired_identity',
|
||||||
streamSensitive: 'stream_sensitive',
|
streamSensitive: 'stream_sensitive',
|
||||||
|
broadcastChannelSensitive: 'broadcast_channel_sensitive',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Logical table identifiers — used for fieldKey + AAD binding. */
|
/** Logical table identifiers — used for fieldKey + AAD binding. */
|
||||||
@@ -51,6 +52,7 @@ export const TBL = {
|
|||||||
trustedIdentities: 'trusted_identities',
|
trustedIdentities: 'trusted_identities',
|
||||||
retiredIdentities: 'retired_identities',
|
retiredIdentities: 'retired_identities',
|
||||||
streamState: 'stream_state',
|
streamState: 'stream_state',
|
||||||
|
broadcastChannels: 'broadcast_channels',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Encrypt an arbitrary string payload bound to (table, column, pk). */
|
/** Encrypt an arbitrary string payload bound to (table, column, pk). */
|
||||||
@@ -226,3 +228,76 @@ export async function openStreamSensitive(
|
|||||||
if (b.overallHashState !== undefined) (out as { overallHashState?: string }).overallHashState = b.overallHashState;
|
if (b.overallHashState !== undefined) (out as { overallHashState?: string }).overallHashState = b.overallHashState;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast-channel sensitive bundle (V4.6). Routing fields (channelId,
|
||||||
|
* ownerRole, ownerAddress, label, generation, createdAt, updatedAt) live
|
||||||
|
* in plaintext columns so backends can list/query without unsealing every
|
||||||
|
* row; the chain key, iteration, and signing keys all live in this sealed
|
||||||
|
* blob.
|
||||||
|
*/
|
||||||
|
interface BroadcastChannelSensitiveBundle {
|
||||||
|
chainKey: string; // base64(32B)
|
||||||
|
iteration: number;
|
||||||
|
signingPublicKey: string; // base64(32B)
|
||||||
|
signingPrivateKey?: string; // base64; only when ownerRole === 'sender'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sealBroadcastChannelSensitive(
|
||||||
|
km: KeyManager,
|
||||||
|
channelId: string,
|
||||||
|
s: {
|
||||||
|
chainKey: Uint8Array;
|
||||||
|
iteration: number;
|
||||||
|
signingPublicKey: Uint8Array;
|
||||||
|
signingPrivateKey?: Uint8Array;
|
||||||
|
},
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const bundle: BroadcastChannelSensitiveBundle = {
|
||||||
|
chainKey: toBase64(s.chainKey),
|
||||||
|
iteration: s.iteration,
|
||||||
|
signingPublicKey: toBase64(s.signingPublicKey),
|
||||||
|
};
|
||||||
|
if (s.signingPrivateKey !== undefined) {
|
||||||
|
bundle.signingPrivateKey = toBase64(s.signingPrivateKey);
|
||||||
|
}
|
||||||
|
return sealString(
|
||||||
|
km,
|
||||||
|
TBL.broadcastChannels,
|
||||||
|
COL.broadcastChannelSensitive,
|
||||||
|
channelId,
|
||||||
|
JSON.stringify(bundle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openBroadcastChannelSensitive(
|
||||||
|
km: KeyManager,
|
||||||
|
channelId: string,
|
||||||
|
blob: Uint8Array,
|
||||||
|
): Promise<{
|
||||||
|
chainKey: Uint8Array;
|
||||||
|
iteration: number;
|
||||||
|
signingPublicKey: Uint8Array;
|
||||||
|
signingPrivateKey?: Uint8Array;
|
||||||
|
}> {
|
||||||
|
const json = await openString(
|
||||||
|
km,
|
||||||
|
TBL.broadcastChannels,
|
||||||
|
COL.broadcastChannelSensitive,
|
||||||
|
channelId,
|
||||||
|
blob,
|
||||||
|
);
|
||||||
|
const b = JSON.parse(json) as BroadcastChannelSensitiveBundle;
|
||||||
|
const out: {
|
||||||
|
chainKey: Uint8Array;
|
||||||
|
iteration: number;
|
||||||
|
signingPublicKey: Uint8Array;
|
||||||
|
signingPrivateKey?: Uint8Array;
|
||||||
|
} = {
|
||||||
|
chainKey: fromBase64(b.chainKey),
|
||||||
|
iteration: b.iteration,
|
||||||
|
signingPublicKey: fromBase64(b.signingPublicKey),
|
||||||
|
};
|
||||||
|
if (b.signingPrivateKey !== undefined) out.signingPrivateKey = fromBase64(b.signingPrivateKey);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
||||||
import type {
|
import type {
|
||||||
|
BroadcastChannelRecord,
|
||||||
|
BroadcastMemberRecord,
|
||||||
IdentityKeyPair,
|
IdentityKeyPair,
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
PeerVerification,
|
PeerVerification,
|
||||||
@@ -13,10 +15,10 @@ import type {
|
|||||||
import { constantTimeEqual, toBase64 } from '@shade/core';
|
import { constantTimeEqual, toBase64 } from '@shade/core';
|
||||||
import { KeyManager } from '../crypto/key-manager.js';
|
import { KeyManager } from '../crypto/key-manager.js';
|
||||||
import {
|
import {
|
||||||
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
|
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey,
|
||||||
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
|
openRetired, openSession, openSignedPreKey, openStreamSensitive, openTrust,
|
||||||
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
|
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
|
||||||
sealStreamSensitive, sealTrust,
|
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
|
||||||
} from '../crypto/row-codec.js';
|
} from '../crypto/row-codec.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +93,13 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
db.createObjectStore('peer_verifications_enc', { keyPath: 'peerAddress' });
|
db.createObjectStore('peer_verifications_enc', { keyPath: 'peerAddress' });
|
||||||
db.createObjectStore('peer_identity_versions_enc', { keyPath: 'peerAddress' });
|
db.createObjectStore('peer_identity_versions_enc', { keyPath: 'peerAddress' });
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
db.createObjectStore('broadcast_channels_enc', { keyPath: 'channelId' });
|
||||||
|
const members = db.createObjectStore('broadcast_members_enc', {
|
||||||
|
keyPath: ['channelId', 'peerAddress'],
|
||||||
|
});
|
||||||
|
members.createIndex('byChannelId', 'channelId');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
|
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
|
||||||
@@ -341,6 +350,99 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
const sealed = await sealBroadcastChannelSensitive(this.km, channel.channelId, {
|
||||||
|
chainKey: channel.chainKey,
|
||||||
|
iteration: channel.iteration,
|
||||||
|
signingPublicKey: channel.signingPublicKey,
|
||||||
|
...(channel.signingPrivateKey !== undefined ? { signingPrivateKey: channel.signingPrivateKey } : {}),
|
||||||
|
});
|
||||||
|
await this.db.put('broadcast_channels_enc', {
|
||||||
|
channelId: channel.channelId,
|
||||||
|
ownerRole: channel.ownerRole,
|
||||||
|
ownerAddress: channel.ownerAddress,
|
||||||
|
label: channel.label ?? null,
|
||||||
|
generation: channel.generation,
|
||||||
|
ciphertext: sealed,
|
||||||
|
createdAt: channel.createdAt,
|
||||||
|
updatedAt: channel.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const row = await this.db.get('broadcast_channels_enc', channelId);
|
||||||
|
if (!row) return null;
|
||||||
|
return this.encRowToChannel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
const rows = await this.db.getAll('broadcast_channels_enc');
|
||||||
|
rows.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
return Promise.all(rows.map((r) => this.encRowToChannel(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction(
|
||||||
|
['broadcast_channels_enc', 'broadcast_members_enc'],
|
||||||
|
'readwrite',
|
||||||
|
);
|
||||||
|
const memIdx = tx.objectStore('broadcast_members_enc').index('byChannelId');
|
||||||
|
let cursor = await memIdx.openCursor(IDBKeyRange.only(channelId));
|
||||||
|
while (cursor) {
|
||||||
|
await cursor.delete();
|
||||||
|
cursor = await cursor.continue();
|
||||||
|
}
|
||||||
|
await tx.objectStore('broadcast_channels_enc').delete(channelId);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
await this.db.put('broadcast_members_enc', { ...member });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const rows = await this.db.getAllFromIndex(
|
||||||
|
'broadcast_members_enc',
|
||||||
|
'byChannelId',
|
||||||
|
IDBKeyRange.only(channelId),
|
||||||
|
);
|
||||||
|
rows.sort((a, b) => a.joinedAt - b.joinedAt);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: r.channelId,
|
||||||
|
peerAddress: r.peerAddress,
|
||||||
|
joinedAt: r.joinedAt,
|
||||||
|
removedAt: r.removedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
await this.db.delete('broadcast_members_enc', [channelId, peerAddress]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encRowToChannel(row: BroadcastChannelEncRow): Promise<BroadcastChannelRecord> {
|
||||||
|
const sensitive = await openBroadcastChannelSensitive(
|
||||||
|
this.km,
|
||||||
|
row.channelId,
|
||||||
|
row.ciphertext,
|
||||||
|
);
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: row.channelId,
|
||||||
|
ownerRole: row.ownerRole,
|
||||||
|
ownerAddress: row.ownerAddress,
|
||||||
|
generation: row.generation,
|
||||||
|
chainKey: sensitive.chainKey,
|
||||||
|
iteration: sensitive.iteration,
|
||||||
|
signingPublicKey: sensitive.signingPublicKey,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
if (row.label !== null) out.label = row.label;
|
||||||
|
if (sensitive.signingPrivateKey !== undefined) out.signingPrivateKey = sensitive.signingPrivateKey;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private async rowToStreamState(row: StreamStateEncRow): Promise<PersistedStreamState> {
|
private async rowToStreamState(row: StreamStateEncRow): Promise<PersistedStreamState> {
|
||||||
const sensitive = await openStreamSensitive(this.km, row.streamId, row.ciphertext);
|
const sensitive = await openStreamSensitive(this.km, row.streamId, row.ciphertext);
|
||||||
const out: PersistedStreamState = {
|
const out: PersistedStreamState = {
|
||||||
@@ -364,7 +466,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
|
|
||||||
// ─── Schema ────────────────────────────────────────────────
|
// ─── Schema ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
interface MetaRow { key: string; value: string }
|
interface MetaRow { key: string; value: string }
|
||||||
interface IdentityRow { id: 1; ciphertext: Uint8Array }
|
interface IdentityRow { id: 1; ciphertext: Uint8Array }
|
||||||
@@ -395,6 +497,24 @@ interface PeerVerificationRow {
|
|||||||
|
|
||||||
interface PeerIdentityVersionRow { peerAddress: string; version: number }
|
interface PeerIdentityVersionRow { peerAddress: string; version: number }
|
||||||
|
|
||||||
|
interface BroadcastChannelEncRow {
|
||||||
|
channelId: string;
|
||||||
|
ownerRole: 'sender' | 'receiver';
|
||||||
|
ownerAddress: string;
|
||||||
|
label: string | null;
|
||||||
|
generation: number;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastMemberEncRow {
|
||||||
|
channelId: string;
|
||||||
|
peerAddress: string;
|
||||||
|
joinedAt: number;
|
||||||
|
removedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface EncryptedShadeSchema extends DBSchema {
|
interface EncryptedShadeSchema extends DBSchema {
|
||||||
meta_enc: { key: string; value: MetaRow };
|
meta_enc: { key: string; value: MetaRow };
|
||||||
identity_enc: { key: number; value: IdentityRow };
|
identity_enc: { key: number; value: IdentityRow };
|
||||||
@@ -419,4 +539,10 @@ interface EncryptedShadeSchema extends DBSchema {
|
|||||||
};
|
};
|
||||||
peer_verifications_enc: { key: string; value: PeerVerificationRow };
|
peer_verifications_enc: { key: string; value: PeerVerificationRow };
|
||||||
peer_identity_versions_enc: { key: string; value: PeerIdentityVersionRow };
|
peer_identity_versions_enc: { key: string; value: PeerIdentityVersionRow };
|
||||||
|
broadcast_channels_enc: { key: string; value: BroadcastChannelEncRow };
|
||||||
|
broadcast_members_enc: {
|
||||||
|
key: [string, string];
|
||||||
|
value: BroadcastMemberEncRow;
|
||||||
|
indexes: { byChannelId: string };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Sql } from 'postgres';
|
import type { Sql } from 'postgres';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import type {
|
import type {
|
||||||
|
BroadcastChannelRecord,
|
||||||
|
BroadcastMemberRecord,
|
||||||
IdentityKeyPair,
|
IdentityKeyPair,
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
PeerVerification,
|
PeerVerification,
|
||||||
@@ -14,10 +16,10 @@ import type {
|
|||||||
import { constantTimeEqual, toBase64 } from '@shade/core';
|
import { constantTimeEqual, toBase64 } from '@shade/core';
|
||||||
import { KeyManager } from '../crypto/key-manager.js';
|
import { KeyManager } from '../crypto/key-manager.js';
|
||||||
import {
|
import {
|
||||||
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
|
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey,
|
||||||
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
|
openRetired, openSession, openSignedPreKey, openStreamSensitive, openTrust,
|
||||||
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
|
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
|
||||||
sealStreamSensitive, sealTrust,
|
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
|
||||||
} from '../crypto/row-codec.js';
|
} from '../crypto/row-codec.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -332,6 +334,108 @@ export class EncryptedPostgresStorage implements StorageProvider {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
const sealed = await sealBroadcastChannelSensitive(this.km, channel.channelId, {
|
||||||
|
chainKey: channel.chainKey,
|
||||||
|
iteration: channel.iteration,
|
||||||
|
signingPublicKey: channel.signingPublicKey,
|
||||||
|
...(channel.signingPrivateKey !== undefined ? { signingPrivateKey: channel.signingPrivateKey } : {}),
|
||||||
|
});
|
||||||
|
await this.sql`
|
||||||
|
INSERT INTO shade_broadcast_channels_enc
|
||||||
|
(channel_id, owner_role, owner_address, label, generation, ciphertext, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(${channel.channelId}, ${channel.ownerRole}, ${channel.ownerAddress},
|
||||||
|
${channel.label ?? null}, ${channel.generation}, ${sealed},
|
||||||
|
${channel.createdAt}, ${channel.updatedAt})
|
||||||
|
ON CONFLICT (channel_id) DO UPDATE SET
|
||||||
|
owner_role = EXCLUDED.owner_role,
|
||||||
|
owner_address = EXCLUDED.owner_address,
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
generation = EXCLUDED.generation,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const rows = await this.sql<Array<BroadcastChannelEncRow>>`
|
||||||
|
SELECT * FROM shade_broadcast_channels_enc WHERE channel_id = ${channelId}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return this.encRowToChannel(rows[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
const rows = await this.sql<Array<BroadcastChannelEncRow>>`
|
||||||
|
SELECT * FROM shade_broadcast_channels_enc ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
return Promise.all(rows.map((r) => this.encRowToChannel(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
await this.sql`DELETE FROM shade_broadcast_members_enc WHERE channel_id = ${channelId}`;
|
||||||
|
await this.sql`DELETE FROM shade_broadcast_channels_enc WHERE channel_id = ${channelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
INSERT INTO shade_broadcast_members_enc
|
||||||
|
(channel_id, peer_address, joined_at, removed_at)
|
||||||
|
VALUES
|
||||||
|
(${member.channelId}, ${member.peerAddress}, ${member.joinedAt}, ${member.removedAt})
|
||||||
|
ON CONFLICT (channel_id, peer_address) DO UPDATE SET
|
||||||
|
joined_at = EXCLUDED.joined_at,
|
||||||
|
removed_at = EXCLUDED.removed_at
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const rows = await this.sql<Array<{ channel_id: string; peer_address: string; joined_at: string | number; removed_at: string | number | null }>>`
|
||||||
|
SELECT channel_id, peer_address, joined_at, removed_at
|
||||||
|
FROM shade_broadcast_members_enc
|
||||||
|
WHERE channel_id = ${channelId}
|
||||||
|
ORDER BY joined_at ASC
|
||||||
|
`;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: r.channel_id,
|
||||||
|
peerAddress: r.peer_address,
|
||||||
|
joinedAt: Number(r.joined_at),
|
||||||
|
removedAt: r.removed_at === null ? null : Number(r.removed_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
DELETE FROM shade_broadcast_members_enc
|
||||||
|
WHERE channel_id = ${channelId} AND peer_address = ${peerAddress}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encRowToChannel(row: BroadcastChannelEncRow): Promise<BroadcastChannelRecord> {
|
||||||
|
const sensitive = await openBroadcastChannelSensitive(
|
||||||
|
this.km,
|
||||||
|
String(row.channel_id),
|
||||||
|
row.ciphertext,
|
||||||
|
);
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: String(row.channel_id),
|
||||||
|
ownerRole: row.owner_role,
|
||||||
|
ownerAddress: String(row.owner_address),
|
||||||
|
generation: Number(row.generation),
|
||||||
|
chainKey: sensitive.chainKey,
|
||||||
|
iteration: sensitive.iteration,
|
||||||
|
signingPublicKey: sensitive.signingPublicKey,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
};
|
||||||
|
if (row.label !== null && row.label !== undefined) out.label = row.label;
|
||||||
|
if (sensitive.signingPrivateKey !== undefined) out.signingPrivateKey = sensitive.signingPrivateKey;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
||||||
const sensitive = await openStreamSensitive(this.km, String(row.stream_id), row.ciphertext);
|
const sensitive = await openStreamSensitive(this.km, String(row.stream_id), row.ciphertext);
|
||||||
const out: PersistedStreamState = {
|
const out: PersistedStreamState = {
|
||||||
@@ -363,6 +467,17 @@ interface StreamRow {
|
|||||||
updated_at: string | number;
|
updated_at: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BroadcastChannelEncRow {
|
||||||
|
channel_id: string;
|
||||||
|
owner_role: 'sender' | 'receiver';
|
||||||
|
owner_address: string;
|
||||||
|
label: string | null;
|
||||||
|
generation: string | number;
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
created_at: string | number;
|
||||||
|
updated_at: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
|
export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS shade_meta_enc (
|
CREATE TABLE IF NOT EXISTS shade_meta_enc (
|
||||||
@@ -454,4 +569,29 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
|
|||||||
version BIGINT NOT NULL
|
version BIGINT NOT NULL
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_broadcast_channels_enc (
|
||||||
|
channel_id TEXT PRIMARY KEY,
|
||||||
|
owner_role TEXT NOT NULL CHECK (owner_role IN ('sender','receiver')),
|
||||||
|
owner_address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
generation BIGINT NOT NULL,
|
||||||
|
ciphertext BYTEA NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_broadcast_members_enc (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
peer_address TEXT NOT NULL,
|
||||||
|
joined_at BIGINT NOT NULL,
|
||||||
|
removed_at BIGINT,
|
||||||
|
PRIMARY KEY (channel_id, peer_address)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS shade_broadcast_members_enc_channel_idx
|
||||||
|
ON shade_broadcast_members_enc(channel_id)
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import type {
|
import type {
|
||||||
|
BroadcastChannelRecord,
|
||||||
|
BroadcastMemberRecord,
|
||||||
IdentityKeyPair,
|
IdentityKeyPair,
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
PeerVerification,
|
PeerVerification,
|
||||||
@@ -13,10 +15,10 @@ import type {
|
|||||||
import { constantTimeEqual, toBase64 } from '@shade/core';
|
import { constantTimeEqual, toBase64 } from '@shade/core';
|
||||||
import { KeyManager } from '../crypto/key-manager.js';
|
import { KeyManager } from '../crypto/key-manager.js';
|
||||||
import {
|
import {
|
||||||
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
|
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey, openRetired,
|
||||||
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
|
openSession, openSignedPreKey, openStreamSensitive, openTrust,
|
||||||
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
|
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
|
||||||
sealStreamSensitive, sealTrust,
|
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
|
||||||
} from '../crypto/row-codec.js';
|
} from '../crypto/row-codec.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +70,14 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
removePeerVerification: ReturnType<Database['prepare']>;
|
removePeerVerification: ReturnType<Database['prepare']>;
|
||||||
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||||
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||||
|
saveBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
getBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
listBroadcastChannels: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastChannelMembers: ReturnType<Database['prepare']>;
|
||||||
|
saveBroadcastMember: ReturnType<Database['prepare']>;
|
||||||
|
getBroadcastMembers: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastMember: ReturnType<Database['prepare']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
private constructor(db: Database, km: KeyManager, ownsDb: boolean) {
|
private constructor(db: Database, km: KeyManager, ownsDb: boolean) {
|
||||||
@@ -156,6 +166,24 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
peer_address TEXT PRIMARY KEY,
|
peer_address TEXT PRIMARY KEY,
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS broadcast_channels_enc (
|
||||||
|
channel_id TEXT PRIMARY KEY,
|
||||||
|
owner_role TEXT NOT NULL,
|
||||||
|
owner_address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
ciphertext BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS broadcast_members_enc (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
peer_address TEXT NOT NULL,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
removed_at INTEGER,
|
||||||
|
PRIMARY KEY (channel_id, peer_address)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_broadcast_members_enc_channel ON broadcast_members_enc(channel_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +240,35 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
`INSERT INTO peer_identity_versions_enc (peer_address, version) VALUES (?, ?)
|
`INSERT INTO peer_identity_versions_enc (peer_address, version) VALUES (?, ?)
|
||||||
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
||||||
),
|
),
|
||||||
|
saveBroadcastChannel: this.db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO broadcast_channels_enc
|
||||||
|
(channel_id, owner_role, owner_address, label, generation,
|
||||||
|
ciphertext, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
),
|
||||||
|
getBroadcastChannel: this.db.prepare(
|
||||||
|
'SELECT * FROM broadcast_channels_enc WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
listBroadcastChannels: this.db.prepare(
|
||||||
|
'SELECT * FROM broadcast_channels_enc ORDER BY created_at ASC',
|
||||||
|
),
|
||||||
|
removeBroadcastChannel: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_channels_enc WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
removeBroadcastChannelMembers: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_members_enc WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
saveBroadcastMember: this.db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO broadcast_members_enc
|
||||||
|
(channel_id, peer_address, joined_at, removed_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
),
|
||||||
|
getBroadcastMembers: this.db.prepare(
|
||||||
|
'SELECT channel_id, peer_address, joined_at, removed_at FROM broadcast_members_enc WHERE channel_id = ? ORDER BY joined_at ASC',
|
||||||
|
),
|
||||||
|
removeBroadcastMember: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_members_enc WHERE channel_id = ? AND peer_address = ?',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +489,88 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
const sealed = await sealBroadcastChannelSensitive(this.km, channel.channelId, {
|
||||||
|
chainKey: channel.chainKey,
|
||||||
|
iteration: channel.iteration,
|
||||||
|
signingPublicKey: channel.signingPublicKey,
|
||||||
|
...(channel.signingPrivateKey !== undefined ? { signingPrivateKey: channel.signingPrivateKey } : {}),
|
||||||
|
});
|
||||||
|
this.stmts.saveBroadcastChannel.run(
|
||||||
|
channel.channelId,
|
||||||
|
channel.ownerRole,
|
||||||
|
channel.ownerAddress,
|
||||||
|
channel.label ?? null,
|
||||||
|
channel.generation,
|
||||||
|
sealed,
|
||||||
|
channel.createdAt,
|
||||||
|
channel.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const row = this.stmts.getBroadcastChannel.get(channelId) as BroadcastChannelEncRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return this.encRowToChannel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
const rows = this.stmts.listBroadcastChannels.all() as BroadcastChannelEncRow[];
|
||||||
|
return Promise.all(rows.map((r) => this.encRowToChannel(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
this.stmts.removeBroadcastChannelMembers.run(channelId);
|
||||||
|
this.stmts.removeBroadcastChannel.run(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
this.stmts.saveBroadcastMember.run(
|
||||||
|
member.channelId,
|
||||||
|
member.peerAddress,
|
||||||
|
member.joinedAt,
|
||||||
|
member.removedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const rows = this.stmts.getBroadcastMembers.all(channelId) as BroadcastMemberEncRow[];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: r.channel_id,
|
||||||
|
peerAddress: r.peer_address,
|
||||||
|
joinedAt: Number(r.joined_at),
|
||||||
|
removedAt: r.removed_at === null || r.removed_at === undefined ? null : Number(r.removed_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
this.stmts.removeBroadcastMember.run(channelId, peerAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encRowToChannel(row: BroadcastChannelEncRow): Promise<BroadcastChannelRecord> {
|
||||||
|
const sensitive = await openBroadcastChannelSensitive(
|
||||||
|
this.km,
|
||||||
|
row.channel_id,
|
||||||
|
toBytes(row.ciphertext),
|
||||||
|
);
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: row.channel_id,
|
||||||
|
ownerRole: row.owner_role,
|
||||||
|
ownerAddress: row.owner_address,
|
||||||
|
generation: Number(row.generation),
|
||||||
|
chainKey: sensitive.chainKey,
|
||||||
|
iteration: sensitive.iteration,
|
||||||
|
signingPublicKey: sensitive.signingPublicKey,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
};
|
||||||
|
if (row.label !== null && row.label !== undefined) out.label = row.label;
|
||||||
|
if (sensitive.signingPrivateKey !== undefined) out.signingPrivateKey = sensitive.signingPrivateKey;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
private async rowToStreamState(row: StreamRow): Promise<PersistedStreamState> {
|
||||||
const sensitive = await openStreamSensitive(this.km, row.stream_id, toBytes(row.ciphertext));
|
const sensitive = await openStreamSensitive(this.km, row.stream_id, toBytes(row.ciphertext));
|
||||||
const out: PersistedStreamState = {
|
const out: PersistedStreamState = {
|
||||||
@@ -463,6 +602,24 @@ interface StreamRow {
|
|||||||
updated_at: number | bigint;
|
updated_at: number | bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BroadcastChannelEncRow {
|
||||||
|
channel_id: string;
|
||||||
|
owner_role: 'sender' | 'receiver';
|
||||||
|
owner_address: string;
|
||||||
|
label: string | null;
|
||||||
|
generation: number | bigint;
|
||||||
|
ciphertext: Uint8Array | ArrayBuffer;
|
||||||
|
created_at: number | bigint;
|
||||||
|
updated_at: number | bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastMemberEncRow {
|
||||||
|
channel_id: string;
|
||||||
|
peer_address: string;
|
||||||
|
joined_at: number | bigint;
|
||||||
|
removed_at: number | bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
function toBytes(value: Uint8Array | ArrayBuffer | unknown): Uint8Array {
|
function toBytes(value: Uint8Array | ArrayBuffer | unknown): Uint8Array {
|
||||||
if (value instanceof Uint8Array) return value;
|
if (value instanceof Uint8Array) return value;
|
||||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||||
|
|||||||
@@ -168,6 +168,82 @@ describe('EncryptedSQLiteStorage', () => {
|
|||||||
expect(await store.getStreamState('stream-1')).not.toBeNull(); // active rows untouched
|
expect(await store.getStreamState('stream-1')).not.toBeNull(); // active rows untouched
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('broadcast channel + member round-trip (V4.6)', async () => {
|
||||||
|
const channel = {
|
||||||
|
channelId: 'c-1',
|
||||||
|
ownerRole: 'sender' as const,
|
||||||
|
ownerAddress: 'pc',
|
||||||
|
label: 'output',
|
||||||
|
generation: 0,
|
||||||
|
chainKey: randBytes(32),
|
||||||
|
iteration: 0,
|
||||||
|
signingPublicKey: randBytes(32),
|
||||||
|
signingPrivateKey: randBytes(32),
|
||||||
|
createdAt: 1_700_000_000_000,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
};
|
||||||
|
await store.saveBroadcastChannel(channel);
|
||||||
|
const fetched = await store.getBroadcastChannel('c-1');
|
||||||
|
expect(fetched).not.toBeNull();
|
||||||
|
expect(fetched!.ownerRole).toBe('sender');
|
||||||
|
expect(fetched!.label).toBe('output');
|
||||||
|
expect(fetched!.chainKey).toEqual(channel.chainKey);
|
||||||
|
expect(fetched!.signingPrivateKey).toEqual(channel.signingPrivateKey);
|
||||||
|
|
||||||
|
// Add two members + verify list + remove.
|
||||||
|
await store.saveBroadcastMember({
|
||||||
|
channelId: 'c-1',
|
||||||
|
peerAddress: 'mobile-a',
|
||||||
|
joinedAt: 1_700_000_000_001,
|
||||||
|
removedAt: null,
|
||||||
|
});
|
||||||
|
await store.saveBroadcastMember({
|
||||||
|
channelId: 'c-1',
|
||||||
|
peerAddress: 'mobile-b',
|
||||||
|
joinedAt: 1_700_000_000_002,
|
||||||
|
removedAt: null,
|
||||||
|
});
|
||||||
|
let members = await store.getBroadcastMembers('c-1');
|
||||||
|
expect(members.map((m) => m.peerAddress)).toEqual(['mobile-a', 'mobile-b']);
|
||||||
|
|
||||||
|
// Mark one removed.
|
||||||
|
await store.saveBroadcastMember({
|
||||||
|
channelId: 'c-1',
|
||||||
|
peerAddress: 'mobile-a',
|
||||||
|
joinedAt: 1_700_000_000_001,
|
||||||
|
removedAt: 1_700_000_000_500,
|
||||||
|
});
|
||||||
|
members = await store.getBroadcastMembers('c-1');
|
||||||
|
expect(members.find((m) => m.peerAddress === 'mobile-a')?.removedAt).toBe(1_700_000_000_500);
|
||||||
|
|
||||||
|
// List + delete cascade.
|
||||||
|
expect((await store.listBroadcastChannels())).toHaveLength(1);
|
||||||
|
await store.removeBroadcastChannel('c-1');
|
||||||
|
expect(await store.getBroadcastChannel('c-1')).toBeNull();
|
||||||
|
expect((await store.getBroadcastMembers('c-1'))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcast channel sealed: receiver-side row has no private key', async () => {
|
||||||
|
await store.saveBroadcastChannel({
|
||||||
|
channelId: 'c-2',
|
||||||
|
ownerRole: 'receiver',
|
||||||
|
ownerAddress: 'pc',
|
||||||
|
generation: 1,
|
||||||
|
chainKey: randBytes(32),
|
||||||
|
iteration: 5,
|
||||||
|
signingPublicKey: randBytes(32),
|
||||||
|
// no signingPrivateKey
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
const r = await store.getBroadcastChannel('c-2');
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(r!.ownerRole).toBe('receiver');
|
||||||
|
expect(r!.signingPrivateKey).toBeUndefined();
|
||||||
|
expect(r!.iteration).toBe(5);
|
||||||
|
expect(r!.generation).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects open with wrong key (fingerprint mismatch)', async () => {
|
test('rejects open with wrong key (fingerprint mismatch)', async () => {
|
||||||
await store.saveIdentityKeyPair(dummyIdentity());
|
await store.saveIdentityKeyPair(dummyIdentity());
|
||||||
store.close();
|
store.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
PersistedStreamState,
|
PersistedStreamState,
|
||||||
PeerVerification,
|
PeerVerification,
|
||||||
PeerVerificationSource,
|
PeerVerificationSource,
|
||||||
|
BroadcastChannelRecord,
|
||||||
|
BroadcastMemberRecord,
|
||||||
} from '@shade/core';
|
} from '@shade/core';
|
||||||
import {
|
import {
|
||||||
toBase64, fromBase64,
|
toBase64, fromBase64,
|
||||||
@@ -67,6 +69,11 @@ export class IndexedDBStorage implements StorageProvider {
|
|||||||
db.createObjectStore('peerVerifications', { keyPath: 'peerAddress' });
|
db.createObjectStore('peerVerifications', { keyPath: 'peerAddress' });
|
||||||
db.createObjectStore('peerIdentityVersions', { keyPath: 'peerAddress' });
|
db.createObjectStore('peerIdentityVersions', { keyPath: 'peerAddress' });
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
db.createObjectStore('broadcastChannels', { keyPath: 'channelId' });
|
||||||
|
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
|
||||||
|
members.createIndex('byChannelId', 'channelId');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return new IndexedDBStorage(db);
|
return new IndexedDBStorage(db);
|
||||||
@@ -296,11 +303,64 @@ export class IndexedDBStorage implements StorageProvider {
|
|||||||
await tx.done;
|
await tx.done;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
await this.db.put('broadcastChannels', channelToRow(channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const row = await this.db.get('broadcastChannels', channelId);
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToChannel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
const rows = await this.db.getAll('broadcastChannels');
|
||||||
|
rows.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
return rows.map(rowToChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction(['broadcastChannels', 'broadcastMembers'], 'readwrite');
|
||||||
|
const memIdx = tx.objectStore('broadcastMembers').index('byChannelId');
|
||||||
|
let cursor = await memIdx.openCursor(IDBKeyRange.only(channelId));
|
||||||
|
while (cursor) {
|
||||||
|
await cursor.delete();
|
||||||
|
cursor = await cursor.continue();
|
||||||
|
}
|
||||||
|
await tx.objectStore('broadcastChannels').delete(channelId);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
await this.db.put('broadcastMembers', { ...member });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const rows = await this.db.getAllFromIndex(
|
||||||
|
'broadcastMembers',
|
||||||
|
'byChannelId',
|
||||||
|
IDBKeyRange.only(channelId),
|
||||||
|
);
|
||||||
|
rows.sort((a, b) => a.joinedAt - b.joinedAt);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: r.channelId,
|
||||||
|
peerAddress: r.peerAddress,
|
||||||
|
joinedAt: r.joinedAt,
|
||||||
|
removedAt: r.removedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
await this.db.delete('broadcastMembers', [channelId, peerAddress]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Schema ────────────────────────────────────────────────
|
// ─── Schema ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
interface IdentityRow {
|
interface IdentityRow {
|
||||||
id: 1;
|
id: 1;
|
||||||
@@ -370,6 +430,27 @@ interface PeerIdentityVersionRow {
|
|||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BroadcastChannelRow {
|
||||||
|
channelId: string;
|
||||||
|
ownerRole: 'sender' | 'receiver';
|
||||||
|
ownerAddress: string;
|
||||||
|
label: string | null;
|
||||||
|
generation: number;
|
||||||
|
chainKey: Uint8Array;
|
||||||
|
iteration: number;
|
||||||
|
signingPublicKey: Uint8Array;
|
||||||
|
signingPrivateKey: Uint8Array | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastMemberRow {
|
||||||
|
channelId: string;
|
||||||
|
peerAddress: string;
|
||||||
|
joinedAt: number;
|
||||||
|
removedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ShadeSchema extends DBSchema {
|
interface ShadeSchema extends DBSchema {
|
||||||
identity: { key: number; value: IdentityRow };
|
identity: { key: number; value: IdentityRow };
|
||||||
config: { key: string; value: ConfigRow };
|
config: { key: string; value: ConfigRow };
|
||||||
@@ -393,6 +474,12 @@ interface ShadeSchema extends DBSchema {
|
|||||||
};
|
};
|
||||||
peerVerifications: { key: string; value: PeerVerificationRow };
|
peerVerifications: { key: string; value: PeerVerificationRow };
|
||||||
peerIdentityVersions: { key: string; value: PeerIdentityVersionRow };
|
peerIdentityVersions: { key: string; value: PeerIdentityVersionRow };
|
||||||
|
broadcastChannels: { key: string; value: BroadcastChannelRow };
|
||||||
|
broadcastMembers: {
|
||||||
|
key: [string, string];
|
||||||
|
value: BroadcastMemberRow;
|
||||||
|
indexes: { byChannelId: string };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────
|
||||||
@@ -415,6 +502,39 @@ function persistedToRow(s: PersistedStreamState): StreamStateRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelToRow(c: BroadcastChannelRecord): BroadcastChannelRow {
|
||||||
|
return {
|
||||||
|
channelId: c.channelId,
|
||||||
|
ownerRole: c.ownerRole,
|
||||||
|
ownerAddress: c.ownerAddress,
|
||||||
|
label: c.label ?? null,
|
||||||
|
generation: c.generation,
|
||||||
|
chainKey: c.chainKey,
|
||||||
|
iteration: c.iteration,
|
||||||
|
signingPublicKey: c.signingPublicKey,
|
||||||
|
signingPrivateKey: c.signingPrivateKey ?? null,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToChannel(r: BroadcastChannelRow): BroadcastChannelRecord {
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: r.channelId,
|
||||||
|
ownerRole: r.ownerRole,
|
||||||
|
ownerAddress: r.ownerAddress,
|
||||||
|
generation: r.generation,
|
||||||
|
chainKey: r.chainKey,
|
||||||
|
iteration: r.iteration,
|
||||||
|
signingPublicKey: r.signingPublicKey,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
};
|
||||||
|
if (r.label !== null) out.label = r.label;
|
||||||
|
if (r.signingPrivateKey !== null) out.signingPrivateKey = r.signingPrivateKey;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function rowToPersisted(r: StreamStateRow): PersistedStreamState {
|
function rowToPersisted(r: StreamStateRow): PersistedStreamState {
|
||||||
const out: PersistedStreamState = {
|
const out: PersistedStreamState = {
|
||||||
streamId: r.streamId,
|
streamId: r.streamId,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification, PeerVerificationSource } from '@shade/core';
|
import type {
|
||||||
|
StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey,
|
||||||
|
SessionState, RetiredIdentity, PersistedStreamState, PeerVerification,
|
||||||
|
PeerVerificationSource, BroadcastChannelRecord, BroadcastMemberRecord,
|
||||||
|
} from '@shade/core';
|
||||||
import {
|
import {
|
||||||
toBase64, fromBase64,
|
toBase64, fromBase64,
|
||||||
constantTimeEqual,
|
constantTimeEqual,
|
||||||
@@ -53,6 +57,14 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
removePeerVerification: ReturnType<Database['prepare']>;
|
removePeerVerification: ReturnType<Database['prepare']>;
|
||||||
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||||
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||||
|
saveBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
getBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
listBroadcastChannels: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastChannel: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastChannelMembers: ReturnType<Database['prepare']>;
|
||||||
|
saveBroadcastMember: ReturnType<Database['prepare']>;
|
||||||
|
getBroadcastMembers: ReturnType<Database['prepare']>;
|
||||||
|
removeBroadcastMember: ReturnType<Database['prepare']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(dbPath?: string) {
|
constructor(dbPath?: string) {
|
||||||
@@ -127,6 +139,27 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
peer_address TEXT PRIMARY KEY,
|
peer_address TEXT PRIMARY KEY,
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS broadcast_channels (
|
||||||
|
channel_id TEXT PRIMARY KEY,
|
||||||
|
owner_role TEXT NOT NULL,
|
||||||
|
owner_address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
chain_key BLOB NOT NULL,
|
||||||
|
iteration INTEGER NOT NULL,
|
||||||
|
signing_public_key BLOB NOT NULL,
|
||||||
|
signing_private_key BLOB,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS broadcast_members (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
peer_address TEXT NOT NULL,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
removed_at INTEGER,
|
||||||
|
PRIMARY KEY (channel_id, peer_address)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_broadcast_members_channel ON broadcast_members(channel_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +216,36 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
`INSERT INTO peer_identity_versions (peer_address, version) VALUES (?, ?)
|
`INSERT INTO peer_identity_versions (peer_address, version) VALUES (?, ?)
|
||||||
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
||||||
),
|
),
|
||||||
|
saveBroadcastChannel: this.db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO broadcast_channels
|
||||||
|
(channel_id, owner_role, owner_address, label, generation,
|
||||||
|
chain_key, iteration, signing_public_key, signing_private_key,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
),
|
||||||
|
getBroadcastChannel: this.db.prepare(
|
||||||
|
'SELECT * FROM broadcast_channels WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
listBroadcastChannels: this.db.prepare(
|
||||||
|
'SELECT * FROM broadcast_channels ORDER BY created_at ASC',
|
||||||
|
),
|
||||||
|
removeBroadcastChannel: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_channels WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
removeBroadcastChannelMembers: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_members WHERE channel_id = ?',
|
||||||
|
),
|
||||||
|
saveBroadcastMember: this.db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO broadcast_members
|
||||||
|
(channel_id, peer_address, joined_at, removed_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
),
|
||||||
|
getBroadcastMembers: this.db.prepare(
|
||||||
|
'SELECT channel_id, peer_address, joined_at, removed_at FROM broadcast_members WHERE channel_id = ? ORDER BY joined_at ASC',
|
||||||
|
),
|
||||||
|
removeBroadcastMember: this.db.prepare(
|
||||||
|
'DELETE FROM broadcast_members WHERE channel_id = ? AND peer_address = ?',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +455,103 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
this.stmts.upsertPeerIdentityVersion.run(address, next);
|
this.stmts.upsertPeerIdentityVersion.run(address, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||||
|
|
||||||
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||||
|
this.stmts.saveBroadcastChannel.run(
|
||||||
|
channel.channelId,
|
||||||
|
channel.ownerRole,
|
||||||
|
channel.ownerAddress,
|
||||||
|
channel.label ?? null,
|
||||||
|
channel.generation,
|
||||||
|
channel.chainKey,
|
||||||
|
channel.iteration,
|
||||||
|
channel.signingPublicKey,
|
||||||
|
channel.signingPrivateKey ?? null,
|
||||||
|
channel.createdAt,
|
||||||
|
channel.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||||
|
const row = this.stmts.getBroadcastChannel.get(channelId) as BroadcastChannelRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToBroadcastChannel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||||
|
const rows = this.stmts.listBroadcastChannels.all() as BroadcastChannelRow[];
|
||||||
|
return rows.map(rowToBroadcastChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||||
|
this.stmts.removeBroadcastChannelMembers.run(channelId);
|
||||||
|
this.stmts.removeBroadcastChannel.run(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||||
|
this.stmts.saveBroadcastMember.run(
|
||||||
|
member.channelId,
|
||||||
|
member.peerAddress,
|
||||||
|
member.joinedAt,
|
||||||
|
member.removedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||||
|
const rows = this.stmts.getBroadcastMembers.all(channelId) as BroadcastMemberRow[];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: r.channel_id,
|
||||||
|
peerAddress: r.peer_address,
|
||||||
|
joinedAt: Number(r.joined_at),
|
||||||
|
removedAt: r.removed_at === null || r.removed_at === undefined ? null : Number(r.removed_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||||
|
this.stmts.removeBroadcastMember.run(channelId, peerAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastChannelRow {
|
||||||
|
channel_id: string;
|
||||||
|
owner_role: 'sender' | 'receiver';
|
||||||
|
owner_address: string;
|
||||||
|
label: string | null;
|
||||||
|
generation: number | bigint;
|
||||||
|
chain_key: Uint8Array | ArrayBuffer;
|
||||||
|
iteration: number | bigint;
|
||||||
|
signing_public_key: Uint8Array | ArrayBuffer;
|
||||||
|
signing_private_key: Uint8Array | ArrayBuffer | null;
|
||||||
|
created_at: number | bigint;
|
||||||
|
updated_at: number | bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastMemberRow {
|
||||||
|
channel_id: string;
|
||||||
|
peer_address: string;
|
||||||
|
joined_at: number | bigint;
|
||||||
|
removed_at: number | bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToBroadcastChannel(r: BroadcastChannelRow): BroadcastChannelRecord {
|
||||||
|
const out: BroadcastChannelRecord = {
|
||||||
|
channelId: r.channel_id,
|
||||||
|
ownerRole: r.owner_role,
|
||||||
|
ownerAddress: r.owner_address,
|
||||||
|
generation: Number(r.generation),
|
||||||
|
chainKey: toBytes(r.chain_key),
|
||||||
|
iteration: Number(r.iteration),
|
||||||
|
signingPublicKey: toBytes(r.signing_public_key),
|
||||||
|
createdAt: Number(r.created_at),
|
||||||
|
updatedAt: Number(r.updated_at),
|
||||||
|
};
|
||||||
|
if (r.label !== null && r.label !== undefined) out.label = r.label;
|
||||||
|
if (r.signing_private_key !== null && r.signing_private_key !== undefined) {
|
||||||
|
out.signingPrivateKey = toBytes(r.signing_private_key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToStreamState(row: any): PersistedStreamState {
|
function rowToStreamState(row: any): PersistedStreamState {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user