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

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-encrypted",
"version": "4.5.0",
"version": "4.6.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

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;
}

View File

@@ -1,5 +1,7 @@
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
import type {
BroadcastChannelRecord,
BroadcastMemberRecord,
IdentityKeyPair,
OneTimePreKey,
PeerVerification,
@@ -13,10 +15,10 @@ import type {
import { constantTimeEqual, toBase64 } from '@shade/core';
import { KeyManager } from '../crypto/key-manager.js';
import {
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
sealStreamSensitive, sealTrust,
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey,
openRetired, openSession, openSignedPreKey, openStreamSensitive, openTrust,
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
} from '../crypto/row-codec.js';
/**
@@ -91,6 +93,13 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
db.createObjectStore('peer_verifications_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);
@@ -341,6 +350,99 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
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> {
const sensitive = await openStreamSensitive(this.km, row.streamId, row.ciphertext);
const out: PersistedStreamState = {
@@ -364,7 +466,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
// ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
interface MetaRow { key: string; value: string }
interface IdentityRow { id: 1; ciphertext: Uint8Array }
@@ -395,6 +497,24 @@ interface PeerVerificationRow {
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 {
meta_enc: { key: string; value: MetaRow };
identity_enc: { key: number; value: IdentityRow };
@@ -419,4 +539,10 @@ interface EncryptedShadeSchema extends DBSchema {
};
peer_verifications_enc: { key: string; value: PeerVerificationRow };
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 };
};
}

View File

@@ -1,6 +1,8 @@
import type { Sql } from 'postgres';
import postgres from 'postgres';
import type {
BroadcastChannelRecord,
BroadcastMemberRecord,
IdentityKeyPair,
OneTimePreKey,
PeerVerification,
@@ -14,10 +16,10 @@ import type {
import { constantTimeEqual, toBase64 } from '@shade/core';
import { KeyManager } from '../crypto/key-manager.js';
import {
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
sealStreamSensitive, sealTrust,
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey,
openRetired, openSession, openSignedPreKey, openStreamSensitive, openTrust,
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
} from '../crypto/row-codec.js';
/**
@@ -332,6 +334,108 @@ export class EncryptedPostgresStorage implements StorageProvider {
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> {
const sensitive = await openStreamSensitive(this.km, String(row.stream_id), row.ciphertext);
const out: PersistedStreamState = {
@@ -363,6 +467,17 @@ interface StreamRow {
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> {
await sql`
CREATE TABLE IF NOT EXISTS shade_meta_enc (
@@ -454,4 +569,29 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
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)
`;
}

View File

@@ -1,5 +1,7 @@
import { Database } from 'bun:sqlite';
import type {
BroadcastChannelRecord,
BroadcastMemberRecord,
IdentityKeyPair,
OneTimePreKey,
PeerVerification,
@@ -13,10 +15,10 @@ import type {
import { constantTimeEqual, toBase64 } from '@shade/core';
import { KeyManager } from '../crypto/key-manager.js';
import {
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
sealStreamSensitive, sealTrust,
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey, openRetired,
openSession, openSignedPreKey, openStreamSensitive, openTrust,
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
} from '../crypto/row-codec.js';
/**
@@ -68,6 +70,14 @@ export class EncryptedSQLiteStorage implements StorageProvider {
removePeerVerification: ReturnType<Database['prepare']>;
getPeerIdentityVersion: 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) {
@@ -156,6 +166,24 @@ export class EncryptedSQLiteStorage implements StorageProvider {
peer_address TEXT PRIMARY KEY,
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 (?, ?)
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;
}
// ─── 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> {
const sensitive = await openStreamSensitive(this.km, row.stream_id, toBytes(row.ciphertext));
const out: PersistedStreamState = {
@@ -463,6 +602,24 @@ interface StreamRow {
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 {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);

View File

@@ -168,6 +168,82 @@ describe('EncryptedSQLiteStorage', () => {
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 () => {
await store.saveIdentityKeyPair(dummyIdentity());
store.close();