Skip to content

fix: sign the signed-fetch metadata verbatim instead of folding it - #322

Open
LautaroPetaccio wants to merge 1 commit into
mainfrom
fix/adr-44-sign-metadata-verbatim
Open

fix: sign the signed-fetch metadata verbatim instead of folding it#322
LautaroPetaccio wants to merge 1 commit into
mainfrom
fix/adr-44-sign-metadata-verbatim

Conversation

@LautaroPetaccio

Copy link
Copy Markdown
Contributor

Description

ADR-44 specifies the signed-fetch payload as:

const payload = [METHOD, PATH, TIMESTAMP, METADATA].join(":").toLowerCase()

The fold happens after the metadata is joined in, which leaves the metadata's casing outside the signature. {"signer":"decentraland-kernel-scene"} and {"Signer":"decentraland-kernel-scene"} produce a byte-identical payload and therefore share one valid signature — while X-Identity-Metadata is delivered exactly as written. A service comparing metadata.signer reads the second as absent, so a request could be re-spelled in flight, keep a genuinely valid signature, and bypass the check that property was there to enforce.

This amends the specification so the metadata is joined verbatim:

const payload = [METHOD.toLowerCase(), PATH.toLowerCase(), TIMESTAMP, METADATA].join(":")

Method and path are still lowercased, so a signature does not depend on how a client spelled them. Requests are otherwise unchanged — the header carries the same JSON, only the signed string differs.

All three amended documents are status Living, which is the status meant to be amended in place rather than superseded.

This documents behaviour the implementations already have. @dcl/crypto-middleware 6.x, decentraland-crypto-fetch 3.x, decentraland-gatsby 9.x and dcl-crypto-middleware-rs 0.3.x all build the payload this way, and the services have been rolled onto them. ADR-44 was the last place still specifying the folded form.

Changes

ADR-44-signed-fetch.md — the substantive change.

  • The payload now lowercases method and path only and joins the metadata verbatim.
  • A new Metadata is signed verbatim section explains why, and keeps the superseded form in the document under a // Superseded. Do not use. label — implementations still need to recognise it while callers migrate.
  • Notes for implementers covers what is easy to get wrong: serialize the metadata once and sign the exact bytes sent (key order and whitespace are signed); rebuild the payload from the metadata as delivered and do not normalize it before verifying; compare authorized-on properties exactly rather than case-folding; and if a verifier keeps a fallback to the superseded payload during migration, scope it to the properties it authorizes on and refuse a delivered property name that differs from the expected spelling — because the superseded payload cannot bind it.

ADR-289-explorer-client-scenes-signed-fetch.md — consistency, not a rewrite.

It states its metadata is part of the signed payload "ensuring its integrity and authenticity", and its fields are camelCase (sceneId, hashPayload, realm.serverName) — precisely what folding left unbound. Its Security Considerations covered the client-side trust boundary (scenes cannot reach the metadata the client builds) but not the request once it leaves the client, so a short Metadata Integrity in Transit subsection now states that, and the Overview notes the metadata is joined verbatim per ADR-44.

ADR-180-communication-adapters.md — one sentence.

It requires verifying intent, signer and isGuest, so it now says those comparisons are exact: a value or property name differing only in case must be refused rather than case-folded.

Checked and deliberately left unchanged

  • ADR-49 (Signed Fetch V2) — already correct. It concatenates the metadata verbatim ('x-identity-metadata:' + Metadata), and its lowercasing applies to header names and Content-Type, which is correct HTTP canonicalization rather than metadata folding. It is also status Draft.
  • ADR-42, ADR-72 — matched a lowercase search but are unrelated (Ethereum address formats; an HTTP method check in a service worker).
  • ADR-183, ADR-208 — mention signed fetch but do not restate the payload construction.

How to Test

make build

Passes, and ADR-44 / ADR-289 / ADR-180 render with the new sections.

ADR-44 built the payload as
`[METHOD, PATH, TIMESTAMP, METADATA].join(":").toLowerCase()` — folded
after the metadata was joined in, which left the metadata's casing
outside the signature. `{"signer":"decentraland-kernel-scene"}` and
`{"Signer":"decentraland-kernel-scene"}` produce byte-identical payloads
and share one valid signature, while the X-Identity-Metadata header is
delivered as written. A service comparing `metadata.signer` reads the
second as absent, so a request could be re-spelled in flight, keep a
valid signature, and bypass the check the property existed to enforce.

The payload now lowercases the method and the path only and joins the
metadata verbatim, binding every property name and value — including
service-defined ones — to the signature. Requests are otherwise
unchanged: the header carries the same JSON, only the signed string
differs. The superseded form is kept in the document, labelled, since
implementations need to recognise it during migration.

Notes for implementers cover the parts that are easy to get wrong:
serialize the metadata once and sign the exact bytes sent, rebuild the
payload from the metadata as delivered without normalizing it, compare
authorized-on properties exactly, and scope any legacy fallback to the
properties being authorized on while refusing unexpected spellings.

ADR-289 and ADR-180 are amended for consistency rather than rewritten.
ADR-289 asserts that its metadata is integrity-protected and its fields
are camelCase (`sceneId`, `hashPayload`, `realm.serverName`), which is
exactly what folding left unbound; its Security Considerations covered
the client-side trust boundary but not the request in flight, so that
gap is now stated. ADR-180 requires verifying `intent`, `signer` and
`isGuest`, so it now says those comparisons are exact.

ADR-49 is unchanged: it already concatenates the metadata verbatim
(`'x-identity-metadata:' + Metadata`), and its lowercasing applies to
header names and Content-Type, which is correct HTTP canonicalization.

All three amended documents are status Living. `make build` passes.
@LautaroPetaccio
LautaroPetaccio requested a review from a team as a code owner September 2, 2026 12:02
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying adr with  Cloudflare Pages  Cloudflare Pages

Latest commit: ef27d98
Status: ✅  Deploy successful!
Preview URL: https://dae8d556.adr-cvq.pages.dev
Branch Preview URL: https://fix-adr-44-sign-metadata-ver.adr-cvq.pages.dev

View logs

@decentraland-bot

Copy link
Copy Markdown
Contributor

✅ Approved by Claude, approved by Codex — amend signed-fetch ADRs to sign metadata verbatim instead of lowercasing, fixing case-sensitive property-name bypass

Checked: all three changed files read in full context (ADR-44, ADR-289, ADR-180); ADR-49 verified as already correct; old-vs-new payload construction semantics; cross-document consistency of "verbatim" and "exact comparison" clauses; security implications; git title/branch conventions; CI status (build pass, Cloudflare Pages pass)


Cross-model review by Jarvis 🤖 · head ef27d98 · Claude + Codex · Requested by Ignacio Mazzara (<@U9ETM8CJH>) via Slack

@nachomazzara nachomazzara left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants