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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:34:13 -07:00
parent 8e10f5cb03
commit e5af01119b
6 changed files with 359 additions and 75 deletions

View File

@ -9,8 +9,8 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package.json bun.lockb* ./ COPY package.json bun.lockb* ./
# Install dependencies # Install dependencies (including postgres)
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile || bun install
# Copy source # Copy source
COPY src/encryptid ./src/encryptid COPY src/encryptid ./src/encryptid
@ -44,8 +44,8 @@ ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" 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 # Start server
CMD ["bun", "run", "src/encryptid/server.ts"] CMD ["bun", "run", "src/encryptid/server.ts"]

View File

@ -8,10 +8,14 @@ services:
dockerfile: Dockerfile.encryptid dockerfile: Dockerfile.encryptid
container_name: encryptid container_name: encryptid
restart: unless-stopped restart: unless-stopped
depends_on:
encryptid-db:
condition: service_healthy
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET:-change-this-in-production} - JWT_SECRET=${JWT_SECRET:-change-this-in-production}
- DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD:-encryptid}@encryptid-db:5432/encryptid
labels: labels:
# Traefik auto-discovery # Traefik auto-discovery
- "traefik.enable=true" - "traefik.enable=true"
@ -23,13 +27,38 @@ services:
- "traefik.http.routers.encryptid-wellknown.entrypoints=web" - "traefik.http.routers.encryptid-wellknown.entrypoints=web"
networks: networks:
- traefik-public - traefik-public
- encryptid-internal
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 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 start_period: 10s
volumes:
encryptid-pgdata:
networks: networks:
traefik-public: traefik-public:
external: true external: true
encryptid-internal:
driver: bridge

View File

@ -16,6 +16,7 @@
"@automerge/automerge": "^2.2.8", "@automerge/automerge": "^2.2.8",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
"hono": "^4.11.7", "hono": "^4.11.7",
"postgres": "^3.4.5",
"perfect-arrows": "^0.3.7", "perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2" "perfect-freehand": "^1.2.2"
}, },

205
src/encryptid/db.ts Normal file
View File

