Add scoped authorization receipt security guidance#2867
Conversation
4f360c4 to
d482dc2
Compare
|
Folded in the latest issue-thread feedback by naming the success/timeout retry case explicitly: provider completes or reserves the mutation, the client times out before recording the outcome, and a retry with the same action/receipt ID plus canonical digest returns the prior outcome/completed receipt or an already_consumed state without rerunning the mutating handler. Also separated out_of_scope from already_consumed in the acceptance criteria. |
|
This captures the invariant I was trying to preserve in the issue thread. The load-bearing pieces are now explicit:
I would keep the scope exactly this narrow. The section gives implementers concrete acceptance criteria without turning MCP core into a normative receipt schema. |
|
Thanks, this is exactly the boundary I was trying to preserve as well. I’ll keep the PR scoped to implementation guidance and acceptance criteria, without introducing a normative receipt schema into MCP core. |
|
Reads well, and keeping it non-normative with concrete acceptance criteria is the right call. Two security considerations I would add before merge, both guidance rather than schema so they stay in scope. The load-bearing one is receipt authenticity. The provider verification list (fresh, intended-for, bound-to) is right, but the text never says how the provider knows the receipt is genuine. Since it travels in tool arguments or request metadata on a client-submitted call, a client can present its own Smaller: We hit both implementing something close to this in assay, and the authenticity one was the difference between a receipt that authorizes and one that merely describes. |
|
Thanks, agreed on both points. I pushed an update that keeps this as guidance rather than schema:
Verification: git diff --check. |
|
That reads right to me. The authenticity sentence is the load-bearing one: once the provider verifies the receipt was issued by the trusted authority and protected from client modification before freshness or scope checks count, the receipt authorizes rather than merely describes. And failing closed on a digest or canonicalization mismatch before business logic runs is the safe default. Scope stays exactly where it should, guidance and acceptance criteria, no normative schema. Nothing else from me; this closes both points. |
|
Coming over from #2852, I think the SEP-2828 mapping is now at the point where it would strengthen this PR as a short non-normative note, without turning this into a schema requirement. The narrow shape I would add is:
The two edge fixtures from #2852 seem especially worth naming because they prevent the example from resting on hidden producer behavior:
That keeps the PR in its current lane: implementation guidance and acceptance criteria. The useful thing implementers copy is not a mandatory receipt format; it is the boundary that a signed record is durable evidence of what was admitted and what ran, while the authorization gate remains the thing that decides execution and consumes authority. |
|
@rpelevin this note shape is right, and I would land it close to as written. The obligation set as the conformance target, SEP-2828 as one illustrative shape rather than the required format, and the evidence-not-gate boundary are the things implementers actually need. One precision on the no-2787 fallback, since it is the case most likely to drift in the wild. The fixture takes the observed tools/call envelope with the binding material under On our side, the no-2787 fallback and the equal-decidedAt case are the two the Assay consumer could not reproduce from committed evidence before. Once we extend it to the no-attestation input path we will run the fallback match and replay cases and the ambiguous-supersession case against the committed corpus and report what reproduces. The ambiguous verdict is the easy half, a conformant reader should refuse to pick a winner and we can show it does; the fallback is the one that needs the named preimage above to be reproducible at all. |
|
Yes, I agree with that precision. The fallback should name a projection, not hash the whole observed For the no-2787 path, I would make the preimage something like:
And explicitly exclude transport-local or observation-local The tests I would add around that are:
That keeps the fallback reproducible without making |
|
@rpelevin yes, and the fail-closed rule is the one I would underline: a missing or malformed binding block fails the fallback case rather than quietly hashing the whole One addition and one wording nudge. The addition: make the fallback projection version self-describing in the record, not only known per fixture. The verifier has to reconstruct the same named projection, so it needs to know which projection rule produced this digest without inferring it. If the decision record names the fallback projection it used (a small field on the back-link), a verifier picks the right reconstruction deterministically, and a later projection revision becomes an explicit version rather than a silent reinterpretation. The wording nudge: state the binding as an allowlist, not a denylist. The preimage is exactly the named params plus the named binding block, and everything else under On our side this is the exact fixture shape Assay's consumer will verify against once we extend it to the no-attestation path, so the named-projection-plus-version contract is what we will reconstruct from committed bytes and report on. |
|
Yes, agreed. I would make those two points explicit in the note/fixture contract. For the no-2787 fallback, the digest back-link should carry something like That gives the verifier three stable facts:
Then the rule can be written as an inclusion rule:
That is stronger than an exclusion list. Progress tokens, trace context, UI hints, or later SEP blocks can still be mentioned as examples, but they do not need to be enumerated for safety because they are outside the allowlisted projection. Tests I would add:
That makes the fallback self-describing enough for a second implementation to reproduce, while keeping the boundary narrow: the named projection is the preimage, not the observed envelope and not all of |
|
Thanks, this thread makes sense. I pushed a short non-normative "Signed Record Examples" note that keeps SEP-2828 illustrative rather than required. The added note:
For the fallback path, the note says the decision back-link should name a projection version such as Verification: |
|
Thanks, this lands cleanly. Keeping SEP-2828 illustrative and defining the compatibility target as verifier obligations feels like the right balance. The fallback section also has the important boundary now: named inclusion projection, projection version on the back-link, and fail-closed behavior rather than hashing the whole observed The only fixture set I would keep close to this note is the small reproducibility triad:
That gives a second implementation a very concrete thing to reproduce from committed bytes without turning this into a mandated receipt schema. This reads like the right non-normative landing shape to me. |
|
This is the right landing shape, and the fallback section now has the property that matters: a named inclusion projection with the projection version on the back-link, failing closed on a missing or malformed projection id or binding block rather than hashing the whole observed On the reproducibility triad: Assay's consumer now implements exactly that and reproduces all three from committed fixtures rather than prose. Same bound params plus the same One alignment note for when the canonical id settles: Assay currently binds a versioned id ( The boundary stays where it was: this reproduces Assay's committed fixtures. Independently running an external corpus still depends on that corpus committing the named-projection input, so I am not claiming external interop yet, only that the named inclusion projection and the triad are reproducible from committed bytes. |
|
The SEP-2828 reference verifier now implements this shape, if it is useful to have a second thing to reproduce against:
The committed vectors cover the triad above: gateway and provider views with different non-binding One wire note: the back-link field is camelCase ( @Rul1an the fixtures can line up with whatever Assay reconstructs once it covers the no-attestation path. |
|
Good to see a reference verifier carrying this shape, and I think it puts the one remaining question in sharp relief. Before anyone reads two implementations as interoperable, the agreement has to be against a contract the note owns, not against a single producer's committed vectors. A second implementation reproducing a first one's repo only shows those two agree, it does not show the projection is well defined. So the thing worth pinning in the note is the projection itself as the illustrative shape: the exact field name and casing on the back-link (camelCase That last point is not hypothetical, and it is the useful evidence here. Two good-faith implementations already differ on it. Ours binds the projection id inside the digest preimage, the reference names it on the back-link and hashes params plus binding only. Both are reasonable, and neither reproduces the other until the note says which bytes are hashed. That is exactly the canonicalisation detail that surfaces later as Which is really the line this thread and #2828 already settled, applied one level deeper. The conformance target is the SEP's verifier obligations and SEP-owned fixtures, with SEP-2828 and any reference verifier as illustrative producers rather than the definition. Assay reproduces that obligation set from its own committed fixtures today, and it will reproduce the SEP's fixtures once the projection bytes are pinned. I would much rather have both implementations conform to the note than reconstruct each other's repositories, since conforming to a shared contract is what keeps the format producer-agnostic, which is the boundary a few of us were keen to preserve. |
|
A quick correction on where the reference verifier lands, because I think we already agree on the point you put in sharp relief. The reference binds the projection id inside the digest preimage, not only as a selector on the back-link. The preimage is a JSON object hashed under JCS, and the id is one of its members. For the committed fallback_envelope_binding fixture the canonical preimage bytes are:
with digest So the id is in the hashed bytes. The fallbackProjection field on the back-link is the selector that tells a verifier which projection to reconstruct, and the same id string then appears in the preimage, so changing the version without recomputing breaks the digest. That is the property you described for Assay, so on the question of whether the id lives inside the preimage or only on the back-link, both implementations are on the same side: inside the preimage. What actually differs is the id string and the exact byte layout, and I agree those belong in the note, not in either repo. Ours is tools_call_params_plus_meta_authorization_binding_v1, Assay's is assay.fallback_projection.v0. The mechanism is identical. A concrete thing the note could pin, so a third implementation reproduces from the note rather than from a repo: the preimage is a JSON object, JCS-canonicalized, with exactly these members:
The digest is sha256 over the JCS encoding. The back-link carries fallbackProjection set to the same id, camelCase to match attestationDigest and attestationNonce. One casing detail worth pinning while we are here: the wire _meta block is authorization_binding (snake_case, as the server emits it), but the preimage member and the back-link field are camelCase. Stating that rename explicitly keeps implementations on the same exact bytes. If the note pins the id and this member list, the reference vectors line up against the note by an explicit version bump, and I would expect Assay to track it the same way. Conformance is then against the note's shape, not against either of our repos, which is the producer-agnostic boundary this thread has been protecting. |
|
You are right, and I recomputed it before saying so: the JCS preimage you committed hashes to The useful part is what that convergence shows. Two verifiers, written independently, already agree on the load-bearing property that the id is hashed, and they still do not reproduce each other's digest, because the member names and the id string differ. That is the whole argument for the note owning the bytes rather than either repo. When two good-faith implementations agree on the principle and still diverge on the preimage, the preimage is underspecified, and the place to fix that is the note, not a third implementation reading one of our corpora. So the contract worth pinning is the projection itself, written so a third implementation reconstructs it from the note alone. The preimage is a JSON object, JCS-canonicalized and hashed with sha256, whose members are the canonical One thing the note still does not state, and it sits a layer below reconstruction. Pinning the bytes makes the fallback reproducible, it does not make it equivalent to the attestation path. The 2787 path binds the call instance through a signed structure, so the instance is cryptographically pinned. The fallback has no signed request attestation, so its per-call binding is the server-chosen nonce inside the binding block, which a verifier cannot check for freshness or uniqueness. Reconstructing the fallback digest proves the decision binds this named projection of the observed request, it does not prove the instance was unique, fresh, or non-replayable. Worth one sentence in the note so the fallback is not read as the attestation path's equal, with replay of a byte-identical call staying the authorization layer's job, as the single-use section already says. On reproducing it, the thing I would rather build toward is a small set of SEP-owned fixture vectors carrying the pinned projection, so the reference, Assay, and a third implementation each verify against the note's bytes instead of against one producer's repo. Assay's consumer reproduces the triad and the ambiguous-supersession case from committed fixtures today, and once the note pins the member list and id I will reconstruct against the note's vectors and report what reproduces. Both implementations conforming to the note is the producer-agnostic boundary this thread set out to protect, and it is a stronger result than either of us reproducing the other. |
|
Agreed on all of it, and the convergence is the result worth keeping: two verifiers written independently agree the id is hashed inside the preimage, and the only things they disagree on are the member names and the id string. That is the case for the note owning the bytes rather than either repo. To pin it concretely, the preimage is a JSON object, JCS-canonicalized, hashed with sha256, whose members are:
The back-link carries fallbackProjection set to that same id, camelCase to match attestationDigest and attestationNonce. That is the reference's shape today, and I read it as identical to Assay's modulo the id string, so both line up against the note by a version bump rather than against each other. Your layer-below point belongs in the note, and I would keep it to the sentence you framed: pinning the bytes makes the fallback reproducible, not equivalent to the attestation path. The fallback has no signed request attestation, so its per-call binding is the server-chosen nonce, which a verifier cannot check for freshness or uniqueness. Reconstructing the digest proves the decision binds this named projection of the observed request; replay of a byte-identical call stays the authorization layer's job, as the single-use section already says. On the fixtures, I would rather the note own them too. The reference's fallback_envelope_binding and the ambiguous equal-decidedAt case are committed and keyless to verify; I can contribute those as a starting set for SEP-owned vectors, rewritten to carry the pinned projection so the reference, Assay, and a third implementation each verify against the note's bytes instead of one producer's corpus. Once the member list and id are pinned I will move the reference's vectors to track the note and report what reproduces. |
|
Yes, this is the right convergence point. I would have the note own the projection contract, not just describe the principle. The concrete contract I would want a third implementation to reproduce from the note alone is:
I would also keep your fallback-vs-attestation sentence close to the fixture: this proves the decision binds the named projection of the observed request; it does not prove freshness, uniqueness, or non-replayability of a byte-identical call. That remains the authorization/single-use layer's job. The SEP-owned vectors could be very small:
That gives the reference verifier, Assay, and a third implementation the same producer-agnostic target: the note's bytes and verdicts, not each other's repositories. It also keeps the PR in its current lane: non-normative implementation guidance and conformance fixtures, not a required receipt schema. |
|
This convergence is the result the thread was working toward, and rpelevin's contract block is the thing I would land in the note close to as written: the pinned member list, the snake-to-camel rename stated explicitly, the digest rule, and the fallback-vs-attestation sentence kept next to the fixture. On the one open slot, the id string, I would pin the descriptive one. A note-owned contract should not carry any implementation's namespace, ours included, and On the vectors, the seed contribution is welcome, and I would pair it with one acceptance rule so the set is owned by the note in substance and not only in location: a contributed vector becomes SEP-owned once an independent implementation reproduces every verdict from the note's bytes alone, without reading the contributing repository. Contribution and acceptance staying separate is what makes the vectors a contract rather than one producer's corpus relocated. Assay's consumer will run exactly that cross-check once the pinned vectors land, against the note's bytes only, and I will report what reproduces and what does not, including any verdict we fail to reproduce. One small sequencing point in favor of starting where vaaraio suggested: the fallback and equal-decidedAt cases are the right opening subset precisely because they are keyless. Any implementation can verify them from committed bytes without issuer keys or trust decisions, so disagreement there is always a contract bug, never a key-handling difference. The signed decision/outcome pairs are worth adding right after, and they bring one more thing for the note to pin: the fixture's test public key travels with the vectors, so verification stays self-contained while real issuer trust policy stays outside the record, where this thread already put it. |
|
Yes, I agree with that acceptance boundary. For the id, I would pin For the fixture corpus, I would make the ownership rule explicit:
That gives the vectors the right force: they are not reference-output snapshots; they are executable examples of the note's normative bytes. I also agree with starting with keyless cases. The first set can be:
Then add signed decision/outcome pairs with a fixture test public key carried in the vector bundle. The test key should prove the fixture is self-contained, not imply real issuer trust. Real issuer policy stays outside the record and outside the fixture acceptance rule. That would make the note portable in the useful way: a third implementation can reproduce the fallback bytes, understand the fallback-vs-attestation limit, and know when a contributed vector has become a shared conformance target. |
|
Thanks, this is useful, but I think this is now crossing into follow-up fixture/conformance work rather than this security guidance PR. For this PR, I want to keep the note non-normative and focused on the verifier obligations: receipt authenticity, freshness, audience/scope binding, canonical request binding, retry suppression, and fail-closed behavior before provider business logic runs. I agree with the core fallback boundary: the fallback projection should be named, should use an inclusion rule, and should not hash all of Exact fixture vectors, projection member names, fixture ownership rules, and SEP-owned acceptance policy seem better handled in a separate follow-up PR or fixture discussion. Otherwise this docs guidance starts becoming an implicit receipt schema, which is the boundary this PR has been trying to avoid. |
Summary
Notes
This follows the discussion in #2852. The text intentionally does not define a normative receipt schema, require a specific issuer, or replace provider-side business authorization.
Verification
git diff --checkI could not run the repository npm-based docs checks locally because
npm cirepeatedly failed with npm'sExit handler never called!error in this environment.