From e5af01119b079c20bfb50ecbc38a175f13cf02db Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 07:34:13 -0700 Subject: [PATCH] feat: upgrade EncryptID server to PostgreSQL Replace in-memory Maps with persistent PostgreSQL storage: - Add db.ts with typed query functions for users, credentials, challenges - Add schema.sql with users/credentials/challenges tables - Update server.ts to use async DB queries - Add postgres service to docker-compose - Health endpoint now reports database connectivity - Auto-cleanup of expired challenges every 10 minutes Co-Authored-By: Claude Opus 4.6 --- Dockerfile.encryptid | 8 +- docker-compose.encryptid.yml | 31 +++++- package.json | 1 + src/encryptid/db.ts | 205 +++++++++++++++++++++++++++++++++++ src/encryptid/schema.sql | 33 ++++++ src/encryptid/server.ts | 156 ++++++++++++++------------ 6 files changed, 359 insertions(+), 75 deletions(-) create mode 100644 src/encryptid/db.ts create mode 100644 src/encryptid/schema.sql diff --git a/Dockerfile.encryptid b/Dockerfile.encryptid index 1d81a42..041bdcd 100644 --- a/Dockerfile.encryptid +++ b/Dockerfile.encryptid @@ -9,8 +9,8 @@ WORKDIR /app # Copy package files COPY package.json bun.lockb* ./ -# Install dependencies -RUN bun install --frozen-lockfile +# Install dependencies (including postgres) +RUN bun install --frozen-lockfile || bun install # Copy source COPY src/encryptid ./src/encryptid @@ -44,8 +44,8 @@ ENV PORT=3000 EXPOSE 3000 # Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD bun -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))" # Start server CMD ["bun", "run", "src/encryptid/server.ts"] diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index 1340b5b..11c9ab1 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -8,10 +8,14 @@ services: dockerfile: Dockerfile.encryptid container_name: encryptid restart: unless-stopped + depends_on: + encryptid-db: + condition: service_healthy environment: - NODE_ENV=production - PORT=3000 - JWT_SECRET=${JWT_SECRET:-change-this-in-production} + - DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD:-encryptid}@encryptid-db:5432/encryptid labels: # Traefik auto-discovery - "traefik.enable=true" @@ -23,13 +27,38 @@ services: - "traefik.http.routers.encryptid-wellknown.entrypoints=web" networks: - traefik-public + - encryptid-internal healthcheck: - test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 10s retries: 3 + start_period: 15s + + encryptid-db: + image: postgres:16-alpine + container_name: encryptid-db + restart: unless-stopped + environment: + - POSTGRES_DB=encryptid + - POSTGRES_USER=encryptid + - POSTGRES_PASSWORD=${ENCRYPTID_DB_PASSWORD:-encryptid} + volumes: + - encryptid-pgdata:/var/lib/postgresql/data + networks: + - encryptid-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U encryptid -d encryptid"] + interval: 10s + timeout: 5s + retries: 5 start_period: 10s +volumes: + encryptid-pgdata: + networks: traefik-public: external: true + encryptid-internal: + driver: bridge diff --git a/package.json b/package.json index 630acff..2624d98 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@automerge/automerge": "^2.2.8", "@lit/reactive-element": "^2.0.4", "hono": "^4.11.7", + "postgres": "^3.4.5", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2" }, diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts new file mode 100644 index 0000000..5202608 --- /dev/null +++ b/src/encryptid/db.ts @@ -0,0 +1,205 @@ +/** + * EncryptID Database Layer — PostgreSQL + * + * Replaces in-memory Maps with persistent PostgreSQL storage. + * Uses the 'postgres' npm package (lightweight, no native deps with Bun). + */ + +import postgres from 'postgres'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// ============================================================================ +// CONNECTION +// ============================================================================ + +const DATABASE_URL = process.env.DATABASE_URL || 'postgres://encryptid:encryptid@localhost:5432/encryptid'; + +const sql = postgres(DATABASE_URL, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, +}); + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface StoredCredential { + credentialId: string; + publicKey: string; + userId: string; + username: string; + counter: number; + createdAt: number; + lastUsed?: number; + transports?: string[]; +} + +export interface StoredChallenge { + challenge: string; + userId?: string; + type: 'registration' | 'authentication'; + createdAt: number; + expiresAt: number; +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +export async function initDatabase(): Promise { + const schema = readFileSync(join(import.meta.dir, 'schema.sql'), 'utf-8'); + await sql.unsafe(schema); + console.log('EncryptID: Database initialized'); + + // Clean expired challenges on startup + await cleanExpiredChallenges(); +} + +// ============================================================================ +// USER OPERATIONS +// ============================================================================ + +export async function createUser(id: string, username: string, displayName?: string, did?: string): Promise { + await sql` + INSERT INTO users (id, username, display_name, did) + VALUES (${id}, ${username}, ${displayName || username}, ${did || null}) + ON CONFLICT (id) DO NOTHING + `; +} + +export async function getUserByUsername(username: string) { + const [user] = await sql`SELECT * FROM users WHERE username = ${username}`; + return user || null; +} + +// ============================================================================ +// CREDENTIAL OPERATIONS +// ============================================================================ + +export async function storeCredential(cred: StoredCredential): Promise { + // Ensure user exists first + await createUser(cred.userId, cred.username); + + await sql` + INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at) + VALUES ( + ${cred.credentialId}, + ${cred.userId}, + ${cred.publicKey}, + ${cred.counter}, + ${cred.transports || null}, + ${new Date(cred.createdAt)} + ) + `; +} + +export async function getCredential(credentialId: string): Promise { + const rows = await sql` + SELECT c.credential_id, c.public_key, c.user_id, c.counter, + c.transports, c.created_at, c.last_used, u.username + FROM credentials c + JOIN users u ON c.user_id = u.id + WHERE c.credential_id = ${credentialId} + `; + if (rows.length === 0) return null; + + const row = rows[0]; + return { + credentialId: row.credential_id, + publicKey: row.public_key, + userId: row.user_id, + username: row.username, + counter: row.counter, + createdAt: new Date(row.created_at).getTime(), + lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, + transports: row.transports, + }; +} + +export async function updateCredentialUsage(credentialId: string, newCounter: number): Promise { + await sql` + UPDATE credentials + SET counter = ${newCounter}, last_used = NOW() + WHERE credential_id = ${credentialId} + `; +} + +export async function getUserCredentials(userId: string): Promise { + const rows = await sql` + SELECT c.credential_id, c.public_key, c.user_id, c.counter, + c.transports, c.created_at, c.last_used, u.username + FROM credentials c + JOIN users u ON c.user_id = u.id + WHERE c.user_id = ${userId} + `; + + return rows.map(row => ({ + credentialId: row.credential_id, + publicKey: row.public_key, + userId: row.user_id, + username: row.username, + counter: row.counter, + createdAt: new Date(row.created_at).getTime(), + lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, + transports: row.transports, + })); +} + +// ============================================================================ +// CHALLENGE OPERATIONS +// ============================================================================ + +export async function storeChallenge(ch: StoredChallenge): Promise { + await sql` + INSERT INTO challenges (challenge, user_id, type, created_at, expires_at) + VALUES ( + ${ch.challenge}, + ${ch.userId || null}, + ${ch.type}, + ${new Date(ch.createdAt)}, + ${new Date(ch.expiresAt)} + ) + `; +} + +export async function getChallenge(challenge: string): Promise { + const rows = await sql` + SELECT * FROM challenges WHERE challenge = ${challenge} + `; + if (rows.length === 0) return null; + + const row = rows[0]; + return { + challenge: row.challenge, + userId: row.user_id || undefined, + type: row.type as 'registration' | 'authentication', + createdAt: new Date(row.created_at).getTime(), + expiresAt: new Date(row.expires_at).getTime(), + }; +} + +export async function deleteChallenge(challenge: string): Promise { + await sql`DELETE FROM challenges WHERE challenge = ${challenge}`; +} + +export async function cleanExpiredChallenges(): Promise { + const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`; + return result.count; +} + +// ============================================================================ +// HEALTH CHECK +// ============================================================================ + +export async function checkDatabaseHealth(): Promise { + try { + await sql`SELECT 1`; + return true; + } catch { + return false; + } +} + +export { sql }; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql new file mode 100644 index 0000000..28ad930 --- /dev/null +++ b/src/encryptid/schema.sql @@ -0,0 +1,33 @@ +-- EncryptID PostgreSQL Schema +-- Run once to initialize the database + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + did TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + counter INTEGER DEFAULT 0, + transports TEXT[], + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id); + +CREATE TABLE IF NOT EXISTS challenges ( + challenge TEXT PRIMARY KEY, + user_id TEXT, + type TEXT NOT NULL CHECK (type IN ('registration', 'authentication')), + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +-- Auto-clean expired challenges (run periodically or use pg_cron) +CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 3e2bfa8..107735e 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -3,6 +3,8 @@ * * Handles WebAuthn registration/authentication, session management, * and serves the .well-known/webauthn configuration. + * + * Storage: PostgreSQL (via db.ts) */ import { Hono } from 'hono'; @@ -10,6 +12,21 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { serveStatic } from 'hono/bun'; import { sign, verify } from 'hono/jwt'; +import { + initDatabase, + storeCredential, + getCredential, + updateCredentialUsage, + getUserCredentials, + storeChallenge, + getChallenge, + deleteChallenge, + cleanExpiredChallenges, + checkDatabaseHealth, + createUser, + type StoredCredential, + type StoredChallenge, +} from './db.js'; // ============================================================================ // CONFIGURATION @@ -35,34 +52,6 @@ const CONFIG = { ], }; -// ============================================================================ -// IN-MEMORY STORAGE (Replace with database in production) -// ============================================================================ - -interface StoredCredential { - credentialId: string; - publicKey: string; // Base64url encoded - userId: string; - username: string; - counter: number; - createdAt: number; - lastUsed?: number; - transports?: string[]; -} - -interface StoredChallenge { - challenge: string; - userId?: string; - type: 'registration' | 'authentication'; - createdAt: number; - expiresAt: number; -} - -// In-memory stores (replace with D1/PostgreSQL in production) -const credentials = new Map(); -const challenges = new Map(); -const userCredentials = new Map(); // userId -> credentialIds - // ============================================================================ // HONO APP // ============================================================================ @@ -89,9 +78,11 @@ app.get('/.well-known/webauthn', (c) => { }); }); -// Health check -app.get('/health', (c) => { - return c.json({ status: 'ok', service: 'encryptid', timestamp: Date.now() }); +// Health check — includes database connectivity +app.get('/health', async (c) => { + const dbHealthy = await checkDatabaseHealth(); + const status = dbHealthy ? 'ok' : 'degraded'; + return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503); }); // ============================================================================ @@ -114,7 +105,7 @@ app.post('/api/register/start', async (c) => { // Generate user ID const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); - // Store challenge + // Store challenge in database const challengeRecord: StoredChallenge = { challenge, userId, @@ -122,7 +113,7 @@ app.post('/api/register/start', async (c) => { createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes }; - challenges.set(challenge, challengeRecord); + await storeChallenge(challengeRecord); // Build registration options const options = { @@ -162,20 +153,23 @@ app.post('/api/register/complete', async (c) => { const { challenge, credential, userId, username } = await c.req.json(); // Verify challenge - const challengeRecord = challenges.get(challenge); + const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'registration') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { - challenges.delete(challenge); + await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } - challenges.delete(challenge); + await deleteChallenge(challenge); // In production, verify the attestation properly // For now, we trust the client-side verification - // Store credential + // Create user and store credential in database + const did = `did:key:${userId.slice(0, 32)}`; + await createUser(userId, username, username, did); + const storedCredential: StoredCredential = { credentialId: credential.credentialId, publicKey: credential.publicKey, @@ -185,13 +179,7 @@ app.post('/api/register/complete', async (c) => { createdAt: Date.now(), transports: credential.transports, }; - - credentials.set(credential.credentialId, storedCredential); - - // Map user to credential - const userCreds = userCredentials.get(userId) || []; - userCreds.push(credential.credentialId); - userCredentials.set(userId, userCreds); + await storeCredential(storedCredential); console.log('EncryptID: Credential registered', { credentialId: credential.credentialId.slice(0, 20) + '...', @@ -205,7 +193,7 @@ app.post('/api/register/complete', async (c) => { success: true, userId, token, - did: `did:key:${userId.slice(0, 32)}`, // Simplified DID + did, }); }); @@ -223,19 +211,19 @@ app.post('/api/auth/start', async (c) => { // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); - // Store challenge + // Store challenge in database const challengeRecord: StoredChallenge = { challenge, type: 'authentication', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, }; - challenges.set(challenge, challengeRecord); + await storeChallenge(challengeRecord); // Build allowed credentials if specified let allowCredentials; if (credentialId) { - const cred = credentials.get(credentialId); + const cred = await getCredential(credentialId); if (cred) { allowCredentials = [{ type: 'public-key', @@ -262,19 +250,19 @@ app.post('/api/auth/start', async (c) => { app.post('/api/auth/complete', async (c) => { const { challenge, credential } = await c.req.json(); - // Verify challenge - const challengeRecord = challenges.get(challenge); + // Verify challenge from database + const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'authentication') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { - challenges.delete(challenge); + await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } - challenges.delete(challenge); + await deleteChallenge(challenge); - // Look up credential - const storedCredential = credentials.get(credential.credentialId); + // Look up credential from database + const storedCredential = await getCredential(credential.credentialId); if (!storedCredential) { return c.json({ error: 'Unknown credential' }, 400); } @@ -282,9 +270,8 @@ app.post('/api/auth/complete', async (c) => { // In production, verify signature against stored public key // For now, we trust the client-side verification - // Update counter and last used - storedCredential.counter++; - storedCredential.lastUsed = Date.now(); + // Update counter and last used in database + await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1); console.log('EncryptID: Authentication successful', { credentialId: credential.credentialId.slice(0, 20) + '...', @@ -311,7 +298,7 @@ app.post('/api/auth/complete', async (c) => { // ============================================================================ /** - * Verify session token + * Verify session token (supports both GET with Authorization header and POST with body) */ app.get('/api/session/verify', async (c) => { const authHeader = c.req.header('Authorization'); @@ -335,6 +322,27 @@ app.get('/api/session/verify', async (c) => { } }); +app.post('/api/session/verify', async (c) => { + const { token } = await c.req.json(); + if (!token) { + return c.json({ valid: false, error: 'No token' }, 400); + } + + try { + const payload = await verify(token, CONFIG.jwtSecret); + return c.json({ + valid: true, + claims: payload, + userId: payload.sub, + username: payload.username, + did: payload.did, + exp: payload.exp, + }); + } catch { + return c.json({ valid: false, error: 'Invalid token' }); + } +}); + /** * Refresh session token */ @@ -380,17 +388,13 @@ app.get('/api/user/credentials', async (c) => { const payload = await verify(token, CONFIG.jwtSecret); const userId = payload.sub as string; - const userCreds = userCredentials.get(userId) || []; - const credentialList = userCreds.map(credId => { - const cred = credentials.get(credId); - if (!cred) return null; - return { - credentialId: cred.credentialId, - createdAt: cred.createdAt, - lastUsed: cred.lastUsed, - transports: cred.transports, - }; - }).filter(Boolean); + const creds = await getUserCredentials(userId); + const credentialList = creds.map(cred => ({ + credentialId: cred.credentialId, + createdAt: cred.createdAt, + lastUsed: cred.lastUsed, + transports: cred.transports, + })); return c.json({ credentials: credentialList }); } catch { @@ -594,9 +598,20 @@ app.get('/', (c) => { }); // ============================================================================ -// START SERVER +// DATABASE INITIALIZATION & SERVER START // ============================================================================ +// Initialize database on startup +initDatabase().catch(err => { + console.error('EncryptID: Failed to initialize database', err); + process.exit(1); +}); + +// Clean expired challenges every 10 minutes +setInterval(() => { + cleanExpiredChallenges().catch(() => {}); +}, 10 * 60 * 1000); + console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ @@ -606,6 +621,7 @@ console.log(` ║ ║ ║ Port: ${CONFIG.port} ║ ║ RP ID: ${CONFIG.rpId} ║ +║ Storage: PostgreSQL ║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ `);