Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
58a9a8e
feat(sdk-go): export CanonicalOfferBytes
legendko Jul 22, 2026
ef01de6
fix(sdk): map CanonicalOfferBytes to its Python/TS faces in the parit…
legendko Jul 23, 2026
874ab83
feat(sdk-go): export CanonicalAcceptanceBytes
legendko Jul 23, 2026
d4c509e
docs(proto): correct the acceptance canonical-form text to JCS over p…
legendko Jul 23, 2026
1fefc44
docs(sdk,conformance): retire the deterministic-marshal and camelCase…
legendko Jul 23, 2026
aa95a25
docs(sdk): align the acceptance docs with the field-set/byte-layout s…
legendko Jul 23, 2026
a456dcd
docs(proto): state the acceptance canonical form without a positional…
legendko Jul 23, 2026
a368f52
docs(sdk/python): state the Connect wire as snake_case in wire_canon
legendko Jul 24, 2026
7d7c752
fix(sdk): omit every unpopulated field from the hand-built acceptance…
legendko Jul 24, 2026
3f64226
docs(sdk/python): attribute signature-field clearing to canonical_off…
legendko Jul 24, 2026
188b1d3
test(sdk-go): drop the duplicate acceptance-corpus replay
legendko Jul 24, 2026
45985f4
docs(sdk-go): state the canonical-bytes contract against its normativ…
legendko Jul 24, 2026
1dc97b6
test(sdk-go): pin unknown-field canonicalization as fail-closed
legendko Jul 24, 2026
54987c5
feat(sdk-go): reject messages carrying unknown fields before canonica…
legendko Jul 24, 2026
f846ec3
docs(proto): make unknown-field rejection part of the canonical signi…
legendko Jul 24, 2026
fc6e7b5
test(sdk-go): pin the empty idempotency-key omission in the shared co…
legendko Jul 24, 2026
c919923
refactor(sdk): drop empty acceptance members in one step per port
legendko Jul 24, 2026
178672b
docs(sdk/python): keep the wire contract in one place
legendko Jul 24, 2026
7308d2e
feat(sdk-go): export the unknown-field refusal and scope its normativ…
legendko Jul 24, 2026
a002f22
test(sdk-go): cover the map-value and map-entry edges of the unknown-…
legendko Jul 24, 2026
26e63ae
docs(sdk): correct the omission prose in the ports and the design his…
legendko Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conformance/reachability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ var outOfBandRoots = map[string]string{
"WBAFile": "served at /.well-known/http-message-signatures-directory — the pure WBA JWK Set (identity keys + revocation_url)",
"ErrorDetail": "the transport-error envelope; carries the seven typed failure reasons",
"KeyRevocationList": "served key-revocation document (thumbprint list), fetched out of band from WBAFile.revocation_url",
"AgentAcceptancePayload": "canonical signing structure for AgentAcceptance; never sent on the wire — the signer and verifier deterministically marshal it to derive byte-identical signed bytes",
"AgentAcceptancePayload": "canonical signing structure for AgentAcceptance; never sent on the wire — it fixes the field set the signer and verifier canonicalize (RFC 8785 JCS over canonical proto-JSON) to derive byte-identical signed bytes",
}

// refFields visits the message/enum each of md's fields references (following the
Expand Down
69 changes: 69 additions & 0 deletions docs/design-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,75 @@ Responses do not retrace the chain: the terminal Exchange returns directly to th
originating agent, because the signed `retrieval_endpoint` is already bound to the
agent's identity (`agent_identity_hash`) and needs no return-path relay.

## Canonical signing: JCS over proto-JSON, not deterministic protobuf

The two signed RAMP payloads that cover a protobuf message — `Offer.signature` and
the agent's `AgentAcceptance.signature` — originally covered deterministic protobuf
*binary*: marshal the message with deterministic field order, sign those bytes. We
reversed that and moved both onto RFC 8785 JCS over canonical proto-JSON,
`JCS(protojson(msg with the signature fields cleared))`, under one pinned
proto-JSON option set (snake_case field names, enums as name strings, unpopulated
fields omitted). (`ResourceAttestation.signature` also outlives an HTTP exchange but
is out of scope here: it signs a hand-built JSON object — `{verifier, keyid,
attested_at, uri, claims}` — under its own rule, never a rendering of its proto
message.)

Deterministic protobuf marshaling is not actually canonical. Protobuf's own
documentation disclaims byte stability across languages, across library versions,
and in the presence of unknown fields — it is a best-effort local property, not a
wire-format guarantee. That makes it unusable as the basis of a signature two
independent implementations must agree on, which is precisely the RAMP case: an
agent SDK signs, a broker relays, an Exchange verifies, and none of them need share
a language. It also forces every verifier — including an edge worker or a browser
client — to link a protobuf binary codec just to check a signature. JCS over
proto-JSON has neither problem: it is a published canonicalization of JSON, and any
language can reproduce the signed bytes from the JSON rendering alone. The
Go-emitted golden vector is the arbiter, and the TS and Python SDKs replay it
byte-for-byte.

The form then changed a **second** time, within JCS. The first cut rendered
proto-JSON with its default camelCase `json_name` keys; we re-pinned it to
snake_case proto field names (`UseProtoNames=true`) so the wire, the conformance
corpus, the generated Pydantic/Zod clients, and the signed form all share one
naming — protojson accepts both spellings on input, so the duality was silently
hiding client divergence rather than absorbing it. That re-pin **re-signed the
golden vectors**: signatures produced over the camelCase-JCS form no longer verify,
and all three SDKs had to move in the same commit.

One clause of that pinned option set carries more weight than it looks.
`EmitUnpopulated=false` means an empty field is *absent* from the JSON, not present
with an empty value — so the canonical bytes for an empty string field are never the
bytes for a populated one. Go inherits that from `protojson` for free. A port that
assembles the JSON object by hand does not, and has to enumerate the omission for
every field or it signs bytes Go never produces. The acceptance payload is the one
place the Python and TS ports hand-build the object — Go renders the
`AgentAcceptancePayload` message through the same protojson canonicalizer as the
offer — and it is exactly where the divergence
appeared: Python and TS dropped an empty `requester_domain` but emitted an empty
`requester_id`, which is wire-valid because `Requester.id` carries no `min_len`. Such
a mismatch fails closed, but it falsifies the byte-equivalence the canonical-bytes
accessors promise, so the shared corpus now carries a vector that holds every port to
the omission.

The two reversals left a consequence worth naming, because it is the reason the
canonical bytes are a first-class SDK export rather than an internal detail. A
canonical form that has already changed twice can change again, and a verifier that
re-derives the bytes at verification time has silently pinned an *already-signed*
payload to whatever canonicalization the SDK implements *later* — the failure would
surface as "the signature does not verify", indistinguishable from "it was never
signed". So all three SDKs expose the exact signed bytes as a public accessor
(`CanonicalOfferBytes` / `CanonicalAcceptanceBytes` in Go, `canonical_offer_payload`
/ `jcs_acceptance_payload` in Python, `canonicalOfferPayload` / `acceptancePayload`
in TS). A party keeping evidence stores those bytes and re-verifies against them
verbatim, rather than trusting a future canonicalizer to reproduce the past.

Storing them is necessary, not sufficient. A signature that verifies over stored bytes
proves only that the holder of that key signed *those bytes*; it says nothing about
which transaction they belong to. Whoever reads the evidence must also parse the stored
bytes and match their content — offer id, requester, idempotency key — against the
transaction under dispute. Skip that and any valid triple minted under the same key
passes as evidence for any transaction.

## "Marketplace" → "Exchange"

The selling intermediary was originally called a *marketplace*. We renamed it to
Expand Down
5 changes: 4 additions & 1 deletion docs/sdk-parity-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

Go is the oracle (`sdk/go/{helpers,resolvers,core,connect,connectserver}`); Python and TS mirror it. This document is **generated** from the same two artifacts CI already enforces against the code, so it cannot drift from the real surface — a mismatch fails the API-surface gate or the corpus-completeness gate before it can reach this file.

**At a glance:** 72 symbols at cross-language parity · 14 documented divergences · 98 Go-idiomatic exclusions · 23 conformance corpora, each tri-replayed.
**At a glance:** 74 symbols at cross-language parity · 14 documented divergences · 99 Go-idiomatic exclusions · 23 conformance corpora, each tri-replayed.

Layering (L1 pure trust core vs L2 I/O resolvers), the SSRF transport-wiring invariant, and naming conventions are recorded in [`design-history.md`](./design-history.md).

Expand All @@ -27,6 +27,8 @@ Legend: a name = the public face in that language · `—` = intentionally none
| `AcceptanceSignatureAlgorithm` | `ACCEPTANCE_SIGNATURE_ALGORITHM` | `ACCEPTANCE_SIGNATURE_ALGORITHM` |
| `AppendSignature` | `append_signature` | `appendSignature` |
| `ApplyScopes` | `apply_scopes` | `applyScopes` |
| `CanonicalAcceptanceBytes` | `jcs_acceptance_payload` | `acceptancePayload` |
| `CanonicalOfferBytes` | `canonical_offer_payload` | `canonicalOfferPayload` |
| `CanonicalizeMoney` | `canonicalize_money` | `canonicalizeMoney` |
| `CatalogRejectionDetail` | `catalog_rejection_detail` | `catalogRejectionDetail` |
| `ConnectProtocolVersion` | `ConnectProtocolVersion` | `ConnectProtocolVersion` |
Expand Down Expand Up @@ -226,6 +228,7 @@ Go constructs (functional-option builders, `errors.Is` sentinels, value types, c
| `helpers.ErrURLMissingSignature` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. |
| `helpers.ErrURLNotAgentBound` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. |
| `helpers.ErrURLSignatureInvalid` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. |
| `helpers.ErrUnknownFields` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. |
| `helpers.ErrUnsupportedAlgorithm` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. |
| `helpers.FromContext` | Go context.Context accessor; py/ts thread verified-request state explicitly. |
| `helpers.NewContext` | Go context.Context accessor; py/ts thread verified-request state explicitly. |
Expand Down
Binary file modified gen/descriptor.binpb
Binary file not shown.
47 changes: 38 additions & 9 deletions gen/go/ramp/v1/ramp.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading