diff --git a/.env.example b/.env.example index ab2460d..8e56b13 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,23 @@ SIGN_DB_PATH=./data/sign.db # Provider used by default when no --provider flag is given. One of: dropbox|docusign|signwell. SIGN_PROVIDER=dropbox +# --- Audit integrity (optional) --- +# Set an HMAC key (held outside the DB) to upgrade the audit chain from an +# unkeyed SHA-256 hash chain to a keyed HMAC chain — forging it then requires +# the key, not just the public algorithm. Use ONE of these; leave both unset +# to keep the default unkeyed (backward-compatible) chain. +SIGN_AUDIT_HMAC_KEY= +# SIGN_AUDIT_HMAC_KEY_FILE=/path/to/audit-hmac.key + +# --- Timestamping (optional) --- +# Override the RFC 3161 TSA. Must be https:// (a timestamp is a trust anchor). +# SIGN_TSA_URL=https://timestamp.digicert.com + +# --- Embedded signing return URL (optional) --- +# Pin which hosts --return-url may point at (comma-separated, exact match; +# localhost is always allowed). Unset = any https host is accepted. +# SIGN_RETURN_URL_ALLOWED_HOSTS=app.example.com,portal.example.com + # --- Dropbox Sign --- DROPBOX_SIGN_API_KEY= # Set to true while validating against Dropbox Sign test mode. diff --git a/docs/reference/security-controls.md b/docs/reference/security-controls.md index 5ebb943..244422d 100644 --- a/docs/reference/security-controls.md +++ b/docs/reference/security-controls.md @@ -73,3 +73,31 @@ An agent driving the requester side never sees signer tokens — `request show` - `--require-signer-email ` — the resolved signer must match. All three throw `PRE_SIGN_*_MISMATCH` errors (exit `3`) before the sign attempt is recorded. The audit chain doesn't grow on a failed pre-sign check. + +## Verified RFC 3161 timestamps + +`audit timestamp` and `audit anchor` obtain an RFC 3161 timestamp token over the audit chain head (or a manifest of all heads) and store it as a re-verifiable artifact. The token is **cryptographically verified**, not merely status-checked: + +- The TSA's CMS (`SignedData`) signature over the `TSTInfo` is verified with the signer certificate's public key. +- The signer certificate must carry `extendedKeyUsage = id-kp-timeStamping` (RFC 3161 §2.3). +- The token's `messageImprint` must equal the digest we asked to be stamped — a valid token over *different* data is rejected. +- Optionally, the signer can be required to chain to a supplied trust anchor. + +The authoritative result is surfaced as `cryptographicallyVerified` and recorded into the `audit.timestamped` / `audit.anchored` events. The legacy `granted` (PKIStatus) and `containsDigest` fields are retained for compatibility but are **not** trustworthy on their own. + +TSA transport is HTTPS-only: `issueRfc3161Timestamp` refuses a plaintext `http://` TSA URL (a timestamp is a trust anchor and must not be downgradeable). A localhost TSA, or `SIGN_ALLOW_INSECURE_TSA=1`, is accepted for trusted local test servers. + +## Keyed audit chain (optional HMAC) + +By default the audit chain is an unkeyed SHA-256 hash chain: tamper-evident against a naive edit, but because the algorithm is public, anyone with write access to the database file could recompute a fully self-consistent forged chain. For deployments that need integrity against a local-file attacker, configure an HMAC key held **outside** the database: + +- `SIGN_AUDIT_HMAC_KEY=` — raw key, or +- `SIGN_AUDIT_HMAC_KEY_FILE=` — file containing the key. + +When a key is set, new audit events are written with `hash_algo = hmac-sha256` and their chain hash is an HMAC over the event body (with the algorithm bound in). Forging a keyed chain then requires the key, not just the algorithm. The design is: + +- **Backward compatible.** Existing unkeyed chains hash byte-identically and keep verifying. Each row records its own `hash_algo`, so mixed-history databases verify correctly. +- **Fail-closed.** A keyed chain cannot be verified without the key — verification fails rather than silently skipping the integrity check. +- **Downgrade-resistant.** Once a chain has a keyed row, any later unkeyed (legacy) row is flagged as tampering, so an attacker can't "downgrade" the chain back to the forgeable scheme. + +Keep the key in your secrets manager / KMS, not alongside the database. Losing the key means keyed history can no longer be verified (the events remain readable; only the integrity proof is lost). diff --git a/fixtures/rfc3161/README.md b/fixtures/rfc3161/README.md new file mode 100644 index 0000000..e346421 --- /dev/null +++ b/fixtures/rfc3161/README.md @@ -0,0 +1,38 @@ +# RFC 3161 test fixtures + +Real, self-issued RFC 3161 timestamp material used by +`src/tests/timestamp-verify.test.ts` to exercise the CMS verification in +`src/lib/timestamp-verify.ts`. Everything here is a throwaway test CA — no +production trust is implied. + +| File | What it is | +| --- | --- | +| `stamped-data.bin` | The bytes that were timestamped. | +| `stamped-data.sha256` | `sha256(stamped-data.bin)` — the messageImprint the token must cover. | +| `valid-token.tsr` | A valid `TimeStampResp` (DER) over that digest, signed by `tsa-signer.crt`. | +| `tsa-signer.crt` | The TSA leaf certificate (carries `extendedKeyUsage = timeStamping`). | +| `test-ca.crt` | The root CA that issued the TSA cert (use as a trust anchor). | + +## Regenerating + +```sh +# Root CA +openssl req -x509 -newkey rsa:2048 -keyout ca.key -out test-ca.crt -days 3650 \ + -nodes -subj "/CN=Sign CLI Test TSA Root/O=Sign CLI Test" + +# TSA leaf with the required timeStamping EKU +openssl req -newkey rsa:2048 -keyout tsa.key -out tsa.csr -nodes \ + -subj "/CN=Sign CLI Test TSA/O=Sign CLI Test" +printf '[v]\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=critical,timeStamping\nbasicConstraints=critical,CA:FALSE\n' > ext.cnf +openssl x509 -req -in tsa.csr -CA test-ca.crt -CAkey ca.key -CAcreateserial \ + -out tsa-signer.crt -days 3650 -extfile ext.cnf -extensions v + +# Issue a token over the data +printf 'sign-cli-audit-chain-head-digest' > stamped-data.bin +openssl ts -query -data stamped-data.bin -sha256 -cert -out q.tsq +# ...with a tsa.cnf pointing signer_cert=tsa-signer.crt, certs=test-ca.crt, signer_key=tsa.key +openssl ts -reply -config tsa.cnf -section tsa_config -queryfile q.tsq -out valid-token.tsr +``` + +Note: `openssl ts -reply` refuses to sign with a certificate that lacks the +`timeStamping` EKU, which is itself a good sign the requirement is real. diff --git a/fixtures/rfc3161/stamped-data.bin b/fixtures/rfc3161/stamped-data.bin new file mode 100644 index 0000000..65eb2ac --- /dev/null +++ b/fixtures/rfc3161/stamped-data.bin @@ -0,0 +1 @@ +sign-cli-audit-chain-head-digest \ No newline at end of file diff --git a/fixtures/rfc3161/stamped-data.sha256 b/fixtures/rfc3161/stamped-data.sha256 new file mode 100644 index 0000000..8705dc0 --- /dev/null +++ b/fixtures/rfc3161/stamped-data.sha256 @@ -0,0 +1 @@ +e3eee92130b53a4a067e127781a63222b9e22c180d07fa4bade6aef3abaa134e diff --git a/fixtures/rfc3161/test-ca.crt b/fixtures/rfc3161/test-ca.crt new file mode 100644 index 0000000..7a948b8 --- /dev/null +++ b/fixtures/rfc3161/test-ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUzCCAjugAwIBAgIUdnjHYgxZA/gs0WEc27WZtg4HTdIwDQYJKoZIhvcNAQEL +BQAwOTEfMB0GA1UEAwwWU2lnbiBDTEkgVGVzdCBUU0EgUm9vdDEWMBQGA1UECgwN +U2lnbiBDTEkgVGVzdDAeFw0yNjA1MzExMjAyNDBaFw0zNjA1MjgxMjAyNDBaMDkx +HzAdBgNVBAMMFlNpZ24gQ0xJIFRlc3QgVFNBIFJvb3QxFjAUBgNVBAoMDVNpZ24g +Q0xJIFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCN2H95Q6MC +LpjMRwkL97wpmEELubgAM8J+fvVVSuvWz4qxTc5DBEK++6XDki5AUjLDvVVXhpx2 +RNdj+FEKULmJ9GVshvd11E1/asMNvvYq0T4TxvzHSWIHamqYkWuJUQDXz1+8tFe9 +gSAwhlC5kL7G13aeqvudRXgOBCl/ktjBqXoPdrT04JL4THr/TbaHt86Y9nPuvZ1f +Eb6kIqOrk3bSx42znBltEBIljKub4Fa9I/ieWuyv/gi+Dep3eyqZlgqYqMcWLnn3 +3oqv551vrSDN08wjJ0UObQDziZjhMc2dwKefHS1ZfN2+iEvmFoqrvZrsoHCr7z8t +1FVb6Rsz+J87AgMBAAGjUzBRMB0GA1UdDgQWBBSAadzUP7TPLE6kXCmd5QLWrrzk +4DAfBgNVHSMEGDAWgBSAadzUP7TPLE6kXCmd5QLWrrzk4DAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAltNfqXOLSwLkRlrcqhu1tYpZGqjKh9AGW +qBruzo1qgzMowaHpul31/rXIPOl7MVHx0NNmIK804zwsoFgOywc1Q8IZVd/xoKgE +GIEclz6gRJFGeR8Y77V6mqzkRpZEs192SM9tlsllcYcHgyGxictKY0JoTe//qJ0/ +g0B3+kHQJT+QAiZ6oiO0yY6kLUpw6Ejjv6flF/p+vmoCPqgoDNIOFcjMQJEx4Zf/ +7gvaDIPg3tEAtVQxT1gtRFXpRTk+itUYTzm/TuqfKC1cJlVc/a/oUxbTirydmYZK +Gnk/DbpmF6O5grEvDGTNj5/3bl0ttoyBjy/wnVeIvO7nnsx+Jh5o +-----END CERTIFICATE----- diff --git a/fixtures/rfc3161/tsa-signer.crt b/fixtures/rfc3161/tsa-signer.crt new file mode 100644 index 0000000..6ec177c --- /dev/null +++ b/fixtures/rfc3161/tsa-signer.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDczCCAlugAwIBAgIUCstX7xEj7K73a6/xRA/pfCw2OGEwDQYJKoZIhvcNAQEL +BQAwOTEfMB0GA1UEAwwWU2lnbiBDTEkgVGVzdCBUU0EgUm9vdDEWMBQGA1UECgwN +U2lnbiBDTEkgVGVzdDAeFw0yNjA1MzExMjAyNDBaFw0zNjA1MjgxMjAyNDBaMDQx +GjAYBgNVBAMMEVNpZ24gQ0xJIFRlc3QgVFNBMRYwFAYDVQQKDA1TaWduIENMSSBU +ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx+2VeKJdiyNFCKib +aMvM0ecqzA+6aECTtTCDi7orY9Jvg5ZNsxaV6M6bepKvfTozYGuvW+QAZhIpRFtv +kZHC0jxV2Hkwpq+ryDtroVamYWgPgZY8qgMFDjhc1NRJc/5/Kdev0PPS8RBXoE2F +I4oXr3bZxkqEVys2jAR2f/ROyvofR6iuqXRTqt8NDX7bNBloGskGOKsaS1MvJusq +2lcZEIOOkT0nmSq/vnkmsbhF8ACTb435qntIvL7wv2MWxvYF+uou5LyHxAgfQUzb +T8EE0/DsCsbXtaA5Lo60pu/T1WW6Xlc+ld3ZOZrkbA1j1kw4pZ0Ci2EZZsAv5LjJ +Y9dszQIDAQABo3gwdjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYB +BQUHAwgwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUgCq5zOibkbPBWEHuIUgSzXBd +oiQwHwYDVR0jBBgwFoAUgGnc1D+0zyxOpFwpneUC1q685OAwDQYJKoZIhvcNAQEL +BQADggEBAFKIoDA+KC/qpx4DVfohpWeYhhFljHCJ5cuk01AJ13c+DfVYVhxyYHI/ ++XRRNdcn3Y9zFIMADQz2YNKUFtmmHVFydZi/gxbzekJc087+MY99kephxQcFurcZ +pXMAevdqBxMQQ8WcDnkmU0q6077S3lXZVmZz6hwOdpx6OesFNA2Hn232bR2eL7Z7 +lUT6k3wyUURaW/W0TaIJcbamE+Y0SA/1ab/6lWtxkAQ3zQdAKcpSaE+iyJvZhB3y +F9s9hWInEVcRdQVQL5GtUZohVJCJjs/o5VVR+oRierLc4yA22eEzJu4PFJ0vjZcX +zBoy6r0WQ0wrEnZx3VtjGk3Ep2n1KO0= +-----END CERTIFICATE----- diff --git a/fixtures/rfc3161/valid-token.tsr b/fixtures/rfc3161/valid-token.tsr new file mode 100644 index 0000000..e4bdc63 Binary files /dev/null and b/fixtures/rfc3161/valid-token.tsr differ diff --git a/src/cli.ts b/src/cli.ts index 10fbb0c..c37b6eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -269,7 +269,7 @@ sign db postgres-smoke --pg-url postgres://… (end-to-end async-against-pg in sign db backend [--backend sqlite|postgres] (report the active storage backend) sign mcp serve [--read-only true] [--tool ...] [--capability tools|resources|prompts ...] [--emit-events ./mcp.ndjson [--emit-events-redact true]] (stdio MCP server; --emit-events tees every JSON-RPC message in/out to NDJSON; --emit-events-redact masks token-shaped fields in the log; --capability/--tool/--read-only further restrict the surface) sign mcp tools [--format json|markdown] (one-shot tool catalog with input + output JSON-Schema; markdown renders a docs page) -sign serve [--port 4000] [--bind 127.0.0.1] [--auth-token ] [--tls-cert ./cert.pem --tls-key ./key.pem [--tls-ca ./ca.pem]] [--web-demo true|] [--rate-limit [--rate-limit-burst ]] [--read-only true] (HTTP REST surface; --read-only blocks the four lifecycle-mutating routes with FORBIDDEN_READ_ONLY) +sign serve [--port 4000] [--bind 127.0.0.1] [--auth-token ] [--tls-cert ./cert.pem --tls-key ./key.pem [--tls-ca ./ca.pem]] [--web-demo true|] [--rate-limit [--rate-limit-burst ]] [--trust-proxy true] [--read-only true] (HTTP REST surface; --read-only blocks the four lifecycle-mutating routes with FORBIDDEN_READ_ONLY; --trust-proxy honours X-Forwarded-For for rate-limit keying behind a trusted proxy) sign completion bash|zsh|fish (print a completion script; pipe into your shell init) Global flags: [--verbose true] Env: SIGN_DEBUG=1, SIGN_HTTP_MAX_RETRIES, SIGN_HTTP_BASE_DELAY_MS, SIGN_MAX_DOCUMENT_BYTES, SIGN_ALLOW_ABSOLUTE_DOCS @@ -2848,7 +2848,8 @@ async function main(): Promise { } : undefined; const readOnly = (flagValue(parsed, "read-only") ?? "false") === "true"; - const server = startHttpApiServer({ db, port, bind, authToken, tls, webDemoDir, rateLimit, readOnly }); + const trustProxy = (flagValue(parsed, "trust-proxy") ?? "false") === "true"; + const server = startHttpApiServer({ db, port, bind, authToken, tls, webDemoDir, rateLimit, readOnly, trustProxy }); const shutdown = () => server.close(() => process.exit(0)); process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); @@ -2858,6 +2859,7 @@ async function main(): Promise { tls: Boolean(tls), authRequired: Boolean(authToken), readOnly, + trustProxy, rateLimit: rateLimit ? { refillPerSec: rateLimit.refillPerSec, capacity: rateLimit.capacity } : null, webDemo: webDemoDir ? `${tls ? "https" : "http"}://${bind}:${port}/web-demo/index.html` : null, // Pull from listMockHttpRoutes() so this banner can't drift from the diff --git a/src/lib/audit-anchor.ts b/src/lib/audit-anchor.ts index ba886aa..05ae514 100644 --- a/src/lib/audit-anchor.ts +++ b/src/lib/audit-anchor.ts @@ -35,7 +35,7 @@ export type AnchorReport = { export async function anchorAllAuditChainHeads( db: SqliteDb, - input: { tsaUrl?: string; outDir?: string; now?: Date; since?: string } = {}, + input: { tsaUrl?: string; outDir?: string; now?: Date; since?: string; trustAnchors?: Array } = {}, ): Promise { const path = await import("node:path"); const fs = await import("node:fs"); @@ -77,7 +77,7 @@ export async function anchorAllAuditChainHeads( const digest = Buffer.from(digestHex, "hex"); const result = await issueRfc3161Timestamp({ digest, tsaUrl: input.tsaUrl }); - const inspection = inspectTimestampResponse(result.responseBuffer, digest); + const inspection = inspectTimestampResponse(result.responseBuffer, digest, input.trustAnchors); const now = input.now ?? new Date(); const stamp = nowIso(now).replace(/[:.]/g, "-"); @@ -118,6 +118,7 @@ export async function anchorAllAuditChainHeads( manifestBytes, coveredRequests: manifest.length, granted: inspection.granted, + cryptographicallyVerified: inspection.cryptographicallyVerified, }, now, }); diff --git a/src/lib/audit-key.ts b/src/lib/audit-key.ts new file mode 100644 index 0000000..41973e0 --- /dev/null +++ b/src/lib/audit-key.ts @@ -0,0 +1,53 @@ +// Optional keyed-HMAC integrity for the audit chain. +// +// By default the audit chain is an unkeyed SHA-256 hash chain: tamper-evident +// against a naive edit, but since the algorithm is public an attacker with +// write access to the DB file can recompute a fully self-consistent forged +// chain. Configuring an HMAC key (held OUTSIDE the database) upgrades the +// chain so that forging it requires the key, not just the algorithm. +// +// Resolution order (first hit wins): +// 1. SIGN_AUDIT_HMAC_KEY — raw key material (utf8), or +// 2. SIGN_AUDIT_HMAC_KEY_FILE — path to a file whose contents are the key. +// +// When neither is set, keying is OFF and the chain behaves exactly as before +// (full backward compatibility — existing chains verify unchanged). + +import { readFileSync } from "node:fs"; + +export const HASH_ALGO_LEGACY = "sha256"; +export const HASH_ALGO_HMAC = "hmac-sha256"; + +let cached: { key: Buffer | null } | null = null; + +/** Resolve the configured audit HMAC key, or null when keying is disabled. + * Cached after first read; call resetAuditHmacKeyCache() in tests that mutate + * the env between cases. */ +export function resolveAuditHmacKey(): Buffer | null { + if (cached) return cached.key; + const raw = process.env.SIGN_AUDIT_HMAC_KEY; + if (raw !== undefined && raw.length > 0) { + cached = { key: Buffer.from(raw, "utf8") }; + return cached.key; + } + const file = process.env.SIGN_AUDIT_HMAC_KEY_FILE; + if (file !== undefined && file.length > 0) { + const contents = readFileSync(file); + if (contents.length === 0) { + throw new Error(`SIGN_AUDIT_HMAC_KEY_FILE (${file}) is empty; provide key material or unset it.`); + } + cached = { key: contents }; + return cached.key; + } + cached = { key: null }; + return null; +} + +export function resetAuditHmacKeyCache(): void { + cached = null; +} + +/** True when an HMAC key is configured (the chain should be written keyed). */ +export function auditKeyingEnabled(): boolean { + return resolveAuditHmacKey() !== null; +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index aa330b8..25c50c5 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -1,9 +1,38 @@ +import crypto from "node:crypto"; import { asBackend, type DbBackend } from "./db-backend.js"; import type { SqliteDb } from "./db.js"; import { maybeNotifySignerEvent } from "./notify.js"; import { notifyResourceChanged } from "./resource-watch.js"; import { SignCliError } from "./sign-error.js"; import { nowIso, sha256, stableStringify } from "./util.js"; +import { resolveAuditHmacKey, HASH_ALGO_HMAC, HASH_ALGO_LEGACY } from "./audit-key.js"; + +// The body every chain entry hashes over. Kept as a named shape so the append +// and verify paths can't compute it differently. +type ChainBody = { + request_id: string; + event_type: string; + payload_json: string; + created_at: string; + hash_prev: string | null; +}; + +// Compute a chain entry's hash. +// +// Unkeyed (key === null): sha256(stableStringify(body)) — byte-identical to +// the original scheme, so every pre-existing chain still verifies unchanged. +// +// Keyed (key supplied): HMAC-SHA256 over the same body with `hash_algo` bound +// IN, so a keyed entry's hash can't be reproduced as if it were unkeyed (and +// vice versa). Forging a keyed chain then requires the key, not just the +// public algorithm. +export function computeChainHash(body: ChainBody, key: Buffer | null): string { + if (key === null) { + return sha256(stableStringify(body)); + } + const keyedBody = stableStringify({ ...body, hash_algo: HASH_ALGO_HMAC }); + return crypto.createHmac("sha256", key).update(keyedBody).digest("hex"); +} export type AuditChainBreak = | { kind: "hash_self_mismatch"; eventId: number; expected: string; actual: string } @@ -18,7 +47,7 @@ export type AuditVerificationResult = { export function verifyAuditChain(db: SqliteDb | DbBackend, requestId: string): AuditVerificationResult { const backend = asBackend(db); const rows = backend.prepare( - `SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, created_at + `SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at FROM audit_events WHERE request_id = ? ORDER BY id ASC`, @@ -30,7 +59,7 @@ export function verifyAuditChain(db: SqliteDb | DbBackend, requestId: string): A // so it works against the Postgres backend (whose sync prepare() throws). export async function verifyAuditChainAsync(backend: DbBackend, requestId: string): Promise { const rows = await backend.prepareAsync( - `SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, created_at + `SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at FROM audit_events WHERE request_id = ? ORDER BY id ASC`, @@ -45,11 +74,17 @@ type AuditChainRow = { payload_json: string; hash_prev: string | null; hash_self: string; + hash_algo?: string | null; created_at: string; }; function verifyChainRows(rows: AuditChainRow[]): AuditVerificationResult { + const key = resolveAuditHmacKey(); let previousHash: string | null = null; + // Downgrade protection: once any row in the chain is HMAC-keyed, every + // subsequent row must also be keyed. Otherwise an attacker who learns the + // chain has gone keyed could append legacy (unkeyed, forgeable) rows. + let seenKeyed = false; for (const row of rows) { if (row.hash_prev !== previousHash) { return { @@ -63,14 +98,35 @@ function verifyChainRows(rows: AuditChainRow[]): AuditVerificationResult { }, }; } - const expected = sha256( - stableStringify({ + const rowKeyed = row.hash_algo === HASH_ALGO_HMAC; + if (seenKeyed && !rowKeyed) { + // A legacy row after a keyed one — chain was downgraded. + return { + valid: false, + events: rows.length, + break: { kind: "hash_self_mismatch", eventId: row.id, expected: "(keyed)", actual: row.hash_self }, + }; + } + if (rowKeyed) seenKeyed = true; + // A keyed row can only be verified when the verifier holds the key. If the + // chain is keyed but no key is configured, fail closed rather than silently + // skipping the integrity check. + if (rowKeyed && key === null) { + return { + valid: false, + events: rows.length, + break: { kind: "hash_self_mismatch", eventId: row.id, expected: "(key required)", actual: row.hash_self }, + }; + } + const expected = computeChainHash( + { request_id: row.request_id, event_type: row.event_type, payload_json: row.payload_json, created_at: row.created_at, hash_prev: row.hash_prev, - }), + }, + rowKeyed ? key : null, ); if (expected !== row.hash_self) { return { @@ -104,30 +160,40 @@ const APPEND_SELECT_PREV_SQL = ORDER BY id DESC LIMIT 1`; const APPEND_INSERT_SQL = - `INSERT INTO audit_events (request_id, event_type, payload_json, hash_prev, hash_self, created_at) - VALUES (?, ?, ?, ?, ?, ?)`; + `INSERT INTO audit_events (request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`; // Compute the next chain entry from the previous hash. Pure — no I/O, no // notifications. Lifted out so the sync + async append paths share identical -// hashing logic and can't drift. +// hashing logic and can't drift. Reads the configured HMAC key (if any) so a +// keyed deployment writes keyed entries automatically. function buildNextChainEntry(input: AuditEventInput, prevHash: string | null): { hashPrev: string | null; hashSelf: string; + hashAlgo: string; createdAt: string; payloadJson: string; } { const createdAt = nowIso(input.now); const payloadJson = stableStringify(input.payload); - const hashSelf = sha256( - stableStringify({ + const key = resolveAuditHmacKey(); + const hashSelf = computeChainHash( + { request_id: input.requestId, event_type: input.eventType, payload_json: payloadJson, created_at: createdAt, hash_prev: prevHash, - }), + }, + key, ); - return { hashPrev: prevHash, hashSelf, createdAt, payloadJson }; + return { + hashPrev: prevHash, + hashSelf, + hashAlgo: key === null ? HASH_ALGO_LEGACY : HASH_ALGO_HMAC, + createdAt, + payloadJson, + }; } function emitPostAppendNotifications( @@ -158,6 +224,7 @@ export function appendAuditEvent(db: SqliteDb, input: AuditEventInput): { entry.payloadJson, entry.hashPrev, entry.hashSelf, + entry.hashAlgo, entry.createdAt, ); emitPostAppendNotifications(input, entry); @@ -180,6 +247,7 @@ export async function appendAuditEventAsync( entry.payloadJson, entry.hashPrev, entry.hashSelf, + entry.hashAlgo, entry.createdAt, ); emitPostAppendNotifications(input, entry); diff --git a/src/lib/db.ts b/src/lib/db.ts index 120ecff..340b740 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -88,6 +88,7 @@ export function openDatabase(dbPath: string): SqliteDb { payload_json TEXT NOT NULL, hash_prev TEXT, hash_self TEXT NOT NULL, + hash_algo TEXT NOT NULL DEFAULT 'sha256', created_at TEXT NOT NULL, FOREIGN KEY (request_id) REFERENCES requests(id) ); @@ -158,6 +159,11 @@ export function openDatabase(dbPath: string): SqliteDb { if (!hasColumn(db, "requests", "prefills_json")) { db.exec("ALTER TABLE requests ADD COLUMN prefills_json TEXT"); } + if (!hasColumn(db, "audit_events", "hash_algo")) { + // Existing rows were written with the unkeyed SHA-256 scheme; default + // them to 'sha256' so verifyChainRows keeps validating them unchanged. + db.exec("ALTER TABLE audit_events ADD COLUMN hash_algo TEXT NOT NULL DEFAULT 'sha256'"); + } installAuditAppendOnlyTriggers(db); diff --git a/src/lib/help-catalog.ts b/src/lib/help-catalog.ts index fbf912f..2be28c6 100644 --- a/src/lib/help-catalog.ts +++ b/src/lib/help-catalog.ts @@ -5,7 +5,7 @@ // sign examples → walkthrough snippets // Bumped manually on each release; mirrored in package.json. -export const SIGN_CLI_VERSION = "0.6.4"; +export const SIGN_CLI_VERSION = "0.6.5"; export type FlagSpec = { name: string; // e.g. "--request-id" or "--token" @@ -801,8 +801,9 @@ export const HELP_CATALOG: CommandSpec[] = [ { name: "--tls-key", description: "TLS private key PEM path." }, { name: "--tls-ca", description: "Optional CA bundle PEM (forwarded to https.createServer)." }, { name: "--web-demo", description: "true to serve the bundled dashboard from fixtures/web-demo, or a path to your own static dir. Mounts at /web-demo/index.html, same-origin (no CORS)." }, - { name: "--rate-limit", description: "Tokens per second per IP (token bucket). Over-budget requests get 429 with Retry-After. Honors X-Forwarded-For when present." }, + { name: "--rate-limit", description: "Tokens per second per IP (token bucket). Over-budget requests get 429 with Retry-After. Keys on the socket peer address unless --trust-proxy is set." }, { name: "--rate-limit-burst", description: "Bucket capacity (max burst). Defaults to 2× --rate-limit." }, + { name: "--trust-proxy", description: "true to key rate-limiting on the X-Forwarded-For header. Only enable behind a reverse proxy you control that sets it — otherwise clients can spoof the header to evade rate limiting." }, { name: "--read-only", description: "true to block the four lifecycle-mutating routes (POST /v1/sign, /v1/signer/decline, /v1/signer/reissue-token, /v1/request/receipt) with HTTP 403 + code FORBIDDEN_READ_ONLY. Read endpoints stay available — useful for compliance or production-clone dashboards." }, ], }, diff --git a/src/lib/http-api.ts b/src/lib/http-api.ts index 8461340..ac4e130 100644 --- a/src/lib/http-api.ts +++ b/src/lib/http-api.ts @@ -23,12 +23,17 @@ import { renderPrometheusMetrics } from "./prom-metrics.js"; import { TokenBucketLimiter } from "./rate-limit.js"; import { validateDocumentPath, validateOutputPath } from "./validate.js"; -function clientKey(req: http.IncomingMessage): string { - // Trust X-Forwarded-For if present (operators terminating TLS at a load - // balancer rely on it). Otherwise fall back to the socket peer address. - const fwd = req.headers["x-forwarded-for"]; - if (typeof fwd === "string" && fwd.length > 0) return fwd.split(",")[0].trim(); - if (Array.isArray(fwd) && fwd.length > 0) return fwd[0].split(",")[0].trim(); +function clientKey(req: http.IncomingMessage, trustProxy: boolean): string { + // X-Forwarded-For is client-controlled and only meaningful when a trusted + // reverse proxy / load balancer sets it. Honour it ONLY when the operator + // opted in via --trust-proxy; otherwise an attacker could rotate the header + // per request to mint a fresh rate-limit bucket each time and defeat the + // limiter. Default: key on the actual socket peer address. + if (trustProxy) { + const fwd = req.headers["x-forwarded-for"]; + if (typeof fwd === "string" && fwd.length > 0) return fwd.split(",")[0].trim(); + if (Array.isArray(fwd) && fwd.length > 0) return fwd[0].split(",")[0].trim(); + } return req.socket.remoteAddress ?? "unknown"; } @@ -394,6 +399,10 @@ export type HttpServerOptions = { // one token from the requester's bucket; over-budget requests get a 429 // with a Retry-After header. rateLimit?: { capacity: number; refillPerSec: number }; + // Trust the X-Forwarded-For header for the rate-limit client key. Only set + // this when the server sits behind a reverse proxy you control that sets + // the header — otherwise clients can spoof it to evade rate limiting. + trustProxy?: boolean; // When true, the four request-mutating routes (sign, decline, reissue-token, // request/receipt) return 403 with code FORBIDDEN_READ_ONLY. Useful for // compliance read-only views or for parking a clone of production behind a @@ -489,7 +498,7 @@ export function startHttpApiServer(opts: HttpServerOptions): http.Server | https } if (limiter) { - const decision = limiter.take(clientKey(req)); + const decision = limiter.take(clientKey(req, Boolean(opts.trustProxy))); res.setHeader("x-ratelimit-limit", String(decision.capacity)); res.setHeader("x-ratelimit-remaining", String(decision.remaining)); if (!decision.allowed) { diff --git a/src/lib/postgres-bootstrap.ts b/src/lib/postgres-bootstrap.ts index dfcb677..d7432af 100644 --- a/src/lib/postgres-bootstrap.ts +++ b/src/lib/postgres-bootstrap.ts @@ -56,8 +56,10 @@ export const POSTGRES_BOOTSTRAP_STATEMENTS: ReadonlyArray = [ payload_json TEXT NOT NULL, hash_prev TEXT, hash_self TEXT NOT NULL, + hash_algo TEXT NOT NULL DEFAULT 'sha256', created_at TEXT NOT NULL )`, + `ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS hash_algo TEXT NOT NULL DEFAULT 'sha256'`, `CREATE TABLE IF NOT EXISTS artifacts ( id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES requests(id), diff --git a/src/lib/receipt-verify.ts b/src/lib/receipt-verify.ts index fb63050..6d9461a 100644 --- a/src/lib/receipt-verify.ts +++ b/src/lib/receipt-verify.ts @@ -2,6 +2,8 @@ import { createVerify, X509Certificate } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { sha256, stableStringify } from "./util.js"; +import { computeChainHash } from "./audit.js"; +import { resolveAuditHmacKey, HASH_ALGO_HMAC } from "./audit-key.js"; export type ReceiptFileCheck = { name: string; @@ -40,6 +42,7 @@ type AuditEvent = { payload_json: string; hash_prev: string | null; hash_self: string; + hash_algo?: string | null; created_at: string; }; @@ -81,7 +84,9 @@ function checkAuditChain(bundleDir: string, errors: string[]): ReceiptChainCheck return null; } const events = Array.isArray(parsed.events) ? parsed.events : []; + const key = resolveAuditHmacKey(); let previousHash: string | null = null; + let seenKeyed = false; for (const event of events) { if (event.hash_prev !== previousHash) { return { @@ -95,14 +100,26 @@ function checkAuditChain(bundleDir: string, errors: string[]): ReceiptChainCheck }, }; } - const expected = sha256( - stableStringify({ - request_id: parsed.request?.id ?? null, + const rowKeyed = event.hash_algo === HASH_ALGO_HMAC; + // Downgrade protection mirrors verifyChainRows: no legacy row after a + // keyed one, and a keyed row can't be verified without the key. + if ((seenKeyed && !rowKeyed) || (rowKeyed && key === null)) { + return { + events: events.length, + ok: false, + break: { kind: "hash_self_mismatch", eventId: event.id, expected: rowKeyed ? "(key required)" : "(keyed)", actual: event.hash_self }, + }; + } + if (rowKeyed) seenKeyed = true; + const expected = computeChainHash( + { + request_id: parsed.request?.id ?? null as unknown as string, event_type: event.event_type, payload_json: event.payload_json, created_at: event.created_at, hash_prev: event.hash_prev, - }), + }, + rowKeyed ? key : null, ); if (expected !== event.hash_self) { return { diff --git a/src/lib/signing-service.ts b/src/lib/signing-service.ts index 85b122b..aa85b75 100644 --- a/src/lib/signing-service.ts +++ b/src/lib/signing-service.ts @@ -1986,11 +1986,12 @@ export type AuditEventRow = { payload_json: string; hash_prev: string | null; hash_self: string; + hash_algo: string; created_at: string; }; const LIST_AUDIT_EVENTS_SQL = - `SELECT id, event_type, payload_json, hash_prev, hash_self, created_at + `SELECT id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at FROM audit_events WHERE request_id = ? ORDER BY id ASC`; @@ -3096,7 +3097,7 @@ export async function inspectRequestSignedPdf( export async function timestampRequestAuditChain( db: SqliteDb, - input: { requestId: string; tsaUrl?: string; outPath?: string; now?: Date }, + input: { requestId: string; tsaUrl?: string; outPath?: string; now?: Date; trustAnchors?: Array }, ): Promise<{ tsaUrl: string; hashSelf: string; @@ -3131,7 +3132,7 @@ export async function timestampRequestAuditChain( createdAt: nowIso(now), }); - const inspection = inspectTimestampResponse(result.responseBuffer, digest); + const inspection = inspectTimestampResponse(result.responseBuffer, digest, input.trustAnchors); appendAuditEvent(db, { requestId: input.requestId, @@ -3141,6 +3142,8 @@ export async function timestampRequestAuditChain( bytes: result.responseBuffer.length, hashSelf: lastEvent.hash_self, granted: inspection.granted, + cryptographicallyVerified: inspection.cryptographicallyVerified, + genTime: inspection.verification?.genTime ?? null, }, now, }); diff --git a/src/lib/timestamp-verify.ts b/src/lib/timestamp-verify.ts new file mode 100644 index 0000000..0b4b3be --- /dev/null +++ b/src/lib/timestamp-verify.ts @@ -0,0 +1,335 @@ +// RFC 3161 timestamp-token verification. +// +// `timestamp.ts` issues a TimeStampReq and parses the *status* of the +// TimeStampResp, but historically it never verified the cryptographic seal: +// it trusted any response whose status byte was 0/1, and its `containsDigest` +// check was an unsigned byte-search. That made the whole "RFC 3161 timestamp" +// feature spoofable — a MITM on the (plaintext, by default) TSA HTTP call +// could return any blob and have it recorded as proof. +// +// This module closes that gap. It performs the full CMS SignedData +// verification described by RFC 5652 §5 and RFC 3161: +// +// 1. Parse the TimeStampResp → ContentInfo → SignedData. +// 2. Extract the encapsulated TSTInfo and confirm its messageImprint +// equals the digest we asked the TSA to stamp (binds the token to OUR +// data — without this a valid token over someone else's data would pass). +// 3. Find the signer's certificate in the SignedData and confirm it (or a +// cert in the same chain) carries extendedKeyUsage = id-kp-timeStamping +// (RFC 3161 §2.3 — a TSA cert MUST have this and MUST be the only EKU). +// 4. Verify the signed-attributes' messageDigest equals sha256(TSTInfo), +// then verify the RSA/ECDSA signature over the DER-re-encoded +// signed-attributes using the signer cert's public key. +// 5. Optionally chain-verify the signer cert up to a provided trust anchor. +// +// All parsing reuses the project's tolerant ASN.1 reader (asn1.ts); all +// crypto uses Node's `crypto.verify` + `X509Certificate` rather than any +// hand-rolled signature math. + +import crypto, { X509Certificate } from "node:crypto"; +import { parseAsn1, decodeOid } from "./asn1.js"; +import type { Asn1Node } from "./asn1.js"; + +const OID_SIGNED_DATA = "1.2.840.113549.1.7.2"; +const OID_CONTENT_TYPE = "1.2.840.113549.1.9.3"; +const OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4"; +const OID_TST_INFO = "1.2.840.113549.1.9.16.1.4"; +const OID_EKU = "2.5.29.37"; +const OID_KP_TIMESTAMPING = "1.3.6.1.5.5.7.3.8"; + +// digestAlgorithm OIDs we understand for the SignerInfo messageDigest. +const DIGEST_ALGOS: Record = { + "2.16.840.1.101.3.4.2.1": "sha256", + "2.16.840.1.101.3.4.2.2": "sha384", + "2.16.840.1.101.3.4.2.3": "sha512", + "1.3.14.3.2.26": "sha1", +}; + +export type TimestampVerification = { + // True only when every cryptographic check below passed. + verified: boolean; + // The token's messageImprint matched the digest we expected to be stamped. + digestMatches: boolean; + // The signer cert (or one in its chain) advertised id-kp-timeStamping. + hasTimeStampingEku: boolean; + // The CMS signature over the signed attributes verified. + signatureValid: boolean; + // The signer chained to the provided trust anchor (null = not checked). + chainTrusted: boolean | null; + // The TSA's asserted signing time (genTime in TSTInfo), ISO 8601. + genTime: string | null; + // The signer certificate subject, for display / audit. + signerSubject: string | null; + // Non-fatal notes and the specific reason verification failed, if it did. + reasons: string[]; +}; + +function fail(reasons: string[], partial: Partial = {}): TimestampVerification { + return { + verified: false, + digestMatches: false, + hasTimeStampingEku: false, + signatureValid: false, + chainTrusted: null, + genTime: null, + signerSubject: null, + reasons, + ...partial, + }; +} + +function oidOf(node: Asn1Node): string | null { + if (node.tagClass === 0 && node.tagNumber === 6) return decodeOid(node.contents); + return null; +} + +// Walk a constructed node's direct children for the first OBJECT IDENTIFIER +// whose value equals `oid`, returning the *containing* node. +function childContainingOid(parent: Asn1Node, oid: string): Asn1Node | null { + if (!parent.children) return null; + for (const child of parent.children) { + if (child.children?.some((c) => oidOf(c) === oid)) return child; + } + return null; +} + +// Re-tag an IMPLICIT [0] signed-attributes node back to the SET OF tag the +// signature is actually computed over (RFC 5652 §5.4): same contents, leading +// byte 0xA0 → 0x31. We rebuild the length too so multi-byte lengths survive. +function reencodeSignedAttrsAsSet(signedAttrs: Asn1Node): Buffer { + const body = signedAttrs.raw.subarray(signedAttrs.headerLength); + const header = signedAttrs.raw.subarray(0, signedAttrs.headerLength); + const newHeader = Buffer.from(header); + newHeader[0] = 0x31; // universal SET OF + return Buffer.concat([newHeader, body]); +} + +function extractCertificates(signedData: Asn1Node): Buffer[] { + const certs: Buffer[] = []; + if (!signedData.children) return certs; + for (const child of signedData.children) { + // [0] IMPLICIT certificates + if (child.tagClass === 2 && child.tagNumber === 0 && child.children) { + for (const cert of child.children) { + if (cert.tagClass === 0 && cert.tagNumber === 16) certs.push(Buffer.from(cert.raw)); + } + } + } + return certs; +} + +// A SignerInfo identifies its cert by issuer+serial (IssuerAndSerialNumber) or +// by subjectKeyIdentifier ([0]). We match on serial number, which is robust +// and avoids re-encoding the issuer DN. Returns the matching cert or, when +// only one cert is present, that one. +function selectSignerCert(certs: Buffer[], serialHex: string | null): X509Certificate | null { + const parsed: X509Certificate[] = []; + for (const der of certs) { + try { parsed.push(new X509Certificate(der)); } catch { /* skip unparseable */ } + } + if (parsed.length === 0) return null; + if (serialHex) { + const want = serialHex.toLowerCase().replace(/^0+/, ""); + for (const c of parsed) { + const have = c.serialNumber.toLowerCase().replace(/^0+/, ""); + if (have === want) return c; + } + } + return parsed.length === 1 ? parsed[0] : null; +} + +// id-kp-timeStamping must be present (RFC 3161 §2.3). We look at the signer +// cert's EKU extension via the raw DER (Node's X509Certificate doesn't expose +// EKU directly across all versions, so we parse it). +function certHasTimeStampingEku(certDer: Buffer): boolean { + try { + const cert = parseAsn1(certDer); + // Certificate → TBSCertificate → ... → extensions [3] → SEQUENCE OF Extension + const tbs = cert.children?.[0]; + if (!tbs?.children) return false; + const extsWrapper = tbs.children.find((c) => c.tagClass === 2 && c.tagNumber === 3); + const exts = extsWrapper?.children?.[0]; + if (!exts?.children) return false; + for (const ext of exts.children) { + const oid = ext.children?.[0] ? oidOf(ext.children[0]) : null; + if (oid !== OID_EKU) continue; + // value OCTET STRING wraps a SEQUENCE OF OID + const octet = ext.children?.find((c) => c.tagClass === 0 && c.tagNumber === 4); + if (!octet) return false; + const ekuSeq = parseAsn1(octet.contents); + return Boolean(ekuSeq.children?.some((o) => oidOf(o) === OID_KP_TIMESTAMPING)); + } + } catch { /* fall through */ } + return false; +} + +function readGeneralizedTime(node: Asn1Node): string | null { + // GeneralizedTime: YYYYMMDDHHMMSS[.fff]Z + const s = node.contents.toString("latin1"); + const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(s); + if (!m) return null; + const [, y, mo, d, h, mi, se] = m; + return `${y}-${mo}-${d}T${h}:${mi}:${se}Z`; +} + +/** + * Verify an RFC 3161 TimeStampResp's cryptographic seal. + * + * @param responseBuffer the raw TimeStampResp DER from the TSA. + * @param expectedDigest the digest we asked the TSA to stamp (the messageImprint + * must equal this — otherwise the token covers different data). + * @param trustAnchors optional PEM/DER CA certs; when provided, the signer cert + * must chain to one of them. When omitted, chainTrusted is left null + * (signature + EKU + digest are still enforced). + */ +export function verifyTimestampToken( + responseBuffer: Buffer, + expectedDigest: Buffer, + trustAnchors?: Array, +): TimestampVerification { + let root: Asn1Node; + try { + root = parseAsn1(responseBuffer); + } catch (error) { + return fail([`TimeStampResp parse failed: ${error instanceof Error ? error.message : String(error)}`]); + } + + // TimeStampResp ::= SEQUENCE { status PKIStatusInfo, timeStampToken ContentInfo OPTIONAL } + const tokenContentInfo = root.children?.find( + (c) => c.children?.some((cc) => oidOf(cc) === OID_SIGNED_DATA), + ); + if (!tokenContentInfo) { + return fail(["No SignedData timeStampToken found in TimeStampResp (TSA may have returned an error/status-only response)."]); + } + // ContentInfo → [0] content → SignedData SEQUENCE + const explicit = tokenContentInfo.children?.find((c) => c.tagClass === 2 && c.tagNumber === 0); + const signedData = explicit?.children?.[0]; + if (!signedData?.children) return fail(["Malformed SignedData in timeStampToken."]); + + // EncapsulatedContentInfo → eContent [0] → OCTET STRING (TSTInfo) + const encap = childContainingOid(signedData, OID_TST_INFO); + const eContentExplicit = encap?.children?.find((c) => c.tagClass === 2 && c.tagNumber === 0); + const tstOctet = eContentExplicit?.children?.find((c) => c.tagClass === 0 && c.tagNumber === 4); + if (!tstOctet) return fail(["Could not locate TSTInfo content in the token."]); + const tstInfoBytes = Buffer.from(tstOctet.contents); + + // --- (2) messageImprint binds the token to OUR digest --- + let digestMatches = false; + let genTime: string | null = null; + try { + const tst = parseAsn1(tstInfoBytes); + // TSTInfo ::= SEQUENCE { version, policy, messageImprint, serialNumber, genTime, ... } + const messageImprint = tst.children?.[2]; + const imprintDigest = messageImprint?.children?.find((c) => c.tagClass === 0 && c.tagNumber === 4); + if (imprintDigest) { + digestMatches = Buffer.from(imprintDigest.contents).equals(expectedDigest); + } + const gt = tst.children?.find((c) => c.tagClass === 0 && c.tagNumber === 24); + if (gt) genTime = readGeneralizedTime(gt); + } catch (error) { + return fail([`TSTInfo parse failed: ${error instanceof Error ? error.message : String(error)}`]); + } + if (!digestMatches) { + return fail(["Timestamp messageImprint does not match the expected digest — the token covers different data."], { genTime }); + } + + // --- locate SignerInfo --- + const signerInfos = [...signedData.children].reverse().find((c) => c.tagClass === 0 && c.tagNumber === 17); + const signerInfo = signerInfos?.children?.[0]; + if (!signerInfo?.children) return fail(["No SignerInfo in SignedData."], { digestMatches, genTime }); + + // SignerInfo ::= SEQUENCE { version, sid, digestAlgorithm, signedAttrs [0] OPTIONAL, sigAlg, signature } + const signedAttrs = signerInfo.children.find((c) => c.tagClass === 2 && c.tagNumber === 0); + if (!signedAttrs) return fail(["SignerInfo has no signed attributes (unsupported; cannot bind signature to TSTInfo)."], { digestMatches, genTime }); + const signatureOctet = signerInfo.children[signerInfo.children.length - 1]; + + // digestAlgorithm (used to hash TSTInfo for the messageDigest attr) + const digestAlgNode = signerInfo.children[2]; + const digestAlgOid = digestAlgNode?.children?.[0] ? oidOf(digestAlgNode.children[0]) : null; + const hashAlgo = (digestAlgOid && DIGEST_ALGOS[digestAlgOid]) || "sha256"; + + // serial number from IssuerAndSerialNumber (sid), for cert selection + let serialHex: string | null = null; + const sid = signerInfo.children[1]; + if (sid?.children) { + const serialNode = sid.children.find((c) => c.tagClass === 0 && c.tagNumber === 2); + if (serialNode) serialHex = serialNode.contents.toString("hex"); + } + + // --- (3) signer cert + timeStamping EKU --- + const certDers = extractCertificates(signedData); + const signerCert = selectSignerCert(certDers, serialHex); + if (!signerCert) return fail(["Could not select the signer certificate from the token."], { digestMatches, genTime }); + const signerSubject = signerCert.subject ?? null; + const signerDer = Buffer.from(signerCert.raw); + const hasTimeStampingEku = certHasTimeStampingEku(signerDer); + if (!hasTimeStampingEku) { + return fail(["Signer certificate is not marked for time-stamping (missing extendedKeyUsage id-kp-timeStamping)."], { digestMatches, genTime, signerSubject }); + } + + // --- (4a) the signed messageDigest attr must equal hash(TSTInfo) --- + const mdAttr = childContainingOid(signedAttrs, OID_MESSAGE_DIGEST); + const mdSet = mdAttr?.children?.find((c) => c.tagClass === 0 && c.tagNumber === 17); + const mdValue = mdSet?.children?.find((c) => c.tagClass === 0 && c.tagNumber === 4); + if (!mdValue) return fail(["Signed attributes have no messageDigest."], { digestMatches, genTime, signerSubject, hasTimeStampingEku }); + const tstInfoHash = crypto.createHash(hashAlgo).update(tstInfoBytes).digest(); + if (!Buffer.from(mdValue.contents).equals(tstInfoHash)) { + return fail(["Signed messageDigest does not match the hash of TSTInfo (token internally inconsistent)."], { digestMatches, genTime, signerSubject, hasTimeStampingEku }); + } + + // --- (4b) require the contentType attr to be id-ct-TSTInfo (RFC 5652 §11.1) --- + const ctAttr = childContainingOid(signedAttrs, OID_CONTENT_TYPE); + const ctSet = ctAttr?.children?.find((c) => c.tagClass === 0 && c.tagNumber === 17); + const ctOid = ctSet?.children?.[0] ? oidOf(ctSet.children[0]) : null; + if (ctOid !== OID_TST_INFO) { + return fail(["Signed contentType attribute is not id-ct-TSTInfo."], { digestMatches, genTime, signerSubject, hasTimeStampingEku }); + } + + // --- (4c) verify the signature over the DER SET-OF-re-tagged signed attrs --- + const signedAttrsDer = reencodeSignedAttrsAsSet(signedAttrs); + let signatureValid = false; + try { + signatureValid = crypto.verify( + hashAlgo, + signedAttrsDer, + signerCert.publicKey, + Buffer.from(signatureOctet.contents), + ); + } catch (error) { + return fail([`Signature verification threw: ${error instanceof Error ? error.message : String(error)}`], { digestMatches, genTime, signerSubject, hasTimeStampingEku }); + } + if (!signatureValid) { + return fail(["CMS signature over the timestamp's signed attributes is invalid."], { digestMatches, genTime, signerSubject, hasTimeStampingEku }); + } + + // --- (5) optional trust-anchor chaining --- + let chainTrusted: boolean | null = null; + if (trustAnchors && trustAnchors.length > 0) { + chainTrusted = false; + for (const anchor of trustAnchors) { + try { + const ca = new X509Certificate(anchor); + if (signerCert.verify(ca.publicKey) || signerCert.checkIssued?.(ca)) { + chainTrusted = true; + break; + } + } catch { /* try next anchor */ } + } + if (!chainTrusted) { + return fail(["Signer certificate does not chain to any provided trust anchor."], { + digestMatches, genTime, signerSubject, hasTimeStampingEku, signatureValid, chainTrusted: false, + }); + } + } + + return { + verified: true, + digestMatches, + hasTimeStampingEku, + signatureValid, + chainTrusted, + genTime, + signerSubject, + reasons: [], + }; +} diff --git a/src/lib/timestamp.ts b/src/lib/timestamp.ts index ed66fa4..7c64bbe 100644 --- a/src/lib/timestamp.ts +++ b/src/lib/timestamp.ts @@ -2,8 +2,32 @@ import crypto from "node:crypto"; import { retryFetch } from "./http.js"; import { parseAsn1, decodeOid } from "./asn1.js"; import type { Asn1Node } from "./asn1.js"; +import { verifyTimestampToken, type TimestampVerification } from "./timestamp-verify.js"; -export const DEFAULT_TSA_URL = "http://timestamp.digicert.com"; +// HTTPS by default: a timestamp is a trust anchor, so the transport to the TSA +// must not be downgradeable. (Historically this was plaintext HTTP, which — +// combined with the absence of token verification — let a network attacker +// substitute the entire "proof".) Override only for localhost test mocks via +// SIGN_ALLOW_INSECURE_TSA=1. +export const DEFAULT_TSA_URL = "https://timestamp.digicert.com"; + +function assertTsaUrlAllowed(tsaUrl: string): void { + let parsed: URL; + try { + parsed = new URL(tsaUrl); + } catch { + throw new Error(`Invalid TSA URL: ${JSON.stringify(tsaUrl)}`); + } + if (parsed.protocol === "https:") return; + const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"; + const allowInsecure = ["1", "true", "yes"].includes((process.env.SIGN_ALLOW_INSECURE_TSA ?? "").toLowerCase()); + if (parsed.protocol === "http:" && (isLocalhost || allowInsecure)) return; + throw new Error( + `Refusing to use a non-HTTPS TSA URL (${parsed.protocol}//${parsed.hostname}). ` + + `A timestamp is a trust anchor and must not travel over plaintext. ` + + `Use an https:// TSA, or set SIGN_ALLOW_INSECURE_TSA=1 for a trusted local test server.`, + ); +} const OID_SHA256 = "2.16.840.1.101.3.4.2.1"; function encodeAsn1Length(length: number): Buffer { @@ -53,6 +77,7 @@ export async function issueRfc3161Timestamp(input: { tsaUrl?: string; }): Promise<{ tsaUrl: string; responseBuffer: Buffer; statusBytes: Buffer }> { const tsaUrl = input.tsaUrl ?? process.env.SIGN_TSA_URL ?? DEFAULT_TSA_URL; + assertTsaUrlAllowed(tsaUrl); const requestBuffer = buildTimeStampRequest(input.digest, OID_SHA256); const response = await retryFetch(tsaUrl, { method: "POST", @@ -72,6 +97,13 @@ export type TimestampInspection = { bytes: number; granted: boolean; containsDigest: boolean | null; + // The full CMS seal check (signature + signer EKU + messageImprint binding). + // `cryptographicallyVerified` is the authoritative trust signal; `granted` + // (the PKIStatus byte) and `containsDigest` (a byte-search) are retained for + // backward compatibility but must NOT be relied on alone. Present only when + // an expectedDigest was supplied to inspectTimestampResponse. + cryptographicallyVerified: boolean | null; + verification: TimestampVerification | null; parseWarnings: string[]; }; @@ -87,10 +119,16 @@ function findOctetStringWithBytes(node: Asn1Node, target: Buffer): boolean { return false; } -export function inspectTimestampResponse(buffer: Buffer, expectedDigest?: Buffer): TimestampInspection { +export function inspectTimestampResponse( + buffer: Buffer, + expectedDigest?: Buffer, + trustAnchors?: Array, +): TimestampInspection { const warnings: string[] = []; let granted = false; let containsDigest: boolean | null = null; + let cryptographicallyVerified: boolean | null = null; + let verification: TimestampVerification | null = null; try { const root = parseAsn1(buffer); @@ -106,10 +144,23 @@ export function inspectTimestampResponse(buffer: Buffer, expectedDigest?: Buffer warnings.push(`TimestampResp parse failed: ${error instanceof Error ? error.message : String(error)}`); } + // The authoritative check: full CMS verification binding the token's + // signature + signer's timeStamping EKU + messageImprint to our digest. + // Only meaningful when we know which digest the token is supposed to cover. + if (expectedDigest) { + verification = verifyTimestampToken(buffer, expectedDigest, trustAnchors); + cryptographicallyVerified = verification.verified; + if (!verification.verified) { + warnings.push(...verification.reasons); + } + } + return { bytes: buffer.length, granted, containsDigest, + cryptographicallyVerified, + verification, parseWarnings: warnings, }; } diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 82a9ad1..b75ca5e 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -40,6 +40,20 @@ export function validateReturnUrl(url: string): void { if (parsed.protocol === "http:" && !isLocalhost) { throw new Error(`--return-url must use https:// (got "${parsed.protocol}//${parsed.hostname}"). Localhost is allowed for development.`); } + // Optional host allowlist. By default any https host is accepted (the + // return URL is where the signer's browser lands after signing). Operators + // who want to prevent an agent from steering signers to an arbitrary domain + // can pin a comma-separated allowlist via SIGN_RETURN_URL_ALLOWED_HOSTS; + // hostnames match exactly (no wildcard), localhost is always allowed. + const allowList = (process.env.SIGN_RETURN_URL_ALLOWED_HOSTS ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + if (allowList.length > 0 && !isLocalhost && !allowList.includes(parsed.hostname.toLowerCase())) { + throw new Error( + `--return-url host "${parsed.hostname}" is not in SIGN_RETURN_URL_ALLOWED_HOSTS (${allowList.join(", ")}).`, + ); + } } export type DocumentPathRule = { diff --git a/src/tests/append-audit-event-async.test.ts b/src/tests/append-audit-event-async.test.ts index eed0ffd..459be94 100644 --- a/src/tests/append-audit-event-async.test.ts +++ b/src/tests/append-audit-event-async.test.ts @@ -71,8 +71,8 @@ test("appendAuditEventAsync against a PostgresBackend issues the right SQL with assert.ok(!call.text.includes("?"), `query should not contain '?' after translation: ${call.text}`); assert.ok(/\$1/.test(call.text), `query should reference $1: ${call.text}`); } - // The INSERT receives 6 params (request_id, event_type, payload_json, - // hash_prev, hash_self, created_at). + // The INSERT receives 7 params (request_id, event_type, payload_json, + // hash_prev, hash_self, hash_algo, created_at). const insertCall = seen.find((c) => c.text.includes("INSERT")); - assert.equal(insertCall!.params!.length, 6); + assert.equal(insertCall!.params!.length, 7); }); diff --git a/src/tests/audit-hmac-chain.test.ts b/src/tests/audit-hmac-chain.test.ts new file mode 100644 index 0000000..b4b2580 --- /dev/null +++ b/src/tests/audit-hmac-chain.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { appendAuditEvent, verifyAuditChain } from "../lib/audit.js"; +import { resetAuditHmacKeyCache } from "../lib/audit-key.js"; +import { createSigningRequest } from "../lib/signing-service.js"; +import { withAuditTamperingAllowed } from "../lib/db.js"; +import { createDb, createDocumentFixture, makeTempDb } from "./helpers.js"; + +// Helper: run `fn` with the audit HMAC key env set, restoring + clearing the +// resolver cache afterwards so cases don't bleed into each other. +function withKey(key: string | null, fn: () => T): T { + const prev = process.env.SIGN_AUDIT_HMAC_KEY; + if (key === null) delete process.env.SIGN_AUDIT_HMAC_KEY; + else process.env.SIGN_AUDIT_HMAC_KEY = key; + resetAuditHmacKeyCache(); + try { + return fn(); + } finally { + if (prev === undefined) delete process.env.SIGN_AUDIT_HMAC_KEY; + else process.env.SIGN_AUDIT_HMAC_KEY = prev; + resetAuditHmacKeyCache(); + } +} + +function seedRequest(db: ReturnType) { + const documentPath = createDocumentFixture("hmac-chain"); + return createSigningRequest(db, { + title: "HMAC chain", + documentPath, + signers: [{ name: "Alice", email: "alice@example.com", order: 1 }], + tokenTtlMinutes: 30, + provider: "dropbox", + }); +} + +test("a keyed chain verifies with the key and stores hash_algo=hmac-sha256", () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + try { + withKey("super-secret-key", () => { + const created = seedRequest(db); + appendAuditEvent(db, { requestId: created.requestId, eventType: "test.keyed", payload: { n: 1 } }); + const rows = db.prepare("SELECT hash_algo FROM audit_events WHERE request_id = ?").all(created.requestId) as Array<{ hash_algo: string }>; + assert.ok(rows.length >= 1); + assert.ok(rows.every((r) => r.hash_algo === "hmac-sha256"), "every event should be keyed"); + assert.equal(verifyAuditChain(db, created.requestId).valid, true); + }); + } finally { + db.close(); + cleanup(); + } +}); + +test("a keyed chain fails verification when the key is absent (fail-closed)", () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + try { + let requestId = ""; + withKey("k1", () => { + const created = seedRequest(db); + requestId = created.requestId; + appendAuditEvent(db, { requestId, eventType: "test.keyed", payload: { n: 1 } }); + assert.equal(verifyAuditChain(db, requestId).valid, true); + }); + // Now with no key configured, the keyed rows must not silently pass. + withKey(null, () => { + const res = verifyAuditChain(db, requestId); + assert.equal(res.valid, false); + assert.equal(res.break?.kind, "hash_self_mismatch"); + }); + } finally { + db.close(); + cleanup(); + } +}); + +test("a keyed chain fails verification under the WRONG key", () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + try { + let requestId = ""; + withKey("right-key", () => { + const created = seedRequest(db); + requestId = created.requestId; + appendAuditEvent(db, { requestId, eventType: "test.keyed", payload: { n: 1 } }); + }); + withKey("wrong-key", () => { + assert.equal(verifyAuditChain(db, requestId).valid, false); + }); + } finally { + db.close(); + cleanup(); + } +}); + +test("unkeyed chains remain byte-identical and keep verifying (backward compatible)", () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + try { + withKey(null, () => { + const created = seedRequest(db); + appendAuditEvent(db, { requestId: created.requestId, eventType: "test.legacy", payload: { n: 1 } }); + const rows = db.prepare("SELECT hash_algo FROM audit_events WHERE request_id = ?").all(created.requestId) as Array<{ hash_algo: string }>; + assert.ok(rows.every((r) => r.hash_algo === "sha256"), "events default to the legacy algo"); + assert.equal(verifyAuditChain(db, created.requestId).valid, true); + }); + } finally { + db.close(); + cleanup(); + } +}); + +test("downgrade is rejected: a legacy row appended after a keyed row is flagged as tampering", () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + try { + let requestId = ""; + let keyedHead = ""; + withKey("k", () => { + const created = seedRequest(db); + requestId = created.requestId; + const r = appendAuditEvent(db, { requestId, eventType: "test.keyed", payload: { n: 1 } }); + keyedHead = r.hashSelf; + assert.equal(verifyAuditChain(db, requestId).valid, true); + }); + // Splice in a forged LEGACY (unkeyed) row that chains off the keyed head. + // This is what a downgrade attacker would attempt. It must be rejected + // even though the legacy hash itself is internally consistent. + withKey(null, () => { + withAuditTamperingAllowed(db, () => { + db.prepare( + "INSERT INTO audit_events (request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at) VALUES (?, ?, ?, ?, ?, 'sha256', ?)", + ).run(requestId, "test.forged", "{}", keyedHead, "deadbeef", "2026-05-08T12:00:00.000Z"); + }); + }); + withKey("k", () => { + const res = verifyAuditChain(db, requestId); + assert.equal(res.valid, false, "a legacy row after a keyed row must fail"); + }); + } finally { + db.close(); + cleanup(); + } +}); diff --git a/src/tests/postgres-smoke.test.ts b/src/tests/postgres-smoke.test.ts index 73358b7..a8c6568 100644 --- a/src/tests/postgres-smoke.test.ts +++ b/src/tests/postgres-smoke.test.ts @@ -16,7 +16,7 @@ function makeInMemoryFakePg(): PgQueryable { async query(text, params = []) { const sql = text.trim(); // DDL — accept silently. - if (/^(CREATE|DROP|REPLACE|--)/i.test(sql) || + if (/^(CREATE|DROP|REPLACE|ALTER|--)/i.test(sql) || sql.startsWith("CREATE OR REPLACE FUNCTION") || sql.startsWith("DROP TRIGGER")) { return { rows: [], rowCount: 0 }; @@ -27,8 +27,8 @@ function makeInMemoryFakePg(): PgQueryable { return { rows: [], rowCount: 1 }; } if (/^INSERT INTO audit_events/i.test(sql)) { - const [request_id, event_type, payload_json, hash_prev, hash_self, created_at] = params as unknown[]; - tables.audit_events.push({ id: nextEventId++, request_id, event_type, payload_json, hash_prev, hash_self, created_at }); + const [request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at] = params as unknown[]; + tables.audit_events.push({ id: nextEventId++, request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at }); return { rows: [], rowCount: 1 }; } if (/SELECT hash_self\s+FROM audit_events\s+WHERE request_id = \$1\s+ORDER BY id DESC\s+LIMIT 1/i.test(sql)) { @@ -37,16 +37,16 @@ function makeInMemoryFakePg(): PgQueryable { const last = matching[matching.length - 1]; return { rows: last ? [{ hash_self: last.hash_self }] : [], rowCount: last ? 1 : 0 }; } - if (/^SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, created_at\s+FROM audit_events\s+WHERE request_id = \$1\s+ORDER BY id ASC/i.test(sql)) { + if (/^SELECT id, request_id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at\s+FROM audit_events\s+WHERE request_id = \$1\s+ORDER BY id ASC/i.test(sql)) { const requestId = (params as unknown[])[0]; const rows = tables.audit_events.filter((r) => r.request_id === requestId); return { rows, rowCount: rows.length }; } - if (/^SELECT id, event_type, payload_json, hash_prev, hash_self, created_at\s+FROM audit_events\s+WHERE request_id = \$1\s+ORDER BY id ASC/i.test(sql)) { + if (/^SELECT id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at\s+FROM audit_events\s+WHERE request_id = \$1\s+ORDER BY id ASC/i.test(sql)) { const requestId = (params as unknown[])[0]; const rows = tables.audit_events .filter((r) => r.request_id === requestId) - .map(({ id, event_type, payload_json, hash_prev, hash_self, created_at }) => ({ id, event_type, payload_json, hash_prev, hash_self, created_at })); + .map(({ id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at }) => ({ id, event_type, payload_json, hash_prev, hash_self, hash_algo, created_at })); return { rows, rowCount: rows.length }; } if (/^SELECT id, request_id, event_type, payload_json, hash_self, created_at\s+FROM audit_events/i.test(sql)) { diff --git a/src/tests/serve-trust-proxy.test.ts b/src/tests/serve-trust-proxy.test.ts new file mode 100644 index 0000000..53f2893 --- /dev/null +++ b/src/tests/serve-trust-proxy.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { startHttpApiServer } from "../lib/http-api.js"; +import { createDb, makeTempDb } from "./helpers.js"; + +// X-Forwarded-For is client-controlled. Without --trust-proxy the limiter must +// key on the real socket peer (so spoofing the header can't mint fresh +// buckets); with --trust-proxy it honours the header (load-balancer case). + +async function listen(opts: Partial[0]>) { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + const server = startHttpApiServer({ db, port: 0, ...opts }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + const close = async () => { + await new Promise((resolve) => server.close(() => resolve(undefined))); + db.close(); + cleanup(); + }; + return { port, close }; +} + +test("default (trustProxy off): spoofed X-Forwarded-For does NOT escape the limit", async () => { + const { port, close } = await listen({ rateLimit: { capacity: 2, refillPerSec: 0.1 } }); + try { + const url = `http://127.0.0.1:${port}/v1/health`; + // All three come from the same socket; a rotating XFF must not help. + const a = await fetch(url, { headers: { "x-forwarded-for": "1.1.1.1" } }); + const b = await fetch(url, { headers: { "x-forwarded-for": "2.2.2.2" } }); + const c = await fetch(url, { headers: { "x-forwarded-for": "3.3.3.3" } }); + assert.equal(a.status, 200); + assert.equal(b.status, 200); + assert.equal(c.status, 429, "rotating XFF must not mint fresh buckets when trustProxy is off"); + } finally { + await close(); + } +}); + +test("trustProxy on: distinct X-Forwarded-For values get distinct buckets", async () => { + const { port, close } = await listen({ rateLimit: { capacity: 1, refillPerSec: 0.1 }, trustProxy: true }); + try { + const url = `http://127.0.0.1:${port}/v1/health`; + // capacity=1, so a second request on the SAME forwarded IP is denied, + // but a different forwarded IP gets its own fresh bucket. + const a = await fetch(url, { headers: { "x-forwarded-for": "9.9.9.9" } }); + const aRepeat = await fetch(url, { headers: { "x-forwarded-for": "9.9.9.9" } }); + const other = await fetch(url, { headers: { "x-forwarded-for": "8.8.8.8" } }); + assert.equal(a.status, 200); + assert.equal(aRepeat.status, 429, "same forwarded IP shares a bucket"); + assert.equal(other.status, 200, "different forwarded IP gets its own bucket"); + } finally { + await close(); + } +}); diff --git a/src/tests/timestamp-verify.test.ts b/src/tests/timestamp-verify.test.ts new file mode 100644 index 0000000..a8b893f --- /dev/null +++ b/src/tests/timestamp-verify.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { verifyTimestampToken } from "../lib/timestamp-verify.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FX = path.resolve(__dirname, "../../fixtures/rfc3161"); + +const token = readFileSync(path.join(FX, "valid-token.tsr")); +const ca = readFileSync(path.join(FX, "test-ca.crt")); +const signerCert = readFileSync(path.join(FX, "tsa-signer.crt")); +const stampedData = readFileSync(path.join(FX, "stamped-data.bin")); +const expectedDigest = crypto.createHash("sha256").update(stampedData).digest(); + +test("accepts a valid RFC 3161 token over the expected digest", () => { + const r = verifyTimestampToken(token, expectedDigest); + assert.equal(r.verified, true, r.reasons.join("; ")); + assert.equal(r.digestMatches, true); + assert.equal(r.hasTimeStampingEku, true); + assert.equal(r.signatureValid, true); + assert.equal(r.chainTrusted, null, "chain not checked without anchors"); + assert.match(r.genTime ?? "", /^\d{4}-\d{2}-\d{2}T/); + assert.match(r.signerSubject ?? "", /Sign CLI Test TSA/); +}); + +test("rejects a token whose messageImprint covers different data", () => { + const wrong = crypto.createHash("sha256").update("not the stamped data").digest(); + const r = verifyTimestampToken(token, wrong); + assert.equal(r.verified, false); + assert.equal(r.digestMatches, false); + assert.match(r.reasons[0], /does not match the expected digest/); +}); + +test("confirms the chain when the issuing CA is supplied as a trust anchor", () => { + const r = verifyTimestampToken(token, expectedDigest, [ca]); + assert.equal(r.verified, true, r.reasons.join("; ")); + assert.equal(r.chainTrusted, true); +}); + +test("rejects when the signer does not chain to the provided trust anchor", () => { + // The signer's own leaf cert is not a CA for itself. + const r = verifyTimestampToken(token, expectedDigest, [signerCert]); + assert.equal(r.verified, false); + assert.equal(r.chainTrusted, false); + assert.match(r.reasons[0], /does not chain to any provided trust anchor/); +}); + +test("rejects a token with a tampered signature", () => { + const tampered = Buffer.from(token); + tampered[tampered.length - 10] ^= 0xff; // flip a byte inside the RSA signature + const r = verifyTimestampToken(tampered, expectedDigest); + assert.equal(r.verified, false); + assert.match(r.reasons[0], /signature .* is invalid|parse failed/i); +}); + +test("rejects a token with tampered TSTInfo content (digest/signature break)", () => { + // Flip a byte early in the structure (inside TSTInfo); either the imprint + // no longer matches or the signed messageDigest breaks — both must fail. + const tampered = Buffer.from(token); + tampered[80] ^= 0xff; + const r = verifyTimestampToken(tampered, expectedDigest); + assert.equal(r.verified, false); +}); + +test("rejects a status-only / non-SignedData response", () => { + // The shape the old mock TSA returned: SEQUENCE { INTEGER 0 } — granted + // status but no token. This used to be treated as proof. + const statusOnly = Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]); + const r = verifyTimestampToken(statusOnly, expectedDigest); + assert.equal(r.verified, false); + assert.match(r.reasons[0], /No SignedData|parse failed/i); +}); + +test("rejects an empty buffer without throwing", () => { + const r = verifyTimestampToken(Buffer.alloc(0), expectedDigest); + assert.equal(r.verified, false); + assert.ok(r.reasons.length > 0); +}); diff --git a/src/tests/validate.test.ts b/src/tests/validate.test.ts index 197ff34..19233d1 100644 --- a/src/tests/validate.test.ts +++ b/src/tests/validate.test.ts @@ -67,6 +67,22 @@ test("validateReturnUrl rejects file: javascript: data: and non-localhost http", assert.throws(() => validateReturnUrl("not-a-url"), /not a valid URL/); }); +test("validateReturnUrl enforces SIGN_RETURN_URL_ALLOWED_HOSTS when set", () => { + const prev = process.env.SIGN_RETURN_URL_ALLOWED_HOSTS; + process.env.SIGN_RETURN_URL_ALLOWED_HOSTS = "app.acme.com, portal.acme.com"; + try { + // Allowed hosts pass; localhost always passes regardless of the list. + validateReturnUrl("https://app.acme.com/done"); + validateReturnUrl("https://portal.acme.com/done"); + validateReturnUrl("http://localhost:3000/done"); + // A host not on the list is rejected even though it's https. + assert.throws(() => validateReturnUrl("https://evil.example.com/x"), /not in SIGN_RETURN_URL_ALLOWED_HOSTS/); + } finally { + if (prev === undefined) delete process.env.SIGN_RETURN_URL_ALLOWED_HOSTS; + else process.env.SIGN_RETURN_URL_ALLOWED_HOSTS = prev; + } +}); + test("validateDocumentPath rejects paths outside cwd unless overridden", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "doc-cwd-")); const insidePath = path.join(dir, "ok.pdf");