Skip to content

ca2a verify-lineage reports authenticated_lineage for hop records signed after credential expiry, before not_before, or before their parent hop (cross_check_trace_dag never reads iat) #306

Open

Description

@drk-ram

Summary

ca2a verify-lineage is the command that reports "verification": "authenticated_lineage". It composes verify_chain, verify_trace_dag and cross_check_trace_dag. None of the three relates a hop record's signed iat to the validity window of the credential that hop acted under, or to the iat of its parent hop. The result is that a lineage whose records were signed after every credential in it had expired, before any of them were valid, or in an order that has each child acting before its parent delegated to it, is reported as authenticated.

This is distinct from the open hygiene issues (#267-#286, #285 for iat type checks) and from #263 (revocation membership vs at_time), and does not overlap with #36 (historical-window replay test cases). Those concern the type and the evaluation time of at_time. This one concerns the absence of any check that ties the evidence clock to the authority clock. I checked every open and closed ca2a issue and PR title before filing, including the 16 open PRs; none addresses it. Pinned at ca2a@945292b.

Where

  • src/ca2a_verify/dag.py:128 verify_trace_dag verifies each record's structure, its signature, the trusted key and the parent link. It reads iat only through verify_record(..., max_age_seconds=...), which is a freshness bound against the verifier's clock and is None by default (the docstring says that default is "suited to offline audit of historical records").
  • src/ca2a_verify/dag.py:224 cross_check_trace_dag compares count, credential_id and the signing key (cnf.jwk) against chain[i].subject. iat is never read.
  • src/ca2a_runtime/delegation/credential.py verify_chain(..., at_time=...) evaluates not_before / not_after at one caller-supplied instant for the whole chain. docs/spec/verification-library.md:25 says an auditor replaying evidence "passes the time the action was decided". The only signed statement of when each action was decided is the hop record's iat, and nothing passes it in.
  • src/ca2a_runtime/cli.py --at-time help for verify-lineage reads "Credential validity/revocation evaluation time; not the TRACE clock". That is the design, and it is exactly why the two clocks need a relation somewhere. Today they have none.

draft-durga-agenttrust-trace-verification-00 §5 lists "temporal monotonicity" among the invariants of an N-hop delegation chain (hop[i].issued_at >= hop[i-1].issued_at). ca2a verifies the other three (signature validity, hash continuity, scope attenuation) and not this one.

Reproduction

From a ca2a checkout at 945292b, with agent-manifest, agentrust-trace and agentrust-trace-tests importable (PYTHONPATH=src:<trace-spec>/src:<agent-manifest>/python/src):

python - <<'PY'
import time
from ca2a_runtime.delegation import DelegationCredential, new_keypair, verify_chain
from ca2a_runtime.trace_binding import HopContext, HopSpec, digest, emit_dag
from ca2a_verify import cross_check_trace_dag, verify_trace_dag

now = int(time.time())
nb, na = now - 7200, now - 3600          # every credential: valid only from now-2h to now-1h
priv, pub = new_keypair()
chain, keys, parent = [], [], None
for d in range(3):
    p2, s2 = new_keypair()
    chain.append(DelegationCredential(f"cred-{d}", pub, s2, frozenset({"tool:search"}), d, parent, not_before=nb, not_after=na).sign(priv))
    keys.append(p2); parent = f"cred-{d}"; priv, pub = p2, s2
ctx = HopContext.software(model_provider="p", model_id="m", image_label="x", policy_bundle_hash=digest(b"p"))
T = [k.public_key() for k in keys]
root = chain[0].issuer

def run(label, iats):
    recs = emit_dag([HopSpec(subject=f"spiffe://x/{i}", signing_key=keys[i], context=ctx, iat=iat, credential_id=chain[i].credential_id) for i, iat in enumerate(iats)])
    verify_chain(chain, trusted_root_issuers=[root], at_time=now - 5000)      # decision time, inside the window
    verify_trace_dag(recs, trusted_keys=T)
    cross_check_trace_dag(recs, chain)
    print(label, "-> verified; hop iats relative to now:", [i - now for i in iats], "window:", (nb - now, na - now))

import json, pathlib, tempfile
from ca2a_runtime.cli import main
tmp = pathlib.Path(tempfile.mkdtemp(dir="."))
recs = emit_dag([HopSpec(subject=f"spiffe://x/{i}", signing_key=keys[i], context=ctx, iat=now, credential_id=chain[i].credential_id) for i in range(3)])
(tmp / "dag.json").write_text(json.dumps(recs))
(tmp / "chain.json").write_text(json.dumps([{**c.body(), "signature": c.signature} for c in chain]))
print("CLI:", end=" ")
main(["verify-lineage", "--dag", str(tmp / "dag.json"), "--chain", str(tmp / "chain.json"),
      "--trusted-root-issuer", root, "--at-time", str(now - 5000)])
import shutil; shutil.rmtree(tmp)

run("A. every record signed AFTER the credential expired        ", [now, now, now])
run("B. child records dated BEFORE the parent delegated to them ", [now - 5000, now - 6000, now - 7000])
run("C. records dated before the credential's not_before       ", [now - 90000] * 3)
PY

Output:

CLI: {"verified": true, "verification": "authenticated_lineage", "records": 3, "cross_checked": true, "revocation": "not_checked"}
A. every record signed AFTER the credential expired         -> verified; hop iats relative to now: [0, 0, 0] window: (-7200, -3600)
B. child records dated BEFORE the parent delegated to them  -> verified; hop iats relative to now: [-5000, -6000, -7000] window: (-7200, -3600)
C. records dated before the credential's not_before        -> verified; hop iats relative to now: [-90000, -90000, -90000] window: (-7200, -3600)

Every credential in the chain is valid only from two hours ago to one hour ago. The CLI: line and case A have each hop record's iat set to the present, which is an hour after every credential expired, and the lineage is still authenticated_lineage. Case B dates the leaf's record before the root's, so the child acted before it was delegated to. Case C dates all three records a day before any credential existed. All four verify; verify_chain is evaluated at an instant (now - 5000) inside the window, exactly as the docs instruct an auditor to do.

Why it matters

  • Expired or retired delegate keys remain usable as evidence. A delegate whose credential has lapsed, or a leaked delegate key, can still mint a record that the lineage verifier authenticates. The validity window is the control that "bounds how long a grant lasts" (docs/spec/delegation-chain.md). Without the relation, it bounds only the verifier's chosen at_time, not the evidence the delegate signs.
  • Back-dating is free. The record's iat is signed by the same key the attacker already holds, so an attacker who can sign at all can also choose any iat inside the window that the auditor will evaluate, and nothing contradicts it.
  • Order of delegation is unchecked. A child record that predates its parent's is evidence of fabrication or clock failure, and an auditor would expect the verifier to say so.

Proposed fix (not yet a PR; the PR queue is being merged)

In cross_check_trace_dag, after the existing per-hop checks, for each i:

  1. If chain[i].not_before is not None, require record["iat"] >= chain[i].not_before; if chain[i].not_after is not None, require record["iat"] <= chain[i].not_after. The window is inclusive, matching delegation-chain.md. Raise ProvenanceLinkBroken naming the hop, the iat and the bound.
  2. For i > 0, require record["iat"] >= records[i - 1]["iat"], which is the monotonicity invariant above. Equal iat values stay allowed: the example DAGs sign every hop at the same second.
  3. Document in docs/spec/verification-library.md that at_time passed to verify_chain for an audit should be the hop's iat, and that cross_check_trace_dag now enforces the per-hop relation itself.

The change is small (about ten lines), fails closed, and does not change any currently accepted well-formed lineage: the demo chain has no windows, and a windowed chain whose records are signed inside their windows still verifies. Test cases A, B and C above, plus the in-window control, are the regression set.

Activity

  1. github-actions commented on Nov 4, 2026

    @github-actions
    Contributor

    🔴 Contributor Check: HIGH

    Check Result
    Profile HIGH
    Credential LOW
    Overall HIGH

    Automated check by AgenTrust Contributor Check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-review:HIGHContributor check flagged HIGH risk

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions