Skip to content

fix(security): reject future-dated Trust Records - #155

Merged
imran-siddique merged 2 commits into
mainfrom
agent/reject-future-dated-records
Aug 11, 2026
Merged

fix(security): reject future-dated Trust Records#155
imran-siddique merged 2 commits into
mainfrom
agent/reject-future-dated-records

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Why

verify_record() bounded how old a record could be but accepted arbitrarily future-dated iat values. A record dated a year ahead therefore remained fresh for a year plus the configured maximum age, creating an unintended replay window.

What changed

  • add max_future_skew_seconds, defaulting to five minutes
  • reject iat values beyond the verifier clock plus that tolerance
  • keep the future bound active even when maximum-age checking is disabled
  • require integer timestamps and reject negative skew configuration
  • update the normative v0.2 freshness language, verification tutorial, limitations, and changelog

Verification

  • focused signature tests: 49 passed
  • full suite: 328 passed, 1 skipped
  • ruff check src tests
  • mypy src/agentrust_trace
  • git diff --check

Five regression tests cover the default rejection boundary, tolerated skew, deployment overrides, independence from max_age_seconds=None, and invalid configuration.

@imran-siddique
imran-siddique marked this pull request as ready for review August 11, 2026 15:37
@imran-siddique
imran-siddique merged commit 2a02eb7 into main Aug 11, 2026
5 checks passed
@imran-siddique
imran-siddique deleted the agent/reject-future-dated-records branch August 11, 2026 15:41
imran-siddique pushed a commit that referenced this pull request Aug 15, 2026
…y documents (#164)

Closes #151.

issued_at has been required since this format existed, and _check_structure
explains why: "A record with no issue time cannot be aged, so a consumer has no
way to reject a stale one." Nothing aged it. Walking the module for Compare
nodes mentioning issued_at gave two, the type check and build_record's default,
and the only time.time() call was build_record stamping it.

sign.verify_record took revocation; provenance.verify_record took nothing. That
is not "one checks and the other does not" - Trust Records skip revocation by
default too - it is that one format gave the consumer the hook and the other did
not, and the caller-side substitute is not equivalent. _key_identifiers is
private and returns the thumbprint *and* the kid, so a caller writing the check
by hand reaches for the thumbprint and misses every entry listed by kid, which is
what kid is for. It diverges in the direction of accepting a revoked key.

    def verify_record(record, trusted_jwk, *,
                      revocation: RevocationStore | None = None,
                      max_age_seconds: int | None = None,
                      max_future_skew_seconds: int = 300) -> None:

Nothing new was needed: RevocationStore and _check_not_revoked already exist and
are reused rather than reimplemented.

max_age_seconds defaults to None, not to the 86400 of a Trust Record. A
provenance record describes an artifact by immutable digest, like a package
signature, and those are conventionally valid indefinitely; a default bound would
be wrong. The docstring names endpoint identity as the case where that reasoning
does not hold, since a URL and an SPKI digest decay.

max_future_skew_seconds is enforced whether or not an age bound is set. Adding
max_age_seconds alone would have shipped the defect #155 had just fixed for Trust
Records: a far-future issued_at sits inside any later age window until that time
arrives.

Revocation is consulted before the signature, because a signature made by a
revoked key stays cryptographically valid and the verifier is the only place the
fact can be applied.

Ten tests. Load-bearing, measured rather than assumed: removing the freshness
block fails three, removing only the future-skew check fails one, removing the
revocation call fails three, and keying revocation on the thumbprint alone -
the caller-side substitute - fails one. That last is the kid case, and it is why
the hook has to live here rather than in the caller.

353 pass; ruff and mypy clean, with ruff clean on the same files before this
change. Merges with #149 without conflict; that PR makes the revocation half
cheaper by bringing jwk_thumbprint into this module, but neither depends on the
other.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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