Skip to content

feat(delegation): add credential validity windows (not_before/not_after) - #110

Merged
imran-siddique merged 3 commits into
agentrust-io:mainfrom
bytebackllc:feat/credential-validity-window
Aug 17, 2026
Merged

feat(delegation): add credential validity windows (not_before/not_after)#110
imran-siddique merged 3 commits into
agentrust-io:mainfrom
bytebackllc:feat/credential-validity-window

Conversation

@bytebackllc

Copy link
Copy Markdown
Contributor

What

Adds an optional validity window to DelegationCredential: not_before / not_after (Unix epoch seconds, inclusive), enforced per hop by verify_chain at a caller-supplied at_time defaulting to the current time. New error codes CREDENTIAL_EXPIRED and CREDENTIAL_NOT_YET_VALID; conformance cases DELEG-007DELEG-009 and ACTION-012/ACTION-013.

Why

The #36 residual checklist asks for expired and not-yet-valid action-evidence cases, blocked on "define credential validity-window fields before adding expired and not-yet-valid cases. DelegationCredential currently has no time bounds." This PR defines those fields and adds the cases.

Design choices, so they can be reviewed as decisions rather than accidents:

  • Epoch-second integers, closed interval. Matches the wire format's strictness ethos (non-negative JSON integer, no boolean/float coercion, explicit null rejected) and avoids RFC 3339 parsing ambiguity. Inclusive at both ends, mirroring X.509.
  • Absent bounds are omitted from the signed body, not encoded as null. Emitting nulls would change the canonical bytes of every credential signed before the fields existed. Every existing signature keeps verifying, and a bound that is present is signed, so it cannot be stripped without failing verification (regression-tested).
  • at_time=None means "now", not "skip". Every existing call site (runtime authorization, CLI, offline verifier) starts enforcing windows immediately, fail-closed. An auditor replaying recorded evidence passes the decision time via at_time / --at-time, because a window that has lapsed by audit time says nothing about validity at decision time.
  • Windows need not nest across hops. A chain is usable only at times inside every hop's window, so the effective window is already the intersection; structural nesting would add no authority bound.
  • Expired / not-yet-valid classify as provenance-invalid in the action-evidence model, alongside the other credential-verifier failures, per the issue's three-class boundary.
  • The a2a-sdk bridge restores the bounds' integer-ness across the protobuf Struct round trip exactly as it already does for depth (the bounds are part of the signed body when present, so this is load-bearing; round-trip tested against the real SDK).

Refs #36 (checklist item: validity-window fields + expired / not-yet-valid cases).

On the checklist's first item (exercise the public agent-manifest delegation verifier): agent_manifest.DelegationHop is a different wire format from DelegationCredential (manifest-bound pre-image, indexed hops, scope_grant objects), so a cA2A chain cannot be fed to it. The conformance suite already routes through the public ca2a_verify.verify_delegation_chain; that acceptance criterion likely wants revising to name that API, per the alternative the checklist itself offers. Happy to do that as a follow-up if the maintainers agree.

Security impact

Touches src/ca2a_runtime/delegation/ and src/ca2a_verify/ (two-approval paths).

  • Adds a fail-closed check; grants no new authority. A chain with no bounds behaves exactly as before (byte-identical signed body, so existing signatures still verify).
  • Window bounds are inside the signed body when present: stripping or altering them invalidates the signature. An attacker cannot extend a grant's lifetime without the issuer's key.
  • Verification now reads a clock. Live authorization evaluates the current time; the at_time parameter exists so offline audit replays at decision time. A skewed verifier clock can reject valid chains (availability), not accept expired ones beyond the skew itself.
  • sign() now uses dataclasses.replace so a future model field cannot be silently dropped from signed credentials.

Test plan

  • pytest passes (487 passed, 3 skipped; 20 new tests)
  • ruff check and ruff format --check pass
  • mypy passes
  • bandit passes
  • Manual test: ca2a verify-chain --chain <windowed chain> --at-time inside/before/after the window returns verified / CREDENTIAL_NOT_YET_VALID / CREDENTIAL_EXPIRED

DCO sign-off

🤖 Generated with Claude Code

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rebase after #107 and trim its residual-risk sentence from 'a credential carries no validity window and there is no revocation path' to the remaining revocation gap, as already agreed on #107. Also validate the public �t_time argument as a non-negative integer excluding bool, with tests for bool/float/negative inputs. Credential bounds are deliberately strict JSON integers; accepting True, 1.5, or -1 as the verifier's evaluation time undermines that deterministic boundary for library callers even though argparse protects the CLI.

manizzle and others added 2 commits August 14, 2026 13:54
Optional not_before / not_after fields (Unix epoch seconds, inclusive) on
DelegationCredential, enforced per hop by verify_chain at a caller-supplied
at_time defaulting to the current time, and threaded through
ca2a_verify.verify_delegation_chain, verify_chain_file, and the verify-chain /
verify-dag CLI as --at-time.

An absent bound is omitted from the signed body rather than encoded as null,
so every previously signed credential keeps its exact signed bytes; a present
bound is signed, so it cannot be stripped without failing verification. The
a2a-sdk bridge restores the bounds' integer-ness across the protobuf Struct
round trip exactly as it already did for depth.

