Security hardening from suite audit: RFC 3161 verification, keyed audit chain, XFF + return-url#188
Draft
DrBaher wants to merge 5 commits into
Draft
Security hardening from suite audit: RFC 3161 verification, keyed audit chain, XFF + return-url#188DrBaher wants to merge 5 commits into
DrBaher wants to merge 5 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
inspectTimestampResponsetrusted 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 plaintexthttp. A network attacker could substitute the entire "proof". Newtimestamp-verify.tsperforms full CMSSignedDataverification (RFC 5652 / RFC 3161):TSTInfowith the signer cert's public key (Nodecrypto.verify+X509Certificate, no hand-rolled signature math);extendedKeyUsage = id-kp-timeStamping;messageImprintequals the digest we asked to be stamped (a valid token over different data is rejected);messageDigest == hash(TSTInfo)andcontentType == id-ct-TSTInfo;cryptographicallyVerifiedis now the authoritative signal and is persisted into theaudit.timestamped/audit.anchoredevents.issueRfc3161Timestamprefuses non-HTTPS TSA URLs (localhost /SIGN_ALLOW_INSECURE_TSA=1excepted for test mocks); default TSA is https. Tested against a real OpenSSL-issued token (committed underfixtures/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 itshash_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 serverate limiter no longer trusts X-Forwarded-For by defaultXFF 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
validateReturnUrlaccepted any https host; an agent could steer the signer's post-sign redirect anywhere. Added opt-inSIGN_RETURN_URL_ALLOWED_HOSTS.P3 —
--versiondrift (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)
persistRequestProviderMetadata,recordSignerSigningState) are already gated behindif (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 arequest_idcan appendverified:falseevents), which contradicts that test. It's an architectural decision, so I left it for you rather than flip a documented behavior.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.