ADR-162: Scoped logins for auth chains

More details about this document
Latest published version:
https://adr.decentraland.org/adr/ADR-162
Authors:
marianogoldman
LautaroPetaccio
Feedback:
GitHub decentraland/adr (pull requests, new issue, open issues)
Edit this documentation:
GitHub View commits View commits on githistory.xyz

Abstract

Signing in to Decentraland delegates to an ephemeral key everything the wallet can do, until the key expires. This ADR adds a scoped login: a delegation that names the only operations the ephemeral key may perform, per service and optionally per resource, in a message the user can read before signing. It is a new auth link type, ECDSA_SCOPED_EPHEMERAL (and ECDSA_EIP_1654_SCOPED_EPHEMERAL for contract wallets), rather than extra lines in the Decentraland Login message. Verifiers that don't know the type already reject it, so a scoped key is never accepted as a full login, by catalysts, Worlds content servers and services that haven't been updated included. A service opts in by validating with its own service identifier; it then receives the grants for that service and checks each operation against them. Chains in use today keep working unchanged and keep granting every operation.

Context, Reach & Prioritization

The full login

When signing in, a client creates an ephemeral key and the user signs a Decentraland Login message delegating to it (ADR-44). The client then signs deployments and requests with that key, without asking the wallet again:

{
  "version": "v3",
  "localTimestamp": 1672836992842,
  "authChain": [
    {
      "type": "SIGNER",
      "payload": "0xed93e62f69c386617003ca0c8d78faca37a73912",
      "signature": ""
    },
    {
      "type": "ECDSA_EPHEMERAL",
      "payload": "Decentraland Login\r\nEphemeral address: 0x9272b45a74942068e6Ebe3e326dc065F7C28e41d\r\nExpiration: 2023-01-09T09:11:13.802Z",
      "signature": "0x827970db4cab26dd0b4743d4eff58c0e3401c400b50362421694b9d070ac624c7da889fa726c38429e8896c5de127666a33786d0c821aef2eea548029e7407eb1c"
    },
    {
      "type": "ECDSA_SIGNED_ENTITY",
      "payload": "bafkreigwzkkzrpkjugifokndlmvwsqfvpmoogthuol2zij67s7hj3flaxq",
      "signature": "0x67f77d86e3e91459469110c2be16682d0c9ff57e402df6d0396033676d087f72334cbcc29972968311c5987660c58ac4d14876792c354bbd864e0302a6cce53f1c"
    }
  ]
}

Until it expires, that key can do anything the wallet can: deploy profiles, but also deploy scenes to the user's LAND or to a World under their name, and sign requests to every service that accepts signed fetch. Whoever obtains it, from a compromised client, a leaked file or a malicious dependency, has the same reach.

Why it matters now

The Explorer needs only a fraction of that: deploying profiles and signing its own requests. Other clients need even less, and increasingly keep their key outside a browser:

Each of them holds a credential that can deploy scenes, although it only needs to read one service. Following the principle of least privilege, the user should be able to sign a key that can do only what the client needs, and see what it can do before signing.

Deployments signed directly by the wallet, without an ephemeral key, such as most scene deployments from the CLI, are not affected by this proposal.

Reach

Vocabulary

Solution Space Exploration

Permissions in the Decentraland Login message

The first draft of this ADR proposed appending permissions to the login message, in a YAML-like block or as phrases:

Decentraland Login
Ephemeral address: 0x4f9c4Ff265357F937B14BCd1E5519f1537985301
Expiration: 2022-12-29T00:00:00.000Z

Permissions:
- allow "dcl:worlds:deploy" for menduz.dcl.eth
- allow "dcl:explorer:*" for 0xaddress
- deny "dcl:explorer:voice" for 0xaddress

It needs no new link type, and an absent block would mean full access. But today's verifiers read only the ephemeral address (line 2) and the expiration (line 3) of that message, and ignore every other line. Such a key would still be a full login for every catalyst, Worlds content server and service that hasn't been updated, so the risk described above would stay open until every verifier on the network upgrades. Rejected.

A version number in the login message

Marking scoped messages with a version, as the first draft also suggested, has the same flaw: today's verifiers ignore it and accept the key as a full login. Rejected.

