From 2450171ce3c50535c8f35c07878091d31beeb846 Mon Sep 17 00:00:00 2001 From: hightrusted Date: Fri, 15 May 2026 18:29:51 +0200 Subject: [PATCH] Initial public release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 76 +++++++ .gitignore | 8 + LICENSE | 9 + README.md | 228 ++++++++++++++++++++ package.json | 28 +++ service.js | 573 +++++++++++++++++++++++++++++++++++++++++++++++++++ setup-ca.sh | 526 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1448 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 service.js create mode 100755 setup-ca.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6f0e18 --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# ────────────────────────────────────────────────────────────── +# Open TSA — Configuration Example +# ────────────────────────────────────────────────────────────── +# Copy this file to `.env` and adjust for your installation. +# Never commit the actual `.env` to version control. +# ────────────────────────────────────────────────────────────── + +# ── Service binding ───────────────────────────────────────── +# The service listens on TSA_HOST:TSA_PORT. +# Default is localhost only — nginx/Apache reverse-proxies from +# the public interface. Do not bind to 0.0.0.0 in production. +TSA_HOST=127.0.0.1 +TSA_PORT=3700 + +# ── Public-facing URL ─────────────────────────────────────── +# Used in logs, health-check responses, and error messages. +# Should match the public HTTPS URL of your reverse proxy. +TSA_PUBLIC_URL=https://tsr.example.org + +# ── CA hierarchy paths ────────────────────────────────────── +# Directory containing the 4-tier CA created by setup-ca.sh. +# The service only needs read access to the TSA signing tier. +CA_DIR=/opt/open-tsa/ca +TSA_CONFIG=/opt/open-tsa/ca/tsa/openssl-tsa.cnf + +# ── TSA policy OID ────────────────────────────────────────── +# Your own enterprise OID (allocated by IANA) or a sub-OID under +# an existing one. Must be present in your TSA certificate's +# `certificatePolicies` extension. Do NOT reuse another +# project's OID in production. +TSA_POLICY_OID=1.3.6.1.4.1.59085.1.1 + +# ── Operational parameters ────────────────────────────────── +# Maximum size of an incoming TimeStampRequest in bytes. +# Real-world TSQ payloads are 200–500 bytes. 64 KiB is generous. +MAX_REQUEST_SIZE=65536 + +# Rate limit per source IP per minute (0 = disabled). +RATE_LIMIT_PER_MINUTE=60 + +# ── Logging ───────────────────────────────────────────────── +# Levels: error, warn, info, debug +LOG_LEVEL=info + +# Log destination: stdout (recommended with systemd/journald) +# or absolute path to log file. +LOG_DESTINATION=stdout + +# ── Audit log ─────────────────────────────────────────────── +# Path to the append-only audit log of issued tokens. +# One JSON line per timestamp with: timestamp, serial, hash, +# client IP, response size. Used for transparency reporting. +AUDIT_LOG=/var/log/open-tsa/audit.jsonl + +# ── Metrics endpoint ──────────────────────────────────────── +# Prometheus-compatible metrics on /metrics. Bound to TSA_HOST. +# Disable in environments where this would be exposed publicly. +METRICS_ENABLED=true + +# ── Health endpoint ───────────────────────────────────────── +# Path of the health-check endpoint. Returns 200 + JSON with +# version, openssl version, and current load. +HEALTH_PATH=/health + +# ── OpenSSL binary ────────────────────────────────────────── +# Path to the OpenSSL 3.x binary. Leave as `openssl` to use the +# system default; override if multiple versions are installed. +OPENSSL_BIN=openssl + +# ── Service user / group ──────────────────────────────────── +# Used by setup-ca.sh to set permissions on the TSA tier. +# The Node.js service must run under this user. +SERVICE_USER=nodejs +SERVICE_GROUP=nodejs + +# ── End of configuration ──────────────────────────────────── diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e3c80c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +*.bak_* +data/ +ca/ +public/certs/*.key +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..812d334 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Open TSA Project contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eaaa2c2 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# Open TSA — Server + +**Free, open-source RFC 3161 timestamp infrastructure for Europe.** + +Open TSA provides a free public RFC 3161 timestamp service that any developer, researcher, NGO, or startup can build on without cost or lock-in. The dominant alternatives are either expensive commercial TSAs (€200–1,000+ per year) or unreliable single-operator free services with no source code. This project closes that gap with production-grade open infrastructure. + +- **Live service:** https://tsr.open-tsa.eu +- **Project home:** https://open-tsa.eu +- **License:** MIT +- **Server location:** Nuremberg, Germany (Hetzner) + +--- + +## Quick verification + +Verify that the live service works correctly, from any machine with `openssl` and `curl`: + +```bash +# 1. Download CA certificates (one-time) +curl -s https://open-tsa.eu/certs/ca.crt -o ca.crt +curl -s https://open-tsa.eu/certs/fullchain.pem -o fullchain.pem + +# 2. Create a test document and request a timestamp +echo "Hello, Open TSA" > test.txt +openssl ts -query -data test.txt -cert -sha256 -no_nonce -out test.tsq +curl -s -H "Content-Type: application/timestamp-query" \ + --data-binary @test.tsq \ + https://tsr.open-tsa.eu -o test.tsr + +# 3. Verify +openssl ts -verify -in test.tsr -queryfile test.tsq \ + -CAfile ca.crt -untrusted fullchain.pem +# Expected output: Verification: OK +``` + +If you see `Verification: OK`, the service is functioning correctly. + +--- + +## Run your own instance + +You can run an independent Open TSA instance. The setup is reproducible on any standard Linux server with Node.js and OpenSSL 3.x. + +### Requirements + +- Linux server (tested on AlmaLinux 9, Debian 12, Ubuntu 22.04 LTS) +- Node.js 18 or newer +- OpenSSL 3.x (`openssl version` should print `OpenSSL 3.x`) +- A domain name pointing to the server (for TLS via Let's Encrypt) +- Port 443 reachable from the internet +- Approximately 5 GB of disk space + +### Build and deploy + +```bash +# 1. Clone +git clone https://git.open-tsa.eu/open-tsa/server.git +cd server + +# 2. Install dependencies +npm install + +# 3. Generate the 4-tier CA hierarchy +# Root CA (25y, offline) → TSA Root CA (15y, offline) +# → Intermediate CA (10y) → TSA Signing Cert (2y) +sudo ./setup-ca.sh + +# 4. Copy and adjust environment configuration +cp .env.example .env +nano .env +# → set TSA_HOST, TSA_PORT, CA_DIR, TSA_POLICY_OID, etc. + +# 5. Start the service +node service.js +``` + +### Reverse proxy + +The service listens on `127.0.0.1:3700` by default. Expose it via nginx with Let's Encrypt: + +```nginx +server { + listen 443 ssl http2; + server_name tsr.example.org; + + ssl_certificate /etc/letsencrypt/live/tsr.example.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/tsr.example.org/privkey.pem; + + # Block internal endpoints from public access + location /admin/ { return 404; } + location /metrics { return 404; } + + location / { + proxy_pass http://127.0.0.1:3700; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 64K; + } +} +``` + +`X-Forwarded-For` must be set to the real client IP — the service uses it for rate-limit accounting and statistics. Without it, the rate limiter sees only `127.0.0.1` and treats all traffic as one client. + +### Health check + +```bash +curl http://127.0.0.1:3700/health +# {"status":"ok","service":"open-tsa","version":"1.0.0","openssl":"OpenSSL 3.x.x ...","time":"...","total_timestamps":N} +``` + +--- + +## Architecture + +``` +Internet + │ + ▼ HTTPS (port 443) +nginx — TLS termination, Let's Encrypt + │ + ▼ HTTP (port 3700, localhost only) +service.js — Node.js / Express + │ + ▼ +openssl ts -reply — RFC 3161 token generation +``` + +The service is intentionally minimal. The cryptographic heavy lifting is done by `openssl ts -reply`. The Node.js layer handles HTTP, request validation, rate limiting, audit logging, and metrics. + +### Certificate hierarchy + +``` +Root CA (25 years, offline) +└── TSA Root CA (15 years, offline) + └── TSA Intermediate (10 years) + └── TSA Signing (2 years) + EKU: id-kp-timeStamping (critical) +``` + +Root and intermediate private keys are kept offline. Only the TSA signing key is online. + +--- + +## Configuration + +See `.env.example` for all available options. The most important ones: + +| Variable | Description | Default | +|---|---|---| +| `TSA_HOST` | Interface to bind to | `127.0.0.1` | +| `TSA_PORT` | HTTP port to bind | `3700` | +| `CA_DIR` | Path to CA hierarchy | `/opt/open-tsa/ca` | +| `TSA_POLICY_OID` | Your own policy OID (allocate at https://pen.iana.org/) | placeholder | +| `RATE_LIMIT_PER_MINUTE` | Per-IP rate limit (0 = disabled) | `60` | +| `AUDIT_LOG` | Append-only JSONL log path | empty (disabled) | +| `METRICS_ENABLED` | Prometheus `/metrics` (loopback-only) | `true` | +| `LOG_LEVEL` | `error`, `warn`, `info`, `debug` | `info` | + +--- + +## Operating notes + +### OpenSSL 3.x quirks + +Open TSA targets OpenSSL 3.x explicitly. A few things to know: + +- `ess_cert_id_chain = no` is **required** in `openssl-tsa.cnf` — there is a known bug with the `-chain` flag in OpenSSL 3.x. +- `default_policy` must be a numeric OID (text policies crash `openssl ts`). +- `other_policies` must not be set to an empty value. +- The `serial` file must contain an **even number of hex digits** (e.g. `1000`, not `10000`). OpenSSL 3.x rejects odd-length serials with `a2i_ASN1_INTEGER:odd number of chars`. + +### Serial number permissions + +The CA setup runs as `root`, but the service runs as a less-privileged user. The setup script sets the necessary permissions on `serial`, `index.txt`, and `newcerts/` at the TSA tier so that the service can issue tokens. The higher tiers (Root, TSA Root, Intermediate) remain root-only. + +### Renewing the TSA signing certificate + +The TSA Signing Cert has a 2-year lifetime. Only the signing cert needs replacement at the 2-year mark; root and intermediate certificates have much longer lifetimes (25 / 15 / 10 years respectively). + +--- + +## Roadmap + +- **Phase 1 (current):** Single-node free public TSA in Nuremberg, RFC 3161 compliant, MIT-licensed. +- **Phase 2:** Multi-region deployment (Falkenstein DE + Helsinki FI), GeoDNS failover, browser-based verification tool. +- **Phase 3:** Explore browser trust paths (HARICA collaboration, Mozilla root program submission) — non-binding research track. + +--- + +## Contributing + +Contributions are welcome. The canonical home of this code is **git.open-tsa.eu**. + +**To contribute:** + +1. Clone the repository (anonymous, no account needed): `git clone https://git.open-tsa.eu/open-tsa/server.git` +2. Sign up on `git.open-tsa.eu/user/sign_up` (e-mail confirmation required) +3. Fork the repository, push your branch, open a pull request against `main` +4. We review within a few days + +For larger changes or feature proposals, please open an issue first to discuss the approach. + +**Reporting bugs:** open an issue at https://git.open-tsa.eu/open-tsa/server/issues or send an e-mail to `info@open-tsa.eu`. + +**Security issues:** please do not file public issues. Send details to `security@open-tsa.eu`. + +--- + +## License + +This project is released under the **MIT License**. See [LICENSE](LICENSE) for the full text. + +--- + +## Acknowledgements + +This project addresses a gap identified by the European open-source community: free RFC 3161 timestamp infrastructure that is independently operated, source-available, and not dependent on a single private individual or commercial entity. + +Funded development supported by the **NLnet Foundation** under the NGI Zero Commons Fund (pending review at time of initial publication). + +--- + +## Contact + +- **Web:** https://open-tsa.eu +- **E-mail:** info@open-tsa.eu +- **Source:** https://git.open-tsa.eu/open-tsa/server diff --git a/package.json b/package.json new file mode 100644 index 0000000..1683e4d --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "open-tsa", + "version": "1.0.0", + "description": "Open TSA — Free European RFC 3161 Timestamp Authority", + "main": "service.js", + "scripts": { + "start": "node service.js" + }, + "repository": { + "type": "git", + "url": "https://git.open-tsa.eu/open-tsa/server.git" + }, + "homepage": "https://open-tsa.eu", + "bugs": { + "url": "https://git.open-tsa.eu/open-tsa/server/issues", + "email": "info@open-tsa.eu" + }, + "author": "Open TSA Project ", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "dependencies": { + "better-sqlite3": "^12.8.0", + "dotenv": "^16.3.1", + "express": "^4.18.2" + } +} diff --git a/service.js b/service.js new file mode 100644 index 0000000..274b038 --- /dev/null +++ b/service.js @@ -0,0 +1,573 @@ +'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)`); +}); diff --git a/setup-ca.sh b/setup-ca.sh new file mode 100755 index 0000000..26f5ee4 --- /dev/null +++ b/setup-ca.sh @@ -0,0 +1,526 @@ +#!/bin/bash +# ============================================================ +# Open TSA — CA & Certificate Setup (4-tier hierarchy) +# https://open-tsa.eu +# +# Builds a 4-tier RFC 3161 TSA CA hierarchy from scratch. +# Designed to be re-used by independent operators, not only by +# the Open TSA project itself. All names, organisations, paths, +# and the policy OID are configurable via environment variables. +# +# The defaults below are deliberately neutral and project-agnostic +# so that any operator can run this script unchanged for a basic +# evaluation, and override only what is needed for production. +# +# CA Hierarchy: +# 1. Root CA (self-signed, 25 years) ← OFFLINE/HSM +# 2. TSA Root CA (15 years) ← OFFLINE +# 3. TSA Intermediate CA (10 years, server) +# 4. TSA Signing Cert (2 years, operational) +# +# OpenSSL 3.x compatible (AlmaLinux 9, RHEL 9, Debian 12) +# MIT License — Open TSA Project +# +# Usage: +# sudo bash setup-ca.sh +# +# Environment variables (override defaults): +# COUNTRY Two-letter country code (default: DE) +# CA_DIR Path for CA hierarchy (default: /opt/open-tsa/ca) +# PUBLIC_DIR Path for public certificates (default: /opt/open-tsa/public/certs) +# CONFIG_PATH Path for openssl-tsa.cnf (default: /opt/open-tsa/openssl-tsa.cnf) +# +# ROOT_ORG Root CA "O=" (default: Open TSA) +# ROOT_CN Root CA "CN=" (default: Open TSA Root CA) +# +# TSA_ROOT_ORG TSA Root + lower tiers "O=" (default: Open TSA) +# TSA_ROOT_CN TSA Root CA "CN=" (default: Open TSA TSA Root CA) +# TSA_INTER_CN Intermediate CA "CN=" (default: Open TSA TSA Intermediate CA) +# TSA_SIGN_CN Signing Cert "CN=" (default: Open TSA TSA Signing Certificate) +# +# DOMAIN Public service hostname (default: tsr.example.org) +# (informational only — used in the summary) +# +# TSA_POLICY_OID Your policy OID (default: 1.3.6.1.4.1.99999.1.1) +# Allocate your own under IANA PEN +# (https://pen.iana.org/) — do not reuse +# another project's OID in production. +# +# SERVICE_USER User the service runs as (default: nodejs) +# SERVICE_GROUP Group the service runs as (default: nodejs) +# +# Example for an independent operator in Finland: +# +# sudo COUNTRY=FI \ +# CA_DIR=/opt/example-tsa/ca \ +# PUBLIC_DIR=/opt/example-tsa/public/certs \ +# CONFIG_PATH=/opt/example-tsa/openssl-tsa.cnf \ +# ROOT_ORG="example.fi" \ +# ROOT_CN="example.fi Root CA" \ +# TSA_ROOT_ORG="tsa.example.fi" \ +# TSA_ROOT_CN="example.fi TSA Root CA" \ +# TSA_INTER_CN="example.fi TSA Intermediate CA" \ +# TSA_SIGN_CN="example.fi TSA Signing Certificate" \ +# DOMAIN=tsr.example.fi \ +# TSA_POLICY_OID="1.3.6.1.4.1.99999.1.1" \ +# bash setup-ca.sh +# ============================================================ + +set -euo pipefail + +# ── Paths ───────────────────────────────────────────────────── +CA_DIR="${CA_DIR:-/opt/open-tsa/ca}" +PUBLIC_DIR="${PUBLIC_DIR:-/opt/open-tsa/public/certs}" +CONFIG_PATH="${CONFIG_PATH:-/opt/open-tsa/openssl-tsa.cnf}" + +# ── Country ─────────────────────────────────────────────────── +COUNTRY="${COUNTRY:-DE}" + +# ── Identities ──────────────────────────────────────────────── +# Neutral defaults — production operators should override these. +ROOT_ORG="${ROOT_ORG:-Open TSA}" +ROOT_CN="${ROOT_CN:-Open TSA Root CA}" + +TSA_ROOT_ORG="${TSA_ROOT_ORG:-Open TSA}" +TSA_ROOT_CN="${TSA_ROOT_CN:-Open TSA TSA Root CA}" + +TSA_INTER_CN="${TSA_INTER_CN:-Open TSA TSA Intermediate CA}" +TSA_SIGN_CN="${TSA_SIGN_CN:-Open TSA TSA Signing Certificate}" + +DOMAIN="${DOMAIN:-tsr.example.org}" + +# ── TSA Policy OID ──────────────────────────────────────────── +# RFC 3161 requires a policy OID in every issued timestamp token. +# The default below is a placeholder under the IANA PEN reserved range. +# DO NOT use this OID in production — register your own at +# https://pen.iana.org/ and substitute it here. +TSA_POLICY_OID="${TSA_POLICY_OID:-1.3.6.1.4.1.99999.1.1}" + +# ── Service user (the user that the Node.js service runs as) ─ +SERVICE_USER="${SERVICE_USER:-nodejs}" +SERVICE_GROUP="${SERVICE_GROUP:-nodejs}" + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' + +echo -e "${BLUE}=== Open TSA — 4-Tier CA Setup ===${NC}" +echo -e "${BLUE}Hierarchy:${NC}" +echo " 1. $ROOT_CN (25y, OFFLINE)" +echo " 2. $TSA_ROOT_CN (15y, OFFLINE)" +echo " 3. $TSA_INTER_CN (10y)" +echo " 4. $TSA_SIGN_CN (2y)" +echo "" +echo -e "${BLUE}Paths:${NC}" +echo " CA_DIR : $CA_DIR" +echo " PUBLIC_DIR : $PUBLIC_DIR" +echo " CONFIG_PATH : $CONFIG_PATH" +echo "" +echo -e "${BLUE}Service:${NC}" +echo " user/group : $SERVICE_USER:$SERVICE_GROUP" +echo " policy OID : $TSA_POLICY_OID" +echo "" + +# Warn if the user is reusing the placeholder OID +if [ "$TSA_POLICY_OID" = "1.3.6.1.4.1.99999.1.1" ]; then + echo -e "${YELLOW}WARNING: You are using the placeholder policy OID.${NC}" + echo -e "${YELLOW} Please allocate your own at https://pen.iana.org/ before production use.${NC}" + echo "" +fi + +# ── Pre-flight ──────────────────────────────────────────────── +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}This script must be run as root (sudo).${NC}" + exit 1 +fi + +if ! id "$SERVICE_USER" >/dev/null 2>&1; then + echo -e "${RED}Service user '$SERVICE_USER' does not exist.${NC}" + echo "Create it first: useradd -r -m -s /sbin/nologin $SERVICE_USER" + exit 1 +fi + +if [ -d "$CA_DIR" ]; then + echo -e "${RED}Warning: $CA_DIR already exists. It will be reset.${NC}" + read -p "Continue? (yes/no): " -r CONFIRM + if [ "$CONFIRM" != "yes" ]; then + echo "Aborted." + exit 0 + fi + rm -rf "$CA_DIR" +fi + +# ── Directory structure ─────────────────────────────────────── +for tier in root tsa-root intermediate tsa; do + mkdir -p "$CA_DIR/$tier"/{private,certs,crl,newcerts} + touch "$CA_DIR/$tier/index.txt" + # IMPORTANT: OpenSSL parses the serial file as a hex string and requires + # an EVEN number of characters. "1000" (4 chars) is fine, "10000" + # (5 chars) would trigger "a2i_ASN1_INTEGER: odd number of chars". + # If you change the start value later, always keep it even-length — + # e.g. "010000" instead of "10000". + echo "1000" > "$CA_DIR/$tier/serial" + echo "1000" > "$CA_DIR/$tier/crlnumber" +done + +chmod 700 "$CA_DIR/root/private" +chmod 700 "$CA_DIR/tsa-root/private" +chmod 700 "$CA_DIR/intermediate/private" +chmod 750 "$CA_DIR/tsa/private" +mkdir -p "$PUBLIC_DIR" +mkdir -p "$(dirname "$CONFIG_PATH")" + +# ── openssl.cnf — Tier 1: Root CA ───────────────────────────── +cat > "$CA_DIR/root/openssl.cnf" << EOF +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = $CA_DIR/root +certs = \$dir/certs +crl_dir = \$dir/crl +new_certs_dir = \$dir/newcerts +database = \$dir/index.txt +serial = \$dir/serial +private_key = \$dir/private/root.key +certificate = \$dir/certs/root.crt +crlnumber = \$dir/crlnumber +default_md = sha256 +policy = policy_loose +default_days = 3650 +copy_extensions = copy + +[ policy_loose ] +countryName = optional +organizationName = optional +commonName = supplied + +[ req ] +default_bits = 4096 +distinguished_name = req_distinguished_name +string_mask = utf8only +default_md = sha256 +prompt = no + +[ req_distinguished_name ] +C = $COUNTRY +O = $ROOT_ORG +CN = $ROOT_CN + +[ v3_root_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:true +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[ v3_sub_root_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:true, pathlen:1 +keyUsage = critical, digitalSignature, cRLSign, keyCertSign +EOF + +# ── openssl.cnf — Tier 2: TSA Root CA ───────────────────────── +cat > "$CA_DIR/tsa-root/openssl.cnf" << EOF +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = $CA_DIR/tsa-root +certs = \$dir/certs +crl_dir = \$dir/crl +new_certs_dir = \$dir/newcerts +database = \$dir/index.txt +serial = \$dir/serial +private_key = \$dir/private/tsa-root.key +certificate = \$dir/certs/tsa-root.crt +crlnumber = \$dir/crlnumber +default_md = sha256 +policy = policy_loose +default_days = 3650 +copy_extensions = copy + +[ policy_loose ] +countryName = optional +organizationName = optional +commonName = supplied + +[ req ] +default_bits = 4096 +distinguished_name = req_distinguished_name +string_mask = utf8only +default_md = sha256 +prompt = no + +[ req_distinguished_name ] +C = $COUNTRY +O = $TSA_ROOT_ORG +CN = $TSA_ROOT_CN + +[ v3_intermediate_ca ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:true, pathlen:0 +keyUsage = critical, digitalSignature, cRLSign, keyCertSign +EOF + +# ── openssl.cnf — Tier 3: TSA Intermediate CA ───────────────── +cat > "$CA_DIR/intermediate/openssl.cnf" << EOF +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = $CA_DIR/intermediate +certs = \$dir/certs +crl_dir = \$dir/crl +new_certs_dir = \$dir/newcerts +database = \$dir/index.txt +serial = \$dir/serial +private_key = \$dir/private/intermediate.key +certificate = \$dir/certs/intermediate.crt +crlnumber = \$dir/crlnumber +default_md = sha384 +policy = policy_loose +default_days = 730 +copy_extensions = copy + +[ policy_loose ] +countryName = optional +organizationName = optional +commonName = supplied + +[ req ] +default_bits = 4096 +distinguished_name = req_distinguished_name +string_mask = utf8only +default_md = sha384 +prompt = no + +[ req_distinguished_name ] +C = $COUNTRY +O = $TSA_ROOT_ORG +CN = $TSA_INTER_CN + +[ v3_tsa ] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer +basicConstraints = critical, CA:false +keyUsage = critical, digitalSignature +extendedKeyUsage = critical, timeStamping +EOF + +echo -e "${GREEN}✓ Configuration files created${NC}" + +# ── [1/4] Root CA ───────────────────────────────────────────── +echo -e "\n${BLUE}[1/4] $ROOT_CN (25 years)...${NC}" +openssl genrsa -out "$CA_DIR/root/private/root.key" 4096 +chmod 400 "$CA_DIR/root/private/root.key" +openssl req -new -x509 \ + -config "$CA_DIR/root/openssl.cnf" \ + -key "$CA_DIR/root/private/root.key" \ + -out "$CA_DIR/root/certs/root.crt" \ + -days 9125 \ + -extensions v3_root_ca \ + -subj "/C=$COUNTRY/O=$ROOT_ORG/CN=$ROOT_CN" +echo -e "${GREEN}✓ Root CA created${NC}" +openssl x509 -noout -subject -dates -in "$CA_DIR/root/certs/root.crt" + +# ── [2/4] TSA Root CA ───────────────────────────────────────── +echo -e "\n${BLUE}[2/4] $TSA_ROOT_CN (15 years)...${NC}" +openssl genrsa -out "$CA_DIR/tsa-root/private/tsa-root.key" 4096 +chmod 400 "$CA_DIR/tsa-root/private/tsa-root.key" +openssl req -new \ + -key "$CA_DIR/tsa-root/private/tsa-root.key" \ + -out "$CA_DIR/tsa-root/certs/tsa-root.csr" \ + -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_ROOT_CN" +openssl ca -batch \ + -config "$CA_DIR/root/openssl.cnf" \ + -extensions v3_sub_root_ca \ + -days 5475 -notext \ + -in "$CA_DIR/tsa-root/certs/tsa-root.csr" \ + -out "$CA_DIR/tsa-root/certs/tsa-root.crt" +echo -e "${GREEN}✓ TSA Root CA created${NC}" +openssl x509 -noout -subject -dates -in "$CA_DIR/tsa-root/certs/tsa-root.crt" + +# ── [3/4] TSA Intermediate CA ───────────────────────────────── +echo -e "\n${BLUE}[3/4] $TSA_INTER_CN (10 years)...${NC}" +openssl genrsa -out "$CA_DIR/intermediate/private/intermediate.key" 4096 +chmod 400 "$CA_DIR/intermediate/private/intermediate.key" +openssl req -new \ + -key "$CA_DIR/intermediate/private/intermediate.key" \ + -out "$CA_DIR/intermediate/certs/intermediate.csr" \ + -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_INTER_CN" +openssl ca -batch \ + -config "$CA_DIR/tsa-root/openssl.cnf" \ + -extensions v3_intermediate_ca \ + -days 3650 -notext \ + -in "$CA_DIR/intermediate/certs/intermediate.csr" \ + -out "$CA_DIR/intermediate/certs/intermediate.crt" +echo -e "${GREEN}✓ TSA Intermediate CA created${NC}" + +# ── [4/4] TSA Signing Certificate ───────────────────────────── +echo -e "\n${BLUE}[4/4] $TSA_SIGN_CN (2 years)...${NC}" +openssl genrsa -out "$CA_DIR/tsa/private/tsa.key" 4096 +chown "root:$SERVICE_GROUP" "$CA_DIR/tsa/private/tsa.key" +chmod 440 "$CA_DIR/tsa/private/tsa.key" +chown "root:$SERVICE_GROUP" "$CA_DIR/tsa/private" +chmod 750 "$CA_DIR/tsa/private" +openssl req -new \ + -key "$CA_DIR/tsa/private/tsa.key" \ + -out "$CA_DIR/tsa/certs/tsa.csr" \ + -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_SIGN_CN" +openssl ca -batch \ + -config "$CA_DIR/intermediate/openssl.cnf" \ + -extensions v3_tsa \ + -days 730 -notext \ + -in "$CA_DIR/tsa/certs/tsa.csr" \ + -out "$CA_DIR/tsa/certs/tsa.crt" + +# chain.pem = Intermediate only (OpenSSL 3.x: ess_cert_id_chain = no) +cp "$CA_DIR/intermediate/certs/intermediate.crt" "$CA_DIR/tsa/certs/chain.pem" +echo -e "${GREEN}✓ TSA Signing Certificate created${NC}" + +# ── Public certs ────────────────────────────────────────────── +# ca.crt = Root (trust anchor) +cp "$CA_DIR/root/certs/root.crt" "$PUBLIC_DIR/ca.crt" +cp "$CA_DIR/tsa-root/certs/tsa-root.crt" "$PUBLIC_DIR/tsa-root.crt" +cp "$CA_DIR/intermediate/certs/intermediate.crt" "$PUBLIC_DIR/intermediate.crt" +cp "$CA_DIR/tsa/certs/tsa.crt" "$PUBLIC_DIR/tsa.crt" +cp "$CA_DIR/tsa/certs/chain.pem" "$PUBLIC_DIR/chain.pem" + +# fullchain.pem = all 3 CAs for easy verification +cat "$CA_DIR/intermediate/certs/intermediate.crt" \ + "$CA_DIR/tsa-root/certs/tsa-root.crt" \ + "$CA_DIR/root/certs/root.crt" \ + > "$PUBLIC_DIR/fullchain.pem" + +chmod 644 "$PUBLIC_DIR"/*.crt "$PUBLIC_DIR"/*.pem + +# ── openssl-tsa.cnf ─────────────────────────────────────────── +cat > "$CONFIG_PATH" << EOF +[ tsa ] +default_tsa = tsa_config + +[ tsa_config ] +dir = $CA_DIR/tsa +serial = \$dir/serial +crypto_device = builtin +signer_cert = $CA_DIR/tsa/certs/tsa.crt +signer_key = $CA_DIR/tsa/private/tsa.key +signer_digest = sha256 +default_policy = $TSA_POLICY_OID +# Do NOT set other_policies — an empty value crashes OpenSSL 3.x +digests = sha256, sha384, sha512 +accuracy = secs:1 +clock_precision_digits = 0 +ordering = yes +tsa_name = yes +ess_cert_id_chain = no +EOF + +# ────────────────────────────────────────────────────────────── +# Set runtime permissions for the service user +# +# The service (service.js) runs as $SERVICE_USER:$SERVICE_GROUP +# and must be able to write serial, index.txt, and newcerts/. +# Only the TSA tier is writable at runtime — the upper CA tiers +# (Root, TSA-Root, Intermediate) remain root:root. +# ────────────────────────────────────────────────────────────── +echo -e "\n${BLUE}Setting runtime permissions for service user...${NC}" + +chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/serial" +chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/index.txt" +chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/newcerts" +chmod 644 "$CA_DIR/tsa/serial" +chmod 644 "$CA_DIR/tsa/index.txt" +chmod 755 "$CA_DIR/tsa/newcerts" + +echo " serial: $(stat -c '%U:%G %a' "$CA_DIR/tsa/serial")" +echo " index.txt: $(stat -c '%U:%G %a' "$CA_DIR/tsa/index.txt")" +echo " newcerts/: $(stat -c '%U:%G %a' "$CA_DIR/tsa/newcerts")" + +# ────────────────────────────────────────────────────────────── +# Smoke test: verify serial generation actually works +# ────────────────────────────────────────────────────────────── +echo -e "\n${BLUE}Smoke test — serial generation...${NC}" + +TESTFILE=$(mktemp) +echo "smoke-test-$(date +%s)" > "$TESTFILE" + +# Generate TSQ as the service user (no root involvement) +sudo -u "$SERVICE_USER" openssl ts -query \ + -data "$TESTFILE" -cert -sha256 -no_nonce \ + -out "${TESTFILE}.tsq" 2>/dev/null + +# Generate TSR. If permissions are wrong, this writes a non-Granted TSR +# with exit code 0 — we MUST verify the status explicitly. +sudo -u "$SERVICE_USER" openssl ts -reply \ + -config "$CONFIG_PATH" \ + -queryfile "${TESTFILE}.tsq" \ + -signer "$CA_DIR/tsa/certs/tsa.crt" \ + -inkey "$CA_DIR/tsa/private/tsa.key" \ + -chain "$CA_DIR/tsa/certs/chain.pem" \ + -out "${TESTFILE}.tsr" 2>/dev/null + +STATUS=$(openssl ts -reply -in "${TESTFILE}.tsr" -text 2>/dev/null | grep "Status:" || true) + +if ! echo "$STATUS" | grep -q "Granted"; then + echo -e "${RED}✗ Smoke test FAILED:${NC} $STATUS" + echo -e "${RED}Check filesystem permissions on $CA_DIR/tsa/${NC}" + rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" + exit 1 +fi + +SERIAL1=$(openssl ts -reply -in "${TESTFILE}.tsr" -text 2>/dev/null | grep "Serial" | head -1) +echo -e " ${GREEN}✓${NC} First request: $STATUS ($SERIAL1)" + +# Second request — serial MUST increment +sudo -u "$SERVICE_USER" openssl ts -reply \ + -config "$CONFIG_PATH" \ + -queryfile "${TESTFILE}.tsq" \ + -signer "$CA_DIR/tsa/certs/tsa.crt" \ + -inkey "$CA_DIR/tsa/private/tsa.key" \ + -chain "$CA_DIR/tsa/certs/chain.pem" \ + -out "${TESTFILE}.tsr2" 2>/dev/null + +SERIAL2=$(openssl ts -reply -in "${TESTFILE}.tsr2" -text 2>/dev/null | grep "Serial" | head -1) + +if [ "$SERIAL1" = "$SERIAL2" ]; then + echo -e "${RED}✗ Serial counter NOT incrementing: $SERIAL1 == $SERIAL2${NC}" + echo -e "${RED}Check that $CA_DIR/tsa/serial is writable by $SERVICE_USER.${NC}" + rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" "${TESTFILE}.tsr2" + exit 1 +fi + +echo -e " ${GREEN}✓${NC} Second request: $SERIAL2" +echo -e " ${GREEN}✓ Serial counter increments correctly.${NC}" + +rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" "${TESTFILE}.tsr2" + +# ── Summary ─────────────────────────────────────────────────── +echo "" +echo -e "${BLUE}============================================${NC}" +echo -e "${GREEN}Setup complete!${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" +echo "Certificate hierarchy:" +echo "" +openssl x509 -noout -subject -dates -in "$CA_DIR/root/certs/root.crt" +openssl x509 -noout -subject -dates -in "$CA_DIR/tsa-root/certs/tsa-root.crt" +openssl x509 -noout -subject -dates -in "$CA_DIR/intermediate/certs/intermediate.crt" +openssl x509 -noout -subject -dates -in "$CA_DIR/tsa/certs/tsa.crt" +echo "" +echo "Public certificates: $PUBLIC_DIR" +echo "TSA config: $CONFIG_PATH" +echo "TSA endpoint (after service deployment): https://$DOMAIN" +echo "" +echo -e "${YELLOW}IMPORTANT — secure the root keys offline:${NC}" +echo " $CA_DIR/root/private/root.key" +echo " $CA_DIR/tsa-root/private/tsa-root.key" +echo "" +echo "Export them to a USB stick, verify the copy, then delete from this server." +echo "These keys are only needed every 2 years to renew the TSA signing certificate" +echo "and after disasters to recover the CA chain." +echo ""