release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

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

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

View File

@@ -39,6 +39,7 @@ export const COL = {
trustedIdentity: 'trusted_identity',
retiredIdentity: 'retired_identity',
streamSensitive: 'stream_sensitive',
broadcastChannelSensitive: 'broadcast_channel_sensitive',
} as const;
/** Logical table identifiers — used for fieldKey + AAD binding. */
@@ -51,6 +52,7 @@ export const TBL = {
trustedIdentities: 'trusted_identities',
retiredIdentities: 'retired_identities',
streamState: 'stream_state',
broadcastChannels: 'broadcast_channels',
} as const;
/** 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;
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;
}