IAM-style policies

The first draft modeled permissions on AWS IAM: allow and deny statements on namespace:service:operation, with wildcards, per resource. deny is only needed to carve exceptions out of wildcards, and the precedence rules between them are easy to get wrong in a verifier. The dcl: namespace only matters if other organizations issue permissions under the same login, which isn't the case. Simplified: this ADR keeps service:operation and per-resource grants, as an allow-list with no deny, no namespace and no wildcard across services.

EIP-712 typed data

The message could be signed as EIP-712 typed data, which wallets show as named fields:

1 2 3

It reads better in wallets that support it and is domain-separated from personal messages. But:

Deferred: the grant model below does not depend on the encoding, so a typed-data link type can be added later with the same grants and validation (see Future work).

Per-service session tokens

Each service could exchange a full-login request for its own short-lived token. It needs no protocol change, but every service builds its own session store and revocation, the user still signs a full login to obtain the token, and the token only exists after that full login has already been exposed to the client. Rejected as a general solution.

Chosen: a scoped login link type

A new auth link type whose message lists the grants:

Specification

Link types

Type Signed with Validated like
ECDSA_SCOPED_EPHEMERAL an EOA personal signature ECDSA_EPHEMERAL
ECDSA_EIP_1654_SCOPED_EPHEMERAL a contract wallet (EIP-1654) ECDSA_EIP_1654_EPHEMERAL

The signature of the link is the current authority's signature of its payload, as for the ECDSA_EPHEMERAL and ECDSA_EIP_1654_EPHEMERAL links. The authority that comes out of the link is its Ephemeral address.

Message

Decentraland Scoped Login
Ephemeral: 0x1111111111111111111111111111111111111111
Expiration: 2026-10-23T18:00:00.000Z
Allow: insights:logs-read
Allow: insights:metrics-read for world:example.dcl.eth
payload    = prefix LF ephemeral LF expiration 1*( LF grant )
prefix     = "Decentraland Scoped Login"
ephemeral  = "Ephemeral: 0x" 40( DIGIT / %x61-66 )   ; lowercase hex
expiration = "Expiration: " timestamp
grant      = "Allow: " service ":" operation [ " for " resource ]
service    = 1*64( %x61-7A / DIGIT / "-" )
operation  = 1*64( %x61-7A / DIGIT / "-" ) / "*"
resource   = 1*256( %x21-7E )                        ; visible ASCII, no space

The field is Ephemeral:, not Ephemeral address:, so code that reads the payload as a Decentraland Login message, whatever the link type, fails on it.

Grants

A grant <service>:<operation> names a service and one of its operations.

There are no deny grants: a grant list is an allow-list, so an operation not listed is denied.

A grant never adds access. The service still authorizes the signer as it does for a full login, for example by checking that the address owns the world; the grant only limits what the ephemeral key may do with that access.

Auth chain structure

A chain with a scoped link MUST have this structure:

  1. a SIGNER link;
  2. zero or more ECDSA_EPHEMERAL or ECDSA_EIP_1654_EPHEMERAL links;
  3. exactly one ECDSA_SCOPED_EPHEMERAL or ECDSA_EIP_1654_SCOPED_EPHEMERAL link;
  4. at most one ECDSA_SIGNED_ENTITY link, signed by the scoped link's ephemeral key.

A chain that ends at the scoped link is the scoped identity itself, and its final authority is the Ephemeral address: that is how a client checks an identity it stored. A request adds the ECDSA_SIGNED_ENTITY link.

So a scoped key cannot delegate to another key, and a chain cannot widen its scope after narrowing it. Step 2 allows a key that already holds a full login, such as a browser dApp's ephemeral key, to delegate a narrower one. An ECDSA_EIP_1654_SCOPED_EPHEMERAL link only follows SIGNER directly.

The chain expires at the earliest expiration of its links. Any exposed expiration, including scope.expiration, MUST be this effective chain expiration, so a derived session cannot outlive a parent delegation.

Validation

A verifier that accepts scoped logins is configured with its own service identifier S.

  1. It validates the chain as it does today, with the validators for the new link types, and checks the structure above.
  2. If the chain has no scoped link, the request has access to every operation, as today.
  3. If the chain has a scoped link:
  4. Each request performs exactly one operation, which the verifier chooses from the request it serves: the endpoint, and where the endpoint's behavior depends on it, the request's parameters. The verifier also chooses the resource the request acts on, if any. A verifier that configures S MUST assign an operation to every endpoint that accepts signed requests, and MUST reject a scoped chain on any request it cannot assign one.
  5. The verifier checks can(operation, resource?):

A request has one operation, not a list of them. An endpoint that does several things at once needs an operation of its own, and a grant never implies another grant: Allow: insights:logs-admin permits logs-admin, and nothing else unless the service defines it to.

A request whose chain is valid but whose grants don't allow the operation SHOULD be answered with HTTP 403. A scoped chain on a request the verifier assigns no operation SHOULD be answered with HTTP 403 and a distinct SCOPED_OPERATION_REQUIRED error code. A verifier that does not accept scoped logins continues to return HTTP 401.

These checks run after the ADR-44 signed fetch is verified. The signed metadata of the request may name the resource the request acts on, such as a scene, since the scoped key signed it; the verifier MUST then act on that same resource. The metadata never names the operation, and nothing in it can widen a grant.

Identity validation and request authorization

Cryptographic identity validation and authorization of an action are distinct APIs. A stored scoped identity can be checked against its ephemeral address without performing an operation. Such a result proves identity validity only. Callers of low-level identity/signature APIs MUST NOT treat ok: true, an address, or the presence of a service grant as authorization to act.

An API that authorizes a scoped request MUST require the verifier's service and one operation, plus the canonical resource when the request acts on a resource. It MUST return success only after the grant check, including for WebSocket handshakes and non-HTTP consumers. Compatibility helpers that return only an address or void without checking an action MUST reject scoped credentials. Request authorization requires a final signature over the request payload; a stored delegation alone is not proof that its ephemeral key signed a request. The reference libraries expose authorizeSignature for generic actions and verify for signed-fetch requests; validateSignature validates stored identities and signatures and is not request authorization.

One resolved resource

The verifier MUST resolve the canonical resource once, authorize that value, and pass that same value to the handler as read-only authorization context. Generic action authorization similarly returns its checked resource read-only. The reference middleware exposes it as ctx.verification.resource, including for full logins on routes that declare a resource resolver. Handlers MUST act on that value and MUST NOT re-resolve it independently from URL parameters, metadata, a body, or a differently cased value. Permission checks on the signer still apply. The library cannot enforce which value application code ultimately uses.

An operation/resource resolver that throws, or a declared resource resolver that cannot resolve its resource, MUST reject the request before running the handler. The reference middleware responds with a controlled HTTP 403 and a distinct resolution error code, without exposing exception details. optional authentication MUST NOT bypass a failed action or resource resolution.

Backwards compatibility

Security considerations

Example

A command line tool that reads a creator's scene logs and metrics delegates a key that can do nothing else:

Decentraland Scoped Login
Ephemeral: 0x1111111111111111111111111111111111111111
Expiration: 2026-10-23T18:00:00.000Z
Allow: insights:logs-read
Allow: insights:metrics-read
[
  {
    "type": "SIGNER",
    "payload": "0x2222222222222222222222222222222222222222",
    "signature": ""
  },
  {
    "type": "ECDSA_SCOPED_EPHEMERAL",
    "payload": "Decentraland Scoped Login\nEphemeral: 0x1111111111111111111111111111111111111111\nExpiration: 2026-10-23T18:00:00.000Z\nAllow: insights:logs-read\nAllow: insights:metrics-read",
    "signature": "0x<signature of the payload by 0x2222…2222>"
  },
  {
    "type": "ECDSA_SIGNED_ENTITY",
    "payload": "get:/v1/logs:1792756800000:{}",
    "signature": "0x<signature of the payload by 0x1111…1111>"
  }
]

If the key leaks, it can read the logs and metrics of the scenes that address may read until it expires. Catalysts, Worlds content servers and every other service reject it.

Future work

RFC 2119 and RFC 8174

The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", " SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

License

Copyright and related rights waived via CC0-1.0. Draft