Skip to content

fix: [DO NOT MERGE] sign the metadata the same way it is sent - #9848

Open
LautaroPetaccio wants to merge 1 commit into
devfrom
fix/sign-metadata-verbatim
Open

fix: [DO NOT MERGE] sign the metadata the same way it is sent#9848
LautaroPetaccio wants to merge 1 commit into
devfrom
fix/sign-metadata-verbatim

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Signs the payload format @dcl/crypto-middleware 6.x verifies.

What changed

WebRequestSignInfo.NewFromRaw folded the whole payload before signing:

string payload = $"{method}:{path}:{unixTimestamp}:{raw}".ToLowerInvariant();

while the request delivered x-identity-metadata unfolded. The signature only ever covered the folded form, so a key or value could be re-cased between signing and delivery and still verify — and services read the delivered header, so they were authorizing on bytes the signature never covered.

Now only the method and path are lowercased and the metadata is interpolated verbatim. This is the sole payload builder behind roughly twenty signed call sites, so it is the whole client-side change.

Testing

Four tests in the existing DCL.WebRequests.Tests assembly: method and path normalized, metadata verbatim, two metadata strings differing only in case now producing different payloads, and the empty-metadata default still the literal {} that NewFromUrl depends on.

Their expectations are not guesses. Unity cannot be built here, so I compiled and ran WebRequestSignInfo.cs standalone against a stub for its one non-Unity dependency. It produces:

post:/api/quote:1700000000000:{"realmName":"main"}
get:/status:1700000000000:{}

Method and path lowercased, realmName untouched, empty metadata still {}. The assertions are pinned to that observed output rather than to reasoning about it. Running them under the Unity test runner is still CI's job.

⚠️ Deploy ordering — this one is ordering-sensitive

Do not merge before every service this client calls verifies the 6.x payload.

A verifier still on the old format folds both sides. It therefore accepts this signature while the metadata is all-lowercase, and rejects it the moment any uppercase appears — and this client's metadata is camelCase throughout (realmName, sceneId, isGuest, realm.serverName). So the breakage is not gradual: every signed request carrying real scene metadata starts failing at once.

`NewFromRaw` folded the whole payload before signing:

    $"{method}:{path}:{unixTimestamp}:{raw}".ToLowerInvariant()

while the request delivered `x-identity-metadata` unfolded. The two disagreed
about the metadata's casing, and the signature only ever covered the folded
form -- so a key or value could be re-cased between signing and delivery and
still verify. Services read the delivered header, which meant they were
authorizing on bytes the signature never covered.

Now only the method and path are lowercased and the metadata is interpolated
verbatim, matching createPayload in @dcl/crypto-middleware 6.x. What is signed
here is what every Decentraland verifier reconstructs.

This is the sole payload builder behind roughly twenty signed call sites, so it
is also what lets the canonicalMetadataKeys fallbacks come out of the services.
They exist only because clients sign the older format.

DEPLOY ORDERING: ships only after every service this client calls verifies the
6.x payload. A verifier still on the old format folds both sides, so it accepts
this signature while the metadata is all-lowercase and rejects it the moment any
uppercase appears -- and scene metadata is camelCase throughout (`realmName`,
`sceneId`, `isGuest`, `realm.serverName`). camera-reel-service is the known
blocker: it verifies through `dcl-crypto-middleware-rs`, whose repository is
archived, so its fix cannot currently be released.

Four tests, in the existing WebRequests test assembly. Their expectations are
not guesses: this file was compiled and run standalone, and it produces
`post:/api/quote:<ts>:{"realmName":"main"}` and `get:/status:<ts>:{}` -- method
and path lowercased, metadata untouched, and the empty-metadata default still
the literal `{}` that `NewFromUrl` depends on.
@LautaroPetaccio
LautaroPetaccio requested review from a team as code owners August 24, 2026 19:39
@github-actions
github-actions Bot requested a review from DafGreco August 24, 2026 19:39
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

Warnings not reduced: 12613 => 12618 — remove at least 6 warnings to merge.

