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>
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import postgres, { type Sql } from 'postgres';
|
|
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification, PeerVerificationSource } from '@shade/core';
|
|
import {
|
|
toBase64, fromBase64,
|
|
constantTimeEqual,
|
|
serializeSessionState, deserializeSessionState,
|
|
serializeSignedPreKey, deserializeSignedPreKey,
|
|
serializeOneTimePreKey, deserializeOneTimePreKey,
|
|
serializeIdentityKeyPair, deserializeIdentityKeyPair,
|
|
} from '@shade/core';
|
|
import { ensureClientTables } from './ensure-tables.js';
|
|
|
|
/**
|
|
* PostgreSQL-backed StorageProvider for Shade client-side storage.
|
|
*
|
|
* Schema: tables prefixed `shade_` so they don't collide with existing
|
|
* tables in projects that share the same PG instance (e.g. Orchestrator).
|
|
*
|
|
* Usage:
|
|
* ```ts
|
|
* const storage = await PostgresStorage.create('postgres://user:pass@host/db');
|
|
* const manager = new ShadeSessionManager(crypto, storage);
|
|
* ```
|
|
*/
|
|
export class PostgresStorage implements StorageProvider {
|
|
private constructor(
|
|
private readonly sql: Sql,
|
|
private readonly ownsConnection: boolean,
|
|
) {}
|
|
|
|
/** Create from a connection string (owns the connection) */
|
|
static async create(connectionString: string): Promise<PostgresStorage> {
|
|
const sql = postgres(connectionString);
|
|
const storage = new PostgresStorage(sql, true);
|
|
await ensureClientTables(sql);
|
|
return storage;
|
|
}
|
|
|
|
/** Create from an existing postgres-js client (caller owns the connection) */
|
|
static async fromClient(sql: Sql): Promise<PostgresStorage> {
|
|
const storage = new PostgresStorage(sql, false);
|
|
await ensureClientTables(sql);
|
|
return storage;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.ownsConnection) await this.sql.end();
|
|
}
|
|
|
|
// ─── Identity ──────────────────────────────────────────────
|
|
|
|
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
|
const rows = await this.sql<Array<{ signing_public_key: string; signing_private_key: string; dh_public_key: string; dh_private_key: string }>>`
|
|
SELECT signing_public_key, signing_private_key, dh_public_key, dh_private_key
|
|
FROM shade_identity WHERE id = 1
|
|
`;
|
|
if (rows.length === 0) return null;
|
|
const r = rows[0]!;
|
|
return {
|
|
signingPublicKey: fromBase64(r.signing_public_key),
|
|
signingPrivateKey: fromBase64(r.signing_private_key),
|
|
dhPublicKey: fromBase64(r.dh_public_key),
|
|
dhPrivateKey: fromBase64(r.dh_private_key),
|
|
};
|
|
}
|
|
|
|
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_identity (id, signing_public_key, signing_private_key, dh_public_key, dh_private_key)
|
|
VALUES (1, ${toBase64(kp.signingPublicKey)}, ${toBase64(kp.signingPrivateKey)}, ${toBase64(kp.dhPublicKey)}, ${toBase64(kp.dhPrivateKey)})
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
signing_public_key = EXCLUDED.signing_public_key,
|
|
signing_private_key = EXCLUDED.signing_private_key,
|
|
dh_public_key = EXCLUDED.dh_public_key,
|
|
dh_private_key = EXCLUDED.dh_private_key
|
|
`;
|
|
}
|
|
|
|
async getLocalRegistrationId(): Promise<number> {
|
|
const rows = await this.sql<Array<{ value: string }>>`
|
|
SELECT value FROM shade_config WHERE key = 'registrationId'
|
|
`;
|
|
return rows.length ? parseInt(rows[0]!.value, 10) : 0;
|
|
}
|
|
|
|
async saveLocalRegistrationId(id: number): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_config (key, value) VALUES ('registrationId', ${String(id)})
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
`;
|
|
}
|
|
|
|
// ─── Signed PreKeys ───────────────────────────────────────
|
|
|
|
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
|
const rows = await this.sql<Array<{ data_json: string }>>`
|
|
SELECT data_json FROM shade_signed_prekeys WHERE key_id = ${keyId}
|
|
`;
|
|
return rows.length ? deserializeSignedPreKey(rows[0]!.data_json) : null;
|
|
}
|
|
|
|
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_signed_prekeys (key_id, data_json)
|
|
VALUES (${key.keyId}, ${serializeSignedPreKey(key)})
|
|
ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json
|
|
`;
|
|
}
|
|
|
|
async removeSignedPreKey(keyId: number): Promise<void> {
|
|
await this.sql`DELETE FROM shade_signed_prekeys WHERE key_id = ${keyId}`;
|
|
}
|
|
|
|
// ─── One-Time PreKeys ─────────────────────────────────────
|
|
|
|
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
|
const rows = await this.sql<Array<{ data_json: string }>>`
|
|
SELECT data_json FROM shade_one_time_prekeys WHERE key_id = ${keyId}
|
|
`;
|
|
return rows.length ? deserializeOneTimePreKey(rows[0]!.data_json) : null;
|
|
}
|
|
|
|
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_one_time_prekeys (key_id, data_json)
|
|
VALUES (${key.keyId}, ${serializeOneTimePreKey(key)})
|
|
ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json
|
|
`;
|
|
}
|
|
|
|
async removeOneTimePreKey(keyId: number): Promise<void> {
|
|
await this.sql`DELETE FROM shade_one_time_prekeys WHERE key_id = ${keyId}`;
|
|
}
|
|
|
|
async getOneTimePreKeyCount(): Promise<number> {
|
|
const rows = await this.sql<Array<{ count: string }>>`
|
|
SELECT COUNT(*)::text as count FROM shade_one_time_prekeys
|
|
`;
|
|
return parseInt(rows[0]!.count, 10);
|
|
}
|
|
|
|
// ─── Sessions ─────────────────────────────────────────────
|
|
|
|
async getSession(address: string): Promise<SessionState | null> {
|
|
const rows = await this.sql<Array<{ state_json: string }>>`
|
|
SELECT state_json FROM shade_sessions WHERE address = ${address}
|
|
`;
|
|
return rows.length ? deserializeSessionState(rows[0]!.state_json) : null;
|
|
}
|
|
|
|
async saveSession(address: string, state: SessionState): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_sessions (address, state_json)
|
|
VALUES (${address}, ${serializeSessionState(state)})
|
|
ON CONFLICT (address) DO UPDATE SET state_json = EXCLUDED.state_json
|
|
`;
|
|
}
|
|
|
|
async removeSession(address: string): Promise<void> {
|
|
await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`;
|
|
}
|
|
|
|
// ─── Session label aliases ────────────────────────────────
|
|
|
|
async getSessionAlias(alias: string): Promise<string | null> {
|
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
|
SELECT canonical FROM shade_session_aliases WHERE alias = ${alias}
|
|
`;
|
|
return rows.length ? rows[0]!.canonical : null;
|
|
}
|
|
|
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_session_aliases (alias, canonical)
|
|
VALUES (${alias}, ${canonical})
|
|
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
|
|
`;
|
|
}
|
|
|
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
|
await this.sql`DELETE FROM shade_session_aliases WHERE canonical = ${canonical}`;
|
|
}
|
|
|
|
// ─── Trust ────────────────────────────────────────────────
|
|
|
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
|
const rows = await this.sql<Array<{ identity_key: string }>>`
|
|
SELECT identity_key FROM shade_trusted_identities WHERE address = ${address}
|
|
`;
|
|
if (rows.length === 0) return true; // TOFU
|
|
return constantTimeEqual(fromBase64(rows[0]!.identity_key), identityKey);
|
|
}
|
|
|
|
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_trusted_identities (address, identity_key)
|
|
VALUES (${address}, ${toBase64(identityKey)})
|
|
ON CONFLICT (address) DO UPDATE SET identity_key = EXCLUDED.identity_key
|
|
`;
|
|
}
|
|
|
|
// ─── Identity History ─────────────────────────────────────
|
|
|
|
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_retired_identities (data_json, retired_at)
|
|
VALUES (${serializeIdentityKeyPair(identity.keyPair)}, ${identity.retiredAt})
|
|
`;
|
|
}
|
|
|
|
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
|
const rows = await this.sql<Array<{ data_json: string; retired_at: string }>>`
|
|
SELECT data_json, retired_at FROM shade_retired_identities ORDER BY retired_at DESC
|
|
`;
|
|
return rows.map((r) => ({
|
|
keyPair: deserializeIdentityKeyPair(r.data_json),
|
|
retiredAt: Number(r.retired_at),
|
|
}));
|
|
}
|
|
|
|
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
|
await this.sql`DELETE FROM shade_retired_identities WHERE retired_at < ${olderThan}`;
|
|
}
|
|
|
|
// ─── Stream-transfer resume state (v0.2.0) ────────────────
|
|
|
|
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_stream_state (
|
|
stream_id, direction, peer_address, status,
|
|
metadata_json, partition_json, lane_state_json, io_descriptor_json,
|
|
secret_enc, secret_nonce, overall_hash_state, created_at, updated_at
|
|
) VALUES (
|
|
${state.streamId}, ${state.direction}, ${state.peerAddress}, ${state.status},
|
|
${state.metadataJson}, ${state.partitionJson}, ${state.laneStateJson}, ${state.ioDescriptorJson},
|
|
${state.secretEnc}, ${state.secretNonce}, ${state.overallHashState ?? null},
|
|
${state.createdAt}, ${state.updatedAt}
|
|
)
|
|
ON CONFLICT (stream_id) DO UPDATE SET
|
|
direction = EXCLUDED.direction,
|
|
peer_address = EXCLUDED.peer_address,
|
|
status = EXCLUDED.status,
|
|
metadata_json = EXCLUDED.metadata_json,
|
|
partition_json = EXCLUDED.partition_json,
|
|
lane_state_json = EXCLUDED.lane_state_json,
|
|
io_descriptor_json = EXCLUDED.io_descriptor_json,
|
|
secret_enc = EXCLUDED.secret_enc,
|
|
secret_nonce = EXCLUDED.secret_nonce,
|
|
overall_hash_state = EXCLUDED.overall_hash_state,
|
|
updated_at = EXCLUDED.updated_at
|
|
`;
|
|
}
|
|
|
|
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
|
const rows = await this.sql<Array<Record<string, unknown>>>`
|
|
SELECT * FROM shade_stream_state WHERE stream_id = ${streamId}
|
|
`;
|
|
if (rows.length === 0) return null;
|
|
return rowToStreamState(rows[0]!);
|
|
}
|
|
|
|
async removeStreamState(streamId: string): Promise<void> {
|
|
await this.sql`DELETE FROM shade_stream_state WHERE stream_id = ${streamId}`;
|
|
}
|
|
|
|
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
|
const rows =
|
|
direction === undefined
|
|
? await this.sql<Array<Record<string, unknown>>>`
|
|
SELECT * FROM shade_stream_state
|
|
WHERE status IN ('active','paused')
|
|
ORDER BY updated_at DESC
|
|
`
|
|
: await this.sql<Array<Record<string, unknown>>>`
|
|
SELECT * FROM shade_stream_state
|
|
WHERE status IN ('active','paused') AND direction = ${direction}
|
|
ORDER BY updated_at DESC
|
|
`;
|
|
return rows.map(rowToStreamState);
|
|
}
|
|
|
|
async pruneStreamStates(olderThan: number): Promise<void> {
|
|
await this.sql`
|
|
DELETE FROM shade_stream_state
|
|
WHERE status IN ('finished','aborted') AND updated_at < ${olderThan}
|
|
`;
|
|
}
|
|
|
|
// ─── Peer verifications (V3.3) ────────────────────────────
|
|
|
|
async savePeerVerification(v: PeerVerification): Promise<void> {
|
|
await this.sql`
|
|
INSERT INTO shade_peer_verifications
|
|
(peer_address, fingerprint, verified_at, verified_by, identity_version)
|
|
VALUES (${v.peerAddress}, ${v.fingerprint}, ${v.verifiedAt}, ${v.verifiedBy}, ${v.identityVersion})
|
|
ON CONFLICT (peer_address) DO UPDATE SET
|
|
fingerprint = EXCLUDED.fingerprint,
|
|
verified_at = EXCLUDED.verified_at,
|
|
verified_by = EXCLUDED.verified_by,
|
|
identity_version = EXCLUDED.identity_version
|
|
`;
|
|
}
|
|
|
|
async getPeerVerification(address: string): Promise<PeerVerification | null> {
|
|
const rows = await this.sql<Array<{ peer_address: string; fingerprint: string; verified_at: string; verified_by: string; identity_version: string }>>`
|
|
SELECT peer_address, fingerprint, verified_at, verified_by, identity_version
|
|
FROM shade_peer_verifications WHERE peer_address = ${address}
|
|
`;
|
|
if (rows.length === 0) return null;
|
|
const r = rows[0]!;
|
|
return {
|
|
peerAddress: r.peer_address,
|
|
fingerprint: r.fingerprint,
|
|
verifiedAt: Number(r.verified_at),
|
|
verifiedBy: r.verified_by as PeerVerificationSource,
|
|
identityVersion: Number(r.identity_version),
|
|
};
|
|
}
|
|
|
|
async removePeerVerification(address: string): Promise<void> {
|
|
await this.sql`DELETE FROM shade_peer_verifications WHERE peer_address = ${address}`;
|
|
}
|
|
|
|
async getPeerIdentityVersion(address: string): Promise<number> {
|
|
const rows = await this.sql<Array<{ version: string }>>`
|
|
SELECT version FROM shade_peer_identity_versions WHERE peer_address = ${address}
|
|
`;
|
|
return rows.length ? Number(rows[0]!.version) : 1;
|
|
}
|
|
|
|
async bumpPeerIdentityVersion(address: string): Promise<number> {
|
|
const current = await this.getPeerIdentityVersion(address);
|
|
const next = current + 1;
|
|
await this.sql`
|
|
INSERT INTO shade_peer_identity_versions (peer_address, version)
|
|
VALUES (${address}, ${next})
|
|
ON CONFLICT (peer_address) DO UPDATE SET version = EXCLUDED.version
|
|
`;
|
|
return next;
|
|
}
|
|
}
|
|
|
|
function rowToStreamState(row: Record<string, unknown>): PersistedStreamState {
|
|
const out: PersistedStreamState = {
|
|
streamId: String(row.stream_id),
|
|
direction: row.direction as 'send' | 'receive',
|
|
peerAddress: String(row.peer_address),
|
|
status: row.status as 'active' | 'paused' | 'finished' | 'aborted',
|
|
metadataJson: String(row.metadata_json),
|
|
partitionJson: String(row.partition_json),
|
|
laneStateJson: String(row.lane_state_json),
|
|
ioDescriptorJson: String(row.io_descriptor_json),
|
|
secretEnc: row.secret_enc as Uint8Array,
|
|
secretNonce: row.secret_nonce as Uint8Array,
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
};
|
|
if (row.overall_hash_state !== null && row.overall_hash_state !== undefined) {
|
|
out.overallHashState = String(row.overall_hash_state);
|
|
}
|
|
return out;
|
|
}
|