Sign in with Starknet technical proposal

This is an attempt to specify an encoding for a ‘sign-in’ data standard to enable a Sign in with Starknet for websites, services and accounts.

Context

We need a standard sign-in message and data format for Starknet similar to eip-4361 in the Ethereum ecosystem. Since Starknet is a green field, we/nethermind would like to take the opportunity to improve upon eip-4361 by taking inspiration from eip-712: we will use structured JSON data to enable strong typing and better user communication. This proposal uses starknet.js’s eip-712 style off-chain signing, however it means we are restricted to 31 character strings, more discussion on this is below. We will make this into a SNIP after some discussion on the forum.

Since Starknet supports native account abstraction the sign in with Starknet functionality is even more powerful than in Ethereum. Any message hash associated with any piece of data which passes an account’s isValidSignature method is considered a valid signature. This means that:

  • Users can revoke signatures simply by changing their account keys
  • Any signature method invented in the future is supported without any changes required to the scheme(or even using secp256r1 <> FaceID)
  • Novel signing methods can implement off-chain signing without requiring any special support from the target domain - an off-chain multisig, for example.

There have been a few previous attempts and foundational steps made by the community already:

Definitions

  • Contract Address: a unique identifier of a contract on Starknet
  • Account: An abstract account on Starknet identified by a Contract Address
  • User: An owner of an account on Starknet
  • Challenger: A domain, app or backend wishing to authenticate a User as the owner of an Account

Specification

Sign in with Starknet will work as follows.

  1. The challenger prepares a challenge which a User must sign to authenticate the user as the owner of some account on Starknet. The sign-in JSON object following the starknet.js standard, includes:
  • the domain requesting the signature
  • the address of the contract against which the signature will be verified
  • a statement by the Domain to be presented to the User
  • the version of the domain/app
  • the version of the message
  • the chain-id
  • the URI for scoping
  • a nonce acceptable to the domain/app
  • and the issued-at timestampThese fields are defined precisely below.
  1. The challenge is presented to the User by the wallet through a signature request. The wallet MUST present this challenge cleanly and SHOULD validate the challenge in some way to protect the user from phishing attacks. This is left open for now, we invite those interested in preventing this to comment below.
  2. The User signs the message if they find it acceptable.
  3. The signed sign-in data is returned to the challenger, who uses an RPC call to the account’s isValidSignaturemethod to verify the signature’s authenticity. Note that due to account abstraction, the challenger cannot predict the signature scheme; the challenger MUST interact with an up-to-date Starknet Node to authenticate the message successfully.

Data format

We propose the following sign-in data format per the spec in starknet.js. We define the two JSON objects, domain and message, that the challenger MUST populate.

domain

  • chainId: can be one of these three values ‘SN_GOERLI’ | ‘SN_GOERLI2’ | ‘SN_MAIN’
  • name: name of the dApp limited to 31 ASCII characters OR the RFC 4501 DNS authority that is requesting the signing, limited to 31 Characters - OPEN QUESTION, please comment
  • version: version of the app in the form of x.y.z Limited to 31 characters

message

  • address: Starknet Contract Address to validate against
  • issuedAt: ISO 8601 datetime string of the current time, limited to 31 Characters
  • nonce: Randomized token used to prevent replay attacks, at least eight alphanumeric characters. Limited to 31 characters
  • statement: Human-readable ASCII assertion that the user will sign. Limited to 31 Characters
  • uri: RFC 3986 URI referring to the resource that is the subject of the signing. Limited to 31 Characters
  • version: version of the message in x.y.x form. Note that this is the message version, not the app domain version mentioned above. Limited to 31 characters
  • expirationTime: optional, ISO 8601 datetime string that, if present, indicates when the signed authentication message is no longer valid. Limited to 31 characters
  • notBefore: optional, ISO 8601 datetime string that, if present, indicates when the signed authentication message will become valid. Limited to 31 characters

Example Payload/Data

