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>
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
import type {
|
|
StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey,
|
|
SessionState, RetiredIdentity, PersistedStreamState, PeerVerification,
|
|
BroadcastChannelRecord, BroadcastMemberRecord,
|
|
} from '@shade/core';
|
|
import { constantTimeEqual } from '@shade/core';
|
|
|
|
/**
|
|
* In-memory StorageProvider for testing and embedded use.
|
|
* All data is lost when the instance is garbage collected.
|
|
*/
|
|
export class MemoryStorage implements StorageProvider {
|
|
private identityKeyPair: IdentityKeyPair | null = null;
|
|
private registrationId: number = 0;
|
|
private signedPreKeys = new Map<number, SignedPreKey>();
|
|
private oneTimePreKeys = new Map<number, OneTimePreKey>();
|
|
private sessions = new Map<string, SessionState>();
|
|
private trustedIdentities = new Map<string, Uint8Array>();
|
|
private retiredIdentities: RetiredIdentity[] = [];
|
|
|
|
// ─── Identity ──────────────────────────────────────────────
|
|
|
|
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
|
return this.identityKeyPair;
|
|
}
|
|
|
|
async saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void> {
|
|
this.identityKeyPair = keyPair;
|
|
}
|
|
|
|
async getLocalRegistrationId(): Promise<number> {
|
|
return this.registrationId;
|
|
}
|
|
|
|
async saveLocalRegistrationId(id: number): Promise<void> {
|
|
this.registrationId = id;
|
|
}
|
|
|
|
// ─── Signed Pre-Keys ──────────────────────────────────────
|
|
|
|
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
|
return this.signedPreKeys.get(keyId) ?? null;
|
|
}
|
|
|
|
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
|
this.signedPreKeys.set(key.keyId, key);
|
|
}
|
|
|
|
async removeSignedPreKey(keyId: number): Promise<void> {
|
|
this.signedPreKeys.delete(keyId);
|
|
}
|
|
|
|
// ─── One-Time Pre-Keys ────────────────────────────────────
|
|
|
|
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
|
return this.oneTimePreKeys.get(keyId) ?? null;
|
|
}
|
|
|
|
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
|
this.oneTimePreKeys.set(key.keyId, key);
|
|
}
|
|
|
|
async removeOneTimePreKey(keyId: number): Promise<void> {
|
|
this.oneTimePreKeys.delete(keyId);
|
|
}
|
|
|
|
async getOneTimePreKeyCount(): Promise<number> {
|
|
return this.oneTimePreKeys.size;
|
|
}
|
|
|
|
// ─── Sessions ─────────────────────────────────────────────
|
|
|
|
async getSession(address: string): Promise<SessionState | null> {
|
|
return this.sessions.get(address) ?? null;
|
|
}
|
|
|
|
async saveSession(address: string, state: SessionState): Promise<void> {
|
|
this.sessions.set(address, state);
|
|
}
|
|
|
|
async removeSession(address: string): Promise<void> {
|
|
this.sessions.delete(address);
|
|
}
|
|
|
|
// ─── Trust ────────────────────────────────────────────────
|
|
|
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
|
const stored = this.trustedIdentities.get(address);
|
|
if (!stored) return true; // TOFU: trust on first use
|
|
return constantTimeEqual(stored, identityKey);
|
|
}
|
|
|
|
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
|
this.trustedIdentities.set(address, identityKey);
|
|
}
|
|
|
|
// ─── Identity History ─────────────────────────────────────
|
|
|
|
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
|
this.retiredIdentities.push(identity);
|
|
}
|
|
|
|
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
|
return [...this.retiredIdentities];
|
|
}
|
|
|
|
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
|
this.retiredIdentities = this.retiredIdentities.filter((r) => r.retiredAt >= olderThan);
|
|
}
|
|
|
|
// ─── Peer verifications (V3.3) ────────────────────────────
|
|
|
|
private peerVerifications = new Map<string, PeerVerification>();
|
|
private peerIdentityVersions = new Map<string, number>();
|
|
|
|
async savePeerVerification(v: PeerVerification): Promise<void> {
|
|
this.peerVerifications.set(v.peerAddress, { ...v });
|
|
}
|
|
|
|
async getPeerVerification(address: string): Promise<PeerVerification | null> {
|
|
const v = this.peerVerifications.get(address);
|
|
return v ? { ...v } : null;
|
|
}
|
|
|
|
async removePeerVerification(address: string): Promise<void> {
|
|
this.peerVerifications.delete(address);
|
|
}
|
|
|
|
async getPeerIdentityVersion(address: string): Promise<number> {
|
|
return this.peerIdentityVersions.get(address) ?? 1;
|
|
}
|
|
|
|
async bumpPeerIdentityVersion(address: string): Promise<number> {
|
|
const next = (this.peerIdentityVersions.get(address) ?? 1) + 1;
|
|
this.peerIdentityVersions.set(address, next);
|
|
return next;
|
|
}
|
|
|
|
// ─── Stream-transfer resume state (v0.2.0) ────────────────
|
|
|
|
private streamStates = new Map<string, PersistedStreamState>();
|
|
|
|
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
|
this.streamStates.set(state.streamId, { ...state });
|
|
}
|
|
|
|
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
|
const v = this.streamStates.get(streamId);
|
|
return v ? { ...v } : null;
|
|
}
|
|
|
|
async removeStreamState(streamId: string): Promise<void> {
|
|
this.streamStates.delete(streamId);
|
|
}
|
|
|
|
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
|
const out: PersistedStreamState[] = [];
|
|
for (const s of this.streamStates.values()) {
|
|
if (s.status !== 'active' && s.status !== 'paused') continue;
|
|
if (direction !== undefined && s.direction !== direction) continue;
|
|
out.push({ ...s });
|
|
}
|
|
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
return out;
|
|
}
|
|
|
|
async pruneStreamStates(olderThan: number): Promise<void> {
|
|
for (const [id, s] of this.streamStates) {
|
|
if ((s.status === 'finished' || s.status === 'aborted') && s.updatedAt < olderThan) {
|
|
this.streamStates.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
|
|
|
private broadcastChannels = new Map<string, BroadcastChannelRecord>();
|
|
private broadcastMembers = new Map<string, Map<string, BroadcastMemberRecord>>();
|
|
|
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
|
this.broadcastChannels.set(channel.channelId, cloneChannel(channel));
|
|
}
|
|
|
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
|
const v = this.broadcastChannels.get(channelId);
|
|
return v ? cloneChannel(v) : null;
|
|
}
|
|
|
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
|
return [...this.broadcastChannels.values()].map(cloneChannel);
|
|
}
|
|
|
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
|
this.broadcastChannels.delete(channelId);
|
|
this.broadcastMembers.delete(channelId);
|
|
}
|
|
|
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
|
let inner = this.broadcastMembers.get(member.channelId);
|
|
if (!inner) {
|
|
inner = new Map();
|
|
this.broadcastMembers.set(member.channelId, inner);
|
|
}
|
|
inner.set(member.peerAddress, { ...member });
|
|
}
|
|
|
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
|
const inner = this.broadcastMembers.get(channelId);
|
|
return inner ? [...inner.values()].map((m) => ({ ...m })) : [];
|
|
}
|
|
|
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
|
this.broadcastMembers.get(channelId)?.delete(peerAddress);
|
|
}
|
|
}
|
|
|
|
function cloneChannel(c: BroadcastChannelRecord): BroadcastChannelRecord {
|
|
const out: BroadcastChannelRecord = {
|
|
channelId: c.channelId,
|
|
ownerRole: c.ownerRole,
|
|
ownerAddress: c.ownerAddress,
|
|
generation: c.generation,
|
|
chainKey: new Uint8Array(c.chainKey),
|
|
iteration: c.iteration,
|
|
signingPublicKey: new Uint8Array(c.signingPublicKey),
|
|
createdAt: c.createdAt,
|
|
updatedAt: c.updatedAt,
|
|
};
|
|
if (c.label !== undefined) out.label = c.label;
|
|
if (c.signingPrivateKey !== undefined) out.signingPrivateKey = new Uint8Array(c.signingPrivateKey);
|
|
return out;
|
|
}
|