Repository navigation
Description
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:128verify_trace_dagverifies each record's structure, its signature, the trusted key and the parent link. It readsiatonly throughverify_record(..., max_age_seconds=...), which is a freshness bound against the verifier's clock and isNoneby default (the docstring says that default is "suited to offline audit of historical records").src/ca2a_verify/dag.py:224cross_check_trace_dagcompares count,credential_idand the signing key (cnf.jwk) againstchain[i].subject.iatis never read.src/ca2a_runtime/delegation/credential.pyverify_chain(..., at_time=...)evaluatesnot_before/not_afterat one caller-supplied instant for the whole chain.docs/spec/verification-library.md:25says 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'siat, and nothing passes it in.src/ca2a_runtime/cli.py--at-timehelp forverify-lineagereads "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 chosenat_time, not the evidence the delegate signs. - Back-dating is free. The record's
iatis signed by the same key the attacker already holds, so an attacker who can sign at all can also choose anyiatinside 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:
- If
chain[i].not_before is not None, requirerecord["iat"] >= chain[i].not_before; ifchain[i].not_after is not None, requirerecord["iat"] <= chain[i].not_after. The window is inclusive, matchingdelegation-chain.md. RaiseProvenanceLinkBrokennaming the hop, theiatand the bound. - For
i > 0, requirerecord["iat"] >= records[i - 1]["iat"], which is the monotonicity invariant above. Equaliatvalues stay allowed: the example DAGs sign every hop at the same second. - Document in
docs/spec/verification-library.mdthatat_timepassed toverify_chainfor an audit should be the hop'siat, and thatcross_check_trace_dagnow 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
github-actions commentedon Nov 4, 2026
🔴 Contributor Check: HIGH
Automated check by AgenTrust Contributor Check.