Skip to content

verify_trace_dag reports a schema-valid sha384: parent link as tampering - #119

Open
lywinged wants to merge 1 commit into
agentrust-io:mainfrom
lywinged:fix/unsupported-link-digest
Open

verify_trace_dag reports a schema-valid sha384: parent link as tampering#119
lywinged wants to merge 1 commit into
agentrust-io:mainfrom
lywinged:fix/unsupported-link-digest

Conversation

@lywinged

@lywinged lywinged commented Aug 18, 2026

Copy link
Copy Markdown

verify_trace_dag accepts sha384: as a well-formed parent link, then compares it
against a value it only ever computes as sha256:. The mismatch is reported as a
tampered or reparented record. The record is neither.

This carries the fix, not just the finding. Reproduction first, since the finding is
what justifies the change.

Reproduction

Two DAGs, both correctly signed, differing only in which of the two digests the TRACE
schema permits names the parent. Against 8c49177:

sha256: ACCEPT
sha384: ProvenanceLinkBroken: record 1 parent link does not match the previous record's hash
        detail: 'a tampered or reparented record was detected'
Script (runs against main, no test dependencies)
import hashlib, time, rfc8785
from agentrust_trace import generate_key
from ca2a_runtime.trace_binding import (HopContext, digest, build_trace_record,
                                        sign_trace_record, trace_record_hash)
from ca2a_verify import verify_trace_dag

def dag(alg):
    now, k = int(time.time()), [generate_key(), generate_key()]
    ctx = lambda i: HopContext.software(model_provider="anthropic", model_id="m",
                       image_label=f"h{i}", policy_bundle_hash=digest(b"p"))
    root = sign_trace_record(build_trace_record(subject="spiffe://x/a", iat=now,
               context=ctx(0), credential_id=None, parent_record_hash=None), k[0])
    link = (trace_record_hash(root) if alg == "sha256"
            else "sha384:" + hashlib.sha384(rfc8785.dumps(root)).hexdigest())
    child = sign_trace_record(build_trace_record(subject="spiffe://x/b", iat=now,
                context=ctx(1), credential_id="cred-1", parent_record_hash=link), k[1])
    return [root, child], [r["cnf"]["jwk"] for r in (root, child)]

for alg in ("sha256", "sha384"):
    records, trusted = dag(alg)
    try:
        verify_trace_dag(records, trusted_keys=trusted)
        print(f"{alg}: ACCEPT")
    except Exception as e:
        print(f"{alg}: {type(e).__name__}: {e}\n{'':8}detail: {getattr(e,'detail',None)!r}")

The sha384 digest is correct. A verifier implementing sha384 resolves that link and
verifies the chain.

Where the two halves disagreed

ca2a_verify/dag.py:56-57 _DIGEST_RE accepts sha256: and sha384:, commented "same as the schema"
ca2a_verify/dag.py:78 that regex is the only validation the link gets
ca2a_runtime/trace_binding.py:207 trace_record_hash returns "sha256:" + ..., always
ca2a_verify/dag.py:186 the link is compared to it as a plain string
ca2a_verify/dag.py:189 the mismatch is reported as "a tampered or reparented record was detected"

The comment on line 56 is accurate. schema/trace-claim.json permits both digests for
delegation.parent_record_hash, and spec/trace-v0.2.md states SHA-384 is required for
FIPS-aligned profiles. The validation was right and the comparison had not caught up.

_DIGEST_RE has exactly one use site and nothing exercised its sha384 branch, which is
why this stayed quiet.

Severity

Not a security defect, and I am not reporting it as one. The behaviour is fail-closed:
no forged, reparented or tampered DAG is accepted as a result, and every sha256 chain
verifies exactly as before. What is lost is a conforming chain that cannot be read.

What is wrong is what the verifier says about it. docs/spec/failure-modes.md documents
that error as catching tampering and reparenting, so the detail string is the code's
documented meaning rather than incidental wording. A verifier that reports tampering when
it means it does not implement a digest puts a false finding into an audit record, and an
auditor reading the outcome cannot tell it from a real one.

The fix

The alternative was to recompute the parent hash with the algorithm the link names, which
would make a conforming sha384 chain verify. I did not take it, for a reason outside this
repository: agentrust-io/trace-spec#184 vectors 22 and 23 assert unverifiable with
digest_algorithm_unsupported for exactly this input, per your ruling on that case.
Accepting here would put the implementation and the published corpus in contradiction. If
you would rather have sha384 support, say so and I will send that instead and correct the
vectors, but the two should be decided together rather than separately.

