Skip to content

Security hardening from suite audit: RFC 3161 verification, keyed audit chain, XFF + return-url#188

Draft
DrBaher wants to merge 5 commits into
mainfrom
claude/cli-suite-audit-p0A3v
Draft

Security hardening from suite audit: RFC 3161 verification, keyed audit chain, XFF + return-url#188
DrBaher wants to merge 5 commits into
mainfrom
claude/cli-suite-audit-p0A3v

Conversation

@DrBaher
Copy link
Copy Markdown
Owner

@DrBaher DrBaher commented May 31, 2026

Findings from an audit of the contract-ops CLI suite. A companion PR covers contract-ops-mcp.

Landed

P0 — RFC 3161 timestamp tokens are now cryptographically verified

inspectTimestampResponse trusted any response whose PKIStatus byte was 0/1 and did an unsigned byte-search for the digest; the TSA's CMS signature was never checked and the default TSA URL was plaintext http. A network attacker could substitute the entire "proof". New timestamp-verify.ts performs full CMS SignedData verification (RFC 5652 / RFC 3161):

  • verifies the TSA signature over TSTInfo with the signer cert's public key (Node crypto.verify + X509Certificate, no hand-rolled signature math);
  • requires the signer cert to carry extendedKeyUsage = id-kp-timeStamping;
  • confirms the token's messageImprint equals the digest we asked to be stamped (a valid token over different data is rejected);
  • checks the signed messageDigest == hash(TSTInfo) and contentType == id-ct-TSTInfo;
  • optional trust-anchor chaining.

cryptographicallyVerified is now the authoritative signal and is persisted into the audit.timestamped / audit.anchored events. issueRfc3161Timestamp refuses non-HTTPS TSA URLs (localhost / SIGN_ALLOW_INSECURE_TSA=1 excepted for test mocks); default TSA is https. Tested against a real OpenSSL-issued token (committed under fixtures/rfc3161/) across valid / wrong-digest / trust-anchor / tampered / status-only cases.

P1 — Optional keyed-HMAC audit chain

