server/service.js
Open TSA Project 2536c55e33
Initial public release
Open TSA — Free European RFC 3161 Timestamp Authority.

- 4-tier CA hierarchy via setup-ca.sh (Root → TSA Root → Intermediate → Signing)
- Node.js service (service.js): RFC 3161 endpoint, health, info, stats,
  /now, Prometheus metrics, rate limiting, audit log, admin endpoints
- All identities and paths configurable via environment variables
- Documented in README, including reverse-proxy setup and operating notes

MIT licensed. See https://open-tsa.eu for the live service.
2026-05-15 18:37:34 +02:00

573 lines
21 KiB
JavaScript

'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)`);
});