├── .flake8 ├── .github ├── release.yml └── workflows │ └── mega-linter.yml ├── .gitignore ├── .mega-linter.yml ├── .pre-commit-config.yaml ├── .yaml-lint.yml ├── README.md ├── pyproject.toml ├── python-package ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── sample │ ├── aci_debug_policy.rego │ └── connect_aci.py ├── setup.py └── src │ └── atls │ ├── __init__.py │ ├── atls_context.py │ ├── httpa_connection.py │ ├── utils │ ├── __init__.py │ ├── _httpa_connection_shim.py │ ├── requests │ │ ├── __init__.py │ │ └── adapter.py │ └── urllib3 │ │ ├── __init__.py │ │ └── patch.py │ └── validators │ ├── __init__.py │ ├── azure │ ├── __init__.py │ └── aas │ │ ├── __init__.py │ │ ├── aci_validator.py │ │ ├── cvm_validator.py │ │ └── shared.py │ └── validator.py └── scripts └── install_pre_commit.sh /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | # E203: Whitespace before ':' 4 | # Causing false positive on list slices 5 | # https://github.com/PyCQA/pycodestyle/issues/373 6 | E203, 7 | # W503: Line break occurred before a binary operator 8 | # PEP8 now recommend line breaks should occur before the binary operator 9 | W503 10 | exclude = 11 | .git, 12 | __pycache__, 13 | max-line-length = 79 14 | max-doc-length = 79 15 | max-complexity = 18 16 | select = B,C,E,F,W,T4,B9 17 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | changelog: 3 | exclude: 4 | labels: [] 5 | authors: [] 6 | categories: 7 | - title: User-facing features 8 | labels: 9 | - feat 10 | - title: Bug fixes 11 | labels: 12 | - fix 13 | - title: Performance improvements 14 | labels: 15 | - perf 16 | - title: Documentation updates 17 | labels: 18 | - docs 19 | - title: Formatting changes 20 | labels: 21 | - style 22 | - title: Refactoring 23 | labels: 24 | - refactor 25 | - title: Test updates 26 | labels: 27 | - test 28 | - title: Build system updates 29 | labels: 30 | - build 31 | -------------------------------------------------------------------------------- /.github/workflows/mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # MegaLinter GitHub Action configuration file 3 | # More info at https://oxsecurity.github.io/megalinter 4 | name: MegaLinter 5 | 6 | # Run this workflow every time a new commit pushed to your repository 7 | on: 8 | push: 9 | branches: 10 | - dev 11 | - master 12 | pull_request: 13 | branches: 14 | - dev 15 | - release-* 16 | - hotfix-* 17 | - master 18 | 19 | env: 20 | # Apply linter fixes configuration, see link for details 21 | # https://oxsecurity.github.io/megalinter/latest/configuration/#apply-fixes 22 | APPLY_FIXES: none # do not apply any fixes 23 | APPLY_FIXES_EVENT: pull_request 24 | APPLY_FIXES_MODE: commit 25 | 26 | # Only allow one run of this workflow per PR at a time. 27 | # 28 | # This will cancel any still-running workflows triggered by a previous commit to 29 | # this PR. Note this will not affect workflows triggered by a push (e.g. merging 30 | # a PR to `dev` or `master`). 31 | concurrency: 32 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 33 | cancel-in-progress: true 34 | 35 | jobs: 36 | build: 37 | name: MegaLinter 38 | runs-on: ubuntu-latest 39 | steps: 40 | # Git Checkout 41 | - name: Checkout Code 42 | uses: actions/checkout@v3 43 | with: 44 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 45 | # If you use VALIDATE_ALL_CODEBASE = true, 46 | # you can remove this line to improve performances 47 | # fetch-depth: 0 48 | 49 | # MegaLinter 50 | - name: MegaLinter 51 | id: ml 52 | # You can override MegaLinter flavor used to have faster performances 53 | # More info at https://oxsecurity.github.io/megalinter/flavors/ 54 | uses: oxsecurity/megalinter@v6 55 | env: 56 | # All available variables are described in documentation 57 | # https://oxsecurity.github.io/megalinter/configuration/ 58 | # Set ${{ github.event_name == 59 | # 'push' && github.ref == 'refs/heads/main' }} 60 | # to validate only diff with main branch 61 | VALIDATE_ALL_CODEBASE: true 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | FILTER_REGEX_EXCLUDE: .*run-clang-tidy\.py 64 | # ADD YOUR CUSTOM ENV VARIABLES HERE TO OVERRIDE VALUES 65 | # OF .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY 66 | 67 | # Upload MegaLinter artifacts 68 | - name: Archive production artifacts 69 | if: ${{ success() }} || ${{ failure() }} 70 | uses: actions/upload-artifact@v2 71 | with: 72 | name: MegaLinter reports 73 | path: | 74 | megalinter-reports 75 | mega-linter.log 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .vscode 3 | **/__pycache__ 4 | **/*.egg-info 5 | build 6 | dist 7 | -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for MegaLinter 2 | # See all available variables at 3 | # https://oxsecurity.github.io/megalinter/configuration/ 4 | # and in linters documentation 5 | 6 | ########## Mega Linter Settings ########## 7 | APPLY_FIXES: none # all, none, or list of linter keys 8 | # If you use ENABLE variable, all other languages/formats/tooling-formats 9 | # will be disabled by default 10 | # ENABLE: 11 | # If you use ENABLE_LINTERS variable, 12 | # all other linters will be disabled by default 13 | ENABLE_LINTERS: 14 | - ACTION_ACTIONLINT 15 | - PYTHON_BLACK 16 | - PYTHON_FLAKE8 17 | - PYTHON_ISORT 18 | - YAML_YAMLLINT 19 | # DISABLE: 20 | # - COPYPASTE # Uncomment to disable checks of excessive copy-pastes 21 | # - SPELL # Uncomment to disable checks of spelling mistakes 22 | SHOW_ELAPSED_TIME: true 23 | FILEIO_REPORTER: false 24 | # Uncomment if you want MegaLinter to detect errors but not block CI to pass 25 | # DISABLE_ERRORS: true 26 | # FILTER_REGEX_EXCLUDE: > 27 | LINTER_RULES_PATH: / 28 | ########## Individual Linter Settings ########## 29 | 30 | #===== Github Action =====# 31 | ACTION_ACTIONLINT_RULES_PATH: .github 32 | 33 | #===== Python =====# 34 | PYTHON_BLACK_CONFIG_FILE: pyproject.toml 35 | PYTHON_BLACK_DISABLE_ERRORS: false 36 | PYTHON_FLAKE8_CONFIG_FILE: .flake8 37 | PYTHON_ISORT_CONFIG_FILE: pyproject.toml 38 | PYTHON_ISORT_DISABLE_ERRORS: false 39 | 40 | #===== YAML =====# 41 | YAML_YAMLLINT_CONFIG_FILE: .yaml-lint.yml 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 23.1.0 5 | hooks: 6 | - id: black 7 | args: [--config=pyproject.toml] 8 | language_version: python3 9 | exclude: ^(sql/src/cpp/thirdparty/duckdb/.*)|(scripts/run-clang-tidy.py) 10 | - repo: https://github.com/pycqa/flake8 11 | rev: 5.0.4 12 | hooks: 13 | - id: flake8 14 | exclude: ^(sql/src/cpp/thirdparty/duckdb/.*)|(scripts/run-clang-tidy.py) 15 | - repo: https://github.com/pocc/pre-commit-hooks 16 | rev: v1.3.5 17 | hooks: 18 | - id: clang-format 19 | args: 20 | - -i 21 | - --style=file 22 | - --fallback-style=Chromium 23 | - repo: https://github.com/pycqa/isort 24 | rev: 5.12.0 25 | hooks: 26 | - id: isort 27 | files: "\\.(py)$" 28 | args: [--settings-path=pyproject.toml] 29 | - repo: https://github.com/pre-commit/mirrors-mypy 30 | rev: v0.991 31 | hooks: 32 | - id: mypy 33 | args: 34 | - --ignore-missing-imports 35 | - --follow-imports=silent 36 | additional_dependencies: ['types-waitress'] 37 | - repo: https://github.com/rhysd/actionlint 38 | rev: v1.6.22 39 | hooks: 40 | - id: actionlint 41 | - repo: https://github.com/adrienverge/yamllint.git 42 | rev: v1.28.0 43 | hooks: 44 | - id: yamllint 45 | args: [-c=.yaml-lint.yml] 46 | -------------------------------------------------------------------------------- /.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | truthy: 6 | ignore: | 7 | # The truthy rule is not relevant for GitHub Actions workflows, 8 | # as the YAML syntax for it is a bit different from normal YAML 9 | .github/*/*.yaml 10 | # Set line length to warning because some bash commands are long 11 | line-length: 12 | max: 80 13 | level: warning 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python aTLS Package 2 | 3 | The humble beginnings of support for Attested TLS (aTLS) in Python. 4 | 5 | Consult the [README](python-package/README.md) of the package itself. 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # ========== black - linter options ========== 2 | [tool.black] 3 | line-length = 79 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.tox 11 | | \.venv 12 | | _build 13 | | buck-out 14 | | build 15 | | dist 16 | )/ 17 | ''' 18 | 19 | # ========== isort - import sorting linter options ========== 20 | # https://pycqa.github.io/isort/index.html 21 | [tool.isort] 22 | # Use isort's default black linter profile: 23 | # https://pycqa.github.io/isort/docs/configuration/profiles.html 24 | profile = "black" 25 | filter_files = true 26 | line_length = 79 # must be to set to the same value as that in black 27 | 28 | # ========== mypy - type checker options ========== 29 | # Global options: 30 | 31 | [tool.mypy] 32 | disallow_untyped_defs = true 33 | warn_unused_configs = true 34 | warn_unused_ignores = true 35 | warn_unreachable = true 36 | 37 | # Per-module options: 38 | 39 | [[tool.mypy.overrides]] 40 | module = [ 41 | "requests", 42 | "requests.adapters", 43 | "OpenSSL", 44 | "OpenSSL.crypto", 45 | "OpenSSL.SSL", 46 | ] 47 | # Ignore "missing library stubs or py.typed marker" for all the above modules 48 | ignore_missing_imports = true 49 | -------------------------------------------------------------------------------- /python-package/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /python-package/README.md: -------------------------------------------------------------------------------- 1 | # Python aTLS Package 2 | 3 | An implementation of Attested TLS (aTLS) for Python. 4 | 5 | Supports the client-side handshake against a custom attester that issues JWT 6 | tokens via the Azure Attestation Service (AAS) running on Azure Container 7 | Instance (ACI) instances. 8 | 9 | For the moment, this package exists to support 10 | [`OpaquePrompts`](https://pypi.org/project/opaqueprompts/), a confidential 11 | information redaction service that runs in a Trusted Execution Environment 12 | (TEE). 13 | 14 | **API Stability:** This package is still in development. As such, its API may 15 | change until it is sufficiently mature. 16 | 17 | **Note:** The server-side counterpart to this package is not yet public. If you 18 | are interested in using the aTLS functionality in this package, please reach out 19 | by filing an issue on [GitHub](https://github.com/opaque-systems/atls-python/). 20 | 21 | ## Overview 22 | 23 | Confidential computing is an emerging field focused on protecting data not only 24 | at rest and in transit, but also during use. 25 | 26 | Typically, the security of a service running in the cloud depends on the 27 | security and trustworthiness of the cloud fabric it is hosted on and of the 28 | entity that provides the service. Additionally, there is no way for a user of 29 | such a service to ascertain, with cryptographic proof, that the service they are 30 | using really is the service they expect in terms of the very code that the 31 | service runs. 32 | 33 | In contrast to traditional service deployments, with confidential computing one 34 | relies on Trusted Execution Environments, or TEEs. A TEE provides guarantees of 35 | confidentiality and integrity of code and data as well as a mechanism for remote 36 | entities to appraise its trustworthiness known as remote attestation, all rooted 37 | in hardware. 38 | 39 | During remote attestation, the user of a service running inside a TEE challenges 40 | the service to produce evidence of its trustworthiness. This evidence includes 41 | measurements of the hosting environment, including hardware, firmware, and 42 | software stack that the service is running on, as well as measurements of the 43 | service itself. In turn, these measurements are produced in such a way that they 44 | are as trustworthy as the manufacturer of the TEE itself (e.g., Intel or AMD). 45 | 46 | Perhaps most crucially, TEEs and remote attestation can be used to create 47 | services that run in such a way that neither the cloud fabric nor the service 48 | owner can neither access nor tamper with the service. That is, users of the 49 | service may convince themselves through remote attestation that any data that 50 | they share with the service will be shielded from the cloud fabric and also from 51 | the service provider. 52 | 53 | This package aims to implement remote attestation for various TEEs in Python. 54 | 55 | ## Design 56 | 57 | The main workhorse of this package is the `ATLSContext` class. Instances of this 58 | class are parameterized with one or more `Validator`s. A `Validator` can 59 | understand and appraise evidence or attestation results issued by an attester or 60 | verifier, respectively, contained in an attestation document created by an 61 | issuer, itself embedded in a TLS certificate. 62 | 63 | The appraisal of an attestation document takes the place of the typical 64 | PKI-based certificate validation performed during regular TLS. By appraising an 65 | attestation document via `Validator`s, the `ATLSContext` class binds the TLS 66 | handshake not to a PKI-backed entity but to a genuine TEE. 67 | 68 | ## Sample Usage 69 | 70 | The following snippet demonstrates how to use this package, assuming a service 71 | running on a confidential ACI instance with the corresponding attestation 72 | document issuer, and submit an HTTP request: 73 | 74 | ```python 75 | from atls import ATLSContext, HTTPAConnection 76 | from atls.validators.azure.aas import AciValidator 77 | 78 | validator = AciValidator() 79 | ctx = ATLSContext([validator]) 80 | conn = HTTPAConnection("my.confidential.service.net", ctx) 81 | 82 | conn.request("GET", "/index") 83 | 84 | response = conn.getresponse() 85 | 86 | print(f"Status: {response.status}") 87 | print(f"Response: {response.data.decode()}") 88 | 89 | conn.close() 90 | ``` 91 | 92 | Alternatively, this package integrates into the 93 | [`requests`](https://requests.readthedocs.io/) library by using the `httpa://` 94 | scheme in lieu of `https://`, like so: 95 | 96 | ```python 97 | import requests 98 | 99 | from atls.utils.requests import HTTPAAdapter 100 | from atls.validators.azure.aas import AciValidator 101 | 102 | validator = AciValidator() 103 | session = requests.Session() 104 | session.mount("httpa://", HTTPAAdapter([validator])) 105 | 106 | response = session.request("GET", "httpa://my.confidential.service.net/index") 107 | 108 | print(f"Status: {response.status_code}") 109 | print(f"Response: {response.text}") 110 | ``` 111 | 112 | **Note**: The `requests` library is not marked as a dependency of this package 113 | because it is not required for its operation. As such, if you wish to use 114 | `requests`, install it via `pip install requests` prior to importing 115 | `HTTPAAdapter`. 116 | 117 | ## Further Reading 118 | 119 | If you are unfamiliar with the terms used in this README and would like to learn 120 | more, consider the following resources: 121 | 122 | - [Confidential Computing at 123 | Wikipedia](https://en.wikipedia.org/wiki/Confidential_computing) 124 | - [White Papers & Resources at the Confidential Computing 125 | Consortium](https://confidentialcomputing.io/resources/white-papers-reports/) 126 | - [Remote Attestation Procedures RFC 9334 at the 127 | IETF](https://datatracker.ietf.org/doc/rfc9334/) 128 | -------------------------------------------------------------------------------- /python-package/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyatls" 3 | version = "0.0.6" 4 | description = "A Python package that implements Attested TLS (aTLS)." 5 | readme = "README.md" 6 | authors = [{ name = "Opaque Systems", email = "pypi@opaque.co" }] 7 | license = { file = "LICENSE" } 8 | requires-python = ">=3.8" 9 | classifiers = [ 10 | "Programming Language :: Python :: 3", 11 | "License :: OSI Approved :: Apache Software License", 12 | ] 13 | keywords = [ 14 | "atls", 15 | "attestation", 16 | "confidential", 17 | "llm", 18 | "privacy", 19 | "security", 20 | "ssl", 21 | "tls", 22 | ] 23 | dependencies = [ 24 | "cryptography", 25 | "PyJWT", 26 | "pyOpenSSL", 27 | "urllib3 >= 2.0.0, < 2.1.0", 28 | ] 29 | 30 | [project.optional-dependencies] 31 | requests = ["requests"] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/opaque-systems/atls-python" 35 | 36 | [build-system] 37 | requires = ["setuptools>=61.0", "wheel"] 38 | build-backend = "setuptools.build_meta" 39 | -------------------------------------------------------------------------------- /python-package/requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==41.0.3 2 | PyJWT==2.8.0 3 | pyOpenSSL==23.2.0 4 | requests==2.31.0 5 | urllib3==2.0.4 -------------------------------------------------------------------------------- /python-package/sample/aci_debug_policy.rego: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | api_svn := "0.10.0" 4 | 5 | mount_device := {"allowed": true} 6 | mount_overlay := {"allowed": true} 7 | create_container := {"allowed": true, "env_list": null, "allow_stdio_access": true} 8 | unmount_device := {"allowed": true} 9 | unmount_overlay := {"allowed": true} 10 | exec_in_container := {"allowed": true, "env_list": null} 11 | exec_external := {"allowed": true, "env_list": null, "allow_stdio_access": true} 12 | shutdown_container := {"allowed": true} 13 | signal_container_process := {"allowed": true} 14 | plan9_mount := {"allowed": true} 15 | plan9_unmount := {"allowed": true} 16 | get_properties := {"allowed": true} 17 | dump_stacks := {"allowed": true} 18 | runtime_logging := {"allowed": true} 19 | load_fragment := {"allowed": true} 20 | scratch_mount := {"allowed": true} 21 | scratch_unmount := {"allowed": true} 22 | -------------------------------------------------------------------------------- /python-package/sample/connect_aci.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Sample usage of the atls package 4 | # 5 | # Suppose a simple HTTP server with a single GET endpoint /index is running 6 | # over aTLS in an AMD SEV-SNP-backed Azure ACI container instance on HOST:PORT. 7 | # Suppose further that this service is issuing attestation documents via the 8 | # Azure Attestation Service (AAS), particularly using the publicly available 9 | # endpoint in the East US 2 region, and is running with the debug Confidential 10 | # Computing Enforcement (CCE) policy. Then, you can run this program as 11 | # follows from the directory where this file is located: 12 | # 13 | # python3 connect_aci.py \ 14 | # --host HOST \ 15 | # --port PORT \ 16 | # --policy aci_debug_policy.rego \ 17 | # --jku https://sharedeus2.eus2.attest.azure.net/certs \ 18 | # --method GET \ 19 | # --url /index 20 | 21 | import argparse 22 | import ast 23 | import warnings 24 | from typing import List, Mapping, Optional 25 | 26 | import urllib3 27 | from atls import ATLSContext, HTTPAConnection 28 | from atls.utils.requests import HTTPAAdapter 29 | from atls.utils.urllib3 import extract_from_urllib3, inject_into_urllib3 30 | from atls.validators import Validator 31 | from atls.validators.azure.aas import AciValidator 32 | from cryptography.x509.oid import ObjectIdentifier 33 | 34 | # Parse arguments 35 | parser = argparse.ArgumentParser() 36 | 37 | parser.add_argument( 38 | "--server", required=True, help="IP or hostname to connect to" 39 | ) 40 | 41 | parser.add_argument( 42 | "--port", default=443, help="port to connect to (default: 443)" 43 | ) 44 | 45 | parser.add_argument( 46 | "--method", 47 | default="GET", 48 | help="HTTP method to use in the request (default: GET)", 49 | ) 50 | 51 | parser.add_argument( 52 | "--url", 53 | default="/index", 54 | help="URL to perform the HTTP request against (default: /index)", 55 | ) 56 | 57 | parser.add_argument( 58 | "--policy", 59 | nargs="*", 60 | help="path to a CCE policy in Rego format, may be specified multiple " 61 | "times, once for each allowed policy (default: ignore)", 62 | ) 63 | 64 | parser.add_argument( 65 | "--jku", 66 | nargs="*", 67 | action="extend", 68 | help="allowed JWKS URL to verify the JKU claim in the AAS JWT token " 69 | "against, may be specified multiple times, one for each allowed value " 70 | "(default: ignore)", 71 | ) 72 | 73 | parser.add_argument( 74 | "--body", 75 | type=argparse.FileType("r"), 76 | help="path to a file containing the content to include in the request " 77 | "(default: nothing)", 78 | ) 79 | 80 | parser.add_argument( 81 | "--headers", 82 | type=argparse.FileType("r"), 83 | help="path to a file containing the string representation of a Python " 84 | "dictionary containing the headers to be sent along with the request " 85 | "(default: none)", 86 | ) 87 | 88 | parser.add_argument( 89 | "--loops", 90 | default=1, 91 | help="number of times to perform the request to evaluate the impact of " 92 | "connection pooling (default: 1)", 93 | ) 94 | 95 | parser.add_argument( 96 | "--use-injection", 97 | action="store_true", 98 | help="inject aTLS support under the urllib3 library to automatically " 99 | "upgade all HTTPS connections into HTTP/aTLS (default: false)", 100 | ) 101 | 102 | parser.add_argument( 103 | "--use-requests", 104 | action="store_true", 105 | help="use the requests library with the HTTPS/aTLS adapater (default: " 106 | "false)", 107 | ) 108 | 109 | parser.add_argument( 110 | "--insecure", 111 | action="store_true", 112 | help="disable attestation (testing only) (default false)", 113 | ) 114 | 115 | args = parser.parse_args() 116 | 117 | if args.insecure and (args.policy or args.jku): 118 | raise Exception( 119 | "Cannot specify --policy and/or --jku alongside --insecure" 120 | ) 121 | 122 | loops: int = int(args.loops) 123 | if loops == 0 or loops < 0: 124 | raise ValueError(f"Invalid loop count: {loops}") 125 | 126 | policy_files: Optional[List[str]] = args.policy 127 | jkus: Optional[List[str]] = args.jku 128 | 129 | # Read in the specified Rego policies, if any. 130 | policies: Optional[List[str]] = None 131 | if policy_files is not None: 132 | policies = [] 133 | for filepath in policy_files: 134 | with open(filepath) as f: 135 | policies.append(f.read()) 136 | 137 | 138 | class NullValidator(Validator): 139 | """ 140 | A validator that accepts any evidence, effectively bypassing attestation. 141 | 142 | This can be useful to evaluate the overhead of the attestation process. For 143 | example, when using AAS, the endpoint may be too far away from where this 144 | script is running and therefore incur significant latency. To test that 145 | hypothesis, it may be valuable to momentarily disable attestation for the 146 | sake of debugging. 147 | 148 | Do not use in production. 149 | """ 150 | 151 | @staticmethod 152 | def accepts(_oid: ObjectIdentifier) -> bool: 153 | return True 154 | 155 | def validate( 156 | self, _document: bytes, _public_key: bytes, _nonce: bytes 157 | ) -> bool: 158 | warnings.warn("Skipping attestation...") 159 | return True 160 | 161 | 162 | # Set up the Azure AAS ACI validator, unless it has been explicitly disabled: 163 | # - The policies array carries all allowed CCE policies, or none if the policy 164 | # should be ignored. 165 | # 166 | # - The JKUs array carries all allowed JWKS URLs, or none if the JKU claim in 167 | # the AAS JWT token sent by the server during the aTLS handshake should not 168 | # be checked. 169 | validator: Validator 170 | if args.insecure: 171 | validator = NullValidator() 172 | else: 173 | validator = AciValidator(policies=policies, jkus=jkus) 174 | 175 | # Parse provided headers, if any. 176 | headers: Mapping[str, str] = {} 177 | if args.headers is not None: 178 | raw = args.headers.read() 179 | headers = ast.literal_eval(raw) 180 | 181 | # Read in the provided body, if any. 182 | body: Optional[str] = None 183 | if args.body is not None: 184 | body = args.body.read() 185 | 186 | 187 | def use_direct() -> None: 188 | # Set up the aTLS context, including at least one attestation document 189 | # validator (only one need succeed). 190 | ctx = ATLSContext([validator]) 191 | 192 | # Purposefully create a new connection per loop to incur the cost of 193 | # attestation to highlight the added value of connection pooling as 194 | # provided by the urllib3 and requests libraries. 195 | for _ in range(loops): 196 | # Set up the HTTP request machinery using the aTLS context. 197 | conn = HTTPAConnection(args.server, ctx, args.port) 198 | 199 | # Send the HTTP request, and read and print the response in the usual 200 | # way. 201 | conn.request(args.method, args.url, body, headers) 202 | 203 | response = conn.getresponse() 204 | 205 | print(f"Status: {response.status}") 206 | print(f"Response: {response.data.decode()}") 207 | 208 | conn.close() 209 | 210 | 211 | def use_injection() -> None: 212 | # Replace urllib3's default HTTPSConnection class with HTTPAConnection. 213 | inject_into_urllib3([validator]) 214 | 215 | for _ in range(loops): 216 | # The rest of urllib3's usage is as usual. 217 | response = urllib3.request( 218 | "POST", 219 | f"https://{args.server}:{args.port}{args.url}", 220 | body=body, 221 | headers=headers, 222 | ) 223 | 224 | print(f"Status: {response.status}") 225 | print(f"Response: {response.data.decode()}") 226 | 227 | # Restore the default HTTPSConnection class. 228 | extract_from_urllib3() 229 | 230 | 231 | def use_requests() -> None: 232 | # Note that this is an optional dependency of PyAtls since it is not 233 | # strictly required for its operation. 234 | import requests 235 | 236 | session = requests.Session() 237 | 238 | # Mount the HTTP/aTLS adapter such that any URL whose scheme is httpa:// 239 | # results in an HTTPAConnection object that in turn establishes an aTLS 240 | # connection with the server. 241 | session.mount("httpa://", HTTPAAdapter([validator])) 242 | 243 | for _ in range(loops): 244 | # The rest of the usage of the requests library is as usual. Do 245 | # remember to use session.request from the session object that has the 246 | # mounted adapter, not requests.request, since that's the global 247 | # request function and has therefore no knowledge of the adapter. 248 | response = session.request( 249 | args.method, 250 | f"httpa://{args.server}:{args.port}{args.url}", 251 | data=body, 252 | headers=headers, 253 | ) 254 | 255 | print(f"Status: {response.status_code}") 256 | print(f"Response: {response.text}") 257 | 258 | 259 | if args.use_requests: 260 | use_requests() 261 | elif args.use_injection: 262 | use_injection() 263 | else: 264 | use_direct() 265 | -------------------------------------------------------------------------------- /python-package/setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /python-package/src/atls/__init__.py: -------------------------------------------------------------------------------- 1 | from atls.atls_context import ATLSContext 2 | from atls.httpa_connection import HTTPAConnection 3 | 4 | __all__ = [ 5 | "HTTPAConnection", 6 | "ATLSContext", 7 | ] 8 | -------------------------------------------------------------------------------- /python-package/src/atls/atls_context.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import secrets 4 | import socket 5 | import ssl 6 | import warnings 7 | from typing import List, Optional 8 | 9 | import OpenSSL.crypto 10 | import OpenSSL.SSL 11 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 12 | from cryptography.x509.extensions import Extension, ExtensionType 13 | 14 | # TODO/HEGATTA: Either take the code from urllib3 that wraps PyOpenSSL or ditch 15 | # PyOpenSSL altogether in favor of either modifying Python's SSL module to 16 | # support custom certificate validation or switching to mbedTLS (and 17 | # contributing support for custom certificate validation there). 18 | warnings.filterwarnings("ignore", category=DeprecationWarning) 19 | from atls.validators import Validator # noqa: E402 20 | from urllib3.contrib.pyopenssl import PyOpenSSLContext # noqa: E402 21 | from urllib3.contrib.pyopenssl import WrappedSocket # noqa: E402 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class ATLSContext(PyOpenSSLContext): 27 | """ 28 | An SSL context that supports validation of aTLS certificates. 29 | 30 | Attention: Because this class manages the aTLS handshake's nonce, you must 31 | use different instances for different connections. 32 | 33 | Parameters 34 | ---------- 35 | validators : list of Validator 36 | A list of one or more evidence or attestation result validators. During 37 | the TLS handshake, each validator in this list is queried for the 38 | certificate extension OID that contains the attestation document that 39 | the validator understands and if a corresponding extension is found in 40 | the peer's certificate, the validator is invoked. 41 | 42 | nonce : bytes, optional 43 | A random string of bytes to use as a nonce to ascertain the freshness 44 | of attestation evidence and mitigate replay attacks. If None, a random 45 | nonce is automatically generated. 46 | """ 47 | 48 | def __init__( 49 | self, validators: List[Validator], nonce: Optional[bytes] = None 50 | ) -> None: 51 | super().__init__(ssl.PROTOCOL_TLSv1_2) 52 | 53 | if len(validators) == 0: 54 | raise ValueError("At least one validator is necessary") 55 | 56 | if nonce is None: 57 | nonce = secrets.token_bytes(32) 58 | 59 | self._validators = validators 60 | self._nonce = nonce 61 | 62 | self._ctx.set_verify(OpenSSL.SSL.VERIFY_PEER, self._verify_certificate) 63 | 64 | def _verify_certificate( 65 | self, 66 | _conn: OpenSSL.SSL.Connection, 67 | x509: OpenSSL.crypto.X509, 68 | _err_no: int, 69 | _err_depth: int, 70 | _return_code: int, 71 | ) -> bool: 72 | """OpenSSL certificate validation callback""" 73 | 74 | logger.info("Validating certificate...") 75 | 76 | peer_cert = x509.to_cryptography() 77 | 78 | logger.info("Looking for a suitable validator...") 79 | for validator in self._validators: 80 | extension: Extension[ExtensionType] 81 | for extension in peer_cert.extensions: 82 | if validator.accepts(extension.oid): 83 | if not hasattr(extension.value, "value"): 84 | continue 85 | 86 | logger.debug("Fetching certificate extension value...") 87 | document = extension.value.value 88 | 89 | logger.debug("Fetching certificate public key...") 90 | pub = peer_cert.public_key() 91 | 92 | logger.debug( 93 | "Fetching certificate SubjectPublicKeyInfo..." 94 | ) 95 | spki = pub.public_bytes( 96 | Encoding.DER, PublicFormat.SubjectPublicKeyInfo 97 | ) 98 | 99 | logger.debug( 100 | "Calling into validator (validator: %s)...", 101 | validator.__class__, 102 | ) 103 | 104 | result = validator.validate(document, spki, self._nonce) 105 | 106 | if result: 107 | logger.info( 108 | "Certificate validation succeeded (validator: %s)", 109 | validator.__class__, 110 | ) 111 | return True 112 | 113 | logger.error( 114 | "Certificate validation failed (validator: %s)", 115 | validator.__class__, 116 | ) 117 | 118 | logger.error("No validator found for certiticate") 119 | return False 120 | 121 | def wrap_socket(self, sock: socket.socket) -> WrappedSocket: 122 | # To perform aTLS over regular TLS, we use the Server Name Indication 123 | # extension to carry the nonce. 124 | sni = base64.encodebytes(self._nonce) 125 | 126 | return super().wrap_socket(sock, False, True, True, sni) 127 | 128 | @property 129 | def validators(self) -> List[Validator]: 130 | return self._validators 131 | 132 | @validators.setter 133 | def validators(self, validators: List[Validator]) -> None: 134 | self._validators = validators 135 | 136 | @property 137 | def nonce(self) -> bytes: 138 | return self._nonce 139 | -------------------------------------------------------------------------------- /python-package/src/atls/httpa_connection.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional, Tuple 2 | 3 | from atls import ATLSContext 4 | from urllib3.connection import HTTPConnection, port_by_scheme 5 | from urllib3.util.connection import _TYPE_SOCKET_OPTIONS 6 | from urllib3.util.timeout import _DEFAULT_TIMEOUT, _TYPE_TIMEOUT 7 | 8 | 9 | class HTTPAConnection(HTTPConnection): 10 | """ 11 | Performs HTTP requests over an Attested TLS (aTLS) connection. It is 12 | equivalent to HTTPSConnection, but the underlying transport is aTLS instead 13 | of standard TLS. 14 | 15 | Parameters 16 | ---------- 17 | host : str 18 | IP address or hostname to connect to. 19 | 20 | context : ATLSContext 21 | An aTLS context that performs the aTLS handshake. 22 | 23 | port : int, optional 24 | Port to connect to. 25 | 26 | timeout : _TYPE_TIMEOUT 27 | Maximum amount of time, in seconds, to await an attempt to connect to 28 | the host on the specified port before timing out. 29 | 30 | source_address : tuple of str and int, optional 31 | A pair of (host, port) for the client socket to bind to before 32 | connecting to the remote host. 33 | 34 | blocksize : int 35 | Size in bytes of blocks when sending and receiving data to and from the 36 | remote host, respectively. 37 | 38 | socket_options: _TYPE_SOCKET_OPTIONS, optional 39 | A sequence of socket options to apply to the socket. 40 | """ 41 | 42 | default_port: ClassVar[int] = port_by_scheme["https"] 43 | 44 | def __init__( 45 | self, 46 | host: str, 47 | context: ATLSContext, 48 | port: Optional[int] = None, 49 | timeout: _TYPE_TIMEOUT = _DEFAULT_TIMEOUT, 50 | source_address: Optional[Tuple[str, int]] = None, 51 | blocksize: int = 8192, 52 | socket_options: Optional[_TYPE_SOCKET_OPTIONS] = None, 53 | ) -> None: 54 | super().__init__( 55 | host, 56 | port, 57 | timeout=timeout, 58 | source_address=source_address, 59 | blocksize=blocksize, 60 | socket_options=socket_options, 61 | ) 62 | 63 | self._context = context 64 | 65 | def connect(self) -> None: 66 | super().connect() 67 | 68 | self.sock = self._context.wrap_socket(self.sock) # type: ignore 69 | -------------------------------------------------------------------------------- /python-package/src/atls/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opaque-systems/atls-python/ccba3261f22623612fa7275cb8e242c2cd1ac4a2/python-package/src/atls/utils/__init__.py -------------------------------------------------------------------------------- /python-package/src/atls/utils/_httpa_connection_shim.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Dict, List, Optional, Tuple 2 | 3 | from atls import ATLSContext 4 | from atls.httpa_connection import HTTPAConnection 5 | from atls.validators import Validator 6 | from urllib3.util.connection import _TYPE_SOCKET_OPTIONS 7 | from urllib3.util.timeout import _DEFAULT_TIMEOUT, _TYPE_TIMEOUT 8 | 9 | 10 | class _HTTPAConnectionShim(HTTPAConnection): 11 | """ 12 | Provides impendance-matching at the interface between urllib3 and the 13 | HTTPAConnection class. 14 | """ 15 | 16 | Validators: ClassVar[List[Validator]] 17 | 18 | is_verified: bool = True 19 | 20 | def __init__( 21 | self, 22 | host: str, 23 | port: Optional[int] = None, 24 | timeout: _TYPE_TIMEOUT = _DEFAULT_TIMEOUT, 25 | source_address: Optional[Tuple[str, int]] = None, 26 | blocksize: int = 8192, 27 | socket_options: Optional[_TYPE_SOCKET_OPTIONS] = None, 28 | **_kwargs: Dict[str, Any], 29 | ) -> None: 30 | context = ATLSContext(self.Validators) 31 | 32 | super().__init__( 33 | host, 34 | context, 35 | port, 36 | timeout, 37 | source_address, 38 | blocksize, 39 | socket_options, 40 | ) 41 | -------------------------------------------------------------------------------- /python-package/src/atls/utils/requests/__init__.py: -------------------------------------------------------------------------------- 1 | from atls.utils.requests.adapter import HTTPAAdapter 2 | 3 | __all__ = ["HTTPAAdapter"] 4 | -------------------------------------------------------------------------------- /python-package/src/atls/utils/requests/adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Union 2 | 3 | from atls.utils._httpa_connection_shim import _HTTPAConnectionShim 4 | from atls.validators import Validator 5 | from requests.adapters import ( 6 | DEFAULT_POOLBLOCK, 7 | DEFAULT_POOLSIZE, 8 | DEFAULT_RETRIES, 9 | HTTPAdapter, 10 | ) 11 | from urllib3 import HTTPSConnectionPool 12 | from urllib3.poolmanager import PoolManager 13 | from urllib3.util.retry import Retry as Retry 14 | 15 | 16 | class _HTTPAPoolManager(PoolManager): 17 | def __init__( 18 | self, 19 | validators: List[Validator], 20 | num_pools: int = DEFAULT_POOLSIZE, 21 | headers: Optional[Dict[Any, Any]] = None, 22 | **connection_pool_kw: Dict[str, Any], 23 | ) -> None: 24 | # This must be called first because it initializes 25 | # pool_classes_by_scheme, which we modify below. 26 | super().__init__(num_pools, headers, **connection_pool_kw) 27 | 28 | dyn_connection_type = type( 29 | "_HTTPAConnectionShim", 30 | (_HTTPAConnectionShim,), 31 | {"Validators": validators}, 32 | ) 33 | 34 | dyn_pool_manager_type = type( 35 | "_HTTPAConnectionPool", 36 | (HTTPSConnectionPool,), 37 | {"ConnectionCls": dyn_connection_type}, 38 | ) 39 | 40 | pools_by_scheme = self.pool_classes_by_scheme.copy() # type: ignore 41 | pools_by_scheme["httpa"] = dyn_pool_manager_type 42 | 43 | self.pool_classes_by_scheme = pools_by_scheme 44 | self.key_fn_by_scheme["httpa"] = self.key_fn_by_scheme["https"] 45 | 46 | 47 | class HTTPAAdapter(HTTPAdapter): 48 | def __init__( 49 | self, 50 | validators: List[Validator], 51 | pool_connections: int = DEFAULT_POOLSIZE, 52 | pool_maxsize: int = DEFAULT_POOLSIZE, 53 | max_retries: Union[Retry, int] = DEFAULT_RETRIES, 54 | pool_block: bool = DEFAULT_POOLBLOCK, 55 | ) -> None: 56 | self.validators = validators 57 | 58 | super().__init__( 59 | pool_connections, pool_maxsize, max_retries, pool_block 60 | ) 61 | 62 | def init_poolmanager( 63 | self, 64 | connections: int, 65 | maxsize: int, 66 | block: bool = DEFAULT_POOLBLOCK, 67 | **pool_kwargs: Dict[str, Any], 68 | ) -> None: 69 | self.poolmanager = _HTTPAPoolManager( 70 | validators=self.validators, 71 | num_pools=connections, 72 | maxsize=maxsize, # type: ignore 73 | block=block, # type: ignore 74 | **pool_kwargs, 75 | ) 76 | -------------------------------------------------------------------------------- /python-package/src/atls/utils/urllib3/__init__.py: -------------------------------------------------------------------------------- 1 | from atls.utils.urllib3.patch import extract_from_urllib3, inject_into_urllib3 2 | 3 | __all__ = ["inject_into_urllib3", "extract_from_urllib3"] 4 | -------------------------------------------------------------------------------- /python-package/src/atls/utils/urllib3/patch.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import urllib3 4 | from atls.utils._httpa_connection_shim import _HTTPAConnectionShim 5 | from atls.validators import Validator 6 | 7 | _orig_urllib3_connection_cls = None 8 | 9 | 10 | def inject_into_urllib3(validators: List[Validator]) -> None: 11 | """ 12 | Monkey-patch aTLS support into urllib3. 13 | 14 | This function overrides the class that urllib3 uses for HTTPS connections 15 | with a wrapper for the HTTPAConnection class. The wrapper is necessary 16 | because the interface that urllib3 expects is not quite the same as that 17 | provided by HTTPAConnection (and which it should not provide). 18 | 19 | Injecting aTLS into urllib3 also allows the requests library to use aTLS, 20 | too. 21 | 22 | Call extract_from_urllib3() to undo the changes made by this function. 23 | """ 24 | 25 | global _orig_urllib3_connection_cls 26 | _orig_urllib3_connection_cls = ( 27 | urllib3.connectionpool.HTTPSConnectionPool.ConnectionCls 28 | ) 29 | 30 | _HTTPAConnectionShim.Validators = validators 31 | urllib3.connectionpool.HTTPSConnectionPool.ConnectionCls = ( 32 | _HTTPAConnectionShim 33 | ) 34 | 35 | 36 | def extract_from_urllib3() -> None: 37 | """Undoes the changes made by inject_into_urllib3().""" 38 | 39 | global _orig_urllib3_connection_cls 40 | if _orig_urllib3_connection_cls is None: 41 | return 42 | 43 | urllib3.connectionpool.HTTPSConnectionPool.ConnectionCls = ( 44 | _orig_urllib3_connection_cls 45 | ) 46 | 47 | _orig_urllib3_connection_cls = None 48 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/__init__.py: -------------------------------------------------------------------------------- 1 | from atls.validators.validator import Validator 2 | 3 | __all__ = ["Validator"] 4 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/azure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opaque-systems/atls-python/ccba3261f22623612fa7275cb8e242c2cd1ac4a2/python-package/src/atls/validators/azure/__init__.py -------------------------------------------------------------------------------- /python-package/src/atls/validators/azure/aas/__init__.py: -------------------------------------------------------------------------------- 1 | from atls.validators.azure.aas.aci_validator import AciValidator 2 | from atls.validators.azure.aas.cvm_validator import CvmValidator 3 | from atls.validators.azure.aas.shared import PUBLIC_JKUS 4 | 5 | __all__ = [ 6 | "PUBLIC_JKUS", 7 | "AciValidator", 8 | "CvmValidator", 9 | ] 10 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/azure/aas/aci_validator.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import logging 5 | import os 6 | from datetime import timedelta 7 | from typing import Any, Dict, List, Optional 8 | 9 | import jwt 10 | from atls.validators import Validator 11 | from cryptography import x509 12 | from cryptography.hazmat.primitives.asymmetric.types import ( 13 | CertificatePublicKeyTypes, 14 | ) 15 | from cryptography.x509.oid import ObjectIdentifier 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class AciValidator(Validator): 21 | """ 22 | Validates an attestation document issued for a confidential Azure ACI 23 | container running on AMD SEV-SNP using the Azure Attestation Service (AAS). 24 | 25 | Parameters 26 | ---------- 27 | policies : list of str, optional 28 | A list of one or more allowed plaintext Azure Confidential Computing 29 | Enforcement (CCE) policies. If no policies are provided, all policies 30 | are allowed, but a warning is issued. 31 | 32 | jkus : list of str, optional 33 | A list of one or more allowed JKU claim values. The JKU claim contains 34 | the URL of the JWKS server that contains the public key to use to 35 | verify the signature of the JWT token issued by AAS. If no JKU claim 36 | values are provided, all values are allowed, but a warning is issued. 37 | """ 38 | 39 | def __init__( 40 | self, 41 | policies: Optional[List[str]] = None, 42 | jkus: Optional[List[str]] = None, 43 | ) -> None: 44 | super().__init__() 45 | 46 | self._policies = policies 47 | self._jkus = jkus 48 | 49 | @staticmethod 50 | def accepts(oid: ObjectIdentifier) -> bool: 51 | # 1.3.9999.2.1.2 = iso.identified-organization.reserved.azure.aas.aci 52 | return oid == ObjectIdentifier("1.3.9999.2.1.2") 53 | 54 | def validate( 55 | self, document: bytes, public_key: bytes, nonce: bytes 56 | ) -> bool: 57 | # This verifies the signature of the JWT, too. 58 | logger.info("Verifying and decoding JWT token...") 59 | try: 60 | token = _verify_and_decode_token(document.decode(), self._jkus) 61 | except jwt.PyJWTError as ex: 62 | logger.error("Failed to verify and decode JWT token:\n%s", ex) 63 | return False 64 | 65 | # The runtime data structure must match exactly the structure generated 66 | # by the Go ACI attestation issuer. 67 | logger.info("Parsing runtime data...") 68 | runtime_data = { 69 | "publicKey": base64.b64encode(public_key).decode(), 70 | "nonce": base64.b64encode(nonce).decode(), 71 | } 72 | 73 | # The JSON representation of the runtime data structure must match 74 | # exactly that generated by the Go ACI attestation issuer. In this 75 | # case, Go's JSON marshaller generates the most compact representation 76 | # possible (i.e., no whitespace), unlike Python's, so configure the 77 | # latter accordingly. 78 | runtime_data_json = json.dumps(runtime_data, separators=(",", ":")) 79 | runtime_data_json_hash = hashlib.sha256(runtime_data_json.encode()) 80 | runtime_data_json_hash_hex = runtime_data_json_hash.hexdigest() 81 | 82 | # A JWT token is valid if both of the following conditions are true: 83 | # 84 | # 1. It contains the keys we expect it to contain, and; 85 | # 2. The keys have the values we expect them to have. 86 | # 87 | # The first condition is the reason why we forgo .get() and explicitly 88 | # check for the presence of the keys. 89 | 90 | # TODO/HEGATTA: The AAS SEV-SNP attestation endpoint expects the 91 | # runtime data hash to be SHA256 while SEV-SNP hardware itself expects 92 | # a 512-byte block. As such, the last 64 hex bytes in AAS' claim is 93 | # just zeroes. Ideally, AAS should accept a SHA512 hash of the runtime 94 | # data. 95 | if "x-ms-sevsnpvm-reportdata" not in token: 96 | logger.error("x-ms-sevsnpvm-reportdata is missing") 97 | return False 98 | 99 | aas_runtime_data_hash: str = token["x-ms-sevsnpvm-reportdata"] 100 | aas_runtime_data_hash = aas_runtime_data_hash[:64] 101 | 102 | if runtime_data_json_hash_hex != aas_runtime_data_hash: 103 | logger.error("Runtime data in JWT is not as expected") 104 | return False 105 | 106 | if ( 107 | "x-ms-attestation-type" not in token 108 | or token["x-ms-attestation-type"] != "sevsnpvm" 109 | ): 110 | logger.error("x-ms-attestation-type is missing or incorrect") 111 | return False 112 | 113 | if ( 114 | "x-ms-compliance-status" not in token 115 | or token["x-ms-compliance-status"] != "azure-compliant-uvm" 116 | ): 117 | logger.error("x-ms-compliance-status is missing or incorrect") 118 | return False 119 | 120 | if ( 121 | "x-ms-sevsnpvm-is-debuggable" not in token 122 | or token["x-ms-sevsnpvm-is-debuggable"] 123 | ): 124 | logger.error("x-ms-sevsnpvm-is-debuggable is missing or incorrect") 125 | return False 126 | 127 | if "x-ms-runtime" not in token: 128 | logger.error("x-ms-runtime is missing") 129 | return False 130 | 131 | token_runtime: Dict[str, Any] = token["x-ms-runtime"] 132 | 133 | if ( 134 | "nonce" not in token_runtime 135 | or base64.b64decode(token_runtime["nonce"]) != nonce 136 | ): 137 | logger.error("nonce is incorrect") 138 | return False 139 | 140 | if ( 141 | "publicKey" not in token_runtime 142 | or base64.b64decode(token_runtime["publicKey"]) != public_key 143 | ): 144 | logger.error("public key in runtime data is incorrect") 145 | return False 146 | 147 | if self._policies is not None: 148 | if "x-ms-sevsnpvm-hostdata" not in token: 149 | return False 150 | 151 | token_host_data = token["x-ms-sevsnpvm-hostdata"] 152 | for policy in self._policies: 153 | policy_hash_hex = hashlib.sha256(policy.encode()).hexdigest() 154 | 155 | if token_host_data == policy_hash_hex: 156 | return True 157 | 158 | return False 159 | 160 | logger.info("JWT validation succeeded") 161 | return True 162 | 163 | @property 164 | def jkus(self) -> Optional[List[str]]: 165 | """List of allowed JKU claim values.""" 166 | return self._jkus 167 | 168 | @jkus.setter 169 | def jkus(self, jkus: List[str]) -> None: 170 | self._jkus = jkus 171 | 172 | @property 173 | def policies(self) -> Optional[List[str]]: 174 | """ 175 | List of allowed Confidential Computing Enforcement (CCE) policies. 176 | """ 177 | return self._policies 178 | 179 | @policies.setter 180 | def policies(self, policies: Optional[List[str]]) -> None: 181 | self._policies = policies 182 | 183 | 184 | def _get_key_by_header( 185 | header: Dict[str, Any], jkus: Optional[List[str]] 186 | ) -> CertificatePublicKeyTypes: 187 | """ 188 | Given an AAS-issued JWT header, this function contacts the JWKS server 189 | indicated by its JKU claim and attempts to find there the public key that 190 | corresponds to the JWT's signature. 191 | 192 | Parameters 193 | ---------- 194 | header : dict of str to any 195 | An unverified JWT header issued by AAS containing a JKU claim. 196 | 197 | jkus : list of str, optional 198 | A list of trusted JWKS URLs (i.e., known-good values of the JKU claim). 199 | If the JKU claim in the provided header is not in this list, this 200 | function raises an exception. 201 | 202 | Returns 203 | ------- 204 | public_key : CertificatePublicKeyTypes 205 | A decoded public key for use with Python's cryptography module that can 206 | be used to verify the signature of the JWT token whose unverified 207 | header was passed to this function. 208 | """ 209 | jku: str = header["jku"] 210 | 211 | if jkus is not None and jku not in jkus: 212 | raise ValueError("Untrusted JKU found in token") 213 | 214 | logger.info("Fetching KID...") 215 | kid: str = header["kid"] 216 | 217 | jwks_client = jwt.PyJWKClient(jku) 218 | 219 | logger.info("Fetching JWKS data...") 220 | jwks_data = jwks_client.fetch_data() 221 | 222 | for key in jwks_data.get("keys", []): 223 | if key["kid"] == kid: 224 | logger.info("Decoding x5c value...") 225 | cert_der = jwt.utils.base64url_decode(key["x5c"][0]) 226 | 227 | logger.info("Loading certificate and public key...") 228 | return x509.load_der_x509_certificate(cert_der).public_key() 229 | 230 | raise LookupError("No matching key was found in JWKS") 231 | 232 | 233 | def _verify_and_decode_token( 234 | token: str, jkus: Optional[List[str]] 235 | ) -> Dict[str, Any]: 236 | """ 237 | Given an AAS-issued JWT header, this function verifies its signature and 238 | decodes its claims. 239 | 240 | Parameters 241 | ---------- 242 | token : str 243 | A JWT token issued by AAS. 244 | 245 | jkus : list of str, optional 246 | A list of trusted JWKS URLs (i.e., known-good values of the JKU claim). 247 | 248 | Returns 249 | ------- 250 | claims : dict of str to any 251 | A dictionary containing the decoded claims from the provided JWT token. 252 | """ 253 | hdr = jwt.get_unverified_header(token) 254 | 255 | max_skew: int = int(os.getenv("PYATLS_CLOCK_SKEW_SECONDS_MAX", 5)) 256 | delta = timedelta(seconds=max_skew) 257 | 258 | logger.debug("Max allowed clock skew is %s", delta) 259 | 260 | return jwt.decode( 261 | token, 262 | _get_key_by_header(hdr, jkus), 263 | [hdr["alg"]], 264 | leeway=delta, 265 | ) 266 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/azure/aas/cvm_validator.py: -------------------------------------------------------------------------------- 1 | from atls.validators import Validator 2 | from cryptography.x509.oid import ObjectIdentifier 3 | 4 | 5 | class CvmValidator(Validator): 6 | """ 7 | Validates an attestation document issued for an Azure Confidential Virtual 8 | Machine (CVM) running on AMD SEV-SNP using the Azure Attestation Service 9 | (AAS) 10 | """ 11 | 12 | @staticmethod 13 | def accepts(oid: ObjectIdentifier) -> bool: 14 | # 1.3.9999.2.1.1 = iso.identified-organization.reserved.azure.aas.cvm 15 | return oid == ObjectIdentifier("1.3.9999.2.1.1") 16 | 17 | def validate( 18 | self, document: bytes, public_key: bytes, nonce: bytes 19 | ) -> bool: 20 | raise NotImplementedError() 21 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/azure/aas/shared.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | PUBLIC_JKUS: List[str] = [ 4 | "https://sharedcus.cus.attest.azure.net/certs", 5 | "https://sharedeus.eus.attest.azure.net/certs", 6 | "https://sharedeus2.eus2.attest.azure.net/certs", 7 | "https://shareduks.uks.attest.azure.net/certs", 8 | ] 9 | -------------------------------------------------------------------------------- /python-package/src/atls/validators/validator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from cryptography.x509.oid import ObjectIdentifier 4 | 5 | 6 | class Validator(ABC): 7 | """ 8 | Abstract class representing functionality to verify evidence or appraise an 9 | attestation result generated by an attester or verifier, respectively. 10 | """ 11 | 12 | @staticmethod 13 | @abstractmethod 14 | def accepts(oid: ObjectIdentifier) -> bool: 15 | """ 16 | Returns whether this validator can appraise an attestation document 17 | contained in a certificate extension with the specified X.509 Object 18 | Identifier (OID). 19 | """ 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def validate( 24 | self, document: bytes, public_key: bytes, nonce: bytes 25 | ) -> bool: 26 | """ 27 | Given an attestation document, a public key, and a nonce, appraises the 28 | document to ascertain the trustworthiness of the issuer. 29 | 30 | Parameters 31 | ---------- 32 | document : bytes 33 | A string of bytes whose underlying format is understood only by the 34 | issuer and validator pair that issued it and that will validate it, 35 | respectively. In other words, the contents of this parameters are 36 | opaque to the caller. 37 | 38 | public_key : bytes 39 | A public key in PKIX ASN.1 DER form. More specifically, this is an 40 | ASN.1 DER-marshalled SubjectPublicKeyInfo structure as per RFC 41 | 5280, Section 4.1 42 | 43 | nonce : bytes 44 | A random string of bytes that must be present in the attestation 45 | document as a means to ascertain the freshness of the evidence and 46 | prevent replay attacks. 47 | 48 | Returns 49 | ------- 50 | ok : bool 51 | True if the document proves that the peer is trustworthy and False 52 | otherwise. 53 | """ 54 | raise NotImplementedError 55 | -------------------------------------------------------------------------------- /scripts/install_pre_commit.sh: -------------------------------------------------------------------------------- 1 | # This script will install all pre-commit hooks and their dependencies 2 | # Once the pre-commit hooks are installed, on each commit you should see a series of linter checks 3 | # Note that this script should be run from the Opaque root directory. 4 | 5 | # Exit on error 6 | set -e 7 | 8 | echo "Installing pre-commit hooks and dependencies." 9 | PROJECT_ROOT_DIR=$(git rev-parse --show-toplevel) 10 | 11 | 12 | if [[ $(lsb_release -is) != "Ubuntu" ]]; then 13 | echo "Unsupported OS distribution. This script currently only works on Ubuntu." 14 | exit 1 15 | fi 16 | 17 | if [[ $(lsb_release -rs) != "20.04" ]]; then 18 | echo "This script has only been tested on Ubuntu 20.04, and is not guaranteed to work on another OS version." 19 | fi 20 | 21 | # Install actionlint to lint GitHub Actions files 22 | go install github.com/rhysd/actionlint/cmd/actionlint@v1.6.19 23 | 24 | # actionlint uses shellcheck to validate bash within a workflow 25 | sudo apt-get install shellcheck 26 | 27 | # Install pre-commit 28 | pip3 install pre-commit 29 | 30 | # Install pre-commit hooks 31 | cd $PROJECT_ROOT_DIR 32 | pre-commit install 33 | --------------------------------------------------------------------------------