Files
Shade/packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts
Sterister 96c20cb4b2 fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.

First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.

Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.

StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.

A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.

The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.

Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.

Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00

659 lines
28 KiB
TypeScript

import { Database } from 'bun:sqlite';
import type {
BroadcastChannelRecord,
BroadcastMemberRecord,
IdentityKeyPair,
OneTimePreKey,
PeerVerification,
PeerVerificationSource,
PersistedStreamState,
RetiredIdentity,
SessionState,
SignedPreKey,
StorageProvider,
} from '@shade/core';
import { constantTimeEqual, toBase64 } from '@shade/core';
import { KeyManager } from '../crypto/key-manager.js';
import {
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey, openRetired,
openSession, openSignedPreKey, openStreamSensitive, openTrust,
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
} from '../crypto/row-codec.js';
/**
* SQLite-backed StorageProvider with at-rest encryption (V3.2).
*
* Schema: parallel `_enc` tables alongside the unencrypted ones, so a
* migration can run side-by-side and atomic-rename at the end. Sensitive
* payloads are sealed with AES-256-GCM bound to (table, column, pk) AAD;
* routing/timestamp fields stay plaintext to keep queries efficient.
*
* Bring your own KeyManager — see `KeyManager.open({ kind: 'passphrase' | 'keychain' | 'injected' })`.
*/
export class EncryptedSQLiteStorage implements StorageProvider {
private readonly db: Database;
private readonly km: KeyManager;
private readonly ownsDb: boolean;
// Prepared statements
private stmts!: {
getIdentity: ReturnType<Database['prepare']>;
saveIdentity: ReturnType<Database['prepare']>;
getConfig: ReturnType<Database['prepare']>;
saveConfig: ReturnType<Database['prepare']>;
getSignedPreKey: ReturnType<Database['prepare']>;
saveSignedPreKey: ReturnType<Database['prepare']>;
removeSignedPreKey: ReturnType<Database['prepare']>;
getOneTimePreKey: ReturnType<Database['prepare']>;
saveOneTimePreKey: ReturnType<Database['prepare']>;
removeOneTimePreKey: ReturnType<Database['prepare']>;
countOneTimePreKeys: ReturnType<Database['prepare']>;
getSession: ReturnType<Database['prepare']>;
saveSession: ReturnType<Database['prepare']>;
removeSession: ReturnType<Database['prepare']>;
getSessionAlias: ReturnType<Database['prepare']>;
saveSessionAlias: ReturnType<Database['prepare']>;
removeAliasesFor: ReturnType<Database['prepare']>;
getTrust: ReturnType<Database['prepare']>;
saveTrust: ReturnType<Database['prepare']>;
addRetired: ReturnType<Database['prepare']>;
listRetired: ReturnType<Database['prepare']>;
pruneRetired: ReturnType<Database['prepare']>;
saveStreamState: ReturnType<Database['prepare']>;
getStreamState: ReturnType<Database['prepare']>;
removeStreamState: ReturnType<Database['prepare']>;
listActiveStreamStates: ReturnType<Database['prepare']>;
listActiveByDirection: ReturnType<Database['prepare']>;
pruneStreamStates: ReturnType<Database['prepare']>;
getMeta: ReturnType<Database['prepare']>;
setMeta: ReturnType<Database['prepare']>;
savePeerVerification: ReturnType<Database['prepare']>;
getPeerVerification: ReturnType<Database['prepare']>;
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) {
this.db = db;
this.km = km;
this.ownsDb = ownsDb;
this.ensureTables();
this.prepareStatements();
}
/**
* Open an encrypted SQLite store. The caller supplies the KeyManager
* (so they control the key source) and the DB path.
*/
static async open(opts: { dbPath?: string; keyManager: KeyManager }): Promise<EncryptedSQLiteStorage> {
const path = opts.dbPath ?? process.env.SHADE_DB_PATH ?? '/data/shade-client.db';
const db = new Database(path, { create: true });
db.exec('PRAGMA journal_mode=WAL');
const store = new EncryptedSQLiteStorage(db, opts.keyManager, true);
await store.assertKeyMatchesOrPersistFingerprint();
return store;
}
/** Wrap an existing bun:sqlite Database (caller owns it). */
static async wrap(db: Database, km: KeyManager): Promise<EncryptedSQLiteStorage> {
const store = new EncryptedSQLiteStorage(db, km, false);
await store.assertKeyMatchesOrPersistFingerprint();
return store;
}
private ensureTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS shade_meta_enc (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS identity_enc (
id INTEGER PRIMARY KEY CHECK (id = 1),
ciphertext BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS config_enc (
key TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS signed_prekeys_enc (
key_id INTEGER PRIMARY KEY,
ciphertext BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS one_time_prekeys_enc (
key_id INTEGER PRIMARY KEY,
ciphertext BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions_enc (
address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL
);
-- Session-label aliases (V4.12). Labels are already stored in the
-- clear as the sessions_enc primary key, so the mapping between two
-- of them reveals nothing new; only session state is encrypted.
CREATE TABLE IF NOT EXISTS session_aliases_enc (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_aliases_enc_canonical
ON session_aliases_enc(canonical);
CREATE TABLE IF NOT EXISTS trusted_identities_enc (
address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS retired_identities_enc (
retired_at INTEGER PRIMARY KEY,
ciphertext BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_retired_at_enc ON retired_identities_enc(retired_at);
CREATE TABLE IF NOT EXISTS stream_state_enc (
stream_id TEXT PRIMARY KEY,
direction TEXT NOT NULL,
peer_address TEXT NOT NULL,
status TEXT NOT NULL,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stream_enc_peer ON stream_state_enc(peer_address);
CREATE INDEX IF NOT EXISTS idx_stream_enc_updated ON stream_state_enc(updated_at);
CREATE INDEX IF NOT EXISTS idx_stream_enc_status ON stream_state_enc(status, direction);
CREATE TABLE IF NOT EXISTS peer_verifications_enc (
peer_address TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
verified_at INTEGER NOT NULL,
verified_by TEXT NOT NULL,
identity_version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_identity_versions_enc (
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);
`);
}
private prepareStatements() {
this.stmts = {
getIdentity: this.db.prepare('SELECT ciphertext FROM identity_enc WHERE id = 1'),
saveIdentity: this.db.prepare('INSERT OR REPLACE INTO identity_enc (id, ciphertext) VALUES (1, ?)'),
getConfig: this.db.prepare('SELECT ciphertext FROM config_enc WHERE key = ?'),
saveConfig: this.db.prepare('INSERT OR REPLACE INTO config_enc (key, ciphertext) VALUES (?, ?)'),
getSignedPreKey: this.db.prepare('SELECT ciphertext FROM signed_prekeys_enc WHERE key_id = ?'),
saveSignedPreKey: this.db.prepare('INSERT OR REPLACE INTO signed_prekeys_enc (key_id, ciphertext) VALUES (?, ?)'),
removeSignedPreKey: this.db.prepare('DELETE FROM signed_prekeys_enc WHERE key_id = ?'),
getOneTimePreKey: this.db.prepare('SELECT ciphertext FROM one_time_prekeys_enc WHERE key_id = ?'),
saveOneTimePreKey: this.db.prepare('INSERT OR REPLACE INTO one_time_prekeys_enc (key_id, ciphertext) VALUES (?, ?)'),
removeOneTimePreKey: this.db.prepare('DELETE FROM one_time_prekeys_enc WHERE key_id = ?'),
countOneTimePreKeys: this.db.prepare('SELECT COUNT(*) as count FROM one_time_prekeys_enc'),
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases_enc WHERE alias = ?'),
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases_enc (alias, canonical) VALUES (?, ?)'),
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases_enc WHERE canonical = ?'),
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
listRetired: this.db.prepare('SELECT retired_at, ciphertext FROM retired_identities_enc ORDER BY retired_at DESC'),
pruneRetired: this.db.prepare('DELETE FROM retired_identities_enc WHERE retired_at < ?'),
saveStreamState: this.db.prepare(
`INSERT OR REPLACE INTO stream_state_enc (
stream_id, direction, peer_address, status, ciphertext, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`
),
getStreamState: this.db.prepare('SELECT * FROM stream_state_enc WHERE stream_id = ?'),
removeStreamState: this.db.prepare('DELETE FROM stream_state_enc WHERE stream_id = ?'),
listActiveStreamStates: this.db.prepare(
"SELECT * FROM stream_state_enc WHERE status IN ('active','paused') ORDER BY updated_at DESC"
),
listActiveByDirection: this.db.prepare(
"SELECT * FROM stream_state_enc WHERE status IN ('active','paused') AND direction = ? ORDER BY updated_at DESC"
),
pruneStreamStates: this.db.prepare(
"DELETE FROM stream_state_enc WHERE status IN ('finished','aborted') AND updated_at < ?"
),
getMeta: this.db.prepare('SELECT value FROM shade_meta_enc WHERE key = ?'),
setMeta: this.db.prepare('INSERT OR REPLACE INTO shade_meta_enc (key, value) VALUES (?, ?)'),
savePeerVerification: this.db.prepare(
`INSERT OR REPLACE INTO peer_verifications_enc
(peer_address, fingerprint, verified_at, verified_by, identity_version)
VALUES (?, ?, ?, ?, ?)`,
),
getPeerVerification: this.db.prepare(
'SELECT peer_address, fingerprint, verified_at, verified_by, identity_version FROM peer_verifications_enc WHERE peer_address = ?',
),
removePeerVerification: this.db.prepare('DELETE FROM peer_verifications_enc WHERE peer_address = ?'),
getPeerIdentityVersion: this.db.prepare('SELECT version FROM peer_identity_versions_enc WHERE peer_address = ?'),
upsertPeerIdentityVersion: this.db.prepare(
`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 = ?',
),
};
}
/**
* On first open, persist a fingerprint of the storageKey. On subsequent
* opens, compare and reject mismatches with a clear error rather than
* silently writing data under the wrong key.
*/
private async assertKeyMatchesOrPersistFingerprint(): Promise<void> {
const expected = toBase64(this.km.storageKeyFingerprint());
const row = this.stmts.getMeta.get('storage_key_fingerprint') as { value: string } | undefined;
if (!row) {
this.stmts.setMeta.run('storage_key_fingerprint', expected);
return;
}
if (row.value !== expected) {
throw new Error(
'storage key mismatch — the supplied passphrase / keychain entry does not unlock this database',
);
}
}
close(): void {
if (this.ownsDb) this.db.close();
this.km.destroy();
}
// ─── Identity ──────────────────────────────────────────────
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
const row = this.stmts.getIdentity.get() as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return null;
return openIdentity(this.km, toBytes(row.ciphertext));
}
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
const blob = await sealIdentity(this.km, kp);
this.stmts.saveIdentity.run(blob);
}
async getLocalRegistrationId(): Promise<number> {
const row = this.stmts.getConfig.get('registrationId') as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return 0;
const v = await openConfig(this.km, 'registrationId', toBytes(row.ciphertext));
return parseInt(v, 10);
}
async saveLocalRegistrationId(id: number): Promise<void> {
const blob = await sealConfig(this.km, 'registrationId', String(id));
this.stmts.saveConfig.run('registrationId', blob);
}
// ─── Signed PreKeys ────────────────────────────────────────
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
const row = this.stmts.getSignedPreKey.get(keyId) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return null;
return openSignedPreKey(this.km, keyId, toBytes(row.ciphertext));
}
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
const blob = await sealSignedPreKey(this.km, key);
this.stmts.saveSignedPreKey.run(key.keyId, blob);
}
async removeSignedPreKey(keyId: number): Promise<void> {
this.stmts.removeSignedPreKey.run(keyId);
}
// ─── One-Time PreKeys ──────────────────────────────────────
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
const row = this.stmts.getOneTimePreKey.get(keyId) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return null;
return openOneTimePreKey(this.km, keyId, toBytes(row.ciphertext));
}
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
const blob = await sealOneTimePreKey(this.km, key);
this.stmts.saveOneTimePreKey.run(key.keyId, blob);
}
async removeOneTimePreKey(keyId: number): Promise<void> {
this.stmts.removeOneTimePreKey.run(keyId);
}
async getOneTimePreKeyCount(): Promise<number> {
const row = this.stmts.countOneTimePreKeys.get() as { count: number };
return row.count;
}
// ─── Sessions ──────────────────────────────────────────────
async getSession(address: string): Promise<SessionState | null> {
const row = this.stmts.getSession.get(address) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return null;
return openSession(this.km, address, toBytes(row.ciphertext));
}
async saveSession(address: string, state: SessionState): Promise<void> {
const blob = await sealSession(this.km, address, state);
this.stmts.saveSession.run(address, blob);
}
async removeSession(address: string): Promise<void> {
this.stmts.removeSession.run(address);
}
// ─── Session label aliases ─────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
this.stmts.saveSessionAlias.run(alias, canonical);
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
this.stmts.removeAliasesFor.run(canonical);
}
// ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
const row = this.stmts.getTrust.get(address) as { ciphertext: Uint8Array | ArrayBuffer } | undefined;
if (!row) return true; // TOFU
const stored = await openTrust(this.km, address, toBytes(row.ciphertext));
return constantTimeEqual(stored, identityKey);
}
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
const blob = await sealTrust(this.km, address, identityKey);
this.stmts.saveTrust.run(address, blob);
}
// ─── Identity History ──────────────────────────────────────
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
const blob = await sealRetired(this.km, identity);
this.stmts.addRetired.run(identity.retiredAt, blob);
}
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
const rows = this.stmts.listRetired.all() as { retired_at: number; ciphertext: Uint8Array | ArrayBuffer }[];
return Promise.all(rows.map((r) => openRetired(this.km, Number(r.retired_at), toBytes(r.ciphertext))));
}
async pruneRetiredIdentities(olderThan: number): Promise<void> {
this.stmts.pruneRetired.run(olderThan);
}
// ─── Stream-transfer resume state ──────────────────────────
async saveStreamState(state: PersistedStreamState): Promise<void> {
const blob = await sealStreamSensitive(this.km, state);
this.stmts.saveStreamState.run(
state.streamId,
state.direction,
state.peerAddress,
state.status,
blob,
state.createdAt,
state.updatedAt,
);
}
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
const row = this.stmts.getStreamState.get(streamId) as StreamRow | undefined;
if (!row) return null;
return this.rowToStreamState(row);
}
async removeStreamState(streamId: string): Promise<void> {
this.stmts.removeStreamState.run(streamId);
}
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
const rows = (
direction === undefined
? (this.stmts.listActiveStreamStates.all() as StreamRow[])
: (this.stmts.listActiveByDirection.all(direction) as StreamRow[])
);
return Promise.all(rows.map((r) => this.rowToStreamState(r)));
}
async pruneStreamStates(olderThan: number): Promise<void> {
this.stmts.pruneStreamStates.run(olderThan);
}
// ─── Peer verifications (V3.3) ────────────────────────────
// Fingerprints are public-by-design; stored in plaintext for symmetry
// with the unencrypted backend.
async savePeerVerification(v: PeerVerification): Promise<void> {
this.stmts.savePeerVerification.run(
v.peerAddress,
v.fingerprint,
v.verifiedAt,
v.verifiedBy,
v.identityVersion,
);
}
async getPeerVerification(address: string): Promise<PeerVerification | null> {
const row = this.stmts.getPeerVerification.get(address) as
| { peer_address: string; fingerprint: string; verified_at: number | bigint; verified_by: string; identity_version: number | bigint }
| undefined;
if (!row) return null;
return {
peerAddress: row.peer_address,
fingerprint: row.fingerprint,
verifiedAt: Number(row.verified_at),
verifiedBy: row.verified_by as PeerVerificationSource,
identityVersion: Number(row.identity_version),
};
}
async removePeerVerification(address: string): Promise<void> {
this.stmts.removePeerVerification.run(address);
}
async getPeerIdentityVersion(address: string): Promise<number> {
const row = this.stmts.getPeerIdentityVersion.get(address) as { version: number | bigint } | undefined;
return row ? Number(row.version) : 1;
}
async bumpPeerIdentityVersion(address: string): Promise<number> {
const current = await this.getPeerIdentityVersion(address);
const next = current + 1;
this.stmts.upsertPeerIdentityVersion.run(address, next);
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 = {
streamId: row.stream_id,
direction: row.direction,
peerAddress: row.peer_address,
status: row.status,
metadataJson: sensitive.metadataJson,
partitionJson: sensitive.partitionJson,
laneStateJson: sensitive.laneStateJson,
ioDescriptorJson: sensitive.ioDescriptorJson,
secretEnc: sensitive.secretEnc,
secretNonce: sensitive.secretNonce,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
if (sensitive.overallHashState !== undefined) out.overallHashState = sensitive.overallHashState;
return out;
}
}
interface StreamRow {
stream_id: string;
direction: 'send' | 'receive';
peer_address: string;
status: 'active' | 'paused' | 'finished' | 'aborted';
ciphertext: Uint8Array | ArrayBuffer;
created_at: number | bigint;
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);
if (Array.isArray(value)) return new Uint8Array(value as number[]);
throw new Error(`Unsupported BLOB representation: ${typeof value}`);
}