'use strict'; require('dotenv').config(); // ============================================================ // Open TSA — RFC 3161 Timestamp Authority Service // https://open-tsa.eu // // MIT License — Open TSA Project // Source: https://git.open-tsa.eu/open-tsa/server // // Endpoints: // POST /tsr → RFC 3161 Timestamp Response // GET /health → Health check (path configurable) // GET /info → Service info (public) // GET /stats → Public counter (total granted timestamps) // GET /now → Authoritative UTC time (for LLM/agent use) // GET /metrics → Prometheus exposition (loopback-only) // GET /admin/clients → Internal analytics (loopback-only) // ============================================================ const express = require('express'); const { execFile } = require('child_process'); const { promisify } = require('util'); const { writeFile, unlink, readFile, appendFile, mkdir } = require('fs/promises'); const { existsSync } = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const Database = require('better-sqlite3'); const execFileAsync = promisify(execFile); // ============================================================ // Configuration — single source of truth: .env / .env.example // ============================================================ const CA_DIR = process.env.CA_DIR || '/opt/open-tsa/ca'; const CONFIG = { // Service binding host: process.env.TSA_HOST || '127.0.0.1', port: parseInt(process.env.TSA_PORT || '3700', 10), publicUrl: process.env.TSA_PUBLIC_URL || 'https://tsr.open-tsa.eu', // CA hierarchy (CA_DIR + optional fine-grained overrides) caDir: CA_DIR, tsaConfig: process.env.TSA_CONFIG || path.join(CA_DIR, 'tsa', 'openssl-tsa.cnf'), tsaCert: process.env.TSA_CERT || path.join(CA_DIR, 'tsa', 'certs', 'tsa.crt'), tsaKey: process.env.TSA_KEY || path.join(CA_DIR, 'tsa', 'private', 'tsa.key'), // TSA policy policyOid: process.env.TSA_POLICY_OID || '1.3.6.1.4.1.59085.1.1', // Operational maxRequestSize: parseInt(process.env.MAX_REQUEST_SIZE || '65536', 10), rateLimitPerMin: parseInt(process.env.RATE_LIMIT_PER_MINUTE || '60', 10), // Logging logLevel: (process.env.LOG_LEVEL || 'info').toLowerCase(), logDestination: process.env.LOG_DESTINATION || 'stdout', // Audit log auditLog: process.env.AUDIT_LOG || '', // Metrics metricsEnabled: (process.env.METRICS_ENABLED || 'true').toLowerCase() === 'true', // Health healthPath: process.env.HEALTH_PATH || '/health', // OpenSSL opensslBin: process.env.OPENSSL_BIN || 'openssl', // Stats DB (not in .env.example — derived from CA_DIR for convenience) dbPath: process.env.DB_PATH || '/opt/open-tsa/data/stats.db', }; // Pre-flight: required CA files must exist for (const key of ['tsaConfig', 'tsaCert', 'tsaKey']) { if (!existsSync(CONFIG[key])) { console.error(`ERROR: ${key} not found: ${CONFIG[key]}`); console.error('Run setup-ca.sh first, or set the matching environment variable.'); process.exit(1); } } // ============================================================ // Minimal logger (no external dependency) // ============================================================ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }; const currentLevel = LEVELS[CONFIG.logLevel] ?? LEVELS.info; let logStream = null; if (CONFIG.logDestination !== 'stdout') { try { logStream = require('fs').createWriteStream(CONFIG.logDestination, { flags: 'a' }); } catch (e) { console.error(`Cannot open log file ${CONFIG.logDestination}: ${e.message}`); process.exit(1); } } function log(level, ...args) { if (LEVELS[level] > currentLevel) return; const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`; if (logStream) logStream.write(line + '\n'); else if (level === 'error' || level === 'warn') console.error(line); else console.log(line); } // ============================================================ // OpenSSL version (cached for /health) // ============================================================ let opensslVersion = 'unknown'; (async () => { try { const { stdout } = await execFileAsync(CONFIG.opensslBin, ['version']); opensslVersion = stdout.trim(); } catch (e) { log('warn', 'Could not determine OpenSSL version:', e.message); } })(); const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return '0.0.0'; } })(); // ============================================================ // SQLite Setup // ============================================================ const dbDir = path.dirname(CONFIG.dbPath); require('fs').mkdirSync(dbDir, { recursive: true }); const db = new Database(CONFIG.dbPath); db.exec(` CREATE TABLE IF NOT EXISTS timestamps ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), ip TEXT, hostname TEXT, user_agent TEXT ); CREATE TABLE IF NOT EXISTS tsr_errors ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), ip TEXT, status_code INTEGER, status_text TEXT ); CREATE INDEX IF NOT EXISTS idx_ip ON timestamps(ip); CREATE INDEX IF NOT EXISTS idx_created ON timestamps(created_at); CREATE INDEX IF NOT EXISTS idx_err_created ON tsr_errors(created_at); `); const stmtInsert = db.prepare(` INSERT INTO timestamps (ip, hostname, user_agent) VALUES (@ip, @hostname, @user_agent) `); const stmtInsertError = db.prepare(` INSERT INTO tsr_errors (ip, status_code, status_text) VALUES (@ip, @status_code, @status_text) `); const stmtCount = db.prepare(` SELECT COUNT(*) AS total FROM timestamps `); const stmtCountErrors = db.prepare(` SELECT COUNT(*) AS total FROM tsr_errors `); const stmtTopClients = db.prepare(` SELECT ip, hostname, COUNT(*) AS requests, MIN(created_at) AS first_seen, MAX(created_at) AS last_seen FROM timestamps GROUP BY ip ORDER BY requests DESC LIMIT 50 `); // ============================================================ // Audit log (append-only JSONL) // ============================================================ let auditWriteQueue = Promise.resolve(); async function auditWrite(entry) { if (!CONFIG.auditLog) return; auditWriteQueue = auditWriteQueue.then(async () => { try { await mkdir(path.dirname(CONFIG.auditLog), { recursive: true }); await appendFile(CONFIG.auditLog, JSON.stringify(entry) + '\n'); } catch (e) { log('error', 'Audit-log write failed:', e.message); } }); } // ============================================================ // Rate limit — in-memory sliding window per IP // ============================================================ const rateBuckets = new Map(); // ip → [timestamps] function rateLimitOk(ip) { if (CONFIG.rateLimitPerMin <= 0) return true; const now = Date.now(); const cutoff = now - 60_000; let bucket = rateBuckets.get(ip); if (!bucket) { bucket = []; rateBuckets.set(ip, bucket); } // prune while (bucket.length && bucket[0] < cutoff) bucket.shift(); if (bucket.length >= CONFIG.rateLimitPerMin) return false; bucket.push(now); return true; } // periodic cleanup of empty buckets setInterval(() => { const cutoff = Date.now() - 60_000; for (const [ip, bucket] of rateBuckets) { while (bucket.length && bucket[0] < cutoff) bucket.shift(); if (bucket.length === 0) rateBuckets.delete(ip); } }, 60_000).unref(); // ============================================================ // App Setup // ============================================================ const app = express(); // Trust the local nginx reverse proxy (loopback only). app.set('trust proxy', 'loopback'); app.use('/tsr', express.raw({ type: 'application/timestamp-query', limit: CONFIG.maxRequestSize, })); app.use(express.json({ limit: '4kb' })); function getClientIp(req) { const xff = (req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return xff || req.headers['x-real-ip'] || req.ip; } function isLoopback(req) { // After `trust proxy = loopback`, req.ip is the real client IP for // requests proxied from 127.0.0.1, but for direct connections it is // the actual remote address. Loopback-only access means the request // must originate from 127.0.0.1 / ::1 directly — NOT proxied. const remote = req.socket.remoteAddress; return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'; } // Request logging app.use((req, _res, next) => { const ip = getClientIp(req); const ua = req.headers['user-agent'] || '-'; log('info', `${req.method} ${req.path} — ${ip} — ${ua}`); next(); }); // ============================================================ // TSR PKIStatus parsing — RFC 3161 Section 2.4.2 // ============================================================ function parseTsrStatus(tsrBuffer) { const FAIL = { code: -1, ok: false }; if (!Buffer.isBuffer(tsrBuffer) || tsrBuffer.length < 10) return FAIL; let p = 0; if (tsrBuffer[p++] !== 0x30) return FAIL; if (tsrBuffer[p] & 0x80) p += 1 + (tsrBuffer[p] & 0x7f); else p += 1; if (tsrBuffer[p++] !== 0x30) return FAIL; if (tsrBuffer[p] & 0x80) p += 1 + (tsrBuffer[p] & 0x7f); else p += 1; if (tsrBuffer[p++] !== 0x02) return FAIL; const statusLen = tsrBuffer[p++]; if (statusLen < 1 || statusLen > 4) return FAIL; let code = 0; for (let i = 0; i < statusLen; i++) code = (code << 8) | tsrBuffer[p + i]; return { code, ok: code === 0 || code === 1 }; } const PKI_STATUS_NAMES = { 0: 'granted', 1: 'grantedWithMods', 2: 'rejection', 3: 'waiting', 4: 'revocationWarning', 5: 'revocationNotification', }; // ============================================================ // TSQ hash extraction — for audit log (best-effort, no throw) // ============================================================ function extractTsqHash(tsqBuffer) { // TimeStampReq ::= SEQUENCE { version, messageImprint, ... } // messageImprint ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier, hashedMessage OCTET STRING } // We walk minimally and return the hex hash if found, else null. try { let p = 0; if (tsqBuffer[p++] !== 0x30) return null; // length of outer SEQUENCE if (tsqBuffer[p] & 0x80) p += 1 + (tsqBuffer[p] & 0x7f); else p += 1; // version INTEGER if (tsqBuffer[p++] !== 0x02) return null; const vLen = tsqBuffer[p++]; p += vLen; // messageImprint SEQUENCE if (tsqBuffer[p++] !== 0x30) return null; if (tsqBuffer[p] & 0x80) p += 1 + (tsqBuffer[p] & 0x7f); else p += 1; // hashAlgorithm AlgorithmIdentifier (skip) if (tsqBuffer[p++] !== 0x30) return null; const algLen = tsqBuffer[p++]; p += algLen; // hashedMessage OCTET STRING if (tsqBuffer[p++] !== 0x04) return null; const hashLen = tsqBuffer[p++]; if (hashLen > 64 || p + hashLen > tsqBuffer.length) return null; return tsqBuffer.slice(p, p + hashLen).toString('hex'); } catch { return null; } } // ============================================================ // Helpers // ============================================================ async function createTimestamp(tsqBuffer) { const tmpId = crypto.randomBytes(8).toString('hex'); const tsqFile = path.join(os.tmpdir(), `open-tsa-${tmpId}.tsq`); const tsrFile = path.join(os.tmpdir(), `open-tsa-${tmpId}.tsr`); try { await writeFile(tsqFile, tsqBuffer); await execFileAsync(CONFIG.opensslBin, [ 'ts', '-reply', '-config', CONFIG.tsaConfig, '-queryfile', tsqFile, '-signer', CONFIG.tsaCert, '-inkey', CONFIG.tsaKey, '-out', tsrFile, ]); return await readFile(tsrFile); } finally { await Promise.allSettled([unlink(tsqFile), unlink(tsrFile)]); } } async function resolveHostname(ip) { try { const dns = require('dns').promises; const result = await Promise.race([ dns.reverse(ip), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)), ]); return result?.[0] || null; } catch { return null; } } // ============================================================ // POST /tsr — RFC 3161 Timestamp // ============================================================ app.post('/tsr', async (req, res) => { try { const ct = req.headers['content-type'] || ''; if (!ct.includes('application/timestamp-query')) { return res.status(415).json({ error: 'unsupported_media_type' }); } if (!Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: 'bad_request' }); } const ip = getClientIp(req); if (!rateLimitOk(ip)) { res.set('Retry-After', '60'); return res.status(429).json({ error: 'rate_limit_exceeded' }); } const tsr = await createTimestamp(req.body); const status = parseTsrStatus(tsr); const userAgent = req.headers['user-agent'] || null; if (status.ok) { resolveHostname(ip).then(hostname => { try { stmtInsert.run({ ip, hostname, user_agent: userAgent }); } catch (e) { log('error', '[DB ERROR]', e?.message); } }); // Audit-log (best-effort, non-blocking) auditWrite({ ts: new Date().toISOString(), hash: extractTsqHash(req.body), ip, response_size: tsr.length, status: 'granted', }); } else { const statusText = PKI_STATUS_NAMES[status.code] || `unknown(${status.code})`; try { stmtInsertError.run({ ip, status_code: status.code, status_text: statusText }); } catch (e) { log('error', '[DB ERROR]', e?.message); } log('error', JSON.stringify({ tag: 'tsr-non-granted', code: status.code, status: statusText, ip, })); } res.set('Content-Type', 'application/timestamp-reply'); res.set('X-TSA', 'open-tsa.eu'); res.set('Cache-Control', 'no-store'); return res.send(tsr); } catch (err) { log('error', '[TSR ERROR]', err?.message || err); return res.status(500).json({ error: 'tsa_error' }); } }); // ============================================================ // GET /stats — Public counter (only granted timestamps) // ============================================================ app.get('/stats', (_req, res) => { try { const { total } = stmtCount.get(); res.json({ total_timestamps: total, service: 'open-tsa.eu', }); } catch (err) { log('error', '[STATS ERROR]', err?.message); res.status(500).json({ error: 'stats_unavailable' }); } }); // ============================================================ // GET /admin/clients — Internal analytics (loopback only) // ============================================================ app.get('/admin/clients', (req, res) => { if (!isLoopback(req)) { return res.status(404).json({ error: 'not_found' }); } try { const clients = stmtTopClients.all(); res.json({ clients }); } catch (err) { log('error', '[ADMIN ERROR]', err?.message); res.status(500).json({ error: 'unavailable' }); } }); // ============================================================ // GET /metrics — Prometheus exposition (loopback only) // ============================================================ if (CONFIG.metricsEnabled) { app.get('/metrics', (req, res) => { if (!isLoopback(req)) { return res.status(404).json({ error: 'not_found' }); } const granted = stmtCount.get().total; const errors = stmtCountErrors.get().total; const lines = [ '# HELP open_tsa_timestamps_granted_total Total granted RFC 3161 timestamps issued.', '# TYPE open_tsa_timestamps_granted_total counter', `open_tsa_timestamps_granted_total ${granted}`, '# HELP open_tsa_timestamps_errors_total Total non-granted TSR responses.', '# TYPE open_tsa_timestamps_errors_total counter', `open_tsa_timestamps_errors_total ${errors}`, '# HELP open_tsa_rate_limit_buckets_current Current number of rate-limit tracking buckets.', '# TYPE open_tsa_rate_limit_buckets_current gauge', `open_tsa_rate_limit_buckets_current ${rateBuckets.size}`, '# HELP open_tsa_process_uptime_seconds Process uptime in seconds.', '# TYPE open_tsa_process_uptime_seconds counter', `open_tsa_process_uptime_seconds ${process.uptime().toFixed(0)}`, '', ]; res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); res.send(lines.join('\n')); }); } // ============================================================ // Health // ============================================================ app.get(CONFIG.healthPath, (_req, res) => { const { total } = stmtCount.get(); res.json({ status: 'ok', service: 'open-tsa', version: PKG_VERSION, openssl: opensslVersion, time: new Date().toISOString(), total_timestamps: total, }); }); // ============================================================ // GET /info // ============================================================ app.get('/info', (_req, res) => { res.json({ name: 'Open TSA — Free European Timestamp Authority', endpoints: { rfc3161_tsa: 'https://tsr.open-tsa.eu', utc_now: 'https://open-tsa.eu/now', service_info: 'https://open-tsa.eu/info', public_stats: 'https://open-tsa.eu/stats', }, protocol: 'RFC 3161', algorithms: ['sha256', 'sha384', 'sha512'], certs: { root: 'https://open-tsa.eu/certs/ca.crt', intermediate: 'https://open-tsa.eu/certs/intermediate.crt', tsa: 'https://open-tsa.eu/certs/tsa.crt', chain: 'https://open-tsa.eu/certs/chain.pem', fullchain: 'https://open-tsa.eu/certs/fullchain.pem', }, source: 'https://git.open-tsa.eu/open-tsa/server', docs: 'https://open-tsa.eu/docs/', policy: 'Free to use. Not eIDAS-qualified. RFC 3161 compliant.', operator: 'Open TSA Project, Europe', contact: 'info@open-tsa.eu', }); }); // ============================================================ // GET /now — Authoritative UTC time for LLM/agent integrations // ============================================================ app.get('/now', (_req, res) => { const d = new Date(); res.set('Cache-Control', 'no-store'); res.set('Access-Control-Allow-Origin', '*'); res.set('X-TSA', 'open-tsa.eu'); res.json({ iso: d.toISOString(), unix: Math.floor(d.getTime() / 1000), unix_ms: d.getTime(), rfc2822: d.toUTCString(), timezone: 'UTC', service: 'open-tsa.eu', note: 'Authoritative UTC time. For cryptographic timestamps (RFC 3161), use POST https://tsr.open-tsa.eu', }); }); // ============================================================ // 404 // ============================================================ app.use((_req, res) => { res.status(404).json({ error: 'not_found', docs: 'https://open-tsa.eu/docs/' }); }); // ============================================================ // Start // ============================================================ app.listen(CONFIG.port, CONFIG.host, () => { log('info', `Open TSA ${PKG_VERSION} started`); log('info', `Listening: http://${CONFIG.host}:${CONFIG.port}`); log('info', `Public URL: ${CONFIG.publicUrl}`); log('info', `CA dir: ${CONFIG.caDir}`); log('info', `TSA config: ${CONFIG.tsaConfig}`); log('info', `DB: ${CONFIG.dbPath}`); log('info', `Rate limit: ${CONFIG.rateLimitPerMin}/min/IP${CONFIG.rateLimitPerMin === 0 ? ' (disabled)' : ''}`); log('info', `Audit log: ${CONFIG.auditLog || '(disabled)'}`); log('info', `Metrics: ${CONFIG.metricsEnabled ? 'enabled' : 'disabled'} (loopback only)`); });