├── .dockerignore ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docker.yml │ ├── pipeline.yml │ ├── publish.yml │ ├── triage_incoming.yml │ └── triage_labelled.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── changelog.d ├── .gitignore └── 590.bugfix ├── docs ├── casefold_migration.md ├── replication.md └── templates.md ├── matrix-sydent.service ├── matrix_is_test ├── __init__.py ├── launcher.py ├── res │ └── is-test │ │ ├── invite_template.eml │ │ ├── invite_template.eml.j2 │ │ ├── verification_template.eml │ │ ├── verification_template.eml.j2 │ │ └── verify_response_template.html └── terms.yaml ├── poetry.lock ├── pyproject.toml ├── res ├── matrix-org │ ├── invite_template.eml │ ├── invite_template.eml.j2 │ ├── migration_template.eml.j2 │ ├── verification_template.eml │ ├── verification_template.eml.j2 │ └── verify_response_template.html ├── vector-im │ ├── invite_template.eml │ ├── invite_template.eml.j2 │ ├── migration_template.eml.j2 │ ├── verification_template.eml │ ├── verification_template.eml.j2 │ └── verify_response_template.html └── vector_verification_sample.txt ├── scripts-dev ├── check_newsfragment.sh └── lint.sh ├── scripts ├── casefold_db.py ├── generate-key └── sydent-bind ├── setup.cfg ├── stubs └── twisted │ ├── __init__.pyi │ ├── internet │ ├── __init__.pyi │ ├── endpoints.pyi │ ├── error.pyi │ └── ssl.pyi │ ├── names │ ├── __init__.pyi │ └── dns.pyi │ ├── python │ ├── __init__.pyi │ ├── failure.pyi │ └── log.pyi │ └── web │ ├── __init__.pyi │ ├── client.pyi │ ├── http.pyi │ ├── iweb.pyi │ ├── resource.pyi │ └── server.pyi ├── sydent ├── __init__.py ├── config │ ├── __init__.py │ ├── _base.py │ ├── crypto.py │ ├── database.py │ ├── email.py │ ├── exceptions.py │ ├── general.py │ ├── http.py │ └── sms.py ├── db │ ├── __init__.py │ ├── accounts.py │ ├── hashing_metadata.py │ ├── invite_tokens.py │ ├── invite_tokens.sql │ ├── peers.py │ ├── peers.sql │ ├── sqlitedb.py │ ├── terms.py │ ├── threepid_associations.py │ ├── threepid_associations.sql │ ├── threepid_validation.sql │ └── valsession.py ├── hs_federation │ ├── __init__.py │ ├── types.py │ └── verifier.py ├── http │ ├── __init__.py │ ├── auth.py │ ├── blacklisting_reactor.py │ ├── federation_tls_options.py │ ├── httpclient.py │ ├── httpcommon.py │ ├── httpsclient.py │ ├── httpserver.py │ ├── matrixfederationagent.py │ ├── servlets │ │ ├── __init__.py │ │ ├── accountservlet.py │ │ ├── authenticated_bind_threepid_servlet.py │ │ ├── authenticated_unbind_threepid_servlet.py │ │ ├── blindlysignstuffservlet.py │ │ ├── bulklookupservlet.py │ │ ├── cors_servlet.py │ │ ├── emailservlet.py │ │ ├── getvalidated3pidservlet.py │ │ ├── hashdetailsservlet.py │ │ ├── logoutservlet.py │ │ ├── lookupservlet.py │ │ ├── lookupv2servlet.py │ │ ├── msisdnservlet.py │ │ ├── pubkeyservlets.py │ │ ├── registerservlet.py │ │ ├── replication.py │ │ ├── store_invite_servlet.py │ │ ├── termsservlet.py │ │ ├── threepidbindservlet.py │ │ ├── threepidunbindservlet.py │ │ └── versions.py │ └── srvresolver.py ├── replication │ ├── __init__.py │ ├── peer.py │ └── pusher.py ├── sms │ ├── __init__.py │ ├── openmarket.py │ └── types.py ├── sydent.py ├── terms │ ├── __init__.py │ └── terms.py ├── threepid │ ├── __init__.py │ ├── bind.py │ └── signer.py ├── types.py ├── users │ ├── __init__.py │ ├── accounts.py │ └── tokens.py ├── util │ ├── __init__.py │ ├── emailutils.py │ ├── hash.py │ ├── ip_range.py │ ├── ratelimiter.py │ ├── stringutils.py │ ├── tokenutils.py │ └── ttlcache.py └── validators │ ├── __init__.py │ ├── common.py │ ├── emailvalidator.py │ └── msisdnvalidator.py ├── terms.sample.yaml └── tests ├── __init__.py ├── test_auth.py ├── test_blacklisting.py ├── test_casefold_migration.py ├── test_email.py ├── test_invites.py ├── test_jinja_templates.py ├── test_msisdn.py ├── test_ratelimiter.py ├── test_register.py ├── test_replication.py ├── test_start.py ├── test_store_invite.py ├── test_threepidunbind.py ├── test_util.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/ 3 | .vscode/ 4 | 5 | sydent.conf 6 | sydent.db 7 | .git 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Automatically request reviews from the synapse-core team when a pull request comes in. 2 | * @matrix-org/synapse-core 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Pull Request Checklist 2 | 3 | * [ ] Pull request includes a [changelog file](https://github.com/matrix-org/sydent/blob/main/CONTRIBUTING.md#changelog). The entry should: 4 | - Be a short description of your change which makes sense to users. "Fix a bug that prevented receiving messages from other servers." instead of "Move X method from `EventStore` to `EventWorkerStore`.". 5 | - Include 'Contributed by *Your Name*.' or 'Contributed by @*your-github-username*.' — unless you would prefer not to be credited in the changelog. 6 | - Use markdown where necessary, mostly for `code blocks`. 7 | - End with either a period (.) or an exclamation mark (!). 8 | - Start with a capital letter. 9 | * [ ] Pull request includes a [sign off](https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.md#sign-off) 10 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | 8 | env: 9 | PLATFORMS: "linux/amd64,linux/arm64" 10 | 11 | jobs: 12 | build: 13 | name: Build and publish images 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: docker/setup-qemu-action@v2 18 | with: 19 | platforms: ${{ env.PLATFORMS }} 20 | - uses: docker/setup-buildx-action@v2 21 | - uses: docker/login-action@v2 22 | with: 23 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 24 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 25 | 26 | - name: Build main Sydent image 27 | if: github.ref_name == 'main' 28 | uses: docker/build-push-action@v4 29 | with: 30 | cache-from: type=gha 31 | cache-to: type=gha,mode=max 32 | context: . 33 | platforms: ${{ env.PLATFORMS }} 34 | push: true 35 | tags: | 36 | matrixdotorg/sydent:main 37 | 38 | - name: Build release Sydent image 39 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 40 | uses: docker/build-push-action@v4 41 | with: 42 | cache-from: type=gha 43 | cache-to: type=gha,mode=max 44 | context: . 45 | platforms: ${{ env.PLATFORMS }} 46 | push: true 47 | tags: | 48 | matrixdotorg/sydent:latest 49 | matrixdotorg/sydent:${{ github.ref_name }} 50 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check-newsfile: 14 | name: Check PR has a changelog 15 | if: ${{ (github.base_ref == 'main' || contains(github.base_ref, 'release-')) && github.actor != 'dependabot[bot]' }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | ref: ${{github.event.pull_request.head.sha}} 22 | - uses: actions/setup-python@v2 23 | with: 24 | python-version: "3.11" 25 | - run: python -m pip install towncrier 26 | - run: "scripts-dev/check_newsfragment.sh ${{ github.event.number }}" 27 | 28 | checks: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | 33 | - uses: matrix-org/setup-python-poetry@v1 34 | with: 35 | python-version: 3.11 36 | install-project: false 37 | 38 | - name: Import order (isort) 39 | run: poetry run isort --check --diff . 40 | 41 | - name: Code style (black) 42 | run: poetry run black --check --diff . 43 | 44 | - name: Semantic checks (ruff) 45 | # --quiet suppresses the update check. 46 | run: poetry run ruff --quiet . 47 | 48 | - name: Restore/persist mypy's cache 49 | uses: actions/cache@v3 50 | with: 51 | path: | 52 | .mypy_cache 53 | key: mypy-cache-${{ github.context.sha }} 54 | restore-keys: mypy-cache- 55 | 56 | - name: Typechecking (mypy) 57 | run: poetry run mypy 58 | 59 | packaging: 60 | uses: "matrix-org/backend-meta/.github/workflows/packaging.yml@v1" 61 | 62 | docker: 63 | # Sanity check that we can build the x64 image 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v2 67 | - name: Set up Docker Buildx 68 | uses: docker/setup-buildx-action@v2 69 | 70 | - name: Build image 71 | uses: docker/build-push-action@v4 72 | with: 73 | cache-from: type=gha 74 | cache-to: type=gha,mode=max 75 | context: . 76 | push: false 77 | 78 | run-tests: 79 | name: Tests 80 | if: ${{ !cancelled() && !failure() }} # Allow previous steps to be skipped, but not fail 81 | needs: [check-newsfile, checks, packaging] 82 | runs-on: ubuntu-latest 83 | strategy: 84 | matrix: 85 | python-version: ['3.7', '3.11'] 86 | test-dir: ['tests', 'matrix_is_tester'] 87 | 88 | steps: 89 | - uses: actions/checkout@v2 90 | - uses: matrix-org/setup-python-poetry@v1 91 | with: 92 | python-version: ${{ matrix.python-version }} 93 | - run: poetry run trial ${{ matrix.test-dir }} 94 | 95 | # a job which runs once all the other jobs are complete, thus allowing PRs to 96 | # be merged. 97 | tests-done: 98 | if: ${{ always() }} 99 | needs: 100 | - check-newsfile 101 | - checks 102 | - packaging 103 | - run-tests 104 | runs-on: ubuntu-latest 105 | steps: 106 | - uses: matrix-org/done-action@v2 107 | with: 108 | needs: ${{ toJSON(needs) }} 109 | # The newsfile lint may be skipped on non PR builds or on dependabot builds 110 | skippable: 111 | check-newsfile 112 | 113 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Build and upload to PyPI" 2 | on: 3 | release: 4 | types: ["published"] 5 | 6 | jobs: 7 | upload: 8 | uses: "matrix-org/backend-meta/.github/workflows/release.yml@v1" 9 | with: 10 | repository: pypi 11 | secrets: 12 | PYPI_API_TOKEN: ${{ secrets.PYPI_ACCESS_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/triage_incoming.yml: -------------------------------------------------------------------------------- 1 | name: Move new issues into the issue triage board 2 | 3 | on: 4 | issues: 5 | types: [ opened ] 6 | 7 | jobs: 8 | add_new_issues: 9 | name: Add new issues to the triage board 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: octokit/graphql-action@v2.x 13 | id: add_to_project 14 | with: 15 | headers: '{"GraphQL-Features": "projects_next_graphql"}' 16 | query: | 17 | mutation add_to_project($projectid:ID!,$contentid:ID!) { 18 | addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { 19 | item { 20 | id 21 | } 22 | } 23 | } 24 | projectid: ${{ env.PROJECT_ID }} 25 | contentid: ${{ github.event.issue.node_id }} 26 | env: 27 | PROJECT_ID: "PVT_kwDOAIB0Bs4AFDdZ" 28 | GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/triage_labelled.yml: -------------------------------------------------------------------------------- 1 | name: Move labelled issues to correct projects 2 | 3 | on: 4 | issues: 5 | types: [ labeled ] 6 | 7 | jobs: 8 | move_needs_info: 9 | name: Move X-Needs-Info on the triage board 10 | runs-on: ubuntu-latest 11 | if: > 12 | contains(github.event.issue.labels.*.name, 'X-Needs-Info') 13 | steps: 14 | - uses: actions/add-to-project@main 15 | id: add_project 16 | with: 17 | project-url: "https://github.com/orgs/matrix-org/projects/67" 18 | github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} 19 | - name: Set status 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} 22 | run: | 23 | gh api graphql -f query=' 24 | mutation( 25 | $project: ID! 26 | $item: ID! 27 | $fieldid: ID! 28 | $columnid: String! 29 | ) { 30 | updateProjectV2ItemFieldValue( 31 | input: { 32 | projectId: $project 33 | itemId: $item 34 | fieldId: $fieldid 35 | value: { 36 | singleSelectOptionId: $columnid 37 | } 38 | } 39 | ) { 40 | projectV2Item { 41 | id 42 | } 43 | } 44 | }' -f project="PVT_kwDOAIB0Bs4AFDdZ" -f item=${{ steps.add_project.outputs.itemId }} -f fieldid="PVTSSF_lADOAIB0Bs4AFDdZzgC6ZA4" -f columnid=ba22e43c --silent 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Development files 2 | *.pyc 3 | .idea/ 4 | .vscode/ 5 | *.iml 6 | _trial_temp 7 | _trial_temp.lock 8 | *.egg 9 | *.egg-info 10 | .python-version 11 | .mypy_cache 12 | /env 13 | 14 | # for direnv users 15 | .envrc 16 | 17 | # Runtime files 18 | /sydent.conf 19 | /sydent.db 20 | /sydent.pid 21 | /matrix_is_test/sydent.stderr 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This Dockerfile installs Sydent from source, which is assumed to be in the current 2 | # working directory. The resulting image contains a single "sydent" user, and populates 3 | # their home area with "src" and "venv" directories. The entrypoint runs Sydent, 4 | # listening on port 8090. 5 | # 6 | # Users must provide a persistent volume available to the container as `/data`. This 7 | # will contain Sydent's configuration and database. A blank configuration and database 8 | # file is created the first time Sydent runs. 9 | 10 | # Step 1: install dependencies 11 | FROM docker.io/python:3.8-slim-bookworm as builder 12 | 13 | # Add user sydent 14 | RUN addgroup --system --gid 993 sydent \ 15 | && useradd -m --system --uid 993 -g sydent sydent 16 | USER sydent:sydent 17 | 18 | # Install poetry 19 | RUN pip install --user poetry==1.2.2 20 | 21 | # Copy source code and resources 22 | WORKDIR /home/sydent/src 23 | COPY --chown=sydent:sydent ["res", "res"] 24 | COPY --chown=sydent:sydent ["scripts", "scripts"] 25 | COPY --chown=sydent:sydent ["sydent", "sydent"] 26 | COPY --chown=sydent:sydent ["README.rst", "pyproject.toml", "poetry.lock", "./"] 27 | 28 | # Install dependencies 29 | RUN python -m poetry install -vv --no-dev --no-interaction --extras "prometheus sentry" 30 | 31 | # Record dependencies for posterity 32 | RUN python -m poetry export -o requirements.txt 33 | 34 | # Make the virtualenv accessible for the final image 35 | RUN ln -s $(python -m poetry env info -p) /home/sydent/venv 36 | 37 | # Nuke bytecode files to keep the final image slim. 38 | RUN find /home/sydent/venv -type f -name '*.pyc' -delete 39 | 40 | # Step 2: Create runtime image 41 | FROM docker.io/python:3.8-slim-bookworm 42 | 43 | # Add user sydent and create /data directory 44 | RUN addgroup --system --gid 993 sydent \ 45 | && useradd -m --system --uid 993 -g sydent sydent \ 46 | && mkdir /data \ 47 | && chown sydent:sydent /data 48 | 49 | # Copy sydent and the virtualenv 50 | COPY --from=builder ["/home/sydent/src", "/home/sydent/src"] 51 | COPY --from=builder ["/home/sydent/venv", "/home/sydent/venv"] 52 | 53 | ENV SYDENT_CONF=/data/sydent.conf 54 | ENV SYDENT_PID_FILE=/data/sydent.pid 55 | ENV SYDENT_DB_PATH=/data/sydent.db 56 | 57 | WORKDIR /home/sydent 58 | USER sydent:sydent 59 | VOLUME ["/data"] 60 | EXPOSE 8090/tcp 61 | CMD [ "venv/bin/python", "-m", "sydent.sydent" ] 62 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | graft res 3 | include scripts/generate-key 4 | include scripts/sydent-bind 5 | recursive-include sydent *.sql 6 | -------------------------------------------------------------------------------- /changelog.d/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /changelog.d/590.bugfix: -------------------------------------------------------------------------------- 1 | Prevent Sydent from overwriting user settings in the DEFAULT section upon startup. -------------------------------------------------------------------------------- /docs/casefold_migration.md: -------------------------------------------------------------------------------- 1 | # Migrating to case-insensitive email addresses 2 | 3 | **Note: the operation described in this documentation is only needed if your server was 4 | running a version of Sydent earlier than 2.4.0 at some point, and only needs to be run 5 | once. If the first version of Sydent you have set up is 2.4.0 or later, or if you have 6 | already run this operation, you don't need to do it again.** 7 | 8 | In the past, the Matrix specification would consider email addresses as case-sensitive. This means 9 | `alice@example.com` and `Alice@example.com` would be seen as two different email addresses 10 | which could each be associated with a different Matrix user ID. 11 | 12 | With [MSC2265](https://github.com/matrix-org/matrix-doc/pull/2265), the Matrix 13 | specification was updated so that email addresses are considered without any case sensitivity (so the two 14 | addresses mentioned in the previous paragraph would be considered as being one and the 15 | same). 16 | 17 | As of version 2.4.0, Sydent supports this change by processing each new association 18 | without case sensitivity. However, some data might remain in the database from earlier 19 | versions when Sydent would support multiple associations for a given email address (by 20 | using variations of the same address with a different case). This means some addresses in 21 | your identity server's database might not have been stored in a format that allows for 22 | case-insensitive processing, or might have duplicate associations. 23 | 24 | To correct this, Sydent 2.4.0 introduces a [script](https://github.com/matrix-org/sydent/blob/main/scripts/casefold_db.py) 25 | that inspects an identity server's database and fixes it to be compatible with this change: 26 | 27 | ``` 28 | Usage: /path/to/sydent/scripts/casefold_db.py [--no-email] [--dry-run] /path/to/sydent.conf 29 | 30 | Arguments: 31 | * --no-email: don't send out emails when deleting associations due to duplicates 32 | * --dry-run: don't update database rows and don't send out emails 33 | ``` 34 | 35 | If the script finds a duplicate (i.e. an email address with multiple associations), it 36 | keeps the most recent association and deletes the others. If one or more of the Matrix 37 | user IDs that are being dissociated don't match the one being kept, the script also sends an 38 | email to the address to inform the user of the dissocation. 39 | 40 | The default template for this email can be found [here](https://github.com/matrix-org/sydent/blob/main/res/matrix-org/migration_template.eml.j2) 41 | and can be overriden by configuring a custom template directory (by changing the 42 | `templates.path` configuration setting). The custom template must be named `migration_template.eml.j2` 43 | (or `migration_template.eml` if not using Jinja 2 syntax), and will be given the Matrix 44 | user ID being dissociated at render through the variable `mxid`. 45 | 46 | This script is safe to run whilst Sydent is running. 47 | 48 | If the script is not run, there may be associations in your database that can no 49 | longer be looked up and duplicate associations may be registered. 50 | -------------------------------------------------------------------------------- /docs/replication.md: -------------------------------------------------------------------------------- 1 | Intra-sydent replication 2 | ------------------------ 3 | 4 | Replication takes place over HTTPs connections, using server and client TLS 5 | certificates (currently each sydent instance can only be configured with a 6 | single certificate which is used as both a server and a client certificate). 7 | 8 | Replication peers are (currently) configured in the sqlite database; you 9 | need to add a row to both the `peers` and `peer_pubkeys` tables. 10 | 11 | The `name` / `peername` in these tables must match the `server_name` in the 12 | configuration of the peer, which is the name that peer will use to sign 13 | associations. 14 | 15 | Inbound replication connections are authenticated according to the Common Name 16 | in the client certificate, so that must also match the `server_name`. 17 | 18 | By default, that name is also used for outbound connections, but it is possible 19 | to override this by adding a setting to the config file such as: 20 | 21 | [peer.example.com] 22 | base_replication_url = https://internal-address.example.com:4434 23 | -------------------------------------------------------------------------------- /matrix-sydent.service: -------------------------------------------------------------------------------- 1 | # Example systemd configuration file for sydent. Copy to 2 | # /etc/systemd/system/matrix-sydent.service, update the paths if necessary, 3 | # then: 4 | # 5 | # systemctl enable matrix-sydent 6 | # systemctl start matrix-sydent 7 | 8 | 9 | [Unit] 10 | Description="Matrix identity server" 11 | 12 | [Service] 13 | WorkingDirectory=/opt/sydent 14 | ExecStart=/opt/sydent/env/bin/python -m sydent.sydent 15 | 16 | User=sydent 17 | Group=nogroup 18 | Restart=always 19 | 20 | [Install] 21 | WantedBy=default.target 22 | -------------------------------------------------------------------------------- /matrix_is_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/matrix_is_test/__init__.py -------------------------------------------------------------------------------- /matrix_is_test/launcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import shutil 17 | import tempfile 18 | import time 19 | from subprocess import Popen 20 | 21 | CFG_TEMPLATE = """ 22 | [http] 23 | clientapi.http.bind_address = localhost 24 | clientapi.http.port = {port} 25 | client_http_base = http://localhost:{port} 26 | federation.verifycerts = False 27 | 28 | [db] 29 | db.file = :memory: 30 | 31 | [general] 32 | server.name = test.local 33 | terms.path = {terms_path} 34 | templates.path = {testsubject_path}/res 35 | brand.default = is-test 36 | 37 | 38 | ip.whitelist = 127.0.0.1 39 | 40 | [email] 41 | email.tlsmode = 0 42 | email.invite.subject = %(sender_display_name)s has invited you to chat 43 | email.invite.subject_space = %(sender_display_name)s has invited you to a space 44 | email.smtphost = localhost 45 | email.from = Sydent Validation 46 | email.smtpport = 9925 47 | email.subject = Your Validation Token 48 | email.ratelimit_sender.burst = 100000 49 | email.ratelimit_sender.rate_hz = 100000 50 | """ 51 | 52 | 53 | class MatrixIsTestLauncher: 54 | def __init__(self, with_terms): 55 | self.with_terms = with_terms 56 | 57 | def launch(self): 58 | sydent_path = os.path.abspath( 59 | os.path.join( 60 | os.path.dirname(__file__), 61 | "..", 62 | ) 63 | ) 64 | testsubject_path = os.path.join( 65 | sydent_path, 66 | "matrix_is_test", 67 | ) 68 | terms_path = ( 69 | os.path.join(testsubject_path, "terms.yaml") if self.with_terms else "" 70 | ) 71 | port = 8099 if self.with_terms else 8098 72 | 73 | self.tmpdir = tempfile.mkdtemp(prefix="sydenttest") 74 | 75 | with open(os.path.join(self.tmpdir, "sydent.conf"), "w") as cfgfp: 76 | cfgfp.write( 77 | CFG_TEMPLATE.format( 78 | testsubject_path=testsubject_path, 79 | terms_path=terms_path, 80 | port=port, 81 | ) 82 | ) 83 | 84 | newEnv = os.environ.copy() 85 | newEnv.update( 86 | { 87 | "PYTHONPATH": sydent_path, 88 | } 89 | ) 90 | 91 | stderr_fp = open(os.path.join(testsubject_path, "sydent.stderr"), "w") 92 | 93 | pybin = os.getenv("SYDENT_PYTHON", "python") 94 | 95 | self.process = Popen( 96 | args=[pybin, "-m", "sydent.sydent"], 97 | cwd=self.tmpdir, 98 | env=newEnv, 99 | stderr=stderr_fp, 100 | ) 101 | # XXX: wait for startup in a sensible way 102 | time.sleep(2) 103 | 104 | self._baseUrl = "http://localhost:%d" % (port,) 105 | 106 | def tearDown(self): 107 | print("Stopping sydent...") 108 | self.process.terminate() 109 | shutil.rmtree(self.tmpdir) 110 | 111 | def get_base_url(self): 112 | return self._baseUrl 113 | -------------------------------------------------------------------------------- /matrix_is_test/res/is-test/invite_template.eml: -------------------------------------------------------------------------------- 1 | { 2 | "token": "%(token)s", 3 | "room_alias": "%(room_alias)s", 4 | "room_avatar_url": "%(room_avatar_url)s", 5 | "room_name": "%(room_name)s", 6 | "sender_display_name": "%(sender_display_name)s", 7 | "sender_avatar_url": "%(sender_avatar_url)s" 8 | } 9 | -------------------------------------------------------------------------------- /matrix_is_test/res/is-test/invite_template.eml.j2: -------------------------------------------------------------------------------- 1 | { 2 | "token": "{{ token }}", 3 | "room_alias": "{{ room_alias }}", 4 | "room_avatar_url": "{{ room_avatar_url }}", 5 | "room_name": "{{ room_name }}", 6 | "sender_display_name": "{{ sender_display_name }}", 7 | "sender_avatar_url": "{{ sender_avatar_url }}" 8 | } 9 | -------------------------------------------------------------------------------- /matrix_is_test/res/is-test/verification_template.eml: -------------------------------------------------------------------------------- 1 | <<<%(token)s>>> 2 | -------------------------------------------------------------------------------- /matrix_is_test/res/is-test/verification_template.eml.j2: -------------------------------------------------------------------------------- 1 | <<<{{ token }}>>> 2 | -------------------------------------------------------------------------------- /matrix_is_test/res/is-test/verify_response_template.html: -------------------------------------------------------------------------------- 1 | matrix_is_tester:email_submit_get_response 2 | -------------------------------------------------------------------------------- /matrix_is_test/terms.yaml: -------------------------------------------------------------------------------- 1 | master_version: "someversion" 2 | docs: 3 | terms_of_service: 4 | version: "5.0" 5 | langs: 6 | en: 7 | name: "Terms of Service" 8 | url: "https://example.org/somewhere/terms-2.0-en.html" 9 | fr: 10 | name: "Conditions d'utilisation" 11 | url: "https://example.org/somewhere/terms-2.0-fr.html" 12 | privacy_policy: 13 | version: "1.2" 14 | langs: 15 | en: 16 | name: "Privacy Policy" 17 | url: "https://example.org/somewhere/privacy-1.2-en.html" 18 | fr: 19 | name: "Politique de confidentialité" 20 | url: "https://example.org/somewhere/privacy-1.2-fr.html" 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | package = "sydent" 3 | filename = "CHANGELOG.md" 4 | directory = "changelog.d" 5 | issue_format = "[\\#{issue}](https://github.com/matrix-org/sydent/issues/{issue})" 6 | 7 | [[tool.towncrier.type]] 8 | directory = "feature" 9 | name = "Features" 10 | showcontent = true 11 | 12 | [[tool.towncrier.type]] 13 | directory = "bugfix" 14 | name = "Bugfixes" 15 | showcontent = true 16 | 17 | [[tool.towncrier.type]] 18 | directory = "docker" 19 | name = "Updates to the Docker image" 20 | showcontent = true 21 | 22 | [[tool.towncrier.type]] 23 | directory = "doc" 24 | name = "Improved Documentation" 25 | showcontent = true 26 | 27 | [[tool.towncrier.type]] 28 | directory = "removal" 29 | name = "Deprecations and Removals" 30 | showcontent = true 31 | 32 | [[tool.towncrier.type]] 33 | directory = "misc" 34 | name = "Internal Changes" 35 | showcontent = true 36 | 37 | [tool.isort] 38 | profile = "black" 39 | 40 | [tool.black] 41 | target-version = ['py36'] 42 | 43 | [tool.mypy] 44 | plugins = "mypy_zope:plugin" 45 | show_error_codes = true 46 | namespace_packages = true 47 | strict = true 48 | 49 | files = [ 50 | # Find files that pass with 51 | # find sydent tests -type d -not -name __pycache__ -exec bash -c "mypy --strict '{}' > /dev/null" \; -print 52 | "sydent" 53 | # TODO the rest of CI checks these---mypy ought to too. 54 | # "tests", 55 | # "matrix_is_test", 56 | # "scripts", 57 | # "setup.py", 58 | ] 59 | mypy_path = "stubs" 60 | 61 | [[tool.mypy.overrides]] 62 | module = [ 63 | "idna", 64 | "netaddr", 65 | "signedjson.*", 66 | "sortedcontainers", 67 | ] 68 | ignore_missing_imports = true 69 | 70 | [tool.poetry] 71 | name = "matrix-sydent" 72 | version = "2.6.1" 73 | description = "Reference Matrix Identity Verification and Lookup Server" 74 | authors = ["Matrix.org Team and Contributors "] 75 | license = "Apache-2.0" 76 | readme = "README.rst" 77 | repository = "https://github.com/matrix-org/sydent" 78 | packages = [ 79 | { include = "sydent" }, 80 | ] 81 | 82 | include = [ 83 | { path = "matrix-sydent.service" }, 84 | { path = "res" }, 85 | { path = "scripts" }, 86 | { path = "matrix_is_test", format = "sdist" }, 87 | { path = "scripts-dev", format = "sdist" }, 88 | { path = "setup.cfg", format = "sdist" }, 89 | { path = "tests", format = "sdist" }, 90 | ] 91 | classifiers = [ 92 | "Development Status :: 5 - Production/Stable", 93 | ] 94 | 95 | [tool.poetry.dependencies] 96 | python = "^3.7" 97 | attrs = ">=19.1.0" 98 | jinja2 = ">=3.0.0" 99 | netaddr = ">=0.7.0" 100 | matrix-common = "^1.1.0" 101 | phonenumbers = ">=8.12.32" 102 | # prometheus-client's lower bound is copied from Synapse. 103 | prometheus-client = ">=0.4.0" 104 | pynacl = ">=1.2.1" 105 | pyOpenSSL = ">=16.0.0" 106 | pyyaml = ">=3.11" 107 | # sentry-sdk's lower bound is copied from Synapse. 108 | sentry-sdk = { version = ">=0.7.2", optional = true } 109 | # twisted warns about about the absence of service-identity 110 | service-identity = ">=1.0.0" 111 | signedjson = "==1.1.1" 112 | sortedcontainers = ">=2.1.0" 113 | twisted = ">=18.4.0" 114 | typing-extensions = ">=3.7.4" 115 | unpaddedbase64 = ">=1.1.0" 116 | "zope.interface" = ">=4.6.0" 117 | 118 | [tool.poetry.dev-dependencies] 119 | black = "==21.6b0" 120 | ruff = "0.0.189" 121 | isort = "==5.8.0" 122 | matrix-is-tester = {git = "https://github.com/matrix-org/matrix-is-tester", rev = "main"} 123 | mypy = ">=0.902" 124 | mypy-zope = ">=0.3.1" 125 | parameterized = "==0.8.1" 126 | # sentry-sdk is required for typechecking. 127 | sentry-sdk = "*" 128 | types-Jinja2 = "2.11.9" 129 | types-mock = "4.0.8" 130 | types-PyOpenSSL = "21.0.3" 131 | types-PyYAML = "6.0.3" 132 | towncrier = "^21.9.0" 133 | 134 | [tool.poetry.extras] 135 | sentry = ["sentry-sdk"] 136 | prometheus = ["prometheus-client"] 137 | 138 | [tool.poetry.scripts] 139 | sydent = "sydent.sydent:main" 140 | 141 | [tool.ruff] 142 | line-length = 88 143 | 144 | ignore = [ 145 | "E501", 146 | "F401", 147 | "F821", 148 | ] 149 | select = [ 150 | # pycodestyle checks. 151 | "E", 152 | "W", 153 | # pyflakes checks. 154 | "F", 155 | ] 156 | 157 | [build-system] 158 | requires = ["poetry-core>=1.0.0"] 159 | build-backend = "poetry.core.masonry.api" 160 | -------------------------------------------------------------------------------- /res/matrix-org/migration_template.eml.j2: -------------------------------------------------------------------------------- 1 | Date: {{ date|safe }} 2 | From: {{ from|safe }} 3 | To: {{ to|safe }} 4 | Message-ID: {{ messageid|safe }} 5 | Subject: We have changed the way your Matrix account and email address are associated 6 | MIME-Version: 1.0 7 | Content-Type: multipart/alternative; 8 | boundary="{{ multipart_boundary|safe }}" 9 | 10 | --{{ multipart_boundary|safe }} 11 | Content-Type: text/plain; charset=UTF-8 12 | Content-Disposition: inline 13 | 14 | Hello, 15 | 16 | We’ve recently improved how people discover your Matrix account. 17 | In the past, identity services took capitalisation into account when storing email 18 | addresses. This means Alice@example.com and alice@example.com would be considered to be 19 | two different addresses, and could be associated with different Matrix accounts. We’ve 20 | now updated this behaviour so anyone can find you, no matter how your email is capitalised. 21 | As part of this recent update, we've dissociated the Matrix account {{ mxid|safe }} from 22 | this e-mail address. 23 | No action is needed on your part. This doesn’t affect any passwords or password reset 24 | options on your account. 25 | 26 | 27 | About Matrix: 28 | 29 | Matrix.org is an open standard for interoperable, decentralised, real-time communication 30 | over IP, supporting group chat, file transfer, voice and video calling, integrations to 31 | other apps, bridges to other communication systems and much more. It can be used to power 32 | Instant Messaging, VoIP/WebRTC signalling, Internet of Things communication - or anywhere 33 | you need a standard HTTP API for publishing and subscribing to data whilst tracking the 34 | conversation history. 35 | 36 | Matrix defines the standard, and provides open source reference implementations of 37 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 38 | create new communication solutions or extend the capabilities and reach of existing ones. 39 | 40 | Thanks, 41 | 42 | Matrix 43 | 44 | --{{ multipart_boundary|safe }} 45 | Content-Type: text/html; charset=UTF-8 46 | Content-Disposition: inline 47 | 48 | 49 | 50 | 51 | 89 | 90 | 91 | 92 | 93 | 94 | 137 | 138 | 139 |
95 | 96 | 97 | 99 | 102 | 103 |
98 |
104 | 105 |

Hello,

106 | 107 |

108 | We’ve recently improved how people discover your Matrix account.
109 | In the past, identity services took capitalisation into account when storing email 110 | addresses. This means Alice@example.com and alice@example.com would be considered to 111 | be two different addresses, and could be associated with different Matrix accounts. 112 | We’ve now updated this behaviour so anyone can find you, no matter how your email is 113 | capitalised. As part of this recent update, we've dissociated the Matrix account 114 | {{ mxid|safe }} from this e-mail address.
115 | No action is needed on your part. This doesn’t affect any passwords or password reset 116 | options on your account. 117 |

118 | 119 |
120 |

About Matrix:

121 | 122 |

Matrix.org is an open standard for interoperable, decentralised, real-time communication 123 | over IP, supporting group chat, file transfer, voice and video calling, integrations to 124 | other apps, bridges to other communication systems and much more. It can be used to power 125 | Instant Messaging, VoIP/WebRTC signalling, Internet of Things communication - or anywhere 126 | you need a standard HTTP API for publishing and subscribing to data whilst tracking the 127 | conversation history.

128 | 129 |

Matrix defines the standard, and provides open source reference implementations of 130 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 131 | create new communication solutions or extend the capabilities and reach of existing ones.

132 | 133 |

Thanks,

134 | 135 |

Matrix

136 |
140 | 141 | 142 | 143 | --{{ multipart_boundary|safe }}-- 144 | -------------------------------------------------------------------------------- /res/matrix-org/verification_template.eml: -------------------------------------------------------------------------------- 1 | Date: %(date)s 2 | From: %(from)s 3 | To: %(to)s 4 | Message-ID: %(messageid)s 5 | Subject: Confirm your email address for Matrix 6 | MIME-Version: 1.0 7 | Content-Type: multipart/alternative; 8 | boundary="%(multipart_boundary)s" 9 | 10 | --%(multipart_boundary)s 11 | Content-Type: text/plain; charset=UTF-8 12 | Content-Disposition: inline 13 | 14 | Hello, 15 | 16 | We have received a request to use this email address with a matrix.org identity 17 | server. If this was you who made this request, you may use the following link 18 | to complete the verification of your email address: 19 | 20 | %(link)s 21 | 22 | If your client requires a code, the code is %(token)s 23 | 24 | If you aren't aware of making such a request, please disregard this email. 25 | 26 | 27 | About Matrix: 28 | 29 | Matrix is an open standard for interoperable, decentralised, real-time communication 30 | over IP. It can be used to power Instant Messaging, VoIP/WebRTC signalling, Internet 31 | of Things communication - or anywhere you need a standard HTTP API for publishing and 32 | subscribing to data whilst tracking the conversation history. 33 | 34 | Matrix defines the standard, and provides open source reference implementations of 35 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 36 | create new communication solutions or extend the capabilities and reach of existing ones. 37 | 38 | --%(multipart_boundary)s 39 | Content-Type: text/html; charset=UTF-8 40 | Content-Disposition: inline 41 | 42 | 43 | 44 | 45 | 46 | 47 | 54 | 55 | 56 |

Hello,

57 | 58 |

We have received a request to use this email address with a matrix.org 59 | identity server. If this was you who made this request, you may use the 60 | following link to complete the verification of your email address:

61 | 62 |

Complete email verification

63 | 64 |

...or copy this link into your web browser:

65 | 66 |

%(link)s

67 | 68 |

If your client requires a code, the code is %(token)s

69 | 70 |

If you aren't aware of making such a request, please disregard this 71 | email.

72 | 73 |
74 |

About Matrix:

75 | 76 |

Matrix is an open standard for interoperable, decentralised, real-time communication 77 | over IP. It can be used to power Instant Messaging, VoIP/WebRTC signalling, Internet 78 | of Things communication - or anywhere you need a standard HTTP API for publishing and 79 | subscribing to data whilst tracking the conversation history.

80 | 81 |

Matrix defines the standard, and provides open source reference implementations of 82 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 83 | create new communication solutions or extend the capabilities and reach of existing ones.

84 | 85 | 86 | 87 | 88 | --%(multipart_boundary)s-- 89 | -------------------------------------------------------------------------------- /res/matrix-org/verification_template.eml.j2: -------------------------------------------------------------------------------- 1 | Date: {{ date|safe }} 2 | From: {{ from|safe }} 3 | To: {{ to|safe }} 4 | Message-ID: {{ messageid|safe }} 5 | Subject: Confirm your email address for Matrix 6 | MIME-Version: 1.0 7 | Content-Type: multipart/alternative; 8 | boundary="{{ multipart_boundary|safe }}" 9 | 10 | --{{ multipart_boundary|safe }} 11 | Content-Type: text/plain; charset=UTF-8 12 | Content-Disposition: inline 13 | 14 | Hello, 15 | 16 | We have received a request to use this email address with a matrix.org identity 17 | server. If this was you who made this request, you may use the following link 18 | to complete the verification of your email address: 19 | 20 | {{ link|safe }} 21 | 22 | If your client requires a code, the code is {{ token|safe }} 23 | 24 | If you aren't aware of making such a request, please disregard this email. 25 | 26 | 27 | About Matrix: 28 | 29 | Matrix is an open standard for interoperable, decentralised, real-time communication 30 | over IP. It can be used to power Instant Messaging, VoIP/WebRTC signalling, Internet 31 | of Things communication - or anywhere you need a standard HTTP API for publishing and 32 | subscribing to data whilst tracking the conversation history. 33 | 34 | Matrix defines the standard, and provides open source reference implementations of 35 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 36 | create new communication solutions or extend the capabilities and reach of existing ones. 37 | 38 | --{{ multipart_boundary|safe }} 39 | Content-Type: text/html; charset=UTF-8 40 | Content-Disposition: inline 41 | 42 | 43 | 44 | 45 | 46 | 47 | 54 | 55 | 56 |

Hello,

57 | 58 |

We have received a request to use this email address with a matrix.org 59 | identity server. If this was you who made this request, you may use the 60 | following link to complete the verification of your email address:

61 | 62 |

Complete email verification

63 | 64 |

...or copy this link into your web browser:

65 | 66 |

{{ link }}

67 | 68 |

If your client requires a code, the code is {{ token }}

69 | 70 |

If you aren't aware of making such a request, please disregard this 71 | email.

72 | 73 |
74 |

About Matrix:

75 | 76 |

Matrix is an open standard for interoperable, decentralised, real-time communication 77 | over IP. It can be used to power Instant Messaging, VoIP/WebRTC signalling, Internet 78 | of Things communication - or anywhere you need a standard HTTP API for publishing and 79 | subscribing to data whilst tracking the conversation history.

80 | 81 |

Matrix defines the standard, and provides open source reference implementations of 82 | Matrix-compatible Servers, Clients, Client SDKs and Application Services to help you 83 | create new communication solutions or extend the capabilities and reach of existing ones.

84 | 85 | 86 | 87 | 88 | --{{ multipart_boundary|safe }}-- 89 | -------------------------------------------------------------------------------- /res/matrix-org/verify_response_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

%(message)s

9 | 10 | 11 | -------------------------------------------------------------------------------- /res/vector-im/verify_response_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 40 | 41 | 42 | 45 |
46 |

%(message)s

47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /scripts-dev/check_newsfragment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # A script which checks that an appropriate news file has been added on this 4 | # branch. 5 | # 6 | # As first argument, it requires the PR number, so that it can check that the 7 | # newsfragment has the correct name. 8 | # 9 | # Usage: 10 | # ./scripts-dev/check_newsfragment.sh 382 11 | # 12 | # Exit codes: 13 | # 0: all is well 14 | # 1: the newsfragment is wrong in some way 15 | # 9: the script has not been invoked properly 16 | 17 | echo -e "+++ \e[32mChecking newsfragment\e[m" 18 | 19 | set -e 20 | 21 | if [ -z "$1" ]; then 22 | echo "Please specify the PR number as the first argument (e.g. 382)." 23 | exit 9 24 | fi 25 | 26 | pull_request_number="$1" 27 | 28 | # Print a link to the contributing guide if the user makes a mistake 29 | CONTRIBUTING_GUIDE_TEXT="!! Please see the contributing guide for help writing your changelog entry: 30 | https://github.com/matrix-org/sydent/blob/main/CONTRIBUTING.md#changelog" 31 | 32 | # If towncrier returns a non-zero exit code, print the contributing guide link and exit 33 | python -m towncrier.check --compare-with="origin/main" || (echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 && exit 1) 34 | 35 | echo 36 | echo "--------------------------" 37 | echo 38 | 39 | matched=0 40 | for f in `git diff --name-only origin/main... -- changelog.d`; do 41 | # check that any modified newsfiles on this branch end with a full stop. 42 | lastchar=`tr -d '\n' < $f | tail -c 1` 43 | if [ $lastchar != '.' -a $lastchar != '!' ]; then 44 | echo -e "\e[31mERROR: newsfragment $f does not end with a '.' or '!'\e[39m" >&2 45 | echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 46 | exit 1 47 | fi 48 | 49 | # see if this newsfile corresponds to the right PR 50 | [[ -n "$pull_request_number" && "$f" == changelog.d/"$pull_request_number".* ]] && matched=1 51 | done 52 | 53 | if [[ -n "$pull_request_number" && "$matched" -eq 0 ]]; then 54 | echo -e "\e[31mERROR: Did not find a news fragment with the right number: expected changelog.d/$pull_request_number.*.\e[39m" >&2 55 | echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 56 | exit 1 57 | fi 58 | -------------------------------------------------------------------------------- /scripts-dev/lint.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -ex 3 | 4 | # We don't list explicit directories here. Instead, rely on the tools' default behaviour 5 | # (or explicit configuration in setup.cfg/pyproject.toml). This helps to keep this script 6 | # consistent with CI. 7 | 8 | black . 9 | # --quiet suppresses the update check. 10 | ruff --quiet . 11 | isort . 12 | mypy 13 | -------------------------------------------------------------------------------- /scripts/generate-key: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Run example 4 | # ./scripts/generate-key 5 | 6 | # Use this to generate a signing key and verify key for use in sydent 7 | # configurations. 8 | 9 | # The signing key is generally used in "ed25519.signingkey" in the sydent config 10 | 11 | import sys 12 | 13 | import signedjson.key 14 | 15 | signing_key = signedjson.key.generate_signing_key(0); 16 | sk_str = "%s %s %s" % ( 17 | signing_key.alg, 18 | signing_key.version, 19 | signedjson.key.encode_signing_key_base64(signing_key) 20 | ) 21 | print ("signing key: %s " % sk_str) 22 | pk_str = signedjson.key.encode_verify_key_base64(signing_key.verify_key) 23 | print ("verify key: %s" % pk_str) 24 | -------------------------------------------------------------------------------- /scripts/sydent-bind: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | if [[ $# != 2 ]]; then 4 | echo >&2 "usage: $0 email mxid" 5 | exit 1 6 | fi 7 | 8 | email="$1" 9 | mxid="$2" 10 | client_secret="$(uuidgen)" 11 | 12 | curl -d "client_secret=${client_secret}" -d email=${email} -d send_attempt=1 http://localhost:8090/_matrix/identity/api/v1/validate/email/requestToken 13 | sid=$(sqlite3 sydent.db "select threepid_validation_sessions.id from threepid_token_auths join threepid_validation_sessions on threepid_validation_sessions.id == threepid_token_auths.validationSession where threepid_validation_sessions.address = \"${email}\";") 14 | token=$(sqlite3 sydent.db "select token from threepid_token_auths join threepid_validation_sessions on threepid_validation_sessions.id == threepid_token_auths.validationSession where threepid_validation_sessions.address = \"${email}\";") 15 | curl "http://localhost:8090/_matrix/identity/api/v1/validate/email/submitToken?token=${token}&client_secret=${client_secret}&sid=${sid}" 16 | curl -d "sid=${sid}" -d "mxid=${mxid}" -d "client_secret=${client_secret}" http://localhost:8090/_matrix/identity/api/v1/3pid/bind 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs/sphinx 3 | build-dir = docs/build 4 | all_files = 1 5 | 6 | [aliases] 7 | test = trial 8 | 9 | [trial] 10 | test_suite = tests 11 | -------------------------------------------------------------------------------- /stubs/twisted/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/stubs/twisted/__init__.pyi -------------------------------------------------------------------------------- /stubs/twisted/internet/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/stubs/twisted/internet/__init__.pyi -------------------------------------------------------------------------------- /stubs/twisted/internet/endpoints.pyi: -------------------------------------------------------------------------------- 1 | from typing import AnyStr, Optional 2 | 3 | from twisted.internet import interfaces 4 | from twisted.internet.defer import Deferred 5 | from twisted.internet.interfaces import ( 6 | IOpenSSLClientConnectionCreator, 7 | IProtocol, 8 | IProtocolFactory, 9 | IStreamClientEndpoint, 10 | ) 11 | from zope.interface import implementer 12 | 13 | @implementer(interfaces.IStreamClientEndpoint) 14 | class HostnameEndpoint: 15 | # Reactor should be a "provider of L{IReactorTCP}, L{IReactorTime} and 16 | # either L{IReactorPluggableNameResolver} or L{IReactorPluggableResolver}." 17 | # I don't know how to encode that in the type system. 18 | def __init__( 19 | self, 20 | reactor: object, 21 | host: AnyStr, 22 | port: int, 23 | timeout: float = ..., 24 | bindAddress: Optional[bytes] = ..., 25 | attemptDelay: Optional[float] = ..., 26 | ): ... 27 | def connect(self, protocol_factory: IProtocolFactory) -> Deferred[IProtocol]: ... 28 | 29 | def wrapClientTLS( 30 | connectionCreator: IOpenSSLClientConnectionCreator, 31 | wrappedEndpoint: IStreamClientEndpoint, 32 | ) -> IStreamClientEndpoint: ... 33 | -------------------------------------------------------------------------------- /stubs/twisted/internet/error.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | class ConnectError(Exception): 4 | def __init__(self, osError: Optional[Any] = ..., string: str = ...): ... 5 | 6 | class DNSLookupError(IOError): ... 7 | -------------------------------------------------------------------------------- /stubs/twisted/internet/ssl.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, AnyStr, Dict, List, Optional, Type, TypeVar 2 | 3 | import OpenSSL.SSL 4 | 5 | # I don't like importing from _sslverify, but IOpenSSLTrustRoot isn't re-exported 6 | # anywhere else in twisted. 7 | from twisted.internet._sslverify import IOpenSSLTrustRoot, KeyPair 8 | from twisted.internet.interfaces import ( 9 | IOpenSSLClientConnectionCreator, 10 | IOpenSSLContextFactory, 11 | ) 12 | from zope.interface import implementer 13 | 14 | _C = TypeVar("_C") 15 | 16 | class Certificate: 17 | original: OpenSSL.crypto.X509 18 | @classmethod 19 | def loadPEM(cls: Type[_C], data: AnyStr) -> _C: ... 20 | 21 | def platformTrust() -> IOpenSSLTrustRoot: ... 22 | 23 | class PrivateCertificate(Certificate): 24 | # PrivateKey is not set until you call _setPrivateKey, e.g. via load() 25 | privateKey: KeyPair 26 | 27 | @implementer(IOpenSSLContextFactory) 28 | class CertificateOptions: 29 | def __init__( 30 | self, trustRoot: Optional[IOpenSSLTrustRoot] = ..., **kwargs: object 31 | ): ... 32 | def _makeContext(self) -> OpenSSL.SSL.Context: ... 33 | def getContext(self) -> OpenSSL.SSL.Context: ... 34 | 35 | def optionsForClientTLS( 36 | hostname: str, 37 | trustRoot: Optional[IOpenSSLTrustRoot] = ..., 38 | clientCertificate: Optional[PrivateCertificate] = ..., 39 | acceptableProtocols: Optional[List[bytes]] = ..., 40 | *, 41 | # Shouldn't use extraCertificateOptions: 42 | # "any time you need to pass an option here that is a bug in this interface." 43 | extraCertificateOptions: Optional[Dict[Any, Any]] = ..., 44 | ) -> IOpenSSLClientConnectionCreator: ... 45 | 46 | # Type ignore: I don't want to respecify the methods on the interface that we 47 | # don't use. 48 | @implementer(IOpenSSLTrustRoot) # type: ignore[misc] 49 | class OpenSSLDefaultPaths: ... 50 | -------------------------------------------------------------------------------- /stubs/twisted/names/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/stubs/twisted/names/__init__.pyi -------------------------------------------------------------------------------- /stubs/twisted/names/dns.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Generic, Optional, TypeVar 2 | 3 | class Name: 4 | name: bytes 5 | def __init__(self, name: bytes = ...): ... 6 | 7 | SRV: int 8 | 9 | class Record_SRV: 10 | priority: int 11 | weight: int 12 | port: int 13 | target: Name 14 | ttl: int 15 | 16 | _Payload = TypeVar("_Payload") # should be bound to IEncodableRecord 17 | 18 | class RRHeader(Generic[_Payload]): 19 | fmt: ClassVar[str] 20 | name: Name 21 | type: int 22 | cls: int 23 | ttl: int 24 | payload: Optional[_Payload] 25 | auth: bool 26 | -------------------------------------------------------------------------------- /stubs/twisted/python/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/stubs/twisted/python/__init__.pyi -------------------------------------------------------------------------------- /stubs/twisted/python/failure.pyi: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import Optional, Type, TypeVar, Union, overload 3 | 4 | _E = TypeVar("_E") 5 | 6 | class Failure(BaseException): 7 | def __init__( 8 | self, 9 | exc_value: Optional[BaseException] = ..., 10 | exc_type: Optional[Type[BaseException]] = ..., 11 | exc_tb: Optional[TracebackType] = ..., 12 | captureVars: bool = ..., 13 | ): ... 14 | @overload 15 | def check(self, singleErrorType: Type[_E]) -> Optional[_E]: ... 16 | @overload 17 | def check( 18 | self, *errorTypes: Union[str, Type[Exception]] 19 | ) -> Optional[Exception]: ... 20 | def getTraceback( 21 | self, 22 | elideFrameworkCode: int = ..., 23 | detail: str = ..., 24 | ) -> str: ... 25 | -------------------------------------------------------------------------------- /stubs/twisted/python/log.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from twisted.python.failure import Failure 4 | 5 | EventDict = Dict[str, Any] 6 | 7 | def err( 8 | _stuff: Union[None, Exception, Failure] = ..., 9 | _why: Optional[str] = ..., 10 | **kw: object, 11 | ) -> None: ... 12 | 13 | class PythonLoggingObserver: 14 | def emit(self, eventDict: EventDict) -> None: ... 15 | def start(self) -> None: ... 16 | def stop(self) -> None: ... 17 | -------------------------------------------------------------------------------- /stubs/twisted/web/__init__.pyi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/stubs/twisted/web/__init__.pyi -------------------------------------------------------------------------------- /stubs/twisted/web/client.pyi: -------------------------------------------------------------------------------- 1 | from typing import BinaryIO, Optional, Sequence, Type, TypeVar 2 | 3 | from twisted.internet.defer import Deferred 4 | from twisted.internet.interfaces import IConsumer, IProtocol 5 | from twisted.internet.task import Cooperator 6 | from twisted.python.failure import Failure 7 | from twisted.web.http_headers import Headers 8 | from twisted.web.iweb import ( 9 | IAgent, 10 | IAgentEndpointFactory, 11 | IBodyProducer, 12 | IPolicyForHTTPS, 13 | IResponse, 14 | ) 15 | from zope.interface import implementer 16 | 17 | _C = TypeVar("_C") 18 | 19 | class ResponseFailed(Exception): 20 | def __init__( 21 | self, reasons: Sequence[Failure], response: Optional[Response] = ... 22 | ): ... 23 | 24 | class HTTPConnectionPool: 25 | persistent: bool 26 | maxPersistentPerHost: int 27 | cachedConnectionTimeout: float 28 | retryAutomatically: bool 29 | def __init__(self, reactor: object, persistent: bool = ...): ... 30 | 31 | @implementer(IAgent) 32 | class Agent: 33 | # Here and in `usingEndpointFactory`, reactor should be a "provider of 34 | # L{IReactorTCP}, L{IReactorTime} and either 35 | # L{IReactorPluggableNameResolver} or L{IReactorPluggableResolver}." 36 | # I don't know how to encode that in the type system; see also 37 | # https://github.com/Shoobx/mypy-zope/issues/58 38 | def __init__( 39 | self, 40 | reactor: object, 41 | contextFactory: IPolicyForHTTPS = ..., 42 | connectTimeout: Optional[float] = ..., 43 | bindAddress: Optional[bytes] = ..., 44 | pool: Optional[HTTPConnectionPool] = ..., 45 | ): ... 46 | def request( 47 | self, 48 | method: bytes, 49 | uri: bytes, 50 | headers: Optional[Headers] = ..., 51 | bodyProducer: Optional[IBodyProducer] = ..., 52 | ) -> Deferred[IResponse]: ... 53 | @classmethod 54 | def usingEndpointFactory( 55 | cls: Type[_C], 56 | reactor: object, 57 | endpointFactory: IAgentEndpointFactory, 58 | pool: Optional[HTTPConnectionPool] = ..., 59 | ) -> _C: ... 60 | 61 | @implementer(IBodyProducer) 62 | class FileBodyProducer: 63 | def __init__( 64 | self, 65 | inputFile: BinaryIO, 66 | cooperator: Cooperator = ..., 67 | readSize: int = ..., 68 | ): ... 69 | # Length is either `int` or the opaque object UNKNOWN_LENGTH. 70 | length: int | object 71 | def startProducing(self, consumer: IConsumer) -> Deferred[None]: ... 72 | def stopProducing(self) -> None: ... 73 | def pauseProducing(self) -> None: ... 74 | def resumeProducing(self) -> None: ... 75 | 76 | def readBody(response: IResponse) -> Deferred[bytes]: ... 77 | 78 | # Type ignore: I don't want to respecify the methods on the interface that we 79 | # don't use. 80 | @implementer(IResponse) # type: ignore[misc] 81 | class Response: 82 | code: int 83 | headers: Headers 84 | # Length is either `int` or the opaque object UNKNOWN_LENGTH. 85 | length: int | object 86 | def deliverBody(self, protocol: IProtocol) -> None: ... 87 | 88 | class ResponseDone: ... 89 | 90 | class URI: 91 | scheme: bytes 92 | netloc: bytes 93 | host: bytes 94 | port: int 95 | path: bytes 96 | params: bytes 97 | query: bytes 98 | fragment: bytes 99 | def __init__( 100 | self, 101 | scheme: bytes, 102 | netloc: bytes, 103 | host: bytes, 104 | port: int, 105 | path: bytes, 106 | params: bytes, 107 | query: bytes, 108 | fragment: bytes, 109 | ): ... 110 | @classmethod 111 | def fromBytes( 112 | cls: Type[_C], uri: bytes, defaultPort: Optional[int] = ... 113 | ) -> _C: ... 114 | 115 | @implementer(IAgent) 116 | class RedirectAgent: 117 | def __init__(self, agent: Agent, redirectLimit: int = ...): ... 118 | def request( 119 | self, 120 | method: bytes, 121 | uri: bytes, 122 | headers: Optional[Headers] = ..., 123 | bodyProducer: Optional[IBodyProducer] = ..., 124 | ) -> Deferred[IResponse]: ... 125 | -------------------------------------------------------------------------------- /stubs/twisted/web/http.pyi: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import AnyStr, Dict, List, Optional 3 | 4 | from twisted.internet import protocol 5 | from twisted.internet.defer import Deferred 6 | from twisted.internet.interfaces import IAddress, ITCPTransport 7 | from twisted.logger import Logger 8 | from twisted.web.http_headers import Headers 9 | from twisted.web.iweb import IRequest 10 | from zope.interface import implementer 11 | 12 | class HTTPFactory(protocol.ServerFactory): ... 13 | class HTTPChannel: ... 14 | 15 | # Type ignore: I don't want to respecify the methods on the interface that we 16 | # don't use. 17 | @implementer(IRequest) # type: ignore[misc] 18 | class Request: 19 | # Instance attributes mentioned in the docstring 20 | method: bytes 21 | uri: bytes 22 | path: bytes 23 | args: Dict[bytes, List[bytes]] 24 | content: typing.BinaryIO 25 | cookies: List[bytes] 26 | requestHeaders: Headers 27 | responseHeaders: Headers 28 | notifications: List[Deferred[None]] 29 | _disconnected: bool 30 | _log: Logger 31 | 32 | # Other instance attributes set in __init__ 33 | channel: HTTPChannel 34 | client: IAddress 35 | # This was hard to derive. 36 | # - `transport` is `self.channel.transport` 37 | # - `self.channel` is set in the constructor, and looks like it's always 38 | # an `HTTPChannel`. 39 | # - `HTTPChannel` is a `LineReceiver` is a `Protocol` is a `BaseProtocol`. 40 | # - `BaseProtocol` sets `self.transport` to initially `None`. 41 | # 42 | # Note that `transport` is set to an ITransport in makeConnection, 43 | # so is almost certainly not None by the time it reaches our code. 44 | # 45 | # I've narrowed this to ITCPTransport because 46 | # - we use `self.transport.abortConnection`, which belongs to that interface 47 | # - twisted does too! in its implementation of HTTPChannel.forceAbortClient 48 | transport: Optional[ITCPTransport] 49 | def __init__(self, channel: HTTPChannel): ... 50 | def getHeader(self, key: AnyStr) -> Optional[AnyStr]: ... 51 | def handleContentChunk(self, data: bytes) -> None: ... 52 | def setResponseCode(self, code: int, message: Optional[bytes] = ...) -> None: ... 53 | def setHeader(self, k: AnyStr, v: AnyStr) -> None: ... 54 | def write(self, data: bytes) -> None: ... 55 | def finish(self) -> None: ... 56 | def getClientAddress(self) -> IAddress: ... 57 | 58 | class PotentialDataLoss(Exception): ... 59 | 60 | CACHED: object 61 | 62 | def stringToDatetime(dateString: bytes) -> int: ... 63 | -------------------------------------------------------------------------------- /stubs/twisted/web/iweb.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, AnyStr, BinaryIO, Dict, List, Mapping, Optional, Tuple 2 | 3 | from twisted.internet.defer import Deferred 4 | from twisted.internet.interfaces import ( 5 | IAddress, 6 | IConsumer, 7 | IOpenSSLClientConnectionCreator, 8 | IProtocol, 9 | IPushProducer, 10 | IStreamClientEndpoint, 11 | ) 12 | from twisted.python import urlpath 13 | from twisted.web.client import URI 14 | from twisted.web.http_headers import Headers 15 | from typing_extensions import Literal 16 | from zope.interface import Interface 17 | 18 | class IClientRequest(Interface): 19 | method: bytes 20 | absoluteURI: Optional[bytes] 21 | headers: Headers 22 | 23 | class IRequest(Interface): 24 | method: bytes 25 | uri: bytes 26 | path: bytes 27 | args: Mapping[bytes, List[bytes]] 28 | prepath: List[bytes] 29 | postpath: List[bytes] 30 | requestHeaders: Headers 31 | content: BinaryIO 32 | responseHeaders: Headers 33 | def getHeader(key: AnyStr) -> Optional[AnyStr]: ... 34 | def getCookie(key: bytes) -> Optional[bytes]: ... 35 | def getAllHeaders() -> Dict[bytes, bytes]: ... 36 | def getRequestHostname() -> bytes: ... 37 | def getHost() -> IAddress: ... 38 | def getClientAddress() -> IAddress: ... 39 | def getClientIP() -> Optional[str]: ... 40 | def getUser() -> str: ... 41 | def getPassword() -> str: ... 42 | def isSecure() -> bool: ... 43 | def getSession(sessionInterface: Any | None = ...) -> Any: ... 44 | def URLPath() -> urlpath.URLPath: ... 45 | def prePathURL() -> bytes: ... 46 | def rememberRootURL() -> None: ... 47 | def getRootURL() -> bytes: ... 48 | def finish() -> None: ... 49 | def write(data: bytes) -> None: ... 50 | def addCookie( 51 | k: AnyStr, 52 | v: AnyStr, 53 | expires: Optional[AnyStr] = ..., 54 | domain: Optional[AnyStr] = ..., 55 | path: Optional[AnyStr] = ..., 56 | max_age: Optional[AnyStr] = ..., 57 | comment: Optional[AnyStr] = ..., 58 | secure: Optional[bool] = ..., 59 | ) -> None: ... 60 | def setResponseCode(code: int, message: Optional[bytes] = ...) -> None: ... 61 | def setHeader(k: AnyStr, v: AnyStr) -> None: ... 62 | def redirect(url: AnyStr) -> None: ... 63 | # returns http.CACHED or False. http.CACHED is a string constant, but we 64 | # treat it as an opaque object, similar to UNKNOWN_LENGTH. 65 | def setLastModified(when: float) -> object | Literal[False]: ... 66 | def setETag(etag: str) -> object | Literal[False]: ... 67 | def setHost(host: bytes, port: int, ssl: bool = ...) -> None: ... 68 | 69 | class IBodyProducer(IPushProducer): 70 | # Length is either `int` or the opaque object UNKNOWN_LENGTH. 71 | length: int | object 72 | def startProducing(consumer: IConsumer) -> Deferred[None]: ... 73 | def stopProducing() -> None: ... 74 | 75 | class IResponse(Interface): 76 | version: Tuple[str, int, int] 77 | code: int 78 | phrase: str 79 | headers: Headers 80 | length: int | object 81 | request: IClientRequest 82 | previousResponse: Optional[IResponse] 83 | def deliverBody(protocol: IProtocol) -> None: ... 84 | def setPreviousResponse(response: IResponse) -> None: ... 85 | 86 | class IAgent(Interface): 87 | def request( 88 | method: bytes, 89 | uri: bytes, 90 | headers: Optional[Headers] = ..., 91 | bodyProducer: Optional[IBodyProducer] = ..., 92 | ) -> Deferred[IResponse]: ... 93 | 94 | class IPolicyForHTTPS(Interface): 95 | def creatorForNetloc( 96 | hostname: bytes, port: int 97 | ) -> IOpenSSLClientConnectionCreator: ... 98 | 99 | class IAgentEndpointFactory(Interface): 100 | def endpointForURI(uri: URI) -> IStreamClientEndpoint: ... 101 | 102 | UNKNOWN_LENGTH: object 103 | -------------------------------------------------------------------------------- /stubs/twisted/web/resource.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | from twisted.web.server import Request 4 | from zope.interface import Interface, implementer 5 | 6 | class IResource(Interface): 7 | isLeaf: ClassVar[bool] 8 | def __init__() -> None: ... 9 | def putChild(path: bytes, child: IResource) -> None: ... 10 | 11 | @implementer(IResource) 12 | class Resource: 13 | isLeaf: ClassVar[bool] 14 | def putChild(self, path: bytes, child: IResource) -> None: ... 15 | def render(self, request: Request) -> Any: ... 16 | -------------------------------------------------------------------------------- /stubs/twisted/web/server.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from twisted.web import http 4 | from twisted.web.resource import IResource 5 | 6 | class Request(http.Request): ... 7 | 8 | # A requestFactory is allowed to be "[a] factory which is called with (channel) 9 | # and creates L{Request} instances.". 10 | RequestFactory = Callable[[http.HTTPChannel], Request] 11 | 12 | class Site(http.HTTPFactory): 13 | displayTracebacks: bool 14 | def __init__( 15 | self, 16 | resource: IResource, 17 | requestFactory: Optional[RequestFactory] = ..., 18 | # Args and kwargs get passed to http.HTTPFactory. But we don't use them. 19 | *args: object, 20 | **kwargs: object, 21 | ): ... 22 | 23 | NOT_DONE_YET = object # Opaque 24 | -------------------------------------------------------------------------------- /sydent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/__init__.py -------------------------------------------------------------------------------- /sydent/config/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from abc import ABC, abstractmethod 16 | from configparser import ConfigParser 17 | 18 | 19 | class BaseConfig(ABC): 20 | @abstractmethod 21 | def parse_config(self, cfg: ConfigParser) -> bool: 22 | """ 23 | Parse the a section of the config 24 | 25 | :param cfg: the configuration to be parsed 26 | 27 | :return: whether or not cfg has been altered. This method CAN 28 | return True, but it *shouldn't* as this leads to altering the 29 | config file. 30 | """ 31 | pass 32 | -------------------------------------------------------------------------------- /sydent/config/crypto.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from configparser import ConfigParser 16 | 17 | import nacl.encoding 18 | import nacl.signing 19 | import signedjson.key 20 | import signedjson.types 21 | 22 | from sydent.config._base import BaseConfig 23 | 24 | 25 | class CryptoConfig(BaseConfig): 26 | def parse_config(self, cfg: "ConfigParser") -> bool: 27 | """ 28 | Parse the crypto section of the config 29 | :param cfg: the configuration to be parsed 30 | """ 31 | 32 | signing_key_str = cfg.get("crypto", "ed25519.signingkey") 33 | signing_key_parts = signing_key_str.split(" ") 34 | 35 | save_key = False 36 | 37 | # N.B. `signedjson` expects `nacl.signing.SigningKey` instances which 38 | # have been monkeypatched to include new `alg` and `version` attributes. 39 | # This is captured by the `signedjson.types.SigningKey` protocol. 40 | self.signing_key: signedjson.types.SigningKey 41 | 42 | if signing_key_str == "": 43 | print( 44 | "INFO: This server does not yet have an ed25519 signing key. " 45 | "Creating one and saving it in the config file." 46 | ) 47 | 48 | self.signing_key = signedjson.key.generate_signing_key("0") 49 | 50 | save_key = True 51 | elif len(signing_key_parts) == 1: 52 | # old format key 53 | print("INFO: Updating signing key format: brace yourselves") 54 | 55 | self.signing_key = nacl.signing.SigningKey( 56 | signing_key_str.encode("ascii"), encoder=nacl.encoding.HexEncoder 57 | ) 58 | self.signing_key.version = "0" 59 | self.signing_key.alg = signedjson.key.NACL_ED25519 60 | 61 | save_key = True 62 | else: 63 | self.signing_key = signedjson.key.decode_signing_key_base64( 64 | signing_key_parts[0], signing_key_parts[1], signing_key_parts[2] 65 | ) 66 | 67 | if save_key: 68 | signing_key_str = "%s %s %s" % ( 69 | self.signing_key.alg, 70 | self.signing_key.version, 71 | signedjson.key.encode_signing_key_base64(self.signing_key), 72 | ) 73 | cfg.set("crypto", "ed25519.signingkey", signing_key_str) 74 | return True 75 | else: 76 | return False 77 | -------------------------------------------------------------------------------- /sydent/config/database.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from configparser import ConfigParser 16 | 17 | from sydent.config._base import BaseConfig 18 | 19 | 20 | class DatabaseConfig(BaseConfig): 21 | def parse_config(self, cfg: "ConfigParser") -> bool: 22 | """ 23 | Parse the database section of the config 24 | 25 | :param cfg: the configuration to be parsed 26 | """ 27 | self.database_path = cfg.get("db", "db.file") 28 | 29 | return False 30 | -------------------------------------------------------------------------------- /sydent/config/email.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import socket 15 | from configparser import ConfigParser 16 | from typing import Optional 17 | 18 | from sydent.config._base import BaseConfig 19 | from sydent.config.exceptions import ConfigError 20 | from sydent.util.emailutils import EmailAddressException, check_valid_email_address 21 | 22 | 23 | class EmailConfig(BaseConfig): 24 | def parse_config(self, cfg: ConfigParser) -> bool: 25 | """ 26 | Parse the email section of the config 27 | 28 | :param cfg: the configuration to be parsed 29 | """ 30 | 31 | # These two options are deprecated 32 | self.template: Optional[str] = cfg.get("email", "email.template", fallback=None) 33 | 34 | self.invite_template = cfg.get("email", "email.invite_template", fallback=None) 35 | 36 | # This isn't used anywhere... 37 | self.validation_subject = cfg.get("email", "email.subject") 38 | 39 | self.invite_subject = cfg.get("email", "email.invite.subject", raw=True) 40 | self.invite_subject_space = cfg.get( 41 | "email", "email.invite.subject_space", raw=True 42 | ) 43 | 44 | self.smtp_server = cfg.get("email", "email.smtphost") 45 | self.smtp_port = cfg.get("email", "email.smtpport") 46 | self.smtp_username = cfg.get("email", "email.smtpusername") 47 | self.smtp_password = cfg.get("email", "email.smtppassword") 48 | self.tls_mode = cfg.get("email", "email.tlsmode") 49 | 50 | # This is the fully qualified domain name for SMTP HELO/EHLO 51 | self.host_name = cfg.get("email", "email.hostname") 52 | if self.host_name == "": 53 | self.host_name = socket.getfqdn() 54 | 55 | self.sender = cfg.get("email", "email.from") 56 | try: 57 | check_valid_email_address(self.sender, allow_description=True) 58 | except EmailAddressException as e: 59 | raise ConfigError(f"Invalid email address '{self.sender}'") from e 60 | 61 | self.default_web_client_location = cfg.get( 62 | "email", "email.default_web_client_location" 63 | ) 64 | 65 | self.username_obfuscate_characters = cfg.getint( 66 | "email", "email.third_party_invite_username_obfuscate_characters" 67 | ) 68 | 69 | self.domain_obfuscate_characters = cfg.getint( 70 | "email", "email.third_party_invite_domain_obfuscate_characters" 71 | ) 72 | 73 | third_party_invite_homeserver_blocklist = cfg.get( 74 | "email", "email.third_party_invite_homeserver_blocklist", fallback="" 75 | ) 76 | third_party_invite_room_blocklist = cfg.get( 77 | "email", "email.third_party_invite_room_blocklist", fallback="" 78 | ) 79 | third_party_invite_keyword_blocklist = cfg.get( 80 | "email", "email.third_party_invite_keyword_blocklist", fallback="" 81 | ) 82 | self.third_party_invite_homeserver_blocklist = { 83 | server 84 | for server in third_party_invite_homeserver_blocklist.split("\n") 85 | if server # filter out empty lines 86 | } 87 | self.third_party_invite_room_blocklist = { 88 | room_id 89 | for room_id in third_party_invite_room_blocklist.split("\n") 90 | if room_id # filter out empty lines 91 | } 92 | self.third_party_invite_keyword_blocklist = { 93 | keyword.casefold() 94 | for keyword in third_party_invite_keyword_blocklist.split("\n") 95 | if keyword 96 | } 97 | 98 | self.email_sender_ratelimit_burst = cfg.getint( 99 | "email", "email.ratelimit_sender.burst", fallback=5 100 | ) 101 | self.email_sender_ratelimit_rate_hz = cfg.getfloat( 102 | "email", "email.ratelimit_sender.rate_hz", fallback=1.0 / (5 * 60.0) 103 | ) 104 | 105 | return False 106 | -------------------------------------------------------------------------------- /sydent/config/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | class ConfigError(Exception): 17 | pass 18 | -------------------------------------------------------------------------------- /sydent/config/http.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from configparser import ConfigParser 16 | from typing import Optional 17 | 18 | from sydent.config._base import BaseConfig 19 | 20 | 21 | class HTTPConfig(BaseConfig): 22 | def parse_config(self, cfg: "ConfigParser") -> bool: 23 | """ 24 | Parse the http section of the config 25 | 26 | :param cfg: the configuration to be parsed 27 | """ 28 | # This option is deprecated 29 | self.verify_response_template = cfg.get( 30 | "http", "verify_response_template", fallback=None 31 | ) 32 | 33 | self.client_bind_address = cfg.get("http", "clientapi.http.bind_address") 34 | self.client_port = cfg.getint("http", "clientapi.http.port") 35 | 36 | # internal port is allowed to be set to an empty string in the config 37 | internal_api_port = cfg.get("http", "internalapi.http.port") 38 | self.internal_bind_address = cfg.get( 39 | "http", "internalapi.http.bind_address", fallback="::1" 40 | ) 41 | self.internal_port: Optional[int] = None 42 | if internal_api_port != "": 43 | self.internal_port = int(internal_api_port) 44 | 45 | self.cert_file = cfg.get("http", "replication.https.certfile") 46 | self.ca_cert_file = cfg.get("http", "replication.https.cacert") 47 | 48 | self.replication_bind_address = cfg.get( 49 | "http", "replication.https.bind_address" 50 | ) 51 | self.replication_port = cfg.getint("http", "replication.https.port") 52 | 53 | self.obey_x_forwarded_for = cfg.getboolean("http", "obey_x_forwarded_for") 54 | 55 | self.verify_federation_certs = cfg.getboolean("http", "federation.verifycerts") 56 | 57 | self.server_http_url_base = cfg.get("http", "client_http_base") 58 | 59 | self.base_replication_urls = {} 60 | 61 | for section in cfg.sections(): 62 | if section.startswith("peer."): 63 | # peer name is all the characters after 'peer.' 64 | peer = section[5:] 65 | if cfg.has_option(section, "base_replication_url"): 66 | base_url = cfg.get(section, "base_replication_url") 67 | self.base_replication_urls[peer] = base_url 68 | 69 | return False 70 | -------------------------------------------------------------------------------- /sydent/config/sms.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from configparser import ConfigParser 16 | from typing import Dict, List 17 | 18 | from sydent.config._base import BaseConfig 19 | from sydent.config.exceptions import ConfigError 20 | 21 | 22 | class SMSConfig(BaseConfig): 23 | def parse_config(self, cfg: "ConfigParser") -> bool: 24 | """ 25 | Parse the sms section of the config 26 | 27 | :param cfg: the configuration to be parsed 28 | """ 29 | self.body_template = cfg.get("sms", "bodyTemplate") 30 | 31 | # Make sure username and password are bytes otherwise we can't use them with 32 | # b64encode. 33 | self.api_username = cfg.get("sms", "username").encode("UTF-8") 34 | self.api_password = cfg.get("sms", "password").encode("UTF-8") 35 | 36 | self.originators: Dict[str, List[Dict[str, str]]] = {} 37 | self.smsRules = {} 38 | 39 | for opt in cfg.options("sms"): 40 | if opt.startswith("originators."): 41 | country = opt.split(".")[1] 42 | rawVal = cfg.get("sms", opt) 43 | rawList = [i.strip() for i in rawVal.split(",")] 44 | 45 | self.originators[country] = [] 46 | for origString in rawList: 47 | parts = origString.split(":") 48 | if len(parts) != 2: 49 | raise ConfigError( 50 | "Originators must be in form: long:, short: or alpha:, separated by commas" 51 | ) 52 | if parts[0] not in ["long", "short", "alpha"]: 53 | raise ConfigError( 54 | "Invalid originator type: valid types are long, short and alpha" 55 | ) 56 | self.originators[country].append( 57 | { 58 | "type": parts[0], 59 | "text": parts[1], 60 | } 61 | ) 62 | elif opt.startswith("smsrule."): 63 | country = opt.split(".")[1] 64 | action = cfg.get("sms", opt) 65 | 66 | if action not in ["allow", "reject"]: 67 | raise ConfigError( 68 | "Invalid SMS rule action: %s, expecting 'allow' or 'reject'" 69 | % action 70 | ) 71 | 72 | self.smsRules[country] = action 73 | 74 | self.msisdn_ratelimit_burst = cfg.getint( 75 | "sms", "msisdn.ratelimit.burst", fallback=5 76 | ) 77 | self.msisdn_ratelimit_rate_hz = cfg.getfloat( 78 | "sms", "msisdn.ratelimit.rate_hz", fallback=1.0 / (60.0 * 60.0) 79 | ) 80 | 81 | self.country_ratelimit_burst = cfg.getint( 82 | "sms", "country.ratelimit.burst", fallback=50 83 | ) 84 | self.country_ratelimit_rate_hz = cfg.getfloat( 85 | "sms", "country.ratelimit.rate_hz", fallback=1.0 / 60.0 86 | ) 87 | 88 | return False 89 | -------------------------------------------------------------------------------- /sydent/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/db/__init__.py -------------------------------------------------------------------------------- /sydent/db/accounts.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING, Optional, Tuple 16 | 17 | from sydent.users.accounts import Account 18 | 19 | if TYPE_CHECKING: 20 | from sydent.sydent import Sydent 21 | 22 | 23 | class AccountStore: 24 | def __init__(self, sydent: "Sydent") -> None: 25 | self.sydent = sydent 26 | 27 | def getAccountByToken(self, token: str) -> Optional[Account]: 28 | """ 29 | Select the account matching the given token, if any. 30 | 31 | :param token: The token to identify the account, if any. 32 | 33 | :return: The account matching the token, or None if no account matched. 34 | """ 35 | cur = self.sydent.db.cursor() 36 | res = cur.execute( 37 | "select a.user_id, a.created_ts, a.consent_version from accounts a, tokens t " 38 | "where t.user_id = a.user_id and t.token = ?", 39 | (token,), 40 | ) 41 | 42 | row: Optional[Tuple[str, int, Optional[str]]] = res.fetchone() 43 | if row is None: 44 | return None 45 | 46 | return Account(*row) 47 | 48 | def storeAccount( 49 | self, user_id: str, creation_ts: int, consent_version: Optional[str] 50 | ) -> None: 51 | """ 52 | Stores an account for the given user ID. 53 | 54 | :param user_id: The Matrix user ID to create an account for. 55 | :param creation_ts: The timestamp in milliseconds. 56 | :param consent_version: The version of the terms of services that the user last 57 | accepted. 58 | """ 59 | cur = self.sydent.db.cursor() 60 | cur.execute( 61 | "insert or ignore into accounts (user_id, created_ts, consent_version) " 62 | "values (?, ?, ?)", 63 | (user_id, creation_ts, consent_version), 64 | ) 65 | self.sydent.db.commit() 66 | 67 | def setConsentVersion(self, user_id: str, consent_version: Optional[str]) -> None: 68 | """ 69 | Saves that the given user has agreed to all of the terms in the document of the 70 | given version. 71 | 72 | :param user_id: The Matrix ID of the user that has agreed to the terms. 73 | :param consent_version: The version of the document the user has agreed to. 74 | """ 75 | cur = self.sydent.db.cursor() 76 | cur.execute( 77 | "update accounts set consent_version = ? where user_id = ?", 78 | (consent_version, user_id), 79 | ) 80 | self.sydent.db.commit() 81 | 82 | def addToken(self, user_id: str, token: str) -> None: 83 | """ 84 | Stores the authentication token for a given user. 85 | 86 | :param user_id: The Matrix user ID to save the given token for. 87 | :param token: The token to store for that user ID. 88 | """ 89 | cur = self.sydent.db.cursor() 90 | cur.execute( 91 | "insert into tokens (user_id, token) values (?, ?)", 92 | (user_id, token), 93 | ) 94 | self.sydent.db.commit() 95 | 96 | def delToken(self, token: str) -> int: 97 | """ 98 | Deletes an authentication token from the database. 99 | 100 | :param token: The token to delete from the database. 101 | """ 102 | cur = self.sydent.db.cursor() 103 | cur.execute( 104 | "delete from tokens where token = ?", 105 | (token,), 106 | ) 107 | deleted = cur.rowcount 108 | self.sydent.db.commit() 109 | return deleted 110 | -------------------------------------------------------------------------------- /sydent/db/invite_tokens.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2015 OpenMarket Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | -- Note that this SQL file is not up to date, and migrations can be found in sydent/db/sqlitedb.py 18 | 19 | CREATE TABLE IF NOT EXISTS invite_tokens ( 20 | id integer primary key, 21 | medium varchar(16) not null, 22 | address varchar(256) not null, 23 | room_id varchar(256) not null, 24 | sender varchar(256) not null, 25 | token varchar(256) not null, 26 | received_ts bigint, -- When the invite was received by us from the homeserver 27 | sent_ts bigint -- When the token was sent by us to the user 28 | ); 29 | CREATE INDEX IF NOT EXISTS invite_token_medium_address on invite_tokens(medium, address); 30 | CREATE INDEX IF NOT EXISTS invite_token_token on invite_tokens(token); 31 | 32 | CREATE TABLE IF NOT EXISTS ephemeral_public_keys( 33 | id integer primary key, 34 | public_key varchar(256) not null, 35 | verify_count bigint default 0, 36 | persistence_ts bigint 37 | ); 38 | 39 | CREATE UNIQUE INDEX IF NOT EXISTS ephemeral_public_keys_index on ephemeral_public_keys(public_key); 40 | -------------------------------------------------------------------------------- /sydent/db/peers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING, Dict, List, Optional, Tuple 16 | 17 | from sydent.replication.peer import RemotePeer 18 | 19 | if TYPE_CHECKING: 20 | from sydent.sydent import Sydent 21 | 22 | 23 | class PeerStore: 24 | def __init__(self, sydent: "Sydent") -> None: 25 | self.sydent = sydent 26 | 27 | def getPeerByName(self, name: str) -> Optional[RemotePeer]: 28 | """ 29 | Retrieves a remote peer using it's server name. 30 | 31 | :param name: The server name of the peer. 32 | 33 | :return: The retrieved peer. 34 | """ 35 | cur = self.sydent.db.cursor() 36 | res = cur.execute( 37 | "select p.name, p.port, p.lastSentVersion, pk.alg, pk.key from peers p, peer_pubkeys pk " 38 | "where p.name = ? and pk.peername = p.name and p.active = 1", 39 | (name,), 40 | ) 41 | 42 | # Type safety: if the query returns no rows, pubkeys will be empty 43 | # and we'll return None before using serverName. Otherwise, we'll read 44 | # at least one row and assign serverName a string value, because the 45 | # `name` column is declared `not null` in the DB. 46 | serverName: str = None # type: ignore[assignment] 47 | port: Optional[int] = None 48 | lastSentVer: Optional[int] = None 49 | pubkeys: Dict[str, str] = {} 50 | 51 | row: Tuple[str, Optional[int], Optional[int], str, str] 52 | for row in res.fetchall(): 53 | serverName = row[0] 54 | port = row[1] 55 | lastSentVer = row[2] 56 | pubkeys[row[3]] = row[4] 57 | 58 | if len(pubkeys) == 0: 59 | return None 60 | 61 | p = RemotePeer(self.sydent, serverName, port, pubkeys, lastSentVer) 62 | 63 | return p 64 | 65 | def getAllPeers(self) -> List[RemotePeer]: 66 | """ 67 | Retrieve all of the remote peers from the database. 68 | 69 | :return: A list of the remote peers this server knows about. 70 | """ 71 | cur = self.sydent.db.cursor() 72 | res = cur.execute( 73 | "select p.name, p.port, p.lastSentVersion, pk.alg, pk.key from peers p, peer_pubkeys pk " 74 | "where pk.peername = p.name and p.active = 1" 75 | ) 76 | 77 | peers = [] 78 | 79 | # Safety: we need to convince ourselves that `peername` will be not None 80 | # when passed to `RemotePeer`. 81 | # 82 | # If `res` is empty, then `pubkeys` will start empty and never be written to. 83 | # So we will never create a `RemotePeer`. That's fine. 84 | # 85 | # Otherwise we process at least one row. The first row we process will 86 | # satisfy `row[0] is not None` because `name` is nonnull in the schema. 87 | # `pubkeys` will be empty, so we skip the innermost `if` and assign peername 88 | # to be a string. There are no further assignments of `None` to `peername`; 89 | # it will be a string whenever we use it. 90 | peername: str = None # type: ignore[assignment] 91 | port = None 92 | lastSentVer = None 93 | pubkeys: Dict[str, str] = {} 94 | 95 | row: Tuple[str, Optional[int], Optional[int], str, str] 96 | for row in res.fetchall(): 97 | if row[0] != peername: 98 | if len(pubkeys) > 0: 99 | p = RemotePeer(self.sydent, peername, port, pubkeys, lastSentVer) 100 | peers.append(p) 101 | pubkeys = {} 102 | peername = row[0] 103 | port = row[1] 104 | lastSentVer = row[2] 105 | pubkeys[row[3]] = row[4] 106 | 107 | if len(pubkeys) > 0: 108 | p = RemotePeer(self.sydent, peername, port, pubkeys, lastSentVer) 109 | peers.append(p) 110 | 111 | return peers 112 | 113 | def setLastSentVersionAndPokeSucceeded( 114 | self, 115 | peerName: str, 116 | lastSentVersion: Optional[int], 117 | lastPokeSucceeded: Optional[int], 118 | ) -> None: 119 | """ 120 | Sets the ID of the last association sent to a given peer and the time of the 121 | last successful request sent to that peer. 122 | 123 | :param peerName: The server name of the peer. 124 | :param lastSentVersion: The ID of the last association sent to that peer. 125 | :param lastPokeSucceeded: The timestamp in milliseconds of the last successful 126 | request sent to that peer. 127 | """ 128 | cur = self.sydent.db.cursor() 129 | cur.execute( 130 | "update peers set lastSentVersion = ?, lastPokeSucceededAt = ? " 131 | "where name = ?", 132 | (lastSentVersion, lastPokeSucceeded, peerName), 133 | ) 134 | self.sydent.db.commit() 135 | -------------------------------------------------------------------------------- /sydent/db/peers.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 OpenMarket Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | -- Note that this SQL file is not up to date, and migrations can be found in sydent/db/sqlitedb.py 18 | 19 | CREATE TABLE IF NOT EXISTS peers (id integer primary key, name varchar(255) not null, port integer default null, lastSentVersion integer, lastPokeSucceededAt integer, active integer not null default 0); 20 | CREATE UNIQUE INDEX IF NOT EXISTS name on peers(name); 21 | 22 | CREATE TABLE IF NOT EXISTS peer_pubkeys (id integer primary key, peername varchar(255) not null, alg varchar(16) not null, key text not null, foreign key (peername) references peers (name)); 23 | CREATE UNIQUE INDEX IF NOT EXISTS peername_alg on peer_pubkeys(peername, alg); 24 | -------------------------------------------------------------------------------- /sydent/db/terms.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING, List 16 | 17 | if TYPE_CHECKING: 18 | from sydent.sydent import Sydent 19 | 20 | 21 | class TermsStore: 22 | def __init__(self, sydent: "Sydent") -> None: 23 | self.sydent = sydent 24 | 25 | def getAgreedUrls(self, user_id: str) -> List[str]: 26 | """ 27 | Retrieves the URLs of the terms the given user has agreed to. 28 | 29 | :param user_id: Matrix user ID to fetch the URLs for. 30 | 31 | :return: A list of the URLs of the terms accepted by the user. 32 | """ 33 | cur = self.sydent.db.cursor() 34 | res = cur.execute( 35 | "select url from accepted_terms_urls " "where user_id = ?", 36 | (user_id,), 37 | ) 38 | 39 | urls = [] 40 | for (url,) in res: 41 | # Ensure we're dealing with unicode. 42 | if url and isinstance(url, bytes): 43 | url = url.decode("UTF-8") 44 | 45 | urls.append(url) 46 | 47 | return urls 48 | 49 | def addAgreedUrls(self, user_id: str, urls: List[str]) -> None: 50 | """ 51 | Saves that the given user has accepted the terms at the given URLs. 52 | 53 | :param user_id: The Matrix user ID that has accepted the terms. 54 | :param urls: The list of URLs. 55 | """ 56 | cur = self.sydent.db.cursor() 57 | cur.executemany( 58 | "insert or ignore into accepted_terms_urls (user_id, url) values (?, ?)", 59 | ((user_id, u) for u in urls), 60 | ) 61 | self.sydent.db.commit() 62 | -------------------------------------------------------------------------------- /sydent/db/threepid_associations.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014,2017 OpenMarket Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | -- Note that this SQL file is not up to date, and migrations can be found in sydent/db/sqlitedb.py 18 | 19 | CREATE TABLE IF NOT EXISTS local_threepid_associations ( 20 | id integer primary key, 21 | medium varchar(16) not null, 22 | address varchar(256) not null, 23 | mxid varchar(256) not null, 24 | ts integer not null, 25 | notBefore bigint not null, 26 | notAfter bigint not null 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS global_threepid_associations ( 30 | id integer primary key, 31 | medium varchar(16) not null, 32 | address varchar(256) not null, 33 | mxid varchar(256) not null, 34 | ts integer not null, 35 | notBefore bigint not null, 36 | notAfter integer not null, 37 | originServer varchar(255) not null, 38 | originId integer not null, 39 | sgAssoc text not null 40 | ); 41 | CREATE UNIQUE INDEX IF NOT EXISTS originServer_originId on global_threepid_associations (originServer, originId); 42 | -------------------------------------------------------------------------------- /sydent/db/threepid_validation.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2014 OpenMarket Ltd 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | -- Note that this SQL file is not up to date, and migrations can be found in sydent/db/sqlitedb.py 18 | 19 | CREATE TABLE IF NOT EXISTS threepid_validation_sessions (id integer primary key, medium varchar(16) not null, address varchar(256) not null, clientSecret varchar(32) not null, validated int default 0, mtime bigint not null); 20 | CREATE TABLE IF NOT EXISTS threepid_token_auths (id integer primary key, validationSession integer not null, token varchar(32) not null, sendAttemptNumber integer not null, foreign key (validationSession) references threepid_validations(id)); 21 | -------------------------------------------------------------------------------- /sydent/hs_federation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/hs_federation/__init__.py -------------------------------------------------------------------------------- /sydent/hs_federation/types.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import attr 4 | from typing_extensions import TypedDict 5 | 6 | from sydent.types import JsonDict 7 | 8 | 9 | class VerifyKey(TypedDict): 10 | key: str 11 | 12 | 13 | VerifyKeys = Dict[str, VerifyKey] 14 | 15 | 16 | @attr.s(frozen=True, slots=True, auto_attribs=True) 17 | class CachedVerificationKeys: 18 | verify_keys: VerifyKeys 19 | valid_until_ts: int 20 | 21 | 22 | # key: "signing key identifier"; value: signature encoded as unpadded base 64 23 | # See https://spec.matrix.org/unstable/appendices/#signing-details 24 | Signature = Dict[str, str] 25 | 26 | 27 | @attr.s(frozen=True, slots=True, auto_attribs=True) 28 | class SignedMatrixRequest: 29 | method: bytes 30 | uri: bytes 31 | destination_is: str 32 | signatures: Dict[str, Signature] 33 | origin: str 34 | content: JsonDict 35 | -------------------------------------------------------------------------------- /sydent/http/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/http/__init__.py -------------------------------------------------------------------------------- /sydent/http/auth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING, Optional 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.db.accounts import AccountStore 21 | from sydent.http.servlets import MatrixRestError, get_args 22 | from sydent.terms.terms import get_terms 23 | 24 | if TYPE_CHECKING: 25 | from sydent.sydent import Sydent 26 | from sydent.users.accounts import Account 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | def tokenFromRequest(request: Request) -> Optional[str]: 32 | """Extract token from header of query parameter. 33 | 34 | :param request: The request to look for an access token in. 35 | 36 | :return: The token or None if not found 37 | """ 38 | token = None 39 | # check for Authorization header first 40 | authHeader = request.getHeader("Authorization") 41 | if authHeader is not None and authHeader.startswith("Bearer "): 42 | token = authHeader[len("Bearer ") :] 43 | 44 | # no? try access_token query param 45 | if token is None: 46 | args = get_args(request, ("access_token",), required=False) 47 | token = args.get("access_token") 48 | 49 | return token 50 | 51 | 52 | def authV2( 53 | sydent: "Sydent", 54 | request: Request, 55 | requireTermsAgreed: bool = True, 56 | ) -> "Account": 57 | """For v2 APIs check that the request has a valid access token associated with it 58 | 59 | :param sydent: The Sydent instance to use. 60 | :param request: The request to look for an access token in. 61 | :param requireTermsAgreed: Whether to deny authentication if the user hasn't accepted 62 | the terms of service. 63 | 64 | :returns Account: The account object if there is correct auth 65 | :raises MatrixRestError: If the request is v2 but could not be authed or the user has 66 | not accepted terms. 67 | """ 68 | token = tokenFromRequest(request) 69 | 70 | if token is None: 71 | raise MatrixRestError(401, "M_UNAUTHORIZED", "Unauthorized") 72 | 73 | accountStore = AccountStore(sydent) 74 | 75 | account = accountStore.getAccountByToken(token) 76 | if account is None: 77 | raise MatrixRestError(401, "M_UNAUTHORIZED", "Unauthorized") 78 | 79 | if requireTermsAgreed: 80 | terms = get_terms(sydent) 81 | if ( 82 | terms.getMasterVersion() is not None 83 | and account.consentVersion != terms.getMasterVersion() 84 | ): 85 | raise MatrixRestError(403, "M_TERMS_NOT_SIGNED", "Terms not signed") 86 | 87 | return account 88 | -------------------------------------------------------------------------------- /sydent/http/federation_tls_options.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 New Vector Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import logging 15 | from typing import Callable 16 | 17 | from OpenSSL import SSL 18 | from twisted.internet import ssl 19 | from twisted.internet.abstract import isIPAddress, isIPv6Address 20 | from twisted.internet.interfaces import IOpenSSLClientConnectionCreator 21 | from twisted.protocols.tls import TLSMemoryBIOProtocol 22 | from twisted.python.failure import Failure 23 | from zope.interface import implementer 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | F = Callable[[SSL.Connection, int, int], None] 28 | 29 | 30 | def _tolerateErrors(wrapped: F) -> F: 31 | """ 32 | Wrap up an info_callback for pyOpenSSL so that if something goes wrong 33 | the error is immediately logged and the connection is dropped if possible. 34 | This is a copy of twisted.internet._sslverify._tolerateErrors. For 35 | documentation, see the twisted documentation. 36 | """ 37 | 38 | def infoCallback(connection: SSL.Connection, where: int, ret: int) -> None: 39 | try: 40 | return wrapped(connection, where, ret) 41 | except BaseException: 42 | f = Failure() 43 | logger.exception("Error during info_callback") 44 | connection.get_app_data().failVerification(f) 45 | 46 | return infoCallback 47 | 48 | 49 | def _idnaBytes(text: str) -> bytes: 50 | """ 51 | Convert some text typed by a human into some ASCII bytes. This is a 52 | copy of twisted.internet._idna._idnaBytes. For documentation, see the 53 | twisted documentation. 54 | """ 55 | try: 56 | import idna 57 | except ImportError: 58 | return text.encode("idna") 59 | else: 60 | return idna.encode(text) 61 | 62 | 63 | @implementer(IOpenSSLClientConnectionCreator) 64 | class ClientTLSOptions: 65 | """ 66 | Client creator for TLS without certificate identity verification. This is a 67 | copy of twisted.internet._sslverify.ClientTLSOptions with the identity 68 | verification left out. For documentation, see the twisted documentation. 69 | """ 70 | 71 | def __init__(self, hostname: str, ctx: SSL.Context): 72 | self._ctx = ctx 73 | 74 | if isIPAddress(hostname) or isIPv6Address(hostname): 75 | self._hostnameBytes = hostname.encode("ascii") 76 | self._sendSNI = False 77 | else: 78 | self._hostnameBytes = _idnaBytes(hostname) 79 | self._sendSNI = True 80 | 81 | ctx.set_info_callback(_tolerateErrors(self._identityVerifyingInfoCallback)) 82 | 83 | def clientConnectionForTLS( 84 | self, tlsProtocol: TLSMemoryBIOProtocol 85 | ) -> SSL.Connection: 86 | context = self._ctx 87 | connection = SSL.Connection(context, None) 88 | connection.set_app_data(tlsProtocol) 89 | return connection 90 | 91 | def _identityVerifyingInfoCallback( 92 | self, connection: SSL.Connection, where: int, ret: int 93 | ) -> None: 94 | # Literal IPv4 and IPv6 addresses are not permitted 95 | # as host names according to the RFCs 96 | if where & SSL.SSL_CB_HANDSHAKE_START and self._sendSNI: 97 | connection.set_tlsext_host_name(self._hostnameBytes) 98 | 99 | 100 | class ClientTLSOptionsFactory: 101 | """Factory for Twisted ClientTLSOptions that are used to make connections 102 | to remote servers for federation.""" 103 | 104 | def __init__(self, verify_requests: bool): 105 | if verify_requests: 106 | self._options = ssl.CertificateOptions(trustRoot=ssl.platformTrust()) 107 | else: 108 | self._options = ssl.CertificateOptions() 109 | 110 | def get_options(self, host: str) -> ClientTLSOptions: 111 | # Use _makeContext so that we get a fresh OpenSSL CTX each time. 112 | return ClientTLSOptions(host, self._options._makeContext()) 113 | -------------------------------------------------------------------------------- /sydent/http/httpsclient.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import logging 17 | from io import BytesIO 18 | from typing import TYPE_CHECKING, Optional 19 | 20 | from twisted.internet.defer import Deferred 21 | from twisted.internet.interfaces import IOpenSSLClientConnectionCreator 22 | from twisted.internet.ssl import optionsForClientTLS 23 | from twisted.web.client import Agent, FileBodyProducer, Response 24 | from twisted.web.http_headers import Headers 25 | from twisted.web.iweb import IPolicyForHTTPS 26 | from zope.interface import implementer 27 | 28 | from sydent.types import JsonDict 29 | 30 | if TYPE_CHECKING: 31 | from sydent.sydent import Sydent 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | class ReplicationHttpsClient: 37 | """ 38 | An HTTPS client specifically for talking replication to other Matrix Identity Servers 39 | (ie. presents our replication SSL certificate and validates peer SSL certificates as we would in the 40 | replication HTTPS server) 41 | """ 42 | 43 | def __init__(self, sydent: "Sydent") -> None: 44 | self.sydent = sydent 45 | self.agent: Optional[Agent] = None 46 | 47 | if self.sydent.sslComponents.myPrivateCertificate: 48 | # We will already have logged a warn if this is absent, so don't do it again 49 | # cert = self.sydent.sslComponents.myPrivateCertificate 50 | # self.certOptions = twisted.internet.ssl.CertificateOptions(privateKey=cert.privateKey.original, 51 | # certificate=cert.original, 52 | # trustRoot=self.sydent.sslComponents.trustRoot) 53 | self.agent = Agent(self.sydent.reactor, SydentPolicyForHTTPS(self.sydent)) 54 | 55 | def postJson( 56 | self, uri: str, jsonObject: JsonDict 57 | ) -> Optional["Deferred[Response]"]: 58 | """ 59 | Sends an POST request over HTTPS. 60 | 61 | :param uri: The URI to send the request to. 62 | :param jsonObject: The request's body. 63 | 64 | :return: The request's response. 65 | """ 66 | logger.debug("POSTing request to %s", uri) 67 | if not self.agent: 68 | logger.error("HTTPS post attempted but HTTPS is not configured") 69 | return None 70 | 71 | headers = Headers( 72 | {"Content-Type": ["application/json"], "User-Agent": ["Sydent"]} 73 | ) 74 | 75 | json_bytes = json.dumps(jsonObject).encode("utf8") 76 | reqDeferred = self.agent.request( 77 | b"POST", uri.encode("utf8"), headers, FileBodyProducer(BytesIO(json_bytes)) 78 | ) 79 | 80 | return reqDeferred 81 | 82 | 83 | @implementer(IPolicyForHTTPS) 84 | class SydentPolicyForHTTPS: 85 | def __init__(self, sydent: "Sydent") -> None: 86 | self.sydent = sydent 87 | 88 | def creatorForNetloc( 89 | self, hostname: bytes, port: int 90 | ) -> IOpenSSLClientConnectionCreator: 91 | return optionsForClientTLS( 92 | hostname.decode("ascii"), 93 | trustRoot=self.sydent.sslComponents.trustRoot, 94 | clientCertificate=self.sydent.sslComponents.myPrivateCertificate, 95 | ) 96 | -------------------------------------------------------------------------------- /sydent/http/servlets/accountservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | from twisted.web.server import Request 18 | 19 | from sydent.http.auth import authV2 20 | from sydent.http.servlets import SydentResource, jsonwrap, send_cors 21 | from sydent.types import JsonDict 22 | 23 | if TYPE_CHECKING: 24 | from sydent.sydent import Sydent 25 | 26 | 27 | class AccountServlet(SydentResource): 28 | isLeaf = False 29 | 30 | def __init__(self, syd: "Sydent") -> None: 31 | super().__init__() 32 | self.sydent = syd 33 | 34 | @jsonwrap 35 | def render_GET(self, request: Request) -> JsonDict: 36 | """ 37 | Return information about the user's account 38 | (essentially just a 'who am i') 39 | """ 40 | send_cors(request) 41 | 42 | account = authV2(self.sydent, request) 43 | 44 | return { 45 | "user_id": account.userId, 46 | } 47 | 48 | def render_OPTIONS(self, request: Request) -> bytes: 49 | send_cors(request) 50 | return b"" 51 | -------------------------------------------------------------------------------- /sydent/http/servlets/authenticated_bind_threepid_servlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 New Vector Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | from twisted.web.server import Request 18 | 19 | from sydent.http.servlets import SydentResource, get_args, jsonwrap, send_cors 20 | from sydent.types import JsonDict 21 | 22 | if TYPE_CHECKING: 23 | from sydent.sydent import Sydent 24 | 25 | 26 | class AuthenticatedBindThreePidServlet(SydentResource): 27 | """A servlet which allows a caller to bind any 3pid they want to an mxid 28 | 29 | It is assumed that authentication happens out of band 30 | """ 31 | 32 | def __init__(self, sydent: "Sydent") -> None: 33 | super().__init__() 34 | self.sydent = sydent 35 | 36 | @jsonwrap 37 | def render_POST(self, request: Request) -> JsonDict: 38 | send_cors(request) 39 | args = get_args(request, ("medium", "address", "mxid")) 40 | 41 | return self.sydent.threepidBinder.addBinding( 42 | args["medium"], 43 | args["address"], 44 | args["mxid"], 45 | ) 46 | 47 | def render_OPTIONS(self, request: Request) -> bytes: 48 | send_cors(request) 49 | return b"" 50 | -------------------------------------------------------------------------------- /sydent/http/servlets/authenticated_unbind_threepid_servlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Dirk Klimpel 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | from twisted.web.server import Request 18 | 19 | from sydent.http.servlets import SydentResource, get_args, jsonwrap, send_cors 20 | from sydent.types import JsonDict 21 | 22 | if TYPE_CHECKING: 23 | from sydent.sydent import Sydent 24 | 25 | 26 | class AuthenticatedUnbindThreePidServlet(SydentResource): 27 | """A servlet which allows a caller to unbind any 3pid they want from an mxid 28 | 29 | It is assumed that authentication happens out of band 30 | """ 31 | 32 | def __init__(self, sydent: "Sydent") -> None: 33 | super().__init__() 34 | self.sydent = sydent 35 | 36 | @jsonwrap 37 | def render_POST(self, request: Request) -> JsonDict: 38 | send_cors(request) 39 | args = get_args(request, ("medium", "address", "mxid")) 40 | 41 | threepid = {"medium": args["medium"], "address": args["address"]} 42 | 43 | self.sydent.threepidBinder.removeBinding( 44 | threepid, 45 | args["mxid"], 46 | ) 47 | return {} 48 | 49 | def render_OPTIONS(self, request: Request) -> bytes: 50 | send_cors(request) 51 | return b"" 52 | -------------------------------------------------------------------------------- /sydent/http/servlets/blindlysignstuffservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING 17 | 18 | import signedjson.key 19 | import signedjson.sign 20 | from twisted.web.server import Request 21 | 22 | from sydent.db.invite_tokens import JoinTokenStore 23 | from sydent.http.auth import authV2 24 | from sydent.http.servlets import ( 25 | MatrixRestError, 26 | SydentResource, 27 | get_args, 28 | jsonwrap, 29 | send_cors, 30 | ) 31 | from sydent.types import JsonDict 32 | 33 | if TYPE_CHECKING: 34 | from sydent.sydent import Sydent 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | class BlindlySignStuffServlet(SydentResource): 40 | isLeaf = True 41 | 42 | def __init__(self, syd: "Sydent", require_auth: bool = False) -> None: 43 | super().__init__() 44 | self.sydent = syd 45 | self.server_name = syd.config.general.server_name 46 | self.tokenStore = JoinTokenStore(syd) 47 | self.require_auth = require_auth 48 | 49 | @jsonwrap 50 | def render_POST(self, request: Request) -> JsonDict: 51 | send_cors(request) 52 | 53 | if self.require_auth: 54 | authV2(self.sydent, request) 55 | 56 | args = get_args(request, ("private_key", "token", "mxid")) 57 | 58 | private_key_base64 = args["private_key"] 59 | token = args["token"] 60 | mxid = args["mxid"] 61 | 62 | sender = self.tokenStore.getSenderForToken(token) 63 | if sender is None: 64 | raise MatrixRestError(404, "M_UNRECOGNIZED", "Didn't recognize token") 65 | 66 | to_sign = { 67 | "mxid": mxid, 68 | "sender": sender, 69 | "token": token, 70 | } 71 | try: 72 | private_key = signedjson.key.decode_signing_key_base64( 73 | "ed25519", "0", private_key_base64 74 | ) 75 | signed: JsonDict = signedjson.sign.sign_json( 76 | to_sign, self.server_name, private_key 77 | ) 78 | except Exception: 79 | logger.exception("signing failed") 80 | raise MatrixRestError(500, "M_UNKNOWN", "Internal Server Error") 81 | 82 | return signed 83 | 84 | def render_OPTIONS(self, request: Request) -> bytes: 85 | send_cors(request) 86 | return b"" 87 | -------------------------------------------------------------------------------- /sydent/http/servlets/bulklookupservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.db.threepid_associations import GlobalAssociationStore 21 | from sydent.http.servlets import ( 22 | MatrixRestError, 23 | SydentResource, 24 | get_args, 25 | jsonwrap, 26 | send_cors, 27 | ) 28 | from sydent.types import JsonDict 29 | 30 | if TYPE_CHECKING: 31 | from sydent.sydent import Sydent 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | class BulkLookupServlet(SydentResource): 37 | isLeaf = True 38 | 39 | def __init__(self, syd: "Sydent") -> None: 40 | super().__init__() 41 | self.sydent = syd 42 | 43 | @jsonwrap 44 | def render_POST(self, request: Request) -> JsonDict: 45 | """ 46 | Bulk-lookup for threepids. 47 | Params: 'threepids': list of threepids, each of which is a list of medium, address 48 | Returns: Object with key 'threepids', which is a list of results where each result 49 | is a 3 item list of medium, address, mxid 50 | Note that results are not streamed to the client. 51 | Threepids for which no mapping is found are omitted. 52 | """ 53 | send_cors(request) 54 | 55 | args = get_args(request, ("threepids",)) 56 | 57 | threepids = args["threepids"] 58 | if not isinstance(threepids, list): 59 | raise MatrixRestError(400, "M_INVALID_PARAM", "threepids must be a list") 60 | 61 | logger.info("Bulk lookup of %d threepids", len(threepids)) 62 | 63 | globalAssocStore = GlobalAssociationStore(self.sydent) 64 | results = globalAssocStore.getMxids(threepids) 65 | 66 | return {"threepids": results} 67 | 68 | def render_OPTIONS(self, request: Request) -> bytes: 69 | send_cors(request) 70 | return b"" 71 | -------------------------------------------------------------------------------- /sydent/http/servlets/cors_servlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Travis Ralston 2 | # Copyright 2018 New Vector Ltd 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.http.servlets import SydentResource, jsonwrap, send_cors 21 | from sydent.types import JsonDict 22 | 23 | if TYPE_CHECKING: 24 | from sydent.sydent import Sydent 25 | 26 | 27 | class CorsServlet(SydentResource): 28 | isLeaf = False 29 | 30 | def __init__(self, syd: "Sydent") -> None: 31 | super().__init__() 32 | self.sydent = syd 33 | 34 | @jsonwrap 35 | def render_GET(self, request: Request) -> JsonDict: 36 | send_cors(request) 37 | return {} 38 | 39 | def render_OPTIONS(self, request: Request) -> bytes: 40 | send_cors(request) 41 | return b"" 42 | -------------------------------------------------------------------------------- /sydent/http/servlets/getvalidated3pidservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | from twisted.web.server import Request 18 | 19 | from sydent.db.valsession import ThreePidValSessionStore 20 | from sydent.http.auth import authV2 21 | from sydent.http.servlets import SydentResource, get_args, jsonwrap, send_cors 22 | from sydent.types import JsonDict 23 | from sydent.util.stringutils import is_valid_client_secret 24 | from sydent.validators import ( 25 | IncorrectClientSecretException, 26 | InvalidSessionIdException, 27 | SessionExpiredException, 28 | SessionNotValidatedException, 29 | ) 30 | 31 | if TYPE_CHECKING: 32 | from sydent.sydent import Sydent 33 | 34 | 35 | class GetValidated3pidServlet(SydentResource): 36 | isLeaf = True 37 | 38 | def __init__(self, syd: "Sydent", require_auth: bool = False) -> None: 39 | super().__init__() 40 | self.sydent = syd 41 | self.require_auth = require_auth 42 | 43 | @jsonwrap 44 | def render_GET(self, request: Request) -> JsonDict: 45 | send_cors(request) 46 | if self.require_auth: 47 | authV2(self.sydent, request) 48 | 49 | args = get_args(request, ("sid", "client_secret")) 50 | 51 | sid = args["sid"] 52 | clientSecret = args["client_secret"] 53 | 54 | if not is_valid_client_secret(clientSecret): 55 | request.setResponseCode(400) 56 | return { 57 | "errcode": "M_INVALID_PARAM", 58 | "error": "Invalid client_secret provided", 59 | } 60 | 61 | valSessionStore = ThreePidValSessionStore(self.sydent) 62 | 63 | noMatchError = { 64 | "errcode": "M_NO_VALID_SESSION", 65 | "error": "No valid session was found matching that sid and client secret", 66 | } 67 | 68 | try: 69 | s = valSessionStore.getValidatedSession(sid, clientSecret) 70 | except (IncorrectClientSecretException, InvalidSessionIdException): 71 | request.setResponseCode(404) 72 | return noMatchError 73 | except SessionExpiredException: 74 | request.setResponseCode(400) 75 | return { 76 | "errcode": "M_SESSION_EXPIRED", 77 | "error": "This validation session has expired: call requestToken again", 78 | } 79 | except SessionNotValidatedException: 80 | request.setResponseCode(400) 81 | return { 82 | "errcode": "M_SESSION_NOT_VALIDATED", 83 | "error": "This validation session has not yet been completed", 84 | } 85 | 86 | return {"medium": s.medium, "address": s.address, "validated_at": s.mtime} 87 | 88 | def render_OPTIONS(self, request: Request) -> bytes: 89 | send_cors(request) 90 | return b"" 91 | -------------------------------------------------------------------------------- /sydent/http/servlets/hashdetailsservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.http.auth import authV2 21 | from sydent.http.servlets import SydentResource, jsonwrap, send_cors 22 | from sydent.types import JsonDict 23 | 24 | if TYPE_CHECKING: 25 | from sydent.sydent import Sydent 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class HashDetailsServlet(SydentResource): 31 | isLeaf = True 32 | known_algorithms = ["sha256", "none"] 33 | 34 | def __init__(self, syd: "Sydent", lookup_pepper: str) -> None: 35 | super().__init__() 36 | self.sydent = syd 37 | self.lookup_pepper = lookup_pepper 38 | 39 | @jsonwrap 40 | def render_GET(self, request: Request) -> JsonDict: 41 | """ 42 | Return the hashing algorithms and pepper that this IS supports. The 43 | pepper included in the response is stored in the database, or 44 | otherwise generated. 45 | 46 | Returns: An object containing an array of hashing algorithms the 47 | server supports, and a `lookup_pepper` field, which is a 48 | server-defined value that the client should include in the 3PID 49 | information before hashing. 50 | """ 51 | send_cors(request) 52 | 53 | authV2(self.sydent, request) 54 | 55 | return { 56 | "algorithms": self.known_algorithms, 57 | "lookup_pepper": self.lookup_pepper, 58 | } 59 | 60 | def render_OPTIONS(self, request: Request) -> bytes: 61 | send_cors(request) 62 | return b"" 63 | -------------------------------------------------------------------------------- /sydent/http/servlets/logoutservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.db.accounts import AccountStore 21 | from sydent.http.auth import authV2, tokenFromRequest 22 | from sydent.http.servlets import MatrixRestError, SydentResource, jsonwrap, send_cors 23 | from sydent.types import JsonDict 24 | 25 | if TYPE_CHECKING: 26 | from sydent.sydent import Sydent 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class LogoutServlet(SydentResource): 32 | isLeaf = True 33 | 34 | def __init__(self, syd: "Sydent") -> None: 35 | super().__init__() 36 | self.sydent = syd 37 | 38 | @jsonwrap 39 | def render_POST(self, request: Request) -> JsonDict: 40 | """ 41 | Invalidate the given access token 42 | """ 43 | send_cors(request) 44 | 45 | authV2(self.sydent, request, False) 46 | 47 | token = tokenFromRequest(request) 48 | if token is None: 49 | raise MatrixRestError(400, "M_MISSING_PARAMS", "Missing token") 50 | 51 | accountStore = AccountStore(self.sydent) 52 | accountStore.delToken(token) 53 | return {} 54 | 55 | def render_OPTIONS(self, request: Request) -> bytes: 56 | send_cors(request) 57 | return b"" 58 | -------------------------------------------------------------------------------- /sydent/http/servlets/lookupservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014,2017 OpenMarket Ltd 2 | # Copyright 2019 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import logging 17 | from typing import TYPE_CHECKING 18 | 19 | import signedjson.sign 20 | from twisted.web.server import Request 21 | 22 | from sydent.db.threepid_associations import GlobalAssociationStore 23 | from sydent.http.servlets import SydentResource, get_args, jsonwrap, send_cors 24 | from sydent.types import JsonDict 25 | from sydent.util import json_decoder 26 | 27 | if TYPE_CHECKING: 28 | from sydent.sydent import Sydent 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class LookupServlet(SydentResource): 34 | isLeaf = True 35 | 36 | def __init__(self, syd: "Sydent") -> None: 37 | super().__init__() 38 | self.sydent = syd 39 | 40 | @jsonwrap 41 | def render_GET(self, request: Request) -> JsonDict: 42 | """ 43 | Look up an individual threepid. 44 | 45 | ** DEPRECATED ** 46 | 47 | Params: 'medium': the medium of the threepid 48 | 'address': the address of the threepid 49 | Returns: A signed association if the threepid has a corresponding mxid, otherwise the empty object. 50 | """ 51 | send_cors(request) 52 | 53 | args = get_args(request, ("medium", "address")) 54 | 55 | medium = args["medium"] 56 | address = args["address"] 57 | 58 | globalAssocStore = GlobalAssociationStore(self.sydent) 59 | 60 | sgassoc_raw = globalAssocStore.signedAssociationStringForThreepid( 61 | medium, address 62 | ) 63 | 64 | if not sgassoc_raw: 65 | return {} 66 | 67 | # TODO validate this really is a dict 68 | sgassoc: JsonDict = json_decoder.decode(sgassoc_raw) 69 | if self.sydent.config.general.server_name not in sgassoc["signatures"]: 70 | # We have not yet worked out what the proper trust model should be. 71 | # 72 | # Maybe clients implicitly trust a server they talk to (and so we 73 | # should sign every assoc we return as ourselves, so they can 74 | # verify this). 75 | # 76 | # Maybe clients really want to know what server did the original 77 | # verification, and want to only know exactly who signed the assoc. 78 | # 79 | # Until we work out what we should do, sign all assocs we return as 80 | # ourself. This is vaguely ok because there actually is only one 81 | # identity server, but it happens to have two names (matrix.org and 82 | # vector.im), and so we're not really lying too much. 83 | # 84 | # We do this when we return assocs, not when we receive them over 85 | # replication, so that we can undo this decision in the future if 86 | # we wish, without having destroyed the raw underlying data. 87 | sgassoc = signedjson.sign.sign_json( 88 | sgassoc, 89 | self.sydent.config.general.server_name, 90 | self.sydent.keyring.ed25519, 91 | ) 92 | return sgassoc 93 | 94 | def render_OPTIONS(self, request: Request) -> bytes: 95 | send_cors(request) 96 | return b"" 97 | -------------------------------------------------------------------------------- /sydent/http/servlets/pubkeyservlets.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | from twisted.web.server import Request 18 | from unpaddedbase64 import encode_base64 19 | 20 | from sydent.db.invite_tokens import JoinTokenStore 21 | from sydent.http.servlets import SydentResource, get_args, jsonwrap 22 | from sydent.types import JsonDict 23 | 24 | if TYPE_CHECKING: 25 | from sydent.sydent import Sydent 26 | 27 | 28 | class Ed25519Servlet(SydentResource): 29 | isLeaf = True 30 | 31 | def __init__(self, syd: "Sydent") -> None: 32 | super().__init__() 33 | self.sydent = syd 34 | 35 | @jsonwrap 36 | def render_GET(self, request: Request) -> JsonDict: 37 | pubKey = self.sydent.keyring.ed25519.verify_key 38 | pubKeyBase64 = encode_base64(pubKey.encode()) 39 | 40 | return {"public_key": pubKeyBase64} 41 | 42 | 43 | class PubkeyIsValidServlet(SydentResource): 44 | isLeaf = True 45 | 46 | def __init__(self, syd: "Sydent") -> None: 47 | super().__init__() 48 | self.sydent = syd 49 | 50 | @jsonwrap 51 | def render_GET(self, request: Request) -> JsonDict: 52 | args = get_args(request, ("public_key",)) 53 | 54 | pubKey = self.sydent.keyring.ed25519.verify_key 55 | pubKeyBase64 = encode_base64(pubKey.encode()) 56 | 57 | return {"valid": args["public_key"] == pubKeyBase64} 58 | 59 | 60 | class EphemeralPubkeyIsValidServlet(SydentResource): 61 | isLeaf = True 62 | 63 | def __init__(self, syd: "Sydent") -> None: 64 | super().__init__() 65 | self.joinTokenStore = JoinTokenStore(syd) 66 | 67 | @jsonwrap 68 | def render_GET(self, request: Request) -> JsonDict: 69 | args = get_args(request, ("public_key",)) 70 | publicKey = args["public_key"] 71 | 72 | return { 73 | "valid": self.joinTokenStore.validateEphemeralPublicKey(publicKey), 74 | } 75 | -------------------------------------------------------------------------------- /sydent/http/servlets/registerservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import urllib 17 | from http import HTTPStatus 18 | from json import JSONDecodeError 19 | from typing import TYPE_CHECKING, Dict 20 | 21 | from twisted.internet.error import ConnectError, DNSLookupError 22 | from twisted.web.client import ResponseFailed 23 | from twisted.web.server import Request 24 | 25 | from sydent.http.httpclient import FederationHttpClient 26 | from sydent.http.servlets import SydentResource, asyncjsonwrap, get_args, send_cors 27 | from sydent.types import JsonDict 28 | from sydent.users.tokens import issueToken 29 | from sydent.util.stringutils import is_valid_matrix_server_name 30 | 31 | if TYPE_CHECKING: 32 | from sydent.sydent import Sydent 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class RegisterServlet(SydentResource): 38 | isLeaf = True 39 | 40 | def __init__(self, syd: "Sydent") -> None: 41 | super().__init__() 42 | self.sydent = syd 43 | self.client = FederationHttpClient(self.sydent) 44 | 45 | @asyncjsonwrap 46 | async def render_POST(self, request: Request) -> JsonDict: 47 | """ 48 | Register with the Identity Server 49 | """ 50 | send_cors(request) 51 | 52 | args = get_args(request, ("matrix_server_name", "access_token")) 53 | 54 | matrix_server = args["matrix_server_name"].lower() 55 | 56 | if self.sydent.config.general.homeserver_allow_list: 57 | if matrix_server not in self.sydent.config.general.homeserver_allow_list: 58 | request.setResponseCode(403) 59 | return { 60 | "errcode": "M_UNAUTHORIZED", 61 | "error": "This homeserver is not authorized to access this server.", 62 | } 63 | 64 | if not is_valid_matrix_server_name(matrix_server): 65 | request.setResponseCode(400) 66 | return { 67 | "errcode": "M_INVALID_PARAM", 68 | "error": "matrix_server_name must be a valid Matrix server name (IP address or hostname)", 69 | } 70 | 71 | def federation_request_problem(error: str) -> Dict[str, str]: 72 | logger.warning(error) 73 | request.setResponseCode(HTTPStatus.INTERNAL_SERVER_ERROR) 74 | return { 75 | "errcode": "M_UNKNOWN", 76 | "error": error, 77 | } 78 | 79 | try: 80 | result = await self.client.get_json( 81 | "matrix://%s/_matrix/federation/v1/openid/userinfo?access_token=%s" 82 | % ( 83 | matrix_server, 84 | urllib.parse.quote(args["access_token"]), 85 | ), 86 | 1024 * 5, 87 | ) 88 | except (DNSLookupError, ConnectError, ResponseFailed) as e: 89 | return federation_request_problem( 90 | f"Unable to contact the Matrix homeserver ({type(e).__name__})" 91 | ) 92 | except JSONDecodeError: 93 | return federation_request_problem( 94 | "The Matrix homeserver returned invalid JSON" 95 | ) 96 | 97 | if "sub" not in result: 98 | return federation_request_problem( 99 | "The Matrix homeserver did not include 'sub' in its response", 100 | ) 101 | 102 | user_id = result["sub"] 103 | 104 | if not isinstance(user_id, str): 105 | return federation_request_problem( 106 | "The Matrix homeserver returned a malformed reply" 107 | ) 108 | 109 | user_id_components = user_id.split(":", 1) 110 | 111 | # Ensure there's a localpart and domain in the returned user ID. 112 | if len(user_id_components) != 2: 113 | return federation_request_problem( 114 | "The Matrix homeserver returned an invalid MXID" 115 | ) 116 | 117 | user_id_server = user_id_components[1] 118 | 119 | if not is_valid_matrix_server_name(user_id_server): 120 | return federation_request_problem( 121 | "The Matrix homeserver returned an invalid MXID" 122 | ) 123 | 124 | if user_id_server != matrix_server: 125 | return federation_request_problem( 126 | "The Matrix homeserver returned a MXID belonging to another homeserver" 127 | ) 128 | 129 | tok = issueToken(self.sydent, user_id) 130 | 131 | # XXX: `token` is correct for the spec, but we released with `access_token` 132 | # for a substantial amount of time. Serve both to make spec-compliant clients 133 | # happy. 134 | return { 135 | "access_token": tok, 136 | "token": tok, 137 | } 138 | 139 | def render_OPTIONS(self, request: Request) -> bytes: 140 | send_cors(request) 141 | return b"" 142 | -------------------------------------------------------------------------------- /sydent/http/servlets/termsservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.db.accounts import AccountStore 21 | from sydent.db.terms import TermsStore 22 | from sydent.http.auth import authV2 23 | from sydent.http.servlets import ( 24 | MatrixRestError, 25 | SydentResource, 26 | get_args, 27 | jsonwrap, 28 | send_cors, 29 | ) 30 | from sydent.terms.terms import get_terms 31 | from sydent.types import JsonDict 32 | 33 | if TYPE_CHECKING: 34 | from sydent.sydent import Sydent 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | class TermsServlet(SydentResource): 40 | isLeaf = True 41 | 42 | def __init__(self, syd: "Sydent") -> None: 43 | super().__init__() 44 | self.sydent = syd 45 | 46 | @jsonwrap 47 | def render_GET(self, request: Request) -> JsonDict: 48 | """ 49 | Get the terms that must be agreed to in order to use this service 50 | Returns: Object describing the terms that require agreement 51 | """ 52 | send_cors(request) 53 | 54 | terms = get_terms(self.sydent) 55 | 56 | return terms.getForClient() 57 | 58 | @jsonwrap 59 | def render_POST(self, request: Request) -> JsonDict: 60 | """ 61 | Mark a set of terms and conditions as having been agreed to 62 | """ 63 | send_cors(request) 64 | 65 | account = authV2(self.sydent, request, False) 66 | 67 | args = get_args(request, ("user_accepts",)) 68 | 69 | user_accepts = args["user_accepts"] 70 | 71 | terms = get_terms(self.sydent) 72 | unknown_urls = list(set(user_accepts) - terms.getUrlSet()) 73 | if len(unknown_urls) > 0: 74 | raise MatrixRestError( 75 | 400, "M_UNKNOWN", "Unrecognised URLs: %s" % (", ".join(unknown_urls),) 76 | ) 77 | 78 | termsStore = TermsStore(self.sydent) 79 | termsStore.addAgreedUrls(account.userId, user_accepts) 80 | 81 | all_accepted_urls = termsStore.getAgreedUrls(account.userId) 82 | 83 | if terms.urlListIsSufficient(all_accepted_urls): 84 | accountStore = AccountStore(self.sydent) 85 | accountStore.setConsentVersion(account.userId, terms.getMasterVersion()) 86 | 87 | return {} 88 | 89 | def render_OPTIONS(self, request: Request) -> bytes: 90 | send_cors(request) 91 | return b"" 92 | -------------------------------------------------------------------------------- /sydent/http/servlets/threepidbindservlet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # Copyright 2019 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from typing import TYPE_CHECKING 17 | 18 | from twisted.web.server import Request 19 | 20 | from sydent.db.valsession import ThreePidValSessionStore 21 | from sydent.http.auth import authV2 22 | from sydent.http.servlets import ( 23 | MatrixRestError, 24 | SydentResource, 25 | get_args, 26 | jsonwrap, 27 | send_cors, 28 | ) 29 | from sydent.types import JsonDict 30 | from sydent.util.stringutils import is_valid_client_secret 31 | from sydent.validators import ( 32 | IncorrectClientSecretException, 33 | InvalidSessionIdException, 34 | SessionExpiredException, 35 | SessionNotValidatedException, 36 | ) 37 | 38 | if TYPE_CHECKING: 39 | from sydent.sydent import Sydent 40 | 41 | 42 | class ThreePidBindServlet(SydentResource): 43 | def __init__(self, sydent: "Sydent", require_auth: bool = False) -> None: 44 | super().__init__() 45 | self.sydent = sydent 46 | self.require_auth = require_auth 47 | 48 | @jsonwrap 49 | def render_POST(self, request: Request) -> JsonDict: 50 | send_cors(request) 51 | 52 | account = None 53 | if self.require_auth: 54 | account = authV2(self.sydent, request) 55 | 56 | args = get_args(request, ("sid", "client_secret", "mxid")) 57 | 58 | sid = args["sid"] 59 | mxid = args["mxid"] 60 | clientSecret = args["client_secret"] 61 | 62 | if not is_valid_client_secret(clientSecret): 63 | raise MatrixRestError( 64 | 400, "M_INVALID_PARAM", "Invalid client_secret provided" 65 | ) 66 | 67 | if account: 68 | # This is a v2 API so only allow binding to the logged in user id 69 | if account.userId != mxid: 70 | raise MatrixRestError( 71 | 403, 72 | "M_UNAUTHORIZED", 73 | "This user is prohibited from binding to the mxid", 74 | ) 75 | 76 | try: 77 | valSessionStore = ThreePidValSessionStore(self.sydent) 78 | s = valSessionStore.getValidatedSession(sid, clientSecret) 79 | except (IncorrectClientSecretException, InvalidSessionIdException): 80 | # Return the same error for not found / bad client secret otherwise 81 | # people can get information about sessions without knowing the 82 | # secret. 83 | raise MatrixRestError( 84 | 404, 85 | "M_NO_VALID_SESSION", 86 | "No valid session was found matching that sid and client secret", 87 | ) 88 | except SessionExpiredException: 89 | raise MatrixRestError( 90 | 400, 91 | "M_SESSION_EXPIRED", 92 | "This validation session has expired: call requestToken again", 93 | ) 94 | except SessionNotValidatedException: 95 | raise MatrixRestError( 96 | 400, 97 | "M_SESSION_NOT_VALIDATED", 98 | "This validation session has not yet been completed", 99 | ) 100 | 101 | res = self.sydent.threepidBinder.addBinding(s.medium, s.address, mxid) 102 | return res 103 | 104 | def render_OPTIONS(self, request: Request) -> bytes: 105 | send_cors(request) 106 | return b"" 107 | -------------------------------------------------------------------------------- /sydent/http/servlets/versions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from twisted.web.server import Request 16 | 17 | from sydent.http.servlets import SydentResource, jsonwrap, send_cors 18 | from sydent.types import JsonDict 19 | 20 | 21 | class VersionsServlet(SydentResource): 22 | isLeaf = True 23 | 24 | @jsonwrap 25 | def render_GET(self, request: Request) -> JsonDict: 26 | """ 27 | Return the supported Matrix versions. 28 | """ 29 | send_cors(request) 30 | 31 | return { 32 | "versions": [ 33 | "r0.1.0", 34 | "r0.2.0", 35 | "r0.2.1", 36 | "r0.3.0", 37 | "v1.1", 38 | "v1.2", 39 | "v1.3", 40 | "v1.4", 41 | "v1.5", 42 | ] 43 | } 44 | 45 | def render_OPTIONS(self, request: Request) -> bytes: 46 | send_cors(request) 47 | return b"" 48 | -------------------------------------------------------------------------------- /sydent/replication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/replication/__init__.py -------------------------------------------------------------------------------- /sydent/replication/pusher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # Copyright 2019 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | import logging 17 | from typing import TYPE_CHECKING, List, Tuple 18 | 19 | import twisted.internet.reactor 20 | import twisted.internet.task 21 | from twisted.internet import defer 22 | 23 | from sydent.db.peers import PeerStore 24 | from sydent.db.threepid_associations import LocalAssociationStore 25 | from sydent.replication.peer import LocalPeer, RemotePeer 26 | from sydent.util import time_msec 27 | 28 | if TYPE_CHECKING: 29 | from sydent.sydent import Sydent 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | # Maximum amount of signed associations to replicate to a peer at a time 34 | ASSOCIATIONS_PUSH_LIMIT = 100 35 | 36 | 37 | class Pusher: 38 | def __init__(self, sydent: "Sydent") -> None: 39 | self.sydent = sydent 40 | self.pushing = False 41 | self.peerStore = PeerStore(self.sydent) 42 | self.local_assoc_store = LocalAssociationStore(self.sydent) 43 | 44 | def setup(self) -> None: 45 | cb = twisted.internet.task.LoopingCall(Pusher.scheduledPush, self) 46 | cb.clock = self.sydent.reactor 47 | cb.start(10.0) 48 | 49 | def doLocalPush(self) -> None: 50 | """ 51 | Synchronously push local associations to this server (ie. copy them to globals table) 52 | The local server is essentially treated the same as any other peer except we don't do 53 | the network round-trip and this function can be used so the association goes into the 54 | global table before the http call returns (so clients know it will be available on at 55 | least the same ID server they used) 56 | """ 57 | localPeer = LocalPeer(self.sydent) 58 | 59 | signedAssocs, _ = self.local_assoc_store.getSignedAssociationsAfterId( 60 | localPeer.lastId, None 61 | ) 62 | 63 | localPeer.pushUpdates(signedAssocs) 64 | 65 | def scheduledPush(self) -> "defer.Deferred[List[Tuple[bool, None]]]": 66 | """Push pending updates to all known remote peers. To be called regularly. 67 | 68 | :returns a deferred.DeferredList of defers, one per peer we're pushing to that will 69 | resolve when pushing to that peer has completed, successfully or otherwise 70 | """ 71 | peers = self.peerStore.getAllPeers() 72 | 73 | # Push to all peers in parallel 74 | dl = [] 75 | for p in peers: 76 | dl.append(defer.ensureDeferred(self._push_to_peer(p))) 77 | return defer.DeferredList(dl) 78 | 79 | async def _push_to_peer(self, p: "RemotePeer") -> None: 80 | """ 81 | For a given peer, retrieves the list of associations that were created since 82 | the last successful push to this peer (limited to ASSOCIATIONS_PUSH_LIMIT) and 83 | sends them. 84 | 85 | :param p: The peer to send associations to. 86 | """ 87 | logger.debug("Looking for updates to push to %s", p.servername) 88 | 89 | # Check if a push operation is already active. If so, don't start another 90 | if p.is_being_pushed_to: 91 | logger.debug( 92 | "Waiting for %s to finish pushing...", p.replication_url_origin 93 | ) 94 | return 95 | 96 | p.is_being_pushed_to = True 97 | 98 | try: 99 | # Push associations 100 | ( 101 | assocs, 102 | latest_assoc_id, 103 | ) = self.local_assoc_store.getSignedAssociationsAfterId( 104 | p.lastSentVersion, ASSOCIATIONS_PUSH_LIMIT 105 | ) 106 | 107 | # If there are no updates left to send, break the loop 108 | if not assocs: 109 | return 110 | 111 | logger.info( 112 | "Pushing %d updates to %s", len(assocs), p.replication_url_origin 113 | ) 114 | result = await p.pushUpdates(assocs) 115 | 116 | self.peerStore.setLastSentVersionAndPokeSucceeded( 117 | p.servername, latest_assoc_id, time_msec() 118 | ) 119 | 120 | logger.info( 121 | "Pushed updates to %s with result %d %s", 122 | p.replication_url_origin, 123 | result.code, 124 | result.phrase, 125 | ) 126 | except Exception: 127 | logger.exception("Error pushing updates to %s", p.replication_url_origin) 128 | finally: 129 | # Whether pushing completed or an error occurred, signal that pushing has finished 130 | p.is_being_pushed_to = False 131 | -------------------------------------------------------------------------------- /sydent/sms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/sms/__init__.py -------------------------------------------------------------------------------- /sydent/sms/types.py: -------------------------------------------------------------------------------- 1 | # See "Request body" section of 2 | # https://www.openmarket.com/docs/Content/apis/v4http/send-json.htm 3 | from typing_extensions import Literal, TypedDict 4 | 5 | TypeOfNumber = Literal[1, 3, 5] 6 | 7 | 8 | class _MessageRequired(TypedDict): 9 | type: Literal["text", "hexEncodedText", "binary", "wapPush"] 10 | content: str 11 | 12 | 13 | class Message(_MessageRequired, total=False): 14 | charset: Literal["GSM", "Latin-1", "UTF-8", "UTF-16"] 15 | validityPeriod: int 16 | url: str 17 | mlc: Literal["reject", "truncate", "segment"] 18 | udh: bool 19 | 20 | 21 | class _DestinationRequired(TypedDict): 22 | address: str 23 | 24 | 25 | class Destination(_DestinationRequired, total=False): 26 | mobileOperatorId: int 27 | 28 | 29 | class _SourceRequired(TypedDict): 30 | address: str 31 | 32 | 33 | class Source(_SourceRequired, total=False): 34 | ton: TypeOfNumber 35 | 36 | 37 | class _MobileTerminateRequired(TypedDict): 38 | # OpenMarket says these are required fields 39 | destination: Destination 40 | message: Message 41 | 42 | 43 | class MobileTerminate(_MobileTerminateRequired, total=False): 44 | # And these are all optional. 45 | interaction: Literal["one-way", "two-way"] 46 | promotional: bool # Ignored, unless we're sending to India 47 | source: Source 48 | # The API also offers optional "options" and "delivery" keys, 49 | # which we don't use 50 | 51 | 52 | class SendSMSBody(TypedDict): 53 | mobileTerminate: MobileTerminate 54 | -------------------------------------------------------------------------------- /sydent/terms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/terms/__init__.py -------------------------------------------------------------------------------- /sydent/threepid/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from typing import Any, Dict, Optional 15 | 16 | import attr 17 | 18 | 19 | def threePidAssocFromDict(d: Dict[str, Any]) -> "ThreepidAssociation": 20 | """Instantiates a ThreepidAssociation from the given dict.""" 21 | assoc = ThreepidAssociation( 22 | d["medium"], 23 | d["address"], 24 | None, # empty lookup_hash digest by default 25 | d["mxid"], 26 | d["ts"], 27 | d["not_before"], 28 | d["not_after"], 29 | ) 30 | return assoc 31 | 32 | 33 | @attr.s(slots=True, auto_attribs=True) 34 | class ThreepidAssociation: 35 | """ 36 | medium: The medium of the 3pid (eg. email) 37 | address: The identifier (eg. email address) 38 | lookup_hash: A hash digest of the 3pid. Can be a str or None 39 | mxid: The matrix ID the 3pid is associated with 40 | ts: The creation timestamp of this association, ms 41 | not_before: The timestamp, in ms, at which this association becomes valid 42 | not_after: The timestamp, in ms, at which this association ceases to be valid 43 | """ 44 | 45 | medium: str 46 | address: str 47 | lookup_hash: Optional[str] 48 | # Note: the next four fields were made optional in schema version 2. 49 | # See sydent.db.sqlitedb.SqliteDatabase._upgradeSchema 50 | mxid: Optional[str] 51 | ts: Optional[int] 52 | not_before: Optional[int] 53 | not_after: Optional[int] 54 | extra_fields: Dict[str, Any] = {} 55 | -------------------------------------------------------------------------------- /sydent/threepid/signer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import TYPE_CHECKING, Any, Dict 16 | 17 | import signedjson.sign 18 | 19 | if TYPE_CHECKING: 20 | from sydent.sydent import Sydent 21 | from sydent.threepid import ThreepidAssociation 22 | 23 | 24 | class Signer: 25 | def __init__(self, sydent: "Sydent") -> None: 26 | self.sydent = sydent 27 | 28 | def signedThreePidAssociation(self, assoc: "ThreepidAssociation") -> Dict[str, Any]: 29 | """ 30 | Signs a 3PID association. 31 | 32 | :param assoc: The association to sign. 33 | 34 | :return: A signed representation of the association. 35 | """ 36 | sgassoc = { 37 | "medium": assoc.medium, 38 | "address": assoc.address, 39 | "mxid": assoc.mxid, 40 | "ts": assoc.ts, 41 | "not_before": assoc.not_before, 42 | "not_after": assoc.not_after, 43 | } 44 | sgassoc.update(assoc.extra_fields) 45 | sgassoc = signedjson.sign.sign_json( 46 | sgassoc, self.sydent.config.general.server_name, self.sydent.keyring.ed25519 47 | ) 48 | return sgassoc 49 | -------------------------------------------------------------------------------- /sydent/types.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import Any, Dict 16 | 17 | JsonDict = Dict[str, Any] 18 | -------------------------------------------------------------------------------- /sydent/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/sydent/users/__init__.py -------------------------------------------------------------------------------- /sydent/users/accounts.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from typing import Optional 15 | 16 | 17 | class Account: 18 | def __init__( 19 | self, user_id: str, creation_ts: int, consent_version: Optional[str] 20 | ) -> None: 21 | """ 22 | :param user_id: The Matrix user ID for the account. 23 | :param creation_ts: The timestamp in milliseconds of the account's creation. 24 | :param consent_version: The version of the terms of services that the user last 25 | accepted. 26 | """ 27 | self.userId = user_id 28 | self.creationTs = creation_ts 29 | self.consentVersion = consent_version 30 | -------------------------------------------------------------------------------- /sydent/users/tokens.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import time 17 | from typing import TYPE_CHECKING 18 | 19 | from sydent.db.accounts import AccountStore 20 | from sydent.util.tokenutils import generateAlphanumericTokenOfLength 21 | 22 | if TYPE_CHECKING: 23 | from sydent.sydent import Sydent 24 | 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def issueToken(sydent: "Sydent", user_id: str) -> str: 30 | """ 31 | Creates an account for the given Matrix user ID, then generates, saves and returns 32 | an access token for that account. 33 | 34 | :param sydent: The Sydent instance to use for storing the token. 35 | :param user_id: The Matrix user ID to issue a token for. 36 | 37 | :return: The access token for that account. 38 | """ 39 | accountStore = AccountStore(sydent) 40 | accountStore.storeAccount(user_id, int(time.time() * 1000), None) 41 | 42 | new_token = generateAlphanumericTokenOfLength(64) 43 | accountStore.addToken(user_id, new_token) 44 | 45 | return new_token 46 | -------------------------------------------------------------------------------- /sydent/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import time 17 | from typing import NoReturn 18 | 19 | 20 | def time_msec() -> int: 21 | """ 22 | Get the current time in milliseconds. 23 | 24 | :return: The current time in milliseconds. 25 | """ 26 | return int(time.time() * 1000) 27 | 28 | 29 | def _reject_invalid_json(val: str) -> NoReturn: 30 | """Do not allow Infinity, -Infinity, or NaN values in JSON.""" 31 | raise ValueError("Invalid JSON value: '%s'" % val) 32 | 33 | 34 | # a custom JSON decoder which will reject Python extensions to JSON. 35 | json_decoder = json.JSONDecoder(parse_constant=_reject_invalid_json) 36 | -------------------------------------------------------------------------------- /sydent/util/hash.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import hashlib 16 | 17 | import unpaddedbase64 18 | 19 | 20 | def sha256_and_url_safe_base64(input_text: str) -> str: 21 | """SHA256 hash an input string, encode the digest as url-safe base64, and 22 | return 23 | 24 | :param input_text: string to hash 25 | 26 | :returns a sha256 hashed and url-safe base64 encoded digest 27 | """ 28 | digest = hashlib.sha256(input_text.encode()).digest() 29 | return unpaddedbase64.encode_base64(digest, urlsafe=True) 30 | -------------------------------------------------------------------------------- /sydent/util/ip_range.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import itertools 15 | from typing import Iterable, Optional 16 | 17 | from netaddr import AddrFormatError, IPNetwork, IPSet 18 | 19 | # IP ranges that are considered private / unroutable / don't make sense. 20 | DEFAULT_IP_RANGE_BLACKLIST = [ 21 | # Localhost 22 | "127.0.0.0/8", 23 | # Private networks. 24 | "10.0.0.0/8", 25 | "172.16.0.0/12", 26 | "192.168.0.0/16", 27 | # Carrier grade NAT. 28 | "100.64.0.0/10", 29 | # Address registry. 30 | "192.0.0.0/24", 31 | # Link-local networks. 32 | "169.254.0.0/16", 33 | # Formerly used for 6to4 relay. 34 | "192.88.99.0/24", 35 | # Testing networks. 36 | "198.18.0.0/15", 37 | "192.0.2.0/24", 38 | "198.51.100.0/24", 39 | "203.0.113.0/24", 40 | # Multicast. 41 | "224.0.0.0/4", 42 | # Localhost 43 | "::1/128", 44 | # Link-local addresses. 45 | "fe80::/10", 46 | # Unique local addresses. 47 | "fc00::/7", 48 | # Testing networks. 49 | "2001:db8::/32", 50 | # Multicast. 51 | "ff00::/8", 52 | # Site-local addresses 53 | "fec0::/10", 54 | ] 55 | 56 | 57 | def generate_ip_set( 58 | ip_addresses: Optional[Iterable[str]], 59 | extra_addresses: Optional[Iterable[str]] = None, 60 | config_path: Optional[Iterable[str]] = None, 61 | ) -> IPSet: 62 | """ 63 | Generate an IPSet from a list of IP addresses or CIDRs. 64 | 65 | Additionally, for each IPv4 network in the list of IP addresses, also 66 | includes the corresponding IPv6 networks. 67 | 68 | This includes: 69 | 70 | * IPv4-Compatible IPv6 Address (see RFC 4291, section 2.5.5.1) 71 | * IPv4-Mapped IPv6 Address (see RFC 4291, section 2.5.5.2) 72 | * 6to4 Address (see RFC 3056, section 2) 73 | 74 | Args: 75 | ip_addresses: An iterable of IP addresses or CIDRs. 76 | extra_addresses: An iterable of IP addresses or CIDRs. 77 | config_path: The path in the configuration for error messages. 78 | 79 | Returns: 80 | A new IP set. 81 | """ 82 | result = IPSet() 83 | for ip in itertools.chain(ip_addresses or (), extra_addresses or ()): 84 | try: 85 | network = IPNetwork(ip) 86 | except AddrFormatError as e: 87 | raise Exception( 88 | "Invalid IP range provided: %s." % (ip,), config_path 89 | ) from e 90 | result.add(network) 91 | 92 | # It is possible that these already exist in the set, but that's OK. 93 | if ":" not in str(network): 94 | result.add(IPNetwork(network).ipv6(ipv4_compatible=True)) 95 | result.add(IPNetwork(network).ipv6(ipv4_compatible=False)) 96 | result.add(_6to4(network)) 97 | 98 | return result 99 | 100 | 101 | def _6to4(network: IPNetwork) -> IPNetwork: 102 | """Convert an IPv4 network into a 6to4 IPv6 network per RFC 3056.""" 103 | 104 | # 6to4 networks consist of: 105 | # * 2002 as the first 16 bits 106 | # * The first IPv4 address in the network hex-encoded as the next 32 bits 107 | # * The new prefix length needs to include the bits from the 2002 prefix. 108 | hex_network = hex(network.first)[2:] 109 | hex_network = ("0" * (8 - len(hex_network))) + hex_network 110 | return IPNetwork( 111 | "2002:%s:%s::/%d" 112 | % ( 113 | hex_network[:4], 114 | hex_network[4:], 115 | 16 + network.prefixlen, 116 | ) 117 | ) 118 | -------------------------------------------------------------------------------- /sydent/util/ratelimiter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Matrix.org Foundation C.I.C. 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | import logging 15 | from http import HTTPStatus 16 | from typing import Dict, Generic, Optional, TypeVar 17 | 18 | from twisted.internet import task 19 | from twisted.internet.interfaces import IReactorTime 20 | 21 | from sydent.http.servlets import MatrixRestError 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | K = TypeVar("K") 26 | 27 | 28 | class LimitExceededException(MatrixRestError): 29 | def __init__(self, error: Optional[str] = None) -> None: 30 | if error is None: 31 | error = "Too many requests" 32 | 33 | super().__init__(HTTPStatus.TOO_MANY_REQUESTS, "M_UNKNOWN", error) 34 | 35 | 36 | class Ratelimiter(Generic[K]): 37 | """A ratelimiter based on leaky token bucket algorithm. 38 | 39 | Args: 40 | reactor 41 | burst: the number of requests that can happen at once before we start 42 | ratelimiting 43 | rate_hz: The maximum average sustained rate in hertz of requests we'll 44 | accept. 45 | """ 46 | 47 | def __init__(self, reactor: IReactorTime, burst: int, rate_hz: float) -> None: 48 | # The "burst" count (or the capacity of each bucket in leaky bucket 49 | # algorithm). 50 | self._burst = burst 51 | 52 | # A map from key to number of tokens in its bucket. We ratelimit when 53 | # the number of tokens is greater than `burst`. 54 | # 55 | # Entries are removed when token count hits zero. 56 | self._buckets: Dict[K, int] = {} 57 | 58 | # We remove tokens from all buckets at `rate_hz` hertz. 59 | call = task.LoopingCall(self._periodic_call) 60 | call.clock = reactor 61 | call.start(1 / rate_hz) 62 | 63 | def _periodic_call(self) -> None: 64 | # Take one away from all active buckets. If a bucket reaches zero then 65 | # remove it from the dict. 66 | self._buckets = { 67 | key: tokens - 1 for key, tokens in self._buckets.items() if tokens > 1 68 | } 69 | 70 | def ratelimit(self, key: K, error: Optional[str] = None) -> None: 71 | """Check if we should ratelimit the request with the given key. 72 | 73 | Raises: 74 | LimitExceededException: if the request should be denied. 75 | """ 76 | if error is None: 77 | error = "Too many requests" 78 | 79 | # We get the current token count and compare it with the `burst`. 80 | current_tokens = self._buckets.get(key, 0) 81 | if current_tokens >= self._burst: 82 | logger.warning("Ratelimit hit: %s: %s", error, key) 83 | raise LimitExceededException(error) 84 | 85 | self._buckets[key] = current_tokens + 1 86 | -------------------------------------------------------------------------------- /sydent/util/stringutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import re 16 | from typing import Optional, Tuple 17 | 18 | from twisted.internet.abstract import isIPAddress, isIPv6Address 19 | 20 | # https://matrix.org/docs/spec/client_server/r0.6.0#post-matrix-client-r0-register-email-requesttoken 21 | CLIENT_SECRET_REGEX = re.compile(r"^[0-9a-zA-Z\.=_\-]+$") 22 | 23 | # hostname/domain name 24 | # https://regex101.com/r/OyN1lg/2 25 | hostname_regex = re.compile( 26 | r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", 27 | flags=re.IGNORECASE, 28 | ) 29 | 30 | # it's unclear what the maximum length of an email address is. RFC3696 (as corrected 31 | # by errata) says: 32 | # the upper limit on address lengths should normally be considered to be 254. 33 | # 34 | # In practice, mail servers appear to be more tolerant and allow 400 characters 35 | # or so. Let's allow 500, which should be plenty for everyone. 36 | # 37 | MAX_EMAIL_ADDRESS_LENGTH = 500 38 | 39 | 40 | def is_valid_client_secret(client_secret: str) -> bool: 41 | """Validate that a given string matches the client_secret regex defined by the spec 42 | 43 | :param client_secret: The client_secret to validate 44 | 45 | :return: Whether the client_secret is valid 46 | """ 47 | return ( 48 | 0 < len(client_secret) <= 255 49 | and CLIENT_SECRET_REGEX.match(client_secret) is not None 50 | ) 51 | 52 | 53 | def is_valid_hostname(string: str) -> bool: 54 | """Validate that a given string is a valid hostname or domain name. 55 | 56 | For domain names, this only validates that the form is right (for 57 | instance, it doesn't check that the TLD is valid). 58 | 59 | :param string: The string to validate 60 | 61 | :return: Whether the input is a valid hostname 62 | """ 63 | 64 | return hostname_regex.match(string) is not None 65 | 66 | 67 | def parse_server_name(server_name: str) -> Tuple[str, Optional[str]]: 68 | """Split a server name into host/port parts. 69 | 70 | No validation is done on the host part. The port part is validated to be 71 | a valid port number. 72 | 73 | Args: 74 | server_name: server name to parse 75 | 76 | Returns: 77 | host/port parts. 78 | 79 | Raises: 80 | ValueError if the server name could not be parsed. 81 | """ 82 | try: 83 | if server_name[-1] == "]": 84 | # ipv6 literal, hopefully 85 | return server_name, None 86 | 87 | host_port = server_name.rsplit(":", 1) 88 | host = host_port[0] 89 | port = host_port[1] if host_port[1:] else None 90 | 91 | if port: 92 | port_num = int(port) 93 | 94 | # exclude things like '08090' or ' 8090' 95 | if port != str(port_num) or not (1 <= port_num < 65536): 96 | raise ValueError("Invalid port") 97 | 98 | return host, port 99 | except Exception: 100 | raise ValueError("Invalid server name '%s'" % server_name) 101 | 102 | 103 | def is_valid_matrix_server_name(string: str) -> bool: 104 | """Validate that the given string is a valid Matrix server name. 105 | 106 | A string is a valid Matrix server name if it is one of the following, plus 107 | an optional port: 108 | 109 | a. IPv4 address 110 | b. IPv6 literal (`[IPV6_ADDRESS]`) 111 | c. A valid hostname 112 | 113 | :param string: The string to validate 114 | 115 | :return: Whether the input is a valid Matrix server name 116 | """ 117 | 118 | try: 119 | host, port = parse_server_name(string) 120 | except ValueError: 121 | return False 122 | 123 | valid_ipv4_addr = isIPAddress(host) 124 | valid_ipv6_literal = ( 125 | host[0] == "[" and host[-1] == "]" and isIPv6Address(host[1:-1]) 126 | ) 127 | 128 | return valid_ipv4_addr or valid_ipv6_literal or is_valid_hostname(host) 129 | 130 | 131 | def normalise_address(address: str, medium: str) -> str: 132 | if medium == "email": 133 | return address.casefold() 134 | else: 135 | return address 136 | -------------------------------------------------------------------------------- /sydent/util/tokenutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import random 16 | import string 17 | 18 | r = random.SystemRandom() 19 | 20 | 21 | def generateTokenForMedium(medium: str) -> str: 22 | """ 23 | Generates a token of a different format depending on the medium, a 32 characters 24 | alphanumeric one if the medium is email, a 6 characters numeric one otherwise. 25 | 26 | :param medium: The medium to generate a token for. 27 | 28 | :return: The generated token. 29 | """ 30 | if medium == "email": 31 | return generateAlphanumericTokenOfLength(32) 32 | else: 33 | return generateNumericTokenOfLength(6) 34 | 35 | 36 | def generateNumericTokenOfLength(length: int) -> str: 37 | """ 38 | Generates a token of the given length with the character set [0-9]. 39 | 40 | :param length: The length of the token to generate. 41 | 42 | :return: The generated token. 43 | """ 44 | return "".join([r.choice(string.digits) for _ in range(length)]) 45 | 46 | 47 | def generateAlphanumericTokenOfLength(length: int) -> str: 48 | """ 49 | Generates a token of the given length with the character set [a-zA-Z0-9]. 50 | 51 | :param length: The length of the token to generate. 52 | 53 | :return: The generated token. 54 | """ 55 | return "".join( 56 | [ 57 | r.choice(string.digits + string.ascii_lowercase + string.ascii_uppercase) 58 | for _ in range(length) 59 | ] 60 | ) 61 | -------------------------------------------------------------------------------- /sydent/util/ttlcache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 New Vector Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import enum 16 | import logging 17 | import time 18 | from typing import Callable, Dict, Generic, Tuple, TypeVar, Union 19 | 20 | import attr 21 | from sortedcontainers import SortedList 22 | from typing_extensions import Literal 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class Sentinel(enum.Enum): 28 | token = enum.auto() 29 | 30 | 31 | K = TypeVar("K") 32 | V = TypeVar("V") 33 | 34 | 35 | class TTLCache(Generic[K, V]): 36 | """A key/value cache implementation where each entry has its own TTL""" 37 | 38 | def __init__(self, cache_name: str, timer: Callable[[], float] = time.time): 39 | self._data: Dict[K, _CacheEntry[K, V]] = {} 40 | 41 | # the _CacheEntries, sorted by expiry time 42 | self._expiry_list: SortedList[_CacheEntry] = SortedList() 43 | 44 | self._timer = timer 45 | 46 | def set(self, key: K, value: V, ttl: float) -> None: 47 | """Add/update an entry in the cache 48 | 49 | :param key: Key for this entry. 50 | 51 | :param value: Value for this entry. 52 | 53 | :param ttl: TTL for this entry, in seconds. 54 | """ 55 | expiry = self._timer() + ttl 56 | 57 | self.expire() 58 | e = self._data.pop(key, Sentinel.token) 59 | if e != Sentinel.token: 60 | self._expiry_list.remove(e) 61 | 62 | entry = _CacheEntry(expiry_time=expiry, key=key, value=value) 63 | self._data[key] = entry 64 | self._expiry_list.add(entry) 65 | 66 | def get( 67 | self, key: K, default: Union[V, Literal[Sentinel.token]] = Sentinel.token 68 | ) -> V: 69 | """Get a value from the cache 70 | 71 | :param key: The key to look up. 72 | :param default: default value to return, if key is not found. If not 73 | set, and the key is not found, a KeyError will be raised. 74 | 75 | :returns a value from the cache, or the default. 76 | """ 77 | self.expire() 78 | e = self._data.get(key, Sentinel.token) 79 | if e is Sentinel.token: 80 | if default is Sentinel.token: 81 | raise KeyError(key) 82 | return default 83 | return e.value 84 | 85 | def get_with_expiry(self, key: K) -> Tuple[V, float]: 86 | """Get a value, and its expiry time, from the cache 87 | 88 | :param key: key to look up 89 | 90 | :returns The value from the cache, and the expiry time. 91 | :rtype: Tuple[Any, float] 92 | 93 | Raises: 94 | KeyError if the entry is not found 95 | """ 96 | self.expire() 97 | try: 98 | e = self._data[key] 99 | except KeyError: 100 | raise 101 | return e.value, e.expiry_time 102 | 103 | def pop( 104 | self, key: K, default: Union[V, Literal[Sentinel.token]] = Sentinel.token 105 | ) -> V: 106 | """Remove a value from the cache 107 | 108 | If key is in the cache, remove it and return its value, else return default. 109 | If default is not given and key is not in the cache, a KeyError is raised. 110 | 111 | :param key: key to look up 112 | :param default: default value to return, if key is not found. If not 113 | set, and the key is not found, a KeyError will be raised 114 | 115 | :returns a value from the cache, or the default 116 | """ 117 | self.expire() 118 | e = self._data.pop(key, Sentinel.token) 119 | if e is Sentinel.token: 120 | if default == Sentinel.token: 121 | raise KeyError(key) 122 | return default 123 | self._expiry_list.remove(e) 124 | return e.value 125 | 126 | def __getitem__(self, key: K) -> V: 127 | return self.get(key) 128 | 129 | def __delitem__(self, key: K) -> None: 130 | self.pop(key) 131 | 132 | def __contains__(self, key: K) -> bool: 133 | return key in self._data 134 | 135 | def __len__(self) -> int: 136 | self.expire() 137 | return len(self._data) 138 | 139 | def expire(self) -> None: 140 | """Run the expiry on the cache. Any entries whose expiry times are due will 141 | be removed 142 | """ 143 | now = self._timer() 144 | while self._expiry_list: 145 | first_entry = self._expiry_list[0] 146 | if first_entry.expiry_time - now > 0.0: 147 | break 148 | del self._data[first_entry.key] 149 | del self._expiry_list[0] 150 | 151 | 152 | @attr.s(frozen=True) 153 | class _CacheEntry(Generic[K, V]): 154 | """TTLCache entry""" 155 | 156 | # expiry_time is the first attribute, so that entries are sorted by expiry. 157 | expiry_time: float = attr.ib() 158 | key: K = attr.ib() 159 | value: V = attr.ib() 160 | -------------------------------------------------------------------------------- /sydent/validators/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 OpenMarket Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import attr 16 | 17 | # how long a user can wait before validating a session after starting it 18 | THREEPID_SESSION_VALIDATION_TIMEOUT_MS = 24 * 60 * 60 * 1000 19 | 20 | # how long we keep sessions for after they've been validated 21 | THREEPID_SESSION_VALID_LIFETIME_MS = 24 * 60 * 60 * 1000 22 | 23 | 24 | @attr.s(frozen=True, slots=True, auto_attribs=True) 25 | class ValidationSession: 26 | id: int 27 | medium: str 28 | address: str 29 | client_secret: str 30 | validated: bool 31 | mtime: int 32 | 33 | 34 | @attr.s(frozen=True, slots=True, auto_attribs=True) 35 | class TokenInfo: 36 | token: str 37 | send_attempt_number: int 38 | 39 | 40 | class IncorrectClientSecretException(Exception): 41 | pass 42 | 43 | 44 | class SessionExpiredException(Exception): 45 | pass 46 | 47 | 48 | class InvalidSessionIdException(Exception): 49 | pass 50 | 51 | 52 | class IncorrectSessionTokenException(Exception): 53 | pass 54 | 55 | 56 | class SessionNotValidatedException(Exception): 57 | pass 58 | 59 | 60 | class DestinationRejectedException(Exception): 61 | pass 62 | -------------------------------------------------------------------------------- /sydent/validators/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Dict 3 | 4 | from sydent.db.valsession import ThreePidValSessionStore 5 | from sydent.util import time_msec 6 | from sydent.validators import ( 7 | THREEPID_SESSION_VALIDATION_TIMEOUT_MS, 8 | IncorrectClientSecretException, 9 | IncorrectSessionTokenException, 10 | InvalidSessionIdException, 11 | SessionExpiredException, 12 | ) 13 | 14 | if TYPE_CHECKING: 15 | from sydent.sydent import Sydent 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def validateSessionWithToken( 21 | sydent: "Sydent", sid: int, clientSecret: str, token: str 22 | ) -> Dict[str, bool]: 23 | """ 24 | Attempt to validate a session, identified by the sid, using 25 | the token from out-of-band. The client secret is given to 26 | prevent attempts to guess the token for a sid. 27 | 28 | :param sid: The ID of the session to validate. 29 | :param clientSecret: The client secret to validate. 30 | :param token: The token to validate. 31 | 32 | :return: A dict with a "success" key which is True if the session 33 | was successfully validated, False otherwise. 34 | 35 | :raise IncorrectClientSecretException: The provided client_secret is incorrect. 36 | :raise SessionExpiredException: The session has expired. 37 | :raise InvalidSessionIdException: The session ID couldn't be matched with an 38 | existing session. 39 | :raise IncorrectSessionTokenException: The provided token is incorrect 40 | """ 41 | valSessionStore = ThreePidValSessionStore(sydent) 42 | result = valSessionStore.getTokenSessionById(sid) 43 | if not result: 44 | logger.info("Session ID %s not found", sid) 45 | raise InvalidSessionIdException() 46 | 47 | session, token_info = result 48 | 49 | if not clientSecret == session.client_secret: 50 | logger.info("Incorrect client secret", sid) 51 | raise IncorrectClientSecretException() 52 | 53 | if session.mtime + THREEPID_SESSION_VALIDATION_TIMEOUT_MS < time_msec(): 54 | logger.info("Session expired") 55 | raise SessionExpiredException() 56 | 57 | # TODO once we can validate the token oob 58 | # if tokenObj.validated and clientSecret == tokenObj.clientSecret: 59 | # return True 60 | 61 | if token_info.token == token: 62 | logger.info("Setting session %s as validated", session.id) 63 | valSessionStore.setValidated(session.id, True) 64 | 65 | return {"success": True} 66 | else: 67 | logger.info("Incorrect token submitted") 68 | raise IncorrectSessionTokenException() 69 | -------------------------------------------------------------------------------- /terms.sample.yaml: -------------------------------------------------------------------------------- 1 | master_version: "master_1_1" 2 | docs: 3 | terms_of_service: 4 | version: "2.0" 5 | langs: 6 | en: 7 | name: "Terms of Service" 8 | url: "https://example.org/somewhere/terms-2.0-en.html" 9 | fr: 10 | name: "Conditions d'utilisation" 11 | url: "https://example.org/somewhere/terms-2.0-fr.html" 12 | privacy_policy: 13 | version: "1.2" 14 | langs: 15 | en: 16 | name: "Privacy Policy" 17 | url: "https://example.org/somewhere/privacy-1.2-en.html" 18 | fr: 19 | name: "Politique de confidentialité" 20 | url: "https://example.org/somewhere/privacy-1.2-fr.html" 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/sydent/a37d60bd8efd78cd71a109136c30b7c9c633f56a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from twisted.trial import unittest 16 | 17 | from sydent.http.auth import tokenFromRequest 18 | from tests.utils import make_request, make_sydent 19 | 20 | 21 | class AuthTestCase(unittest.TestCase): 22 | """Tests Sydent's auth code""" 23 | 24 | def setUp(self): 25 | # Create a new sydent 26 | self.sydent = make_sydent() 27 | self.test_token = "testingtoken" 28 | 29 | # Inject a fake OpenID token into the database 30 | cur = self.sydent.db.cursor() 31 | cur.execute( 32 | "INSERT INTO accounts (user_id, created_ts, consent_version)" 33 | "VALUES (?, ?, ?)", 34 | ("@bob:localhost", 101010101, "asd"), 35 | ) 36 | cur.execute( 37 | "INSERT INTO tokens (user_id, token)" "VALUES (?, ?)", 38 | ("@bob:localhost", self.test_token), 39 | ) 40 | 41 | self.sydent.db.commit() 42 | 43 | def test_can_read_token_from_headers(self): 44 | """Tests that Sydent correctly extracts an auth token from request headers""" 45 | self.sydent.run() 46 | 47 | request, _ = make_request( 48 | self.sydent.reactor, 49 | self.sydent.clientApiHttpServer.factory, 50 | "GET", 51 | "/_matrix/identity/v2/hash_details", 52 | ) 53 | request.requestHeaders.addRawHeader( 54 | b"Authorization", b"Bearer " + self.test_token.encode("ascii") 55 | ) 56 | 57 | token = tokenFromRequest(request) 58 | 59 | self.assertEqual(token, self.test_token) 60 | 61 | def test_can_read_token_from_query_parameters(self): 62 | """Tests that Sydent correctly extracts an auth token from query parameters""" 63 | self.sydent.run() 64 | 65 | request, _ = make_request( 66 | self.sydent.reactor, 67 | self.sydent.clientApiHttpServer.factory, 68 | "GET", 69 | "/_matrix/identity/v2/hash_details?access_token=" + self.test_token, 70 | ) 71 | 72 | token = tokenFromRequest(request) 73 | 74 | self.assertEqual(token, self.test_token) 75 | -------------------------------------------------------------------------------- /tests/test_email.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | from typing import Optional 15 | from unittest.mock import Mock, patch 16 | 17 | from twisted.trial import unittest 18 | 19 | from sydent.types import JsonDict 20 | from tests.utils import make_request, make_sydent 21 | 22 | 23 | class TestRequestCode(unittest.TestCase): 24 | def setUp(self) -> None: 25 | # Create a new sydent 26 | self.sydent = make_sydent() 27 | 28 | def _make_request(self, url: str, body: Optional[JsonDict] = None) -> Mock: 29 | # Patch out the email sending so we can investigate the resulting email. 30 | with patch("sydent.util.emailutils.smtplib") as smtplib: 31 | request, channel = make_request( 32 | self.sydent.reactor, 33 | self.sydent.clientApiHttpServer.factory, 34 | "POST", 35 | url, 36 | body, 37 | ) 38 | 39 | self.assertEqual(channel.code, 200) 40 | 41 | # Fish out the SMTP object and return it. 42 | smtp = smtplib.SMTP.return_value 43 | smtp.sendmail.assert_called_once() 44 | 45 | return smtp 46 | 47 | def test_request_code(self) -> None: 48 | self.sydent.run() 49 | 50 | smtp = self._make_request( 51 | "/_matrix/identity/api/v1/validate/email/requestToken", 52 | { 53 | "email": "test@test", 54 | "client_secret": "oursecret", 55 | "send_attempt": 0, 56 | }, 57 | ) 58 | 59 | # Ensure the email is as expected. 60 | email_contents = smtp.sendmail.call_args[0][2].decode("utf-8") 61 | self.assertIn("Confirm your email address for Matrix", email_contents) 62 | 63 | def test_request_code_via_url_query_params(self) -> None: 64 | self.sydent.run() 65 | url = ( 66 | "/_matrix/identity/api/v1/validate/email/requestToken?" 67 | "email=test@test" 68 | "&client_secret=oursecret" 69 | "&send_attempt=0" 70 | ) 71 | smtp = self._make_request(url) 72 | 73 | # Ensure the email is as expected. 74 | email_contents = smtp.sendmail.call_args[0][2].decode("utf-8") 75 | self.assertIn("Confirm your email address for Matrix", email_contents) 76 | 77 | def test_branded_request_code(self) -> None: 78 | self.sydent.run() 79 | 80 | smtp = self._make_request( 81 | "/_matrix/identity/api/v1/validate/email/requestToken?brand=vector-im", 82 | { 83 | "email": "test@test", 84 | "client_secret": "oursecret", 85 | "send_attempt": 0, 86 | }, 87 | ) 88 | 89 | # Ensure the email is as expected. 90 | email_contents = smtp.sendmail.call_args[0][2].decode("utf-8") 91 | self.assertIn("Confirm your email address for Element", email_contents) 92 | -------------------------------------------------------------------------------- /tests/test_msisdn.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | import asyncio 14 | import os.path 15 | from typing import Optional 16 | from unittest.mock import Mock, patch 17 | 18 | import attr 19 | from twisted.trial import unittest 20 | 21 | from sydent.types import JsonDict 22 | from tests.utils import make_request, make_sydent 23 | 24 | 25 | @attr.s(auto_attribs=True) 26 | class FakeHeader: 27 | """ 28 | A fake header object 29 | """ 30 | 31 | headers: dict 32 | 33 | def getAllRawHeaders(self): 34 | return self.headers 35 | 36 | 37 | @attr.s(auto_attribs=True) 38 | class FakeResponse: 39 | """A fake twisted.web.IResponse object""" 40 | 41 | # HTTP response code 42 | code: int 43 | 44 | # Fake Header 45 | headers: FakeHeader 46 | 47 | 48 | class TestRequestCode(unittest.TestCase): 49 | def setUp(self) -> None: 50 | # Create a new sydent 51 | config = { 52 | "general": { 53 | "templates.path": os.path.join( 54 | os.path.dirname(os.path.dirname(__file__)), "res" 55 | ), 56 | }, 57 | } 58 | self.sydent = make_sydent(test_config=config) 59 | 60 | def _make_request(self, url: str, body: Optional[JsonDict] = None) -> Mock: 61 | # Patch out the email sending so we can investigate the resulting email. 62 | with patch("sydent.sms.openmarket.OpenMarketSMS.sendTextSMS") as sendTextSMS: 63 | # We can't use AsyncMock until Python 3.8. Instead, mock the 64 | # function as returning a future. 65 | f = asyncio.Future() 66 | f.set_result(Mock()) 67 | sendTextSMS.return_value = f 68 | 69 | request, channel = make_request( 70 | self.sydent.reactor, 71 | self.sydent.clientApiHttpServer.factory, 72 | "POST", 73 | url, 74 | body, 75 | ) 76 | self.assertEqual(channel.code, 200) 77 | 78 | return sendTextSMS 79 | 80 | def test_request_code(self) -> None: 81 | self.sydent.run() 82 | 83 | sendSMS_mock = self._make_request( 84 | "/_matrix/identity/api/v1/validate/msisdn/requestToken", 85 | { 86 | "phone_number": "447700900750", 87 | "country": "GB", 88 | "client_secret": "oursecret", 89 | "send_attempt": 0, 90 | }, 91 | ) 92 | sendSMS_mock.assert_called_once() 93 | 94 | def test_request_code_via_url_query_params(self) -> None: 95 | self.sydent.run() 96 | url = ( 97 | "/_matrix/identity/api/v1/validate/msisdn/requestToken?" 98 | "phone_number=447700900750" 99 | "&country=GB" 100 | "&client_secret=oursecret" 101 | "&send_attempt=0" 102 | ) 103 | sendSMS_mock = self._make_request(url) 104 | sendSMS_mock.assert_called_once() 105 | 106 | @patch("sydent.http.httpclient.HTTPClient.post_json_maybe_get_json") 107 | def test_bad_api_response_raises_exception(self, post_json: Mock) -> None: 108 | """Test that an error response from OpenMarket raises an exception 109 | and that the requester receives an error code.""" 110 | 111 | header = FakeHeader({}) 112 | resp = FakeResponse(code=400, headers=header), {} 113 | post_json.return_value = resp 114 | self.sydent.run() 115 | request, channel = make_request( 116 | self.sydent.reactor, 117 | self.sydent.clientApiHttpServer.factory, 118 | "POST", 119 | "/_matrix/identity/api/v1/validate/msisdn/requestToken", 120 | { 121 | "phone_number": "447700900750", 122 | "country": "GB", 123 | "client_secret": "oursecret", 124 | "send_attempt": 0, 125 | }, 126 | ) 127 | self.assertEqual(channel.code, 500) 128 | -------------------------------------------------------------------------------- /tests/test_ratelimiter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | from twisted.test.proto_helpers import MemoryReactorClock 17 | from twisted.trial import unittest 18 | 19 | from sydent.util.ratelimiter import LimitExceededException, Ratelimiter 20 | 21 | 22 | class RatelimiterTest(unittest.TestCase): 23 | def setUp(self) -> None: 24 | self.clock = MemoryReactorClock() 25 | self.ratelimiter = Ratelimiter(self.clock, burst=5, rate_hz=0.5) 26 | 27 | def test_simple(self) -> None: 28 | """Test that a request doesn't get ratelimited to start off with""" 29 | key = "key" 30 | 31 | # This should not raise as we're below the ratelimit 32 | self.ratelimiter.ratelimit(key) 33 | 34 | def test_burst(self) -> None: 35 | """Test that we can send `burst` number of messages before getting 36 | ratelimited 37 | """ 38 | key = "key" 39 | 40 | # This should not raise as we're below the ratelimit 41 | for _ in range(5): 42 | self.ratelimiter.ratelimit(key) 43 | 44 | with self.assertRaises(LimitExceededException): 45 | self.ratelimiter.ratelimit(key) 46 | 47 | def test_burst_reset(self) -> None: 48 | """Test that once we hit the ratelimit we can wait a while and we'll be 49 | able to send requests again 50 | """ 51 | key = "key" 52 | 53 | # This should not raise as we're below the ratelimit 54 | for _ in range(5): 55 | self.ratelimiter.ratelimit(key) 56 | 57 | with self.assertRaises(LimitExceededException): 58 | self.ratelimiter.ratelimit(key) 59 | 60 | self.clock.pump([2.0] * 5) 61 | 62 | for _ in range(5): 63 | self.ratelimiter.ratelimit(key) 64 | 65 | with self.assertRaises(LimitExceededException): 66 | self.ratelimiter.ratelimit(key) 67 | 68 | def test_average_rate(self): 69 | """Test that sending requests at a rate higher than the maximum rate 70 | gets ratelimited. 71 | """ 72 | key = "key" 73 | 74 | with self.assertRaises(LimitExceededException): 75 | for _ in range(100): 76 | self.clock.advance(1) 77 | self.ratelimiter.ratelimit(key) 78 | 79 | def test_average_rate_burst(self): 80 | """Test that if we go above the maximum rate we'll get ratelimited""" 81 | key = "key" 82 | 83 | for _ in range(5): 84 | self.ratelimiter.ratelimit(key) 85 | 86 | for _ in range(100): 87 | self.clock.advance(2) 88 | self.ratelimiter.ratelimit(key) 89 | 90 | with self.assertRaises(LimitExceededException): 91 | self.ratelimiter.ratelimit(key) 92 | -------------------------------------------------------------------------------- /tests/test_start.py: -------------------------------------------------------------------------------- 1 | from twisted.trial import unittest 2 | 3 | from tests.utils import make_sydent 4 | 5 | 6 | class StartupTestCase(unittest.TestCase): 7 | """Test that sydent started up correctly""" 8 | 9 | def test_start(self): 10 | sydent = make_sydent() 11 | sydent.run() 12 | 13 | def test_homeserver_allow_list_refuses_to_start_if_v1_not_disabled(self): 14 | """ 15 | Test that Sydent throws a runtime error if `homeserver_allow_list` is specified 16 | but the v1 API has not been disabled 17 | """ 18 | config = { 19 | "general": { 20 | "homeserver_allow_list": "friendly.com, example.com", 21 | "enable_v1_access": "true", 22 | } 23 | } 24 | 25 | with self.assertRaises(RuntimeError): 26 | make_sydent(test_config=config) 27 | -------------------------------------------------------------------------------- /tests/test_store_invite.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from unittest.mock import patch 15 | 16 | from parameterized import parameterized 17 | from twisted.trial import unittest 18 | 19 | from sydent.users.accounts import Account 20 | from tests.utils import make_request, make_sydent 21 | 22 | 23 | class StoreInviteTestCase(unittest.TestCase): 24 | """Tests Sydent's register servlet""" 25 | 26 | def setUp(self) -> None: 27 | # Create a new sydent 28 | config = { 29 | "email": { 30 | "email.from": "Sydent Validation ", 31 | }, 32 | } 33 | self.sydent = make_sydent(test_config=config) 34 | self.sender = "@alice:wonderland" 35 | 36 | @parameterized.expand( 37 | [ 38 | ("not@an@email@address",), 39 | ("Naughty Nigel ",), 40 | ] 41 | ) 42 | def test_invalid_email_returns_400(self, address: str) -> None: 43 | self.sydent.run() 44 | 45 | with patch("sydent.http.servlets.store_invite_servlet.authV2") as authV2: 46 | authV2.return_value = Account(self.sender, 0, None) 47 | 48 | request, channel = make_request( 49 | self.sydent.reactor, 50 | self.sydent.clientApiHttpServer.factory, 51 | "POST", 52 | "/_matrix/identity/v2/store-invite", 53 | content={ 54 | "address": address, 55 | "medium": "email", 56 | "room_id": "!myroom:test", 57 | "sender": self.sender, 58 | }, 59 | ) 60 | 61 | self.assertEqual(channel.code, 400) 62 | self.assertEqual(channel.json_body["errcode"], "M_INVALID_EMAIL") 63 | -------------------------------------------------------------------------------- /tests/test_threepidunbind.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from http import HTTPStatus 15 | from unittest.mock import patch 16 | 17 | import twisted.internet.error 18 | import twisted.web.client 19 | from parameterized import parameterized 20 | from twisted.trial import unittest 21 | 22 | from tests.utils import make_request, make_sydent 23 | 24 | 25 | class ThreepidUnbindTestCase(unittest.TestCase): 26 | """Tests Sydent's threepidunbind servlet""" 27 | 28 | def setUp(self) -> None: 29 | # Create a new sydent 30 | self.sydent = make_sydent() 31 | 32 | # Duplicated from TestRegisterServelet. Is there a way for us to keep 33 | # ourselves DRY? 34 | @parameterized.expand( 35 | [ 36 | (twisted.internet.error.DNSLookupError(),), 37 | (twisted.internet.error.TimeoutError(),), 38 | (twisted.internet.error.ConnectionRefusedError(),), 39 | # Naughty: strictly we're supposed to initialise a ResponseNeverReceived 40 | # with a list of 1 or more failures. 41 | (twisted.web.client.ResponseNeverReceived([]),), 42 | ] 43 | ) 44 | def test_connection_failure(self, exc: Exception) -> None: 45 | """Check we respond sensibly if we can't contact the homeserver.""" 46 | self.sydent.run() 47 | with patch.object( 48 | self.sydent.sig_verifier, "authenticate_request", side_effect=exc 49 | ): 50 | request, channel = make_request( 51 | self.sydent.reactor, 52 | self.sydent.clientApiHttpServer.factory, 53 | "POST", 54 | "/_matrix/identity/v2/3pid/unbind", 55 | content={ 56 | "mxid": "@alice:wonderland", 57 | "threepid": { 58 | "address": "alice.cooper@wonderland.biz", 59 | "medium": "email", 60 | }, 61 | }, 62 | ) 63 | self.assertEqual(channel.code, HTTPStatus.INTERNAL_SERVER_ERROR) 64 | self.assertEqual(channel.json_body["errcode"], "M_UNKNOWN") 65 | self.assertIn("contact", channel.json_body["error"]) 66 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from twisted.trial import unittest 2 | 3 | from sydent.util.stringutils import is_valid_matrix_server_name 4 | 5 | 6 | class UtilTests(unittest.TestCase): 7 | """Tests Sydent utility functions.""" 8 | 9 | def test_is_valid_matrix_server_name(self): 10 | """Tests that the is_valid_matrix_server_name function accepts only 11 | valid hostnames (or domain names), with optional port number. 12 | """ 13 | self.assertTrue(is_valid_matrix_server_name("9.9.9.9")) 14 | self.assertTrue(is_valid_matrix_server_name("9.9.9.9:4242")) 15 | self.assertTrue(is_valid_matrix_server_name("[::]")) 16 | self.assertTrue(is_valid_matrix_server_name("[::]:4242")) 17 | self.assertTrue(is_valid_matrix_server_name("[a:b:c::]:4242")) 18 | 19 | self.assertTrue(is_valid_matrix_server_name("example.com")) 20 | self.assertTrue(is_valid_matrix_server_name("EXAMPLE.COM")) 21 | self.assertTrue(is_valid_matrix_server_name("ExAmPlE.CoM")) 22 | self.assertTrue(is_valid_matrix_server_name("example.com:4242")) 23 | self.assertTrue(is_valid_matrix_server_name("localhost")) 24 | self.assertTrue(is_valid_matrix_server_name("localhost:9000")) 25 | self.assertTrue(is_valid_matrix_server_name("a.b.c.d:1234")) 26 | 27 | self.assertFalse(is_valid_matrix_server_name("[:::]")) 28 | self.assertFalse(is_valid_matrix_server_name("a:b:c::")) 29 | 30 | self.assertFalse(is_valid_matrix_server_name("example.com:65536")) 31 | self.assertFalse(is_valid_matrix_server_name("example.com:0")) 32 | self.assertFalse(is_valid_matrix_server_name("example.com:-1")) 33 | self.assertFalse(is_valid_matrix_server_name("example.com:a")) 34 | self.assertFalse(is_valid_matrix_server_name("example.com: ")) 35 | self.assertFalse(is_valid_matrix_server_name("example.com:04242")) 36 | self.assertFalse(is_valid_matrix_server_name("example.com: 4242")) 37 | self.assertFalse(is_valid_matrix_server_name("example.com/example.com")) 38 | self.assertFalse(is_valid_matrix_server_name("example.com#example.com")) 39 | --------------------------------------------------------------------------------