├── tests ├── __init__.py └── test_siwe.py ├── .flake8 ├── siwe ├── grammars │ ├── __init__.py │ ├── rfc5234.py │ ├── rfc3339.py │ └── eip4361.py ├── __init__.py ├── defs.py ├── parsed.py └── siwe.py ├── .gitmodules ├── LICENSE-MIT ├── .github └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── pyproject.toml ├── README.md ├── .gitignore └── LICENSE-APACHE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503 3 | -------------------------------------------------------------------------------- /siwe/grammars/__init__.py: -------------------------------------------------------------------------------- 1 | """ABNF definitions.""" 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/siwe"] 2 | path = tests/siwe 3 | url = https://github.com/spruceid/siwe.git 4 | -------------------------------------------------------------------------------- /siwe/__init__.py: -------------------------------------------------------------------------------- 1 | """Library for EIP-4361 Sign-In with Ethereum.""" 2 | 3 | # flake8: noqa: F401 4 | from .siwe import ( 5 | DomainMismatch, 6 | ExpiredMessage, 7 | InvalidSignature, 8 | ISO8601Datetime, 9 | MalformedSession, 10 | NonceMismatch, 11 | NotYetValidMessage, 12 | SiweMessage, 13 | VerificationError, 14 | generate_nonce, 15 | ) 16 | -------------------------------------------------------------------------------- /siwe/grammars/rfc5234.py: -------------------------------------------------------------------------------- 1 | """Primitive ABNF definition.""" 2 | 3 | from typing import ClassVar, List 4 | 5 | from abnf.grammars.misc import load_grammar_rules 6 | from abnf.parser import Rule as _Rule 7 | 8 | 9 | @load_grammar_rules() 10 | class Rule(_Rule): 11 | """Rules from RFC 5234.""" 12 | 13 | grammar: ClassVar[List] = [ 14 | "ALPHA = %x41-5A / %x61-7A ; A-Z / a-z", 15 | "LF = %x0A \ 16 | ; linefeed", 17 | "DIGIT = %x30-39 \ 18 | ; 0-9", 19 | 'HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"', 20 | ] 21 | -------------------------------------------------------------------------------- /siwe/grammars/rfc3339.py: -------------------------------------------------------------------------------- 1 | """Date ABNF definition.""" 2 | 3 | from typing import ClassVar, List 4 | 5 | from abnf.grammars.misc import load_grammar_rules 6 | from abnf.parser import Rule as _Rule 7 | 8 | from . import rfc5234 9 | 10 | 11 | @load_grammar_rules( 12 | [ 13 | # RFC 5234 14 | ("DIGIT", rfc5234.Rule("DIGIT")), 15 | ] 16 | ) 17 | class Rule(_Rule): 18 | """Rules from RFC 3339.""" 19 | 20 | grammar: ClassVar[List] = [ 21 | "date-fullyear = 4DIGIT", 22 | "date-month = 2DIGIT", 23 | "date-mday = 2DIGIT", 24 | "time-hour = 2DIGIT", 25 | "time-minute = 2DIGIT", 26 | "time-second = 2DIGIT", 27 | 'time-secfrac = "." 1*DIGIT', 28 | 'time-numoffset = ( "+" / "-" ) time-hour ":" time-minute', 29 | 'time-offset = "Z" / time-numoffset', 30 | 'partial-time = time-hour ":" time-minute ":" time-second [ time-secfrac ]', 31 | 'full-date = date-fullyear "-" date-month "-" date-mday', 32 | "full-time = partial-time time-offset", 33 | 'date-time = full-date "T" full-time', 34 | ] 35 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Spruce Systems, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | ci: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: [3.8, 3.11] 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | submodules: recursive 21 | - uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Run image 25 | uses: abatilo/actions-poetry@v2.3.0 26 | - name: Install 27 | run: poetry install && echo "$(poetry env info --path)/bin" >> $GITHUB_PATH 28 | - name: Test 29 | run: poetry run pytest -v 30 | env: 31 | WEB3_PROVIDER_URI: '${{ secrets.WEB3_PROVIDER_URI }}' 32 | - name: Fmt 33 | run: poetry run ruff format . --check 34 | - name: Ruff 35 | run: poetry run ruff check . 36 | - name: Deptry 37 | run: poetry run deptry . 38 | - uses: jakebailey/pyright-action@v1 39 | if: ${{ matrix.os != 'windows-latest' }} 40 | -------------------------------------------------------------------------------- /siwe/defs.py: -------------------------------------------------------------------------------- 1 | """Regexes for the various fields.""" 2 | 3 | SCHEME = "((?P([a-zA-Z][a-zA-Z0-9\\+\\-\\.]*))://)?" 4 | DOMAIN = "(?P([^/?#]+)) wants you to sign in with your Ethereum account:\\n" 5 | ADDRESS = "(?P
0x[a-zA-Z0-9]{40})\\n\\n" 6 | STATEMENT = "((?P[^\\n]+)\\n)?\\n" 7 | URI = "(([^ :/?#]+):)?(//([^ /?#]*))?([^ ?#]*)(\\?([^ #]*))?(#(.*))?" 8 | URI_LINE = f"URI: (?P{URI}?)\\n" 9 | VERSION = "Version: (?P1)\\n" 10 | CHAIN_ID = "Chain ID: (?P[0-9]+)\\n" 11 | NONCE = "Nonce: (?P[a-zA-Z0-9]{8,})\\n" 12 | DATETIME = ( 13 | "([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]" 14 | "([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(.[0-9]+)?" 15 | "(([Zz])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))" 16 | ) 17 | ISSUED_AT = f"Issued At: (?P{DATETIME})" 18 | EXPIRATION_TIME = f"(\\nExpiration Time: (?P{DATETIME}))?" 19 | NOT_BEFORE = f"(\\nNot Before: (?P{DATETIME}))?" 20 | REQUEST_ID = "(\\nRequest ID: (?P[-._~!$&'()*+,;=:@%a-zA-Z0-9]*))?" 21 | RESOURCES = f"(\\nResources:(?P(\\n- {URI})+))?" 22 | REGEX_MESSAGE = ( 23 | f"^{SCHEME}{DOMAIN}{ADDRESS}{STATEMENT}{URI_LINE}{VERSION}{CHAIN_ID}{NONCE}" 24 | f"{ISSUED_AT}{EXPIRATION_TIME}{NOT_BEFORE}{REQUEST_ID}{RESOURCES}$" 25 | ) 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "siwe" 3 | version = "4.4.0" 4 | description = "A Python implementation of Sign-In with Ethereum (EIP-4361)." 5 | license = "MIT OR Apache-2.0" 6 | authors = [ 7 | "Spruce Systems, Inc. ", 8 | "Payton Garland ", 9 | ] 10 | readme = "README.md" 11 | homepage = "https://login.xyz" 12 | repository = "https://github.com/spruceid/siwe-py" 13 | keywords = ["SIWE", "EIP-4361", "Sign-In with Ethereum", "Spruce ID"] 14 | 15 | [tool.poetry.dependencies] 16 | python = ">=3.8,<4.0" 17 | abnf = ">=2.2,<3" 18 | web3 = ">=7.3.0,<8" 19 | eth-account = ">=0.13.1,<0.14" 20 | eth-typing = ">=3.4.0" # peer of eth-account/web3 21 | eth-utils = ">=2.2.0" # peer of eth-account/web3 22 | pydantic = ">=2.7,<3" 23 | pydantic-core = "^2" # peer of pydantic 24 | typing-extensions = "^4" # peer of pydantic 25 | 26 | [tool.poetry.dev-dependencies] 27 | pytest = ">=8.1" 28 | pyhumps = ">=3.8" 29 | ruff = ">=0.4" 30 | deptry = ">=0.16" 31 | 32 | [build-system] 33 | requires = ["poetry-core>=1.0.0"] 34 | build-backend = "poetry.core.masonry.api" 35 | 36 | [tool.poetry.urls] 37 | "EIP" = "https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4361.md" 38 | "Discord" = "https://discord.gg/Sf9tSFzrnt" 39 | 40 | [tool.pyright] 41 | typeCheckingMode = "basic" 42 | 43 | [tool.ruff] 44 | lint.select = [ 45 | "E", # pycodestyle errors 46 | "W", # pycodestyle warnings 47 | "F", # pyflakes 48 | "I", # isort 49 | "C", # flake8-comprehensions 50 | "B", # flake8-bugbear 51 | "RUF", # ruff specific 52 | "PT", # flake8-pytest-style 53 | "SIM", # flake8-simplify 54 | "D", # pydocstyle 55 | "RUF", # ruff-specific rules 56 | ] 57 | 58 | include = ["siwe/**"] 59 | lint.ignore = ["D203", "D213"] 60 | -------------------------------------------------------------------------------- /siwe/grammars/eip4361.py: -------------------------------------------------------------------------------- 1 | """Top-level ABNF definition.""" 2 | 3 | from typing import ClassVar, List 4 | 5 | from abnf.grammars import rfc3986 6 | from abnf.grammars.misc import load_grammar_rules 7 | from abnf.parser import Rule as _Rule 8 | 9 | from . import rfc3339, rfc5234 10 | 11 | 12 | @load_grammar_rules( 13 | [ 14 | # RFC 3986 15 | ("URI", rfc3986.Rule("URI")), 16 | ("authority", rfc3986.Rule("authority")), 17 | ("scheme", rfc3986.Rule("scheme")), 18 | ("reserved", rfc3986.Rule("reserved")), 19 | ("unreserved", rfc3986.Rule("unreserved")), 20 | ("reserved", rfc3986.Rule("reserved")), 21 | ("pchar", rfc3986.Rule("pchar")), 22 | # RFC 5234 23 | ("LF", rfc5234.Rule("LF")), 24 | ("HEXDIG", rfc5234.Rule("HEXDIG")), 25 | ("ALPHA", rfc5234.Rule("ALPHA")), 26 | ("DIGIT", rfc5234.Rule("DIGIT")), 27 | # RFC 3339 28 | ("date-time", rfc3339.Rule("date-time")), 29 | ] 30 | ) 31 | class Rule(_Rule): 32 | """Rules from EIP-4361.""" 33 | 34 | grammar: ClassVar[List] = [ 35 | 'sign-in-with-ethereum = [ scheme "://" ] domain %s" wants you to sign in with ' 36 | 'your Ethereum account:" LF address LF LF [ statement LF ] LF %s"URI: " uri LF ' 37 | '%s"Version: " version LF %s"Chain ID: " chain-id LF %s"Nonce: " nonce LF %s"' 38 | 'Issued At: " issued-at [ LF %s"Expiration Time: " expiration-time ] [ LF %s"' 39 | 'Not Before: " not-before ] [ LF %s"Request ID: " request-id ] [ LF %s"' 40 | 'Resources:" resources ]', 41 | "domain = authority", 42 | 'address = "0x" 40HEXDIG', 43 | 'statement = 1*( reserved / unreserved / " " )', 44 | "uri = URI", 45 | 'version = "1"', 46 | "nonce = 8*( ALPHA / DIGIT )", 47 | "issued-at = date-time", 48 | "expiration-time = date-time", 49 | "not-before = date-time", 50 | "request-id = *pchar", 51 | "chain-id = 1*DIGIT", 52 | "resources = *( LF resource )", 53 | 'resource = "- " URI', 54 | ] 55 | -------------------------------------------------------------------------------- /siwe/parsed.py: -------------------------------------------------------------------------------- 1 | """SIWE message parsers.""" 2 | 3 | import re 4 | 5 | import abnf 6 | 7 | from .defs import REGEX_MESSAGE 8 | from .grammars import eip4361 9 | 10 | 11 | class RegExpParsedMessage: 12 | """Regex parsed SIWE message.""" 13 | 14 | def __init__(self, message: str): 15 | """Parse a SIWE message.""" 16 | expr = re.compile(REGEX_MESSAGE) 17 | match = re.match(REGEX_MESSAGE, message) 18 | 19 | if not match: 20 | raise ValueError("Message did not match the regular expression.") 21 | 22 | self.match = match 23 | self.scheme = match.group(expr.groupindex["scheme"]) 24 | self.domain = match.group(expr.groupindex["domain"]) 25 | self.address = match.group(expr.groupindex["address"]) 26 | self.statement = match.group(expr.groupindex["statement"]) 27 | self.uri = match.group(expr.groupindex["uri"]) 28 | self.version = match.group(expr.groupindex["version"]) 29 | self.nonce = match.group(expr.groupindex["nonce"]) 30 | self.chain_id = match.group(expr.groupindex["chainId"]) 31 | self.issued_at = match.group(expr.groupindex["issuedAt"]) 32 | self.expiration_time = match.group(expr.groupindex["expirationTime"]) 33 | self.not_before = match.group(expr.groupindex["notBefore"]) 34 | self.request_id = match.group(expr.groupindex["requestId"]) 35 | self.resources = match.group(expr.groupindex["resources"]) 36 | if self.resources: 37 | self.resources = self.resources.split("\n- ")[1:] 38 | 39 | 40 | class ABNFParsedMessage: 41 | """ABNF parsed SIWE message.""" 42 | 43 | def __init__(self, message: str): 44 | """Parse a SIWE message.""" 45 | parser = eip4361.Rule("sign-in-with-ethereum") 46 | try: 47 | node = parser.parse_all(message) 48 | except abnf.ParseError as e: 49 | raise ValueError from e 50 | 51 | for child in node.children: 52 | if child.name in [ 53 | "scheme", 54 | "domain", 55 | "address", 56 | "statement", 57 | "uri", 58 | "version", 59 | "nonce", 60 | "chain-id", 61 | "issued-at", 62 | "expiration-time", 63 | "not-before", 64 | "request-id", 65 | "resources", 66 | ]: 67 | setattr(self, child.name.replace("-", "_"), child.value) 68 | 69 | if child.name == "resources": 70 | resources = [] 71 | for resource in child.children: 72 | resources.extend( 73 | [r.value for r in resource.children if r.name == "uri"] 74 | ) 75 | self.resources = resources 76 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '37 2 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sign-In with Ethereum 2 | 3 | This package provides a Python implementation of EIP-4361: Sign In With Ethereum. 4 | 5 | ## Installation 6 | 7 | SIWE can be easily installed in any Python project with pip: 8 | 9 | ```bash 10 | pip install siwe 11 | ``` 12 | 13 | ## Usage 14 | 15 | SIWE provides a `SiweMessage` class which implements EIP-4361. 16 | 17 | ### Parsing a SIWE Message 18 | 19 | Parsing is done by initializing a `SiweMessage` object with an EIP-4361 formatted string: 20 | 21 | ```python 22 | from siwe import SiweMessage 23 | message = SiweMessage.from_message(message=eip_4361_string) 24 | ``` 25 | 26 | Or to initialize a `SiweMessage` as a `pydantic.BaseModel` right away: 27 | 28 | ```python 29 | message = SiweMessage(domain="login.xyz", address="0x1234...", ...) 30 | ``` 31 | 32 | ### Verifying and Authenticating a SIWE Message 33 | 34 | Verification and authentication is performed via EIP-191, using the `address` field of the `SiweMessage` as the expected signer. The validate method checks message structural integrity, signature address validity, and time-based validity attributes. 35 | 36 | ```python 37 | try: 38 | message.verify(signature="0x...") 39 | # You can also specify other checks (e.g. the nonce or domain expected). 40 | except siwe.ValidationError: 41 | # Invalid 42 | ``` 43 | 44 | ### Serialization of a SIWE Message 45 | 46 | `SiweMessage` instances can also be serialized as their EIP-4361 string representations via the `prepare_message` method: 47 | 48 | ```python 49 | print(message.prepare_message()) 50 | ``` 51 | 52 | ## Example 53 | 54 | Parsing and verifying a `SiweMessage` is easy: 55 | 56 | ```python 57 | try: 58 | message: SiweMessage = SiweMessage(message=eip_4361_string) 59 | message.verify(signature, nonce="abcdef", domain="example.com"): 60 | except siwe.ValueError: 61 | # Invalid message 62 | print("Authentication attempt rejected.") 63 | except siwe.ExpiredMessage: 64 | print("Authentication attempt rejected.") 65 | except siwe.DomainMismatch: 66 | print("Authentication attempt rejected.") 67 | except siwe.NonceMismatch: 68 | print("Authentication attempt rejected.") 69 | except siwe.MalformedSession as e: 70 | # e.missing_fields contains the missing information needed for validation 71 | print("Authentication attempt rejected.") 72 | except siwe.InvalidSignature: 73 | print("Authentication attempt rejected.") 74 | 75 | # Message has been verified. Authentication complete. Continue with authorization/other. 76 | ``` 77 | 78 | ## Testing 79 | 80 | ```bash 81 | poetry install 82 | git submodule update --init 83 | poetry run pytest 84 | ``` 85 | 86 | ## See Also 87 | 88 | - [Sign-In with Ethereum: TypeScript](https://github.com/spruceid/siwe) 89 | - [Example SIWE application: login.xyz](https://login.xyz) 90 | - [EIP-4361 Specification Draft](https://eips.ethereum.org/EIPS/eip-4361) 91 | - [EIP-191 Specification](https://eips.ethereum.org/EIPS/eip-191) 92 | 93 | ## Disclaimer 94 | 95 | Our Python library for Sign-In with Ethereum has not yet undergone a formal 96 | security audit. We welcome continued feedback on the usability, architecture, 97 | and security of this implementation. 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | tests/siwe 155 | -------------------------------------------------------------------------------- /tests/test_siwe.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | from eth_account import Account, messages 6 | from humps import decamelize 7 | from web3 import HTTPProvider 8 | from pydantic import ValidationError 9 | 10 | from siwe.siwe import SiweMessage, VerificationError, datetime_from_iso8601_string 11 | 12 | BASE_TESTS = "tests/siwe/test/" 13 | with open(BASE_TESTS + "parsing_positive.json", "r") as f: 14 | parsing_positive = decamelize(json.load(fp=f)) 15 | with open(BASE_TESTS + "parsing_negative.json", "r") as f: 16 | parsing_negative = decamelize(json.load(fp=f)) 17 | with open(BASE_TESTS + "parsing_negative_objects.json", "r") as f: 18 | parsing_negative_objects = decamelize(json.load(fp=f)) 19 | with open(BASE_TESTS + "verification_negative.json", "r") as f: 20 | verification_negative = decamelize(json.load(fp=f)) 21 | with open(BASE_TESTS + "verification_positive.json", "r") as f: 22 | verification_positive = decamelize(json.load(fp=f)) 23 | with open(BASE_TESTS + "eip1271.json", "r") as f: 24 | verification_eip1271 = decamelize(json.load(fp=f)) 25 | 26 | endpoint_uri = "https://cloudflare-eth.com" 27 | try: 28 | uri = os.environ["WEB3_PROVIDER_URI"] 29 | if uri != "": 30 | endpoint_uri = uri 31 | except KeyError: 32 | pass 33 | sepolia_endpoint_uri = "https://rpc.sepolia.org" 34 | try: 35 | uri = os.environ["WEB3_PROVIDER_URI_SEPOLIA"] 36 | if uri != "": 37 | sepolia_endpoint_uri = uri 38 | except KeyError: 39 | pass 40 | 41 | 42 | class TestMessageParsing: 43 | @pytest.mark.parametrize("abnf", [True, False]) 44 | @pytest.mark.parametrize( 45 | "test_name,test", 46 | [(test_name, test) for test_name, test in parsing_positive.items()], 47 | ) 48 | def test_valid_message(self, abnf, test_name, test): 49 | siwe_message = SiweMessage.from_message(message=test["message"], abnf=abnf) 50 | for key, value in test["fields"].items(): 51 | v = getattr(siwe_message, key) 52 | if not (isinstance(v, int) or isinstance(v, list) or v is None): 53 | v = str(v) 54 | assert v == value 55 | 56 | @pytest.mark.parametrize("abnf", [True, False]) 57 | @pytest.mark.parametrize( 58 | "test_name,test", 59 | [(test_name, test) for test_name, test in parsing_negative.items()], 60 | ) 61 | def test_invalid_message(self, abnf, test_name, test): 62 | with pytest.raises(ValueError): 63 | SiweMessage.from_message(message=test, abnf=abnf) 64 | 65 | @pytest.mark.parametrize( 66 | "test_name,test", 67 | [(test_name, test) for test_name, test in parsing_negative_objects.items()], 68 | ) 69 | def test_invalid_object_message(self, test_name, test): 70 | with pytest.raises(ValidationError): 71 | SiweMessage(**test) 72 | 73 | 74 | class TestMessageGeneration: 75 | @pytest.mark.parametrize( 76 | "test_name,test", 77 | [(test_name, test) for test_name, test in parsing_positive.items()], 78 | ) 79 | def test_valid_message(self, test_name, test): 80 | siwe_message = SiweMessage(**test["fields"]) 81 | assert siwe_message.prepare_message() == test["message"] 82 | 83 | 84 | class TestMessageVerification: 85 | @pytest.mark.parametrize( 86 | "test_name,test", 87 | [(test_name, test) for test_name, test in verification_positive.items()], 88 | ) 89 | def test_valid_message(self, test_name, test): 90 | siwe_message = SiweMessage(**test) 91 | timestamp = ( 92 | datetime_from_iso8601_string(test["time"]) if "time" in test else None 93 | ) 94 | siwe_message.verify(test["signature"], timestamp=timestamp) 95 | 96 | @pytest.mark.parametrize( 97 | "test_name,test", 98 | [(test_name, test) for test_name, test in verification_eip1271.items()], 99 | ) 100 | def test_eip1271_message(self, test_name, test): 101 | if test_name == "loopring": 102 | pytest.skip() 103 | provider = HTTPProvider(endpoint_uri=endpoint_uri) 104 | siwe_message = SiweMessage.from_message(message=test["message"]) 105 | siwe_message.verify(test["signature"], provider=provider) 106 | 107 | def test_safe_wallet_message(self): 108 | message = "localhost:3000 wants you to sign in with your Ethereum account:\n0x54D97AEa047838CAC7A9C3e452951647f12a440c\n\nPlease sign in to verify your ownership of this wallet\n\nURI: http://localhost:3000\nVersion: 1\nChain ID: 11155111\nNonce: gDj8rv7VVxN\nIssued At: 2024-10-10T08:34:03.152Z\nExpiration Time: 2024-10-13T08:34:03.249112Z" 109 | signature = "0x" 110 | # Use a Sepolia RPC node since the signature is generated on Sepolia testnet 111 | # instead of mainnet like other EIP-1271 tests. 112 | provider = HTTPProvider(endpoint_uri=sepolia_endpoint_uri) 113 | siwe_message = SiweMessage.from_message(message=message) 114 | siwe_message.verify(signature, provider=provider) 115 | 116 | @pytest.mark.parametrize( 117 | "provider", [HTTPProvider(endpoint_uri=endpoint_uri), None] 118 | ) 119 | @pytest.mark.parametrize( 120 | "test_name,test", 121 | [(test_name, test) for test_name, test in verification_negative.items()], 122 | ) 123 | def test_invalid_message(self, provider, test_name, test): 124 | if test_name in [ 125 | "invalidexpiration_time", 126 | "invalidnot_before", 127 | "invalidissued_at", 128 | ]: 129 | with pytest.raises(ValidationError): 130 | siwe_message = SiweMessage(**test) 131 | return 132 | siwe_message = SiweMessage(**test) 133 | domain_binding = test.get("domain_binding") 134 | match_nonce = test.get("match_nonce") 135 | timestamp = ( 136 | datetime_from_iso8601_string(test["time"]) if "time" in test else None 137 | ) 138 | with pytest.raises(VerificationError): 139 | siwe_message.verify( 140 | test.get("signature"), 141 | domain=domain_binding, 142 | nonce=match_nonce, 143 | timestamp=timestamp, 144 | provider=provider, 145 | ) 146 | 147 | 148 | class TestMessageRoundTrip: 149 | account = Account.create() 150 | 151 | @pytest.mark.parametrize( 152 | "test_name,test", 153 | [(test_name, test) for test_name, test in parsing_positive.items()], 154 | ) 155 | def test_message_round_trip(self, test_name, test): 156 | message = SiweMessage(**test["fields"]) 157 | message.address = self.account.address 158 | signature = self.account.sign_message( 159 | messages.encode_defunct(text=message.prepare_message()) 160 | ).signature 161 | message.verify(signature) 162 | 163 | def test_schema_generation(self): 164 | # NOTE: Needed so that FastAPI/OpenAPI json schema works 165 | SiweMessage.model_json_schema() 166 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /siwe/siwe.py: -------------------------------------------------------------------------------- 1 | """Main module for SIWE messages construction and validation.""" 2 | 3 | import secrets 4 | import string 5 | from datetime import datetime, timezone 6 | from enum import Enum 7 | from typing import Iterable, List, Optional 8 | 9 | import eth_utils 10 | from eth_account.messages import SignableMessage, _hash_eip191_message, encode_defunct 11 | from eth_typing import ChecksumAddress 12 | from pydantic import ( 13 | AnyUrl, 14 | BaseModel, 15 | BeforeValidator, 16 | Field, 17 | NonNegativeInt, 18 | TypeAdapter, 19 | field_validator, 20 | ) 21 | from pydantic_core import core_schema 22 | from typing_extensions import Annotated 23 | from web3 import HTTPProvider, Web3 24 | from web3.exceptions import BadFunctionCallOutput, ContractLogicError 25 | 26 | from .parsed import ABNFParsedMessage, RegExpParsedMessage 27 | 28 | EIP1271_CONTRACT_ABI = [ 29 | { 30 | "inputs": [ 31 | {"internalType": "bytes32", "name": " _message", "type": "bytes32"}, 32 | {"internalType": "bytes", "name": " _signature", "type": "bytes"}, 33 | ], 34 | "name": "isValidSignature", 35 | "outputs": [{"internalType": "bytes4", "name": "", "type": "bytes4"}], 36 | "stateMutability": "view", 37 | "type": "function", 38 | } 39 | ] 40 | EIP1271_MAGICVALUE = "1626ba7e" 41 | 42 | 43 | _ALPHANUMERICS = string.ascii_letters + string.digits 44 | 45 | 46 | def generate_nonce() -> str: 47 | """Generate a cryptographically sound nonce.""" 48 | return "".join(secrets.choice(_ALPHANUMERICS) for _ in range(11)) 49 | 50 | 51 | class VerificationError(Exception): 52 | """Top-level validation and verification exception.""" 53 | 54 | pass 55 | 56 | 57 | class InvalidSignature(VerificationError): 58 | """The signature does not match the message.""" 59 | 60 | pass 61 | 62 | 63 | class ExpiredMessage(VerificationError): 64 | """The message is not valid any more.""" 65 | 66 | pass 67 | 68 | 69 | class NotYetValidMessage(VerificationError): 70 | """The message is not yet valid.""" 71 | 72 | pass 73 | 74 | 75 | class SchemeMismatch(VerificationError): 76 | """The message does not contain the expected scheme.""" 77 | 78 | pass 79 | 80 | 81 | class DomainMismatch(VerificationError): 82 | """The message does not contain the expected domain.""" 83 | 84 | pass 85 | 86 | 87 | class NonceMismatch(VerificationError): 88 | """The message does not contain the expected nonce.""" 89 | 90 | pass 91 | 92 | 93 | class MalformedSession(VerificationError): 94 | """A message could not be constructed as it is missing certain fields.""" 95 | 96 | def __init__(self, missing_fields: Iterable[str]): 97 | """Construct the exception with the missing fields.""" 98 | self.missing_fields = missing_fields 99 | 100 | 101 | class VersionEnum(str, Enum): 102 | """EIP-4361 versions.""" 103 | 104 | one = "1" 105 | 106 | def __str__(self): 107 | """EIP-4361 representation of the enum field.""" 108 | return self.value 109 | 110 | 111 | # NOTE: Do not override the original uri string, just do validation 112 | # https://github.com/pydantic/pydantic/issues/7186#issuecomment-1874338146 113 | AnyUrlTypeAdapter = TypeAdapter(AnyUrl) 114 | AnyUrlStr = Annotated[ 115 | str, 116 | BeforeValidator(lambda value: AnyUrlTypeAdapter.validate_python(value) and value), 117 | ] 118 | 119 | 120 | def datetime_from_iso8601_string(val: str) -> datetime: 121 | """Convert an ISO-8601 Datetime string into a valid datetime object.""" 122 | return datetime.fromisoformat(val.replace(".000Z", "Z").replace("Z", "+00:00")) 123 | 124 | 125 | # NOTE: Do not override the original string, but ensure we do timestamp validation 126 | class ISO8601Datetime(str): 127 | """A special field class used to denote ISO-8601 Datetime strings.""" 128 | 129 | def __init__(self, val: str): 130 | """Validate ISO-8601 string.""" 131 | # NOTE: `self` is already this class, we are just running our validation here 132 | datetime_from_iso8601_string(val) 133 | 134 | @classmethod 135 | def __get_pydantic_core_schema__(cls, source, handler): 136 | """Create valid pydantic schema object for this type.""" 137 | return core_schema.no_info_after_validator_function( 138 | cls, core_schema.str_schema() 139 | ) 140 | 141 | @classmethod 142 | def from_datetime( 143 | cls, dt: datetime, timespec: str = "milliseconds" 144 | ) -> "ISO8601Datetime": 145 | """Create an ISO-8601 formatted string from a datetime object.""" 146 | # NOTE: Only a useful classmethod for creating these objects 147 | return ISO8601Datetime( 148 | dt.astimezone(tz=timezone.utc) 149 | .isoformat(timespec=timespec) 150 | .replace("+00:00", "Z") 151 | ) 152 | 153 | @property 154 | def _datetime(self) -> datetime: 155 | return datetime_from_iso8601_string(self) 156 | 157 | 158 | def utc_now() -> datetime: 159 | """Get the current datetime as UTC timezone.""" 160 | return datetime.now(tz=timezone.utc) 161 | 162 | 163 | class SiweMessage(BaseModel): 164 | """A Sign-in with Ethereum (EIP-4361) message.""" 165 | 166 | scheme: Optional[str] = None 167 | """RFC 3986 URI scheme for the authority that is requesting the signing.""" 168 | domain: str = Field(pattern="^[^/?#]+$") 169 | """RFC 4501 dns authority that is requesting the signing.""" 170 | address: ChecksumAddress 171 | """Ethereum address performing the signing conformant to capitalization encoded 172 | checksum specified in EIP-55 where applicable. 173 | """ 174 | uri: AnyUrlStr 175 | """RFC 3986 URI referring to the resource that is the subject of the signing.""" 176 | version: VersionEnum 177 | """Current version of the message.""" 178 | chain_id: NonNegativeInt 179 | """EIP-155 Chain ID to which the session is bound, and the network where Contract 180 | Accounts must be resolved. 181 | """ 182 | issued_at: ISO8601Datetime 183 | """ISO 8601 datetime string of the current time.""" 184 | nonce: str = Field(min_length=8) 185 | """Randomized token used to prevent replay attacks, at least 8 alphanumeric 186 | characters. Use generate_nonce() to generate a secure nonce and store it for 187 | verification later. 188 | """ 189 | statement: Optional[str] = Field(None, pattern="^[^\n]+$") 190 | """Human-readable ASCII assertion that the user will sign, and it must not contain 191 | `\n`. 192 | """ 193 | expiration_time: Optional[ISO8601Datetime] = None 194 | """ISO 8601 datetime string that, if present, indicates when the signed 195 | authentication message is no longer valid. 196 | """ 197 | not_before: Optional[ISO8601Datetime] = None 198 | """ISO 8601 datetime string that, if present, indicates when the signed 199 | authentication message will become valid. 200 | """ 201 | request_id: Optional[str] = None 202 | """System-specific identifier that may be used to uniquely refer to the sign-in 203 | request. 204 | """ 205 | resources: Optional[List[AnyUrlStr]] = None 206 | """List of information or references to information the user wishes to have resolved 207 | as part of authentication by the relying party. They are expressed as RFC 3986 URIs 208 | separated by `\n- `. 209 | """ 210 | 211 | @field_validator("address") 212 | @classmethod 213 | def address_is_checksum_address(cls, v: str) -> str: 214 | """Validate the address follows EIP-55 formatting.""" 215 | if not Web3.is_checksum_address(v): 216 | raise ValueError("Message `address` must be in EIP-55 format") 217 | return v 218 | 219 | @classmethod 220 | def from_message(cls, message: str, abnf: bool = True) -> "SiweMessage": 221 | """Parse a message in its EIP-4361 format.""" 222 | if abnf: 223 | parsed_message = ABNFParsedMessage(message=message) 224 | else: 225 | parsed_message = RegExpParsedMessage(message=message) 226 | 227 | # TODO There is some redundancy in the checks when deserialising a message. 228 | return cls(**parsed_message.__dict__) 229 | 230 | def prepare_message(self) -> str: 231 | """Serialize to the EIP-4361 format for signing. 232 | 233 | It can then be passed to an EIP-191 signing function. 234 | 235 | :return: EIP-4361 formatted message, ready for EIP-191 signing. 236 | """ 237 | header = f"{self.domain} wants you to sign in with your Ethereum account:" 238 | if self.scheme: 239 | header = f"{self.scheme}://{header}" 240 | 241 | uri_field = f"URI: {self.uri}" 242 | 243 | prefix = "\n".join([header, self.address]) 244 | 245 | version_field = f"Version: {self.version}" 246 | 247 | chain_field = f"Chain ID: {self.chain_id or 1}" 248 | 249 | nonce_field = f"Nonce: {self.nonce}" 250 | 251 | suffix_array = [uri_field, version_field, chain_field, nonce_field] 252 | 253 | issued_at_field = f"Issued At: {self.issued_at}" 254 | suffix_array.append(issued_at_field) 255 | 256 | if self.expiration_time: 257 | expiration_time_field = f"Expiration Time: {self.expiration_time}" 258 | suffix_array.append(expiration_time_field) 259 | 260 | if self.not_before: 261 | not_before_field = f"Not Before: {self.not_before}" 262 | suffix_array.append(not_before_field) 263 | 264 | if self.request_id: 265 | request_id_field = f"Request ID: {self.request_id}" 266 | suffix_array.append(request_id_field) 267 | 268 | if self.resources: 269 | resources_field = "\n".join( 270 | ["Resources:"] + [f"- {resource}" for resource in self.resources] 271 | ) 272 | suffix_array.append(resources_field) 273 | 274 | suffix = "\n".join(suffix_array) 275 | 276 | if self.statement: 277 | prefix = "\n\n".join([prefix, self.statement]) 278 | else: 279 | prefix += "\n" 280 | 281 | return "\n\n".join([prefix, suffix]) 282 | 283 | def verify( 284 | self, 285 | signature: str, 286 | *, 287 | scheme: Optional[str] = None, 288 | domain: Optional[str] = None, 289 | nonce: Optional[str] = None, 290 | timestamp: Optional[datetime] = None, 291 | provider: Optional[HTTPProvider] = None, 292 | ) -> None: 293 | """Verify the validity of the message and its signature. 294 | 295 | :param signature: Signature to check against the current message. 296 | :param domain: Domain expected to be in the current message. 297 | :param nonce: Nonce expected to be in the current message. 298 | :param timestamp: Timestamp used to verify the expiry date and other dates 299 | fields. Uses the current time by default. 300 | :param provider: A Web3 provider able to perform a contract check, this is 301 | required if support for Smart Contract Wallets that implement EIP-1271 is 302 | needed. It is also configurable with the environment variable 303 | `WEB3_HTTP_PROVIDER_URI` 304 | :return: None if the message is valid and raises an exception otherwise 305 | """ 306 | message = encode_defunct(text=self.prepare_message()) 307 | w3 = Web3(provider=provider) 308 | 309 | if scheme is not None and self.scheme != scheme: 310 | raise SchemeMismatch() 311 | if domain is not None and self.domain != domain: 312 | raise DomainMismatch() 313 | if nonce is not None and self.nonce != nonce: 314 | raise NonceMismatch() 315 | 316 | verification_time = utc_now() if timestamp is None else timestamp 317 | if ( 318 | self.expiration_time is not None 319 | and verification_time >= self.expiration_time._datetime 320 | ): 321 | raise ExpiredMessage() 322 | if ( 323 | self.not_before is not None 324 | and verification_time <= self.not_before._datetime 325 | ): 326 | raise NotYetValidMessage() 327 | 328 | try: 329 | address = w3.eth.account.recover_message(message, signature=signature) 330 | except (ValueError, IndexError): 331 | address = None 332 | except eth_utils.exceptions.ValidationError: 333 | raise InvalidSignature from None 334 | 335 | if address != self.address and ( 336 | provider is None 337 | or not check_contract_wallet_signature( 338 | address=self.address, message=message, signature=signature, w3=w3 339 | ) 340 | ): 341 | raise InvalidSignature() 342 | 343 | 344 | def check_contract_wallet_signature( 345 | address: ChecksumAddress, message: SignableMessage, signature: str, w3: Web3 346 | ) -> bool: 347 | """Call the EIP-1271 method for a Smart Contract wallet. 348 | 349 | :param address: The address of the contract 350 | :param message: The EIP-4361 formatted message 351 | :param signature: The EIP-1271 signature 352 | :param w3: A Web3 provider able to perform a contract check. 353 | :return: True if the signature is valid per EIP-1271. 354 | """ 355 | contract = w3.eth.contract(address=address, abi=EIP1271_CONTRACT_ABI) 356 | hash_ = _hash_eip191_message(message) 357 | try: 358 | # For message hashes stored on-chain for Safe wallets, the signatures 359 | # are always "0x" and should be passed in as-is. 360 | response = contract.caller.isValidSignature( 361 | hash_, signature if signature == "0x" else bytes.fromhex(signature[2:]) 362 | ) 363 | return response.hex() == EIP1271_MAGICVALUE 364 | except (BadFunctionCallOutput, ContractLogicError): 365 | return False 366 | --------------------------------------------------------------------------------