{
  "domain": {
    "version": "0.0.1",
    "chainId": "SN_GOERLI",
    "name": "siws.nethermind.io"
  },
  "message": {
    "address": "0x1c62c52c1709acb3eb9195594e39c04323658463cfe0c641e39b99a83ba11a1",
    "statement": "Please Sign in",
    "uri": "<https://siws.nethermind.io>",
    "version": "0.0.5",
    "nonce": "FIrowza8YUhluXSrb",
    "issuedAt": "2023-06-27T21:19:52.929Z"
  },
  "primaryType": "Message",
  "types": {
    "Message": [
      {
        "name": "address",
        "type": "felt"
      },
      {
        "name": "statement",
        "type": "string"
      },
      {
        "name": "uri",
        "type": "string"
      },
      {
        "name": "nonce",
        "type": "string"
      },
      {
        "name": "issuedAt",
        "type": "string"
      },
      {
        "name": "version",
        "type": "felt"
      }
    ],
    "StarkNetDomain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      }
    ]
  }
}

JSON Schema :

{
  "$schema": "<https://json-schema.org/draft/2020-12/schema>",
  "type": "object",
  "properties": {
    "domain": {
      "type": "object",
      "properties": {
        "chainId": {
          "type": "string",
          "enum": ["SN_GOERLI", "SN_GOERLI2", "SN_MAIN"],
          "errorMessage": "ChainId must be one of 'SN_GOERLI', 'SN_GOERLI2', 'SN_MAIN'"
        },
        "name": {
          "type": "string",
          "maxLength": 31,
          "errorMessage": "Name must be a string and cannot exceed 31 characters"
        },
        "version": {
          "type": "string",
          "maxLength": 31,
          "pattern": "^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$",
          "errorMessage": "Version must be a string in the format x.y.z"
        }
      },
      "required": ["chainId", "name", "version"],
      "additionalProperties": false,
      "errorMessage": "Domain must include chainId, name, version"
    },
    "message": {
      "type": "object",
      "properties": {
        "version": {
          "type": "string",
          "maxLength": 31,
          "pattern": "^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$",
          "errorMessage": "Version must be a string in the format x.y.z"
        },
        "address": {
          "type": "string",
          "pattern": "^0x[a-fA-F0-9]{63,64}$",
          "errorMessage": "Address must be a hexadecimal string with 66 characters, including the '0x' prefix"
        },
        "issuedAt": {
          "type": "string",
          "format": "date-time",
          "errorMessage": "IssuedAt must be a valid date-time string"
        },
        "nonce": {
          "type": "string",
          "minLength": 8,
          "maxLength": 31,
          "pattern": "^[a-zA-Z0-9]*$",
          "errorMessage": "Nonce must be an alphanumeric string between 8 and 31 characters"
        },
        "statement": {
          "type": "string",
          "maxLength": 31,
          "errorMessage": "Statement must be a string and cannot exceed 31 characters"
        },
        "uri": {
          "type": "string",
          "format": "uri",
          "errorMessage": "Uri must be a valid URI string"
        },
        "expirationTime": {
          "type": "string",
          "format": "date-time",
          "errorMessage": "ExpirationTime, if present, must be a valid date-time string"
        },
        "notBefore": {
          "type": "string",
          "format": "date-time",
          "errorMessage": "NotBefore, if present, must be a valid date-time string"
        }
      },
      "required": ["address", "issuedAt", "nonce", "statement", "uri", "version"],
      "additionalProperties": false,
      "errorMessage": "Message must include address, issuedAt, nonce, statement, uri, version"
    },
    "primaryType": {
      "type": "string",
      "const": "Message",
      "errorMessage": "PrimaryType must be 'Message'"
    },
    "types": {
      "type": "object",
      "properties": {
        "Message": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "enum": [ "version" ,"address", "statement", "uri", "nonce", "issuedAt", "expirationTime", "notBefore"],
                "errorMessage": "Name must be one of 'version', 'address', 'statement', 'uri', 'nonce', 'issuedAt', 'expirationTime', 'notBefore'"
              },
              "type": {
                "type": "string",
                "enum": ["string", "felt"],
                "errorMessage": "Type must be either 'string' or 'felt'"
              }
            },
            "required": ["name", "type"],
            "additionalProperties": false,
            "errorMessage": "Items must include name and type"
          },
          "minItems": 6,
          "maxItems": 8,
          "uniqueItems": true,
          "errorMessage": "Message must contain min 6-8 unique items "
        },
        "StarkNetDomain": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "enum": ["name", "chainId", "version"],
                "errorMessage": "Name must be one of 'name', 'chainId', 'version'"
              },
              "type": {
                "type": "string",
                "enum": ["felt", "string"],
                "errorMessage": "Type must be 'felt' or 'string'"
              }
            },
            "required": ["name", "type"],
            "additionalProperties": false,
            "errorMessage": "Items must include name and type"
          },
          "minItems": 3,
          "maxItems": 3,
          "uniqueItems": true,
          "errorMessage": "StarkNetDomain must contain exactly 3 unique items"
        }
      },
      "required": ["Message", "StarkNetDomain"],
      "additionalProperties": false,
      "errorMessage": "Types must include Message and StarkNetDomain"
    }
  },
  "required": ["domain", "message", "primaryType", "types"],
  "additionalProperties": false,
  "errorMessage": "Data must include domain, message, primaryType, types"
}