@ -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<void> {
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<void> {
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<void> {
// 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<StoredCredential | null> {
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<void> {
await sql`
UPDATE credentials
SET counter = ${newCounter}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
}
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
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<void> {
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<StoredChallenge | null> {
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<void> {
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
}
export async function cleanExpiredChallenges(): Promise<number> {
const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`;
return result.count;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
export async function checkDatabaseHealth(): Promise<boolean> {
try {
await sql`SELECT 1`;
return true;
} catch {
return false;
}
}
export { sql };

33
src/encryptid/schema.sql Normal file
View File

@ -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);

View File

@ -3,6 +3,8 @@
* *
* Handles WebAuthn registration/authentication, session management, * Handles WebAuthn registration/authentication, session management,
* and serves the .well-known/webauthn configuration. * and serves the .well-known/webauthn configuration.
*
* Storage: PostgreSQL (via db.ts)
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
@ -10,6 +12,21 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun'; import { serveStatic } from 'hono/bun';
import { sign, verify } from 'hono/jwt'; 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 // 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<string, StoredCredential>();
const challenges = new Map<string, StoredChallenge>();
const userCredentials = new Map<string, string[]>(); // userId -> credentialIds
// ============================================================================ // ============================================================================
// HONO APP // HONO APP
// ============================================================================ // ============================================================================
@ -89,9 +78,11 @@ app.get('/.well-known/webauthn', (c) => {
}); });
}); });
// Health check // Health check — includes database connectivity
app.get('/health', (c) => { app.get('/health', async (c) => {
return c.json({ status: 'ok', service: 'encryptid', timestamp: Date.now() }); 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 // Generate user ID
const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge // Store challenge in database
const challengeRecord: StoredChallenge = { const challengeRecord: StoredChallenge = {
challenge, challenge,
userId, userId,
@ -122,7 +113,7 @@ app.post('/api/register/start', async (c) => {
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
}; };
challenges.set(challenge, challengeRecord); await storeChallenge(challengeRecord);
// Build registration options // Build registration options
const options = { const options = {
@ -162,20 +153,23 @@ app.post('/api/register/complete', async (c) => {
const { challenge, credential, userId, username } = await c.req.json(); const { challenge, credential, userId, username } = await c.req.json();
// Verify challenge // Verify challenge
const challengeRecord = challenges.get(challenge); const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'registration') { if (!challengeRecord || challengeRecord.type !== 'registration') {
return c.json({ error: 'Invalid challenge' }, 400); return c.json({ error: 'Invalid challenge' }, 400);
} }
if (Date.now() > challengeRecord.expiresAt) { if (Date.now() > challengeRecord.expiresAt) {
challenges.delete(challenge); await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400); return c.json({ error: 'Challenge expired' }, 400);
} }
challenges.delete(challenge); await deleteChallenge(challenge);
// In production, verify the attestation properly // In production, verify the attestation properly
// For now, we trust the client-side verification // 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 = { const storedCredential: StoredCredential = {
credentialId: credential.credentialId, credentialId: credential.credentialId,
publicKey: credential.publicKey, publicKey: credential.publicKey,
@ -185,13 +179,7 @@ app.post('/api/register/complete', async (c) => {
createdAt: Date.now(), createdAt: Date.now(),
transports: credential.transports, transports: credential.transports,
}; };
await storeCredential(storedCredential);
credentials.set(credential.credentialId, storedCredential);
// Map user to credential
const userCreds = userCredentials.get(userId) || [];
userCreds.push(credential.credentialId);
userCredentials.set(userId, userCreds);
console.log('EncryptID: Credential registered', { console.log('EncryptID: Credential registered', {
credentialId: credential.credentialId.slice(0, 20) + '...', credentialId: credential.credentialId.slice(0, 20) + '...',
@ -205,7 +193,7 @@ app.post('/api/register/complete', async (c) => {
success: true, success: true,
userId, userId,
token, token,
did: `did:key:${userId.slice(0, 32)}`, // Simplified DID did,
}); });
}); });
@ -223,19 +211,19 @@ app.post('/api/auth/start', async (c) => {
// Generate challenge // Generate challenge
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge // Store challenge in database
const challengeRecord: StoredChallenge = { const challengeRecord: StoredChallenge = {
challenge, challenge,
type: 'authentication', type: 'authentication',
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, expiresAt: Date.now() + 5 * 60 * 1000,
}; };
challenges.set(challenge, challengeRecord); await storeChallenge(challengeRecord);
// Build allowed credentials if specified // Build allowed credentials if specified
let allowCredentials; let allowCredentials;
if (credentialId) { if (credentialId) {
const cred = credentials.get(credentialId); const cred = await getCredential(credentialId);
if (cred) { if (cred) {
allowCredentials = [{ allowCredentials = [{
type: 'public-key', type: 'public-key',
@ -262,19 +250,19 @@ app.post('/api/auth/start', async (c) => {
app.post('/api/auth/complete', async (c) => { app.post('/api/auth/complete', async (c) => {
const { challenge, credential } = await c.req.json(); const { challenge, credential } = await c.req.json();
// Verify challenge // Verify challenge from database
const challengeRecord = challenges.get(challenge); const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'authentication') { if (!challengeRecord || challengeRecord.type !== 'authentication') {
return c.json({ error: 'Invalid challenge' }, 400); return c.json({ error: 'Invalid challenge' }, 400);
} }
if (Date.now() > challengeRecord.expiresAt) { if (Date.now() > challengeRecord.expiresAt) {
challenges.delete(challenge); await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400); return c.json({ error: 'Challenge expired' }, 400);
} }
challenges.delete(challenge); await deleteChallenge(challenge);
// Look up credential // Look up credential from database
const storedCredential = credentials.get(credential.credentialId); const storedCredential = await getCredential(credential.credentialId);
if (!storedCredential) { if (!storedCredential) {
return c.json({ error: 'Unknown credential' }, 400); 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 // In production, verify signature against stored public key
// For now, we trust the client-side verification // For now, we trust the client-side verification
// Update counter and last used // Update counter and last used in database
storedCredential.counter++; await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1);
storedCredential.lastUsed = Date.now();
console.log('EncryptID: Authentication successful', { console.log('EncryptID: Authentication successful', {
credentialId: credential.credentialId.slice(0, 20) + '...', 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) => { app.get('/api/session/verify', async (c) => {
const authHeader = c.req.header('Authorization'); 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 * Refresh session token
*/ */
@ -380,17 +388,13 @@ app.get('/api/user/credentials', async (c) => {
const payload = await verify(token, CONFIG.jwtSecret); const payload = await verify(token, CONFIG.jwtSecret);
const userId = payload.sub as string; const userId = payload.sub as string;
const userCreds = userCredentials.get(userId) || []; const creds = await getUserCredentials(userId);
const credentialList = userCreds.map(credId => { const credentialList = creds.map(cred => ({
const cred = credentials.get(credId);
if (!cred) return null;
return {
credentialId: cred.credentialId, credentialId: cred.credentialId,
createdAt: cred.createdAt, createdAt: cred.createdAt,
lastUsed: cred.lastUsed, lastUsed: cred.lastUsed,
transports: cred.transports, transports: cred.transports,
}; }));
}).filter(Boolean);
return c.json({ credentials: credentialList }); return c.json({ credentials: credentialList });
} catch { } 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(` console.log(`
@ -606,6 +621,7 @@ console.log(`
Port: ${CONFIG.port} Port: ${CONFIG.port}
RP ID: ${CONFIG.rpId} RP ID: ${CONFIG.rpId}
Storage: PostgreSQL
`); `);