Documentation
OverviewQuickstartConceptsDecisions and errorsApprovalsVersioningSecurityExtensionsFAQPartner integration profileSecurity
Status: Draft 0.4, pre-release. Sources: Draft 0.4 sections 14 and 17.2,
AGENTS.md,SECURITY.md. To report a vulnerability, see the last section.
The threat RCTP is built around is a well-behaved agent making a bad decision: persuaded by a buyer, confused by a listing, or injected by a prompt. So the rules live outside the model, in an enforcement point the agent cannot argue with, and the seller's private terms never reach any agent, so no agent can leak them [14]. Nine invariants state what must always hold [17.2]. Each one has at least one test whose name contains its ID, and deadlines never weaken them.
Run them:
cd packages/gateway-go && go test ./... -run INV # Go: gateway, engine and INV-8 tests
pnpm test:schemas # schema tests named "INV-1:", "INV-2:", …
The nine invariants
INV-1: private terms never leave the platform; a low-offer refusal says below_floor and carries no private value
| Guarantee | The marketplace never discloses the floor to any agent: not in a listing, offer, header, body, approval request, webhook, log line or deal record. Probing for the floor runs out of tries. |
| How the gateway enforces it | The seller types the terms on the hosted page; no agent route accepts or returns them. Listing data carries public_terms and terms_commitment only. The floor check runs before the tier, so an auto accept below the floor is refused. Proposed terms only pre-fill the seller's hosted page. Below-floor offers are counted per buyer agent and per buyer operator per listing; once either is spent, every offer from that agent or operator on that listing gets 429. Only listing-terms and terms-submission can carry a floor. |
| Tests | TestINV1_V11_NoAgentReceivesAnyPrivateTerm (gateway/invariants_test.go); TestINV1_BuyerBudgetAndNoPrivateValue (enforce/engine_test.go); TestINV1_INV5_ProposedTermsOnlyPrefillTheSellersPage (gateway/draft05_test.go); TestV1_AutoAcceptBelowTheFloorIsRefused, TestINV1_V3_BelowFloorRefusalCarriesNoPrivateValue, TestV4_OverBudgetEveryOfferGets429, TestV4_OperatorCeilingAcrossAgents (gateway/offers_test.go); TestPublicNeverCarriesPrivateTerms (terms/terms_test.go); schema tests INV-1: no schema other than listing-terms and terms-submission can carry a floor and four more named INV-1: |
| Vectors | V1, V3, V4a, V4b, V7, V10, V11 |
INV-2: an ask that expires is a decline
| Guarantee | Silence is never consent. No answer by expires_at means no. |
| How the gateway enforces it | Pending approvals are swept before every request. An expired approval becomes expired with reason approval_timeout, a pending offer becomes expired, and the operator is told by webhook. A late answer, even from the seller's own passkey, is refused. on_timeout is the constant deny in the schemas. |
| Tests | TestINV2_V2_AskThatExpiresIsADecline (gateway/invariants_test.go); schema test INV-2: on_timeout can only ever be deny |
| Vectors | V2 |
INV-3: deal.sign_contract and account.update are always forbid
| Guarantee | An agent can never sign a contract or change the seller's account, whatever the grant says. |
| How the gateway enforces it | Both verbs have no scope, so no grant can carry them, and their routes exist only to answer 403 action_forbidden once the signature is valid. Grant tiers are only auto or ask. |
| Tests | TestINV3_S3_FixedForbidsWhateverTheGrant (gateway/deals_test.go); schema tests INV-3: the fixed forbids are exactly deal.sign_contract and account.update, with no scope and INV-3: no schema lets a grant carry a fixed-forbid verb |
| Vectors | S3 |
INV-4: only the seller signs approvals
| Guarantee | A partner, an operator or an agent can show an approval but can never decide one. |
| How the gateway enforces it | An answer counts only if it is a WebAuthn assertion, with user verification, from a passkey registered for this seller, over this approval's one-time challenge, at an allowed level, for the first time, before expires_at, with a matching displayed-text digest and, for listings, the matching terms_commitment. A refused answer leaves the approval pending for the real seller. |
| Tests | TestINV4_V9_OnlyTheSellersPasskeyApproves (gateway/invariants_test.go) |
| Vectors | V9 |
INV-5: only the seller, on a Trusted Surface, can create or change a grant or terms
| Guarantee | Listing text, prompts and request bodies cannot loosen the seller's rules. |
| How the gateway enforces it | Tiers come only from the grant the seller confirmed on the consent page, within the scope's allowed_tiers. Terms come only from the hosted page, which refuses any agent-signed request, and are bound to the seller's passkey by terms_commitment. Request bodies are validated against closed schemas, so a field such as floor, tier or a free-text instruction is a 400. The engine never reads listing text. |
| Tests | TestINV5_OnlyTheSellerOnTheHostedPageSetsTerms (gateway/terms_test.go); TestV7_ListingTextChangesNothing (gateway/offers_test.go); schema test INV-5: request bodies are closed, so no field can set a tier, floor, budget, grant or scope |
| Vectors | V7, V10 |
INV-6: an accepted offer becomes a single-use, business-signed checkout; a listing has at most one live checkout
| Guarantee | An accepted offer cannot be replayed, altered or redeemed by someone else, and one item is never sold twice. |
| How the gateway enforces it | The checkout is a compact JWS signed by the gateway profile key (typ rctp-checkout+jwt) bound to the listing hash, offer, negotiation, Deal Terms, buyer key thumbprint, a nonce and an expiry. Accepting reserves the listing: another accept gets 409 listing_reserved. Accepting twice, or redeeming twice, is 409 offer_replayed; another buyer key is 401 signature_invalid; after exp it is 409 offer_expired and the listing reopens. Later set_terms or reprice leave the checkout's Deal Terms unchanged. |
| Tests | TestINV6_V5_AcceptedOfferIsASingleUseCheckout, TestINV6_AtMostOneLiveCheckoutPerListing, TestV26_SecondAcceptOnAReservedListingIsRefused, TestV27_TermsAndPriceChangesAfterAcceptanceLeaveTheCheckout (gateway/offers_test.go); schema test INV-6: a checkout carries a single-use nonce, the buyer key and fixed Deal Terms |
| Vectors | V5, V26, V27 |
INV-7: a revoked or expired grant fails the next signed call
| Guarantee | When a seller revokes, the agent stops at its very next call, not when a cache expires. |
| How the gateway enforces it | The gateway introspects the access token on every seller-side call and never caches the answer. The authorization server reports grant_status so revoked and expired grants get their own reasons (B-3). The grant is checked again before an approved ask runs. |
| Tests | TestINV7_V6_RevokedGrantFailsTheNextCall, TestINV7_ExpiredGrantFailsTheNextCall, TestINV7_RevocationBetweenAskAndApprovalStopsTheAction (gateway/grant_test.go) |
| Vectors | V6 |
INV-8: no custom cryptography
| Guarantee | Every signature, token, digest and WebAuthn check is done by a pinned, widely used library. |
| How it is enforced | Go: go-jose, yaronf/httpsign, go-webauthn, gowebpki/jcs, dunglas/httpsfv. TypeScript: jose, http-message-signatures, oauth4webapi, structured-headers. Python: joserfc, http-message-signatures, http-sf. Hand-written signature bases, JWT splitting, raw ECDSA calls and hand-rolled MACs are not allowed. AP2 buyer authority waits for a pinned library rather than a hand-written verifier (#59). |
| Tests | TestINV8_NoCustomCryptography (keys/inv8_test.go) scans the gateway source for raw ECDSA sign/verify, raw point encoding, hmac.New, hand-split JWTs and hand-built signature bases, and checks that go.mod pins the vetted libraries. TestS4_WebhooksAreSignedAndABadOneIsRejected checks webhooks verify through the library. |
| Vectors | None tagged INV-8. S4 exercises webhook verification |
INV-9: no personal data in RCTP objects
| Guarantee | Grants, terms, offers, decisions, approvals, deal events, webhooks and partner-visible logs carry no names, addresses, phone numbers, emails or identity numbers [15]. |
| How the gateway enforces it | Every schema is closed and has no field where personal data could go. The seller is a pairwise pseudonym (sub of the grant, B-9); a buyer is its agent key thumbprint. location is city-level. The seller's marketplace account never leaves the authorization server. A deal event that tries to carry personal data is a 400. |
| Tests | TestINV9_NoPersonalDataInRCTPObjects (gateway/invariants_test.go); schema test INV-9: no RCTP object schema has a field for names, addresses, phones, emails or identity numbers |
Other security tests: TestS1_UnsignedRequestIsRefused, TestS2_UnknownOperatorIsRefused,
TestProfileKeyWhoseKidIsNotItsThumbprintIsUnusable, TestSignatureCoverageAndDigest,
TestIdempotencyReplaysAndConflicts (gateway/signing_test.go); TestGrantChecksInOrder (gateway/grant_test.go);
TestV8_ReleaseBeforeBuyerAcceptsIsRefused (gateway/deals_test.go). In enforce/engine_test.go, TestCheckOrder
pins the order of the checks.
Key handling
| Topic | Rule |
|---|---|
| Algorithm | ECDSA P-256 everywhere: ES256 in JOSE, ecdsa-p256-sha256 in RFC 9421 (binding 1) |
| Key ID | kid is the key's RFC 7638 JWK SHA-256 thumbprint, base64url without padding (B-1). The RFC 9421 keyid is the same value. A key whose kid is not its thumbprint is unusable |
| Publication | Public keys in keys[] of your UCP profile at https://<host>/.well-known/ucp. Public keys only |
| Request freshness | created and expires are required, at most 300 s apart, within ±60 s of the gateway's clock (B-2); every POST carries an Idempotency-Key with at least 128 bits of entropy |
| Storage | Generate and keep agent keys in hardware-backed storage where you can. Never export private keys. A private key never appears in a profile, log, example or commit |
| SDK note | The TypeScript and Python SDKs sign with a private JWK held in process memory. They have no HSM or KMS signer hook yet, so protect that process and its memory |
| Binding | A grant is bound to one agent key (jkt), and the request keyid must match it. Rotating an agent key means the seller connects again to get a grant bound to the new key |
| Compromise | Remove the key from your profile (the reference gateway caches a profile for at most 5 minutes) and revoke the affected grants' tokens (RFC 7009); the next call fails (INV-7) |
| Webhooks | Verify with the gateway profile's keys[] at <gateway>/.well-known/ucp |
| Declare it | State key_custody and data_residency in your profile's dev.rctp.operator entry when you act across borders |
Sandbox keys and secrets are TEST ONLY. The sandbox services keep keys in plain files in Docker volumes, the
sandbox operators service hands out its private keys at /sandbox/keys/{name} on purpose, and the introspection secret
is made up. The Go testkit package and the conformance runner's fixtures are test only too. Never reuse any of these,
and never commit a private key outside sandbox/ test fixtures marked TEST ONLY.
Threats and controls
A summary of Draft 0.4 section 14, with what v0.2 covers.
| Threat | Control | Section | v0.2 |
|---|---|---|---|
| An agent is persuaded or injected into a bad deal | Terms enforced by the platform; structured facts only | 6, 8 | Yes (INV-1, INV-5) |
| The seller's own agent leaks the floor | No agent ever receives private terms | 6.4 | Yes (INV-1, V11) |
| A buyer binary-searches the floor | No floor in refusals or records; per-agent and per-operator budgets | 8.2, 8.5 | Yes (INV-1, V4a, V4b) |
| An accepted offer is replayed or altered | Single-use, business-signed checkout bound to Deal Terms | 7.4 | Yes (INV-6) |
| Two buyers each get a checkout for one item | Acceptance reserves the listing | 7.4 | Yes (listing_reserved, V26) |
| A change of terms after acceptance reprices the deal | Accepted Deal Terms are fixed | 7.4 | Yes (V27) |
| A stolen agent key keeps acting | Key-bound grant checked live on every call | 5 | Yes (INV-7) |
| Fake approvals; a partner shows one thing and the seller signs another | Seller's own key over a challenge bound to action, amount and terms; displayed-text digest | 9 | Yes: only the marketplace-hosted page is supported (INV-4) |
| Funds released early | Release only on signed events, by the holder of funds | 10.3 | Yes (release_condition_unmet, V8, V13) |
| "You sent me a brick"; swapped returns | Seal and identifier compared at pickup, delivery, return | 11.4 | Not yet |
| A relayed event is wrong | Relayed entries name their source; required_observation per category |
10 | Partly: a relayed event must name reported_by and source_event_id, and required_observation is enforced (B-7) |
| Farmed reputation | Published, reviewed methodology | 11.1 | Not yet (no Party Credential) |
| Over-shared identity data | Derived facts only | 11.2 | Not yet (no identity facts); INV-9 holds |
| Agents bypass the enforcement point | Signed calls are the only agent path | 4.1 | The marketplace's job to block unsigned automation, outside this repository |
| A compromised enforcement point | Its entries are signed and chained | 10 | Entries are signed and chained; no route exports the full record yet |
None of this holds if the marketplace API behind the gateway ignores grant scopes. "A grant in front of an API that ignores scopes is a promise, not a control" [14].
Reporting a vulnerability
Report privately. Do not open a public issue.
- Email security@rctp.dev. This is a placeholder until the domain's mailboxes are live.
- Until then, use GitHub's private vulnerability reporting on this repository.
Include what you found, how to reproduce it, and which invariant (INV-1 to INV-9) or spec section it affects. The maintainers acknowledge within 3 working days and aim to agree a disclosure date within 30 days.
In scope: the SDKs, the Go middleware and reference gateway, the conformance runner, and the specification itself. A
spec flaw that lets an agent bypass a grant or learn private terms is a vulnerability. Out of scope: the sandbox's mock
services and their TEST ONLY keys and secrets, which are deliberately insecure.
Source: docs/security.md