No warnings in files changed by this PR — showing general ones you can remove to unblock (50 of 12618)
Assets/DCL/AvatarRendering/AvatarShape/Tests/PerformanceTests/AvatarOutlineFrustumHoistPerformanceTest.cs:126  ArrangeRedundantParentheses  Redundant parentheses
Assets/DCL/AvatarRendering/AvatarShape/Tests/PlayMode/BoneMatrixCalculationJobPerformanceTest.cs:58  ArrangeRedundantParentheses  Redundant parentheses
Assets/DCL/Rendering/GPUInstanceBatcher/ComputeShaders/DrawArgsInstanceCountTransfer.compute:1  CppUnusedIncludeDirective  Possibly unused #include directive
Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/ClientWebSocketApiImplementation.cs:275  RedundantArgumentDefaultValue  The parameter 'initialCount' has the same default value
Assets/DCL/VoiceChat/Microphone/MicrophoneTrackPublisher.cs:43  RedundantArgumentDefaultValue  The parameter 'initialCount' has the same default value
Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebLoginService.cs:29  RedundantArgumentDefaultValue  The parameter 'initialCount' has the same default value
Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/ClientWebSocketApiImplementation.cs:275  RedundantArgumentDefaultValue  The parameter 'maxCount' has the same default value
Assets/DCL/VoiceChat/Microphone/MicrophoneTrackPublisher.cs:43  RedundantArgumentDefaultValue  The parameter 'maxCount' has the same default value
Assets/DCL/Web3/Authenticators/Implementations/ThirdWeb/ThirdWebLoginService.cs:29  RedundantArgumentDefaultValue  The parameter 'maxCount' has the same default value
Assets/DCL/PluginSystem/Global/DefaultTexturesContainer.cs:75  RedundantArgumentDefaultValue  The parameter 'optionValue' has the same default value
Assets/DCL/PluginSystem/Global/DefaultTexturesContainer.cs:76  RedundantArgumentDefaultValue  The parameter 'optionValue' has the same default value
Assets/DCL/Communities/CommunitiesCard/Events/EventListController.cs:156  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
Assets/DCL/Events/EventCardActionsController.cs:93  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
Assets/DCL/Friends/UI/FriendPanel/Sections/Friends/FriendListSectionUtilities.cs:60  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
Assets/DCL/Places/PlacesCardSocialActionsController.cs:183  RedundantArgumentDefaultValue  The parameter 'parcelToTeleport' has the same default value
Assets/DCL/Tests/PlayMode/PerformanceTests/EventsStateServiceLookupPerformanceTest.cs:140  RedundantArgumentDefaultValue  The parameter 'unit' has the same default value
Assets/DCL/Tests/PlayMode/PerformanceTests/EventsStateServiceLookupPerformanceTest.cs:141  RedundantArgumentDefaultValue  The parameter 'unit' has the same default value
Assets/DCL/Tests/PlayMode/PerformanceTests/PlacesStateServiceLookupPerformanceTest.cs:161  RedundantArgumentDefaultValue  The parameter 'unit' has the same default value
Assets/DCL/Tests/PlayMode/PerformanceTests/PlacesStateServiceLookupPerformanceTest.cs:162  RedundantArgumentDefaultValue  The parameter 'unit' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:141  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:156  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/Chat/_Refactor/ChatReactions/Tests/LocalPlayerWorldReactorShould.cs:171  RedundantArgumentDefaultValue  The parameter 'walletId' has the same default value
Assets/DCL/MapRenderer/MapLayers/HomeMarker/HomeMarkerController.cs:135  RedundantArgumentDefaultValue  The parameter 'worldName' has the same default value
Assets/DCL/Infrastructure/ECS/Unity/Materials/Tests/CreateBasicMaterialSystemShould.cs:76  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/Infrastructure/ECS/Unity/Materials/Tests/CreatePBRMaterialSystemShould.cs:85  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/RealmNavigation/RetrieveSceneFromFixedRealm.cs:46  RedundantAssignment  The value passed to the method is never used because it is overwritten in the method body before being read
Assets/DCL/AvatarRendering/Emotes/Editor/EmbeddedEmotesEditor.cs:37  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/AvatarRendering/Emotes/Editor/EmbeddedEmotesEditor.cs:38  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Backpack/AvatarSection/Outfits/OutfitsPresenter.cs:193  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Chat/History/ChatHistoryEncryptor.cs:25  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/ECS/Unity/Materials/Systems/StartMaterialsLoadingSystem.cs:167  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/GLTF/DownloadProvider/GltFastDownloadProviderBase.cs:67  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Global/Editor/DebugSettingsDrawer.cs:73  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Global/Editor/RealmLaunchSettingsDrawer.cs:248  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:64  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:100  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:136  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:172  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/BoxFactory.cs:219  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Infrastructure/Utility/Primitives/CylinderVariantsFactory.cs:137  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Landscape/Jobs/NoiseJob.cs:68  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/PerformanceAndDiagnostics/Diagnostics/ReportsHandling/Sentry/DclAnrIntegration.cs:411  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/PluginSystem/Global/VoiceChatDebugContainer.cs:164  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/SDKComponents/AudioAnalysis/AudioAnalysisSystem.cs:78  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/SDKComponents/AvatarAttach/Systems/AvatarAttachHandlerSystem.cs:87  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Translation/Processors/AngleBracketSegmentationRule.cs:29  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Translation/Processors/AngleBracketSegmentationRule.cs:38  RedundantAssignment  Value assigned is not used in any execution path
Assets/DCL/Multiplayer/Connections/Archipelago/Rooms/ArchipelagoIslandRoom.cs:52  RedundantBaseConstructorCall  Redundant base constructor call
Assets/DCL/AvatarRendering/AvatarShape/ComputeShader/TransformGatherJobs.cs:38  RedundantCast  Type cast is redundant
Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs:139  RedundantCast  Type cast is redundant

