This document presents the problem of authenticating requests in http servers at Decentraland. It presents two alternatives: Ephemeral keys or Private keys. Ephemeral keys are decided to improve the user experience to not require N interactions on the wallet.
We want to integrate our dApps with the explorer in order to provide a better experience for our users. To do so we need to send request authenticated by the user's wallet.
Ask the user to sign each request
Use
decentraland-crypto
as intermediary to sign each request
The option to use
decentraland-crypto
to interact with our services was chosen in order to provide a better experience to our users
In order to minimize the reutilization of request each payload must include data about the request and the moment it is made
/**
* Request method
*/
const METHOD: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" = `POST`
/**
* request path without domain, query string or hash
* @example
* - `/ping`
* - `new URL('https://decentraland.org/ping').pathname`
*/
const PATH: string = `/ping`
/**
* request timestamp
* each service decide how long it will consider valid a request using this timestamp,
* if timestamp is greater than now the request should fail
*/
const TIMESTAMP: number = Date.now()
/**
* request metadata
* can include extra data about the service that is making the request
* shouldn't contains data required to make the request, if it is empty
* and empty object should be use
*/
const METADATA: string = JSON.stringify({
/* extra data */
})
/**
* Payload
*
* The method and the path are lowercased so a signature does not depend on how a
* client happened to spell them. The metadata is joined verbatim: it is a JSON
* document whose property names and values are compared exactly by the services
* that authorize on them, so its bytes MUST be the bytes that were signed.
*/
const payload = [METHOD.toLowerCase(), PATH.toLowerCase(), TIMESTAMP, METADATA].join(":")
The payload above lowercases the method and the path only. Earlier revisions of this document folded the whole joined string, metadata included:
// Superseded. Do not use.
const payload = [METHOD, PATH, TIMESTAMP, METADATA].join(":").toLowerCase()
Folding after the metadata was joined in left 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 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 a check that the property was there
to enforce.
Joining the metadata verbatim binds every property name and value — including service-defined ones — to the signature, so what is verified is what the handler reads.
Requests are otherwise unchanged: the X-Identity-Metadata header still carries
the same JSON, and only the string that is signed differs.
METADATA
MUST be the exact string sent in the X-Identity-Metadata header —
serialize it once and reuse it, rather than re-serializing, since key order and whitespace
are part of the signed bytes.
Once you have an Identity from
decentraland-crypto
use signPayload to get an AuthLink
import { Authenticator } from "decentraland-crypto"
const auth = await Authenticator.signPayload(identity, data)
To sign a request the AuthChain should be included as a header in the request
along with the timestamp and the metadata
fetch("https://decentraland.org/ping", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Identity-Auth-Chain-0": JSON.stringify(auth[0]),
"X-Identity-Auth-Chain-1": JSON.stringify(auth[1]),
"X-Identity-Auth-Chain-2": JSON.stringify(auth[2]),
"X-Identity-Timestamp": TIMESTAMP,
"X-Identity-Metadata": METADATA
},
body: JSON.stringify({})
})