So: TraceDigestUnsupported (TRACE_DIGEST_UNSUPPORTED, 501), following the shape
AttestationUnsupported already sets in this taxonomy for "no backend for this".

  • Raised before the comparison, per hop. An unreadable link deep in a chain is not
    masked by a readable one at the leaf. That is vector 23's case: a verifier that inspects
    the first link's algorithm and assumes the rest matches reports the chain verified
    having never resolved half of it.
  • LINK_DIGEST names the algorithm once, and is used both to label and to compute the
    hash, so the verifier cannot drift from the producer the way these two had.
  • 501 rather than 500, unlike AttestationUnsupported. Not implemented is what this
    is. Trivial to change if you would rather the two match.
  • Registered in error-codes.md, and failure-modes.md now says a hash that was never
    recomputed cannot be evidence of tampering.

Tests

Three cases plus a control, all in tests/unit/test_trace_binding.py:

Test What it pins
test_baseline_link_verifies the control, so the arms differ only in the digest
test_unsupported_link_digest_is_unverifiable_not_broken the two-hop case (vector 22)
test_unsupported_link_is_caught_wherever_it_sits one readable link and one unreadable one, in both orders (vector 23)
test_supported_digest_still_detects_a_broken_link a well-formed link pointing at the wrong record still raises ProvenanceLinkBroken

Rather than assume these fail for the right reason, I broke each behaviour in turn and
recorded which tests went red:

Mutation Tests that went red
the guard never fires (the defect, restored) both unsupported-digest tests
the tamper comparison never fires test_supported_digest_still_detects_a_broken_link, test_resigned_parent_breaks_child_link
the guard checks only the first non-root hop test_unsupported_link_is_caught_wherever_it_sits

The second row is the one worth reviewing: the fix must narrow tamper detection, not
replace it, and that test is red exactly when the old comparison is gone. The third row is
why the both-orders parametrisation is there. With a single ordering that mutation
survived, because a guard applied to the first hop alone is indistinguishable from a
per-hop guard when the interesting hop is first.

504 passed, 2 skipped; ruff check src tests clean; mypy clean on 42 source files.

One thing I did not touch

Running the suite rewrites examples/rejection-with-proof/{chain,dag}.json with freshly
generated keys, so a clean checkout has a dirty tree after pytest. Unrelated to this
change and left alone, but it is easy to mistake for something else in a diff.

…ot tampering

verify_trace_dag accepted sha384: as a well-formed parent link and then
compared it against a value trace_record_hash only ever computes as sha256:.
The mismatch was reported as ProvenanceLinkBroken, whose documented meaning in
failure-modes.md is that tampering or reparenting was detected.

Nothing was tampered with. The two halves disagreed: _DIGEST_RE admits both
digests the TRACE schema permits for delegation.parent_record_hash, and the
comparison had not caught up. A conforming, correctly signed chain differing
from an accepted baseline only in which permitted digest names its parent was
reported as a tampering finding, and an auditor reading the outcome had no way
to tell that from a real one.

Fail-closed either way: no forged, reparented or tampered DAG was accepted as a
result, and every sha256 chain verifies exactly as before. What changes is what
the verifier says about a chain it cannot read.

TraceDigestUnsupported (TRACE_DIGEST_UNSUPPORTED, 501) is raised instead, before
the comparison and per hop, so an unreadable link deep in a chain is not masked
by a readable one at the leaf. LINK_DIGEST names the algorithm once and is used
both to label and to compute the hash, so the verifier cannot drift from the
producer.

Tests cover the leaf case, the deep case, and a control that a well-formed link
pointing at the wrong record still raises ProvenanceLinkBroken: the fix narrows
tamper detection, it does not replace it. Both new tests fail without the guard.

Portable vectors for this case are agentrust-io/trace-spec#184, numbers 22 and
23, which assert unverifiable with digest_algorithm_unsupported.

Signed-off-by: lywinged <louie.lunz@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:UNKNOWN Contributor check flagged UNKNOWN risk label Aug 18, 2026
@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!

@lywinged

Copy link
Copy Markdown
Author

The red gate check is not a failure in this branch. require-maintainer-approval.yml
refuses any head commit that a maintainer has not approved, and I am not on the list, so
it will stay red until you look at it. Everything else is green: tests on 3.11 through
3.13 across ubuntu and windows, CodeQL, build, and check.

Noting it here because I cannot request a reviewer on this repository, so there is
nothing else that puts it in front of you. No rush from my side, and the branch is
rebased on 8c49177 if you want it later.

@imran-siddique
imran-siddique self-requested a review August 18, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:UNKNOWN Contributor check flagged UNKNOWN risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants