Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d7980c7
docs(proto): state the Ed25519 delivery-URL scheme in the file header…
noxlesh Sep 1, 2026
d7079e1
docs(edge): rewrite signed-URL verification for the Ed25519 scheme
noxlesh Sep 1, 2026
290b41f
docs(edge): correct the edge-function pages that assert a shared secret
noxlesh Sep 1, 2026
ba9b779
docs(exchange): correct the Exchange signing-engine and storage pages
noxlesh Sep 1, 2026
60397f7
docs(protocol): correct the protocol pages and walkthroughs
noxlesh Sep 1, 2026
bd8f24a
docs(ci): deny the retired HMAC signed-URL phrasings
noxlesh Sep 1, 2026
631b8d6
docs(changelog): record the signed-URL scheme correction
noxlesh Sep 1, 2026
078493c
Merge remote-tracking branch 'origin/main' into docs/ed25519-signed-urls
noxlesh Sep 2, 2026
bed71e1
docs(architecture): correct the deployment-model signed-URL claims
noxlesh Sep 2, 2026
35dbcce
docs(proto): say who holds the delivery-URL private key, and who fetches
noxlesh Sep 2, 2026
2861e09
docs(protocol): make the worked examples pass their own checks
noxlesh Sep 2, 2026
b9887af
docs(ci): fail the run on a denylist pattern that cannot compile
noxlesh Sep 2, 2026
16afc50
docs(edge): say what the three verifiers do about check ordering
noxlesh Sep 2, 2026
27aa659
docs(edge): finish the pages the sweep only half-corrected
noxlesh Sep 2, 2026
830fee6
docs(security): correct the URL TTL ceiling and the key-leak counterm…
noxlesh Sep 2, 2026
a85f90d
docs(changelog): record the custody model and the two fetch paths
noxlesh Sep 2, 2026
fe0384b
docs(changelog): match the entry to the file's own dash style and dro…
noxlesh Sep 2, 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
Binary file modified gen/descriptor.binpb
Binary file not shown.
23 changes: 18 additions & 5 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.

7 changes: 5 additions & 2 deletions gen/python/wire/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,10 @@ class DomainVerificationChallenge(WireModel):


