├── .github └── workflows │ ├── ci.yml │ ├── notarize.yml │ └── publish-test-report.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.txt ├── OWNERS ├── README.md ├── SECURITY.md ├── dev-requirements.txt ├── docs ├── README.md ├── oidc.md └── registration_policies.md ├── environment.yml ├── postman ├── README.md ├── run-sanity.sh ├── sanity.postman_collection.json └── statement.cbor ├── pytest.ini ├── run-tests.sh ├── scitt-emulator.sh ├── scitt_emulator ├── __init__.py ├── ccf.py ├── cli.py ├── client.py ├── create_statement.py ├── did_helpers.py ├── key_helper_dataclasses.py ├── key_helpers.py ├── key_loader_format_did_jwk.py ├── key_loader_format_url_referencing_oidc_issuer.py ├── key_loader_format_url_referencing_scitt_scrapi.py ├── key_loader_format_url_referencing_ssh_authorized_keys.py ├── key_transforms.py ├── oidc.py ├── plugin_helpers.py ├── rkvst.py ├── rkvst_mocks.py ├── scitt.py ├── server.py ├── tree_algs.py └── verify_statement.py ├── setup.py └── tests ├── __init__.py ├── test_cli.py ├── test_docs.py └── test_plugin_helpers.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | paths-ignore: 10 | - '**.md' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | ci-venv: 15 | name: CI (venv) 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ["3.8"] 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - run: ./run-tests.sh 27 | 28 | ci-conda: 29 | name: CI (conda) 30 | runs-on: ubuntu-latest 31 | defaults: 32 | run: 33 | # https://github.com/conda-incubator/setup-miniconda#use-a-default-shell 34 | shell: bash -el {0} 35 | steps: 36 | - uses: actions/checkout@v3 37 | - uses: conda-incubator/setup-miniconda@v2 38 | with: 39 | activate-environment: scitt 40 | environment-file: environment.yml 41 | - run: | 42 | python -m pip install -e . 43 | python -m pytest 44 | 45 | ci-cd-build-and-push-image-container: 46 | name: CI/CD (container) 47 | runs-on: ubuntu-latest 48 | permissions: 49 | contents: read 50 | packages: write 51 | env: 52 | REGISTRY: ghcr.io 53 | CONTAINER_IMAGE_NAME: ${{ github.repository }} 54 | steps: 55 | - uses: actions/checkout@v3 56 | - name: Log in to the Container registry 57 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 58 | with: 59 | registry: ${{ env.REGISTRY }} 60 | username: ${{ github.actor }} 61 | password: ${{ secrets.GITHUB_TOKEN }} 62 | - name: Extract metadata (tags, labels) for Docker 63 | id: meta 64 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 65 | with: 66 | images: | 67 | ${{ env.REGISTRY }}/${{ env.CONTAINER_IMAGE_NAME }} 68 | - name: Build and push Docker image 69 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 70 | with: 71 | context: . 72 | push: ${{ github.ref == 'refs/heads/main' }} 73 | tags: ${{ steps.meta.outputs.tags }} 74 | labels: ${{ steps.meta.outputs.labels }} 75 | -------------------------------------------------------------------------------- /.github/workflows/notarize.yml: -------------------------------------------------------------------------------- 1 | name: "SCITT Notary" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | workflow_dispatch: 10 | inputs: 11 | scitt-url: 12 | description: 'URL of SCITT instance' 13 | type: string 14 | payload: 15 | description: 'Payload for claim' 16 | default: '' 17 | type: string 18 | subject: 19 | description: 'Subject for statement' 20 | default: '' 21 | type: string 22 | workflow_call: 23 | inputs: 24 | scitt-url: 25 | description: 'URL of SCITT instance' 26 | type: string 27 | payload: 28 | description: 'Payload for claim' 29 | type: string 30 | subject: 31 | description: 'Subject for statement' 32 | default: '' 33 | type: string 34 | 35 | jobs: 36 | notarize: 37 | runs-on: ubuntu-latest 38 | permissions: 39 | id-token: write 40 | env: 41 | SCITT_URL: '${{ inputs.scitt-url || github.event.inputs.scitt-url }}' 42 | PAYLOAD: '${{ inputs.payload || github.event.inputs.payload }}' 43 | SUBJECT: '${{ inputs.subject || github.event.inputs.subject }}' 44 | steps: 45 | - name: Set defaults if env vars not set (as happens with on.push trigger) 46 | run: | 47 | if [[ "x${SCITT_URL}" = "x" ]]; then 48 | echo "SCITT_URL=http://localhost:8080" >> "${GITHUB_ENV}" 49 | fi 50 | if [[ "x${PAYLOAD}" = "x" ]]; then 51 | echo 'PAYLOAD={"key": "value"}' >> "${GITHUB_ENV}" 52 | fi 53 | if [[ "x${SUBJECT}" = "x" ]]; then 54 | echo 'SUBJECT=subject:value' >> "${GITHUB_ENV}" 55 | fi 56 | - uses: actions/checkout@v4 57 | - name: Set up Python 3.8 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: 3.8 61 | - name: Install SCITT API Emulator 62 | run: | 63 | pip install -U pip setuptools wheel 64 | pip install .[oidc] 65 | - name: Install github-script dependencies 66 | run: | 67 | npm install @actions/core 68 | - name: Get OIDC token to use as bearer token for auth to SCITT 69 | uses: actions/github-script@v6 70 | id: github-oidc 71 | with: 72 | script: | 73 | const {SCITT_URL} = process.env; 74 | core.setOutput('token', await core.getIDToken(SCITT_URL)); 75 | - name: Create claim 76 | run: | 77 | scitt-emulator client create-claim --issuer did:web:example.org --subject "${SUBJECT}" --content-type application/json --payload "${PAYLOAD}" --out claim.cose 78 | - name: Submit claim 79 | env: 80 | OIDC_TOKEN: '${{ steps.github-oidc.outputs.token }}' 81 | WORKFLOW_REF: '${{ github.workflow_ref }}' 82 | # Use of job_workflow_sha blocked by 83 | # https://github.com/actions/runner/issues/2417#issuecomment-1718369460 84 | JOB_WORKFLOW_SHA: '${{ github.sha }}' 85 | REPOSITORY_OWNER_ID: '${{ github.repository_owner_id }}' 86 | REPOSITORY_ID: '${{ github.repository_id }}' 87 | run: | 88 | # Create the middleware config file 89 | tee oidc-middleware-config.json < ## ⚠️This Repository Has Been Archived and Is No Longer Maintained. ⚠️ 4 | 5 | For current info, please see: 6 | 7 | - [IETF SCITT Working Group](https://datatracker.ietf.org/wg/scitt/about/) 8 | - [SCITT Implementations](https://scitt.io/implementations.html) 9 | 10 | [Pre-archived content](https://github.com/scitt-community/scitt-api-emulator/blob/pre-archive/README.md) -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pyOpenSSL 2 | pytest 3 | jsonschema 4 | requests==2.31.0 5 | requests-toolbelt==0.9 6 | urllib3<2.0.0 7 | myst-parser 8 | PyJWT 9 | jwcrypto 10 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | This directory is used to publish test reports to the `gh-pages` branch. -------------------------------------------------------------------------------- /docs/oidc.md: -------------------------------------------------------------------------------- 1 | # OIDC Support 2 | 3 | - References 4 | - [5.1.1.1.1.](https://github.com/ietf-wg-scitt/draft-ietf-scitt-architecture/blob/main/draft-ietf-scitt-architecture.md#comment-on-oidc) 5 | 6 | [![asciicast-of-oidc-auth-issued-by-github-actions](https://asciinema.org/a/607600.svg)](https://asciinema.org/a/607600) 7 | 8 | ## Dependencies 9 | 10 | Install the SCITT API Emulator with the `oidc` extra. 11 | 12 | ```console 13 | $ pip install -e .[oidc] 14 | ``` 15 | 16 | ## Usage example with GitHub Actions 17 | 18 | See [`notarize.yml`](../.github/workflows/notarize.yml) 19 | 20 | References: 21 | 22 | - https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/using-openid-connect-with-reusable-workflows 23 | - https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect 24 | -------------------------------------------------------------------------------- /docs/registration_policies.md: -------------------------------------------------------------------------------- 1 | # Registration Policies 2 | 3 | - References 4 | - [5.2.2. Registration Policies](https://www.ietf.org/archive/id/draft-birkholz-scitt-architecture-02.html#name-registration-policies) 5 | 6 | ## Simple decoupled file based policy engine 7 | 8 | The SCITT API emulator can deny entry based on presence of 9 | `operation.policy.{insert,denied,failed}` files. Currently only for use with 10 | `use_lro=True`. 11 | 12 | This is a simple way to enable evaluation of claims prior to submission by 13 | arbitrary policy engines which watch the workspace (fanotify, inotify, etc.). 14 | 15 | [![asciicast-of-simple-decoupled-file-based-policy-engine](https://asciinema.org/a/620587.svg)](https://asciinema.org/a/620587) 16 | 17 | Start the server 18 | 19 | ```console 20 | $ rm -rf workspace/ 21 | $ mkdir -p workspace/storage/operations 22 | $ timeout 1s scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro 23 | Service parameters: workspace/service_parameters.json 24 | ^C 25 | ``` 26 | 27 | Modification of config to non-`*` insert policy. Restart SCITT API emulator server after this. 28 | 29 | ```console 30 | $ echo "$(cat workspace/service_parameters.json)" \ 31 | | jq '.insertPolicy = "allowlist.schema.json"' \ 32 | | tee workspace/service_parameters.json.new \ 33 | && mv workspace/service_parameters.json.new workspace/service_parameters.json 34 | { 35 | "serviceId": "emulator", 36 | "treeAlgorithm": "CCF", 37 | "signatureAlgorithm": "ES256", 38 | "serviceCertificate": "-----BEGIN CERTIFICATE-----", 39 | "insertPolicy": "allowlist.schema.json" 40 | } 41 | ``` 42 | 43 | Basic policy engine in two files 44 | 45 | **enforce_policy.py** 46 | 47 | ```python 48 | import os 49 | import sys 50 | import pathlib 51 | 52 | policy_reason = "" 53 | if "POLICY_REASON_PATH" in os.environ: 54 | policy_reason = pathlib.Path(os.environ["POLICY_REASON_PATH"]).read_text() 55 | 56 | cose_path = pathlib.Path(sys.argv[-1]) 57 | policy_action_path = cose_path.with_suffix(".policy." + os.environ["POLICY_ACTION"].lower()) 58 | policy_action_path.write_text(policy_reason) 59 | ``` 60 | 61 | Simple drop rule based on claim content allowlist. 62 | 63 | **allowlist.schema.json** 64 | 65 | ```json 66 | { 67 | "$id": "https://schema.example.com/scitt-allowlist.schema.json", 68 | "$schema": "https://json-schema.org/draft/2020-12/schema", 69 | "properties": { 70 | "issuer": { 71 | "type": "string", 72 | "enum": [ 73 | "did:web:example.org" 74 | ] 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | **jsonschema_validator.py** 81 | 82 | ```python 83 | import os 84 | import sys 85 | import json 86 | import pathlib 87 | import unittest 88 | 89 | import cwt 90 | import pycose 91 | from pycose.messages import Sign1Message 92 | from jsonschema import validate, ValidationError 93 | 94 | from scitt_emulator.scitt import ClaimInvalidError, CWTClaims 95 | from scitt_emulator.verify_statement import verify_statement 96 | from scitt_emulator.key_helpers import verification_key_to_object 97 | 98 | 99 | def main(): 100 | claim = sys.stdin.buffer.read() 101 | 102 | msg = Sign1Message.decode(claim, tag=True) 103 | 104 | if pycose.headers.ContentType not in msg.phdr: 105 | raise ClaimInvalidError("Claim does not have a content type header parameter") 106 | if not msg.phdr[pycose.headers.ContentType].startswith("application/json"): 107 | raise TypeError( 108 | f"Claim content type does not start with application/json: {msg.phdr[pycose.headers.ContentType]!r}" 109 | ) 110 | 111 | verification_key = verify_statement(msg) 112 | unittest.TestCase().assertTrue( 113 | verification_key, 114 | "Failed to verify signature on statement", 115 | ) 116 | 117 | cwt_protected = cwt.decode(msg.phdr[CWTClaims], verification_key.cwt) 118 | issuer = cwt_protected[1] 119 | subject = cwt_protected[2] 120 | 121 | issuer_key_as_object = verification_key_to_object(verification_key) 122 | unittest.TestCase().assertTrue( 123 | issuer_key_as_object, 124 | "Failed to convert issuer key to JSON schema verifiable object", 125 | ) 126 | 127 | SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text()) 128 | 129 | try: 130 | validate( 131 | instance={ 132 | "$schema": "https://schema.example.com/scitt-policy-engine-jsonschema.schema.json", 133 | "issuer": issuer, 134 | "issuer_key": issuer_key_as_object, 135 | "subject": subject, 136 | "claim": json.loads(msg.payload.decode()), 137 | }, 138 | schema=SCHEMA, 139 | ) 140 | except ValidationError as error: 141 | print(str(error), file=sys.stderr) 142 | sys.exit(1) 143 | 144 | 145 | if __name__ == "__main__": 146 | main() 147 | ``` 148 | 149 | We'll create a small wrapper to serve in place of a more fully featured policy 150 | engine. 151 | 152 | **policy_engine.sh** 153 | 154 | ```bash 155 | export SCHEMA_PATH="${1}" 156 | CLAIM_PATH="${2}" 157 | 158 | echo ${CLAIM_PATH} 159 | 160 | (python3 jsonschema_validator.py < ${CLAIM_PATH} 2>error && POLICY_ACTION=insert python3 enforce_policy.py ${CLAIM_PATH}) || (python3 -c 'import sys, json; print(json.dumps({"type": "denied", "detail": sys.stdin.read()}))' < error > reason.json; POLICY_ACTION=denied POLICY_REASON_PATH=reason.json python3 enforce_policy.py ${CLAIM_PATH}) 161 | ``` 162 | 163 | Example running allowlist check and enforcement. 164 | 165 | ```console 166 | $ npm install nodemon && \ 167 | DID_WEB_ASSUME_SCHEME=http node_modules/.bin/nodemon -e .cose --exec 'find workspace/storage/operations -name \*.cose -exec nohup sh -xe policy_engine.sh $(cat workspace/service_parameters.json | jq -r .insertPolicy) {} \;' 168 | ``` 169 | 170 | Also ensure you restart the server with the new config we edited. 171 | 172 | ```console 173 | $ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro 174 | ``` 175 | 176 | The current emulator notary (create-statement) implementation will sign 177 | statements using a generated ephemeral key or a key we provide via the 178 | `--private-key-pem` argument. 179 | 180 | Since we need to export the key for verification by the policy engine, we will 181 | first generate it using `ssh-keygen`. 182 | 183 | ```console 184 | $ export ISSUER_PORT="9000" \ 185 | && export ISSUER_URL="http://localhost:${ISSUER_PORT}" \ 186 | && ssh-keygen -q -f /dev/stdout -t ecdsa -b 384 -N '' -I $RANDOM <</dev/null | python -c 'import sys; from cryptography.hazmat.primitives import serialization; print(serialization.load_ssh_private_key(sys.stdin.buffer.read(), password=None).private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()).decode().rstrip())' > private-key.pem \ 187 | && scitt-emulator client create-claim \ 188 | --private-key-pem private-key.pem \ 189 | --issuer "${ISSUER_URL}" \ 190 | --subject "solar" \ 191 | --content-type application/json \ 192 | --payload '{"sun": "yellow"}' \ 193 | --out claim.cose 194 | ``` 195 | 196 | The core of policy engine we implemented in `jsonschema_validator.py` will 197 | verify the COSE message generated using the public portion of the notary's key. 198 | We've implemented two possible styles of key resolution. Both of them require 199 | resolution of public keys via an HTTP server. 200 | 201 | Let's start the HTTP server now, we'll populate the needed files in the 202 | sections corresponding to each resolution style. 203 | 204 | ```console 205 | $ python -m http.server "${ISSUER_PORT}" & 206 | $ python_http_server_pid=$! 207 | ``` 208 | 209 | ### SSH `authorized_keys` style notary public key resolution 210 | 211 | Keys are discovered via making an HTTP GET request to the URL given by the 212 | `issuer` parameter via the `web` DID method and de-serializing the SSH 213 | public keys found within the response body. 214 | 215 | GitHub exports a users authentication keys at https://github.com/username.keys 216 | Leveraging this URL as an issuer `did:web:github.com:username.keys` with the 217 | following pattern would enable a GitHub user to act as a SCITT notary. 218 | 219 | Start an HTTP server with an SSH public key served at the root. 220 | 221 | ```console 222 | $ cat private-key.pem | ssh-keygen -f /dev/stdin -y | tee index.html 223 | ``` 224 | 225 | ### OpenID Connect token style notary public key resolution 226 | 227 | Keys are discovered two part resolution of HTTP paths relative to the issuer 228 | 229 | `/.well-known/openid-configuration` path is requested via HTTP GET. The 230 | response body is parsed as JSON and the value of the `jwks_uri` key is 231 | requested via HTTP GET. 232 | 233 | `/.well-known/jwks` (is typically the value of `jwks_uri`) path is requested 234 | via HTTP GET. The response body is parsed as JSON. Public keys are loaded 235 | from the value of the `keys` key which stores an array of JSON Web Key (JWK) 236 | style serializations. 237 | 238 | ```console 239 | $ mkdir -p .well-known/ 240 | $ cat > .well-known/openid-configuration < 297 | sys.exit(load_entry_point('scitt-emulator', 'console_scripts', 'scitt-emulator')()) 298 | File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/cli.py", line 22, in main 299 | args.func(args) 300 | File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 196, in 301 | func=lambda args: submit_claim( 302 | File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 107, in submit_claim 303 | raise_for_operation_status(operation) 304 | File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 43, in raise_for_operation_status 305 | raise ClaimOperationError(operation) 306 | scitt_emulator.client.ClaimOperationError: Operation error denied: 'did:web:example.com' is not one of ['did:web:example.org'] 307 | 308 | Failed validating 'enum' in schema['properties']['issuer']: 309 | {'enum': ['did:web:example.org'], 'type': 'string'} 310 | 311 | On instance['issuer']: 312 | 'did:web:example.com' 313 | ``` 314 | 315 | ### Policy engine executing allowlist policy on allowed issuer 316 | 317 | Modify the allowlist to ensure that our issuer, aka our local HTTP server with 318 | our keys, is set to be the allowed issuer. 319 | 320 | ```console 321 | $ export allowlist="$(cat allowlist.schema.json)" && \ 322 | jq '.properties.issuer.enum = [env.ISSUER_URL, "http://localhost:8000"]' <(echo "${allowlist}") \ 323 | | tee allowlist.schema.json 324 | ``` 325 | 326 | Submit the statement from the issuer we just added to the allowlist. 327 | 328 | ```console 329 | $ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor 330 | Claim registered with entry ID 1 331 | Receipt written to claim.receipt.cbor 332 | ``` 333 | 334 | Stop the server that serves the public keys 335 | 336 | ```console 337 | $ kill $python_http_server_pid 338 | ``` 339 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: scitt 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - ca-certificates=2023.01.10 7 | - certifi=2022.12.7 8 | - cryptography=39.0.0 9 | - exceptiongroup=1.1.0 10 | - flask=2.2.2 11 | - httpcore=0.16.3 12 | - httpx=0.23.3 13 | - itsdangerous=2.1.2 14 | - jinja2=3.1.2 15 | - markupsafe=2.1.2 16 | - ncurses=6.3 17 | - openssl=1.1.1s 18 | - pytest=7.2.1 19 | - python=3.8.16 20 | - rfc3986=1.5.0 21 | - setuptools=66.1.1 22 | - wheel=0.38.4 23 | - pip=22.3.1 24 | - pip: 25 | - asn1crypto==1.5.1 26 | - cbor2==5.4.6 27 | - certvalidator==0.11.1 28 | - pycose==1.0.1 29 | - ecdsa==0.18.0 30 | - pyOpenSSL==23.2.0 31 | - oscrypto==1.3.0 32 | - requests==2.28 33 | - requests-toolbelt==0.9 34 | - rkvst-archivist==0.20.0 35 | - six==1.16.0 36 | - urllib3<2.0.0 37 | - myst-parser==1.0.0 38 | - jsonschema==4.17.3 39 | - jwcrypto==1.5.0 40 | - PyJWT==2.8.0 41 | - werkzeug==2.2.2 42 | - cwt==2.7.1 43 | -------------------------------------------------------------------------------- /postman/README.md: -------------------------------------------------------------------------------- 1 | # Testing the SCITT Emulator with Postman 2 | 3 | - [Install Postman](https://www.postman.com/downloads/) 4 | - [Install Newman](https://support.postman.com/hc/en-us/articles/115003703325-How-to-install-Newman) 5 | 6 | We will need to install some extra newman commands as well: 7 | 8 | ```sh 9 | npm install -g newman newman-reporter-htmlextra 10 | ``` 11 | 12 | Run this command from this directory: 13 | 14 | ```sh 15 | ./run-sanity.sh 16 | ``` 17 | -------------------------------------------------------------------------------- /postman/run-sanity.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | newman run sanity.postman_collection.json \ 4 | --reporters cli,htmlextra \ 5 | --reporter-htmlextra-skipSensitiveData \ 6 | --reporter-htmlextra-export "../docs/index.html" \ 7 | 8 | -------------------------------------------------------------------------------- /postman/sanity.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "5f8bcc82-332c-4c55-8d95-7f3861d65446", 4 | "name": "SCITT Emulator Sanity", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Submit Claim", 10 | "request": { 11 | "method": "POST", 12 | "header": [ 13 | { 14 | "key": "Content-Type", 15 | "value": "application/cose", 16 | "type": "default" 17 | } 18 | ], 19 | "body": { 20 | "mode": "file", 21 | "file": { 22 | "src": "./statement.cbor" 23 | } 24 | }, 25 | "url": { 26 | "raw": "http://127.0.0.1:8000/entries", 27 | "protocol": "http", 28 | "host": [ 29 | "127", 30 | "0", 31 | "0", 32 | "1" 33 | ], 34 | "port": "8000", 35 | "path": [ 36 | "entries" 37 | ] 38 | } 39 | }, 40 | "response": [] 41 | }, 42 | { 43 | "name": "Retrieve Claim", 44 | "event": [ 45 | { 46 | "listen": "prerequest", 47 | "script": { 48 | "exec": [ 49 | "// https://blog.postman.com/adding-external-libraries-in-postman/\r", 50 | "globalThis = this;\r", 51 | "pm.sendRequest(\"https://cdn.jsdelivr.net/npm/cbor-x@1.5.1/dist/index.min.js\", (err, res) => {\r", 52 | " eval(res.text()); \r", 53 | "})" 54 | ], 55 | "type": "text/javascript" 56 | } 57 | }, 58 | { 59 | "listen": "test", 60 | "script": { 61 | "exec": [ 62 | "CBOR = globalThis.CBOR;\r", 63 | "\r", 64 | "const COSESign1Tag = 18;\r", 65 | "const COSEAlgorithmLabel = 1;\r", 66 | "const COSEContentTypeLabel = 3;\r", 67 | "\r", 68 | "pm.test(\"Valid Claim response\", function () {\r", 69 | " pm.expect(pm.response.code).to.be.oneOf([200]);\r", 70 | "\r", 71 | " const msg = new CBOR.Decoder({mapsAsObjects: false}).decode(pm.response.stream);\r", 72 | " pm.expect(msg.tag).to.equal(COSESign1Tag);\r", 73 | " pm.expect(msg.value).to.have.length(4);\r", 74 | "\r", 75 | " const [phdr, uhdr, payload, signature] = msg.value;\r", 76 | " pm.expect(phdr).to.be.instanceof(Buffer);\r", 77 | " pm.expect(uhdr).to.be.instanceof(Map);\r", 78 | " pm.expect(payload).to.be.instanceof(Buffer);\r", 79 | " pm.expect(signature).to.be.instanceof(Buffer);\r", 80 | "\r", 81 | " const phdrDecoded = new CBOR.Decoder({mapsAsObjects: false}).decode(phdr);\r", 82 | " pm.expect(phdrDecoded).to.be.instanceof(Map);\r", 83 | " pm.expect(typeof phdrDecoded.get(COSEAlgorithmLabel)).to.be.oneOf(['number', 'string']);\r", 84 | " pm.expect(typeof phdrDecoded.get(COSEContentTypeLabel)).to.be.oneOf(['number', 'string']);\r", 85 | "});" 86 | ], 87 | "type": "text/javascript" 88 | } 89 | } 90 | ], 91 | "request": { 92 | "method": "GET", 93 | "header": [], 94 | "url": { 95 | "raw": "http://127.0.0.1:8000/entries/1", 96 | "protocol": "http", 97 | "host": [ 98 | "127", 99 | "0", 100 | "0", 101 | "1" 102 | ], 103 | "port": "8000", 104 | "path": [ 105 | "entries", 106 | "1" 107 | ] 108 | } 109 | }, 110 | "response": [] 111 | }, 112 | { 113 | "name": "Retrieve Receipt", 114 | "request": { 115 | "method": "GET", 116 | "header": [], 117 | "url": { 118 | "raw": "http://127.0.0.1:8000/entries/1/receipt", 119 | "protocol": "http", 120 | "host": [ 121 | "127", 122 | "0", 123 | "0", 124 | "1" 125 | ], 126 | "port": "8000", 127 | "path": [ 128 | "entries", 129 | "1", 130 | "receipt" 131 | ] 132 | } 133 | }, 134 | "response": [] 135 | } 136 | ] 137 | } -------------------------------------------------------------------------------- /postman/statement.cbor: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scitt-community/scitt-api-emulator/6e6776070a1adf7298c9427871f15a8f087be73d/postman/statement.cbor -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # https://docs.pytest.org/en/7.1.x/how-to/doctest.html#using-doctest-options 3 | doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL 4 | # Alternatively, options can be enabled by an inline comment in the doc test itself: 5 | # >>> something_that_raises() # doctest: +IGNORE_EXCEPTION_DETAIL 6 | # Traceback (most recent call last): 7 | # ValueError: ... 8 | addopts = --doctest-modules 9 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright (c) Microsoft Corporation. 3 | # Licensed under the MIT License. 4 | 5 | set -e 6 | 7 | if [ ! -f "venv/bin/activate" ]; then 8 | echo "Setting up Python virtual environment." 9 | python3 -m venv "venv" 10 | . ./venv/bin/activate 11 | pip install -q -U pip setuptools wheel 12 | pip install -q -r dev-requirements.txt 13 | pip install -q -e .[oidc] 14 | else 15 | . ./venv/bin/activate 16 | fi 17 | 18 | pytest "$@" 19 | -------------------------------------------------------------------------------- /scitt-emulator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright (c) Microsoft Corporation. 3 | # Licensed under the MIT License. 4 | 5 | set -e 6 | 7 | if [ ! -f "venv/bin/activate" ]; then 8 | echo "Setting up Python virtual environment." 9 | python3 -m venv "venv" 10 | . ./venv/bin/activate 11 | pip install -q -e . 12 | else 13 | . ./venv/bin/activate 14 | fi 15 | 16 | scitt-emulator "$@" 17 | -------------------------------------------------------------------------------- /scitt_emulator/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | -------------------------------------------------------------------------------- /scitt_emulator/ccf.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | from typing import Optional, Tuple 5 | from pathlib import Path 6 | from hashlib import sha256 7 | import datetime 8 | import pathlib 9 | import json 10 | 11 | import jwcrypto.jwk 12 | from cryptography.hazmat.primitives.asymmetric import ec, utils 13 | from cryptography.hazmat.primitives.serialization import ( 14 | Encoding, 15 | load_pem_private_key, 16 | NoEncryption, 17 | PrivateFormat, 18 | ) 19 | from cryptography import x509 20 | from cryptography.hazmat.primitives import hashes 21 | 22 | from scitt_emulator.scitt import SCITTServiceEmulator 23 | 24 | 25 | class CCFSCITTServiceEmulator(SCITTServiceEmulator): 26 | tree_alg = "CCF" 27 | 28 | def __init__( 29 | self, service_parameters_path: Path, storage_path: Optional[Path] = None 30 | ): 31 | super().__init__(service_parameters_path, storage_path) 32 | if storage_path is not None: 33 | self._service_private_key_path = ( 34 | self.storage_path / "service_private_key.pem" 35 | ) 36 | 37 | def initialize_service(self): 38 | if self.service_parameters_path.exists(): 39 | return 40 | 41 | # Create service private key 42 | service_private_key = ec.generate_private_key(ec.SECP256R1()) 43 | service_private_key_pem = service_private_key.private_bytes( 44 | Encoding.PEM, PrivateFormat.PKCS8, NoEncryption() 45 | ) 46 | with open(self._service_private_key_path, "wb") as f: 47 | f.write(service_private_key_pem) 48 | print(f"Service private key written to {self._service_private_key_path}") 49 | 50 | # Create service certificate 51 | issuer = subject = x509.Name( 52 | [x509.NameAttribute(x509.NameOID.COMMON_NAME, "service")] 53 | ) 54 | service_cert = ( 55 | x509.CertificateBuilder() 56 | .subject_name(subject) 57 | .issuer_name(issuer) 58 | .public_key(service_private_key.public_key()) 59 | .serial_number(x509.random_serial_number()) 60 | .not_valid_before(datetime.datetime.utcnow()) 61 | .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) 62 | .sign(service_private_key, hashes.SHA256()) 63 | ) 64 | service_cert_pem = service_cert.public_bytes(Encoding.PEM) 65 | 66 | self.service_parameters = { 67 | "serviceId": "emulator", 68 | "treeAlgorithm": self.tree_alg, 69 | "signatureAlgorithm": "ES256", 70 | "serviceCertificate": service_cert_pem.decode("utf-8"), 71 | } 72 | 73 | with open(self.service_parameters_path, "w") as f: 74 | json.dump(self.service_parameters, f) 75 | print(f"Service parameters written to {self.service_parameters_path}") 76 | 77 | def keys_as_jwks(self): 78 | key = jwcrypto.jwk.JWK() 79 | key_bytes = pathlib.Path(self._service_private_key_path).read_bytes() 80 | key.import_from_pem(key_bytes) 81 | return [ 82 | { 83 | **key.export_public(as_dict=True), 84 | "use": "sig", 85 | "kid": key.thumbprint(), 86 | } 87 | ] 88 | 89 | def create_receipt_contents(self, countersign_tbi: bytes, entry_id: str): 90 | # Load service private key and certificate 91 | with open(self._service_private_key_path, "rb") as f: 92 | priv_key_service = load_pem_private_key(f.read(), None) 93 | 94 | service_cert = x509.load_pem_x509_certificate( 95 | self.service_parameters["serviceCertificate"].encode("utf-8") 96 | ) 97 | 98 | # Create ad-hoc node key pair 99 | node_priv_key = ec.generate_private_key(ec.SECP256R1()) 100 | node_pub_key = node_priv_key.public_key() 101 | 102 | # Create ad-hoc node certificate endorsed by service key 103 | node_cert = ( 104 | x509.CertificateBuilder() 105 | .subject_name( 106 | x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "node")]) 107 | ) 108 | .issuer_name(service_cert.subject) 109 | .public_key(node_pub_key) 110 | .serial_number(x509.random_serial_number()) 111 | .not_valid_before(service_cert.not_valid_before) 112 | .not_valid_after(service_cert.not_valid_after) 113 | .sign(priv_key_service, hashes.SHA256()) 114 | ) 115 | node_cert_der = node_cert.public_bytes(Encoding.DER) 116 | 117 | # Compute Merkle tree leaf hash 118 | countersign_tbi_hash = sha256(countersign_tbi).digest() 119 | internal_hash = sha256(b"dummy").digest() 120 | internal_data = f"{entry_id}".encode("ascii") 121 | internal_data_hash = sha256(internal_data).digest() 122 | leaf = sha256( 123 | internal_hash + internal_data_hash + countersign_tbi_hash 124 | ).digest() 125 | print("Leaf hash: " + leaf.hex()) 126 | 127 | # Compute Merkle tree root 128 | fake_tree = CCFMerkleTree() 129 | for i in range(63): 130 | fake_tree.add_leaf(f"dummy-envelope-{i}".encode()) 131 | fake_tree.add_leaf(leaf, do_hash=False) 132 | root = fake_tree.get_merkle_root() 133 | print("Root: " + root.hex()) 134 | 135 | # Sign root 136 | signature_dss = node_priv_key.sign(root, ec.ECDSA(utils.Prehashed(hashes.SHA256()))) 137 | curve_size = node_priv_key.curve.key_size // 8 138 | signature = convert_dss_signature_to_p1363(signature_dss, curve_size) 139 | 140 | # Compute Merkle tree proof 141 | # Simplification, since the tree has an even number of leaves 142 | # and the leaf of interest is the last one. 143 | proof = [[True, level[-2]] for level in fake_tree.levels[::-1][:-1]] 144 | 145 | # Create receipt contents for CCF tree algorithm 146 | leaf_info = [internal_hash, internal_data] 147 | receipt_contents = [signature, node_cert_der, proof, leaf_info] 148 | 149 | return receipt_contents 150 | 151 | def verify_receipt_contents(self, receipt_contents: list, countersign_tbi: bytes): 152 | [signature, node_cert_der, proof, leaf_info] = receipt_contents 153 | 154 | [internal_hash, internal_data] = leaf_info 155 | 156 | # Compute Merkle tree leaf hash 157 | countersign_tbi_hash = sha256(countersign_tbi).digest() 158 | internal_data_hash = sha256(internal_data).digest() 159 | leaf = sha256( 160 | internal_hash + internal_data_hash + countersign_tbi_hash 161 | ).digest() 162 | print("Leaf hash: " + leaf.hex()) 163 | 164 | # Compute Merkle tree root 165 | current = leaf 166 | for [left, hash] in proof: 167 | if left: 168 | current = sha256(hash + current).digest() 169 | else: 170 | current = sha256(current + hash).digest() 171 | root = current 172 | print("Root: " + root.hex()) 173 | 174 | # Verify Merkle tree root signature 175 | signature_dss = convert_p1363_signature_to_dss(signature) 176 | node_cert = x509.load_der_x509_certificate(node_cert_der) 177 | node_cert.public_key().verify( 178 | signature_dss, root, ec.ECDSA(utils.Prehashed(hashes.SHA256())) 179 | ) 180 | 181 | # Verify node certificate 182 | service_cert = x509.load_pem_x509_certificate( 183 | self.service_parameters["serviceCertificate"].encode("utf-8") 184 | ) 185 | verify_certificate_is_issued_by(node_cert, service_cert) 186 | 187 | 188 | def decode_p1363_signature(signature: bytes) -> Tuple[int, int]: 189 | """ 190 | Decode an ECDSA signature from its IEEE P1363 encoding into its r and s 191 | components. The two integers are padded to the curve size and concatenated. 192 | 193 | This is the format used throughout the COSE/JOSE ecosystem. 194 | """ 195 | # The two components are padded to the same size, so we can find the size 196 | # of each one by taking half the size of the signature. 197 | if len(signature) % 2 != 0: 198 | raise ValueError("Signature must be an even number of bytes") 199 | mid = len(signature) // 2 200 | r = int.from_bytes(signature[:mid], "big") 201 | s = int.from_bytes(signature[mid:], "big") 202 | return r, s 203 | 204 | 205 | def convert_p1363_signature_to_dss(signature: bytes) -> bytes: 206 | """ 207 | Convert an ECDSA signature from its IEEE P1363 encoding to an ASN1/DER 208 | encoding. 209 | 210 | The former is the format used throughout the COSE/JOSE ecosystem. 211 | The latter is used by OpenSSL and the cryptography package. 212 | """ 213 | r, s = decode_p1363_signature(signature) 214 | return utils.encode_dss_signature(r, s) 215 | 216 | 217 | def convert_dss_signature_to_p1363(signature: bytes, curve_size: int) -> bytes: 218 | """ 219 | Convert an ECDSA signature from its ASN1/DER encoding to IEEE P1363 220 | encoding. 221 | 222 | The former is used by OpenSSL and the cryptography package. 223 | The latter is the format used throughout the COSE/JOSE ecosystem. 224 | """ 225 | r, s = utils.decode_dss_signature(signature) 226 | try: 227 | return r.to_bytes(curve_size, "big") + s.to_bytes(curve_size, "big") 228 | except OverflowError: 229 | raise ValueError("Signature is too large for given curve size") 230 | 231 | 232 | def verify_certificate_is_issued_by( 233 | certificate: x509.Certificate, other: x509.Certificate 234 | ): 235 | if other.subject != certificate.issuer: 236 | raise RuntimeError( 237 | "Certificate issuer does not match subject of issuer certificate" 238 | ) 239 | public_key = other.public_key() 240 | signature = certificate.signature 241 | data = certificate.tbs_certificate_bytes 242 | if isinstance(public_key, ec.EllipticCurvePublicKeyWithSerialization): 243 | public_key.verify( 244 | signature, 245 | data, 246 | signature_algorithm=ec.ECDSA(certificate.signature_hash_algorithm), 247 | ) 248 | else: 249 | raise NotImplementedError("Unsupported public key type") 250 | 251 | 252 | class CCFMerkleTree(object): 253 | """ 254 | CCF-style Merkle Tree implementation. 255 | """ 256 | 257 | def __init__(self): 258 | self.levels = [] 259 | self.reset_tree() 260 | 261 | def reset_tree(self): 262 | self.leaves = [] 263 | self.levels = [] 264 | 265 | def add_leaf(self, values: bytes, do_hash=True): 266 | digest = values 267 | if do_hash: 268 | digest = sha256(values).digest() 269 | self.leaves.append(digest) 270 | 271 | def get_leaf(self, index: int) -> bytes: 272 | return self.leaves[index] 273 | 274 | def get_leaf_count(self) -> int: 275 | return len(self.leaves) 276 | 277 | def get_merkle_root(self) -> bytes: 278 | # Always make tree before getting root 279 | self._make_tree() 280 | if self.levels is None: 281 | raise Exception( 282 | "Unexpected error while getting root. CCFMerkleTree has no levels." 283 | ) 284 | 285 | return self.levels[0][0] 286 | 287 | def _calculate_next_level(self): 288 | solo_leaf = None 289 | # number of leaves on the level 290 | number_of_leaves_on_current_level = len(self.levels[0]) 291 | 292 | if number_of_leaves_on_current_level == 1: 293 | raise Exception("Merkle Tree should have more than one leaf at every level") 294 | 295 | if ( 296 | number_of_leaves_on_current_level % 2 == 1 297 | ): # if odd number of leaves on the level 298 | # Get the solo leaf (last leaf in-case the leaves are odd numbered) 299 | solo_leaf = self.levels[0][-1] 300 | number_of_leaves_on_current_level -= 1 301 | 302 | new_level = [] 303 | for left_node, right_node in zip( 304 | self.levels[0][0:number_of_leaves_on_current_level:2], 305 | self.levels[0][1:number_of_leaves_on_current_level:2], 306 | ): 307 | new_level.append(sha256(left_node + right_node).digest()) 308 | if solo_leaf is not None: 309 | new_level.append(solo_leaf) 310 | self.levels = [ 311 | new_level, 312 | ] + self.levels # prepend new level 313 | 314 | def _make_tree(self): 315 | if self.get_leaf_count() > 0: 316 | self.levels = [ 317 | self.leaves, 318 | ] 319 | while len(self.levels[0]) > 1: 320 | self._calculate_next_level() 321 | -------------------------------------------------------------------------------- /scitt_emulator/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | import argparse 5 | 6 | from scitt_emulator import client, server 7 | 8 | 9 | def cli(fn): 10 | parser = fn(description="SCITT emulator") 11 | sub = parser.add_subparsers(dest="cmd", help="Command to execute", required=True) 12 | 13 | client.cli(lambda *args, **kw: sub.add_parser("client", *args, **kw)) 14 | server.cli(lambda *args, **kw: sub.add_parser("server", *args, **kw)) 15 | 16 | return parser 17 | 18 | 19 | def main(argv=None): 20 | parser = cli(argparse.ArgumentParser) 21 | args = parser.parse_args(argv) 22 | args.func(args) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /scitt_emulator/client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | from typing import Optional 5 | from pathlib import Path 6 | import json 7 | import time 8 | 9 | import httpx 10 | 11 | from scitt_emulator import create_statement 12 | from scitt_emulator.tree_algs import TREE_ALGS 13 | 14 | DEFAULT_URL = "http://127.0.0.1:8000" 15 | CONNECT_RETRIES = 3 16 | HTTP_RETRIES = 3 17 | HTTP_DEFAULT_RETRY_DELAY = 1 18 | 19 | 20 | class ClaimOperationError(Exception): 21 | def __init__(self, operation): 22 | self.operation = operation 23 | 24 | def __str__(self): 25 | error_type = self.operation.get("error", {}).get( 26 | "type", "error.type not present", 27 | ) 28 | error_detail = self.operation.get("error", {}).get( 29 | "detail", "error.detail not present", 30 | ) 31 | return f"Operation error {error_type}: {error_detail}" 32 | 33 | 34 | def raise_for_status(response: httpx.Response): 35 | if response.is_success: 36 | return 37 | raise RuntimeError(f"HTTP error {response.status_code}: {response.text}") 38 | 39 | 40 | def raise_for_operation_status(operation: dict): 41 | if operation["status"] != "failed": 42 | return 43 | raise ClaimOperationError(operation) 44 | 45 | 46 | class HttpClient: 47 | def __init__(self, bearer_token: Optional[str] = None, cacert: Optional[Path] = None): 48 | headers = {} 49 | if bearer_token is not None: 50 | headers["Authorization"] = f"Bearer {bearer_token}" 51 | verify = True if cacert is None else str(cacert) 52 | transport = httpx.HTTPTransport(retries=CONNECT_RETRIES, verify=verify) 53 | self.client = httpx.Client(transport=transport, headers=headers) 54 | 55 | def _request(self, *args, **kwargs): 56 | response = self.client.request(*args, **kwargs) 57 | retries = HTTP_RETRIES 58 | while retries >= 0 and response.status_code == 503: 59 | retries -= 1 60 | retry_after = int( 61 | response.headers.get("retry-after", HTTP_DEFAULT_RETRY_DELAY) 62 | ) 63 | time.sleep(retry_after) 64 | response = self.client.request(*args, **kwargs) 65 | raise_for_status(response) 66 | return response 67 | 68 | def get(self, *args, **kwargs): 69 | return self._request("GET", *args, **kwargs) 70 | 71 | def post(self, *args, **kwargs): 72 | return self._request("POST", *args, **kwargs) 73 | 74 | 75 | def submit_claim( 76 | url: str, 77 | claim_path: Path, 78 | receipt_path: Path, 79 | entry_id_path: Optional[Path], 80 | client: HttpClient, 81 | ): 82 | with open(claim_path, "rb") as f: 83 | claim = f.read() 84 | 85 | # Submit claim 86 | response = client.post(f"{url}/entries", content=claim, headers={ 87 | "Content-Type": "application/cose"}) 88 | 89 | post_response=response.json() 90 | 91 | if response.status_code == 201: 92 | entry = response.json() 93 | entry_id = entry["entryId"] 94 | 95 | elif response.status_code == 202: 96 | operation = response.json() 97 | 98 | # Wait for registration to finish 99 | while operation["status"] != "succeeded": 100 | retry_after = int( 101 | response.headers.get("retry-after", HTTP_DEFAULT_RETRY_DELAY) 102 | ) 103 | time.sleep(retry_after) 104 | response = client.get(f"{url}/operations/{operation['operationId']}") 105 | operation = response.json() 106 | raise_for_operation_status(operation) 107 | 108 | entry_id = operation["entryId"] 109 | 110 | else: 111 | raise RuntimeError(f"Unexpected status code: {response.status_code}") 112 | 113 | # Fetch receipt 114 | response = client.get(f"{url}/entries/{entry_id}/receipt", timeout=15) 115 | receipt = response.content 116 | 117 | print("Claim Registered:") 118 | print(f" json: {post_response}") 119 | print(f" Entry ID: {entry_id}") 120 | 121 | # Save receipt to file 122 | with open(receipt_path, "wb") as f: 123 | f.write(receipt) 124 | 125 | print(f" Receipt: ./{receipt_path}") 126 | 127 | # Save entry ID to file 128 | if entry_id_path: 129 | with open(entry_id_path, "w") as f: 130 | f.write(str(entry_id)) 131 | 132 | print(f"Entry ID written to {entry_id_path}") 133 | 134 | 135 | def retrieve_claim(url: str, entry_id: Path, claim_path: Path, client: HttpClient): 136 | response = client.get(f"{url}/entries/{entry_id}") 137 | claim = response.content 138 | 139 | with open(claim_path, "wb") as f: 140 | f.write(claim) 141 | 142 | print(f"A COSE signed Claim was written to: {claim_path}") 143 | 144 | 145 | def retrieve_receipt(url: str, entry_id: Path, receipt_path: Path, client: HttpClient): 146 | response = client.get(f"{url}/entries/{entry_id}/receipt") 147 | receipt = response.content 148 | 149 | with open(receipt_path, "wb") as f: 150 | f.write(receipt) 151 | 152 | print(f"Receipt written to {receipt_path}") 153 | 154 | 155 | def verify_receipt(cose_path: Path, receipt_path: Path, service_parameters_path: Path): 156 | with open(service_parameters_path) as f: 157 | service_parameters = json.load(f) 158 | 159 | clazz = TREE_ALGS[service_parameters["treeAlgorithm"]] 160 | service = clazz(service_parameters_path=service_parameters_path) 161 | service.verify_receipt(cose_path, receipt_path) 162 | print("Receipt verified") 163 | 164 | 165 | def cli(fn): 166 | parser = fn(description="Execute client commands") 167 | sub = parser.add_subparsers(dest="cmd", help="Command to execute", required=True) 168 | 169 | create_statement.cli(sub.add_parser) 170 | 171 | p = sub.add_parser( 172 | "submit-claim", description="Submit a SCITT claim and retrieve the receipt" 173 | ) 174 | p.add_argument("--claim", required=True, type=Path) 175 | p.add_argument( 176 | "--out", required=True, type=Path, help="Path to write the receipt to" 177 | ) 178 | p.add_argument( 179 | "--out-entry-id", 180 | required=False, 181 | type=Path, 182 | help="Path to write the entry id to", 183 | ) 184 | p.add_argument("--url", required=False, default=DEFAULT_URL) 185 | p.add_argument("--token", help="Bearer token to authenticate with") 186 | p.add_argument("--cacert", type=Path, help="CA certificate to verify host against") 187 | p.set_defaults( 188 | func=lambda args: submit_claim( 189 | args.url, args.claim, args.out, args.out_entry_id, 190 | HttpClient(args.token, args.cacert) 191 | ) 192 | ) 193 | 194 | p = sub.add_parser("retrieve-claim", description="Retrieve a SCITT claim") 195 | p.add_argument("--entry-id", required=True, type=str) 196 | p.add_argument("--out", required=True, type=Path, help="Path to write the claim to") 197 | p.add_argument("--url", required=False, default=DEFAULT_URL) 198 | p.add_argument("--token", help="Bearer token to authenticate with") 199 | p.add_argument("--cacert", type=Path, help="CA certificate to verify host against") 200 | p.set_defaults( 201 | func=lambda args: retrieve_claim( 202 | args.url, args.entry_id, args.out, 203 | HttpClient(args.token, args.cacert) 204 | ) 205 | ) 206 | 207 | p = sub.add_parser("retrieve-receipt", description="Retrieve a SCITT receipt") 208 | p.add_argument("--entry-id", required=True, type=str) 209 | p.add_argument( 210 | "--out", required=True, type=Path, help="Path to write the receipt to" 211 | ) 212 | p.add_argument("--url", required=False, default=DEFAULT_URL) 213 | p.add_argument("--token", help="Bearer token to authenticate with") 214 | p.add_argument("--cacert", type=Path, help="CA certificate to verify host against") 215 | p.set_defaults( 216 | func=lambda args: retrieve_receipt( 217 | args.url, args.entry_id, args.out, 218 | HttpClient(args.token, args.cacert) 219 | ) 220 | ) 221 | 222 | p = sub.add_parser("verify-receipt", description="Verify a SCITT receipt") 223 | p.add_argument("--claim", required=True, type=Path) 224 | p.add_argument("--receipt", required=True, type=Path) 225 | p.add_argument("--service-parameters", required=True, type=Path) 226 | p.set_defaults( 227 | func=lambda args: verify_receipt( 228 | args.claim, args.receipt, args.service_parameters 229 | ) 230 | ) 231 | 232 | return parser 233 | -------------------------------------------------------------------------------- /scitt_emulator/create_statement.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) SCITT Authors 2 | # Licensed under the MIT License. 3 | import base64 4 | import pathlib 5 | import argparse 6 | from typing import Union, Optional, List 7 | 8 | import cwt 9 | import pycose 10 | import pycose.headers 11 | import pycose.messages 12 | import pycose.keys.ec2 13 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 14 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 15 | 16 | # TODO jwcrypto is LGPLv3, is there another option with a permissive licence? 17 | import jwcrypto.jwk 18 | 19 | from scitt_emulator.did_helpers import DID_JWK_METHOD 20 | 21 | 22 | @pycose.headers.CoseHeaderAttribute.register_attribute() 23 | class CWTClaims(pycose.headers.CoseHeaderAttribute): 24 | identifier = 14 25 | fullname = "CWT_CLAIMS" 26 | 27 | 28 | @pycose.headers.CoseHeaderAttribute.register_attribute() 29 | class RegInfo(pycose.headers.CoseHeaderAttribute): 30 | identifier = 393 31 | fullname = "REG_INFO" 32 | 33 | 34 | @pycose.headers.CoseHeaderAttribute.register_attribute() 35 | class Receipts(pycose.headers.CoseHeaderAttribute): 36 | identifier = 394 37 | fullname = "RECEIPTS" 38 | 39 | 40 | @pycose.headers.CoseHeaderAttribute.register_attribute() 41 | class TBD(pycose.headers.CoseHeaderAttribute): 42 | identifier = 395 43 | fullname = "TBD" 44 | 45 | 46 | def create_claim( 47 | claim_path: pathlib.Path, 48 | issuer: Union[str, None], 49 | subject: str, 50 | content_type: str, 51 | payload: bytes, 52 | private_key_pem_path: Optional[str] = None, 53 | receipts: Optional[List[bytes]] = None, 54 | ): 55 | # https://ietf-wg-scitt.github.io/draft-ietf-scitt-architecture/draft-ietf-scitt-architecture.html#name-signed-statement-envelope 56 | 57 | # Registration Policy (label: TBD, temporary: 393): A map containing 58 | # key/value pairs set by the Issuer which are sealed on Registration and 59 | # non-opaque to the Transparency Service. The key/value pair semantics are 60 | # specified by the Issuer or are specific to the CWT_Claims iss and 61 | # CWT_Claims sub tuple. 62 | # Examples: the sequence number of signed statements 63 | # on a CWT_Claims Subject, Issuer metadata, or a reference to other 64 | # Transparent Statements (e.g., augments, replaces, new-version, CPE-for) 65 | # Reg_Info = { 66 | reg_info = { 67 | # ? "register_by": uint .within (~time), 68 | "register_by": 1000, 69 | # ? "sequence_no": uint, 70 | "sequence_no": 0, 71 | # ? "issuance_ts": uint .within (~time), 72 | "issuance_ts": 1000, 73 | # ? "no_replay": null, 74 | "no_replay": None, 75 | # * tstr => any 76 | } 77 | # } 78 | 79 | # Create COSE_Sign1 structure 80 | # Create an ad-hoc key 81 | # oct: size(int) 82 | # RSA: public_exponent(int), size(int) 83 | # EC: crv(str) (one of P-256, P-384, P-521, secp256k1) 84 | # OKP: crv(str) (one of Ed25519, Ed448, X25519, X448) 85 | key = jwcrypto.jwk.JWK() 86 | if private_key_pem_path and private_key_pem_path.exists(): 87 | key.import_from_pem(private_key_pem_path.read_bytes()) 88 | else: 89 | key = key.generate(kty="EC", crv="P-384") 90 | # https://python-cwt.readthedocs.io/en/stable/algorithms.html 91 | alg = key.key_curve.replace("P-", "ES") 92 | kid = key.thumbprint() 93 | key_as_pem_bytes = key.export_to_pem(private_key=True, password=None) 94 | # cwt_cose_key = cwt.COSEKey.generate_symmetric_key(alg=alg, kid=kid) 95 | cwt_cose_key = cwt.COSEKey.from_pem(key_as_pem_bytes, kid=kid) 96 | # cwt_cose_key_to_cose_key = cwt.algs.ec2.EC2Key.to_cose_key(cwt_cose_key) 97 | cwt_cose_key_to_cose_key = cwt_cose_key.to_dict() 98 | sign1_message_key = pycose.keys.ec2.EC2Key.from_dict(cwt_cose_key_to_cose_key) 99 | 100 | # If issuer was not given used did:jwk of public key 101 | if issuer is None: 102 | issuer = DID_JWK_METHOD + base64.urlsafe_b64encode(key.export_public().encode()).decode() 103 | 104 | # CWT_Claims (label: 14 pending [CWT_CLAIM_COSE]): A CWT representing 105 | # the Issuer (iss) making the statement, and the Subject (sub) to 106 | # correlate a collection of statements about an Artifact. Additional 107 | # [CWT_CLAIMS] MAY be used, while iss and sub MUST be provided 108 | # CWT_Claims = { 109 | cwt_claims = { 110 | # iss (CWT_Claim Key 1): The Identifier of the signer, as a string 111 | # Example: did:web:example.com 112 | # 1 => tstr; iss, the issuer making statements, 113 | 1: issuer, 114 | # sub (CWT_Claim Key 2): The Subject to which the Statement refers, 115 | # chosen by the Issuer 116 | # Example: github.com/opensbom-generator/spdx-sbom-generator/releases/tag/v0.0.13 117 | # 2 => tstr; sub, the subject of the statements, 118 | 2: subject, 119 | # * tstr => any 120 | } 121 | # } 122 | cwt_token = cwt.encode(cwt_claims, cwt_cose_key) 123 | 124 | # Protected_Header = { 125 | protected = { 126 | # algorithm (label: 1): Asymmetric signature algorithm used by the 127 | # Issuer of a Signed Statement, as an integer. 128 | # Example: -35 is the registered algorithm identifier for ECDSA with 129 | # SHA-384, see COSE Algorithms Registry [IANA.cose]. 130 | # 1 => int ; algorithm identifier, 131 | # https://www.iana.org/assignments/cose/cose.xhtml#algorithms 132 | # pycose.headers.Algorithm: "ES256", 133 | pycose.headers.Algorithm: getattr(cwt.enums.COSEAlgs, alg), 134 | # Key ID (label: 4): Key ID, as a bytestring 135 | # 4 => bstr ; Key ID, 136 | pycose.headers.KID: kid.encode("ascii"), 137 | # 14 => CWT_Claims ; CBOR Web Token Claims, 138 | CWTClaims: cwt_token, 139 | # 393 => Reg_Info ; Registration Policy info, 140 | RegInfo: reg_info, 141 | # 3 => tstr ; payload type 142 | pycose.headers.ContentType: content_type, 143 | } 144 | # } 145 | 146 | # Unprotected_Header = { 147 | unprotected = { 148 | # ; TBD, Labels are temporary, 149 | TBD: "TBD", 150 | # ? 394 => [+ Receipts] 151 | Receipts: receipts, 152 | } 153 | # } 154 | 155 | # https://github.com/TimothyClaeys/pycose/blob/e527e79b611f6cc6673bbb694056a7468c2eef75/pycose/messages/cosemessage.py#L84-L91 156 | msg = pycose.messages.Sign1Message( 157 | phdr=protected, 158 | uhdr=unprotected, 159 | payload=payload, 160 | ) 161 | 162 | # Sign 163 | msg.key = sign1_message_key 164 | # https://github.com/TimothyClaeys/pycose/blob/e527e79b611f6cc6673bbb694056a7468c2eef75/pycose/messages/cosemessage.py#L143 165 | claim = msg.encode(tag=True) 166 | claim_path.write_bytes(claim) 167 | 168 | # Write out private key in PEM format if argument given and not exists 169 | if private_key_pem_path and not private_key_pem_path.exists(): 170 | private_key_pem_path.write_bytes(key_as_pem_bytes) 171 | 172 | 173 | def cli(fn): 174 | p = fn("create-claim", description="Create a fake SCITT claim") 175 | p.add_argument("--out", required=True, type=pathlib.Path) 176 | p.add_argument("--issuer", required=False, type=str, default=None) 177 | p.add_argument("--subject", required=True, type=str) 178 | p.add_argument("--content-type", required=True, type=str) 179 | p.add_argument("--payload", required=True, type=str) 180 | p.add_argument("--private-key-pem", required=False, type=pathlib.Path) 181 | p.add_argument("--receipts", type=pathlib.Path, nargs="*", default=[]) 182 | p.set_defaults( 183 | func=lambda args: create_claim( 184 | args.out, 185 | args.issuer, 186 | args.subject, 187 | args.content_type, 188 | args.payload.encode("utf-8"), 189 | private_key_pem_path=args.private_key_pem, 190 | receipts=[receipt.read_bytes() for receipt in args.receipts], 191 | ) 192 | ) 193 | 194 | return p 195 | 196 | 197 | def main(argv=None): 198 | parser = cli(argparse.ArgumentParser) 199 | args = parser.parse_args(argv) 200 | args.func(args) 201 | 202 | 203 | if __name__ == "__main__": 204 | main() 205 | -------------------------------------------------------------------------------- /scitt_emulator/did_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | from typing import Optional 4 | 5 | 6 | DID_JWK_METHOD = "did:jwk:" 7 | 8 | 9 | def did_web_to_url( 10 | did_web_string: str, 11 | *, 12 | scheme: Optional[str] = None, 13 | ): 14 | if scheme is None: 15 | scheme = os.environ.get("DID_WEB_ASSUME_SCHEME", "https") 16 | return "/".join( 17 | [ 18 | f"{scheme}:/", 19 | *[urllib.parse.unquote(i) for i in did_web_string.split(":")[2:]], 20 | ] 21 | ) 22 | 23 | 24 | def url_to_did_web(url_string): 25 | url = urllib.parse.urlparse(url_string) 26 | return ":".join( 27 | [ 28 | urllib.parse.quote(i) 29 | for i in ["did", "web", url.netloc, *filter(bool, url.path.split("/"))] 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /scitt_emulator/key_helper_dataclasses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Any, Union 3 | 4 | import cwt 5 | import pycose.keys.ec2 6 | 7 | 8 | @dataclass 9 | class VerificationKey: 10 | transforms: List[Any] 11 | original: Any 12 | original_content_type: str 13 | original_bytes: bytes 14 | original_bytes_encoding: str 15 | usable: bool 16 | cwt: Union[cwt.COSEKey, None] 17 | cose: Union[pycose.keys.ec2.EC2Key, None] 18 | -------------------------------------------------------------------------------- /scitt_emulator/key_helpers.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import importlib.metadata 3 | from typing import Optional, Callable, List, Tuple 4 | 5 | from scitt_emulator.key_helper_dataclasses import VerificationKey 6 | 7 | 8 | ENTRYPOINT_KEY_TRANSFORMS_TO_OBJECT = "scitt_emulator.key_helpers.verification_key_to_object" 9 | 10 | 11 | def verification_key_to_object( 12 | verification_key: VerificationKey, 13 | *, 14 | key_transforms: Optional[List[Callable[[VerificationKey], dict]]] = None, 15 | ) -> bool: 16 | """ 17 | Resolve keys for statement issuer and verify signature on COSESign1 18 | statement and embedded CWT 19 | """ 20 | if key_transforms is None: 21 | key_transforms = [] 22 | # There is some difference in the return value of entry_points across 23 | # Python versions/envs (conda vs. non-conda). Python 3.8 returns a dict. 24 | entrypoints = importlib.metadata.entry_points() 25 | if isinstance(entrypoints, dict): 26 | for entrypoint in entrypoints.get(ENTRYPOINT_KEY_TRANSFORMS_TO_OBJECT, []): 27 | key_transforms.append(entrypoint.load()) 28 | elif isinstance(entrypoints, getattr(importlib.metadata, "EntryPoints", list)): 29 | for entrypoint in entrypoints: 30 | if entrypoint.group == ENTRYPOINT_KEY_TRANSFORMS_TO_OBJECT: 31 | key_transforms.append(entrypoint.load()) 32 | else: 33 | raise TypeError(f"importlib.metadata.entry_points returned unknown type: {type(entrypoints)}: {entrypoints!r}") 34 | 35 | for key_transform in key_transforms: 36 | verification_key_as_object = key_transform(verification_key) 37 | # Skip keys that we couldn't derive COSE keys for 38 | if verification_key_as_object: 39 | return verification_key_as_object 40 | 41 | return None 42 | -------------------------------------------------------------------------------- /scitt_emulator/key_loader_format_did_jwk.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from typing import List, Tuple 3 | 4 | import cwt 5 | import cwt.algs.ec2 6 | import pycose 7 | import pycose.keys.ec2 8 | import cryptography.hazmat.primitives.asymmetric.ec 9 | from cryptography.hazmat.primitives import serialization 10 | 11 | import jwcrypto.jwk 12 | 13 | from scitt_emulator.did_helpers import DID_JWK_METHOD 14 | from scitt_emulator.key_helper_dataclasses import VerificationKey 15 | 16 | 17 | CONTENT_TYPE = "application/did+jwk" 18 | 19 | 20 | def key_loader_format_did_jwk( 21 | unverified_issuer: str, 22 | ) -> List[VerificationKey]: 23 | if not unverified_issuer.startswith(DID_JWK_METHOD): 24 | return [] 25 | key = jwcrypto.jwk.JWK.from_json( 26 | base64.urlsafe_b64decode(unverified_issuer[len(DID_JWK_METHOD):]).decode() 27 | ) 28 | return [ 29 | VerificationKey( 30 | transforms=[key], 31 | original=key, 32 | original_content_type=CONTENT_TYPE, 33 | original_bytes=unverified_issuer.encode("utf-8"), 34 | original_bytes_encoding="utf-8", 35 | usable=False, 36 | cwt=None, 37 | cose=None, 38 | ) 39 | ] 40 | 41 | 42 | def to_object_jwk(verification_key: VerificationKey) -> dict: 43 | if not isinstance(verification_key.original, jwcrypto.jwk.JWK): 44 | return 45 | 46 | return { 47 | "content_type": verification_key.original_content_type, 48 | "key": { 49 | **verification_key.original.export_public(as_dict=True), 50 | "use": "sig", 51 | "kid": verification_key.original.thumbprint(), 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /scitt_emulator/key_loader_format_url_referencing_oidc_issuer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import contextlib 3 | import urllib.parse 4 | import urllib.request 5 | from typing import List, Tuple 6 | 7 | import cwt 8 | import cwt.algs.ec2 9 | import pycose 10 | import pycose.keys.ec2 11 | 12 | # TODO Remove this once we have a example flow for proper key verification 13 | import jwcrypto.jwk 14 | 15 | from scitt_emulator.did_helpers import did_web_to_url 16 | from scitt_emulator.key_helper_dataclasses import VerificationKey 17 | from scitt_emulator.key_loader_format_did_jwk import to_object_jwk 18 | 19 | 20 | CONTENT_TYPE = "application/jwk+json" 21 | 22 | 23 | def key_loader_format_url_referencing_oidc_issuer( 24 | unverified_issuer: str, 25 | ) -> List[Tuple[cwt.COSEKey, pycose.keys.ec2.EC2Key]]: 26 | keys = [] 27 | 28 | if unverified_issuer.startswith("did:web:"): 29 | unverified_issuer = did_web_to_url(unverified_issuer) 30 | 31 | if "://" not in unverified_issuer or unverified_issuer.startswith("file://"): 32 | return keys 33 | 34 | # TODO Logging for URLErrors 35 | # Check if OIDC issuer 36 | unverified_issuer_parsed_url = urllib.parse.urlparse(unverified_issuer) 37 | openid_configuration_url = unverified_issuer_parsed_url._replace( 38 | path="/.well-known/openid-configuration", 39 | ).geturl() 40 | with contextlib.suppress(urllib.request.URLError): 41 | with urllib.request.urlopen(openid_configuration_url) as response: 42 | if response.status == 200: 43 | openid_configuration = json.loads(response.read()) 44 | jwks_uri = openid_configuration["jwks_uri"] 45 | with urllib.request.urlopen(jwks_uri) as response: 46 | if response.status == 200: 47 | jwks = json.loads(response.read()) 48 | for jwk_key_as_dict in jwks["keys"]: 49 | jwk_key_as_string = json.dumps(jwk_key_as_dict) 50 | jwk_key = jwcrypto.jwk.JWK.from_json(jwk_key_as_string) 51 | keys.append( 52 | VerificationKey( 53 | transforms=[jwk_key], 54 | original=jwk_key, 55 | original_content_type=CONTENT_TYPE, 56 | original_bytes=jwk_key_as_string.encode("utf-8"), 57 | original_bytes_encoding="utf-8", 58 | usable=False, 59 | cwt=None, 60 | cose=None, 61 | ) 62 | ) 63 | 64 | return keys 65 | -------------------------------------------------------------------------------- /scitt_emulator/key_loader_format_url_referencing_scitt_scrapi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import contextlib 3 | import urllib.parse 4 | import urllib.request 5 | from typing import List, Tuple 6 | 7 | import cwt 8 | import cwt.algs.ec2 9 | import pycose 10 | import pycose.keys.ec2 11 | 12 | # TODO Remove this once we have a example flow for proper key verification 13 | import jwcrypto.jwk 14 | 15 | from scitt_emulator.did_helpers import did_web_to_url 16 | from scitt_emulator.key_helper_dataclasses import VerificationKey 17 | from scitt_emulator.key_loader_format_did_jwk import to_object_jwk 18 | 19 | 20 | CONTENT_TYPE = "application/scitt+jwk+set+json" 21 | 22 | 23 | def key_loader_format_url_referencing_scitt_scrapi( 24 | unverified_issuer: str, 25 | ) -> List[Tuple[cwt.COSEKey, pycose.keys.ec2.EC2Key]]: 26 | keys = [] 27 | 28 | if unverified_issuer.startswith("did:web:"): 29 | unverified_issuer = did_web_to_url(unverified_issuer) 30 | 31 | if "://" not in unverified_issuer or unverified_issuer.startswith("file://"): 32 | return keys 33 | 34 | # TODO Logging for URLErrors 35 | # Check if OIDC issuer 36 | unverified_issuer_parsed_url = urllib.parse.urlparse(unverified_issuer) 37 | openid_configuration_url = unverified_issuer_parsed_url._replace( 38 | path="/.well-known/transparency-configuration", 39 | ).geturl() 40 | with contextlib.suppress(urllib.request.URLError): 41 | with urllib.request.urlopen(openid_configuration_url) as response: 42 | if response.status == 200: 43 | openid_configuration = json.loads(response.read()) 44 | jwks = openid_configuration["jwks"] 45 | for jwk_key_as_dict in jwks["keys"]: 46 | jwk_key_as_string = json.dumps(jwk_key_as_dict) 47 | jwk_key = jwcrypto.jwk.JWK.from_json(jwk_key_as_string) 48 | keys.append( 49 | VerificationKey( 50 | transforms=[jwk_key], 51 | original=jwk_key, 52 | original_content_type=CONTENT_TYPE, 53 | original_bytes=jwk_key_as_string.encode("utf-8"), 54 | original_bytes_encoding="utf-8", 55 | usable=False, 56 | cwt=None, 57 | cose=None, 58 | ) 59 | ) 60 | 61 | return keys 62 | 63 | 64 | def transform_key_instance_jwcrypto_jwk_to_cwt_cose( 65 | key: jwcrypto.jwk.JWK, 66 | ) -> cwt.COSEKey: 67 | if not isinstance(key, jwcrypto.jwk.JWK): 68 | raise TypeError(key) 69 | return cwt.COSEKey.from_pem( 70 | key.export_to_pem(), 71 | kid=key.thumbprint(), 72 | ) 73 | -------------------------------------------------------------------------------- /scitt_emulator/key_loader_format_url_referencing_ssh_authorized_keys.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import dataclasses 3 | import urllib.parse 4 | import urllib.request 5 | from typing import List, Tuple 6 | 7 | import cwt 8 | import cwt.algs.ec2 9 | import pycose 10 | import pycose.keys.ec2 11 | import cryptography.exceptions 12 | from cryptography.hazmat.primitives import serialization 13 | 14 | # TODO Remove this once we have a example flow for proper key verification 15 | import jwcrypto.jwk 16 | 17 | from scitt_emulator.did_helpers import did_web_to_url 18 | from scitt_emulator.key_helper_dataclasses import VerificationKey 19 | from scitt_emulator.key_loader_format_did_jwk import to_object_jwk 20 | 21 | CONTENT_TYPE = "application/key+ssh" 22 | 23 | 24 | def key_loader_format_url_referencing_ssh_authorized_keys( 25 | unverified_issuer: str, 26 | ) -> List[Tuple[cwt.COSEKey, pycose.keys.ec2.EC2Key]]: 27 | keys = [] 28 | 29 | if unverified_issuer.startswith("did:web:"): 30 | unverified_issuer = did_web_to_url(unverified_issuer) 31 | 32 | if "://" not in unverified_issuer or unverified_issuer.startswith("file://"): 33 | return keys 34 | 35 | # Try loading ssh keys. Example: https://github.com/username.keys 36 | with contextlib.suppress(urllib.request.URLError): 37 | with urllib.request.urlopen(unverified_issuer) as response: 38 | while line := response.readline(): 39 | with contextlib.suppress( 40 | (ValueError, cryptography.exceptions.UnsupportedAlgorithm) 41 | ): 42 | key = serialization.load_ssh_public_key(line) 43 | keys.append( 44 | VerificationKey( 45 | transforms=[key], 46 | original=key, 47 | original_content_type=CONTENT_TYPE, 48 | original_bytes=line, 49 | original_bytes_encoding="utf-8", 50 | usable=False, 51 | cwt=None, 52 | cose=None, 53 | ) 54 | ) 55 | 56 | return keys 57 | 58 | 59 | def transform_key_instance_cryptography_ecc_public_to_jwcrypto_jwk( 60 | key: cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey, 61 | ) -> jwcrypto.jwk.JWK: 62 | if not isinstance( 63 | key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey 64 | ): 65 | raise TypeError(key) 66 | return jwcrypto.jwk.JWK.from_pem( 67 | key.public_bytes( 68 | encoding=serialization.Encoding.PEM, 69 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 70 | ) 71 | ) 72 | 73 | 74 | def to_object_ssh_public(verification_key: VerificationKey) -> dict: 75 | if verification_key.original_content_type != CONTENT_TYPE: 76 | return 77 | 78 | return to_object_jwk( 79 | dataclasses.replace( 80 | verification_key, 81 | original=transform_key_instance_cryptography_ecc_public_to_jwcrypto_jwk( 82 | verification_key.original, 83 | ) 84 | ) 85 | ) 86 | -------------------------------------------------------------------------------- /scitt_emulator/key_transforms.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import itertools 3 | import importlib.metadata 4 | from typing import Optional, Callable, List, Tuple 5 | 6 | import cwt 7 | import pycose.keys.ec2 8 | 9 | from scitt_emulator.key_helper_dataclasses import VerificationKey 10 | 11 | 12 | ENTRYPOINT_KEY_TRANSFORMS_KEY_INSTANCES = "scitt_emulator.key_helpers.transforms_key_instances" 13 | 14 | 15 | def preform_verification_key_transforms( 16 | verification_keys: List[VerificationKey], 17 | *, 18 | key_transforms: Optional[List[Callable[[VerificationKey], dict]]] = None, 19 | ) -> None: 20 | """ 21 | Resolve keys for statement issuer and verify signature on COSESign1 22 | statement and embedded CWT 23 | """ 24 | # In case of iterators since we have to loop multiple times 25 | verification_keys = list(verification_keys) 26 | 27 | if key_transforms is None: 28 | key_transforms = [] 29 | # There is some difference in the return value of entry_points across 30 | # Python versions/envs (conda vs. non-conda). Python 3.8 returns a dict. 31 | entrypoints = importlib.metadata.entry_points() 32 | if isinstance(entrypoints, dict): 33 | for entrypoint in entrypoints.get(ENTRYPOINT_KEY_TRANSFORMS_KEY_INSTANCES, []): 34 | key_transforms.append(entrypoint.load()) 35 | elif isinstance(entrypoints, getattr(importlib.metadata, "EntryPoints", list)): 36 | for entrypoint in entrypoints: 37 | if entrypoint.group == ENTRYPOINT_KEY_TRANSFORMS_KEY_INSTANCES: 38 | key_transforms.append(entrypoint.load()) 39 | else: 40 | raise TypeError(f"importlib.metadata.entry_points returned unknown type: {type(entrypoints)}: {entrypoints!r}") 41 | 42 | key_transform_types = tuple( 43 | [ 44 | list(inspect.signature(key_transform).parameters.values())[0].annotation 45 | for key_transform in key_transforms 46 | ] 47 | ) 48 | 49 | for verification_key in verification_keys: 50 | while not verification_key.usable: 51 | # Attempt key transforms 52 | for key_transform in key_transforms: 53 | key = verification_key.transforms[-1] 54 | if isinstance(key, list(inspect.signature(key_transform).parameters.values())[0].annotation): 55 | transformed_key = key_transform(key) 56 | if transformed_key: 57 | verification_key.transforms.append(transformed_key) 58 | # Check if key is usable yet 59 | for key in reversed(verification_key.transforms): 60 | if not verification_key.cwt and isinstance(key, cwt.algs.ec2.EC2Key): 61 | verification_key.cwt = key 62 | if ( 63 | not verification_key.cose 64 | and isinstance( 65 | key, 66 | ( 67 | pycose.keys.ec2.EC2Key, 68 | ) 69 | ) 70 | ): 71 | verification_key.cose = key 72 | if verification_key.cwt and verification_key.cose: 73 | verification_key.usable = True 74 | break 75 | # If we are unable to transform further, raise exception 76 | key = verification_key.transforms[-1] 77 | if not isinstance(key, key_transform_types): 78 | raise NotImplementedError(f"Unable to transform {type(key)} into CWT and COSE keys needed. Transforms available: {key_transforms}. Transform types accepted: {key_transform_types}. Transforms completed: {verification_key.transforms}") 79 | 80 | return verification_keys 81 | 82 | 83 | def transform_key_instance_cwt_cose_ec2_to_pycose_ec2( 84 | key: cwt.algs.ec2.EC2Key, 85 | ) -> pycose.keys.ec2.EC2Key: 86 | if not isinstance(key, cwt.algs.ec2.EC2Key): 87 | raise TypeError(key) 88 | cwt_ec2_key_as_dict = key.to_dict() 89 | return pycose.keys.ec2.EC2Key.from_dict(cwt_ec2_key_as_dict) 90 | -------------------------------------------------------------------------------- /scitt_emulator/oidc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) SCITT Authors. 2 | # Licensed under the MIT License. 3 | import jwt 4 | import json 5 | import jsonschema 6 | from werkzeug.wrappers import Request 7 | from scitt_emulator.client import HttpClient 8 | 9 | 10 | class OIDCAuthMiddleware: 11 | def __init__(self, app, config_path): 12 | self.app = app 13 | self.config = {} 14 | if config_path and config_path.exists(): 15 | self.config = json.loads(config_path.read_text()) 16 | 17 | # Initialize JSON Web Key client for given issuer 18 | self.client = HttpClient() 19 | self.oidc_configs = {} 20 | self.jwks_clients = {} 21 | for issuer in self.config['issuers']: 22 | self.oidc_configs[issuer] = self.client.get( 23 | f"{issuer}/.well-known/openid-configuration" 24 | ).json() 25 | self.jwks_clients[issuer] = jwt.PyJWKClient(self.oidc_configs[issuer]["jwks_uri"]) 26 | 27 | def __call__(self, environ, start_response): 28 | request = Request(environ) 29 | claims = self.validate_token(request.headers["Authorization"].replace("Bearer ", "")) 30 | if "claim_schema" in self.config and claims["iss"] in self.config["claim_schema"]: 31 | jsonschema.validate(claims, schema=self.config["claim_schema"][claims["iss"]]) 32 | return self.app(environ, start_response) 33 | 34 | def validate_token(self, token): 35 | validation_error = Exception(f"Failed to validate against all issuers: {self.jwks_clients.keys()!s}") 36 | for issuer, jwk_client in self.jwks_clients.items(): 37 | try: 38 | return jwt.decode( 39 | token, 40 | key=jwk_client.get_signing_key_from_jwt(token).key, 41 | algorithms=self.oidc_configs[issuer]["id_token_signing_alg_values_supported"], 42 | audience=self.config.get("audience", None), 43 | issuer=self.oidc_configs[issuer]["issuer"], 44 | options={"strict_aud": self.config.get("strict_aud", True),}, 45 | leeway=self.config.get("leeway", 0), 46 | ) 47 | except jwt.PyJWTError as error: 48 | validation_error = error 49 | raise validation_error 50 | -------------------------------------------------------------------------------- /scitt_emulator/plugin_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) SCITT Authors. 2 | # Licensed under the MIT License. 3 | import os 4 | import sys 5 | import pathlib 6 | import importlib 7 | from typing import Iterator, Optional, Union, Any 8 | 9 | 10 | def entrypoint_style_load( 11 | *args: str, relative: Optional[Union[str, pathlib.Path]] = None 12 | ) -> Iterator[Any]: 13 | """ 14 | Load objects given the entrypoint formatted path to the object. Roughly how 15 | the python stdlib docs say entrypoint loading works. 16 | """ 17 | # Push current directory into front of path so we can run things 18 | # relative to where we are in the shell 19 | if relative is not None: 20 | if relative == True: 21 | relative = os.getcwd() 22 | # str() in case of Path object 23 | sys.path.insert(0, str(relative)) 24 | try: 25 | for entry in args: 26 | modname, qualname_separator, qualname = entry.partition(":") 27 | obj = importlib.import_module(modname) 28 | for attr in qualname.split("."): 29 | if hasattr(obj, "__getitem__"): 30 | obj = obj[attr] 31 | else: 32 | obj = getattr(obj, attr) 33 | yield obj 34 | finally: 35 | if relative is not None: 36 | sys.path.pop(0) 37 | -------------------------------------------------------------------------------- /scitt_emulator/rkvst.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | from archivist.archivist import Archivist 5 | from typing import Optional 6 | from pathlib import Path 7 | import json 8 | from pycose.messages import Sign1Message 9 | import pycose.headers 10 | import base64 11 | from os import getenv 12 | from . import rkvst_mocks 13 | 14 | from scitt_emulator.scitt import SCITTServiceEmulator 15 | 16 | class RKVSTSCITTServiceEmulator(SCITTServiceEmulator): 17 | tree_alg = "RKVST" 18 | 19 | def __init__( 20 | self, service_parameters_path: Path, storage_path: Optional[Path] = None 21 | ): 22 | super().__init__(service_parameters_path, storage_path) 23 | if storage_path is not None: 24 | self._service_private_key_path = ( 25 | self.storage_path / "service_private_key.pem" 26 | ) 27 | 28 | 29 | def initialize_service(self): 30 | ######################### 31 | # One time initial set-up 32 | ######################### 33 | 34 | # No permanent state to manage as yet 35 | 36 | ################### 37 | # Every time set-up 38 | ################### 39 | 40 | # Grab credentials from the environment 41 | # TODO: we should support unauthenticated connections for public read calls 42 | self.rkvst_network_fqdn = getenv("RKVST_SCITT_URL") or "https://app.rkvst.io" 43 | client_id = getenv("RKVST_SCITT_CLIENT_ID") or rkvst_mocks.mock_client_id 44 | client_secret = getenv("RKVST_SCITT_CLIENT_SECRET") or rkvst_mocks.mock_client_secret 45 | 46 | # Initialise RKVST session handler 47 | self.rkvst_connection = Archivist( 48 | self.rkvst_network_fqdn, 49 | (client_id, client_secret), 50 | max_time=300 51 | ) 52 | 53 | # TODO: Download the countersign certificate from RKVST if/when verify is supported in the tool 54 | self.service_parameters = { 55 | "serviceId": "RKVST", 56 | "treeAlgorithm": self.tree_alg, 57 | "signatureAlgorithm": "ES256", 58 | "serviceCertificate": None, 59 | } 60 | 61 | def keys_as_jwks(self): 62 | return [] 63 | 64 | def _event_id_to_operation_id(self, event_id: str): 65 | return event_id.replace('/', '_') 66 | 67 | def _operation_id_to_event_id(self, operation_id: str): 68 | return operation_id.replace('_', '/') 69 | 70 | def _feed_id_to_asset_id(self, feed_id: str): 71 | # TODO: Work out this mapping (explicit Feeds to be added in a future PR) 72 | return feed_id 73 | 74 | def _asset_id_to_feed_id(self, asset_id: str): 75 | # TODO: Work out this mapping (explicit Feeds to be added in a future PR) 76 | return asset_id 77 | 78 | def _claim_to_attrs(self, claim: bytes): 79 | cose_msg = Sign1Message.decode(claim) 80 | encoded_claim = base64.b64encode(claim) 81 | string_claim = encoded_claim.decode("UTF-8") 82 | attrs = { 83 | "arc_display_type": "SCITT Attestation", 84 | "scitt_claim_b64": string_claim 85 | } 86 | 87 | # If the claim payload has an understood type then pull out the bits for indexing 88 | if pycose.headers.ContentType in cose_msg.phdr and cose_msg.phdr[pycose.headers.ContentType] == 'application/json': 89 | # Try loading the payload as a JSON structure 90 | payload_str = cose_msg.payload.decode("utf-8") 91 | json_elements = json.loads(payload_str) 92 | # TODO: Make sure RKVST reserved elements aren't overwritten 93 | for k in json_elements.keys(): 94 | if type(k) == str and type(json_elements[k]) == str: 95 | attrs[k] = json_elements[k] 96 | 97 | return attrs 98 | 99 | def _submit_claim_sync(self, claim: bytes): 100 | raise NotImplementedError 101 | 102 | def _submit_claim_async(self, claim: bytes): 103 | # TODO: explicit Feed handling to be added in a future PR 104 | feed_id = rkvst_mocks.mock_feed_id 105 | asset_id = self._feed_id_to_asset_id(feed_id) 106 | asset_id = 'assets/a4be5d0c-02c4-4f67-b148-ceac5532e001' 107 | props = props = { 108 | "operation": "Record", 109 | "behaviour": "RecordEvidence", 110 | } 111 | attrs = self._claim_to_attrs(claim) 112 | asset_attrs = {} 113 | 114 | # Note: Confirm=True here only assures that the claim is accepted by the Transparency 115 | # Service. It does not wait for full commitment to the Merkle tree so this is still LRO 116 | event = self.rkvst_connection.events.create(asset_id, props, attrs, asset_attrs=asset_attrs, confirm=True) 117 | #event = rkvst_mocks.mock_event_lro_incomplete 118 | 119 | operation_id = self._event_id_to_operation_id(event["identity"]) 120 | return { 121 | "operationId": operation_id, 122 | "status": "running" 123 | } 124 | 125 | def submit_claim(self, claim: bytes, long_running=True) -> dict: 126 | if long_running: 127 | return self._submit_claim_async(claim) 128 | else: 129 | return self._submit_claim_sync(claim) 130 | 131 | def get_claim(self, entry_id: str): 132 | # TODO: What should we do here? Our API currently takes a transaction ID and returns a magic 133 | # claim with the Event ID in, but I think that's wrong: we should take whatever the entryID 134 | # is deemed to be and return the claim from the Event attributes, countersigned by RKVST. 135 | # Big question here is how we deal with the submitted claim VS the transparent claim: the 136 | # emulator isn't faithful to the spec here. TBD in a future PR 137 | rkvst_claim= self.rkvst_connection.post( 138 | f"{self.rkvst_network_fqdn}/archivist/v1/notary/claims/events", 139 | {"transaction_id": entry_id}, 140 | ) 141 | #rkvst_claim=rkvst_mocks.mock_claim 142 | 143 | return base64.b64decode(rkvst_claim["claim"]) 144 | 145 | def get_operation(self, operation_id: str): 146 | # Operation IDs in our implementation are RKVST Event IDs so all we need to do 147 | # is fetch the Event record and see if it has a TxID yet. If it does, we're 148 | # ready. If not, it's still waiting for commitment to the tree 149 | event_id = self._operation_id_to_event_id(operation_id) 150 | event = self.rkvst_connection.events.read(event_id) 151 | #event = rkvst_mocks.mock_event_lro_complete 152 | 153 | if event['transaction_id']: 154 | return { 155 | "operationId": operation_id, 156 | "status": "succeeded", 157 | "entryId": event['transaction_id'] 158 | } 159 | else: 160 | return { 161 | "operationId": operation_id, 162 | "status": "running" 163 | } 164 | 165 | def get_receipt(self, entry_id: str): 166 | # TODO: It looks like we got the interface wrong here: we don't need to get the claim 167 | # and submit it back in the body of the receipt call: we should be able to simply 168 | # get the receipt direct from the entry ID (aka TransactionID). 169 | # For now we'll make the 2 round trips but this is probably unnecessarily wasteful 170 | rkvst_claim= self.rkvst_connection.post( 171 | f"{self.rkvst_network_fqdn}/archivist/v1/notary/claims/events", 172 | {"transaction_id": entry_id}, 173 | ) 174 | rkvst_receipt = self.rkvst_connection.post( 175 | f"{self.rkvst_network_fqdn}/archivist/v1/notary/receipts", 176 | {"claim": rkvst_claim["claim"]}, 177 | ) 178 | #rkvst_receipt = rkvst_mocks.mock_receipt 179 | 180 | # This is just neat debug. Get the JSON form of the receipt 181 | receipt_file_path = f'{entry_id}.receipt.json' 182 | with open(receipt_file_path, "w") as receipt_file: 183 | json.dump(rkvst_receipt, receipt_file) 184 | print(f"RKVST receipt written to {receipt_file_path}") 185 | receipt_data = str(base64.b64decode(rkvst_receipt["receipt"])) 186 | application_receipt = receipt_data.split('{"application_parameters"') 187 | compact_json = '{"application_parameters"' + application_receipt[1][:-1] 188 | receipt_structure = json.loads(compact_json) 189 | print(json.dumps(receipt_structure, sort_keys=True, indent=2)) 190 | 191 | # Pull the receipt out of the JSON structure then B64 decode it 192 | return base64.b64decode(rkvst_receipt["receipt"]) 193 | 194 | def create_receipt_contents(self, countersign_tbi: bytes, entry_id: str): 195 | # This is required by the superclass signature but it's not necessary because 196 | # RKVST makes all the receipts in the back end 197 | raise NotImplementedError 198 | 199 | def verify_receipt_contents(self, receipt_contents: list, countersign_tbi: bytes): 200 | [signature, node_cert_der, proof, leaf_info] = receipt_contents 201 | [internal_hash, internal_data] = leaf_info 202 | 203 | # RKVST receipt verification is detailed at https://docs.rkvst.com 204 | # Although we could do a shallow verification of the receipt that could 205 | # be misleading, so return an error 206 | raise NotImplementedError('To verify RKVST receipts, visit: https://docs.rkvst.com') 207 | 208 | -------------------------------------------------------------------------------- /scitt_emulator/rkvst_mocks.py: -------------------------------------------------------------------------------- 1 | mock_client_id="" 2 | 3 | mock_client_secret="" 4 | 5 | mock_event_id="" 6 | 7 | mock_feed_id="" 8 | 9 | mock_claim={} 10 | 11 | mock_event_lro_incomplete={} 12 | 13 | mock_event_lro_complete={} 14 | 15 | mock_receipt={} -------------------------------------------------------------------------------- /scitt_emulator/scitt.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from typing import Optional 5 | from abc import ABC, abstractmethod 6 | from pathlib import Path 7 | import time 8 | import json 9 | import uuid 10 | 11 | import cbor2 12 | from pycose.messages import Sign1Message 13 | import pycose.headers 14 | 15 | from scitt_emulator.create_statement import CWTClaims 16 | from scitt_emulator.verify_statement import verify_statement 17 | 18 | # temporary receipt header labels, see draft-birkholz-scitt-receipts 19 | COSE_Headers_Service_Id = "service_id" 20 | COSE_Headers_Tree_Alg = "tree_alg" 21 | COSE_Headers_Issued_At = "issued_at" 22 | 23 | # permissive insert policy 24 | MOST_PERMISSIVE_INSERT_POLICY = "*" 25 | DEFAULT_INSERT_POLICY = MOST_PERMISSIVE_INSERT_POLICY 26 | 27 | 28 | class ClaimInvalidError(Exception): 29 | pass 30 | 31 | 32 | class EntryNotFoundError(Exception): 33 | pass 34 | 35 | 36 | class OperationNotFoundError(Exception): 37 | pass 38 | 39 | 40 | class PolicyResultDecodeError(Exception): 41 | pass 42 | 43 | 44 | class SCITTServiceEmulator(ABC): 45 | def __init__( 46 | self, service_parameters_path: Path, storage_path: Optional[Path] = None 47 | ): 48 | self.storage_path = storage_path 49 | self.service_parameters_path = service_parameters_path 50 | 51 | if storage_path is not None: 52 | self.operations_path = storage_path / "operations" 53 | self.operations_path.mkdir(exist_ok=True) 54 | 55 | if self.service_parameters_path.exists(): 56 | with open(self.service_parameters_path) as f: 57 | self.service_parameters = json.load(f) 58 | 59 | @abstractmethod 60 | def initialize_service(self): 61 | raise NotImplementedError 62 | 63 | @abstractmethod 64 | def keys_as_jwks(self): 65 | raise NotImplementedError 66 | 67 | @abstractmethod 68 | def create_receipt_contents(self, countersign_tbi: bytes, entry_id: str): 69 | raise NotImplementedError 70 | 71 | @abstractmethod 72 | def verify_receipt_contents(receipt_contents: list, countersign_tbi: bytes): 73 | raise NotImplementedError 74 | 75 | def get_operation(self, operation_id: str) -> dict: 76 | operation_path = self.operations_path / f"{operation_id}.json" 77 | try: 78 | with open(operation_path, "r") as f: 79 | operation = json.load(f) 80 | except FileNotFoundError: 81 | raise EntryNotFoundError(f"Operation {operation_id} not found") 82 | 83 | if operation["status"] == "running": 84 | # Pretend that the service finishes the operation after 85 | # the client having checked the operation status once. 86 | operation = self._finish_operation(operation) 87 | return operation 88 | 89 | def get_entry(self, entry_id: str) -> dict: 90 | try: 91 | self.get_claim(entry_id) 92 | except EntryNotFoundError: 93 | raise 94 | # More metadata to follow in the future. 95 | return { "entryId": entry_id } 96 | 97 | def get_claim(self, entry_id: str) -> bytes: 98 | claim_path = self.storage_path / f"{entry_id}.cose" 99 | try: 100 | with open(claim_path, "rb") as f: 101 | claim = f.read() 102 | except FileNotFoundError: 103 | raise EntryNotFoundError(f"Entry {entry_id} not found") 104 | return claim 105 | 106 | def submit_claim(self, claim: bytes, long_running=True) -> dict: 107 | insert_policy = self.service_parameters.get("insertPolicy", DEFAULT_INSERT_POLICY) 108 | 109 | if long_running: 110 | return self._create_operation(claim) 111 | elif insert_policy != MOST_PERMISSIVE_INSERT_POLICY: 112 | raise NotImplementedError( 113 | f"non-* insertPolicy only works with long_running=True: {insert_policy!r}" 114 | ) 115 | else: 116 | return self._create_entry(claim) 117 | 118 | def _create_entry(self, claim: bytes) -> dict: 119 | last_entry_path = self.storage_path / "last_entry_id.txt" 120 | if last_entry_path.exists(): 121 | with open(last_entry_path, "r") as f: 122 | last_entry_id = int(f.read()) 123 | else: 124 | last_entry_id = 0 125 | 126 | entry_id = str(last_entry_id + 1) 127 | 128 | self._create_receipt(claim, entry_id) 129 | 130 | last_entry_path.write_text(entry_id) 131 | 132 | claim_path = self.storage_path / f"{entry_id}.cose" 133 | claim_path.write_bytes(claim) 134 | 135 | print(f"A COSE signed Claim was written to: {claim_path}") 136 | 137 | entry = {"entryId": entry_id} 138 | return entry 139 | 140 | def _create_operation(self, claim: bytes): 141 | operation_id = str(uuid.uuid4()) 142 | operation_path = self.operations_path / f"{operation_id}.json" 143 | claim_path = self.operations_path / f"{operation_id}.cose" 144 | 145 | operation = { 146 | "operationId": operation_id, 147 | "status": "running" 148 | } 149 | 150 | with open(operation_path, "w") as f: 151 | json.dump(operation, f) 152 | 153 | with open(claim_path, "wb") as f: 154 | f.write(claim) 155 | 156 | print(f"Operation {operation_id} created") 157 | print(f"A COSE signed Claim was written to: {claim_path}") 158 | 159 | return operation 160 | 161 | def _sync_policy_result(self, operation: dict): 162 | operation_id = operation["operationId"] 163 | policy_insert_path = self.operations_path / f"{operation_id}.policy.insert" 164 | policy_denied_path = self.operations_path / f"{operation_id}.policy.denied" 165 | policy_failed_path = self.operations_path / f"{operation_id}.policy.failed" 166 | insert_policy = self.service_parameters.get("insertPolicy", DEFAULT_INSERT_POLICY) 167 | 168 | policy_result = {"status": operation["status"]} 169 | 170 | if insert_policy == MOST_PERMISSIVE_INSERT_POLICY: 171 | policy_result["status"] = "succeeded" 172 | if policy_insert_path.exists(): 173 | policy_result["status"] = "succeeded" 174 | policy_insert_path.unlink() 175 | if policy_failed_path.exists(): 176 | policy_result["status"] = "failed" 177 | if policy_failed_path.stat().st_size != 0: 178 | try: 179 | policy_result_error = json.loads(policy_failed_path.read_text()) 180 | except Exception as error: 181 | raise PolicyResultDecodeError(operation_id) from error 182 | policy_result["error"] = policy_result_error 183 | policy_failed_path.unlink() 184 | if policy_denied_path.exists(): 185 | policy_result["status"] = "denied" 186 | if policy_denied_path.stat().st_size != 0: 187 | try: 188 | policy_result_error = json.loads(policy_denied_path.read_text()) 189 | except Exception as error: 190 | raise PolicyResultDecodeError(operation_id) from error 191 | policy_result["error"] = policy_result_error 192 | policy_denied_path.unlink() 193 | 194 | return policy_result 195 | 196 | def _finish_operation(self, operation: dict): 197 | operation_id = operation["operationId"] 198 | operation_path = self.operations_path / f"{operation_id}.json" 199 | claim_src_path = self.operations_path / f"{operation_id}.cose" 200 | 201 | policy_result = self._sync_policy_result(operation) 202 | if policy_result["status"] == "running": 203 | return operation 204 | if policy_result["status"] != "succeeded": 205 | operation["status"] = "failed" 206 | if "error" in policy_result: 207 | operation["error"] = policy_result["error"] 208 | operation_path.unlink() 209 | claim_src_path.unlink() 210 | return operation 211 | 212 | claim = claim_src_path.read_bytes() 213 | entry = self._create_entry(claim) 214 | claim_src_path.unlink() 215 | 216 | operation["status"] = "succeeded" 217 | operation["entryId"] = entry["entryId"] 218 | 219 | with open(operation_path, "w") as f: 220 | json.dump(operation, f) 221 | 222 | return operation 223 | 224 | def _create_receipt(self, claim: bytes, entry_id: str): 225 | # Validate claim 226 | # Note: This emulator does not verify the claim signature and does not apply 227 | # registration policies. 228 | try: 229 | msg = Sign1Message.decode(claim, tag=True) 230 | except: 231 | raise ClaimInvalidError("Claim is not a valid COSE message") 232 | if not isinstance(msg, Sign1Message): 233 | raise ClaimInvalidError("Claim is not a COSE_Sign1 message") 234 | if pycose.headers.Algorithm not in msg.phdr: 235 | raise ClaimInvalidError("Claim does not have an algorithm header parameter") 236 | if pycose.headers.ContentType not in msg.phdr: 237 | raise ClaimInvalidError( 238 | "Claim does not have a content type header parameter" 239 | ) 240 | if CWTClaims not in msg.phdr: 241 | raise ClaimInvalidError("Claim does not have a CWTClaims header parameter") 242 | 243 | # Extract fields of COSE_Sign1 for countersigning 244 | outer = cbor2.loads(claim) 245 | [phdr, uhdr, payload, sig] = outer.value 246 | 247 | # Create countersigner protected header 248 | sign_protected = cbor2.dumps( 249 | { 250 | COSE_Headers_Service_Id: self.service_parameters["serviceId"], 251 | COSE_Headers_Tree_Alg: self.service_parameters["treeAlgorithm"], 252 | COSE_Headers_Issued_At: int(time.time()), 253 | } 254 | ) 255 | 256 | # Compute countersign to-be-included 257 | countersign_tbi = create_countersign_to_be_included( 258 | phdr, sign_protected, payload, sig 259 | ) 260 | 261 | # Tree algorithm receipt contents 262 | receipt_contents = self.create_receipt_contents(countersign_tbi, entry_id) 263 | 264 | # Create receipt 265 | receipt = cbor2.dumps([sign_protected, receipt_contents]) 266 | 267 | # Store receipt 268 | receipt_path = self.storage_path / f"{entry_id}.receipt.cbor" 269 | with open(receipt_path, "wb") as f: 270 | f.write(receipt) 271 | print(f"Receipt written to {receipt_path}") 272 | 273 | def get_receipt(self, entry_id: str): 274 | receipt_path = self.storage_path / f"{entry_id}.receipt.cbor" 275 | try: 276 | with open(receipt_path, "rb") as f: 277 | receipt = f.read() 278 | except FileNotFoundError: 279 | raise EntryNotFoundError(f"Entry {entry_id} not found") 280 | return receipt 281 | 282 | def verify_receipt(self, cose_path: Path, receipt_path: Path): 283 | with open(cose_path, "rb") as f: 284 | envelope = f.read() 285 | 286 | outer = cbor2.loads(envelope) 287 | assert outer.tag == Sign1Message.cbor_tag 288 | [phdr, uhdr, payload, sig] = outer.value 289 | 290 | with open(receipt_path, "rb") as f: 291 | receipt = cbor2.loads(f.read()) 292 | 293 | [sign_protected, receipt_contents] = receipt 294 | 295 | countersign_tbi = create_countersign_to_be_included( 296 | phdr, sign_protected, payload, sig 297 | ) 298 | 299 | sign_protected_decoded = cbor2.loads(sign_protected) 300 | tree_alg = sign_protected_decoded[COSE_Headers_Tree_Alg] 301 | assert tree_alg == self.tree_alg 302 | 303 | self.verify_receipt_contents(receipt_contents, countersign_tbi) 304 | 305 | 306 | def create_countersign_to_be_included( 307 | body_protected, sign_protected, payload, signature 308 | ): 309 | context = "CounterSignatureV2" 310 | countersign_structure = [ 311 | context, 312 | body_protected, 313 | sign_protected, 314 | b"", # no external AAD 315 | payload, 316 | [signature], 317 | ] 318 | to_be_signed = cbor2.dumps(countersign_structure) 319 | return to_be_signed 320 | -------------------------------------------------------------------------------- /scitt_emulator/server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | import os 5 | from pathlib import Path 6 | from io import BytesIO 7 | import random 8 | 9 | from flask import Flask, request, send_file, make_response, jsonify 10 | 11 | from scitt_emulator.tree_algs import TREE_ALGS 12 | from scitt_emulator.plugin_helpers import entrypoint_style_load 13 | from scitt_emulator.scitt import EntryNotFoundError, ClaimInvalidError, OperationNotFoundError 14 | 15 | 16 | def make_error(code: str, msg: str, status_code: int): 17 | return make_response( 18 | { 19 | "type": f"urn:ietf:params:scitt:error:{code}", 20 | "detail": msg, 21 | }, 22 | status_code, 23 | ) 24 | 25 | 26 | def make_unavailable_error(): 27 | return make_error("serviceUnavailable", "Service unavailable, try again later", 503) 28 | 29 | 30 | def create_flask_app(config): 31 | app = Flask(__name__) 32 | 33 | # See http://flask.pocoo.org/docs/latest/config/ 34 | app.config.update(dict(DEBUG=True)) 35 | app.config.update(config) 36 | 37 | if app.config.get("middleware", None): 38 | app.wsgi_app = app.config["middleware"](app.wsgi_app, app.config.get("middleware_config_path", None)) 39 | 40 | error_rate = app.config["error_rate"] 41 | use_lro = app.config["use_lro"] 42 | 43 | workspace_path = app.config["workspace"] 44 | storage_path = workspace_path / "storage" 45 | os.makedirs(storage_path, exist_ok=True) 46 | app.service_parameters_path = workspace_path / "service_parameters.json" 47 | 48 | clazz = TREE_ALGS[app.config["tree_alg"]] 49 | 50 | app.scitt_service = clazz( 51 | storage_path=storage_path, service_parameters_path=app.service_parameters_path 52 | ) 53 | app.scitt_service.initialize_service() 54 | print(f"Service parameters: {app.service_parameters_path}") 55 | 56 | def is_unavailable(): 57 | return random.random() <= error_rate 58 | 59 | @app.route("/.well-known/transparency-configuration", methods=["GET"]) 60 | def get_transparency_configuration(): 61 | if is_unavailable(): 62 | return make_unavailable_error() 63 | return jsonify( 64 | { 65 | "issuer": "/", 66 | "registration_endpoint": f"/entries", 67 | "nonce_endpoint": f"/nonce", 68 | "registration_policy": f"/statements/TODO", 69 | "supported_signature_algorithms": ["ES256"], 70 | "jwks": { 71 | "keys": app.scitt_service.keys_as_jwks(), 72 | } 73 | } 74 | ) 75 | 76 | @app.route("/entries//receipt", methods=["GET"]) 77 | def get_receipt(entry_id: str): 78 | if is_unavailable(): 79 | return make_unavailable_error() 80 | try: 81 | receipt = app.scitt_service.get_receipt(entry_id) 82 | except EntryNotFoundError as e: 83 | return make_error("entryNotFound", str(e), 404) 84 | return send_file(BytesIO(receipt), download_name=f"{entry_id}.receipt.cbor") 85 | 86 | @app.route("/entries/", methods=["GET"]) 87 | def get_claim(entry_id: str): 88 | if is_unavailable(): 89 | return make_unavailable_error() 90 | try: 91 | claim = app.scitt_service.get_claim(entry_id) 92 | except EntryNotFoundError as e: 93 | return make_error("entryNotFound", str(e), 404) 94 | return send_file(BytesIO(claim), download_name=f"{entry_id}.cose") 95 | 96 | @app.route("/entries", methods=["POST"]) 97 | def submit_claim(): 98 | if is_unavailable(): 99 | return make_unavailable_error() 100 | try: 101 | if use_lro: 102 | result = app.scitt_service.submit_claim(request.get_data(), long_running=True) 103 | headers = { 104 | "Location": f"{request.host_url}/operations/{result['operationId']}", 105 | "Retry-After": "1" 106 | } 107 | status_code = 202 108 | else: 109 | result = app.scitt_service.submit_claim(request.get_data(), long_running=False) 110 | headers = { 111 | "Location": f"{request.host_url}/entries/{result['entryId']}", 112 | } 113 | status_code = 201 114 | except ClaimInvalidError as e: 115 | return make_error("invalidInput", str(e), 400) 116 | return make_response(result, status_code, headers) 117 | 118 | @app.route("/operations/", methods=["GET"]) 119 | def get_operation(operation_id: str): 120 | if is_unavailable(): 121 | return make_unavailable_error() 122 | try: 123 | operation = app.scitt_service.get_operation(operation_id) 124 | except OperationNotFoundError as e: 125 | return make_error("operationNotFound", str(e), 404) 126 | headers = {} 127 | if operation["status"] == "running": 128 | headers["Retry-After"] = "1" 129 | return make_response(operation, 200, headers) 130 | 131 | return app 132 | 133 | 134 | def cli(fn): 135 | parser = fn() 136 | parser.add_argument("-p", "--port", type=int, default=8000) 137 | parser.add_argument("--error-rate", type=float, default=0.01) 138 | parser.add_argument("--use-lro", action="store_true", help="Create operations for submissions") 139 | parser.add_argument("--tree-alg", required=True, choices=list(TREE_ALGS.keys())) 140 | parser.add_argument("--workspace", type=Path, default=Path("workspace")) 141 | parser.add_argument( 142 | "--middleware", 143 | type=lambda value: list(entrypoint_style_load(value))[0], 144 | default=None, 145 | ) 146 | parser.add_argument("--middleware-config-path", type=Path, default=None) 147 | 148 | def cmd(args): 149 | app = create_flask_app( 150 | { 151 | "middleware": args.middleware, 152 | "middleware_config_path": args.middleware_config_path, 153 | "tree_alg": args.tree_alg, 154 | "workspace": args.workspace, 155 | "error_rate": args.error_rate, 156 | "use_lro": args.use_lro 157 | } 158 | ) 159 | app.run(host="0.0.0.0", port=args.port) 160 | 161 | parser.set_defaults(func=cmd) 162 | 163 | return parser 164 | -------------------------------------------------------------------------------- /scitt_emulator/tree_algs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | from typing import Mapping 5 | from scitt_emulator.scitt import SCITTServiceEmulator 6 | from scitt_emulator.ccf import CCFSCITTServiceEmulator 7 | from scitt_emulator.rkvst import RKVSTSCITTServiceEmulator 8 | 9 | TREE_ALGS: Mapping[str, SCITTServiceEmulator] = { 10 | "CCF": CCFSCITTServiceEmulator, 11 | "RKVST": RKVSTSCITTServiceEmulator, 12 | } 13 | -------------------------------------------------------------------------------- /scitt_emulator/verify_statement.py: -------------------------------------------------------------------------------- 1 | import os 2 | import itertools 3 | import contextlib 4 | import dataclasses 5 | import urllib.parse 6 | import urllib.request 7 | import importlib.metadata 8 | from typing import Optional, Callable, List, Tuple 9 | 10 | import cwt 11 | import cwt.algs.ec2 12 | import pycose 13 | import pycose.keys.ec2 14 | from pycose.messages import Sign1Message 15 | 16 | from scitt_emulator.did_helpers import did_web_to_url 17 | from scitt_emulator.create_statement import CWTClaims 18 | from scitt_emulator.key_helper_dataclasses import VerificationKey 19 | from scitt_emulator.key_transforms import preform_verification_key_transforms 20 | 21 | 22 | ENTRYPOINT_KEY_LOADERS = "scitt_emulator.verify_signature.key_loaders" 23 | 24 | 25 | def verify_statement( 26 | msg: Sign1Message, 27 | *, 28 | key_loaders: Optional[List[Callable[[str], List[VerificationKey]]]] = None, 29 | ) -> bool: 30 | """ 31 | Resolve keys for statement issuer and verify signature on COSESign1 32 | statement and embedded CWT 33 | """ 34 | if key_loaders is None: 35 | key_loaders = [] 36 | # There is some difference in the return value of entry_points across 37 | # Python versions/envs (conda vs. non-conda). Python 3.8 returns a dict. 38 | entrypoints = importlib.metadata.entry_points() 39 | if isinstance(entrypoints, dict): 40 | for entrypoint in entrypoints.get(ENTRYPOINT_KEY_LOADERS, []): 41 | key_loaders.append(entrypoint.load()) 42 | elif isinstance(entrypoints, getattr(importlib.metadata, "EntryPoints", list)): 43 | for entrypoint in entrypoints: 44 | if entrypoint.group == ENTRYPOINT_KEY_LOADERS: 45 | key_loaders.append(entrypoint.load()) 46 | else: 47 | raise TypeError(f"importlib.metadata.entry_points returned unknown type: {type(entrypoints)}: {entrypoints!r}") 48 | 49 | # Figure out what the issuer is 50 | cwt_cose_loads = cwt.cose.COSE()._loads 51 | cwt_unverified_protected = cwt_cose_loads( 52 | cwt_cose_loads(msg.phdr[CWTClaims]).value[2] 53 | ) 54 | unverified_issuer = cwt_unverified_protected[1] 55 | 56 | # Load keys from issuer and attempt verification. Return key used to verify 57 | for verification_key in preform_verification_key_transforms( 58 | itertools.chain( 59 | *[key_loader(unverified_issuer) for key_loader in key_loaders] 60 | ) 61 | ): 62 | # Skip keys that we couldn't derive COSE keys for 63 | if not verification_key.usable: 64 | # TODO Logging 65 | continue 66 | msg.key = verification_key.cose 67 | verify_signature = False 68 | with contextlib.suppress(Exception): 69 | verify_signature = msg.verify_signature() 70 | if verify_signature: 71 | return verification_key 72 | 73 | return None 74 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | from setuptools import setup, find_packages 5 | 6 | setup( 7 | name="scitt-emulator", 8 | version="0.0.1", 9 | packages=find_packages(), 10 | entry_points = { 11 | 'console_scripts': [ 12 | 'scitt-emulator=scitt_emulator.cli:main' 13 | ], 14 | 'scitt_emulator.verify_signature.key_loaders': [ 15 | 'did_jwk=scitt_emulator.key_loader_format_did_jwk:key_loader_format_did_jwk', 16 | 'url_referencing_scitt_scrapi=scitt_emulator.key_loader_format_url_referencing_scitt_scrapi:key_loader_format_url_referencing_scitt_scrapi', 17 | 'url_referencing_oidc_issuer=scitt_emulator.key_loader_format_url_referencing_oidc_issuer:key_loader_format_url_referencing_oidc_issuer', 18 | 'url_referencing_ssh_authorized_keys=scitt_emulator.key_loader_format_url_referencing_ssh_authorized_keys:key_loader_format_url_referencing_ssh_authorized_keys', 19 | ], 20 | 'scitt_emulator.key_helpers.transforms_key_instances': [ 21 | 'transform_key_instance_cwt_cose_ec2_to_pycose_ec2=scitt_emulator.key_transforms:transform_key_instance_cwt_cose_ec2_to_pycose_ec2', 22 | 'transform_key_instance_jwcrypto_jwk_to_cwt_cose=scitt_emulator.key_loader_format_url_referencing_scitt_scrapi:transform_key_instance_jwcrypto_jwk_to_cwt_cose', 23 | 'transform_key_instance_cryptography_ecc_public_to_jwcrypto_jwk=scitt_emulator:key_loader_format_url_referencing_ssh_authorized_keys.transform_key_instance_cryptography_ecc_public_to_jwcrypto_jwk', 24 | ], 25 | 'scitt_emulator.key_helpers.verification_key_to_object': [ 26 | 'to_object_jwk=scitt_emulator.key_loader_format_did_jwk:to_object_jwk', 27 | 'to_object_ssh_public=scitt_emulator.key_loader_format_url_referencing_ssh_authorized_keys:to_object_ssh_public', 28 | ], 29 | }, 30 | python_requires=">=3.8", 31 | install_requires=[ 32 | "cryptography", 33 | "cbor2", 34 | "cwt", 35 | "jwcrypto", 36 | "pycose", 37 | "httpx", 38 | "flask", 39 | "rkvst-archivist" 40 | ], 41 | extras_require={ 42 | "oidc": [ 43 | "PyJWT", 44 | "jwcrypto", 45 | "jsonschema", 46 | ] 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scitt-community/scitt-api-emulator/6e6776070a1adf7298c9427871f15a8f087be73d/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | import os 4 | import io 5 | import json 6 | import threading 7 | import pytest 8 | import jwt 9 | import jwcrypto 10 | from flask import Flask, jsonify, send_file 11 | from werkzeug.serving import make_server 12 | from scitt_emulator import cli, server 13 | from scitt_emulator.oidc import OIDCAuthMiddleware 14 | 15 | content_type = "application/json" 16 | payload = '{"foo": "bar"}' 17 | 18 | 19 | def execute_cli(argv): 20 | return cli.main([str(v) for v in argv]) 21 | 22 | 23 | class Service: 24 | def __init__(self, config, create_flask_app=None): 25 | self.config = config 26 | self.create_flask_app = ( 27 | create_flask_app 28 | if create_flask_app is not None 29 | else server.create_flask_app 30 | ) 31 | 32 | def __enter__(self): 33 | app = self.create_flask_app(self.config) 34 | if hasattr(app, "service_parameters_path"): 35 | self.service_parameters_path = app.service_parameters_path 36 | self.host = "127.0.0.1" 37 | self.server = make_server(self.host, 0, app) 38 | port = self.server.port 39 | self.url = f"http://{self.host}:{port}" 40 | app.url = self.url 41 | self.thread = threading.Thread(name="server", target=self.server.serve_forever) 42 | self.thread.start() 43 | return self 44 | 45 | def __exit__(self, *args): 46 | self.server.shutdown() 47 | self.thread.join() 48 | 49 | @pytest.mark.parametrize( 50 | "use_lro", [True, False], 51 | ) 52 | def test_client_cli(use_lro: bool, tmp_path): 53 | workspace_path = tmp_path / "workspace" 54 | 55 | claim_path = tmp_path / "claim.cose" 56 | receipt_path = tmp_path / "claim.receipt.cbor" 57 | entry_id_path = tmp_path / "claim.entry_id.txt" 58 | retrieved_claim_path = tmp_path / "claim.retrieved.cose" 59 | 60 | with Service( 61 | { 62 | "tree_alg": "CCF", 63 | "workspace": workspace_path, 64 | "error_rate": 0.1, 65 | "use_lro": use_lro 66 | } 67 | ) as service: 68 | # create claim 69 | command = [ 70 | "client", 71 | "create-claim", 72 | "--out", 73 | claim_path, 74 | "--subject", 75 | "test", 76 | "--content-type", 77 | content_type, 78 | "--payload", 79 | payload, 80 | ] 81 | execute_cli(command) 82 | assert os.path.exists(claim_path) 83 | 84 | # submit claim 85 | command = [ 86 | "client", 87 | "submit-claim", 88 | "--claim", 89 | claim_path, 90 | "--out", 91 | receipt_path, 92 | "--out-entry-id", 93 | entry_id_path, 94 | "--url", 95 | service.url 96 | ] 97 | execute_cli(command) 98 | assert os.path.exists(receipt_path) 99 | assert os.path.exists(entry_id_path) 100 | 101 | # verify receipt 102 | command = [ 103 | "client", 104 | "verify-receipt", 105 | "--claim", 106 | claim_path, 107 | "--receipt", 108 | receipt_path, 109 | "--service-parameters", 110 | service.service_parameters_path, 111 | ] 112 | execute_cli(command) 113 | 114 | # retrieve claim 115 | with open(entry_id_path) as f: 116 | entry_id = f.read() 117 | 118 | command = [ 119 | "client", 120 | "retrieve-claim", 121 | "--entry-id", 122 | entry_id, 123 | "--out", 124 | retrieved_claim_path, 125 | "--url", 126 | service.url 127 | ] 128 | execute_cli(command) 129 | assert os.path.exists(retrieved_claim_path) 130 | 131 | with open(claim_path, "rb") as f: 132 | original_claim = f.read() 133 | with open(retrieved_claim_path, "rb") as f: 134 | retrieved_claim = f.read() 135 | assert original_claim == retrieved_claim 136 | 137 | # retrieve receipt 138 | receipt_path_2 = tmp_path / "claim.receipt2.cbor" 139 | command = [ 140 | "client", 141 | "retrieve-receipt", 142 | "--entry-id", 143 | entry_id, 144 | "--out", 145 | receipt_path_2, 146 | "--url", 147 | service.url 148 | ] 149 | execute_cli(command) 150 | assert os.path.exists(receipt_path_2) 151 | 152 | with open(receipt_path, "rb") as f: 153 | receipt = f.read() 154 | with open(receipt_path_2, "rb") as f: 155 | receipt_2 = f.read() 156 | assert receipt == receipt_2 157 | 158 | # create transparent statement 159 | command = [ 160 | "client", 161 | "create-claim", 162 | "--out", 163 | claim_path, 164 | "--subject", 165 | "test", 166 | "--content-type", 167 | content_type, 168 | "--payload", 169 | payload, 170 | "--receipts", 171 | receipt_path, 172 | ] 173 | execute_cli(command) 174 | assert os.path.exists(claim_path) 175 | 176 | 177 | def create_flask_app_ssh_authorized_keys_server(config): 178 | app = Flask("ssh_authorized_keys_server") 179 | 180 | app.config.update(dict(DEBUG=True)) 181 | app.config.update(config) 182 | 183 | @app.route("/", methods=["GET"]) 184 | def ssh_public_keys(): 185 | from cryptography.hazmat.primitives import serialization 186 | return send_file( 187 | io.BytesIO( 188 | serialization.load_pem_public_key( 189 | app.config["key"].export_to_pem(), 190 | ).public_bytes( 191 | encoding=serialization.Encoding.OpenSSH, 192 | format=serialization.PublicFormat.OpenSSH, 193 | ) 194 | ), 195 | mimetype="text/plain", 196 | ) 197 | 198 | return app 199 | 200 | 201 | def create_flask_app_oidc_server(config): 202 | app = Flask("oidc_server") 203 | 204 | app.config.update(dict(DEBUG=True)) 205 | app.config.update(config) 206 | 207 | @app.route("/.well-known/openid-configuration", methods=["GET"]) 208 | def openid_configuration(): 209 | return jsonify( 210 | { 211 | "issuer": app.url, 212 | "jwks_uri": f"{app.url}/.well-known/jwks", 213 | "response_types_supported": ["id_token"], 214 | "claims_supported": ["sub", "aud", "exp", "iat", "iss"], 215 | "id_token_signing_alg_values_supported": app.config["algorithms"], 216 | "scopes_supported": ["openid"], 217 | } 218 | ) 219 | 220 | @app.route("/.well-known/jwks", methods=["GET"]) 221 | def jwks(): 222 | return jsonify( 223 | { 224 | "keys": [ 225 | { 226 | **app.config["key"].export_public(as_dict=True), 227 | "use": "sig", 228 | "kid": app.config["key"].thumbprint(), 229 | } 230 | ] 231 | } 232 | ) 233 | 234 | return app 235 | 236 | 237 | def test_client_cli_token(tmp_path): 238 | workspace_path = tmp_path / "workspace" 239 | 240 | claim_path = tmp_path / "claim.cose" 241 | receipt_path = tmp_path / "claim.receipt.cbor" 242 | entry_id_path = tmp_path / "claim.entry_id.txt" 243 | retrieved_claim_path = tmp_path / "claim.retrieved.cose" 244 | 245 | key = jwcrypto.jwk.JWK.generate(kty="RSA", size=2048) 246 | algorithm = "RS256" 247 | audience = "scitt.example.org" 248 | subject = "repo:scitt-community/scitt-api-emulator:ref:refs/heads/main" 249 | 250 | with Service( 251 | {"key": key, "algorithms": [algorithm]}, 252 | create_flask_app=create_flask_app_oidc_server, 253 | ) as oidc_service: 254 | os.environ["no_proxy"] = ",".join( 255 | os.environ.get("no_proxy", "").split(",") + [oidc_service.host] 256 | ) 257 | middleware_config_path = tmp_path / "oidc-middleware-config.json" 258 | middleware_config_path.write_text( 259 | json.dumps( 260 | { 261 | "issuers": [oidc_service.url], 262 | "audience": audience, 263 | "claim_schema": { 264 | oidc_service.url: { 265 | "$schema": "https://json-schema.org/draft/2020-12/schema", 266 | "required": ["sub"], 267 | "properties": { 268 | "sub": {"type": "string", "enum": [subject]}, 269 | }, 270 | } 271 | }, 272 | } 273 | ) 274 | ) 275 | with Service( 276 | { 277 | "middleware": OIDCAuthMiddleware, 278 | "middleware_config_path": middleware_config_path, 279 | "tree_alg": "CCF", 280 | "workspace": workspace_path, 281 | "error_rate": 0.1, 282 | "use_lro": False, 283 | } 284 | ) as service: 285 | # create claim 286 | command = [ 287 | "client", 288 | "create-claim", 289 | "--out", 290 | claim_path, 291 | "--subject", 292 | "test", 293 | "--content-type", 294 | content_type, 295 | "--payload", 296 | payload, 297 | ] 298 | execute_cli(command) 299 | assert os.path.exists(claim_path) 300 | 301 | # submit claim without token 302 | command = [ 303 | "client", 304 | "submit-claim", 305 | "--claim", 306 | claim_path, 307 | "--out", 308 | receipt_path, 309 | "--out-entry-id", 310 | entry_id_path, 311 | "--url", 312 | service.url, 313 | ] 314 | check_error = None 315 | try: 316 | execute_cli(command) 317 | except Exception as error: 318 | check_error = error 319 | assert check_error 320 | assert not os.path.exists(receipt_path) 321 | assert not os.path.exists(entry_id_path) 322 | 323 | # create token without subject 324 | token = jwt.encode( 325 | {"iss": oidc_service.url, "aud": audience}, 326 | key.export_to_pem(private_key=True, password=None), 327 | algorithm=algorithm, 328 | headers={"kid": key.thumbprint()}, 329 | ) 330 | # submit claim with token lacking subject 331 | command += [ 332 | "--token", 333 | token, 334 | ] 335 | check_error = None 336 | try: 337 | execute_cli(command) 338 | except Exception as error: 339 | check_error = error 340 | assert check_error 341 | assert not os.path.exists(receipt_path) 342 | assert not os.path.exists(entry_id_path) 343 | 344 | # create token with subject 345 | token = jwt.encode( 346 | {"iss": oidc_service.url, "aud": audience, "sub": subject}, 347 | key.export_to_pem(private_key=True, password=None), 348 | algorithm=algorithm, 349 | headers={"kid": key.thumbprint()}, 350 | ) 351 | # submit claim with token containing subject 352 | command[-1] = token 353 | execute_cli(command) 354 | assert os.path.exists(receipt_path) 355 | assert os.path.exists(entry_id_path) 356 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) SCITT Authors 2 | # Licensed under the MIT License. 3 | import os 4 | import sys 5 | import time 6 | import json 7 | import copy 8 | import types 9 | import pathlib 10 | import tempfile 11 | import textwrap 12 | import threading 13 | import itertools 14 | import subprocess 15 | import urllib.parse 16 | 17 | import pytest 18 | import myst_parser.parsers.docutils_ 19 | import docutils.nodes 20 | import docutils.utils 21 | from flask import Flask 22 | 23 | import jwcrypto 24 | 25 | from scitt_emulator.client import ClaimOperationError 26 | from scitt_emulator.did_helpers import url_to_did_web 27 | 28 | from .test_cli import ( 29 | Service, 30 | content_type, 31 | payload, 32 | execute_cli, 33 | create_flask_app_oidc_server, 34 | create_flask_app_ssh_authorized_keys_server, 35 | ) 36 | 37 | 38 | repo_root = pathlib.Path(__file__).parents[1] 39 | docs_dir = repo_root.joinpath("docs") 40 | non_allowlisted_issuer = "did:web:denied.example.com" 41 | CLAIM_DENIED_ERROR = {"type": "denied", "detail": "content_address_of_reason"} 42 | CLAIM_DENIED_ERROR_BLOCKED = { 43 | "type": "denied", 44 | "detail": textwrap.dedent( 45 | """ 46 | 'did:web:denied.example.com' is not one of ['did:web:example.org'] 47 | 48 | Failed validating 'enum' in schema['properties']['issuer']: 49 | {'enum': ['did:web:example.org'], 'type': 'string'} 50 | 51 | On instance['issuer']: 52 | 'did:web:denied.example.com' 53 | """ 54 | ).lstrip(), 55 | } 56 | 57 | 58 | class SimpleFileBasedPolicyEngine: 59 | def __init__(self, config): 60 | self.config = config 61 | 62 | def __enter__(self): 63 | self.stop_event = threading.Event() 64 | self.thread = threading.Thread( 65 | name="policy", 66 | target=self.poll_workspace, 67 | args=[self.config, self.stop_event], 68 | ) 69 | self.thread.start() 70 | return self 71 | 72 | def __exit__(self, *args): 73 | self.stop_event.set() 74 | self.thread.join() 75 | 76 | @staticmethod 77 | def poll_workspace(config, stop_event): 78 | operations_path = pathlib.Path(config["storage_path"], "operations") 79 | command_jsonschema_validator = [ 80 | sys.executable, 81 | str(config["jsonschema_validator"].resolve()), 82 | ] 83 | command_enforce_policy = [ 84 | sys.executable, 85 | str(config["enforce_policy"].resolve()), 86 | ] 87 | 88 | running = True 89 | while running: 90 | for cose_path in operations_path.glob("*.cose"): 91 | denial = copy.deepcopy(CLAIM_DENIED_ERROR) 92 | with open(cose_path, "rb") as stdin_fileobj: 93 | env = { 94 | **os.environ, 95 | "SCHEMA_PATH": str(config["schema_path"].resolve()), 96 | "PYTHONPATH": ":".join( 97 | os.environ.get("PYTHONPATH", "").split(":") 98 | + [str(pathlib.Path(__file__).parents[1].resolve())] 99 | ), 100 | } 101 | exit_code = 0 102 | try: 103 | subprocess.check_output( 104 | command_jsonschema_validator, 105 | stdin=stdin_fileobj, 106 | stderr=subprocess.STDOUT, 107 | env=env, 108 | ) 109 | except subprocess.CalledProcessError as error: 110 | denial["detail"] = error.output.decode() 111 | exit_code = error.returncode 112 | # EXIT_FAILRUE from validator == MUST block 113 | with tempfile.TemporaryDirectory() as tempdir: 114 | policy_reason_path = pathlib.Path(tempdir, "reason.json") 115 | policy_reason_path.write_text(json.dumps(denial)) 116 | env = { 117 | **os.environ, 118 | "POLICY_REASON_PATH": str(policy_reason_path), 119 | "POLICY_ACTION": { 120 | 0: "insert", 121 | }.get(exit_code, "denied"), 122 | } 123 | command = command_enforce_policy + [cose_path] 124 | exit_code = subprocess.call(command, env=env) 125 | time.sleep(0.1) 126 | running = not stop_event.is_set() 127 | 128 | def docutils_recursively_extract_nodes(node, samples = None): 129 | if samples is None: 130 | samples = [] 131 | if isinstance(node, list): 132 | node = types.SimpleNamespace(children=node) 133 | return samples + list(itertools.chain(*[ 134 | [ 135 | child, 136 | *docutils_recursively_extract_nodes(child), 137 | ] 138 | for child in node.children 139 | if hasattr(child, "children") 140 | ])) 141 | 142 | def docutils_find_code_samples(nodes): 143 | samples = {} 144 | for i, node in enumerate(nodes): 145 | # Look ahead for next literal allow with code sample. Pattern is: 146 | # 147 | # **strong.suffix** 148 | # 149 | # ```language 150 | # content 151 | # ```` 152 | # TODO Gracefully handle expections to index out of bounds 153 | if ( 154 | isinstance(node, docutils.nodes.strong) 155 | and isinstance(nodes[i + 3], docutils.nodes.literal_block) 156 | ): 157 | samples[node.astext()] = nodes[i + 3].astext() 158 | return samples 159 | 160 | def create_flask_app_nop_scitt_scrapi(config): 161 | # Used to test resolving keys from scrapi 162 | # /.well-known/transparency-configuration 163 | app = Flask("nop") 164 | 165 | app.config.update(dict(DEBUG=True)) 166 | app.config.update(config) 167 | 168 | return app 169 | 170 | @pytest.mark.parametrize( 171 | "create_flask_app_notary_identity", [ 172 | create_flask_app_oidc_server, 173 | create_flask_app_ssh_authorized_keys_server, 174 | create_flask_app_nop_scitt_scrapi, 175 | ], 176 | ) 177 | def test_docs_registration_policies(create_flask_app_notary_identity, tmp_path): 178 | workspace_path = tmp_path / "workspace" 179 | 180 | claim_path = tmp_path / "claim.cose" 181 | receipt_path = tmp_path / "claim.receipt.cbor" 182 | entry_id_path = tmp_path / "claim.entry_id.txt" 183 | retrieved_claim_path = tmp_path / "claim.retrieved.cose" 184 | private_key_pem_path = tmp_path / "notary-private-key.pem" 185 | 186 | # Grab code samples from docs 187 | # TODO Abstract into abitrary docs testing code 188 | doc_path = docs_dir.joinpath("registration_policies.md") 189 | markdown_parser = myst_parser.parsers.docutils_.Parser() 190 | document = docutils.utils.new_document(str(doc_path.resolve())) 191 | parsed = markdown_parser.parse(doc_path.read_text(), document) 192 | nodes = docutils_recursively_extract_nodes(document) 193 | for name, content in docutils_find_code_samples(nodes).items(): 194 | tmp_path.joinpath(name).write_text(content) 195 | 196 | key = jwcrypto.jwk.JWK.generate(kty="EC", crv="P-384") 197 | # cwt_cose_key = cwt.COSEKey.generate_symmetric_key(alg=alg, kid=kid) 198 | private_key_pem_path.write_bytes( 199 | key.export_to_pem(private_key=True, password=None), 200 | ) 201 | algorithm = "ES384" 202 | audience = "scitt.example.org" 203 | subject = "repo:scitt-community/scitt-api-emulator:ref:refs/heads/main" 204 | 205 | # tell jsonschema_validator.py that we want to assume non-TLS URLs for tests 206 | os.environ["DID_WEB_ASSUME_SCHEME"] = "http" 207 | 208 | with Service( 209 | {"key": key, "algorithms": [algorithm]}, 210 | create_flask_app=create_flask_app_notary_identity, 211 | ) as oidc_service, Service( 212 | { 213 | "tree_alg": "CCF", 214 | "workspace": workspace_path, 215 | "error_rate": 0, 216 | "use_lro": True, 217 | } 218 | ) as service, SimpleFileBasedPolicyEngine( 219 | { 220 | "storage_path": service.server.app.scitt_service.storage_path, 221 | "enforce_policy": tmp_path.joinpath("enforce_policy.py"), 222 | "jsonschema_validator": tmp_path.joinpath("jsonschema_validator.py"), 223 | "schema_path": tmp_path.joinpath("allowlist.schema.json"), 224 | } 225 | ) as policy_engine: 226 | # set the policy to enforce 227 | service.server.app.scitt_service.service_parameters["insertPolicy"] = "external" 228 | 229 | if create_flask_app_nop_scitt_scrapi is create_flask_app_notary_identity: 230 | # set the issuer to the SCITT SCRAPI service 231 | issuer = url_to_did_web(service.url) 232 | # use private key from SCITT SCRAPI service to sign 233 | private_key_pem_path = workspace_path.joinpath("storage", "service_private_key.pem") 234 | else: 235 | # set the issuer to the did:web version of the OIDC / SSH keys service 236 | issuer = url_to_did_web(oidc_service.url) 237 | 238 | # create claim 239 | command = [ 240 | "client", 241 | "create-claim", 242 | "--out", 243 | claim_path, 244 | "--issuer", 245 | issuer, 246 | "--subject", 247 | subject, 248 | "--content-type", 249 | content_type, 250 | "--payload", 251 | payload, 252 | "--private-key-pem", 253 | private_key_pem_path, 254 | ] 255 | execute_cli(command) 256 | assert os.path.exists(claim_path) 257 | 258 | # replace example issuer with test OIDC service issuer (URL) in error 259 | claim_denied_error_blocked = copy.deepcopy(CLAIM_DENIED_ERROR_BLOCKED) 260 | claim_denied_error_blocked["detail"] = claim_denied_error_blocked["detail"].replace( 261 | "did:web:denied.example.com", issuer, 262 | ) 263 | 264 | # submit denied claim 265 | command = [ 266 | "client", 267 | "submit-claim", 268 | "--claim", 269 | claim_path, 270 | "--out", 271 | receipt_path, 272 | "--out-entry-id", 273 | entry_id_path, 274 | "--url", 275 | service.url 276 | ] 277 | check_error = None 278 | try: 279 | execute_cli(command) 280 | except ClaimOperationError as error: 281 | check_error = error 282 | assert check_error 283 | assert "error" in check_error.operation 284 | if check_error.operation["error"] != claim_denied_error_blocked: 285 | raise check_error 286 | assert not os.path.exists(receipt_path) 287 | assert not os.path.exists(entry_id_path) 288 | 289 | # replace example issuer with test OIDC service issuer in allowlist 290 | allowlist_schema_json_path = tmp_path.joinpath("allowlist.schema.json") 291 | allowlist_schema_json_path.write_text( 292 | allowlist_schema_json_path.read_text().replace( 293 | "did:web:example.org", issuer, 294 | ) 295 | ) 296 | 297 | # submit accepted claim 298 | command = [ 299 | "client", 300 | "submit-claim", 301 | "--claim", 302 | claim_path, 303 | "--out", 304 | receipt_path, 305 | "--out-entry-id", 306 | entry_id_path, 307 | "--url", 308 | service.url 309 | ] 310 | execute_cli(command) 311 | assert os.path.exists(receipt_path) 312 | receipt_path.unlink() 313 | assert os.path.exists(entry_id_path) 314 | receipt_path.unlink(entry_id_path) 315 | -------------------------------------------------------------------------------- /tests/test_plugin_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) SCITT Authors. 2 | # Licensed under the MIT License. 3 | import os 4 | import textwrap 5 | 6 | from scitt_emulator.plugin_helpers import entrypoint_style_load 7 | 8 | 9 | def test_entrypoint_style_load_relative(tmp_path): 10 | plugin_path = tmp_path / "myplugin.py" 11 | 12 | plugin_path.write_text( 13 | textwrap.dedent( 14 | """ 15 | def my_cool_plugin(): 16 | return "Hello World" 17 | 18 | 19 | class MyCoolClass: 20 | @staticmethod 21 | def my_cool_plugin(): 22 | return my_cool_plugin() 23 | 24 | 25 | my_cool_dict = { 26 | "my_cool_plugin": my_cool_plugin, 27 | } 28 | """, 29 | ) 30 | ) 31 | 32 | for load_within_file in [ 33 | "my_cool_plugin", 34 | "MyCoolClass.my_cool_plugin", 35 | "my_cool_dict.my_cool_plugin", 36 | ]: 37 | plugin_entrypoint_style_path = ( 38 | str(plugin_path.relative_to(tmp_path).with_suffix("")).replace( 39 | os.path.sep, "." 40 | ) 41 | + ":" 42 | + load_within_file 43 | ) 44 | 45 | loaded = list( 46 | entrypoint_style_load(plugin_entrypoint_style_path, relative=tmp_path) 47 | )[0] 48 | 49 | os.chdir(tmp_path) 50 | 51 | loaded = list( 52 | entrypoint_style_load(plugin_entrypoint_style_path, relative=True) 53 | )[0] 54 | 55 | assert loaded() == "Hello World" 56 | --------------------------------------------------------------------------------