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:
@@ -7,6 +7,7 @@
|
||||
* 0x01 = PreKeyMessage
|
||||
* 0x02 = RatchetMessage
|
||||
* 0x11 = StreamChunk
|
||||
* 0x21 = BroadcastMessage (V4.6 — sender-key encrypted group payload)
|
||||
*
|
||||
* All multi-byte integers are big-endian.
|
||||
*
|
||||
@@ -23,6 +24,7 @@ const VERSION = 0x02;
|
||||
const TYPE_PREKEY = 0x01;
|
||||
const TYPE_RATCHET = 0x02;
|
||||
export const TYPE_STREAM_CHUNK = 0x11;
|
||||
export const TYPE_BROADCAST = 0x21;
|
||||
|
||||
// ─── Stream chunk types ──────────────────────────────────────
|
||||
|
||||
@@ -43,6 +45,28 @@ export interface StreamChunkWire {
|
||||
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_NONCE_BYTES = 12;
|
||||
|
||||
@@ -230,11 +254,11 @@ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
data: Uint8Array,
|
||||
): 'prekey' | 'ratchet' | 'stream-chunk' | 'unknown' {
|
||||
): 'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown' {
|
||||
if (data.length < 2 || data[0] !== VERSION) return 'unknown';
|
||||
switch (data[1]) {
|
||||
case TYPE_PREKEY:
|
||||
@@ -243,11 +267,116 @@ export function inspectEnvelopeType(
|
||||
return 'ratchet';
|
||||
case TYPE_STREAM_CHUNK:
|
||||
return 'stream-chunk';
|
||||
case TYPE_BROADCAST:
|
||||
return 'broadcast';
|
||||
default:
|
||||
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 {
|
||||
let offset = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user