feat(hardening): M-Hard 6 + 7 — PostgreSQL backend + production server infra

M-Hard 6: PostgreSQL Storage Backend
- @shade/storage-postgres with PostgresStorage + PostgresPrekeyStore
- Drizzle-style raw SQL ensureClientTables / ensurePrekeyServerTables
- All tables prefixed `shade_` to avoid collisions in shared databases
- DELETE ... FOR UPDATE SKIP LOCKED for concurrent OTPK consumption
- Tests skip gracefully without SHADE_TEST_PG_URL, run against real PG when set

M-Hard 7: Production Server Infrastructure
- Structured JSON logger (logger.ts) — SHADE_LOG_LEVEL configurable
- Health endpoints (/health, /healthz, /ready) — Kubernetes-friendly
- Prometheus metrics (/metrics) — counters, histograms, middleware
- Graceful shutdown with SIGTERM/SIGINT handlers + store close
- Production Dockerfile with non-root user, healthcheck, multi-stage build
- docker-compose.yml example for Dokploy with optional PostgreSQL

193 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:51:29 +02:00
parent 96a8c210b2
commit 1bd5436506
17 changed files with 1168 additions and 15 deletions

View File

@@ -0,0 +1,91 @@
import type { Sql } from 'postgres';
/**
* Auto-create all Shade tables if they don't exist.
*
* Called on PostgresStorage / PostgresPrekeyStore construction.
* Uses raw SQL (not Drizzle migrations) for zero-config deployment.
*
* All tables prefixed with `shade_` to avoid collisions when sharing
* a PostgreSQL instance with another project.
*/
export async function ensureClientTables(sql: Sql): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS shade_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
signing_public_key TEXT NOT NULL,
signing_private_key TEXT NOT NULL,
dh_public_key TEXT NOT NULL,
dh_private_key TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_signed_prekeys (
key_id INTEGER PRIMARY KEY,
data_json TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_one_time_prekeys (
key_id INTEGER PRIMARY KEY,
data_json TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_sessions (
address TEXT PRIMARY KEY,
state_json TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_trusted_identities (
address TEXT PRIMARY KEY,
identity_key TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_retired_identities (
id SERIAL PRIMARY KEY,
data_json TEXT NOT NULL,
retired_at BIGINT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS shade_retired_at_idx ON shade_retired_identities(retired_at)
`;
}
export async function ensurePrekeyServerTables(sql: Sql): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS shade_server_identities (
address TEXT PRIMARY KEY,
identity_signing_key TEXT NOT NULL,
identity_dh_key TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_server_signed_prekeys (
address TEXT PRIMARY KEY,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_server_one_time_prekeys (
id SERIAL PRIMARY KEY,
address TEXT NOT NULL,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS shade_server_otp_address_idx ON shade_server_one_time_prekeys(address)
`;
}

View File

@@ -0,0 +1,3 @@
export { PostgresStorage } from './postgres-storage.js';
export { PostgresPrekeyStore } from './postgres-prekey-store.js';
export { ensureClientTables, ensurePrekeyServerTables } from './ensure-tables.js';

View File

@@ -0,0 +1,125 @@
import postgres, { type Sql } from 'postgres';
import type { PrekeyStore } from '@shade/server';
import { toBase64, fromBase64 } from '@shade/core';
import { ensurePrekeyServerTables } from './ensure-tables.js';
/**
* PostgreSQL-backed PrekeyStore for the Shade Prekey Server.
*
* Concurrent-safe one-time prekey consumption using FOR UPDATE SKIP LOCKED,
* so multiple server instances can share the same PostgreSQL.
*/
export class PostgresPrekeyStore implements PrekeyStore {
private constructor(
private readonly sql: Sql,
private readonly ownsConnection: boolean,
) {}
static async create(connectionString: string): Promise<PostgresPrekeyStore> {
const sql = postgres(connectionString);
const store = new PostgresPrekeyStore(sql, true);
await ensurePrekeyServerTables(sql);
return store;
}
static async fromClient(sql: Sql): Promise<PostgresPrekeyStore> {
const store = new PostgresPrekeyStore(sql, false);
await ensurePrekeyServerTables(sql);
return store;
}
async close(): Promise<void> {
if (this.ownsConnection) await this.sql.end();
}
async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise<void> {
await this.sql`
INSERT INTO shade_server_identities (address, identity_signing_key, identity_dh_key)
VALUES (${address}, ${toBase64(identitySigningKey)}, ${toBase64(identityDHKey)})
ON CONFLICT (address) DO UPDATE SET
identity_signing_key = EXCLUDED.identity_signing_key,
identity_dh_key = EXCLUDED.identity_dh_key
`;
}
async getIdentity(address: string): Promise<{ identitySigningKey: Uint8Array; identityDHKey: Uint8Array } | null> {
const rows = await this.sql<Array<{ identity_signing_key: string; identity_dh_key: string }>>`
SELECT identity_signing_key, identity_dh_key FROM shade_server_identities WHERE address = ${address}
`;
if (rows.length === 0) return null;
return {
identitySigningKey: fromBase64(rows[0]!.identity_signing_key),
identityDHKey: fromBase64(rows[0]!.identity_dh_key),
};
}
async saveSignedPreKey(address: string, keyId: number, publicKey: Uint8Array, signature: Uint8Array): Promise<void> {
await this.sql`
INSERT INTO shade_server_signed_prekeys (address, key_id, public_key, signature)
VALUES (${address}, ${keyId}, ${toBase64(publicKey)}, ${toBase64(signature)})
ON CONFLICT (address) DO UPDATE SET
key_id = EXCLUDED.key_id,
public_key = EXCLUDED.public_key,
signature = EXCLUDED.signature
`;
}
async getSignedPreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array; signature: Uint8Array } | null> {
const rows = await this.sql<Array<{ key_id: number; public_key: string; signature: string }>>`
SELECT key_id, public_key, signature FROM shade_server_signed_prekeys WHERE address = ${address}
`;
if (rows.length === 0) return null;
return {
keyId: rows[0]!.key_id,
publicKey: fromBase64(rows[0]!.public_key),
signature: fromBase64(rows[0]!.signature),
};
}
async saveOneTimePreKeys(address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>): Promise<void> {
if (keys.length === 0) return;
await this.sql.begin(async (sql) => {
for (const k of keys) {
await sql`
INSERT INTO shade_server_one_time_prekeys (address, key_id, public_key)
VALUES (${address}, ${k.keyId}, ${toBase64(k.publicKey)})
`;
}
});
}
async consumeOneTimePreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array } | null> {
// Atomic consume with FOR UPDATE SKIP LOCKED for concurrent safety
const rows = await this.sql<Array<{ key_id: number; public_key: string }>>`
DELETE FROM shade_server_one_time_prekeys
WHERE id = (
SELECT id FROM shade_server_one_time_prekeys
WHERE address = ${address}
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING key_id, public_key
`;
if (rows.length === 0) return null;
return {
keyId: rows[0]!.key_id,
publicKey: fromBase64(rows[0]!.public_key),
};
}
async getOneTimePreKeyCount(address: string): Promise<number> {
const rows = await this.sql<Array<{ count: string }>>`
SELECT COUNT(*)::text as count FROM shade_server_one_time_prekeys WHERE address = ${address}
`;
return parseInt(rows[0]!.count, 10);
}
async deleteAll(address: string): Promise<void> {
await this.sql.begin(async (sql) => {
await sql`DELETE FROM shade_server_identities WHERE address = ${address}`;
await sql`DELETE FROM shade_server_signed_prekeys WHERE address = ${address}`;
await sql`DELETE FROM shade_server_one_time_prekeys WHERE address = ${address}`;
});
}
}

View File

@@ -0,0 +1,203 @@
import postgres, { type Sql } from 'postgres';
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity } 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}`;
}
// ─── 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}`;
}
}