New error codes CREDENTIAL_EXPIRED and CREDENTIAL_NOT_YET_VALID. Conformance
DELEG-007..009 and ACTION-012/013 cover the expired and not-yet-valid cases
from the agentrust-io#36 residual checklist.

Refs agentrust-io#36

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Murtaza Munaim <manizzle.msf@gmail.com>
…idity windows

Review follow-up to agentrust-io#110:

- verify_chain now rejects a non-integer at_time (bool, float, negative)
  with ValueError before any credential is examined. The bounds on the wire
  are strict JSON integers; the evaluation time they are compared against
  holds the same line for library callers. The CLI was already argparse-typed.
- The residual-risk entry agentrust-io#107 added to the threat model now reflects that a
  credential can carry a validity window: the remaining gap is revocation
  inside a still-valid window, as agreed on agentrust-io#107.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Murtaza Munaim <manizzle.msf@gmail.com>
@bytebackllc
bytebackllc force-pushed the feat/credential-validity-window branch from 20fe95d to 8d6ba7f Compare August 15, 2026 01:15
@bytebackllc

Copy link
Copy Markdown
Contributor Author

Both addressed in 8d6ba7f, rebased onto current main:

  • Rebased after fix(delegation): commit the parent link, and drop the proof replay cache #107 and trimmed its residual-risk entry as agreed there: the heading is now "Delegated authority cannot be actively withdrawn" — a credential can carry a validity window that bounds how long a compromised delegate keeps its grant, and the remaining gap is revocation inside a still-valid window. The offline-verification interaction sentence is unchanged.
  • at_time is validated at the top of verify_chain, before any credential is examined: bool, non-int, and negative values raise ValueError. Tests cover True, 1.5, -1, and "1500". ValueError rather than a CA2AError subtype because a malformed evaluation time is a caller programming error, not a property of the evidence — same line DelegationRecord.__post_init__ draws for caller_attestation. Say the word if you'd rather it be a CA2AError.

Full suite on the rebased branch: 489 passed, 3 skipped; ruff, ruff format, mypy, bandit all clean.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prior rebase, threat-model, and at_time findings are addressed. One blocking direct-construction bypass remains in the new validity fields.

from_dict enforces non-negative integers excluding bool, but DelegationCredential is also a public dataclass constructor and both sign() and verify_chain() trust its in-memory bounds. I reproduced signed credentials with not_before=True and with not_before=-5; both signatures verified and verify_chain(..., at_time=1) accepted them. That creates signed wire bodies outside the documented format and causes cross-implementation divergence (a serialized credential produced by this API is rejected by this implementation's own from_dict).

Please centralize credential model validation and invoke it before signing and before/within verification, or add equivalent __post_init__ enforcement. Add direct-construction tests for bool, negative, float/string/null-equivalent misuse on both bounds, plus the inverted case, proving invalid objects cannot be signed or accepted. The existing from_dict tests should remain.

Focused reproduction on head 8d6ba7f:

DelegationCredential(..., not_before=True, not_after=2).sign(key) followed by verify_chain([cred], at_time=1) -> ACCEPTED

DelegationCredential(..., not_before=-5, not_after=2).sign(key) followed by verify_chain([cred], at_time=1) -> ACCEPTED

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… the wire

Review follow-up to agentrust-io#110, round 2. DelegationCredential is a public
constructor as well as a wire format, and sign() would put a bound outside
the documented format into a signed body that this implementation's own
from_dict rejects. __post_init__ now enforces the same non-negative-integer
rule and window ordering from_dict applies, so an invalid credential cannot
be constructed, signed, or verified; replace() re-runs it, so sign() is
covered. The now-unreachable inverted-window check in verify_chain is
removed. Direct-construction tests cover bool, negative, float, and string
misuse on both bounds, plus the inverted window; the from_dict tests are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Murtaza Munaim <manizzle.msf@gmail.com>
@bytebackllc

Copy link
Copy Markdown
Contributor Author

Good catch — reproduced both of your examples on 8d6ba7f before fixing. Addressed in c7098a8 via __post_init__ (the DelegationRecord precedent), so the same non-negative-integer rule and window ordering from_dict applies now hold at construction: an invalid credential cannot be constructed at all, which covers sign() and verify_chain() in one place, and replace() re-runs it so sign()'s own reconstruction is covered too.

Direct-construction tests cover True, -5, 1.5, and "1000" on both bounds plus the inverted window; both of your reproductions now raise INVALID_CREDENTIAL at the constructor. The from_dict tests are unchanged, and its explicit checks are retained since they also reject a present-but-null bound, which the constructor cannot distinguish from an absent one. The now-unreachable inverted-window branch inside verify_chain is removed rather than left as dead code.

Full suite: 497 passed, 3 skipped; ruff, format, mypy, bandit clean.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rebase and at_time validation are addressed, and the direct-construction bypass is closed in post_init with tests for bool, negative, float/string misuse, null-equivalent bounds, and inverted windows. Focused verification: 52 unit tests passed, 1 skipped; Ruff and mypy pass. Approving pending hosted conformance CI.

@imran-siddique
imran-siddique merged commit acdbcf4 into agentrust-io:main Aug 17, 2026
1 check failed
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.

4 participants