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