Files
Shade/packages/shade-core/src/storage.ts
Sterister 2c400d7094
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
release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
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>
2026-05-07 15:55:34 +02:00

267 lines
10 KiB
TypeScript

import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from './types.js';
/** A retired identity kept in history during the rotation grace period */
export interface RetiredIdentity {
keyPair: IdentityKeyPair;
retiredAt: number;
}
/**
* Persisted stream-transfer resume record. Holds enough state for either
* side of a transfer to resume after restart. The `streamSecret` MUST be
* encrypted before storage (see `@shade/transfer` for the deviceKey-based
* AES-GCM at-rest scheme).
*/
export interface PersistedStreamState {
streamId: string;
direction: 'send' | 'receive';
peerAddress: string;
status: 'active' | 'paused' | 'finished' | 'aborted';
/** JSON-serialized `StreamMetadata`. */
metadataJson: string;
/** JSON-serialized `LaneInitSpec[]`. */
partitionJson: string;
/** JSON-serialized per-lane progress array (laneId/nextSeq/bytesProcessed). */
laneStateJson: string;
/** JSON-serialized I/O descriptor (file path / file handle reference / buffer). */
ioDescriptorJson: string;
/** AES-GCM-encrypted streamSecret (under deviceKey). */
secretEnc: Uint8Array;
/** AES-GCM nonce used for `secretEnc`. */
secretNonce: Uint8Array;
/**
* Reserved for future hasher serialization. Empty in v0.2.0; resume
* re-hashes received bytes from disk.
*/
overallHashState?: string;
createdAt: number;
updatedAt: number;
}
/**
* Why a peer's fingerprint was deemed verified.
* - `user`: human did the side-channel comparison and confirmed.
* - `transitive`: trust derived from another verified party (reserved for V3.10).
* - `tofu-after-warning`: caller bypassed gate after seeing a warning.
*/
export type PeerVerificationSource = 'user' | 'transitive' | 'tofu-after-warning';
/**
* Persisted broadcast-channel state (V4.6). Holds the sender-key chain that
* lets the channel owner encrypt a single ciphertext and fan it out to all
* paired members.
*
* - `ownerRole === 'sender'`: this device created the channel; the record
* carries the full chain (chainKey + iteration + signing keypair).
* - `ownerRole === 'receiver'`: this device joined a channel that
* `ownerAddress` owns; we hold a tracking copy of the chain (chainKey +
* iteration + signing public key only — no private signing key).
*
* `generation` is bumped each time `removeMember` rotates the chain. Stale
* broadcasts at lower generations are silently dropped on receive.
*/
export interface BroadcastChannelRecord {
/** Opaque, stable across restarts. UUID-style. */
channelId: string;
ownerRole: 'sender' | 'receiver';
/** The address that owns the channel (i.e. the only sender). */
ownerAddress: string;
label?: string;
/** Sender-key generation — bumped on rotation. Starts at 0. */
generation: number;
/** Current chain key (32 bytes). */
chainKey: Uint8Array;
/** Counter advanced by `senderKeyEncrypt` / `senderKeyDecrypt`. */
iteration: number;
/** Owner's Ed25519 signing public key (32 bytes). */
signingPublicKey: Uint8Array;
/** Owner's Ed25519 signing private key. Present iff `ownerRole === 'sender'`. */
signingPrivateKey?: Uint8Array;
createdAt: number;
updatedAt: number;
}
/** Membership row for a broadcast channel (sender-side only). */
export interface BroadcastMemberRecord {
channelId: string;
peerAddress: string;
joinedAt: number;
/** When this peer was revoked. `null` while still active. */
removedAt: number | null;
}
/**
* Persistent record that a peer's safety number was verified at a point
* in time. `identityVersion` is the local counter for that peer's identity:
* incrementing it (e.g. via `bumpPeerIdentityVersion` on `acceptIdentityChange`)
* invalidates the saved verification because `isPeerVerified` requires the
* stored version to equal the current version.
*/
export interface PeerVerification {
peerAddress: string;
fingerprint: string;
verifiedAt: number;
verifiedBy: PeerVerificationSource;
identityVersion: number;
}
/**
* StorageProvider — abstract interface for persisting cryptographic state.
*
* Implementations per platform:
* - In-memory (testing)
* - IndexedDB (browser)
* - SQLite/PostgreSQL (server)
* - EncryptedSharedPreferences (Android)
*/
export interface StorageProvider {
// ─── Identity ──────────────────────────────────────────────
/** Get our local identity keypair, or null if not yet generated */
getIdentityKeyPair(): Promise<IdentityKeyPair | null>;
/** Persist our local identity keypair */
saveIdentityKeyPair(keyPair: IdentityKeyPair): Promise<void>;
/** Get our local registration ID (unique per installation) */
getLocalRegistrationId(): Promise<number>;
/** Save our local registration ID */
saveLocalRegistrationId(id: number): Promise<void>;
// ─── Signed Pre-Keys ──────────────────────────────────────
/** Get a signed prekey by ID */
getSignedPreKey(keyId: number): Promise<SignedPreKey | null>;
/** Persist a signed prekey */
saveSignedPreKey(key: SignedPreKey): Promise<void>;
/** Remove a signed prekey (after rotation grace period) */
removeSignedPreKey(keyId: number): Promise<void>;
// ─── One-Time Pre-Keys ────────────────────────────────────
/** Get a one-time prekey by ID */
getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null>;
/** Persist a one-time prekey */
saveOneTimePreKey(key: OneTimePreKey): Promise<void>;
/** Remove a consumed one-time prekey */
removeOneTimePreKey(keyId: number): Promise<void>;
/** Count remaining one-time prekeys */
getOneTimePreKeyCount(): Promise<number>;
// ─── Sessions ─────────────────────────────────────────────
/** Get session state for a peer address (e.g. "device:abc123") */
getSession(address: string): Promise<SessionState | null>;
/** Persist session state for a peer */
saveSession(address: string, state: SessionState): Promise<void>;
/** Remove session for a peer */
removeSession(address: string): Promise<void>;
/** Check if we trust a remote identity key (for TOFU or pinned keys) */
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;
/** Save a trusted remote identity key */
saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void>;
// ─── Identity History (rotation with grace period) ──────
/** Add an identity to the retired history */
addRetiredIdentity(identity: RetiredIdentity): Promise<void>;
/** Get all retired identities (for grace-period decryption) */
getRetiredIdentities(): Promise<RetiredIdentity[]>;
/** Remove retired identities older than the given timestamp */
pruneRetiredIdentities(olderThan: number): Promise<void>;
// ─── Peer verifications (V3.3) ────────────────────────────
/**
* Persist or replace the verification record for a peer. Idempotent
* upsert on `peerAddress`.
*/
savePeerVerification(verification: PeerVerification): Promise<void>;
/** Look up the saved verification for a peer (null if never verified). */
getPeerVerification(address: string): Promise<PeerVerification | null>;
/** Remove a peer's verification record (e.g. user revoked trust). */
removePeerVerification(address: string): Promise<void>;
/**
* Returns the current local identity-version counter for a peer.
* Defaults to 1 when the peer has never been seen. Bumped by
* `bumpPeerIdentityVersion` whenever the peer rotates identity.
*/
getPeerIdentityVersion(address: string): Promise<number>;
/**
* Increment the peer's identity-version counter and return the new value.
* Called from `acceptIdentityChange` so previous verification rows (which
* carry the old version number) become stale.
*/
bumpPeerIdentityVersion(address: string): Promise<number>;
// ─── Stream-transfer resume state (optional, added in v0.2.0) ──
/**
* Persist or update the stream-state row for a given streamId. Idempotent:
* upserts on `streamId`. Optional — providers that don't support resume
* can omit this and consumers will fall back to in-memory state.
*/
saveStreamState?(state: PersistedStreamState): Promise<void>;
/** Look up the stream-state row by streamId. Returns null if absent. */
getStreamState?(streamId: string): Promise<PersistedStreamState | null>;
/** Remove a stream-state row (e.g. on completion or abort). */
removeStreamState?(streamId: string): Promise<void>;
/** List active or paused stream-state rows (for resume on startup). */
listActiveStreamStates?(
direction?: 'send' | 'receive',
): Promise<PersistedStreamState[]>;
/** Prune stream-state rows in `'finished' | 'aborted'` status older than `olderThan`. */
pruneStreamStates?(olderThan: number): Promise<void>;
// ─── Broadcast channels (V4.6) — optional ─────────────────
/**
* Persist or replace the broadcast-channel record. Idempotent upsert on
* `channelId`. Backends that don't implement broadcast-channel support
* may omit this; the SDK throws a clear error when the app tries to
* call `createBroadcastChannel` on such a backend.
*/
saveBroadcastChannel?(channel: BroadcastChannelRecord): Promise<void>;
/** Look up a broadcast channel by id. */
getBroadcastChannel?(channelId: string): Promise<BroadcastChannelRecord | null>;
/** Enumerate all broadcast channels persisted on this device. */
listBroadcastChannels?(): Promise<BroadcastChannelRecord[]>;
/** Drop a broadcast channel and all its membership rows. */
removeBroadcastChannel?(channelId: string): Promise<void>;
/** Persist or replace a broadcast-membership row (sender-side only). */
saveBroadcastMember?(member: BroadcastMemberRecord): Promise<void>;
/**
* List membership rows for a channel. Includes revoked members (with
* `removedAt !== null`); callers filter as needed.
*/
getBroadcastMembers?(channelId: string): Promise<BroadcastMemberRecord[]>;
/** Hard-delete a single membership row. */
removeBroadcastMember?(channelId: string, peerAddress: string): Promise<void>;
}