class DomainVerificationConfirmation(WireModel):
cdn_type: str | None = Field(None, description='CDN type this key is for.')
cdn_type: str | None = Field(
None,
description='Which delivery-URL verification scheme this key is for: "edge-ed25519" (a\n code-capable edge that verifies the Ed25519 URL signature itself) or\n "cloudfront" (AWS CloudFront trusted key groups, RSA, verified natively by\n the CDN). One value per Exchange-side tenant signing scheme:\n "edge-ed25519" is ED25519, "cloudfront" is AWS_CLOUDFRONT_RSA.',
)
domain: str | None = Field('', description='The domain being verified.')
exchange: constr(
pattern=r'^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$',
Expand All @@ -310,7 +313,7 @@ class DomainVerificationConfirmation(WireModel):
)
signing_key: str | None = Field(
None,
description='Optional: signing key to register upon successful verification.\n If present, the key is registered atomically with verification.\n Key format depends on CDN type (PEM for CloudFront, hex for HMAC).',
description='Optional: the delivery endpoint\'s verification key, registered atomically\n with the domain on successful verification. PUBLIC key material only.\n The Exchange signs delivery URLs with a private key it holds and never\n publishes; a delivery endpoint verifies with the public half and holds\n nothing secret. Where the Exchange has to sign with a key the provider\n generated -- a CloudFront trusted key group is the provider\'s own AWS\n resource -- the private half is provisioned to the Exchange out of band\n and never travels in this field.\n\nFormat follows cdn_type: a PEM-encoded RSA public key for "cloudfront", or\n the base64url-encoded raw Ed25519 public key (the JWK "x" value) for\n "edge-ed25519".',
)
token: str | None = Field(
'', description='The challenge token (echoed from DomainVerificationChallenge).'
Expand Down
2 changes: 1 addition & 1 deletion gen/ts/wire/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const DisputeStatusSchema = wire(z.enum(["DISPUTE_STATUS_FILED","DISPUTE_

export const DomainVerificationChallengeSchema = wire(z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this challenge expires. Provider must confirm before this time.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "token": z.string().describe("Opaque challenge token. Provider must serve this at:\n https://{domain}/.well-known/ramp-verify/{token}").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default(""), "verification_url": z.string().describe("The exact URL the Exchange will fetch to verify.").default("") }).describe("DomainVerificationChallenge — Exchange returns a challenge."));

export const DomainVerificationConfirmationSchema = wire(z.object({ "cdn_type": z.string().describe("CDN type this key is for.").optional(), "domain": z.string().describe("The domain being verified.").default(""), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. Distinct from `domain` above, which is the provider domain\n being verified — the subject of the request, not its recipient."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "signing_key": z.string().describe("Optional: signing key to register upon successful verification.\n If present, the key is registered atomically with verification.\n Key format depends on CDN type (PEM for CloudFront, hex for HMAC).").optional(), "token": z.string().describe("The challenge token (echoed from DomainVerificationChallenge).").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DomainVerificationConfirmation — Provider confirms the challenge is placed."));
export const DomainVerificationConfirmationSchema = wire(z.object({ "cdn_type": z.string().describe("Which delivery-URL verification scheme this key is for: \"edge-ed25519\" (a\n code-capable edge that verifies the Ed25519 URL signature itself) or\n \"cloudfront\" (AWS CloudFront trusted key groups, RSA, verified natively by\n the CDN). One value per Exchange-side tenant signing scheme:\n \"edge-ed25519\" is ED25519, \"cloudfront\" is AWS_CLOUDFRONT_RSA.").optional(), "domain": z.string().describe("The domain being verified.").default(""), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. Distinct from `domain` above, which is the provider domain\n being verified — the subject of the request, not its recipient."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "signing_key": z.string().describe("Optional: the delivery endpoint's verification key, registered atomically\n with the domain on successful verification. PUBLIC key material only.\n The Exchange signs delivery URLs with a private key it holds and never\n publishes; a delivery endpoint verifies with the public half and holds\n nothing secret. Where the Exchange has to sign with a key the provider\n generated -- a CloudFront trusted key group is the provider's own AWS\n resource -- the private half is provisioned to the Exchange out of band\n and never travels in this field.\n\nFormat follows cdn_type: a PEM-encoded RSA public key for \"cloudfront\", or\n the base64url-encoded raw Ed25519 public key (the JWK \"x\" value) for\n \"edge-ed25519\".").optional(), "token": z.string().describe("The challenge token (echoed from DomainVerificationChallenge).").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DomainVerificationConfirmation — Provider confirms the challenge is placed."));

export const DomainVerificationFailureSchema = wire(z.object({ "reason": z.enum(["DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_EXPIRED","DOMAIN_VERIFICATION_FAILURE_REASON_FETCH_FAILED","DOMAIN_VERIFICATION_FAILURE_REASON_EXCHANGE_NOT_AUTHORIZED","DOMAIN_VERIFICATION_FAILURE_REASON_KEY_REGISTRATION_FAILED"]).describe("The failure reason (defined-only, non-zero)") }).describe("DomainVerificationFailure — RequestDomainVerification / ConfirmDomainVerification failed."));

Expand Down
68 changes: 68 additions & 0 deletions proto/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,74 @@

## Unreleased

**Signed delivery URLs are documented as Ed25519 signed by the Exchange and
verified with its published public key, not HMAC-SHA256 over a shared secret
(documentation correction; no wire change).** Since the initial public snapshot
the file header, the DomainVerificationConfirmation comments and twenty website
pages described a symmetric scheme with a secret shared between the Exchange and
the CDN. No implementation ever produced one: `git grep -ci hmac — sdk/` finds
nothing, and signing has always been a detached Ed25519 signature that a
delivery endpoint verifies with a public key.

The divergence was wider than the algorithm name, so an implementer following
the documentation got four things wrong at once. The signed message is `"GET\n"`
followed by the canonical URL — the whole URL with the `sig` parameter removed
and the remaining query sorted by key — not four selected fields joined by
newlines, which means scheme, host, path and every publisher query parameter are
covered too. The signature is base64url with no padding, not a hex digest. The
expiry parameter is `exp`, documented as `expires`. There is a `kid` parameter
the pages never mentioned, and there is no `txn_id` parameter at all.

Where the documentation said `txn_id` enables three-sided reconciliation, the
join key is `signed_url_hash` — SHA-256 of the URL verbatim — recorded by the
Exchange on the transaction and by the delivery endpoint on its delivery event.
Neither side chooses the value.

`DomainVerificationConfirmation.signing_key` said key format is "PEM for
CloudFront, hex for HMAC"; hex is an HMAC-secret encoding, so the sentence had no
reading that matched the implementation. Its format now follows `cdn_type`, whose
documented value set becomes `"edge-ed25519"` | `"cloudfront"`. The retired
values named vendors rather than schemes, which made `"fastly"` actively wrong --
a Fastly Compute deployment runs the Ed25519 verifier. The name `edge-ed25519`
is not new; the reference implementation's architecture records already use it
for this path. The value list also
moves into the field's leading comment, because the reference page renders
leading comments in preference to trailing ones and this field already had one,
so the trailing list was invisible to every reader. The field now also states
its custody model: it carries public key material, the Exchange signs with a
private key it holds and never publishes, and where the Exchange must sign with
a key the provider generated — a CloudFront trusted key group is the provider's
own AWS resource — the private half is provisioned out of band and never
travels in this field. Both `cdn_type` values name the tenant signing scheme
they mirror, which the previous wording asserted without saying which is which.

The file header said "the agent is the fetcher", which held for one of the two
deployments. An agent embedding the SDK holds its own key and fetches for
itself; a custodial agent never fetches, because its key lives in its identity
service, which fetches on its behalf. Both present the same key to the delivery
endpoint, which is what makes the binding check work either way. The header's
"fully offline, no JWKS fetch required" is true of the fetcher's key, which
arrives in the request; the edge still resolves `kid` to the Exchange's public
key from a cached directory, so the claim now names the key it applies to.

Two security claims were corrected beyond the primitive. The threat model said
the provider holds the URL-signing private key and the Exchange calls a provider
signing service; no such service exists, and the Exchange signs with a per-tenant
key it holds itself. A walkthrough verified agent binding as
`SHA256(requester.id + ":" + requester.domain)`, which binds nothing an attacker
cannot recompute; the check is a thumbprint comparison against the presented
public key plus an RFC 9421 proof of possession.

Akamai is no longer documented as a supported delivery target: EdgeAuth verifies
with a secret shared with the CDN, which is the model this correction removes.
The retired phrasings are held out by the documentation conformance gate.

No wire change. The `cdn_type` value set lives only in a comment on an
`optional string`, never in an enum, and the field has no consumer in the SDK,
the conformance corpus, or the reference implementation. `gen/` is regenerated
with the pinned buf; the conformance corpus is unchanged, as comments carry no
constraints.

**`Offer.offer_id` is documented as an opaque unique identifier, not a resource
key (comment clarification; no wire change).** The comment already said the id is
assigned by the Exchange, but an implementation historically derived it from the
Expand Down
48 changes: 35 additions & 13 deletions proto/ramp/v1/ramp.proto
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,29 @@ import "ramp/v1/vocab.proto";
// Agent → Exchange: Signature header (alg=ed25519)
// Agent → Broker → Exchange: multiple Signature headers (one per hop)
//
// Signed URLs use HMAC-SHA256 (Exchange↔CDN shared secret, separate concern).
// Signed delivery URLs carry an Ed25519 signature made by the Exchange over
// "GET\n<canonical URL>"; the delivery endpoint verifies it with the Exchange's
// published public key. No shared secret (separate concern).
//
// Retrieval-URL identity binding (OPTIONAL, DPoP-style — RFC 9449):
// The Exchange MAY bind a signed retrieval_endpoint to the requesting agent
// by embedding agent_identity_hash — the RFC 7638 JWK Thumbprint (SHA-256)
// of the agent's Ed25519 request-signing key — inside the HMAC-signed URL,
// and echoing it in the response. A capable delivery endpoint (edge function)
// verifies the binding fully offline: confirm the URL HMAC (proves the hash
// is Exchange-issued and untampered), then require the fetcher to present its
// of the agent's Ed25519 request-signing key — as the URL's agent_id query
// parameter, which the URL signature covers, and echoing it in the response.
// A capable delivery endpoint (edge function) verifies the binding without
// contacting the fetcher's directory: verify the URL's Ed25519 signature
// against the Exchange's published public key (proves the hash is
// Exchange-issued and untampered), then require the fetcher to present its
// public key and an RFC 9421 signature over the retrieval request, and check
// thumbprint(presented key) == agent_identity_hash. No JWKS fetch required.
// thumbprint(presented key) == agent_identity_hash. The fetcher's key arrives
// in the request, so no JWKS fetch is needed for it; the edge still resolves
// kid to the Exchange's public key, from a cached directory or pinned config.
// The fetcher is whoever holds the agent's request-signing key: an agent
// embedding the SDK fetches for itself, while a custodial agent does not
// fetch at all and its identity service fetches on its behalf.
// Enforcement is NOT mandatory: a bearer-only signed-URL CDN that cannot run
// code falls back to HMAC + short TTL + TLS. RAMP reference implementations
// run on edge functions and DO enforce it.
// code falls back to the URL signature + short TTL + TLS. RAMP reference
// implementations run on edge functions and DO enforce it.
// ============================================================================

// ============================================================================
Expand Down Expand Up @@ -3662,13 +3671,26 @@ message DomainVerificationConfirmation {
// The challenge token (echoed from DomainVerificationChallenge).
string token = 3;

// Optional: signing key to register upon successful verification.
// If present, the key is registered atomically with verification.
// Key format depends on CDN type (PEM for CloudFront, hex for HMAC).
// Optional: the delivery endpoint's verification key, registered atomically
// with the domain on successful verification. PUBLIC key material only.
// The Exchange signs delivery URLs with a private key it holds and never
// publishes; a delivery endpoint verifies with the public half and holds
// nothing secret. Where the Exchange has to sign with a key the provider
// generated -- a CloudFront trusted key group is the provider's own AWS
// resource -- the private half is provisioned to the Exchange out of band
// and never travels in this field.
//
// Format follows cdn_type: a PEM-encoded RSA public key for "cloudfront", or
// the base64url-encoded raw Ed25519 public key (the JWK "x" value) for
// "edge-ed25519".
optional string signing_key = 4;

// CDN type this key is for.
optional string cdn_type = 5; // "cloudfront", "akamai", "fastly", "hmac"
// Which delivery-URL verification scheme this key is for: "edge-ed25519" (a
// code-capable edge that verifies the Ed25519 URL signature itself) or
// "cloudfront" (AWS CloudFront trusted key groups, RSA, verified natively by
// the CDN). One value per Exchange-side tenant signing scheme:
// "edge-ed25519" is ED25519, "cloudfront" is AWS_CLOUDFRONT_RSA.
optional string cdn_type = 5;

// REQUIRED. Bare host of the recipient this request is addressed to (e.g.
// "exchange.example" or "exchange.example:8081"). See "Request recipient" in
Expand Down
Loading
Loading