The chain was unkeyed SHA-256, so anyone with DB-file write access could recompute a self-consistent forged chain. Added opt-in HMAC keying (SIGN_AUDIT_HMAC_KEY / SIGN_AUDIT_HMAC_KEY_FILE), keyed by material held outside the DB. Backward-compatible (existing chains hash identically and keep verifying; each row records its hash_algo), fail-closed (a keyed chain can't pass without the key), and downgrade-resistant (a legacy row after a keyed one is flagged as tampering). Threaded through the sync + async (Postgres) paths, both schemas (+ migrations), and the portable receipt-bundle verifier.

P1 — sign serve rate limiter no longer trusts X-Forwarded-For by default

XFF is client-controlled, so it could be rotated per request to defeat the limiter. Now keys on the socket peer address unless sign serve --trust-proxy true.

P2 — Optional return-url host allowlist

validateReturnUrl accepted any https host; an agent could steer the signer's post-sign redirect anywhere. Added opt-in SIGN_RETURN_URL_ALLOWED_HOSTS.

P3 — --version drift (0.6.4 → 0.6.5)

Tests

Build + typecheck pass. Full suite: 782 pass, 1 skip, 0 fail (was 766 before this PR; +16 new tests).

Reported but intentionally not changed here (flagged for your call)

  • Webhook ingest-before-verify: I verified the actual behavior — state mutations (persistRequestProviderMetadata, recordSignerSigningState) are already gated behind if (verified), and an existing test (webhook-replay-protection.test.ts:149) deliberately pins "unverified replays still recorded as failures". The residual is audit-log injection (an unauthenticated caller who knows a request_id can append verified:false events), which contradicts that test. It's an architectural decision, so I left it for you rather than flip a documented behavior.
  • Trust-label spoofing (classifyTrust): the labels are explicitly documented as descriptive, not enforced; a robust fix needs pinning the local CA's public key (a larger change). Left as-is.
  • createSigningRequest path confinement: attempted as defense-in-depth but it broke legitimate absolute-path callers (MCP/bulk/tests use absolute temp paths); the correct boundary is the CLI/MCP entry layer, where confinement already exists. Reverted.
  • sign/decline lifecycle + audit append not wrapped in a transaction (P2) — not addressed in this PR.

DrBaher added 5 commits May 31, 2026 10:48
…ault

X-Forwarded-For is client-controlled; honoring it whenever present let a caller rotate the header per request to mint a fresh token bucket and defeat the limiter. Key on the socket peer address by default; honor XFF only under the new 'sign serve --trust-proxy true' for deployments behind a trusted reverse proxy. Adds serve-trust-proxy.test.ts proving spoofed XFF cannot escape the limit when trust-proxy is off.
The RFC 3161 timestamping feature never verified the TSA's signature.
inspectTimestampResponse trusted any response whose PKIStatus byte was
0/1, and its containsDigest check was an unsigned byte-search; the default
TSA URL was plaintext http. A network attacker could substitute the entire
'proof' and have it recorded as a granted, valid anchor — undermining the
product's central tamper-evidence claim.

This adds full CMS SignedData verification (RFC 5652 / RFC 3161):
- new timestamp-verify.ts: parses the TimeStampToken, confirms the TSTInfo
  messageImprint equals the digest we asked to be stamped, requires the
  signer cert to carry extendedKeyUsage=id-kp-timeStamping, checks the
  signed messageDigest == hash(TSTInfo) and contentType == id-ct-TSTInfo,
  then verifies the CMS signature over the DER-re-tagged signed attributes
  via Node crypto.verify + X509Certificate. Optional trust-anchor chaining.
- inspectTimestampResponse now runs that verification when an expectedDigest
  is supplied and exposes cryptographicallyVerified (the authoritative
  signal); granted/containsDigest are kept for back-compat but documented as
  non-authoritative.
- issueRfc3161Timestamp refuses non-HTTPS TSA URLs (localhost or
  SIGN_ALLOW_INSECURE_TSA=1 excepted for test mocks); default TSA is https.
- the audit.timestamped and audit.anchored events now persist
  cryptographicallyVerified (+ genTime) into the chain.

Tests: timestamp-verify.test.ts drives a real OpenSSL-issued token
(committed under fixtures/rfc3161, with a regeneration README) through
valid / wrong-digest / trust-anchor / tampered-signature / tampered-TSTInfo
/ status-only / empty cases. Full suite: 776 pass, 1 skip, 0 fail.
The audit chain was an unkeyed SHA-256 hash chain. Because the algorithm
is public, anyone with write access to the database file could recompute a
fully self-consistent forged chain — the append-only triggers are trivially
bypassed by opening the file directly. For a 'tamper-evident audit trail'
that's a meaningful gap against a local-file attacker.

This adds opt-in keyed-HMAC chaining, keyed by material held OUTSIDE the DB
(SIGN_AUDIT_HMAC_KEY or SIGN_AUDIT_HMAC_KEY_FILE):

- new audit-key.ts resolves the key (cached; resettable for tests).
- computeChainHash() in audit.ts is the single hashing primitive: unkeyed it
  is byte-identical to the old scheme (full backward compatibility); keyed it
  HMACs the event body with hash_algo bound in.
- each event records its own hash_algo, so mixed-history DBs verify per-row.
- verification is fail-closed (a keyed chain can't pass without the key) and
  downgrade-resistant (a legacy row after a keyed row is flagged as tamper).
- threaded through the sync + async (Postgres) append/verify paths, the
  SQLite + Postgres schemas (new hash_algo column, with migrations for
  existing DBs), and the portable receipt-bundle verifier.

Docs: security-controls.md gains 'Keyed audit chain' + 'Verified RFC 3161
timestamps' sections; .env.example documents the new vars.

Tests: audit-hmac-chain.test.ts covers keyed verify, wrong-key, missing-key
fail-closed, legacy backward-compat, and downgrade rejection. Postgres fakes
updated for the new column. Full suite: 781 pass, 1 skip, 0 fail.
validateReturnUrl accepted any https host, so an agent constructing an
embedded-signing --return-url could steer the signer's post-sign redirect
to an arbitrary domain. Add an opt-in allowlist via
SIGN_RETURN_URL_ALLOWED_HOSTS (comma-separated, exact host match; localhost
always allowed). Default behavior is unchanged when the var is unset.

Adds a validate.test.ts case for the allowlist and documents the var.
@DrBaher DrBaher changed the title Security hardening: XFF rate-limit spoofing + version drift (audit, in progress) Security hardening from suite audit: RFC 3161 verification, keyed audit chain, XFF + return-url May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant