carousell/rctp

Security

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