We have made available a typescript library to help generate and validate the sign-in message and also an example of the implementation at https://siws.nethermind.io. https://github.com/NethermindEth/sign-in-with-starknet

Areas for improvement

  1. The 31-character limit of the message data in starknet.js signing standard significantly limits the statement and domain. We want to develop a string representation in Starknet, which is unbounded and efficient; we invite the community or Nethermind to take this on.

Next steps

  1. Wallets need to support this standard to present it cleanly to users. However, this is not required for this standard to be usable.
  2. Update starknet.js so that StarkNetDomain is StarknetDomain :point_right::point_left:
  3. Include isValidSignature in the account standard, as __is_valid_signature__, make it so that all accounts must be deployed with this method. Accounts which do not support off-chain signing should simply make this call revert.
  4. Create an SNIP with this proposal

Thank you for putting this together. A big +1 to this proposal.

I wonder why you think is better to enforce __is_valid_signature__ at protocol-level, instead of following an application-level standard for accounts containing is_valid_signature like the Starknet Standard Account.

I think the less we enforce at the protocol level, the better.

Thanks for the feedback, thats exactly the purpose of the forum post. Can you elaborate why enforcement at the protocol level is bad? We think that the added complexity at the protocol ‘might’ be worth it or at least worth a discussion. The question is should every account be able to verify an off chain message? We think yes as that would allow a number of features that account abstraction enables(sign-in, messaging etc). SIWS(Sign-in-with-Starknet) with better recovery features because of AA could be a very important/unique feature of Starknet. Ofcourse it goes without saying it should be part of the Account Standard SNIP

To summarize, the way I see it, the more restrictions we put at the protocol level, the further we get from Account Abstraction, and the closer we get to a specific Account implementation, killing the potential for different future ways of addressing things (potentially better).

Account Abstraction gives us the freedom to play with accounts the same way we play with smart contracts, with the potential of multiple different standards to come, some of them replacing previous ones, or some just completely different addressing different things. These standards are better placed IMO at the application level than at the protocol level (as we’ve seen in Ethereum). Moving things at the protocol level is kind of “setting things in stone”, making them more difficult to change (even for improvements), and enforcing them on all the contracts, even when sometimes you may not want it.

With this said, there is a line of course, we want some things enforced at the protocol level (execute and validate entry points are good examples), but the less the better, and for me is_valid_signature is above the line, and shouldn’t be “set in stone” for all the accounts.

The ability to sign off-chain data is something all accounts should be able to do, as all accounts are capable of doing in Ethereum. I think this puts it within the realm of protocol standardisation. It would be a shame if we lost the ability to sign and verify data reliably in our account abstracted future. I dread a world where every account implementation has different endpoints for signature validation.

To that end, I think it’s appropriate for accounts to have enshrined interfaces, as long as these interfaces specify functionality an account must have without restricting how they implement them. Future improvements to accounts can still be implemented, after all, the standard doesn’t prevent new methods from being included. Enshrining this at the protocol level is the most reliable way to ensure this doesn’t devolve into a mess.

I do not want to put any protocol-level expectations on what an account’s valid signature is. This should be entirely up to the abstracted account to define. If an account does not support off-chain signatures, it can return false.

That said, I think it may already be too late to enshrine this in accounts. Although, there is a window of opportunity around the Cairo 1.0 upgrade.

This sign-in proposal is unaffected by whether is_valid_signature is protocol level or application level.

Discussion on removing the 31 characters limit from Starknet.js on GitHub

A similar proposal has now been made by EthSign SNIP-x: Sign-In with Starknet by boyuanx · Pull Request #59 · starknet-io/SNIPs · GitHub

Should we start discussing this again now that Cairo 2.4.0 has support for ByteArray?

Absolutely, we’re also interested in getting this conversation moving again