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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user