A standard envelope for third-party verifier attestations (carried as UCP Signals) #534
Replies: 11 comments
|
Facet has built the verify side of almost exactly this envelope in production, so a strong +1 on standardizing one rather than N. For context on where this is coming from: we verify inbound agent identity (KYAPay ES256 tokens, issuer-pinned, resolving
On Happy to contribute a second reference implementation from the agent-identity side if useful. |
|
+1 on one envelope, and a production data point that bears on both the canonicalization question and Weston's first note: the trust-verdict signal proposed in #535 ( That experience makes me want to push on open question 2: make the envelope a JWS rather than a hand-rolled signature field. The preimage ambiguity Weston is warning about disappears by construction, because RFC 7515 defines the signing input exactly (base64url header, dot, base64url payload), the signature lives outside the payload, and every mainstream JOSE library already agrees byte for byte. A custom "signature over JCS minus the signature field" will get re-implemented slightly differently N times, which is the failure this thread exists to prevent. It also settles the AP2-consistency question in the same move: On the other open questions, from what we run in production:
On replay: agreed that TTL alone is thin for payment-adjacent claims. For transaction-scoped claim types, the subject binding you already require ( Also +1 to promoting And I will take you up on the conformance vectors: once field names settle I will contribute signed vectors from our live engine for the risk |
|
@KwameNyantakyi coming back to the use case you opened this thread with, the decision-provenance record from #56: proof that a specific decision existed, with that content, at that moment, and was not rewritten after the fact. Quick status from #535, the external-verifier role proposal: the risk and identity halves of the envelope now verify end to end across two independent issuers (our EdDSA risk verdict, Facet's ES256 identity claim), with live-signed vectors and a cross-verify run anyone can reproduce. Of the four claim types enumerated there (risk, identity, reputation, decision-provenance), the one this thread started from is still unclaimed. It's also the one with requirements the others don't share: The issuer must sit outside the transaction. A rail attesting to its own decision log is exactly what auditors distrust. Same structural-neutrality rule as the conformance clause already on record in #535. Two process notes. The cross-issuer vector pack from #535 (5 risk vectors, 5 identity vectors, the cross-verify run) could become the conformance suite this envelope references. A spec with a runnable test suite stays honest, and anyone adding a claim type has a bar to clear. And the envelope itself belongs to no issuer. As it firms up, its home should be a neutral namespace, this org or one no single vendor controls, with each claim-type spec referenced from its own repo. Weston agreed with this on #535; noting it here so it lands on the envelope thread's record too. |
|
Picking up where this converged: @lucaslubi's push to make the envelope a JWS rather than a hand-rolled "signature over JCS minus the signature field" is the right call, and #535 now has it running, so I want to put the concrete field names on the table here. That's the piece #534 still lacks, and the piece every "I'll contribute vectors once names settle" is waiting on. The realization from the cross-verify run (two independent issuers, two algs, both verified offline, reproducible from the committed bytes) is a "signals": {
"llc.facet.kya": { // issuer namespace + claim type in one key
"format": "kya+jwt", // the JWS typ
"jws": "<compact JWS>", // the only trusted thing
"kid": "facet-1b644cbb", // convenience copy of the protected-header kid
"provider_jwks": "https://issuer.facet.llc/.well-known/jwks.json" // NON-NORMATIVE hint
},
"com.fidacy.trust_verdict": {
"format": "application/vc+jws",
"jws": "<compact JWS>",
"kid": "...",
"provider_jwks": "https://api.fidacy.com/.well-known/jwks.json"
}
}How this maps onto your original shape, so nothing is lost:
The two rules that make the non-
On the remaining open questions:
Neutral home, restating what Lucas and I agreed on #535 so it lands here too: the envelope belongs to no issuer. Its spec should live in a neutral namespace (this org or one no single vendor controls), with each claim-type spec referenced from its own repo. If this shape looks right, the concrete next step is a spec PR for the envelope plus the two claim-type profiles that already run under it, with the vector pack as the conformance reference. Happy to open the identity half. |
|
On the decision-provenance longevity requirement you set out — evidence that has to hold for years and gets tested in a dispute long after issuance — one axis worth putting on the record while the alg set is still open: the signing algorithm's own survival over that horizon, which the standards actually closed in May. External timestamping (your Bitcoin checkpoint) proves a verdict's bytes existed at time T — necessary, and right. What it doesn't do is keep the signature unforgeable across the retention window: ES256 and EdDSA are both classically breakable, and decision-provenance is exactly the long-lived claim type where a retrospective-forgery horizon (a CRQC years after issuance) is in scope in a way it isn't for a one-hour identity token. Anchoring everything mitigates it for issuers who anchor; it's thinner for those who don't, and it's a liveness dependency at issue time. The reason to raise it now, not as future work: RFC 9964 (ML-DSA for JOSE and COSE, May 2026) registered ML-DSA as a first-class JOSE algorithm — alg values "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" (COSE -48/-49/-50), plus the AKP key type. A post-quantum signature is now a registered compact-JWS alg, so it fits the dispatch-on-alg envelope you and @westonale-facet already run — resolve JWKS by kid, dispatch to the ML-DSA verify routine exactly as for ES256 vs EdDSA. Wrapping our claim into that form is a thin serialization on top, not a new envelope field. Two honest notes so this isn't a PQC hand-wave:
If useful, I've put together an ML-DSA-65 decision-provenance conformance-vector pack in the RFC 9964 JOSE form — valid, expired, wrong-session, rotated-key, bad-signature, plus a hybrid classical-valid/PQC-invalid case — that verifies offline against a published AKP JWK. Fair warning: stock JOSE libraries don't implement ML-DSA/AKP yet (jwcrypto doesn't), so today it self-verifies via the ML-DSA primitive rather than an off-the-shelf verifier; it's built to drop into the #535 cross-issuer pack the day a JOSE lib ships AKP support. Happy to contribute it against whatever field names #534 settles on — pack here if useful: ‹pack link› |
|
@chopmob-cloud This is the right question to put on the record while the alg set is still open, and the split you drew is exactly right: the anchor answers "did these bytes exist at T", the signature answers "who produced them", and only the second decays. Credit also for naming the signature analog of harvest-now-decrypt-later correctly, retrospective forgery, instead of waving a confidentiality argument at a signing scheme. But I want to relocate where the horizon actually lives for this claim type, because anchoring is not a mitigation some issuers opt into. For For an anchored record, a CRQC in 2035 can forge an Ed25519 signature over whatever bytes it likes. What it cannot do is place those bytes inside a checkpoint Bitcoin confirmed in 2026. To pass verification-with-inclusion, the forgery needs a second preimage against SHA-256 somewhere in the hash chain, or a rewrite of the anchor itself, and Grover only degrades a 256-bit hash to 128-bit quantum security, which remains out of reach on any horizon this spec has to care about. So the unforgeability requirement on the classical signature shrinks from the retention window, years, to the interval between issuance and checkpoint confirmation, hours. Past the checkpoint the signature is demoted from load-bearing to corroborating, and the inclusion proof carries the dispute. Key lifecycle bounds it further: the pin lives in the claim type's public spec, so at the classical-to-PQ transition the issuer retires classical kids for new issuance while every old record stays verifiable through inclusion, and a forged "old" record still has to name a checkpoint, and the checkpoints are enumerable. The residual exposure is then exactly the two cases you flagged: records never anchored, which for this claim type is a conformance failure rather than a deployment choice, and verifiers who check the JWS and skip the inclusion proof. The second one is real and currently implicit, so I will make it explicit in the claim doc: for decision-provenance, a verifier that has checked the signature and not the anchor has not verified the claim. Thank you for surfacing that; it is the genuinely load-bearing sentence in this exchange. On adopting ML-DSA now. RFC 9964 makes it a registered compact-JWS alg, and this envelope dispatches on alg by construction, so structurally it drops in as a third sibling with zero new fields, same kid-resolved-within-pinned-JWKS path that already carries ES256 and EdDSA. What blocks it today is not the envelope, it is the envelope's own conformance bar. Section 8 defines a profile as real when an independent party can recompute its verdicts from committed bytes without running the issuer's code, and both live profiles cash that out the same way: verifies with a stock JOSE library, no vendor package. You said it yourself, no mainstream JOSE stack ships AKP yet, so the pack self-verifies via the primitive. Registered is not interoperable, and a vector set only its author can check is precisely the failure mode this repo exists to catch. We just merged a fix for a subtler instance of the same disease in Facet-llc/ucp-verifier-envelope#6, a bad-signature vector that decoded to genuine bytes and passed on where the signature happened to land. The same reasoning keeps hybrid Falcon out of normative text while draft-ietf-cose-falcon carries provisional identifiers: running Falcon-1024 internally is your prerogative, pinning provisional code points in an interoperable envelope is how envelopes rot. Size argues for exactly the per-claim-type choice you propose, and it is worth stating with the numbers. An Ed25519 signature is 64 bytes and rides inline on the session-scoped verdict in checkout metadata. ML-DSA-65 at roughly 3.3 KB is fifty times that, intolerable on the hot path, and perfectly tolerable on decision-provenance, which is stored evidence with a content-hash pattern already in section 7 to absorb it. So, concretely: contribute the pack as a tracked |
|
@lucaslubi Pack is up: https://gist.github.com/chopmob-cloud/e32efb91048c2aef43d7679d14e09091 It is the ML-DSA-65 decision-provenance set in RFC 9964 JOSE form: compact JWS (RFC 7515) with an AKP JWK (kty AKP, alg ML-DSA-65). One correction to expectations first, this half is Python, not npm. Reproduce with Six vectors, each with its expected disposition: valid (accept), expired and wrong-session (reject on scope), rotated-key and bad-signature (reject on signature), and the hybrid classical-valid / PQC-invalid case you asked for (Ed25519 co-signature verifies, ML-DSA signature corrupted, reject on PQC). That last one is the discrimination test for a verifier that silently falls back to the classical half. On your ucp-verifier-envelope#6 point: the bad-signature vector flips a bit in the decoded signature bytes, not a base64url character, precisely because a no-pad tail has a restricted final-char alphabet and editing an encoded char can be a silent no-op. Same defect class, handled. The signing input is pinned in the README: sign ASCII(header_b64 + "." + payload_b64), where payload_b64 is BASE64URL_NOPAD(JCS(payload)). We do not sign the bare JCS bytes. Honest status against your Section 8 bar: it self-verifies via the ml_dsa_65 primitive, not a stock JOSE library, because no mainstream JOSE stack ships AKP yet (jwcrypto included). So it is pre-interoperability by your own criterion, and it graduates the day a published JOSE library verifies these cold. Sizes for the record: 1952-byte public key, 3309-byte signature. Your three points land: anchoring as normative requirement 3 of 3, the explicit verifier obligation to check inclusion and not only the signature, and per-claim-type alg choice driven by size. Run it cold whenever; happy to align field names to whatever #534 settles on. |
|
Ran the pack cold, without executing the scripts. Everything checkable from the data alone holds:
On the hybrid vector I verified the classical half end to end: 32-byte key, 64-byte One interop note before the substance: your signing input construction is exactly what Two things I would raise. First, the hybrid format. ed25519_pub and ed25519_sig sit outside the JWS as sibling Second, where wrong-session gets rejected. The merged envelope spec separates On field names, I would not wait for this thread to settle. The envelope repo has a On alg by size, a production number in support: we sign period audit exports with Agreed on your honest-status framing too. Self-verification through ml_dsa_65 is the only |
|
Pack is updated: https://gist.github.com/chopmob-cloud/e32efb91048c2aef43d7679d14e09091 cnf.jkt binding. Fixed. The Ed25519 public key is now bound inside the PQC-signed payload via a Seventh vector. Added: wrong-session label. Fixed. Results, run cold: 7/7 PASS. bad-signature detail: Honest status unchanged: self-verification via On the claim-type registry PR, will open it against |
|
Branch from |
|
westonale-facet, noted on CI, and my mistake upthread for assuming it existed. Built it and opened it as the follow-up: Facet-llc/ucp-verifier-envelope#8. Two layers, and the split matters more than it looks. Deterministic gate on PRs: run a profile's generator, then its verifier, and let the exit code decide. My first instinct was to assert the vectors regenerate byte-identical, and that is wrong: I ran both existing generators twice and the bytes differ every time, because each run signs with a fresh key. What is stable is each vector's expected disposition, which is exactly what verify.py already asserts per vector across both layers. No network in this job, so a PR only goes red for a real reason. Live checks on a daily schedule: cross-verify.mjs mints a real KYA and fetches two production JWKS. Worth keeping, but a PR must not go red because an issuer's JWKS was slow that minute. I ran it before scheduling it: 4 pass, 0 fail. Scope, stated plainly: only decision-provenance has a runnable verifier today. fidacy-risk and facet-identity ship generators without one, mine included, so wiring them into the gate starts by writing those verify scripts rather than by adding more YAML. I would rather the gate cover one profile honestly than list three and check one. The PR does not depend on #7's merge order: the gate checks for the profile directory and skips with a notice on refs that predate it, so it merges green on its own and arms itself the moment decision-provenance lands. No stepping on chopmob's PR. Proven both directions locally: clean run exits 0 at 7/7; flipping one character of the valid vector's signature turns it into authentic to reject and exit 1; restoring returns it to green. chopmob: ran your updated pack cold in an empty venv and reproduced 7/7 independently. I also recomputed the RFC 7638 thumbprint of ed25519_pub myself rather than trusting verify.py, and it matches cnf.jkt on the legitimate hybrid vector and mismatches on the substituted one. The binding does what you say it does. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
UCP's Security section already defines Signals as "cryptographically signed results from an external verifier" that a business validates against a published key set, and dev.ucp.shopping.ap2_mandate already leans on verifiable credentials for payment authorization.
So the protocol clearly anticipates external attestations but it doesn't yet specify a common envelope for them. Two active threads need one and are quietly diverging:
#56 (decision provenance) wants a signed record of which inputs an agent's decision cited (the prompt-injection-via-product-description case).
#301 (MCP-T trust scoring) wants a signed pass/fail trust result from a trust provider.
These are different claims but the same delivery problem: a third party signs something about an agent, the merchant verifies it offline against a published key.
If every verifier invents its own envelope, merchants implement N validators.
If we agree on one, the Signals slot becomes pluggable.
Proposed shape (vendor-namespaced until there's uptake, per the extension framework):
Merchant validation reuses the existing Signals path: resolve kid → published JWK set, verify the signature over the JCS-canonicalized envelope (minus signature), reject if expired or if subject.ucp_checkout_session doesn't match the session in hand.
Open questions for the community:
Should the envelope live in core (so claim_type is an extension point) or stay fully vendor-namespaced for now?
Canonicalization: is RFC 8785 (JCS) + Ed25519 the right default, or should we mirror whatever AP2/ap2_mandate uses for consistency?
Inline vs. content-hash-referenced payloads for bulky claims (the inline-vs-URL question raised in #56)?
Multi-issuer trust: how does a merchant advertise which verifiers/kids it accepts in /.well-known/ucp?
Happy to contribute a reference verifier + validator and conformance vectors (valid / expired / wrong-session / bad-signature / rotated-key) if there's appetite, so this is testable rather than just on paper.
All reactions