Tests

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

⚠️ PlayMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (playmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ⚠️ No results

@github-actions

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@LautaroPetaccio LautaroPetaccio changed the title fix: sign the metadata the same way it is sent fix: [DO NOT MERGE] sign the metadata the same way it is sent Aug 24, 2026

@decentraland-bot decentraland-bot 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.

STEP 2 — Root-cause check: PASS ✅

The PR fixes the root cause. The old .ToLowerInvariant() on the entire payload string folded the metadata's casing in the signature, but the x-identity-metadata header delivered the metadata verbatim via WebRequestHeadersInfo.WithSign(). This made the signature cover a lowercased representation while services verified against the original-cased header bytes — a genuine signature coverage gap. The fix correctly applies lowercasing only to method and path, leaving metadata verbatim so signed bytes match delivered bytes.

STEP 3 — Design & integration: PASS ✅

No new types, systems, lifecycle management, or persistent state introduced. The change modifies the existing NewFromRaw factory method on the WebRequestSignInfo readonly struct — the sole payload builder behind ~20 signed call sites. This is the correct and minimal location for the fix.

Owner search: Not applicable — no new long-lived unit is introduced. The struct is stateless.

Teardown trace: No subscriptions, event hookups, connections, or resources added.

Consumer note: Two call sites in PulseMultiplayerBus.Handshake and RPCSocialServices bypass WebRequestSignInfo and construct payloads manually with hardcoded {} metadata. These are unaffected by this change (lowercase of {} is {}), but are a pre-existing maintenance concern outside the scope of this PR.

STEP 4 — Member audit: PASS ✅

No new public properties or accessors added. The existing StringToSign property is unchanged.

STEP 5 — Line-level review

One P2 finding — see inline comment.

STEP 6 — Complexity: COMPLEX

Touches auth/web3 signing code paths — the signature payload builder used by every signed web request in the client.

STEP 7 — QA: YES

Modifies runtime code that affects how every signed request's payload is constructed. Changes authentication behavior that is deploy-ordering sensitive.

STEP 8 — Non-blocking warnings: None

Main scene not modified.

Security review: No issues found

The change is a security improvement — it closes a gap where metadata casing wasn't covered by the signature. No new vulnerabilities introduced. No hardcoded secrets, no injection risks, no input validation regressions. The string.IsNullOrEmpty fallback to "{}" in NewFromRaw matches the same fallback in WithSign(), so signed bytes always equal delivered bytes.

Deploy ordering (services must upgrade to @dcl/crypto-middleware 6.x before this ships) is correctly flagged in the PR description as a prerequisite.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies the auth/web3 signing payload builder (WebRequestSignInfo.NewFromRaw) used by every signed request in the client.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown (<@unknown>) via Slack

Comment on lines +25 to +39
/// <summary>
/// Builds the ADR-44 payload the auth chain is signed over.
/// <para>
/// The method and path are lowercased; the timestamp and metadata are interpolated verbatim.
/// That last part matters: folding the whole string, as this did before, left the metadata's
/// casing outside the signature while the request still delivered `x-identity-metadata`
/// unfolded. A key or value could therefore be re-cased between signing and delivery and still
/// verify, and services read that header — so they were authorizing on bytes the signature
/// never covered.
/// </para>
/// <para>
/// Matches createPayload in @dcl/crypto-middleware 6.x, so what is signed here is exactly what
/// every Decentraland verifier reconstructs.
/// </para>
/// </summary>

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.

[P2] XML doc narrates external behavior (CLAUDE.md §11 anti-patterns).

The extended comment describes what services and verifiers do ("services read that header", "every Decentraland verifier reconstructs") rather than what this code itself does or guarantees. The spec reference (@dcl/crypto-middleware 6.x) is valuable; the external behavior narration should be trimmed.

Suggested change
/// <summary>
/// Builds the ADR-44 payload the auth chain is signed over.
/// <para>
/// The method and path are lowercased; the timestamp and metadata are interpolated verbatim.
/// That last part matters: folding the whole string, as this did before, left the metadata's
/// casing outside the signature while the request still delivered `x-identity-metadata`
/// unfolded. A key or value could therefore be re-cased between signing and delivery and still
/// verify, and services read that header — so they were authorizing on bytes the signature
/// never covered.
/// </para>
/// <para>
/// Matches createPayload in @dcl/crypto-middleware 6.x, so what is signed here is exactly what
/// every Decentraland verifier reconstructs.
/// </para>
/// </summary>
/// <summary>
/// Builds the ADR-44 payload the auth chain is signed over.
/// <para>
/// The method and path are lowercased; the timestamp and metadata are interpolated verbatim
/// so the signed bytes match the delivered <c>x-identity-metadata</c> header byte-for-byte.
/// </para>
/// <para>
/// Matches <c>createPayload</c> in <c>@dcl/crypto-middleware</c> 6.x.
/// </para>
/// </summary>

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9848, run #32776904160

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2232 (×3) 2700 (×3)
CPU average 40.0 ms (37.5–40.6) 33.2 ms -6.8 ms 🟢 17% faster
CPU 1% worst 416.2 ms (410.8–424.4) 35.8 ms (35.8–35.9) -380.4 ms 🟢 91% faster
CPU 0.1% worst 448.2 ms (428.8–467.7) 66.0 ms (65.7–66.6) -382.2 ms 🟢 85% faster
GPU average 27.6 ms (26.3–27.6) 10.1 ms (10.0–10.2) -17.5 ms 🟢 63% faster
GPU 1% worst 420.9 ms (415.6–430.0) 10.4 ms (10.4–10.7) -410.5 ms 🟢 98% faster
GPU 0.1% worst 450.9 ms (434.8–471.9) 11.3 ms (10.5–11.5) -439.6 ms 🟢 98% faster
Exceptions per run 1 0 -1 🟢 fewer errors
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[ENGINE] ObjectDisposedException 3 0

Apple M1

Metric Baseline Change Δ Result
Samples 2710 (×3) 3928 (×3)
CPU average 33.1 ms (33.0–33.2) 22.8 ms (22.8–22.9) -10.2 ms 🟢 31% faster
CPU 1% worst 228.4 ms (225.1–230.6) 34.5 ms (30.6–34.7) -193.9 ms 🟢 85% faster
CPU 0.1% worst 236.7 ms (233.1–286.8) 34.8 ms (32.2–34.8) -201.9 ms 🟢 85% faster
GPU average 22.7 ms (21.5–25.0) 0.2 ms (0.1–5.2) -22.5 ms 🟢 99% faster
GPU 1% worst 46.3 ms (45.6–46.4) 15.2 ms (7.7–39.4) -31.1 ms ⚪ within noise
GPU 0.1% worst 47.7 ms (47.2–48.2) 39.9 ms (39.4–40.3) -7.8 ms 🟢 16% faster
Exceptions per run 0 0 0 ⚪ none new

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

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants