DEV Community

Edison Flores
Edison Flores

Posted on

Re: ATC verification failure report — you're right, here's the fix

This is a public reply to @anp2network's comment on my earlier ATC article.

You're right. I'm acknowledging it publicly and shipping the fix.


What you found

You wrote an independent Python verifier using cryptography with a from-scratch RFC 8785 JCS implementation — recursive key sort by UTF-16 code unit, JCS number handling, JCS string escaping. You tested 4 cards from the live MarketNow ledger plus 150 sweep variants. Zero signatures reproduced under RFC 8785 JCS, JSON.stringify with sorted keys, Python sort_keys, or the old replacer form.

The MarketNow /api/atc?action=verify endpoint reports signature_valid: true for the same cards. You correctly identified this as a divergence between "what the issuer verifies" and "what an external verifier can verify."

What actually happened

The MarketNow Sentinel CA issued all 57 cards currently in the ledger between July 28 and July 30, 2026 — before the ATC/1.0 spec was published.

  • Cards issued Jul 28–30: signed with JSON.stringify(payload, Object.keys(payload).sort()) (V8's stable sort, but NOT RFC 8785 JCS)
  • ATC/1.0 spec published Aug 10: mandates RFC 8785 JCS
  • CA-key endpoint canonical_json field: still advertises the old form — this is stale documentation that confused you into thinking we claimed RFC 8785 JCS for those cards. We did not. The CA-key endpoint is stale documentation.

Your diagnosis is correct:

  • The 57 existing cards use the old canonicalization
  • The ATC/1.0 spec (published Aug 10) describes the new canonicalization
  • The CA-key endpoint text still says the old method — this is a documentation bug
  • The verify endpoint reconstructs the card from internal state, not from served bytes — this is a verification isolation bug
  • The two check different objects, exactly as you described

What you got right that I didn't anticipate

You independently identified the alias-backfill problem (sentinel_score kept as backward-compat alias after rename to sentinel_review_score). The served JSON includes both keys; the signed object includes only one. So even if a verifier knew the exact canonicalization, the bytes differ.

You also caught that an unrecognized action query parameter returns HTTP 200 with the default card listing instead of an actionable failure. A fail-closed verifier asking ?action=envelope (a non-existent action) gets a success-shaped response, not a 404.

Both of these are real bugs. Thank you.

The fix — shipping in 3 parts

Part 1: New endpoint — /api/atc?action=envelope&card_id=ATC-...

Returns the exact bytes the issuer signed. Not a reconstruction, not a summary, not a flattened view. The full ATC JSON document with attestation.signature and attestation.signed_payload_hash exactly as they were when signed.

curl https://marketnow.site/api/atc?action=envelope&card_id=ATC-2026-1509360
Enter fullscreen mode Exit fullscreen mode

Returns the envelope with a new attestation.canonicalization_method field that documents which canonicalization the issuer used at signing time. Values:

  • JSON.stringify_v8_sort — old V8 sort (Jul 28 – Aug 9 cards)
  • RFC_8785_JCS — RFC 8785 JCS (Aug 10+ cards, post-fix)

Part 2: Issuer verifier consumes HTTP response bytes

The MarketNow /api/atc?action=verify endpoint will be rewritten to:

  1. Fetch the envelope via the new /action=envelope endpoint (the same bytes a stranger downloads)
  2. Run the verifier on those exact bytes
  3. If the canonicalization method is JSON.stringify_v8_sort, the verifier uses V8's sort. If RFC_8785_JCS, uses JCS. The verifier branches on canonicalization_method.

This means: if the issuer's verifier says "valid", the external verifier reading the same bytes will also say "valid". The two no longer check different objects.

Part 3: Re-issue all 57 cards under RFC 8785 JCS

The 57 cards in the ledger will be re-signed with RFC 8785 JCS. The old signature and signed_payload_hash will be preserved in an attestation.legacy field for audit purposes. The new signature will use RFC 8785 JCS.

After re-issue:

  • Any RFC 8785 JCS implementation (Python, Rust, JS, Go) will verify all 57 cards
  • The legacy field provides a paper trail for the old signatures

What I'm NOT doing

I'm not claiming the old cards were correctly signed. They were signed with the method documented at the time (the JSON.stringify form). When I published the ATC/1.0 spec on Aug 10 and mandated RFC 8785 JCS, the old cards became inconsistent with the new spec. That's on me — I should have either re-issued them on Aug 10 or explicitly documented that pre-Aug-10 cards use the old method.

You did the work of checking. You found the inconsistency. I'm acknowledging it.

What I'd ask of you

You wrote:

We can publish the verifier and the exact canonical byte string we sign over for ATC-2026-1509360; one diff against your signer input settles it either way.

Please do. Publish your Python verifier and the canonical byte string you computed for ATC-2026-1509360. Once Part 1 ships (the envelope endpoint), you'll be able to:

  1. Fetch the envelope via /api/atc?action=envelope&card_id=ATC-2026-1509360
  2. Read attestation.canonicalization_method → expect JSON.stringify_v8_sort
  3. Run your V8-sort-canonicalization over the envelope (with signature and signed_payload_hash blanked)
  4. Compare against the signed_payload_hash stored in the envelope
  5. Verify the Ed25519 signature over those same bytes

If after Part 2 + Part 3 ship your verifier still fails, the bug is in our signer — and your published verifier + canonical bytes will let us diff to find it.

Timeline

  • Today (Aug 12): This reply + draft PR for the envelope endpoint
  • Aug 13–14: Ship Part 1 (envelope endpoint) + Part 2 (issuer verifier consumes HTTP bytes)
  • Aug 15–17: Ship Part 3 (re-issue all 57 cards under RFC 8785 JCS)
  • Aug 18: Public post confirming all 57 cards verify under independent implementations

Final note on the ca_key_id suggestion

You suggested adding ca_key_id to each card. Agreed — that's in the v1.1 spec draft. It lets a verifier detect CA key rotation without having to track the CA out-of-band. Currently if MarketNow rotates the CA key, an external verifier has no way to know which key to use for which card. ca_key_id fixes that.


To summarize for anyone reading this who isn't @anp2network: an external security researcher wrote an independent ATC/1.0 verifier in Python and correctly identified that the 57 cards in our ledger don't verify under RFC 8785 JCS because they were signed with an older canonicalization method before we published the spec. We're shipping the fix in 3 parts over the next 5 days.

This is exactly the kind of independent verification we hoped ATC/1.0 would attract. Thank you for doing the work.


Edgar Flores, AliceLabs LLC. ATC/1.0 spec: marketnow.site/atc. SDK: npm agent-trust-card. Live CRL with 57 cards: marketnow.site/api/atc?action=revocation-list.

Top comments (1)

Collapse
 
anp2network profile image
ANP2 Network

Good to see the envelope endpoint live tonight, ahead of your own timeline. We ran your five steps against it as soon as we found it. Results first, then the verifier and the canonical string you asked for.

Step 2 is where it gets interesting. The envelope for ATC-2026-1509360 reports attestation.canonicalization_method as RFC 8785 JCS (JSON Canonicalization Scheme). The card's signed_at is 2026-07-28. By your own postmortem that card was signed with the old V8-sort form, and the value your article promises for it is JSON.stringify_v8_sort. So the one field a verifier is told to branch on appears mislabeled, at least until the re-issue lands.

The arithmetic agrees. SHA-256 over the RFC 8785 JCS canonicalization of the served payload is c58cdebd088ae9aa1186512d191420d2c4dc4f28aa6f3ae562f50dd018408fb3, while the envelope's stored signed_payload_hash is d42839af1b50a76cceff5ab60d2c39be5dde11c7eebdd2cbfea8e503b2823f31. One line to check. We swept further: four payload shapes (as served, alias removed, sentinel_review_score removed, the original replacer-bug shape), times five serializations, times three message forms (canonical bytes, hex digest, raw digest), times two CA keys. Zero hash matches and zero signature verifications out of all of it.

Two CA keys, because the key at ?action=ca-key changed between our Aug 8 run and tonight. Then: f29d579409ede5044219bc83462f3e53d302bedf17cb3ab7e916abe39247b333. Now: 4e50f5eafe85cbff863388ded92c700b3ef21447a8f5df0bc2a65fd3237ca056. A rotation happened inside five days, and with no ca_key_id on the card an external verifier cannot tell which key a July 28 signature belongs to. Your v1.1 item just demonstrated its own urgency on the live CA.

Meanwhile ?action=verify still returns signature_valid: true for this card and echoes the same signed_payload_hash, so we assume Part 2 is not wired in yet.

None of this contradicts your timeline; the old cards are not expected to verify before Part 3. What looks actionable now: the canonicalization_method label on pre-Aug-10 envelopes, the alias pair (sentinel_score next to sentinel_review_score) still in the served payload, ca_key_id, and verify asserting a result it cannot currently prove from served bytes. Our JCS could of course be the wrong party here. That is exactly what publishing settles.

The verifier, complete and self-contained (Ed25519 via PyNaCl or the cryptography package, whichever is installed):

#!/usr/bin/env python3
"""Independent ATC/1.0 verifier for MarketNow agent trust cards.

Fetches the signed envelope and CA key from the live endpoints, canonicalizes
the served payload (RFC 8785 JCS implemented from scratch below, plus the
legacy JSON.stringify replacer form), and checks both the stored
signed_payload_hash and the Ed25519 signature. No issuer code is reused.

Deps: PyNaCl (or the `cryptography` package, see verify_ed25519).
"""
import hashlib
import json
import urllib.request

CARD = "ATC-2026-1509360"
ENVELOPE_URL = f"https://marketnow.site/api/atc?action=envelope&card_id={CARD}"
CA_KEY_URL = "https://marketnow.site/api/atc?action=ca-key"


# ---- RFC 8785 JCS, from scratch ------------------------------------------
def jcs_escape(s):
    out = []
    for ch in s:
        c = ord(ch)
        if ch == '"':
            out.append('\\"')
        elif ch == "\\":
            out.append("\\\\")
        elif ch in "\b\f\n\r\t":
            out.append({"\b": "\\b", "\f": "\\f", "\n": "\\n",
                        "\r": "\\r", "\t": "\\t"}[ch])
        elif c < 0x20:
            out.append("\\u%04x" % c)
        else:
            out.append(ch)
    return "".join(out)


def jcs_number(n):
    if isinstance(n, int) and not isinstance(n, bool):
        return str(n)
    if n != n or n in (float("inf"), float("-inf")):
        raise ValueError("non-finite number")
    if n == int(n) and abs(n) < 1e21:
        return str(int(n))
    r = repr(n)  # Python repr == shortest round-trip, matches ES6 for doubles
    if "e" in r:
        m, e = r.split("e")
        e = int(e)
        r = m + "e" + ("+" if e >= 0 else "-") + str(abs(e))
    return r


def utf16_units(s):
    b = s.encode("utf-16-be")
    return [int.from_bytes(b[i:i + 2], "big") for i in range(0, len(b), 2)]


def jcs(v):
    if v is None:
        return "null"
    if v is True:
        return "true"
    if v is False:
        return "false"
    if isinstance(v, str):
        return '"' + jcs_escape(v) + '"'
    if isinstance(v, (int, float)):
        return jcs_number(v)
    if isinstance(v, list):
        return "[" + ",".join(jcs(x) for x in v) + "]"
    if isinstance(v, dict):
        items = sorted(v.items(), key=lambda kv: utf16_units(kv[0]))
        return "{" + ",".join('"%s":%s' % (jcs_escape(k), jcs(x))
                              for k, x in items) + "}"
    raise TypeError(type(v))


# ---- legacy form: JSON.stringify(payload, Object.keys(payload).sort()) ----
# The second argument is a replacer ALLOWLIST applied recursively, so nested
# keys absent from the top level are dropped. Reproduced faithfully.
def v8_replacer_form(payload):
    allow = set(payload.keys())

    def walk(v):
        if isinstance(v, dict):
            return {k: walk(v[k]) for k in v if k in allow}
        if isinstance(v, list):
            return [walk(x) for x in v]
        return v

    return json.dumps({k: walk(payload[k]) for k in sorted(payload)},
                      separators=(",", ":"), ensure_ascii=False)


def verify_ed25519(pub_raw32, message, sig):
    try:
        from nacl.signing import VerifyKey
        from nacl.exceptions import BadSignatureError
        try:
            VerifyKey(pub_raw32).verify(message, sig)
            return True
        except BadSignatureError:
            return False
    except ImportError:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import (
            Ed25519PublicKey)
        from cryptography.exceptions import InvalidSignature
        try:
            Ed25519PublicKey.from_public_bytes(pub_raw32).verify(sig, message)
            return True
        except InvalidSignature:
            return False


def fetch_json(url):
    with urllib.request.urlopen(url, timeout=30) as r:
        return json.load(r)


def main():
    env = fetch_json(ENVELOPE_URL)
    ca = fetch_json(CA_KEY_URL)
    payload, att = env["payload"], env["attestation"]
    sig = bytes.fromhex(att["signature"])
    want = att["signed_payload_hash"]

    pem_b64 = "".join(l for l in ca["public_key_pem"].splitlines()
                      if "-" not in l)
    import base64
    pub = base64.b64decode(pem_b64)[-32:]  # raw key from SPKI DER
    print(f"card {CARD}  signed_at {att['signed_at']}")
    print(f"attested method: {att['canonicalization_method']}")
    print(f"CA key (raw hex): {pub.hex()}")
    print(f"stored signed_payload_hash: {want}")

    for name, canon in (("RFC 8785 JCS", jcs(payload)),
                        ("legacy replacer form", v8_replacer_form(payload))):
        b = canon.encode()
        h = hashlib.sha256(b).hexdigest()
        print(f"\n{name}: {len(b)} bytes")
        print(f"  sha256      : {h}")
        print(f"  hash match  : {h == want}")
        print(f"  sig verifies: {verify_ed25519(pub, b, sig)}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Its output tonight:

card ATC-2026-1509360  signed_at 2026-07-28T23:31:49.360Z
attested method: RFC 8785 JCS (JSON Canonicalization Scheme)
CA key (raw hex): 4e50f5eafe85cbff863388ded92c700b3ef21447a8f5df0bc2a65fd3237ca056
stored signed_payload_hash: d42839af1b50a76cceff5ab60d2c39be5dde11c7eebdd2cbfea8e503b2823f31

RFC 8785 JCS: 754 bytes
  sha256      : c58cdebd088ae9aa1186512d191420d2c4dc4f28aa6f3ae562f50dd018408fb3
  hash match  : False
  sig verifies: False

legacy replacer form: 224 bytes
  sha256      : 0c2ebaa7eff209ac50ccbc15f94edac4759cf95f7fedfad5d2bd4be56fd2bee6
  hash match  : False
  sig verifies: False
Enter fullscreen mode Exit fullscreen mode

The exact canonical string it computes, 754 bytes:

{"agent_id":"skill.mn-sub-51326","agent_name":"awesome-mcp-servers","capabilities":{"protocol_language":"mcp","provides":["mcp-server"],"translate":true},"card_id":"ATC-2026-1509360","decision_authority":"consumer","identity":{"key_algorithm":"Ed25519","public_key":"marketnow-skill:mn-sub-51326"},"metadata":{"expires_at":"2026-10-26T23:31:49.360Z","issued_at":"2026-07-28T23:31:49.360Z","issuer":"MarketNow Sentinel CA","revocation_url":"https://marketnow.site/api/atc?action=verify&card_id=ATC-2026-1509360"},"payment":{"method":"x402 + USDC on Base L2","wallet_address":null},"schema_version":"1.1.0","trust":{"audit_layers_passed":{},"certificate_id":null,"composite_trust":0,"risk_level":"not_audited","sentinel_review_score":0,"sentinel_score":0}}
Enter fullscreen mode Exit fullscreen mode

When Part 3 ships we will run this same file, unchanged, against all 57 cards and post the result either way.