Files
Shade/packages/shade-storage-postgres/src/postgres-storage.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

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;
}