security: cryptographically verify RFC 3161 tokens and TSA trust chains #1

Closed
opened 2026-07-21 17:20:15 +02:00 by heiko · 1 comment
Owner

Problem

sign.go accepts timestamp responses without verifying the CMS signature or the TSA certificate chain. Response.Validate(req) checks RFC 3161 structure, status, message imprint, nonce, generation-time form, and certificate presence, but the upstream package explicitly requires callers to invoke SignedToken.Verify before consumption.

verify.go is also parse-only: it JSON-decodes each x-signature, calls ParseSignedToken, logs the parsed token, and returns success. It does not:

  • verify the CMS signature;
  • build and validate a chain to trusted TSA roots;
  • compare the token imprint with a freshly normalized message;
  • enforce header format version or an accepted field-selection policy;
  • fail when no signature header exists.

The CLI help still describes this command as “Verify a signed message”. Several enabled TSA URLs use plain HTTP. An active network attacker can observe the request imprint and nonce and return a structurally matching token signed by an untrusted key; the current application provides no trust decision that rejects it.

Relevant code:

  • sign.go: ts.Response.Validate(ts.Request), SignedToken() and header emission
  • verify.go: complete current implementation
  • main.go: verifyCmd help text and HTTP TSA endpoints
  • header.go: unauthenticated JSON metadata
  • tspclient-go v1.0.0: Response.Validate and SignedToken.Verify

Checklist: Go mistake #53 (required errors/verification are not handled).

Proposed fix

  1. Define an explicit TSA trust policy and source of roots (system pool, configured roots, or pinned TSA roots).
  2. During signing, parse the token and call SignedToken.Verify(ctx, x509.VerifyOptions{...}) before emitting any output.
  3. During verification:
    • require at least one supported signature header;
    • enforce Version == 1;
    • enforce an expected/allowed Fields policy rather than blindly trusting the header;
    • decode the token, extract its TSTInfo, normalize the message, and compare SHA-512 imprint;
    • verify CMS signature, EKU, certificate validity, and chain;
    • return nonzero if any required check fails.
  4. If full verification is intentionally deferred, rename the current command to inspect and do not expose it as verify.
  5. Prefer HTTPS TSA URLs where supported; signed tokens are not a substitute for transport availability/privacy.

Historical timestamp validation needs a documented policy for certificate validity time and revocation; do not silently equate current system trust with validity at the timestamp generation time.

Acceptance criteria

  • A valid fixture chained to an explicitly trusted root succeeds.
  • A self-signed/untrusted signer fails.
  • A modified message, nonce mismatch, imprint mismatch, malformed token, unsupported header version, and missing signature all fail.
  • Failure emits no signed message/token and exits nonzero.
  • Help, README, technical documentation, and mail-seal-signature(5) accurately describe the trust policy.
  • Tests do not contact public TSAs.

Reviewed against 948b7a9 on master (Go 1.26.2).

## Problem `sign.go` accepts timestamp responses without verifying the CMS signature or the TSA certificate chain. `Response.Validate(req)` checks RFC 3161 structure, status, message imprint, nonce, generation-time form, and certificate presence, but the upstream package explicitly requires callers to invoke `SignedToken.Verify` before consumption. `verify.go` is also parse-only: it JSON-decodes each `x-signature`, calls `ParseSignedToken`, logs the parsed token, and returns success. It does not: - verify the CMS signature; - build and validate a chain to trusted TSA roots; - compare the token imprint with a freshly normalized message; - enforce header format version or an accepted field-selection policy; - fail when no signature header exists. The CLI help still describes this command as “Verify a signed message”. Several enabled TSA URLs use plain HTTP. An active network attacker can observe the request imprint and nonce and return a structurally matching token signed by an untrusted key; the current application provides no trust decision that rejects it. Relevant code: - `sign.go`: `ts.Response.Validate(ts.Request)`, `SignedToken()` and header emission - `verify.go`: complete current implementation - `main.go`: `verifyCmd` help text and HTTP TSA endpoints - `header.go`: unauthenticated JSON metadata - tspclient-go v1.0.0: `Response.Validate` and `SignedToken.Verify` Checklist: Go mistake #53 (required errors/verification are not handled). ## Proposed fix 1. Define an explicit TSA trust policy and source of roots (system pool, configured roots, or pinned TSA roots). 2. During signing, parse the token and call `SignedToken.Verify(ctx, x509.VerifyOptions{...})` before emitting any output. 3. During verification: - require at least one supported signature header; - enforce `Version == 1`; - enforce an expected/allowed `Fields` policy rather than blindly trusting the header; - decode the token, extract its `TSTInfo`, normalize the message, and compare SHA-512 imprint; - verify CMS signature, EKU, certificate validity, and chain; - return nonzero if any required check fails. 4. If full verification is intentionally deferred, rename the current command to `inspect` and do not expose it as `verify`. 5. Prefer HTTPS TSA URLs where supported; signed tokens are not a substitute for transport availability/privacy. Historical timestamp validation needs a documented policy for certificate validity time and revocation; do not silently equate current system trust with validity at the timestamp generation time. ## Acceptance criteria - A valid fixture chained to an explicitly trusted root succeeds. - A self-signed/untrusted signer fails. - A modified message, nonce mismatch, imprint mismatch, malformed token, unsupported header version, and missing signature all fail. - Failure emits no signed message/token and exits nonzero. - Help, README, technical documentation, and `mail-seal-signature(5)` accurately describe the trust policy. - Tests do not contact public TSAs. Reviewed against `948b7a9` on `master` (Go 1.26.2).
Author
Owner

Cryptographic signing/verification, trust-policy documentation, and failure-path acceptance tests are complete.

Closing commit: 96b9850d7a6e

Cryptographic signing/verification, trust-policy documentation, and failure-path acceptance tests are complete. Closing commit: [`96b9850d7a6e`](https://forgejo.schlittermann.de/heiko/mail-seal/commit/96b9850d7a6ebb5dcd78e0bdf38ed0710b5b15d1)
heiko closed this issue 2026-07-21 23:07:49 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
heiko/mailseal#1
No description provided.