diff --git a/.github/workflows/gateway-custody-live.yml b/.github/workflows/gateway-custody-live.yml index af3c8fb9d..13a1a0367 100644 --- a/.github/workflows/gateway-custody-live.yml +++ b/.github/workflows/gateway-custody-live.yml @@ -22,6 +22,7 @@ concurrency: jobs: store-contract: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 environment: gateway-custody-live @@ -89,6 +90,7 @@ jobs: run: rm -f "${RUNNER_TEMP}/custody-identity-token" gateway-journey: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' # A real gateway whose provider secret lives in the production store: # the north-star refund journey, hostile cases included, with the key # installed into Secrets Manager and leased from it for every write. @@ -121,12 +123,14 @@ jobs: working-directory: examples/stripe-refund-approval env: AWS_ROLE_ARN: arn:aws:iam::585985124542:role/auths-gateway-custody-gateway + AUTHS_GATEWAY_RUNTIME_ROLE_ARN: arn:aws:iam::585985124542:role/auths-gateway-custody-runtime NAMESPACE: journey-${{ github.run_id }}-${{ github.run_attempt }} KMS_KEY: arn:aws:kms:eu-west-1:585985124542:key/1e1c85ae-7d9c-4f2d-978f-bd672b597907 run: | set -euo pipefail umask 077 export AWS_WEB_IDENTITY_TOKEN_FILE="${RUNNER_TEMP}/journey-identity-token" + export AUTHS_GATEWAY_RUNTIME_TOKEN_FILE="${AWS_WEB_IDENTITY_TOKEN_FILE}" refresh() { curl --fail-with-body --silent --show-error \ -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ @@ -156,6 +160,7 @@ jobs: retention-days: 30 provider-journey: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' # The same journey against a live provider: Stripe test mode. The gateway # is the default build, with no loopback option, and its restricted test # key lives in Secrets Manager. The run makes one 15.00 test refund and @@ -198,6 +203,7 @@ jobs: env: STRIPE_TEST_RESTRICTED_KEY: ${{ secrets.STRIPE_TEST_KEY }} AWS_ROLE_ARN: arn:aws:iam::585985124542:role/auths-gateway-custody-gateway + AUTHS_GATEWAY_RUNTIME_ROLE_ARN: arn:aws:iam::585985124542:role/auths-gateway-custody-runtime NAMESPACE: provider-${{ github.run_id }}-${{ github.run_attempt }} KMS_KEY: arn:aws:kms:eu-west-1:585985124542:key/1e1c85ae-7d9c-4f2d-978f-bd672b597907 STRIPE_ACCOUNT: acct_1QekbYID70YvJ7mY @@ -207,6 +213,7 @@ jobs: set -euo pipefail umask 077 export AWS_WEB_IDENTITY_TOKEN_FILE="${RUNNER_TEMP}/provider-identity-token" + export AUTHS_GATEWAY_RUNTIME_TOKEN_FILE="${AWS_WEB_IDENTITY_TOKEN_FILE}" refresh() { curl --fail-with-body --silent --show-error \ -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ @@ -241,4 +248,3 @@ jobs: path: target/journey-artifacts/provider-journey-summary.json if-no-files-found: warn retention-days: 30 - diff --git a/.github/workflows/recipe-qualification.yml b/.github/workflows/recipe-qualification.yml index 6ed670176..83dd3a1c4 100644 --- a/.github/workflows/recipe-qualification.yml +++ b/.github/workflows/recipe-qualification.yml @@ -14,6 +14,11 @@ on: pull_request: paths: - "qualification/**" + - "product/runtime/auths-gateway/**" + - "product/qualification/**" + - "examples/stripe-refund-approval/recipe.json" + - "examples/stripe-refund-approval/profile.lock.json" + - "bindings/fixtures/gateway/**" - ".github/workflows/recipe-qualification.yml" permissions: @@ -24,6 +29,181 @@ concurrency: cancel-in-progress: false jobs: + candidate: + name: shipping gateway candidate without credentials + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - name: Build the shipping binaries without simulation features + run: | + cargo build --locked --release -p auths-gateway --bin auths-gateway + cargo build --locked --release -p auths-recipe-qualification-issuance --bin auths-qualification + mkdir -p target/qualification-shipping-candidate/bin + cp target/release/auths-gateway target/release/auths-qualification target/qualification-shipping-candidate/bin/ + - name: Review maintained recipes without installing or leasing custody + run: | + target/release/auths-gateway review \ + --recipe qualification/simulation/live/stripe-platform/recipe.json \ + --profile-lock qualification/simulation/live/stripe-platform/profile.lock.json \ + > target/qualification-shipping-candidate/stripe-review.json + target/release/auths-gateway review \ + --recipe bindings/fixtures/gateway/airtable/recipe.json \ + --profile-lock bindings/fixtures/gateway/airtable/profile.lock.json \ + > target/qualification-shipping-candidate/airtable-review.json + - name: Bind the retained bytes to this source and run + env: + SOURCE_COMMIT: ${{ github.sha }} + SOURCE_RUN: ${{ github.run_id }} + SOURCE_ATTEMPT: ${{ github.run_attempt }} + run: | + python3 - <<'PY' + import hashlib, json, os + from pathlib import Path + root = Path('target/qualification-shipping-candidate') + files = {} + for path in sorted(root.rglob('*')): + if path.is_file(): + files[str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest() + (root / 'candidate.json').write_text(json.dumps({ + 'schema': 'auths.qualification-shipping-candidate/1', + 'source_commit': os.environ['SOURCE_COMMIT'], + 'run_id': os.environ['SOURCE_RUN'], 'run_attempt': os.environ['SOURCE_ATTEMPT'], + 'features': [], 'platform': 'linux-x86_64', + 'qualification_issued': False, 'stable_launch_ready': False, + 'files': files, + }, sort_keys=True, indent=2) + '\n') + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qualification-shipping-candidate-${{ github.run_id }}-${{ github.run_attempt }} + path: target/qualification-shipping-candidate + if-no-files-found: error + retention-days: 30 + + packet-author: + name: installed packet author and shipping native review + needs: candidate + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Build the installed SDK artifact + run: | + python -m pip install maturin==1.9.6 + maturin build --profile python-extension --locked \ + --manifest-path bindings/python/Cargo.toml --out target/qualification-wheels + python -m venv "${RUNNER_TEMP}/packet-consumer" + "${RUNNER_TEMP}/packet-consumer/bin/python" -m pip install target/qualification-wheels/*.whl + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: qualification-shipping-candidate-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/packet-candidate + - name: Run the author without a checkout or provider credential + shell: bash + run: | + set -euo pipefail + kit="${RUNNER_TEMP}/packet-kit" + mkdir -p "${kit}/qualification/simulation/live" "${kit}/bindings/fixtures/gateway" + cp -R qualification/reference "${kit}/qualification/" + cp -R qualification/simulation/live/stripe-platform "${kit}/qualification/simulation/live/" + cp -R bindings/fixtures/gateway/airtable "${kit}/bindings/fixtures/gateway/" + chmod +x "${RUNNER_TEMP}/packet-candidate/bin/auths-gateway" + cd "${RUNNER_TEMP}" + python "${kit}/qualification/reference/check_packets.py" \ + --gateway "${RUNNER_TEMP}/packet-candidate/bin/auths-gateway" \ + --python "${RUNNER_TEMP}/packet-consumer/bin/python" \ + --work "${RUNNER_TEMP}/packet-operator" + - name: Reconnect to the installed author under a separate UID + shell: bash + run: | + set -euo pipefail + sudo -n mkdir -m 0700 "${RUNNER_TEMP}/socket-report" + # The runner's temp ancestors may be private to its login UID. + # Copy only the credential-free kit and installed wheel environment + # into public-readable /opt inputs; keep author output private in /tmp. + sudo -n mkdir -m 0755 /opt/auths-packet-kit /opt/auths-packet-consumer + sudo -n cp -R "${RUNNER_TEMP}/packet-kit/." /opt/auths-packet-kit/ + sudo -n cp -R "${RUNNER_TEMP}/packet-consumer/." /opt/auths-packet-consumer/ + sudo -n chmod -R a+rX /opt/auths-packet-kit /opt/auths-packet-consumer + sudo -n /opt/auths-packet-consumer/bin/python \ + /opt/auths-packet-kit/qualification/reference/check_socket.py \ + --gateway "${RUNNER_TEMP}/packet-candidate/bin/auths-gateway" \ + --python /opt/auths-packet-consumer/bin/python \ + --work "/tmp/auths-socket-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --report "${RUNNER_TEMP}/socket-report/report.json" + sudo -n cp "${RUNNER_TEMP}/socket-report/report.json" "${RUNNER_TEMP}/packet-operator/socket-report.json" + sudo -n chown "$(id -u):$(id -g)" "${RUNNER_TEMP}/packet-operator/socket-report.json" + - name: Exercise the private root controller transport without credentials + shell: bash + run: | + sudo -n /opt/auths-packet-consumer/bin/python -B -m unittest discover \ + -s /opt/auths-packet-kit/qualification/reference/tests -p test_controller_socket.py + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qualification-packet-author-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/packet-operator/report.json + ${{ runner.temp }}/packet-operator/socket-report.json + if-no-files-found: error + retention-days: 30 + + simulation: + name: disposable bootstrap and two provider simulations + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Check the independent resource and request references + run: python -m unittest discover -s qualification/reference/tests + - name: Build the portable simulation harness + run: | + mkdir -p target + cargo test --locked -p auths-gateway --features loopback-provider --lib --no-run --message-format=json > target/qualification-harness-build.jsonl + python qualification/simulation/pack.py --build-report target/qualification-harness-build.jsonl --out target/qualification-candidate-kit + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qualification-simulation-kit-${{ github.run_id }}-${{ github.run_attempt }} + path: target/qualification-candidate-kit + if-no-files-found: error + retention-days: 30 + - name: Run with no checkout, credentials or network + run: | + docker build -t auths-qualification-simulation qualification/simulation + mkdir -p target/qualification-simulation-reports + docker run --rm --network none \ + --mount type=bind,src="$PWD/target/qualification-candidate-kit",dst=/candidate,readonly \ + --mount type=bind,src="$PWD/target/qualification-simulation-reports",dst=/reports \ + auths-qualification-simulation \ + python3 /candidate/run.py --candidate-kit /candidate --out /reports/run + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qualification-simulation-${{ github.run_id }}-${{ github.run_attempt }} + path: target/qualification-simulation-reports/run + if-no-files-found: error + retention-days: 30 + families: name: list the families runs-on: ubuntu-latest @@ -45,7 +225,7 @@ jobs: offline: name: candidate and offline evidence (${{ matrix.family }}) - needs: families + needs: [families, candidate] if: needs.families.outputs.list != '[]' runs-on: ubuntu-latest timeout-minutes: 60 @@ -60,17 +240,39 @@ jobs: - uses: ./.github/actions/setup-rust-cache with: toolchain: 1.97.1 - - name: Build the candidate and run the offline stages + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install the exact SDK wheel outside the checkout + shell: bash + run: | + set -euo pipefail + python -m pip install maturin==1.9.6 + maturin build --profile python-extension --locked \ + --manifest-path bindings/python/Cargo.toml --out target/qualification-offline-wheels + python -m venv "${RUNNER_TEMP}/offline-consumer" + "${RUNNER_TEMP}/offline-consumer/bin/python" -m pip install target/qualification-offline-wheels/*.whl + mkdir -p "${RUNNER_TEMP}/offline-author-kit" + cp qualification/reference/*.py "${RUNNER_TEMP}/offline-author-kit/" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: qualification-shipping-candidate-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/candidate + - name: Run offline stages on the exact shipping candidate shell: bash env: FAMILY: ${{ matrix.family }} - run: qualification/run/offline.sh "${FAMILY}" "${RUNNER_TEMP}/work" - - name: Keep the candidate's binaries with its evidence - shell: bash run: | set -euo pipefail - mkdir -p "${RUNNER_TEMP}/work/bin" - cp target/release/auths-gateway target/release/auths-qualification "${RUNNER_TEMP}/work/bin/" + chmod +x "${RUNNER_TEMP}/candidate/bin/"* + export AUTHS_GATEWAY="${RUNNER_TEMP}/candidate/bin/auths-gateway" + export AUTHS_QUALIFICATION="${RUNNER_TEMP}/candidate/bin/auths-qualification" + export AUTHS_QUALIFICATION_CONSUMER_PYTHON="${RUNNER_TEMP}/offline-consumer/bin/python" + export AUTHS_QUALIFICATION_AUTHOR_KIT="${RUNNER_TEMP}/offline-author-kit" + wheels=("${GITHUB_WORKSPACE}"/target/qualification-offline-wheels/*.whl) + test "${#wheels[@]}" -eq 1 + export AUTHS_QUALIFICATION_WHEEL="${wheels[0]}" + qualification/run/offline.sh "${FAMILY}" "${RUNNER_TEMP}/work" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: offline-${{ matrix.family }} @@ -83,7 +285,7 @@ jobs: # family has its own protected environment holding only that family's # qualification credential, which is not a production credential. name: live evidence (${{ matrix.family }}) - needs: [families, offline] + needs: [families, candidate, offline] if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 90 @@ -108,6 +310,10 @@ jobs: with: name: offline-${{ matrix.family }} path: ${{ runner.temp }}/work + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: qualification-shipping-candidate-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/candidate - name: Run the family's live harness shell: bash env: @@ -115,74 +321,39 @@ jobs: AUTHS_QUALIFICATION_PROVIDER_CREDENTIAL: ${{ secrets.QUALIFICATION_PROVIDER_CREDENTIAL }} run: | set -euo pipefail - chmod +x "${RUNNER_TEMP}/work/bin/"* - export AUTHS_GATEWAY="${RUNNER_TEMP}/work/bin/auths-gateway" + chmod +x "${RUNNER_TEMP}/candidate/bin/"* + export AUTHS_GATEWAY="${RUNNER_TEMP}/candidate/bin/auths-gateway" export AUTHS_QUALIFICATION="${GITHUB_WORKSPACE}/target/release/auths-qualification" - qualification/run/live.sh "${FAMILY}" "${RUNNER_TEMP}/work" - - name: Scan what the run kept, then remove the canaries - # The scan runs here because the canaries exist only here. A failed - # scan fails the job before anything is uploaded. - shell: bash - run: | - set -euo pipefail - export AUTHS_QUALIFICATION="${GITHUB_WORKSPACE}/target/release/auths-qualification" - qualification/run/redact.sh "${RUNNER_TEMP}/work" - test ! -e "${RUNNER_TEMP}/work/canaries" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: live-${{ matrix.family }} - path: ${{ runner.temp }}/work - if-no-files-found: error - retention-days: 30 - - assemble: - name: assemble the record (${{ matrix.family }}) - needs: [families, live] - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: true - matrix: - family: ${{ fromJSON(needs.families.outputs.list) }} - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - uses: ./.github/actions/setup-rust-cache - with: - toolchain: 1.97.1 - - name: Build the release tool from this commit - run: cargo build --locked --release -p auths-recipe-qualification-issuance --bin auths-qualification - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: live-${{ matrix.family }} - path: ${{ runner.temp }}/work - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: offline-${{ matrix.family }} - path: ${{ runner.temp }}/offline - - name: Build the evidence and assemble + bash qualification/run/resource-session.sh "${FAMILY}" "${RUNNER_TEMP}/work" \ + bash qualification/run/live.sh "${FAMILY}" "${RUNNER_TEMP}/work" + - name: Close and scan the final proposal after successful cleanup + # The resource session has exited successfully, including its cleanup. + # Keep the real canaries while rebuilding the complete redaction + # evidence, then scan the actual final proposal before any upload. shell: bash env: FAMILY: ${{ matrix.family }} RUN: ${{ github.run_id }}-${{ github.run_attempt }} run: | set -euo pipefail - export AUTHS_QUALIFICATION="${GITHUB_WORKSPACE}/target/release/auths-qualification" - # The candidate's facts are the ones recorded before any live - # harness ran. - cp "${RUNNER_TEMP}/offline/facts.json" "${RUNNER_TEMP}/work/facts.json" - qualification/run/assemble.sh "${FAMILY}" "${RUNNER_TEMP}/work" \ - "recipe-qualification-live-${FAMILY}" "${RUN}" - mkdir -p "${RUNNER_TEMP}/proposal" - cp -R "${RUNNER_TEMP}/work/proposal/." "${RUNNER_TEMP}/proposal/" + chmod 0700 "${RUNNER_TEMP}/work" + python3 -B qualification/run/close_proposal.py \ + --family "${FAMILY}" --work "${RUNNER_TEMP}/work" \ + --environment "recipe-qualification-live-${FAMILY}" --run "${RUN}" \ + --tool "${GITHUB_WORKSPACE}/target/release/auths-qualification" + test ! -e "${RUNNER_TEMP}/work/canaries" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: proposal-${{ matrix.family }} - path: ${{ runner.temp }}/proposal + path: ${{ runner.temp }}/work/proposal if-no-files-found: error retention-days: 90 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qualification-operator-report-${{ matrix.family }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/work + if-no-files-found: error + retention-days: 30 sign: # The only job that holds the release signer's key. It waits for a @@ -190,7 +361,7 @@ jobs: # evidence closure, attests each record, and signs one index listing # exactly this run's records. It signs nothing else. name: sign the release - needs: assemble + needs: live if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.gitleaksignore b/.gitleaksignore index f02eec401..ef57bf711 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -72,3 +72,13 @@ af4791caed6a8cb6fc37313f9cacfe52bc0e2394:product/integrations/auths-stripe/fixtu # generator now names the key "commit" and writes digests as "sha256:". d92064d919b7c2498ce62588ff48a82284f0bcd7:formal/proof-coverage-v1.json:generic-api-key:9 d92064d919b7c2498ce62588ff48a82284f0bcd7:formal/proof-coverage-v1.json:generic-api-key:84 + +# Public metadata in signed development-rehearsal evidence (7 October 2026). +# key_sha256 is hex::encode(AttemptKey), the SHA-256 of public attempt identity; +# support.rs never exports provider credentials. Preserve the exact signed-run +# support bytes; these exceptions cover only the named historical fingerprints. +8b6f96e7e43dacf5b7f237cfc015f84a513a2bd1:qualification/simulation/evidence/airtable-live-2026-10-07/support-bundle.json:generic-api-key:1 +3e9704b5d4d12bc654fc48de9b03ab6adb9fcb41:qualification/simulation/evidence/stripe-platform-live-2026-10-07/support-bundle.json:generic-api-key:1 +# Ordinary progress prose triggered the generic key rule. The current prose +# avoids the ambiguous wording; no credential occurred in the historical line. +3e9704b5d4d12bc654fc48de9b03ab6adb9fcb41:docs/PROGRAM_BOARD.md:generic-api-key:477 diff --git a/Cargo.lock b/Cargo.lock index 1ad02cb1b..aca959af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7405,6 +7405,7 @@ dependencies = [ "auths-did-key", "auths-did-web", "auths-errors", + "auths-gateway", "auths-github", "auths-hsm-attested", "auths-kubernetes", @@ -7419,6 +7420,7 @@ dependencies = [ "auths-radicle", "auths-raw-key", "auths-receipts", + "auths-recipe-qualification", "auths-records-api", "auths-registries", "auths-signature", diff --git a/architecture/dependency-graph.dot b/architecture/dependency-graph.dot index b866cf197..0ff3ff544 100644 --- a/architecture/dependency-graph.dot +++ b/architecture/dependency-graph.dot @@ -763,6 +763,7 @@ digraph auths_architecture { "xtask" -> "auths-did-key" [label="normal"]; "xtask" -> "auths-did-web" [label="normal"]; "xtask" -> "auths-errors" [label="normal"]; + "xtask" -> "auths-gateway" [label="normal"]; "xtask" -> "auths-github" [label="normal"]; "xtask" -> "auths-hsm-attested" [label="normal"]; "xtask" -> "auths-kubernetes" [label="normal"]; @@ -777,6 +778,7 @@ digraph auths_architecture { "xtask" -> "auths-radicle" [label="normal"]; "xtask" -> "auths-raw-key" [label="normal"]; "xtask" -> "auths-receipts" [label="normal"]; + "xtask" -> "auths-recipe-qualification" [label="normal"]; "xtask" -> "auths-records-api" [label="normal"]; "xtask" -> "auths-registries" [label="normal"]; "xtask" -> "auths-signature" [label="normal"]; diff --git a/architecture/dependency-graph.json b/architecture/dependency-graph.json index 3dfcf370d..6ee389cb2 100644 --- a/architecture/dependency-graph.json +++ b/architecture/dependency-graph.json @@ -16991,6 +16991,18 @@ "std" ] }, + { + "source": "xtask", + "source_layer": "tooling", + "target": "auths-gateway", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, { "source": "xtask", "source_layer": "tooling", @@ -17169,6 +17181,18 @@ "default_features": true, "features": [] }, + { + "source": "xtask", + "source_layer": "tooling", + "target": "auths-recipe-qualification", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, { "source": "xtask", "source_layer": "tooling", diff --git a/bindings/fixtures/gateway/production-codes.json b/bindings/fixtures/gateway/production-codes.json index 41c40edaf..2933726c8 100644 --- a/bindings/fixtures/gateway/production-codes.json +++ b/bindings/fixtures/gateway/production-codes.json @@ -20,6 +20,110 @@ "id": "lease-never-precedes-claim" } }, + { + "code": "gateway.commissioning.binding-mismatch", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.clock-untrusted", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.contention", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.exhausted", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.expired", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.registration-missing", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.restore-rollback", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.revocation-rollback", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.revocation-stale", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.revoked", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.state-corrupt", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.store-unavailable", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, + { + "code": "gateway.commissioning.unavailable", + "owner": "commissioning-budget", + "stage": "before-custody", + "status": "implemented", + "epic": 5, + "case": null + }, { "code": "gateway.connection.credential-generation-missing", "owner": "engine", diff --git a/bindings/fixtures/qualification/commissioning-v2.json b/bindings/fixtures/qualification/commissioning-v2.json new file mode 100644 index 000000000..5f5a48043 --- /dev/null +++ b/bindings/fixtures/qualification/commissioning-v2.json @@ -0,0 +1,18 @@ +{ + "schema": "auths.qualification-commissioning-vectors/2", + "synthetic": true, + "trust_root": "{\"public_key_b64\":\"0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc\",\"root_id\":\"test-root\",\"schema\":\"auths.qualification-trust-root/1\",\"signature_suite\":\"ed25519-v1\"}", + "signer_certificate": "{\"root_signature_b64\":\"CbNmJUTovHK87FHlPhOC2TtvjfxmR5FHDvg4EAsNSjLR5bjqqG4zBOhDwbwXlNN97La1kMEVU57h3ApQVI_MCg\",\"statement\":{\"issued_at\":1789913600,\"not_after\":1790086400,\"not_before\":1789913600,\"permitted_artifact_kinds\":[\"qualification-commissioning-permit\"],\"public_key_b64\":\"oJql9HpnWYAv-VX43C0qFKXJnSO-l_hkEn_5ODRVpPA\",\"root_id\":\"test-root\",\"schema\":\"auths.qualification-signer-certificate/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"test-commissioner\",\"signer_kind\":\"protected-software-release-key-v1\"}}", + "revocation_list": "{\"root_signature_b64\":\"sAtYxcGQsy05npek7vQrBcpqiHKWqcEV-g4EowFJhxs4guxt73ual7FEngULuO3H15qIZLIy4HrbJdyISzJVAA\",\"statement\":{\"issued_at\":1789996400,\"next_update\":1790086400,\"revoked_qualifications\":[],\"revoked_signers\":[],\"root_id\":\"test-root\",\"schema\":\"auths.qualification-revocation-list/1\",\"sequence\":1}}", + "permit": "{\"signature_b64\":\"0nrpp-Hm34Xnj5fGodRMJJDp6ifsNFCvFJ4M3j7aS9Jz73Y7HkXjYi1GfhzQWLTh6tRTP-VhzSHoukbmeTN6Bg\",\"statement\":{\"binding\":{\"allowed_actions\":[\"6363636363636363636363636363636363636363636363636363636363636363\",\"6464646464646464646464646464646464646464646464646464646464646464\"],\"maximum_credential_leases\":32,\"offline_evidence\":{\"conformance_sha256\":\"8749fb03dc89fe583529f59c613c78319e893898118092c61344812be78d1b5c\",\"differential_sha256\":\"5f22f2e011050cbabf1f58bdf92a2912fa489353309b594b4db327865e82cd7f\"},\"principal_sha256\":\"6060606060606060606060606060606060606060606060606060606060606060\",\"protected_run\":\"github.com/auths-dev/auths-proof/actions/runs/1/attempts/1\",\"provider_environment_class\":\"provider-test-mode\",\"resources_sha256\":\"6262626262626262626262626262626262626262626262626262626262626262\",\"source_commit\":\"0123456789abcdef0123456789abcdef01234567\",\"trusted_contexts_sha256\":[\"6161616161616161616161616161616161616161616161616161616161616161\",\"6565656565656565656565656565656565656565656565656565656565656565\"],\"tuple\":{\"compiled_recipe_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"gateway_semantic_closure_sha256\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"profile_lock_sha256\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"provider_contract_id\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.gateway-store/1\"}}},\"issued_at\":1790000000,\"not_after\":1790007200,\"not_before\":1790000000,\"schema\":\"auths.qualification-commissioning-permit/2\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"test-commissioner\"}}", + "permit_digest": "6023cdfdc1af437745f506196bfb853dc10e9d9e53be86337d230a8934a44480", + "budget_scope_sha256": "01640eb7b447d8b909e6955a186f95e3c7bc6765174ee23d8b3dbd0db1c845e5", + "budget_binding_sha256": "d6782163617e1f1aa54cd272fb776fc70426a90fade59718b24306cbfe2275c0", + "limits": { + "actions": 256, + "contexts": 4, + "leases": 1024, + "seconds": 7200, + "bytes": 32768 + } +} diff --git a/bindings/fixtures/qualification/schema-vectors.json b/bindings/fixtures/qualification/schema-vectors.json index ebf79327e..f8db5468e 100644 --- a/bindings/fixtures/qualification/schema-vectors.json +++ b/bindings/fixtures/qualification/schema-vectors.json @@ -23,9 +23,9 @@ "trust_root": "{\"public_key_b64\":\"0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc\",\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-trust-root/1\",\"signature_suite\":\"ed25519-v1\"}", "signer_certificate": "{\"root_signature_b64\":\"obGs_sPHvOXfTu8emdHu3odSbdq3PEW0XwoKhrOSb1LQTgq9LOvFPrRcuhE6Tis01Ac9ndvtGQiey6UWh0MmBg\",\"statement\":{\"issued_at\":1789913600,\"not_after\":1805465600,\"not_before\":1789913600,\"permitted_artifact_kinds\":[\"qualification-release-index\",\"recipe-qualification-attestation\"],\"public_key_b64\":\"oJql9HpnWYAv-VX43C0qFKXJnSO-l_hkEn_5ODRVpPA\",\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-signer-certificate/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\",\"signer_kind\":\"protected-software-release-key-v1\"}}", "revocation_list": "{\"root_signature_b64\":\"nVaEVMHFGD_kiB0SIdz3uK3Xh0YlYrUJY-PRkV7_sCAc6mGs39yQxxGsiShFyuVrE_NxGHNnX40Lg6mi_w_hAw\",\"statement\":{\"issued_at\":1789996400,\"next_update\":1790255600,\"revoked_qualifications\":[],\"revoked_signers\":[],\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-revocation-list/1\",\"sequence\":7}}", - "release_index": "{\"signature_b64\":\"QMMwlBk-GT36P5vZFGmqwVaI6dSlhiDIy6b69926s_4UkaNLW8VDkrCtAQtedDPG_B7Mwa7vteWiAtHHGJSuCQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", - "record": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}", - "attestation": "{\"signature_b64\":\"oR2QsmEvCp-fGvV0ysEM-a3opOp6SlgJzb63U4ESuZz0yRPlyoOK39PhRELjaiaMzpiRyj-LVUmnKkd3pvFNAQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"qLnnKRCjNPMQ4E4htzwihMF_RcLGUtZ-QpmUc0xvAf9So4UwLqsqnQ8iE7KQDBPiDDEaT2tXMdXo8UJx7BKoCw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "record": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}", + "attestation": "{\"signature_b64\":\"RNvz-GVoozTJulLOMogzaDjwNMmNJIciII7dGyYiqv7CLXqD7OeqqnmO4COD_-YI7CtUTuT50ARNLDOYx6HOAw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "provider_contract": "{\"api_release\":\"2026-09-01.fixture\",\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"declarations\":{\"idempotency_sha256\":\"d6ee51b4b1b7adbf404892f9326dd33a8d42047c0d7592aa8366ad4fcf99a300\",\"observation_sha256\":\"41eecb260924ac4b305313888b59325a24161b0c9d60ecda92ab26bc10e3fbf5\",\"recovery_sha256\":\"871f9dded4942ff0262f147ca7fd4992b38da2741bac401abf3cd8dc43d4d2d3\",\"retention_sha256\":\"5e783fc5c0cf8950b44fd3095ff0679df34660600d0e7a59cedb2084e850aec7\"},\"environment_class\":\"provider-test-mode\",\"manual_assumptions\":[\"a refund of a refunded payment is rejected, not repeated\",\"the idempotency key is honored for at least 24 hours\"],\"openapi_slice_sha256\":\"b11c8865e81ede17d33a255a677549fa87ba045c67077495b8749fc5455c0b2f\",\"oracle_version\":\"oracle-1\",\"provider\":\"example-payments\",\"schema\":\"auths.provider-contract/1\"}" }, "provider_contract_id": "f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d", @@ -511,21 +511,21 @@ "artifact": "record", "class": "non-canonical", "reason": "non-canonical", - "text": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}\n" + "text": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}\n" }, { "id": "record-exponent-time", "artifact": "record", "class": "non-canonical", "reason": "malformed", - "text": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1.7899928e9,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}" + "text": "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1.7899928e9,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}" }, { "id": "record-repeated-member", "artifact": "record", "class": "non-canonical", "reason": "malformed", - "text": "{\"schema\":\"auths.recipe-qualification/1\",\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}" + "text": "{\"schema\":\"auths.recipe-qualification/1\",\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}" }, { "id": "certificate-hardware-signer-kind", @@ -833,14 +833,14 @@ "pointer": "/statement/entries", "value": [ { - "attestation_sha256": "70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd", + "attestation_sha256": "00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82", "qualification_id": "qlf_01010101010101010101010101010101", - "record_sha256": "d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306" + "record_sha256": "e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a" }, { - "attestation_sha256": "70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd", + "attestation_sha256": "00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82", "qualification_id": "qlf_01010101010101010101010101010101", - "record_sha256": "d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306" + "record_sha256": "e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a" } ] } @@ -858,9 +858,9 @@ "count": 257, "width": 32, "template": { - "attestation_sha256": "70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd", + "attestation_sha256": "00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82", "qualification_id": "qlf_{}", - "record_sha256": "d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306" + "record_sha256": "e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a" } } ] diff --git a/bindings/fixtures/qualification/verification-vectors.json b/bindings/fixtures/qualification/verification-vectors.json index 080ae4666..62d5ad446 100644 --- a/bindings/fixtures/qualification/verification-vectors.json +++ b/bindings/fixtures/qualification/verification-vectors.json @@ -50,12 +50,12 @@ "trust_root": "{\"public_key_b64\":\"0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc\",\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-trust-root/1\",\"signature_suite\":\"ed25519-v1\"}", "signer_certificate": "{\"root_signature_b64\":\"obGs_sPHvOXfTu8emdHu3odSbdq3PEW0XwoKhrOSb1LQTgq9LOvFPrRcuhE6Tis01Ac9ndvtGQiey6UWh0MmBg\",\"statement\":{\"issued_at\":1789913600,\"not_after\":1805465600,\"not_before\":1789913600,\"permitted_artifact_kinds\":[\"qualification-release-index\",\"recipe-qualification-attestation\"],\"public_key_b64\":\"oJql9HpnWYAv-VX43C0qFKXJnSO-l_hkEn_5ODRVpPA\",\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-signer-certificate/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\",\"signer_kind\":\"protected-software-release-key-v1\"}}", "revocation_list": "{\"root_signature_b64\":\"nVaEVMHFGD_kiB0SIdz3uK3Xh0YlYrUJY-PRkV7_sCAc6mGs39yQxxGsiShFyuVrE_NxGHNnX40Lg6mi_w_hAw\",\"statement\":{\"issued_at\":1789996400,\"next_update\":1790255600,\"revoked_qualifications\":[],\"revoked_signers\":[],\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-revocation-list/1\",\"sequence\":7}}", - "release_index": "{\"signature_b64\":\"QMMwlBk-GT36P5vZFGmqwVaI6dSlhiDIy6b69926s_4UkaNLW8VDkrCtAQtedDPG_B7Mwa7vteWiAtHHGJSuCQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"qLnnKRCjNPMQ4E4htzwihMF_RcLGUtZ-QpmUc0xvAf9So4UwLqsqnQ8iE7KQDBPiDDEaT2tXMdXo8UJx7BKoCw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "records": [ - "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}" + "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}" ], "attestations": [ - "{\"signature_b64\":\"oR2QsmEvCp-fGvV0ysEM-a3opOp6SlgJzb63U4ESuZz0yRPlyoOK39PhRELjaiaMzpiRyj-LVUmnKkd3pvFNAQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"RNvz-GVoozTJulLOMogzaDjwNMmNJIciII7dGyYiqv7CLXqD7OeqqnmO4COD_-YI7CtUTuT50ARNLDOYx6HOAw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], "deployment": { "recipe_family": "example-refund-v1", @@ -70,7 +70,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -91,14 +91,14 @@ "class": "valid", "replace": { "records": [ - "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}", - "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"aarch64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}" + "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}", + "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1797768800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"aarch64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}" ], "attestations": [ - "{\"signature_b64\":\"oR2QsmEvCp-fGvV0ysEM-a3opOp6SlgJzb63U4ESuZz0yRPlyoOK39PhRELjaiaMzpiRyj-LVUmnKkd3pvFNAQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", - "{\"signature_b64\":\"VOQpMz4m_0eCYsoJt-BDNjXjyrWLzp_PvTl9H4nbNWd7nyWYQK_kkS2_aCa_ciuRacd9nsJI5UDYgQT-dITpAg\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"record_sha256\":\"6274966a6fb4aa4b9bd77755cc8089e6a00d5e483fef57b4bb454af91270a2c7\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"RNvz-GVoozTJulLOMogzaDjwNMmNJIciII7dGyYiqv7CLXqD7OeqqnmO4COD_-YI7CtUTuT50ARNLDOYx6HOAw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "{\"signature_b64\":\"1Hd7wDEt6bzoXwrcguCBXtcDy32fmhJRgKZVIG5iyFw-vVzLFAE06rP-p786NrT5nTepTF9DXwURduAeTvHSBQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"record_sha256\":\"f4eee7e4a2af6f48d64102f768a9e056cf340d9be38cafde6808d8455785b7b7\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"DkU0HaXav14R6WlF4e_qqdpmZ5NiHonJUYtG3LeaHkPs-YLA59c80b0Xorrgmr5-T5mJ_mRfP-7oUIV2RxOwAQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"},{\"attestation_sha256\":\"86a6d76fa26713a1d1eee02cc60efd911ade5d73643e0d5b0adb1d5c7805c736\",\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"record_sha256\":\"6274966a6fb4aa4b9bd77755cc8089e6a00d5e483fef57b4bb454af91270a2c7\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"rrIlocaWjnu0yGZqBctFItRCwBdsQACIBWypqxI4-DaRasCgyU0x354oFpqZT0HTcDQyh1cds1BrtMoslrhnDg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"},{\"attestation_sha256\":\"07660bc891d054fb560be8027aa9d74f489f74a347ede9818f826df7d42711a2\",\"qualification_id\":\"qlf_02020202020202020202020202020202\",\"record_sha256\":\"f4eee7e4a2af6f48d64102f768a9e056cf340d9be38cafde6808d8455785b7b7\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "deployment": { "recipe_family": "example-refund-v1", "compiled_recipe_sha256": "262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef", @@ -112,7 +112,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -154,9 +154,9 @@ "class": "forged", "replace": { "attestations": [ - "{\"signature_b64\":\"oB2QsmEvCp-fGvV0ysEM-a3opOp6SlgJzb63U4ESuZz0yRPlyoOK39PhRELjaiaMzpiRyj-LVUmnKkd3pvFNAQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"Rdvz-GVoozTJulLOMogzaDjwNMmNJIciII7dGyYiqv7CLXqD7OeqqnmO4COD_-YI7CtUTuT50ARNLDOYx6HOAw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"Q4kNLqaLBUocWfqElcoBn5LnR-hjcX5SizgJ7o4IoFgu7qlsgGHWscDv33JbLnJPtfkpS-FekbA9uyB7HZfYCA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"f0ad9a6e35cd8d1d7e7388dcb016596388cabfb7c3658a60e6283e03a314d0c1\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"CA6xsrEbfeBdNhtmWqR01vTD8Rh4tqH9xiKORfNaFa-URL_hS5mF3RqUA-6imag1YMvDcUEU2QLfqsmmu3dpBg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"53e358591892ff3a4733a7c685b5fcc5f36df1073c9813059f217ff28191fe18\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -169,7 +169,7 @@ "class": "forged", "replace": { "attestations": [ - "{\"signature_b64\":\"R1FKEhfbdJAcP8kb7xLiGwN9hhSHMedMtBB9toHgSqSXBW0OlAUFlAQTnvHmknYXPAFOghohkfPMw4jvYG-WCQ\",\"statement\":{\"issued_at\":1789992801,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"Byp9otAfoNdjUbZD0Fa0L3phBk8rgRCuRd4OwQdt0ja_anYDYlGYW9tBXAXCv6IX5uRWK_m5qh4RbJKQeHDaDA\",\"statement\":{\"issued_at\":1789992801,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ] }, "expect": { @@ -183,9 +183,9 @@ "class": "forged", "replace": { "attestations": [ - "{\"signature_b64\":\"tzcnurCZt_J1H8Fl3TWa5iHZUjpsmYzQIJK61c72CyZ4jSfAsEAYCWVXCASF7zbmHdsCLu6NE4vJ0-hUmAK3DQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"xrJ1DFsrecZJ1w6RIfbOj1yQ9l-ZcrDRqHPZYP_Cqo1kj9G1Rc2FTRB6O05b_znhal3O9knMDlDpey-_hC4vBg\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"Z3UjHuk0P1vXwp3sdRugtrOMMqphXGjTUM0z84-x_sT8FcwD1lFl02AXCEHk3rVBIWsgYolMjd90Ixr3_IgLAA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"6f0acc99bc26bd63fad9cecd15cfcb3d1986b2a92caea30d5955c10997a67d2c\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"_5fxI4CUWG1x39gELCP_b6Tgh0TH_Slt2K9dd6PdCt8Hp7rMB9GLSmqIBCbVYxrZP2wE5RbXaIrZPmzmrt0OAg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"8a24fea63afbe047524b68b394f957b8530a6fbbc78ad4defec2d39dd2a4de80\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -197,7 +197,7 @@ "id": "index-signature-forged", "class": "forged", "replace": { - "release_index": "{\"signature_b64\":\"QcMwlBk-GT36P5vZFGmqwVaI6dSlhiDIy6b69926s_4UkaNLW8VDkrCtAQtedDPG_B7Mwa7vteWiAtHHGJSuCQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"qbnnKRCjNPMQ4E4htzwihMF_RcLGUtZ-QpmUc0xvAf9So4UwLqsqnQ8iE7KQDBPiDDEaT2tXMdXo8UJx7BKoCw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "unqualified", @@ -209,7 +209,7 @@ "id": "index-signed-by-outsider", "class": "forged", "replace": { - "release_index": "{\"signature_b64\":\"-nPEtyUDG04UApIHZlHnf5R1lKwJU8hGCHRJKEKeFb-ncon0AbR0dylkcpWL6ZuWqhTY5bPxxy42-TBTis5oAg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"1keFTCfs9mQxuQntTNOv_W9vqciC-oCRxxlVQrP2XpM23XoFBpxPEVPX25YGxZxGfByqeTTjvrzqEt8x5M0HCQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "unqualified", @@ -294,9 +294,9 @@ "class": "forged", "replace": { "attestations": [ - "{\"signature_b64\":\"V5BF5MUPBjm5Ub5-lzccpMHE0xVTpIri4atA0L1fadeyRO8Hsdp3VYzGfEzvUFjup2xN1Ux3RjI7Qfl4UnDhCQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"another-signer\"}}" + "{\"signature_b64\":\"In_Pz4oSQhgTnbrScTBPACd3JNgcw7VDY4468cZMWvsjA-pzYcdCC_SGJHjaHn_LZ4FjVxd4Vv_8EOL41c4hDw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"another-signer\"}}" ], - "release_index": "{\"signature_b64\":\"CXyGKmB8x1kZA5ZR6UuoGTRfNORjBP1mGycIBj65v_NLaczM4IGAoZgSamxaEnUxOxdf8gJ3TcuDLrL0SZM1Bw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"894dd22628c540c13068386485c8fc9b6d104bb4b59906f6246519f4e61cd615\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"PU3HBBhvW9glWplV0oZMyXhF56jPQagnXK9dXP_9IPNHLkNerKW02skNwEVq-YYiq5XZJb8wqsRYw5d32EBmCg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"b845e328d0333fefb98b407f35e2e7e0f23ad4cdcd2855f60ba858dc16c93b13\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -311,7 +311,7 @@ "attestations": [ "{\"signature_b64\":\"dU8UPdRvQ6Xr6xCtRvpnj5HT5oD_oPcTI1sc3fnHTzNL-eenyjjdJOq0-0wBZicAH4uVvsJV2LmcnPmcRaXXBQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"1bc3a22a5bba0537b35bcb4db6ec7a29fafb9deae8f5f964919628c38654c42d\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"0J5IWbuqUb1iNmNmbdPpIUTc_HcvcnJdhPOJaZnqTbP4G9wSmeqTX6U9Or3zZQKWg6L8bbylelLDYmLdwiQtDw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"b349ee3d88adb475afa1ffabc89b76c010c1d890cc3ccf8ebe282f0e7cdece3f\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"C_W4OzY5dDjLR_EZAJYZqTKSJVJgs233eInKoc-aMIEPgO202P8lpu5echZJ7Yboh-_xZOegNojFccXqf2d-AQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"b349ee3d88adb475afa1ffabc89b76c010c1d890cc3ccf8ebe282f0e7cdece3f\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -324,12 +324,12 @@ "class": "widened-time-bound", "replace": { "records": [ - "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1795176800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/5\"}}}" + "{\"capabilities\":[{\"capability\":\"credential-guard\",\"result\":\"exercised\"},{\"capability\":\"version-pin\",\"result\":\"exercised\"},{\"capability\":\"account-binding\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"},{\"capability\":\"denied-reads\",\"result\":\"exercised\"},{\"capability\":\"ceiling\",\"result\":\"exercised\"},{\"capability\":\"budget\",\"result\":\"exercised\"},{\"capability\":\"idempotency\",\"result\":\"exercised\"},{\"capability\":\"response-locator\",\"result\":\"exercised\"},{\"capability\":\"echo\",\"result\":\"exercised\"},{\"capability\":\"observation\",\"result\":\"exercised\"},{\"capability\":\"recovery\",\"result\":\"exercised\"},{\"capability\":\"observer-rotation\",\"reason\":\"the qualified recipe and target declare none\",\"result\":\"not-applicable\"}],\"corpus_manifest_sha256\":\"d6c1cad9a6b0fe0fc27d1f8107c2381e8b9ab1bbfc04a47aa8234140de74a23f\",\"custody_descriptor\":\"aws-secrets-manager-v1 with workload identity\",\"evidence\":[{\"cases\":12,\"evidence_sha256\":\"c3a2a799eb04b1ee53a22495e0d95171a9f42f2ad557b639f3fe5440bbed90e7\",\"member\":\"conformance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"dfd5b5dccc73cae91abec7a76c332dae145eb37eddb18f56dca2e68f2dbc81ab\",\"member\":\"differential\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"a05d5182b4f20eb67c0d95622a70f1e4ac87d1d1b8b7fab219a2276b6da41256\",\"member\":\"hostile\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"00beb3d9e44f8d20d5da8861818ecd7b430714f959a6613dbd2d870997eb90f8\",\"member\":\"live\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"3a4e01d7b8d11480e334d0d2fffc2efeeaf2363d21f9107094c3cfc3c063b362\",\"member\":\"recovery\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"6b428d653e06c75268e2e33f234f30f9cc24616c42faf551699f7a7ac2376011\",\"member\":\"rotation\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"e8713a54bfb187836d4db2f34f181129f2e03f7221bc2d3247134470adcacee8\",\"member\":\"restart\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8824a7669543ecf652b651fa2d370c548e09b73c1babac841cd689389a1fdcbe\",\"member\":\"multi-instance\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"f31f29dc30a4e8ccd3aea69c012656c2811ac194881147b2cced5dd7dc1a7d9b\",\"member\":\"redaction\",\"result\":\"passed\",\"unauthorized_provider_entries\":0},{\"cases\":12,\"evidence_sha256\":\"8e91fde286bdbb80e6d6245e7ab9dab68f92e790922170195fc80aab2eeb227b\",\"member\":\"installed-consumer\",\"result\":\"passed\",\"unauthorized_provider_entries\":0}],\"excluded_claims\":[\"the provider performed or settled the business effect\"],\"generated_artifacts_sha256\":\"fff3ece48e4f5271825e80b355a1243a4c30619329bbbc70bc4e5c1b242973e2\",\"installed_packages\":[{\"name\":\"auths-gateway\",\"sha256\":\"8a58d99c69c098e53c2197df2c6a6a16b131c69798f728db134c978f77e95360\",\"version\":\"1.0.0-rc.1\"},{\"name\":\"auths-python\",\"sha256\":\"59b028e3e66e681bfa195fb6afee775b91f30fc418f05f86353bc71cd021d2f9\",\"version\":\"1.0.0rc1\"}],\"live_effects\":{\"confirmed_by_read_back\":4,\"entered\":4},\"not_after\":1795176800,\"not_before\":1789992800,\"provenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"environment\":\"recipe-qualification\",\"repository\":\"github.com/auths-dev/auths-proof\",\"workflow\":\".github/workflows/recipe-qualification.yml\"},\"provider_kind\":\"example-payments\",\"provider_resources\":[\"refund:fixture-0001\",\"refund:fixture-0002\"],\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"recipe_decision_record_sha256\":\"0c2328dfcdc21fcd917fd242dd16f9adf1354395a472e589e2778dee99527081\",\"residual_assumptions\":[\"the provider keeps its test mode separate from live data\"],\"schema\":\"auths.recipe-qualification/1\",\"source_closure_sha256\":\"00023b3a6fbf90b531085365646cda2ffcac182f6eaedf453eb680b63ae33106\",\"store_descriptor\":\"postgresql 16 with TLS\",\"tuple\":{\"compiled_recipe_sha256\":\"262f5ef11b349645f2421c533220fc6bbfd70c4a5395f5d65c601406eba7d4ef\",\"gateway_semantic_closure_sha256\":\"01ec13346b098e4f5e1c64a995a50ce15ef339dc1f304966a45c94e80638203b\",\"profile_lock_sha256\":\"4dd8982feedb83403f985de3e1a952a90060e44035ab797e5313ff93957a22d5\",\"provider_contract_id\":\"f15f154a38de58de1b27c70c61a6b20fe532746e2e4462576dbd3d27d76cce3d\",\"recipe_family\":\"example-refund-v1\",\"target\":{\"arch\":\"x86_64\",\"credential_store_kind\":\"aws-secrets-manager-v1\",\"gateway_build_sha256\":\"42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3\",\"gateway_package\":\"auths-gateway\",\"gateway_version\":\"1.0.0-rc.1\",\"os\":\"linux\",\"store_kind\":\"postgresql-v1\",\"store_schema\":\"auths.lifecycle.postgresql/6\"}}}" ], "attestations": [ - "{\"signature_b64\":\"Ia1VvfmqFwQ0O85MZACdtP4fh44y578C1Mq0yHD2j7rog2-txQvDKQ_Wu8uULzx7Qr-xWOck57mB6mzqNgLKCA\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e583292fbd3825d1086ba3c2d947266c028d90d3ddaf9403508d608d8e0b58bd\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"llV5lBszVwmOYM3A6diwpACMWJ5B9H9sFvFfnthdn43G-Vv3jlMPEW1L6opboExmUFvpLvVrX85k5sh-y7mWDw\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"10cc5b9573f799fbe2edb24ca401950b782e399001276c2a6b010040154499dc\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"5rQjGHQ6GOcaSBbqGyNv0jABP3vEhVMEMyQZp7G3oKoVvLyGqbumsZzXyYiuA-I9EZojuc7RczF57DMOzjFGCw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"68682e0648cdc765e36a4baf88d5503dfa0a5a463b06cda441404120bb5125b0\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e583292fbd3825d1086ba3c2d947266c028d90d3ddaf9403508d608d8e0b58bd\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"02dWO040SWUrdjFYAEGatWBeF4ISg4xINKvFWUG-q1lY2nN-pHRhyC4-qEib7YaWeSLuvfOFFr5CXuiyCkg8DQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"10dfac2b1176c1a877f5f2cba5ced11a6f2e38f1d634c3d7f764e1651997d568\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"10cc5b9573f799fbe2edb24ca401950b782e399001276c2a6b010040154499dc\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -342,9 +342,9 @@ "class": "not-yet-valid", "replace": { "attestations": [ - "{\"signature_b64\":\"Gsrdvv__DJeV8jD_wIXrkKcxgAGGyCBbDvvI7oNWcpMogBOaoa4Wi0MQg9-gX8WUiuZUMKh-Mpb_V7IXFnn5DQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1792595600,\"not_before\":1790003600,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"xhMqiwUs1yCbR0ebhIO4hWPvROOx8HJVvQiAco5fKOF54wRuuJGH6hGsiNdbNPB4PdzsVE4CP8Ufv57b5fl8AQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1792595600,\"not_before\":1790003600,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"oujpkbId3SweB5yGLpxYF9rBhcNQVQyJwZY5Q5dIlhwmckTllgQS_1IJckUZOguE5n3vJHoDDF-O7t_EZwJQAg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"b54ecb7102d68cb90db11b02722bd3adf6d79250626fd99b2957e461f1c0f911\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"Ue5Ra0u17IlVF3otp4TbGtiJgnHSeHUx-RMC-EtR__PKqKA0Gtli-OFtmufJCwiPlK2MNgUGU1gh2KbbTLthBg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"0ec2dfd894f7b1809e61d1e9c86a67ad61e83cea5bc7924d12d1dcfc7693f15b\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "candidate", @@ -368,7 +368,7 @@ "id": "unusable-index-before-revocation", "class": "precedence", "replace": { - "release_index": "{\"signature_b64\":\"QcMwlBk-GT36P5vZFGmqwVaI6dSlhiDIy6b69926s_4UkaNLW8VDkrCtAQtedDPG_B7Mwa7vteWiAtHHGJSuCQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"qbnnKRCjNPMQ4E4htzwihMF_RcLGUtZ-QpmUc0xvAf9So4UwLqsqnQ8iE7KQDBPiDDEaT2tXMdXo8UJx7BKoCw\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "revocation_list": "{\"root_signature_b64\":\"Oe8aY9Lt91XEvAJ86JGwNpAUwVl5WGvXfP3xvBIraeDWf8B0m0OPMVVmAp5WqglK8RDodbQ9g8CfDgAJI3coBg\",\"statement\":{\"issued_at\":1789996400,\"next_update\":1790255600,\"revoked_qualifications\":[\"qlf_01010101010101010101010101010101\"],\"revoked_signers\":[],\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-revocation-list/1\",\"sequence\":7}}" }, "expect": { @@ -426,9 +426,9 @@ "class": "precedence", "replace": { "attestations": [ - "{\"signature_b64\":\"tzcnurCZt_J1H8Fl3TWa5iHZUjpsmYzQIJK61c72CyZ4jSfAsEAYCWVXCASF7zbmHdsCLu6NE4vJ0-hUmAK3DQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"xrJ1DFsrecZJ1w6RIfbOj1yQ9l-ZcrDRqHPZYP_Cqo1kj9G1Rc2FTRB6O05b_znhal3O9knMDlDpey-_hC4vBg\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"Z3UjHuk0P1vXwp3sdRugtrOMMqphXGjTUM0z84-x_sT8FcwD1lFl02AXCEHk3rVBIWsgYolMjd90Ixr3_IgLAA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"6f0acc99bc26bd63fad9cecd15cfcb3d1986b2a92caea30d5955c10997a67d2c\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"_5fxI4CUWG1x39gELCP_b6Tgh0TH_Slt2K9dd6PdCt8Hp7rMB9GLSmqIBCbVYxrZP2wE5RbXaIrZPmzmrt0OAg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"8a24fea63afbe047524b68b394f957b8530a6fbbc78ad4defec2d39dd2a4de80\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "now": 1790255601 }, "expect": { @@ -442,9 +442,9 @@ "class": "precedence", "replace": { "attestations": [ - "{\"signature_b64\":\"tzcnurCZt_J1H8Fl3TWa5iHZUjpsmYzQIJK61c72CyZ4jSfAsEAYCWVXCASF7zbmHdsCLu6NE4vJ0-hUmAK3DQ\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"xrJ1DFsrecZJ1w6RIfbOj1yQ9l-ZcrDRqHPZYP_Cqo1kj9G1Rc2FTRB6O05b_znhal3O9knMDlDpey-_hC4vBg\",\"statement\":{\"issued_at\":1789992800,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"Z3UjHuk0P1vXwp3sdRugtrOMMqphXGjTUM0z84-x_sT8FcwD1lFl02AXCEHk3rVBIWsgYolMjd90Ixr3_IgLAA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"6f0acc99bc26bd63fad9cecd15cfcb3d1986b2a92caea30d5955c10997a67d2c\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", + "release_index": "{\"signature_b64\":\"_5fxI4CUWG1x39gELCP_b6Tgh0TH_Slt2K9dd6PdCt8Hp7rMB9GLSmqIBCbVYxrZP2wE5RbXaIrZPmzmrt0OAg\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"8a24fea63afbe047524b68b394f957b8530a6fbbc78ad4defec2d39dd2a4de80\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}", "signer_certificate": "{\"root_signature_b64\":\"AzUQnJwFYUZzLtBZTs8b0R2kt6nvkhJoBbMttBXnyAP7dSk7rVfe7sZQxD4Laerbyc7jgH4yzz1dUWdEOfscDA\",\"statement\":{\"issued_at\":1787408000,\"not_after\":1789999999,\"not_before\":1787408000,\"permitted_artifact_kinds\":[\"qualification-release-index\",\"recipe-qualification-attestation\"],\"public_key_b64\":\"oJql9HpnWYAv-VX43C0qFKXJnSO-l_hkEn_5ODRVpPA\",\"root_id\":\"auths-qualification-root-1\",\"schema\":\"auths.qualification-signer-certificate/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\",\"signer_kind\":\"protected-software-release-key-v1\"}}" }, "expect": { @@ -472,7 +472,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -548,7 +548,7 @@ "id": "index-issued-ahead", "class": "future-issued-at", "replace": { - "release_index": "{\"signature_b64\":\"iFZllyzGcs8v1ERP6LPKqjA6kvxnE5uOQjB8FySMUWiJgJew4OOVPpjTXU0S2f3r61bq0TdsLQdnMF7KTi8ECQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"70d270e747fcf8bca9f408758ddcc1cd4470bd0dc8fa675ea68ce960146c8bbd\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1790000060,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"5taLptZFLigql-wheq4jrzJOZz6psUgXhzyzXzAgIuvuHx7bSgLdWS3PCFCMgooFSKE4-Z-IFgUbVISrNZngCA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"00449fd858186bb6711b8697f6ea376b5c6a99ef79e0443c6ccc23a1a36e8c82\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1790000060,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "stale", @@ -561,9 +561,9 @@ "class": "future-issued-at", "replace": { "attestations": [ - "{\"signature_b64\":\"TMNaatveqBWAj3wsq4j1V7vVj9Tu-kZOFMwwXnQDXpQG0I0HURDFo1nF8nITAFoVGuutMmQbmZb_sFhRpZIWBQ\",\"statement\":{\"issued_at\":1790000060,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "{\"signature_b64\":\"1OYEnrP0APZgNIc9VFzQmKyYK-ltQO7ZjTduSVOzkNeQ0S8e2Ek4xFw2O3CyVfsajqVeHgDUPmDFsvWDBg-5Bw\",\"statement\":{\"issued_at\":1790000060,\"not_after\":1797768800,\"not_before\":1789992800,\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\",\"schema\":\"auths.recipe-qualification-attestation/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" ], - "release_index": "{\"signature_b64\":\"HNKdrtymwAkxEN7ZILJ6An0FAwpfCisSdASlzyWwTdxgS4mIh0iL9c5U2QGPL9HqK8UCPSoJ9gDfniyOez6JBA\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"3015c776a66528d143ce6702186d944e11bca8f210e80123585679a664a68700\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"d7a00ddda1c3b7afa99bb997fd9d88f864937a104e1b8cdd4370ebb914c6d306\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" + "release_index": "{\"signature_b64\":\"QMiLn3BFlwLUUJctksJ9mbmwNJ8IEF5A1D-yy13oE2yuxvABWwZT-4Vw_N4onw1SrMY86a650H6xCqSUy9QNBQ\",\"statement\":{\"entries\":[{\"attestation_sha256\":\"9d6bfaadd23e3be7ce7320fa6ad7779384a3187c7713856b5b12aa5728e86c32\",\"qualification_id\":\"qlf_01010101010101010101010101010101\",\"record_sha256\":\"e5495a2c95356229a778d6781860eed872090ac5f4b6ac447953d353a3592f7a\"}],\"issued_at\":1789996400,\"schema\":\"auths.qualification-release-index/1\",\"signature_suite\":\"ed25519-v1\",\"signer_id\":\"release-signer-2026-10\"}}" }, "expect": { "state": "stale", @@ -637,7 +637,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } }, @@ -740,7 +740,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -768,7 +768,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -796,7 +796,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -824,7 +824,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -852,7 +852,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -880,7 +880,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -908,7 +908,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -936,7 +936,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -964,7 +964,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "245f158f0e08031548685ae415e98194feb1831db12f4563ecd7ab15b2630651", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -992,7 +992,7 @@ "gateway_version": "1.0.0-rc.2", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -1020,7 +1020,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "shared-file-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -1048,7 +1048,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/6", + "store_schema": "auths.lifecycle.postgresql/0", "credential_store_kind": "aws-secrets-manager-v1" } } @@ -1076,7 +1076,7 @@ "gateway_version": "1.0.0-rc.1", "gateway_build_sha256": "42194bc83fbd821aaede008519091b4516baf914f8c2e27f3f2ac7a25f7485c3", "store_kind": "postgresql-v1", - "store_schema": "auths.lifecycle.postgresql/5", + "store_schema": "auths.lifecycle.postgresql/6", "credential_store_kind": "local-file-v1" } } diff --git a/compliance.toml b/compliance.toml index c6581ec06..c9673c0bc 100644 --- a/compliance.toml +++ b/compliance.toml @@ -1161,18 +1161,21 @@ kind = "cargo" layer = "product" path = "product/qualification/auths-recipe-qualification" core_apis = [] -protocol_versions = ["auths.provider-contract/1", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.qualification-evidence/1", "auths.qualification-tuple/1", "auths.gateway-semantic-closure/1", "auths.qualification-schema-vectors/1", "auths.qualification-verification-vectors/1"] -wire_objects = ["GatewaySemanticClosure", "ProviderContract", "QualificationEvidence", "QualificationReleaseIndex", "QualificationRevocationList", "QualificationSignerCertificate", "QualificationTrustRoot", "RecipeQualificationAttestation", "RecipeQualificationRecord"] +protocol_versions = ["auths.provider-contract/1", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-commissioning-permit/2", "auths.qualification-commissioning-budget-key/1", "auths.qualification-commissioning-budget-binding/2", "auths.qualification-commissioning-vectors/2", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.qualification-evidence/1", "auths.qualification-tuple/1", "auths.gateway-semantic-closure/1", "auths.qualification-schema-vectors/1", "auths.qualification-verification-vectors/1"] +wire_objects = ["QualificationCommissioningPermit", "GatewaySemanticClosure", "ProviderContract", "QualificationEvidence", "QualificationReleaseIndex", "QualificationRevocationList", "QualificationSignerCertificate", "QualificationTrustRoot", "RecipeQualificationAttestation", "RecipeQualificationRecord"] fixture_suites = ["bindings/fixtures/qualification"] principal_families = [] signature_families = ["ed25519-v1"] profiles = [] transports = [] -configuration_inputs = ["qualification-trust-root", "qualification-signer-certificate", "qualification-revocation-list", "qualification-release-index"] +configuration_inputs = ["commissioning-permit", "qualification-trust-root", "qualification-signer-certificate", "qualification-revocation-list", "qualification-release-index"] security_state = [] [packages.auths-recipe-qualification.claims] independent-semantic-implementation = [ + "product/qualification/auths-recipe-qualification/src/commissioning/tests.rs#frozen_commissioning_bytes_authenticate_only_their_exact_binding", + "product/qualification/auths-recipe-qualification/src/commissioning/tests.rs#every_single_bit_signature_mutation_is_refused", + "product/qualification/auths-recipe-qualification/src/commissioning/tests.rs#unsigned_shape_never_accepts_extra_duplicate_or_unknown_members", "product/qualification/auths-recipe-qualification/src/vectors/schemas.rs#every_valid_artifact_decodes", "product/qualification/auths-recipe-qualification/src/vectors/schemas.rs#every_structural_case_is_refused_with_its_reason", "product/qualification/auths-recipe-qualification/src/vectors/schemas.rs#contract_drift_changes_the_contract_identifier", @@ -1187,18 +1190,30 @@ kind = "cargo" layer = "product" path = "product/qualification/auths-recipe-qualification-issuance" core_apis = [] -protocol_versions = ["auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.qualification-evidence/1"] -wire_objects = ["QualificationEvidence", "QualificationReleaseIndex", "QualificationRevocationList", "QualificationSignerCertificate", "QualificationTrustRoot", "RecipeQualificationAttestation", "RecipeQualificationRecord"] +protocol_versions = ["auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-commissioning-permit/2", "auths.qualification-commissioning-budget-key/1", "auths.qualification-commissioning-budget-binding/2", "auths.qualification-commissioning-vectors/2", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.qualification-evidence/1"] +wire_objects = ["QualificationCommissioningPermit", "QualificationEvidence", "QualificationReleaseIndex", "QualificationRevocationList", "QualificationSignerCertificate", "QualificationTrustRoot", "RecipeQualificationAttestation", "RecipeQualificationRecord"] fixture_suites = [] principal_families = [] signature_families = ["ed25519-v1"] profiles = [] transports = [] -configuration_inputs = ["record-draft", "stage-case-reports", "executable-qualification-corpus", "candidate-observations", "family-harness", "qualification-trust-root-key-file", "release-signer-key-file"] -security_state = ["qualification-trust-root-key", "release-signer-key"] +configuration_inputs = ["record-draft", "stage-case-reports", "executable-qualification-corpus", "candidate-observations", "family-harness", "qualification-trust-root-key-file", "release-signer-key-file", "commissioning-proposal", "commissioning-signer-key-file"] +security_state = ["qualification-trust-root-key", "release-signer-key", "commissioning-signer-key"] [packages.auths-recipe-qualification-issuance.claims] proof-author-or-assembler = [ + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#a_commissioning_permit_never_qualifies_ordinary_production", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#commissioning_and_release_signers_have_separate_purposes", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#each_signature_root_and_domain_is_required", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#every_runtime_binding_must_equal_the_reviewed_session", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#commissioning_windows_are_half_open_and_require_trusted_time", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#revocations_are_permanent_and_an_older_list_cannot_restore_authority", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#proposal_rechecks_exact_offline_artifacts_and_all_required_scenarios", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#action_and_lease_bounds_are_exact_and_never_silently_repaired", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#renewal_cannot_change_the_durable_budget_identity_or_binding", + "product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs#commissioning_artifact_fixture_is_current", + "product/qualification/auths-recipe-qualification-issuance/tests/cli.rs#commissioning_cli_requires_its_own_purpose_and_rechecks_offline_evidence", + "product/qualification/auths-recipe-qualification-issuance/tests/issuance.rs#a_record_is_assembled_from_its_evidence_and_closes_over_it", "product/qualification/auths-recipe-qualification-issuance/tests/issuance.rs#a_run_with_a_failed_case_produces_no_evidence", "product/qualification/auths-recipe-qualification-issuance/tests/issuance.rs#assembly_refuses_a_wall_that_is_not_whole", @@ -1211,6 +1226,12 @@ proof-author-or-assembler = [ "product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs#a_pull_request_reaches_no_secret_and_no_signing_job", "product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs#the_repository_holds_no_qualification_key", "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#every_stage_executes_its_operations_and_a_missing_runner_refuses", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#production_readiness_requires_a_read_only_live_probe_on_a_production_target", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#commissioning_client_refusal_cannot_replace_an_ordinary_installed_effect", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#fresh_evidence_is_closed_to_the_reviewed_subject_and_exact_candidate_digest", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#offline_differential_requires_native_review_without_custody_or_provider_entry", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#a_dynamic_doctor_witness_must_match_the_actual_production_tuple", + "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#replay_can_complete_an_unresolved_read_back_but_never_write_or_reacquire_after_observation", "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#observed_faults_and_changed_candidates_produce_no_passing_case", "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#differential_compares_the_actual_oracle_and_gateway_commitments", "product/qualification/auths-recipe-qualification-issuance/tests/execution.rs#recovery_requires_read_back_and_without_the_capability_stays_unknown", @@ -1579,15 +1600,15 @@ kind = "cargo" layer = "product" path = "product/runtime/auths-gateway" core_apis = ["auths-author", "auths-codec", "auths-did-keri", "auths-did-key", "auths-model", "auths-multikey", "auths-ports", "auths-raw-key", "auths-raw-key-core", "auths-registries", "auths-signature", "auths-verifier"] -protocol_versions = ["auths.gateway-credential-journal/1", "auths.gateway-credential-collection/1", "auths.gateway-readiness/1", "auths.gateway-support-bundle/1", "auths.gateway-generation-floor/1", "auths.gateway-emergency-stop/1", "auths.gateway-installation/5", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.gateway-semantic-closure/1", "auths.qualification-verification-vectors/1", "auths.gateway-operator-attestation/1", "auths.gateway-admin-request/1", "auths.gateway-admin-response/1", "auths.gateway-connection/1", "auths.provider-connection/2", "auths.gateway-key-identity/1", "auths.lifecycle.transactional-store/4", "auths.gateway-recipe-source/2", "auths.gateway-compiled-recipe/2", "auths.gateway-recipe-review/2", "auths.gateway-recovery-capability/1", "auths.gateway-connection-descriptor/1", "auths.gateway-attempt/3", "auths.gateway-pre-entry/1", "auths.lifecycle.postgresql/5", "auths.gateway-echo/1", "auths.gateway-idempotency-key/1", "auths.gateway-observe/2", "auths.gateway-outcome/2", "auths.gateway-readback/1", "auths.self-hosted-profile-lock/1", "mcp-arguments-v1", "auths.gateway-audit-bundle/2", "auths.gateway-audit-report/3", "auths.gateway-counter-set/1", "auths.gateway-echo-verification/1", "auths.gateway-codes/1", "auths.gateway-production-codes/1", "auths.gateway-custody-vectors/1", "auths.gateway-outcome-vectors/1", "bounded-policy-commitment-v1", "auths.approval-response/1", "auths.gateway-bounded-count/2", "auths.gateway-bounded-sum/1", "auths.gateway.argument-ceiling-window-count/2", "auths.gateway.argument-ceiling-policy/2"] -wire_objects = ["CompiledRecipe", "ClosedProviderRequest", "ClosedCredentialReads", "RecipeReview", "RecoveryCapability", "GatewayAttemptSnapshot", "GatewayPreEntry", "GatewayRecordEntry", "GatewayConnectionDescriptor", "ConnectionRecord", "OperatorAttestation", "OperatorStatement", "GatewayAdminStatus", "GatewayEvidenceSummary", "GatewayObserveRequest", "GatewayObserveResult", "GatewayProviderEvidence", "GatewaySubmitResult", "SignedObservation", "ArgumentCeilingPolicy", "BoundedPolicyCommitment", "AuditReport", "ProviderResult", "AuditedPreEntry", "EchoVerification", "AdminRequestCommand", "ListedValues"] +protocol_versions = ["auths.gateway-execution-witness/1", "auths.gateway-commissioning-execution/1", "auths.gateway-commissioning-budget/1", "auths.qualification-commissioning-permit/2", "auths.qualification-commissioning-budget-key/1", "auths.qualification-commissioning-budget-binding/2", "auths.gateway-credential-journal/1", "auths.gateway-credential-collection/1", "auths.gateway-readiness/1", "auths.gateway-support-bundle/1", "auths.gateway-generation-floor/1", "auths.gateway-emergency-stop/1", "auths.gateway-installation/5", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.gateway-semantic-closure/1", "auths.qualification-verification-vectors/1", "auths.gateway-operator-attestation/1", "auths.gateway-admin-request/1", "auths.gateway-admin-response/1", "auths.gateway-connection/1", "auths.provider-connection/2", "auths.gateway-key-identity/1", "auths.lifecycle.transactional-store/4", "auths.gateway-recipe-source/2", "auths.gateway-compiled-recipe/2", "auths.gateway-recipe-review/2", "auths.gateway-recovery-capability/1", "auths.gateway-connection-descriptor/1", "auths.gateway-attempt/3", "auths.gateway-pre-entry/1", "auths.lifecycle.postgresql/6", "auths.gateway-echo/1", "auths.gateway-idempotency-key/1", "auths.gateway-observe/2", "auths.gateway-outcome/2", "auths.gateway-readback/1", "auths.self-hosted-profile-lock/1", "mcp-arguments-v1", "auths.gateway-audit-bundle/2", "auths.gateway-audit-report/3", "auths.gateway-counter-set/1", "auths.gateway-echo-verification/1", "auths.gateway-codes/1", "auths.gateway-production-codes/1", "auths.gateway-custody-vectors/1", "auths.gateway-outcome-vectors/1", "bounded-policy-commitment-v1", "auths.approval-response/1", "auths.gateway-bounded-count/2", "auths.gateway-bounded-sum/1", "auths.gateway.argument-ceiling-window-count/2", "auths.gateway.argument-ceiling-policy/2"] +wire_objects = ["GatewayExecutionWitness", "CommissioningBudgetSnapshot", "CompiledRecipe", "ClosedProviderRequest", "ClosedCredentialReads", "RecipeReview", "RecoveryCapability", "GatewayAttemptSnapshot", "GatewayPreEntry", "GatewayRecordEntry", "GatewayConnectionDescriptor", "ConnectionRecord", "OperatorAttestation", "OperatorStatement", "GatewayAdminStatus", "GatewayEvidenceSummary", "GatewayObserveRequest", "GatewayObserveResult", "GatewayProviderEvidence", "GatewaySubmitResult", "SignedObservation", "ArgumentCeilingPolicy", "BoundedPolicyCommitment", "AuditReport", "ProviderResult", "AuditedPreEntry", "EchoVerification", "AdminRequestCommand", "ListedValues"] fixture_suites = ["bindings/fixtures/approval", "bindings/fixtures/gateway", "bindings/fixtures/qualification"] principal_families = ["raw-key-v1", "did-key-v1", "did-keri-v1"] signature_families = ["ed25519-v1", "p256-sha256-v1"] profiles = ["auths.mcp/2"] transports = ["bounded-https", "postgresql-tls", "loopback-http-development"] -configuration_inputs = ["operator-approved-recipe", "profile-lock", "independently-provisioned-trust", "connection-binding", "observer-anchor", "observer-signing-key", "deployment-kind", "operator-attestation", "app-capacity", "development-shared-file-store", "postgresql-store-configuration", "audit-trust-and-observer-pins", "qualification-policy", "recipe-family", "provider-contract-id", "qualification-release-inputs", "deployment-clock"] -security_state = ["durable-credential-cleanup-notes", "host-generation-floor", "recipe-digest", "logical-operation-claims", "credential-reference-generation", "shared-connection-record", "credential-generation", "in-flight-entry-count", "echo-bound-provider-evidence", "observer-signing-seed", "custody-held-observer-key", "principal-separation", "key-identity-separation", "pre-entry-evidence", "relative-ceiling-basis", "gateway-record-batches", "link-subject-count-and-sum-slots", "verified-qualification-index", "remembered-revocations", "accepted-revocation-sequence", "gateway-semantic-closure"] +configuration_inputs = ["commissioning-witness-file", "commissioning-binding", "commissioning-permit", "retained-commissioning-budget-floor", "operator-approved-recipe", "profile-lock", "independently-provisioned-trust", "connection-binding", "observer-anchor", "observer-signing-key", "deployment-kind", "operator-attestation", "app-capacity", "development-shared-file-store", "postgresql-store-configuration", "audit-trust-and-observer-pins", "qualification-policy", "recipe-family", "provider-contract-id", "qualification-release-inputs", "deployment-clock"] +security_state = ["permanent-commissioning-consumption", "commissioning-revocation-and-time-floor", "durable-credential-cleanup-notes", "host-generation-floor", "recipe-digest", "logical-operation-claims", "credential-reference-generation", "shared-connection-record", "credential-generation", "in-flight-entry-count", "echo-bound-provider-evidence", "observer-signing-seed", "custody-held-observer-key", "principal-separation", "key-identity-separation", "pre-entry-evidence", "relative-ceiling-basis", "gateway-record-batches", "link-subject-count-and-sum-slots", "verified-qualification-index", "remembered-revocations", "accepted-revocation-sequence", "gateway-semantic-closure"] [packages.auths-gateway.claims] operational-diagnostics = [ @@ -1744,6 +1765,36 @@ runtime-enforcement-boundary = [ "product/runtime/auths-gateway/src/onboarding.rs#the_loopback_build_runs_the_same_onboarding_checks", ] stateful-replay-budget-component = [ + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#capacity_survives_crash_restart_and_sweep_file", + "product/runtime/auths-gateway/src/qualification.rs#pinned_ceremony_authenticates_separate_signer_purposes", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#runtime_never_creates_a_missing_floor", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#committed_floor_survives_restart_and_cannot_be_reset", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#concurrent_local_claims_never_decrease_the_witness", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#unsafe_witness_paths_refuse_before_capacity_claim", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#database_restore_below_retained_witness_is_refused", + "product/runtime/auths-gateway/src/commissioning_floor/tests.rs#database_claim_without_witness_acknowledgement_stays_spent", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#ordinary_submission_cannot_select_commissioning_authority", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#offline_review_verifies_exact_actors_and_requests_without_custody", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#execution_witness_measures_custody_calls_even_when_a_charged_call_fails", + "product/runtime/auths-gateway/src/bin/auths-gateway.rs#commissioning_witness_refuses_shared_existing_and_linked_outputs", + "product/runtime/auths-gateway/src/bin/auths-gateway.rs#commissioning_witness_bounds_changed_counter_frames_without_secret_fields", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#exact_native_actor_and_action_require_a_durable_unit_before_custody", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#absent_registration_and_expiry_after_preparation_never_lease", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#another_signed_actor_or_action_is_refused_before_custody", + "product/runtime/auths-gateway/src/commissioning_session/tests.rs#changed_resources_run_operator_or_context_cannot_open_a_session", + "product/runtime/auths-gateway/src/bin/auths-gateway.rs#concurrent_operator_imports_keep_every_authenticated_revocation", + "product/runtime/auths-gateway/src/bin/auths-gateway.rs#application_and_admin_frames_cannot_select_a_commissioning_session", + "product/runtime/auths-gateway/src/bin/auths-gateway.rs#candidate_identity_needs_no_installation_and_binds_the_running_bytes", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#capacity_survives_crash_restart_and_sweep_postgres", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#exactly_one_host_claims_the_final_unit_file", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#exactly_one_host_claims_the_final_unit_postgres", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#renewal_keeps_consumption_and_a_retained_floor_detects_restore_file", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#renewal_keeps_consumption_and_a_retained_floor_detects_restore_postgres", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#hostile_inputs_consume_nothing_and_revocation_survives_omission_file", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#hostile_inputs_consume_nothing_and_revocation_survives_omission_postgres", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#snapshots_reject_noncanonical_unknown_oversized_and_impossible_state", + "product/runtime/auths-gateway/src/commissioning_budget/tests.rs#consumption_never_exceeds_the_ceiling_or_charges_a_refused_action", + "product/runtime/auths-gateway/src/recipe/tests.rs#durable_claim_is_one_use_across_races_and_restart", "product/runtime/auths-gateway/src/recipe/tests.rs#response_and_observation_are_distinct_durable_stages", "product/runtime/auths-gateway/src/recipe/tests.rs#read_back_links_only_an_exact_token_with_the_verified_value", @@ -1885,7 +1936,7 @@ kind = "cargo" layer = "product" path = "product/stores/auths-stores" core_apis = ["auths-model"] -protocol_versions = ["auths-proof/v1", "auths.lifecycle.postgresql/5"] +protocol_versions = ["auths-proof/v1", "auths.lifecycle.postgresql/6"] wire_objects = ["AttestedDecisionReceipt", "AttestedExecutionReceipt", "LifecycleRecordV1", "GatewayRecordEntry"] fixture_suites = ["product/fixtures/v1/lifecycle"] principal_families = [] @@ -1893,7 +1944,7 @@ signature_families = [] profiles = [] transports = ["filesystem", "memory", "postgresql-tls"] configuration_inputs = ["budget-algebra", "capacity", "lifecycle-capacity-rule", "receipt-policy"] -security_state = ["budget-ledger", "gateway-attempt-records", "lifecycle-records", "receipt-spool"] +security_state = ["budget-ledger", "gateway-attempt-records", "permanent-commissioning-budget-records", "lifecycle-records", "receipt-spool"] [packages.auths-stores.claims] receipt-producer-consumer = ["product/stores/auths-stores/src/lib.rs#local_spool_policy_persists_attested_receipt_on_primary_failure"] diff --git a/deployment/gateway/operator.conf.example b/deployment/gateway/operator.conf.example index 8c6561e27..1f0a55f44 100644 --- a/deployment/gateway/operator.conf.example +++ b/deployment/gateway/operator.conf.example @@ -4,6 +4,7 @@ AUTHS_POSTGRES_CA_PEM=/etc/auths/postgres-ca.pem AUTHS_POSTGRES_SERVER_NAME=postgres.internal AWS_ROLE_ARN=arn:aws:iam::ACCOUNT:role/auths-operator AWS_WEB_IDENTITY_TOKEN_FILE=/run/auths-identity/operator/token -# Read confirmation during rotation uses the separately provisioned runtime identity. +# Installation, join and rotation read confirmation use the separately +# provisioned runtime identity; the operator role needs no secret-read grant. AUTHS_GATEWAY_RUNTIME_ROLE_ARN=arn:aws:iam::ACCOUNT:role/auths-runtime AUTHS_GATEWAY_RUNTIME_TOKEN_FILE=/run/auths-identity/runtime/token diff --git a/docs/PROGRAM_BOARD.md b/docs/PROGRAM_BOARD.md index f3d647fac..845c3274d 100644 --- a/docs/PROGRAM_BOARD.md +++ b/docs/PROGRAM_BOARD.md @@ -414,3 +414,77 @@ shared controls, retained-floor restore refusal, drained file rotation, privilege-dropped diagnostics, support redaction, disposable trust stages, restart and store-only outage controls. Exact-current-commit hosted rehearsal and CI remain pending. Live production and human adoption claims remain open. + +Epic 5 engineering is underway on `codex/recipe-qualification`, based on the +operator-polish candidate. The exact `393edc52` source-free hosted rehearsal +passed 47 steps in 0.553 seconds; its downloaded archive passed the same 47 +steps in local Docker in 6.162 seconds, including manifest and payload checks. +Main CI remains pending. The initial Epic 5 change derives stable launch +readiness from every signed index entry, exact candidate/production target, +freshness and revocation, three independence dimensions and complete +digest-bound evidence. Each launch claim additionally needs protected +production readiness evidence. The signing output retains those artifacts; +`release-check` emits the projection. No production root is pinned, so the +current projection remains false. Hosted verification of this implementation +is pending. Provider ADRs/corpora/harnesses, live qualification, signed release +publication and the installed SDK pilot remain outstanding. + +The Epic 5 finalizer now re-evaluates launch readiness before binding the +projection, and the manifest contract refuses a missing or duplicate reference. +ADR 0014 (Stripe Connect refund) and ADR 0015 (Airtable field update) remain +proposed; neither has an executable protected family corpus or a qualification. +The documented first-attestation bootstrap cycle and reviewed dynamic resource +binding must be resolved before live qualification. The production lease gate +has not been relaxed. Epic 4's exact `393edc52` candidate passed all 26 main CI +jobs plus the six package/isolation/PostgreSQL/recipe/SDK workflows and the +47-step Docker rehearsal. PR #205 merged on 2026-10-06 as `4623d635` under the +owner's labeled-simulation deliverable. Production live and human claims remain +explicitly unexercised. Epic 5's current GitHub verification remains pending. + +The owner explicitly assigned first bootstrap and Stripe/Airtable qualification +to the agent as simulations. `qualification/simulation/run.py` now executes the +disposable ceremony and native provider harnesses without external credentials. +The required gate measures zero leases for unsigned fixtures and permits an +entry after verified import under disposable test trust. Separate provider +reports measure exact requests, replay, restart, response loss, crash, race and +hostile input behavior. The Airtable ADR now correctly names its derived linked +read-back recovery; Stripe cannot recover a response-provided locator that was +lost. Signing fixtures explicitly exclude provider-run claims. Production trust +and readiness remain unchanged; real users remain the owner's offline work. +The local expanded rehearsal passed 36 provider cases and the two-family +ceremony. Hosted kit verification is pending; the kit is designed to run in +Docker with no checkout or network mounted. + +On 2026-10-07 the agent used the existing `.env` credentials and completed a +real Airtable operator rehearsal through downloaded gateway commit `20837b56` +and the installed Linux Python SDK `0.0.1rc1`. It passed fresh value/echo +read-back, two-process submission, replay/altered-action/restart refusals, +application credential isolation, support-bundle scanning, record cleanup and +verification of the exact report's detached simulation signature. Evidence is +retained in `qualification/simulation/evidence/airtable-live-2026-10-07/`. +Docker Desktop host sharing initially defeated the file-owner access check; +the runner now receives the credential through operator-only stdin and stages +it privately inside the container. The Stripe keys work, but creating a +distinct test connected account is refused because Connect is not enabled. +These are development live observations, not protected production +qualifications; no production trust or stable-readiness claim has changed. + +The owner then excluded paid Connect onboarding and authorized adapting the +Stripe test to platform-account refunds. The agent generated a separate +`stripe-platform-refund-v1` profile/recipe with no connected-account argument +or scope header, updated AP-SPEC-066 §7.6 and ADR 0014, and passed the real +Stripe journey using the same downloaded gateway and installed `0.0.1rc1` wheel. +Credential guards, limit refusals, fresh refund value/echo +read-back, proof/altered-action/restart refusals, secret isolation, support +scanning, full test-payment cleanup and the published report signature all +passed. Evidence is retained under +`qualification/simulation/evidence/stripe-platform-live-2026-10-07/`. +Connect scope is excluded; both real provider rehearsals remain development +evidence and do not change production qualification or stable readiness. + +The next bootstrap design is recorded in proposed ADR 0016. It separates +finite, signed operator commissioning authority from normal application +qualification, with exact candidate/action/run bindings and durable lease +bounds. No gateway accepts such a permit yet; the ordinary production gate +and stable-readiness result remain unchanged. The existing protected AWS +custody workflow provides a reusable infrastructure starting point. diff --git a/docs/adr/0014-stripe-refund-recipe-qualification.md b/docs/adr/0014-stripe-refund-recipe-qualification.md new file mode 100644 index 000000000..9a6713e53 --- /dev/null +++ b/docs/adr/0014-stripe-refund-recipe-qualification.md @@ -0,0 +1,98 @@ +# ADR 0014: Qualify platform-account Stripe test refunds + +**Status:** Proposed. No family is a candidate or qualified. Acceptance requires +the executable family corpus, independent oracle and protected evidence below. + +**Date:** 7 October 2026 + +## Scope and identity + +The proposed family is `stripe-platform-refund-v1`, starting from the separately +generated profile and reviewed recipe under +[`qualification/simulation/live/stripe-platform/`](../../qualification/simulation/live/stripe-platform/). +The owner excluded paid Connect onboarding on 7 October 2026 and authorized a +platform-account test refund. The recipe contains no account-scope header; the +profile contains no connected-account argument. The existing Connect demo remains a separate example. The native simulation +now compiles this platform recipe and checks it against an independent wire +oracle; evidence for the old Connect recipe cannot establish this recipe's +digest or qualification. + +Qualification still binds the compiled recipe, profile lock, reviewed provider +contract, candidate executable and semantic closure, Linux x86_64 target, +PostgreSQL schema and `aws-secrets-manager-v1` custody. The current live operator +rehearsal uses development file custody/store and claims no production tuple. + +The contract is Stripe API `2025-03-31.basil` in `provider-test-mode`, directly +under the platform account bound by the credential guard. Only freshly created +disposable test payments may be refunded; no customer data or real-money claim +is in scope. Connect account selection is not applicable because the recipe +declares none. A future connected-account tuple needs independent evidence +against a genuinely distinct account. Provider semantics and the qualification +oracle remain test/release code, outside the runtime build. + +## Oracle and provider assumptions + +The test-only oracle must independently derive the POST URL, ordered form body, +version and idempotency headers, payment-intent basis read, refund locator and +fresh observation from the reviewed action and evidence. It must compare both +accepted and refused cases with the candidate, including request/evidence +commitments. It may reuse canonical encoding and cryptographic primitives; +calling the gateway's evaluator to produce the expected result is not an oracle. +The old Stripe vertical's bounded-refund evaluator is a reference for arithmetic, +not proof that the current recipe shares every budget or recovery meaning. + +| Assumption | Required protected observation | +| --- | --- | +| The credential addresses test mode | The `rk_test_` prefix and `/v1/balance` `livemode=false` guard both hold. | +| Credential identity stays bound | The unscoped `/v1/account` result agrees with the installed platform commitment on every lease. | +| Restricted reads are refused | Unscoped customers and payouts reads return the recipe's declared 403 statuses. | +| The platform-only scope is exact | Record the sanitized platform identifier; the profile has no connected-account field and the recipe sends no `Stripe-Account` header. The credential guard binds the installed platform on every lease. | +| The ceiling uses the selected payment | Fresh amount and currency evidence passes at 50%, and refuses at boundary plus one or with a changed currency. | +| The exact request is entered once | Provider and credential witnesses count replay, fresh-challenge replay, race, restart and crash cases separately from the corpus's expected counters. | +| A recorded refund is observed | Fresh GET of the returned refund identifier matches amount and echo under the selected account. | +| The idempotency declaration held in this run | Inspect a deliberate duplicate request inside the declared 86,400-second window; do not infer indefinite retention. | + +Stripe documents refund amounts in minor units and a remaining-refundable +constraint ([refund API](https://docs.stripe.com/api/refunds/create)); the recipe's +50% basis is not an atomic reservation of that provider balance. This platform-only contract makes no connected-account selection or restriction +claim. The operator uses a full test key only to create and clean up its fixture; +the gateway receives only the restricted test key. + +## Corpus, recovery and publication gates + +The maintained platform profile/recipe are accompanied by gateway +`attempt-scenarios-v3.json`, `bounds-aggregate.json`, `outcome-v2.json` and +`hostile-recipes-v2.json`. They must be expanded into the family-owned +`auths.qualification-corpus/3` with executable coverage of every mandatory wall +scenario; they are not the missing protected family corpus. + +The live corpus must additionally exercise every declared capability, provider +secret rotation, production doctor, two-host restart, redaction of actual support +bundles and installed Python/TypeScript clients with no token or source import. +The current recipe declares no lost-response recovery capability. Response loss +must remain `unknown`; delayed read-back may resolve only when the gateway has +retained the recipe's required response locator. `recovery` is not applicable +because this recipe cannot locate an unrecorded refund response. Observer +rotation is not applicable unless the qualified installation declares one. + +The owner-directed [simulation](../../qualification/simulation/README.md) runs +this recipe through an independent wire oracle and mutable provider double, +with fixed synthetic resources and disposable signing trust. Before a protected +production candidate run, resolve the qualification bootstrap described +in [`qualification/README.md`](../../qualification/README.md): no optional policy, +test-feature executable or fabricated intermediate attestation can stand in for +the exact shipped target. Resource bindings and expected digests must also be +fixed by a reviewed corpus expansion before execution, rather than copied from +the candidate's answers. + +Use a maximum 30-day attestation and rerun before renewal or any tuple change. +Revoke on evidence/custody compromise, unauthorized entry, broken account binding, +changed provider behavior or a materially false published assumption. Publish +only sanitized disposable identifiers, bounded evidence, signed artifacts and +explicit exclusions. Provider availability, settlement, indefinite idempotency, +global exactly-once behavior and prevention of writes by other actors remain +provider-owned. The development live platform-account rehearsal passed on 7 October 2026; +its signed simulation report is retained under +`qualification/simulation/evidence/stripe-platform-live-2026-10-07/`. It +establishes the named test observations under development custody, not +protected production qualification or complete-corpus coverage. diff --git a/docs/adr/0015-airtable-record-update-recipe-qualification.md b/docs/adr/0015-airtable-record-update-recipe-qualification.md new file mode 100644 index 000000000..8449f4972 --- /dev/null +++ b/docs/adr/0015-airtable-record-update-recipe-qualification.md @@ -0,0 +1,84 @@ +# ADR 0015: Independently qualify one Airtable field update + +**Status:** Proposed. No family is a candidate or qualified. Acceptance requires +the executable family corpus, independent oracle and protected evidence below. + +**Date:** 6 October 2026 + +## Scope and identity + +The proposed family is `airtable-record-update-v1`. Start with the independently +authored field-lab recipe and profile under +[`bindings/fixtures/gateway/airtable/`](../../bindings/fixtures/gateway/airtable/). +The existing `appTEST...` and `tblTEST...` fixture values are synthetic and cannot +be used as live evidence. Substitute one dedicated disposable base and table +through a reviewed recipe construction, then approve and attest its actual +compiled digest. Changing either fixed identifier changes qualification. + +The provider contract is an explicitly reviewed Airtable Web API v0 slice in +`disposable-live-resources`; the recipe has no API version header. Bind the +contract to reviewed documentation and assumptions rather than treating `v0` as +an immutable provider implementation. The candidate target is the same shipped +Linux x86_64 gateway, PostgreSQL schema and production AWS custody used by the +Stripe family. The shared runtime gains no Airtable operation or provider branch. + +## Independent oracle and provider assumptions + +The test-only oracle derives exactly PATCH `/v0///` with +`fields.DemoStatus` and the declared echo, and GET of the same record with the +declared status/echo pointers. It must independently validate allowed replacement +values, field bounds, segment encoding and request/evidence commitments. +It must not call the candidate to generate expected decisions or requests. + +| Assumption | Required protected observation | +| --- | --- | +| The operator's token reaches only disposable resources | Configure the personal access token with the necessary record read/write scopes and one base; inspect refusal against an excluded base. | +| The fixed table and record belong to that base | Retain sanitized base/table/record identifiers; inspect a fresh record read before and after each successful action. | +| The write changes the intended field | Compare the exact PATCH body with the independent oracle; fresh GET must return the declared replacement and echo. | +| Invalid actions enter nothing | Forge, alter, unknown-field, replacement-enum, path-injection and wrong-recipe cases show zero credential leases and provider entries. | +| Persisted admission survives process changes | Replay, fresh challenge, race, crash and restart witnesses show no second authorized entry for the logical operation. | +| Reconciliation needs fresh matching evidence | Lose the response after entry and delay visibility; the verified record locator permits read-back, but only matching value and echo can establish observed success. No retry sends another PATCH. | + +Airtable's token permissions depend on both scopes and selected resources +([personal access tokens](https://support.airtable.com/articles/9934989703-creating-personal-access-tokens)). +This family must observe its configured restrictions; possession of a token is +not evidence that they were configured. The current recipe has no credential +guard or account-binding probe, so the ADR cannot claim the gateway enforces +those capabilities on every lease. + +## Corpus, exclusions and publication gates + +The maintained offline seeds are `airtable/vectors.json`, the profile lock and +gateway hostile, attempt and outcome corpora. Publish a family-owned executable +`auths.qualification-corpus/3` covering the complete evidence wall, including +an oracle-accepted update, all refused mappings, real application isolation and +credential drift. These seeds alone are not the missing protected family corpus. + +The protected live corpus must exercise a fresh confirmed update, echo, +production doctor, provider-secret rotation, two hosts, restart/recovery, +actual support/log/trace/metric scans and installed Python/TypeScript journeys. +Create records with no personal data; cleanup removes only resources whose +identifiers were recorded by this run and must also execute after partial setup. + +The recipe declares no provider idempotency. Its derived gateway recovery +capability is linked read-back: the fixed record locator and declared echo permit +read-only reconciliation after a lost response. The simulation exercises this +through the native driver and persistent claims; it sends no second PATCH. +This is not provider deduplication or compare-and-swap. Version pin, credential guard, account +binding, denied reads, relative ceiling, sum budget, response locator and +pre-entry read are also not applicable because they are absent from this recipe. +Observer rotation is required only if the installed target declares one. + +The owner-directed [simulation](../../qualification/simulation/README.md) uses +fixed synthetic resources and disposable signing trust. Production qualification +still requires reviewed live resource bindings and real protected evidence. +Do not rename a development tuple as the production target. + +Use a maximum 14-day attestation, reflecting the absence of a fixed API release +header, and rerun before renewal or any tuple change. Revoke on unexpected +provider entries, secret/evidence compromise, changed response behavior, token +scope widening or invalid observation assumptions. Publish signed artifacts, +bounded evidence and sanitized resource identifiers. Atomic compare-and-swap, +isolation from another writer, provider idempotency, recovery without a fresh +matching value and echo, and +generic account-binding guarantees are excluded. No live evidence is claimed. diff --git a/docs/adr/0016-bounded-qualification-commissioning-authority.md b/docs/adr/0016-bounded-qualification-commissioning-authority.md new file mode 100644 index 000000000..427067a8b --- /dev/null +++ b/docs/adr/0016-bounded-qualification-commissioning-authority.md @@ -0,0 +1,251 @@ +# ADR 0016: Bound the first qualification run with explicit commissioning authority + +**Status:** Implementing. The closed permit, commissioning-only signer purpose, +offline evidence closure, pure signature/binding verifier, atomic permanent +budget, retained host floors and authenticated private operator commands are +implemented. The first offline root ceremony and separate public signer +certificates are pinned. Protected family references/workflows and actual live +evidence remain required before the bootstrap is resolved. +Ordinary production leases still require qualification. + +**Date:** 7 October 2026 + +## Problem + +The shipped gateway requires an exact-tuple qualification before every +production lease. Issuance requires provider effects through that shipped +candidate before it can sign the first qualification. A development file-store +installation or differently compiled test executable changes the tuple, so its +measurements cannot break this cycle. Fabricating an initial qualification, +relaxing ordinary production policy or substituting simulated provider evidence +would invalidate the claim. + +## Proposed authority boundary + +Introduce a separate, signed, finite qualification-run permit. Its purpose is +authorizing a reviewed commissioning run, not certifying a completed run. The +ordinary application socket continues to require a current qualification. +Only an authenticated private operator session may use commissioning authority; +normal application commands cannot select that session or authority kind. +Both paths must use the same shipped verifier, recipe compiler, reservation, +attempt, credential and provider driver implementations. No test feature, +caller callback, provider module or unverified effect enters the runtime. + +The offline qualification root certifies a protected signer with an explicit +commissioning permission. A permit has its own closed schema and signature +domain, reusing the existing canonicalization and Ed25519 implementation. It is +not decoded as a qualification record or listed as a release qualification. +The signer runs separately from the live harness and validates reviewed offline +inputs before signing. The root key never enters a qualification runner. + +Every permit must bind: + +- the exact candidate commit, executable digest, semantic closure and production + tuple, including PostgreSQL schema and custody kind; +- the reviewed provider contract, recipe, lock and finite exact trusted-context + digests; +- one protected workflow run and one ephemeral proof-authoring principal; +- reviewed disposable resource bindings and a precomputed finite set of exact + canonical action commitments; +- a maximum credential-lease count, stored atomically and durably for the permit + across both gateway instances and restarts; and +- a signed validity window of at most two hours, current signer certification + and a current root-signed revocation list. + +The reviewed finite pool may include exact provider-read refusal probes. The +Stripe platform reference includes an amount above the relative ceiling and +a currency mismatch; both proofs are native-valid and both require the real +guard's post-lease refusal with zero write entries. The permit never replaces +that guard or grants a callback. Signing reconstructs the entire source pool; +an additional action, changed probe or omitted/reordered packet refuses. Each +probe consumes the same finite credential budget as a successful submission. + +Resource acquisition, action authoring and independent oracle expansion happen +before permit issuance. Expected request/evidence commitments are derived from +the reviewed reference and bounded resource binding, not candidate answers. +The reference remains test/release code. Runtime permit checks compare closed +identities and commitments; they implement no provider-specific oracle. + +A permit never replaces proof verification, grant attenuation, exact approval, +recipe evaluation, critical reads, budget reservation or durable replay claims. +The principal, target, context, action and run must all match. The lease bound +is claimed durably before custody access. Expiry, revocation, exhaustion, +rollback, unavailable shared state or any mismatch refuses before a lease. +Unknown provider outcomes retain ordinary uncertainty and cannot gain a second +write through permit renewal. A denied input is terminal for those inputs. + +Permit schema 2 fixes a fresh-challenge coverage problem in schema 1: pinning +only one installed challenge prevented a valid new-challenge replay from reaching +the durable claim. The signer now binds a sorted unique set of at most four exact +canonical context hashes. Each installation still needs its own authenticated +operator attestation for its actual context, and every proof still verifies +against that installed challenge. The complete set is immutable at the one +run/family budget key; another context, set change, renewal or host cannot reset +capacity. There is no wildcard context, context-template normalization or old +permit reader. Reviewed author/reference support must create and independently +review the exact contexts before signing; adding the schema alone does not +establish protected replay evidence. + +## Evidence and bootstrap sequence + +1. Build and install the exact production candidate with ordinary qualification + required. Demonstrate that ordinary application requests cannot lease. +2. Acquire only the reviewed disposable resources, author the finite actions, + expand the independent oracle and finish the candidate's offline evidence. +3. The protected signer validates those inputs and issues commissioning + authority for that exact run. The authenticated operator imports it. +4. Execute and independently witness the mandatory live cases through the same + shipped driver, production PostgreSQL and production custody. Persist the + actual permit, counters, observations, read-backs and redacted evidence. +5. Close and sign a truthful intermediate qualification without claiming + production readiness. Commissioning authority expires and remains consumed. +6. Import that qualification through the existing verifier. Run ordinary + production requests and the typed production doctor without commissioning + authority. Close a fresh final qualification containing those observations. +7. Only verified final qualifications can contribute to stable launch readiness. + +The two-hour window is a hard maximum, not a promise that the whole wall fits. +Timeout or incomplete evidence produces no qualification. Cleanup retains +operator authority over its own fixtures and does not create provider evidence. +Renewal cannot reset attempt identities or permit counters. + +## Required implementation and rejection evidence + +This proposal is incomplete until closed schemas, signer permissions, sealed +runtime authority, durable PostgreSQL accounting, authenticated operator +commands, independent resource/oracle binding and the protected workflow are +implemented together. New state rejects obsolete disposable schemas under the +prelaunch contract; no compatibility reader or hidden policy relaxation is added. + +Tests must exercise forged signatures, another root/signer/run/principal, +changed context/action/recipe/target, excessive or empty allowlists, time and +revocation faults, unsupported permissions, exhausted counters, two-host races, +crashes around lease claims, restart/rollback, ordinary application attempts to +use a permit, and failure before credentials. The first actual protected run +must show no ordinary lease before qualification, measured finite commissioned +leases, and ordinary qualified operation after import. CI and the signed evidence +must name the exact shipped candidate. Simulations cannot establish this claim. + +The initial artifact implementation keeps its run/family budget key independent +of signature, signer and validity window. Its immutable budget binding commits +to every candidate, principal, context, resource, environment, offline-evidence, +action and ceiling member. A durable registration must refuse a changed binding +at the same key; renewal must consume the original counter. Windows are +half-open, and both the issue-to-expiry and start-to-expiry durations are capped +at two hours. Release certificates carry no commissioning permission; +commissioning certificates carry no attestation or index permission. A +mixed-purpose certificate is refused by the commissioning verifier. + +The public synthetic vector is +`bindings/fixtures/qualification/commissioning-v2.json`. Native tests consume +these frozen bytes without issuance code. They cover strict decoding and every +single-bit signature mutation; issuance tests additionally cover purpose/root/ +domain separation, exact runtime bindings, time/revocation boundaries, finite +action/lease bounds, evidence omissions and stable renewal identities. These +tests establish the pure artifact boundary only, not durable consumption or a +completed production bootstrap. + +The accounting component reuses the existing bounded opaque record store's +insert-once and compare-and-swap operations. `commissioning-budget` is a fifth, +non-expiring record kind in PostgreSQL schema 6. Schema 5 is rejected; disposable +prelaunch databases must be recreated, with no migration or compatibility +reader. The existing lifecycle transition contract remains version 4 because +its transition and receipt semantics are unchanged. + +Runtime loading cannot initialize a missing counter. Authenticated fresh setup +registers zero consumption once; a subsequent initialization returns existing +consumption and refuses changed bindings. Every successful claim increments +before custody and is never refunded. The record additionally remembers signer +revocations, the largest accepted revocation sequence and the latest successful +trusted verifier time. A renewed permit cannot reset any of them. + +The private session must persist the returned validated snapshot as an +independently retained host floor before custody, including trust updates from +denied attempts. The mechanism refuses database state below that floor. This +does not detect a coordinated rollback of the database and every retained +witness; retaining witnesses outside the restore set is an explicit operator +obligation, as for the existing connection generation floor. Source tests run +the same final-unit race, crash/reopen, renewal, sweep, revocation and restore +cases on file and TLS PostgreSQL stores; the PostgreSQL cases require the +existing protected CI fixture. A property test additionally bounds consumption +over arbitrary accepted/refused input sequences. These tests exercise storage +accounting only and grant no production credential authority. + +## Consequences + +The private operator interface is `commissioning-init` followed by +`commissioning-submit`. Both require the existing owner-private production +installation and its signed operator attestation. The directory passed with +`--from` contains `commissioning-permit.json`, `signer-certificate.json` and +`revocation-list.json`. `--protected-run` identifies the exact workflow run and +attempt; `--resource-binding` supplies the exact public resource file reviewed +before issuance. A submit additionally names `--proof` and `--action`; optional +`--witness-file` records bounded secret-free counters in a new owner-private +file. Recording never grants authority or pauses execution; any recording +failure is reported after the native attempt completes. +The root, production tuple, installed context and synchronized clock come from +the installation, with no command-line root or policy override. + +The source revision is authenticated by the permit's signature together with +the executable digest that the installation computes from its own running +binary. There is no independently changeable runtime source-commit setting. +The actor commitment is raw SHA-256 of the normalized `PrincipalId` UTF-8 +identifier; runtime extracts distinct actors only from the exact action IDs +sealed by native verification and requires exactly one. The action commitment +is the existing `auths.canonical-action.v1` commitment of verified canonical +CBOR; the context and resource commitments are raw SHA-256 of the exact +installed context and reviewed public resource-file bytes respectively. + +A host-private lock covers loading the prior witness, the database claim and +atomic witness replacement. Private regular files reject symbolic links, +hard links, different ownership, public modes, oversized bytes and malformed +state. The snapshot and parent directory are synchronized before a claim +returns. A failed acknowledgement burns any database-committed unit. Setup +also records the authenticated revocation sequence without allowing or charging +a credential acquisition. Import persistence merges prior authenticated +revocations under a separate private host lock, so concurrent or stale writers +cannot erase them. + +After a capacity/floor commit, the session rechecks current trusted time and +reloads the exact connection generation immediately before custody. Waiting +for storage cannot extend the signed permit window. Ordinary app/admin frames +have no commissioning member or command. The operator's private submission +uses the same translated order driver, native verifier, recipe checks, +reservation, replay, transport and observation implementation; its authority +is selected by the authenticated operator process, never by a proof or frame. + +Focused tests use public synthetic authority, frozen native quorum proofs and +counted custody to exercise these boundaries. They establish runtime refusal +and accounting behavior, not protected provider evidence or a completed first +qualification. The first pinned root and public certificates are recorded in +`qualification/trust/ceremony.json`, explicitly as a repository-owner-delegated +technical assessment. Its private root key stays outside the checkout and CI. +No qualification record or release index was issued by this ceremony. + +This adds explicit operator commissioning authority and its associated trust +obligation. It does not make the first qualification appear to preexist its own +evidence. Commissioning state remains distinct from `qualified`; readiness +never derives true from a permit. The normal production application gate stays +closed until the existing qualification chain verifies. + +## Three-phase corpus and finite first qualification + +The installed-client journey exposed a second first-run cycle: an ordinary +client must remain refused until qualification exists. The closed release-only +corpus is therefore version 2, with `offline`, `commissioning` and `live` phases. +The commissioning phase requires PostgreSQL and production custody and cannot +claim production readiness. Its installed-client case must witness a missing +qualification refusal with no lease or provider entry. Private operator effects +still need the complete mandatory wall and fresh read-back. + +The resulting first record has a maximum two-hour validity and explicitly +excludes ordinary client success and production readiness. It authorizes the +subsequent ordinary live phase for the same exact tuple. That phase requires +a confirmed installed-client effect and the actual production doctor; a denied +client, a development target or a commissioning doctor cannot substitute. +Assembly reads offline reports and only the selected protected phase. The +contract binds one unchanged manifest containing both phase scopes, so the +first qualification does not require a different recipe, binary or contract. + +This sequencing must still be executed by protected jobs and retained as +measured evidence before the first qualification can be claimed complete. diff --git a/docs/operations/GATEWAY_TRUST_AND_GIT_SIGNING_RUNBOOK.md b/docs/operations/GATEWAY_TRUST_AND_GIT_SIGNING_RUNBOOK.md index e33eab0fc..b5c69dffa 100644 --- a/docs/operations/GATEWAY_TRUST_AND_GIT_SIGNING_RUNBOOK.md +++ b/docs/operations/GATEWAY_TRUST_AND_GIT_SIGNING_RUNBOOK.md @@ -90,7 +90,7 @@ held which key, is operator evidence and is not produced by this code. `AUTHS_POSTGRES_URL`, `AUTHS_POSTGRES_CA_PEM`, and `AUTHS_POSTGRES_SERVER_NAME`. Connections are TLS-only with certificate and server-name verification. The schema is - `auths.lifecycle.postgresql/5`; it installs only into an empty database. + `auths.lifecycle.postgresql/6`; it installs only into an empty database. A database created at schema 4 or earlier is disposable prelaunch state: recreate it. Gateway claims are stored as attempt record `auths.gateway-attempt/3`; a store holding `/2` records is refused as diff --git a/docs/plans/RECIPE_QUALIFICATION_PROTECTED_RUN_PLAN.md b/docs/plans/RECIPE_QUALIFICATION_PROTECTED_RUN_PLAN.md index 42edc6e5c..7437da32f 100644 --- a/docs/plans/RECIPE_QUALIFICATION_PROTECTED_RUN_PLAN.md +++ b/docs/plans/RECIPE_QUALIFICATION_PROTECTED_RUN_PLAN.md @@ -82,9 +82,12 @@ reason its decision record fixes; nothing is omitted. | 11 secret and provider-data scans | `redaction` | | | 12 installed consumer journey | `installed-consumer` | | -Within a row, the cases are tagged with one of 36 scenarios, and a record +Within a row, the cases are tagged with one of 37 scenarios, and a record closes only when every scenario the wall always requires has a case; -AP-SPEC-066 §20.3 reading 3 lists them. The release tooling runs the +AP-SPEC-066 §20.3 reading 3 lists them. The additional `production-readiness` +scenario is a protected live doctor probe required for stable launch, with zero +leases and provider entries; intermediate records may omit it without becoming +launch-ready. The release tooling runs the differential comparison, the redaction scan, and the freshness and signer-rotation stages itself. The executable corpus runner sequences the remaining operations through a reviewed family's harness and derives reports from measured observations. See `qualification/README.md` for the bounded subprocess contract. diff --git a/docs/research/INDEPENDENT_OPERATOR_QUALIFICATION_NOTES.md b/docs/research/INDEPENDENT_OPERATOR_QUALIFICATION_NOTES.md new file mode 100644 index 000000000..bb5ed1ba1 --- /dev/null +++ b/docs/research/INDEPENDENT_OPERATOR_QUALIFICATION_NOTES.md @@ -0,0 +1,42 @@ +# Independent operator qualification notes + +7 October 2026. Repository-owner-delegated technical work; no independent human +audit or real-user trial is claimed. + +| Issue found | Correction and status | +| --- | --- | +| First production qualification required its own pre-existing attestation. | Separate signed, finite commissioning authority; private operator session; durable shared budget and retained host floor. Implemented on PR #206, ordinary application authority unchanged. | +| Offline evidence attempted custody installation before protected setup. | The actual candidate derives a planned production tuple without installation; live setup must compare its installation with that tuple. Implemented. | +| Disposable identifiers and exact actions needed independent expansion. | Closed Stripe/Airtable resources, reconstruction of the whole reviewed recipe/lock, native proof review and independent request oracles. Implemented release-only expansion; protected family setup still needs integration. | +| An installed ordinary client could not succeed before first qualification. | Separate commissioning and ordinary live phases. First records have a two-hour maximum and explicit exclusions; ordinary live evidence requires a confirmed installed-client effect. Implemented runner/assembly controls, protected sequencing still needs integration. | +| The runner prohibited a legitimate read-only recovery lease on replay. | One lease may finish an unresolved entered attempt's observation. A replay cannot write again or reacquire after observation. Implemented with regression cases. | +| A live provider response's exact digest cannot be predicted before creating the effect. | Implemented: corpus schema 3 declares a closed evidence source and reviewed subject; fresh provider-response/doctor witnesses must match the candidate digest and actual tuple. All remaining facts stay exact. Closed step observations are retained for the trace scan. | +| The production CLI does not expose independently counted custody/transport boundary observations. | Implemented: private process-scoped counters at the actual custody lease call and HTTP execution boundaries, including failures. Commissioning commands return before/after snapshots; ordinary clients cannot reset them. Remote effects still require separate fresh read-back. | +| Stripe Refund objects were incorrectly assumed to carry `livemode`. | Corrected against the provider API: require the exact test PaymentIntent/Charge and run marker; validate the refund's binding/success. Synthetic responses now follow the real object shape. Local resource rehearsal exposed this before protected qualification. | +| Disposable setup could leak resources after a lost creation response. | Implemented run-specific durable preparation journals, Stripe idempotent recovery and Airtable exact ownership discovery. Cleanup requires fresh ownership/state checks and leaves unrelated resources alone. Local Docker CLI rehearsal passed for one actual test payment and one actual record; cleanup confirmed both retired. Public rehearsal report is retained under `qualification/simulation/evidence/provider-resource-lifecycle-2026-10-07`. Protected family orchestration still needs integration. | +| Existing AWS roles trust the `gateway-custody-live` environment, while the qualification template names separate family environments. | Inspected the existing reviewer-protected custody environment. Its branch policy is currently unset. Automatic approval review rejected tightening that shared environment and uploading local provider credentials there because that destination was not explicitly authorized. No environment change or provider-key upload occurred; protected orchestration remains pending. No role trust or gate is weakened. | +| Main-only protected code must be deployed before the first protected qualification run. | A bounded prerequisite rollout of the completed runner is needed before collecting evidence. Epic acceptance stays open until actual runs, signed closure and CI are complete. | +| Normal authored actions expire while a protected signing job waits. | Keep the installed author in an isolated process for a bounded two-hour grant/session. Refresh only the same predeclared action just before submission; native signatures keep the ordinary five-minute action limit, exact actor/action/request and installation trust. No private key enters artifacts. Both native Docker rehearsals passed with synthetic resources and no network; protected sequencing still needs integration. | +| Publication scanning omitted newly added commissioning files and other unlisted outputs. | Replace the filename allowlist with a bounded complete-tree scan. Refuse symbolic/hard links, special or oversized files and concurrent changes; keep canaries private and outside artifacts. Move shipping executables outside the evidence tree and use one credential-free candidate build. Native pipeline regressions plant leaks in commissioning effects, source facts and an unexpected filename. | +| A phase's cleanup would destroy the resources needed by the subsequent ordinary qualified phase. | Resource setup/cleanup now belongs to one whole source-owned journey; individual phase runners only execute their phase. Setup runs once, resources stay present across sequential commands, and setup/stage/cleanup failure invalidates both protected phase outputs and the final proposal. Source pipeline tests use explicit doubles; the complete protected signing/qualification sequence still needs integration. | +| An in-memory author connected only by stdin could not survive separate runner steps. | Added a fixed private Unix socket, authenticated Linux peer credentials, a dedicated non-root author UID and root-only controller. Reconnection preserves one key and monotonic refresh generation; changed action/trust bytes or stale packets prevent public handoff. Actual Docker rehearsals passed for both synthetic recipe families with the installed wheel and shipping gateway; no provider or protected qualification was involved. | +| Artifact waits needed exact run identity and safe public extraction. | Added a source-owned reader that pins repository, main/manual workflow, source SHA, run/attempt and artifact role; checks the actual GitHub archive digest; refuses code, links, traversal, duplicates and oversized members; removes partial output. The actual retained native-author report was downloaded and successfully extracted against GitHub's digest. The first local run exposed an unnecessary Python 3.11 hashing API; bounded streaming hashing now works on Python before 3.11, including the supported 3.9 baseline. Protected workflow integration remains pending. | +| A dedicated author UID could not launch the SDK beneath the hosted runner's private temporary ancestors. | The hosted author job failed. A Docker reproduction confirmed the private-parent launcher refusal and then passed with public-readable copied SDK inputs. CI now copies only the credential-free kit and installed wheel environment to `/opt`; author output remains owner-private under `/tmp`. Hosted verification of this correction is pending. | +| A permit pinned one challenge, preventing a valid fresh-challenge replay from reaching the durable claim. | Native permit schema 2 binds 1–4 exact sorted context hashes; every context still needs its own operator attestation and native challenge verification. The full set shares one immutable run/family budget. Frozen vectors cover both listed contexts and an outsider; the durable final-unit race uses different contexts, and a set change cannot register new capacity. The installed author emits two challenges for one actor and identical declared operations. Actual pipe and isolated-socket Docker rehearsals passed native review of both contexts and preserved exact action/context bytes across refresh. Protected durable replay evidence remains pending. | +| Real response-loss testing needs visibility into a short-lived commissioning process. | Added optional owner-private, bounded read-only counter streaming. A stream failure never cancels an entered native attempt. Counters indicate local execution boundaries; the family harness still needs actual external network faults and independent provider read-back. | +| Signing checked packets individually without requiring the complete reviewed operation pool. | Signing now rederives the entire pool, exact filenames, contexts, arguments and order. Added source-fixed Stripe relative-ceiling and currency refusal probes so real guard tests can reach custody without using an unlisted action. Actual installed-wheel Docker pipe/socket rehearsals passed native review of all eight Stripe and four Airtable packets with no network or credentials. Real protected guard refusals and the complete corpus remain pending. | +| Repeated read-back needed explicit accounting for previously confirmed writes. | Added closed projection of actual native result/counter facts and a bounded journey ledger. Only linked evidence matched to independently fetched exact provider bytes can confirm an entered write; read-only recovery confirms its earlier measured entry once. Tuple/action/scope drift, duplicate writes, unmeasured effects and extra result fields refuse. Source unit regressions cover these boundaries; protected harness integration remains pending. | + +The public offline root ceremony and purpose-separated certificates are pinned. +Both signer keys are provisioned in the existing reviewer-protected main-only +signing environment; the root key remains outside CI and every Git checkout. +No production family has yet been qualified by this work. Development live +reports remain explicitly separate from protected production evidence. +# Publication closure correction + +The protected workflow previously removed its real canaries before a separate +job assembled the final proposal. The source-owned closure now assembles and +scans in the operator job after confirmed resource cleanup. It rebuilds with +the complete redaction report and rescans the actual final bytes; output growth +or any assembly/scan failure invalidates the proposal. This closes a publication +gap and does not establish missing protected provider evidence. diff --git a/docs/specs/0038-production-runtime-custody-observability-and-assurance.md b/docs/specs/0038-production-runtime-custody-observability-and-assurance.md index 773efb527..45bd0289e 100644 --- a/docs/specs/0038-production-runtime-custody-observability-and-assurance.md +++ b/docs/specs/0038-production-runtime-custody-observability-and-assurance.md @@ -308,7 +308,7 @@ Implemented (engineering only; no production claim): | Requirement | Where | Evidence | | --- | --- | --- | -| §9.1 Epic 2: claims, provider-bound evidence, and outcome stages on the multi-host PostgreSQL store (Epic 2 qualification open) | `GatewayAttemptStore` in `auths-gateway`; `PostgresLifecycleStore` rows in schema `auths.lifecycle.postgresql/5` (AP-SPEC-063 §9.4 replaced `/4`) | One conformance suite on both stores: claim exactly once, replay, fresh challenge, unknown and re-observe, `echo-mismatch`, concurrent re-observation, fail-closed reads, and concurrent claims from two gateway processes. The PostgreSQL variants run in the PostgreSQL lifecycle workflow against its TLS fixture. | +| §9.1 Epic 2: claims, provider-bound evidence, and outcome stages on the multi-host PostgreSQL store (Epic 2 qualification open) | `GatewayAttemptStore` in `auths-gateway`; `PostgresLifecycleStore` rows in schema `auths.lifecycle.postgresql/6` (AP-SPEC-063 §9.4 replaced `/4`) | One conformance suite on both stores: claim exactly once, replay, fresh challenge, unknown and re-observe, `echo-mismatch`, concurrent re-observation, fail-closed reads, and concurrent claims from two gateway processes. The PostgreSQL variants run in the PostgreSQL lifecycle workflow against its TLS fixture. | | §9.1 Epic 2 fault matrix: replay, fresh challenge, crash after entry, concurrent re-observation | same | The attempt-scenario corpus and the conformance suite, on both stores. The TLS/pooling/failover/backup parts of the Epic 2 matrix are Epic 2's own open work. | | §9.1 Epic 4: observer and Git roots behind `auths-custody` | `GatewayObserver::from_custody`, `CustodyKeySigner`, `CustodyKey` | KMS- and PKCS#11-held keys, through the reference adapters over mock provider APIs, sign observations and grants that verify in the kernel. The shared custody conformance kit runs every `CustodyConformanceCase` and every lifecycle state on both paths. | | §9.1 Epic 4: each signer reports its custody kind | `ObserverCustody`, `GitProofSigner::custody` | Unit tests. | diff --git a/docs/specs/0063-generalized-gateway.md b/docs/specs/0063-generalized-gateway.md index 79e83cb35..da6442da5 100644 --- a/docs/specs/0063-generalized-gateway.md +++ b/docs/specs/0063-generalized-gateway.md @@ -1519,7 +1519,7 @@ entry, with its test evidence, in the change that introduces it: | Kind | Identities | | --- | --- | | Recipe | `auths.gateway-recipe-source/2`, `auths.gateway-compiled-recipe/2`, `auths.gateway-recipe-review/2`, `auths.gateway-recovery-capability/1` | -| Store | `auths.gateway-attempt/3`, `auths.gateway-bounded-count/2`, `auths.gateway-bounded-sum/1`, `auths.gateway-connection/1`, `auths.lifecycle.postgresql/5` | +| Store | `auths.gateway-attempt/3`, `auths.gateway-bounded-count/2`, `auths.gateway-bounded-sum/1`, `auths.gateway-connection/1`, `auths.lifecycle.postgresql/6` | | Policy | `auths.gateway.argument-ceiling-window-count/2`, `auths.gateway.argument-ceiling-policy/2`, `auths.gateway-counter-set/1` | | Evidence | `auths.gateway-pre-entry/1`, `auths.gateway-outcome/2`, `auths.gateway-observe/2`, `auths.gateway-audit-bundle/2`, `auths.gateway-audit-report/2`, `auths.gateway-echo-verification/1`, `auths.gateway-codes/1` | | Operator | `auths.gateway-operator-attestation/1`, `auths.gateway-installation/3`, `auths.gateway-admin-request/1`, `auths.gateway-admin-response/1` | @@ -1579,7 +1579,7 @@ nothing was leased or sent. ### 9.4 PostgreSQL schema 5 -`auths.lifecycle.postgresql/5` replaces `/4`; a `/4` database is refused, not +`auths.lifecycle.postgresql/6` replaces `/4`; a `/4` database is refused, not migrated. `auths_gateway_attempts` is replaced by: ```sql diff --git a/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md b/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md index 05f0911f0..8da40f660 100644 --- a/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md +++ b/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md @@ -567,12 +567,16 @@ is never silently skipped. Infrastructure failure produces no candidate. The machinery is not accepted using mocks alone. Its release gate requires two recipe families against two live providers: -- the Stripe refund journey, including a genuinely distinct connected account - when account-scope support is part of the qualified tuple; and +- the platform-account Stripe refund journey in test mode, with no Connect + account-scope capability in this launch tuple (owner decision, 7 October 2026); and - one independently authored simple write recipe from the field-lab journey (Airtable or Todoist), proving that qualification does not depend on a first-party provider module. +A future tuple that declares Connect account-scope support must exercise a +genuinely distinct connected account; the platform-only evidence cannot qualify +that capability. + Each requires its own ADR and protected evidence. The two recipes share the qualification mechanism, not provider semantics. Until both attestations verify, the release metadata MUST say `stable_launch_ready: false`. @@ -1428,7 +1432,7 @@ Each was taken unattended as the narrower or fail-closed reading. | 1 | `clean-source`, `recipe-digest-rederives` | conformance | | 2 | `recipe-vectors`, `closed-enumeration-hostile` | conformance | | 3 | `oracle-accepts`, `oracle-rejects` | differential | - | 4 | `application-cannot-read-secret` | hostile | + | 4 | `application-cannot-read-secret`, `production-readiness` (launch gate) | hostile | | 5 | `forged-proof`, `altered-action`, `proof-replay`, `fresh-challenge-replay`, `direct-provider-attempt`, `ambiguous-response` | hostile | | 5 | `two-instance-race` | multi-instance | | 5 | `restart`, `crash` | restart | @@ -1440,7 +1444,13 @@ Each was taken unattended as the narrower or fail-closed reading. | 11 | `log-scan`, `trace-scan`, `metric-scan`, `support-bundle-scan`, `evidence-scan` | redaction | | 12 | `installed-journey`, `no-repository-import`, `no-provider-token` | installed-consumer | - Thirty-four are required of every record. `observer-rotation` and + The closed set has thirty-seven scenarios. Thirty-four are required of every + record. `production-readiness` is additionally required for the stable launch + projection: a protected live, read-only doctor probe on the exact PostgreSQL + and production-custody target, with all required typed rows ready and zero + leases or provider entries. Its evidence commits to the actual doctor report. + Intermediate records may omit it but cannot close the stable launch gate. + `observer-rotation` and `declared-capability` are required exactly when a capability they show is exercised. `freshness` is placed in row 7 because the signed time bounds are part of the trust transitions. @@ -1478,7 +1488,7 @@ Each was taken unattended as the narrower or fail-closed reading. rederived from the installed files. The semantic closure, package, version, operating system, and architecture are the build's. The build digest is SHA-256 of the running executable. The store kind and schema - follow the deployment (`postgresql-v1` with `auths.lifecycle.postgresql/5`, + follow the deployment (`postgresql-v1` with `auths.lifecycle.postgresql/6`, or `shared-file-v1` with `auths.gateway-attempt/3`). The recipe family and the provider contract identifier are declared by the operator at install, because a gateway cannot derive them; a wrong declaration matches no @@ -1640,10 +1650,223 @@ source-free packaged simulation passed 47 steps in Docker against the verified `f1f92c3f` package after fixing development file-rotation and tuple-declaration friction. The sanitized report is `deployment/gateway/evidence/operator-simulation-2026-10-06.json`. -Exact-current-commit hosted verification remains pending; no live provider, +The exact `393edc52` candidate subsequently passed all 26 main CI jobs and the +six package/isolation/PostgreSQL/recipe/SDK workflows. Its source-free hosted +rehearsal passed all 47 steps in 0.553 seconds, and the downloaded archive passed +the same 47 steps in Docker in 6.162 seconds. PR #205 merged as `4623d635` on +2026-10-06 under the owner's labeled-simulation deliverable. No live provider, production AWS rotation or production PostgreSQL PITR is claimed by this run. Epic 5 also lacks the owner's offline root ceremony, protected signer secret, two protected provider environments and human release review. On 2026-10-06, GitHub environment/secret metadata confirmed no qualification signer secret and neither family live environment. No production recipe is qualified. + +## 22. Epic 5 engineering and unresolved qualification inputs (2026-10-06) + +The release projection now verifies every signed index entry, exact candidate +identity, production target, time/revocation, evidence closure and production +readiness, requiring independent families, contracts and provider kinds. +Finalization re-evaluates the result and the manifest binds exactly one +projection. Signing publishes the canonical evidence needed to inspect closure. +The current build pins no root and derives false; hosted verification is pending. + +[ADR 0014](../adr/0014-stripe-refund-recipe-qualification.md) and +[ADR 0015](../adr/0015-airtable-record-update-recipe-qualification.md) are proposed +provider decisions, not production-qualified families. The owner has directed +the agent to simulate bootstrap and both provider harnesses independently. +`qualification/simulation/run.py` now runs disposable in-memory root/signer +creation, certification, signing, index/revocation publication and required-gate +import. Unsigned and expired inputs cannot lease; verified test import can. +The signed placeholder records explicitly claim no provider run. Separate +Stripe and Airtable harness reports measure actual native driver leases, entries, +fresh read-back, replay, reopen, response loss and response-record crash against +independent wire oracles and mutable doubles. Each measured provider report +also has a detached signature under a fresh self-signed simulation key; the +runner re-reads and verifies the exact published bytes in a separate native +stage. These signatures have no production or protected-run authority. +The production pinned root stays +unchanged and stable launch readiness stays false. The rehearsal is a maintained +CI job and needs no external credentials. + +The local rehearsal passed 36 measured provider cases (18 per family), plus +the disposable two-family signing/import ceremony. Each race entered one +write; a concurrent contender may additionally use a measured read-only +reconciliation lease. The +candidate kit embeds its fixtures and is run in Docker without checkout, +credentials or network. Hosted verification of that packaged run is pending. + +The protected production run still has a first-attestation cycle: +production leases require qualification before the live effects needed to issue +it. A reviewed authority design must resolve this without bypassing the shipped +lease gate. Dynamically created provider identifiers need reviewed oracle/corpus +binding before execution, not expected digests copied from candidate output. +Those real-production prerequisites do not block the owner-directed simulation. +No protected live evidence or production qualification is claimed. + +### 22.1 Live independent-operator rehearsal (2026-10-07) + +The agent located and used the existing sandbox credentials without publishing +them. `qualification/simulation/live/airtable.py` passed against real Airtable +with the downloaded gateway package at `20837b56` and the installed Linux +Python SDK `0.0.1rc1`. It fixed a dedicated table in the recipe before review, +created a disposable record, used two independent gateway processes with a +shared durable file store, and obtained one provider-observed submission and +one replay refusal. Fresh independent read-back matched value and echo. +Original-proof replay, altered action and replay after process restart were +refused. The application UID could read neither the provider credential nor +gateway state; the actual support bundle passed secret scanning. Cleanup +deleted the created record. The exact published report has a verified detached +simulation signature and is retained with the support bundle under +`qualification/simulation/evidence/airtable-live-2026-10-07/`. + +The first isolation check caught Docker Desktop host sharing presenting the +credential file as owned by the application caller. The corrected runner +receives dotenv input only through operator stdin, stages it in a root-owned +private directory on the container filesystem and repeats the access check. +It mounts no provider credential or source checkout. Failed journeys also +delete their disposable records. + +The Stripe test keys authenticate, but the platform lists no connected +accounts and refuses creating one with HTTP 400 because Connect is not enabled. +This is a real failed setup, not distinct-account qualification evidence. +The Airtable token reaches the existing base; its restriction to one base has +not been established. These reports exclude protected production qualification, +production PostgreSQL/AWS custody, complete-corpus coverage and independently +witnessed lease/provider-entry counts. Stable readiness remains false. + +### 22.2 Owner-approved Stripe scope change (2026-10-07) + +The owner has excluded paid Connect onboarding and authorized adapting the +qualification case to a platform-account refund. The launch Stripe family is +now `stripe-platform-refund-v1`: its separately generated profile and reviewed +recipe are under `qualification/simulation/live/stripe-platform/`. Neither the +profile nor the recipe declares a connected-account argument or account-scope +header. The restricted test credential guard, platform-identity commitment, +version pin, denied reads, relative ceiling, count/sum budgets, idempotency, +response locator and fresh echo observation remain required. Connected-account +selection is explicitly outside this tuple and these reports. + +The revised platform-only live Stripe run passed through the downloaded +`20837b56` gateway and installed `0.0.1rc1` Linux wheel. The restricted key +passed its test-mode/platform/denied-read guards. Grant ceiling, relative +ceiling and currency partition violations were refused. One 500-cent refund +against a freshly created 2,000-cent test payment was confirmed by an +independent fresh refund listing with matching amount and echo; original proof, +altered action and replay after restart were refused. The application could +read neither the staged credential nor gateway state. Teardown refunded the +remaining balance and freshly confirmed the charge was fully refunded. +The exact signed simulation report and support bundle are retained under +`qualification/simulation/evidence/stripe-platform-live-2026-10-07/`. +Both live provider journeys now have measured development evidence. They still +exclude protected production qualification and the full production evidence +wall; the normal production lease gate remains unchanged. + +### 22.3 First-run commissioning design (2026-10-07) + +[ADR 0016](../adr/0016-bounded-qualification-commissioning-authority.md) proposes +a separate, finite signed authority for an authenticated operator qualification +run against the exact production candidate. It binds reviewed resource/action +commitments, the production tuple, trusted context, one protected run and actor, +durable shared lease bounds, time and revocation. Normal application leases +remain subject to the current qualification gate. A permit is neither a +qualification nor evidence of provider behavior or production readiness. + +The signed schema, pure issuance/verifier, sealed private runtime path, +PostgreSQL accounting, retained host floor and authenticated operator commands +are implemented. The first offline root and separate purpose certificates are +pinned, with no qualification issued. The protected family reference/workflow +and rejection/live evidence still must land before the bootstrap can be +claimed resolved. Epic 5's done conditions remain unchanged. + +### 22.4 Reviewed commissioning expansion (2026-10-07) + +The default candidate now derives its planned production tuple without installing +custody through `qualification-candidate`. This is offline candidate identity, +not a deployment or a qualified state. The protected live installation must +print the same tuple before permit import or submission. + +`review-submission` verifies a proof and derives its actors, native action +commitment and credential-free closed request without custody, state or provider +I/O. Its optional evaluation time is an offline input and cannot alter the +production lease clock. `qualification/reference/` independently derives the +Stripe/Airtable requests, fixes resource membership and reconstructs the entire +reviewed recipe and exact lock. Expansion invokes a reviewer built by the +signing job from its checkout; downloaded candidate bytes are hashed but never +executed with a signing key. The original candidate digest remains signed. +One actor, finite exact actions, immutable resources and a source-owned ceiling +of 64 custody acquisitions are required. The native issuer additionally checks +offline evidence closure before issuing a two-hour permit. + +Both purpose-separated signer keys now exist in the existing reviewer-protected, +default-branch-only signing environment. The offline root remains outside CI. +Complete family setup/corpora, protected commissioning/live runs and final signed +qualification still remain; these changes issue no qualification or readiness. + +### 22.5 Ordinary-client bootstrap closure (2026-10-07) + +The closed release-only corpus now distinguishes offline verification, private +commissioning and ordinary qualified live execution (schema +`auths.qualification-corpus/3`). An installed client in commissioning must +measure a missing-qualification refusal with zero lease and entry; the same +scenario in ordinary live execution must measure a fresh confirmed effect. +Commissioning cases require the production tuple and cannot satisfy production +readiness. Assembly selects one protected phase, limits the first record to two +hours and explicitly excludes the ordinary-client/readiness claims that only +the subsequent phase can establish. Both scopes belong to the same reviewed +manifest and exact tuple. This closes the second dependency cycle without +opening ordinary application leases under a commissioning permit. The protected +workflow and family corpora still must execute this sequence. + +#### 22.6 Credential-free differential review + +Offline `oracle-accepts` / `oracle-rejects` cases execute the reviewed pure +oracle followed by `review`, the candidate's native `review-submission` command. +They compare exact proof/action verdict and closed request commitments with +zero custody leases, provider entries and read-back confirmations. A permitted +mapping is `complete`, without an HTTP response or effect claim. `review` is +refused as a protected operation or a replacement for an application submission. +First commissioning therefore depends on offline native verification, without +requiring pre-existing qualification to collect that verification. + +#### 22.7 Reviewed family plans and executable offline collection + +Both family directories now bind the exact source compiler, provider references, +packet author, maintained recipe/lock and ADR in a canonical reviewed-plan +manifest. The native provider contract hashes that manifest; a record separately +hashes the concrete native corpus expanded from its authenticated public pool. +The source generator refuses drift before permit contract derivation. Candidate +outcomes never supply expected verdicts or request/evidence commitments. + +The full plan fixes 16 resources per family and includes both protected phases, +ordinary Python/TypeScript journeys, isolation, drift, rotation, replay, race, +restart, crash, loss/delayed visibility and production doctor. Stripe additionally +has fixed count/sum capacity experiments and exact relative-ceiling probes. +The installed author keeps one actor and reviewed grants; the distinct native +budget windows isolate those capacity experiments without resetting state. + +The six offline scenarios have an executable family harness and hosted jobs +using the actual installed wheel and shipping gateway. The native runner derives +their conformance/differential reports, and the issuer constructs canonical +offline evidence. Verification of this new collection is pending. Protected +operations currently refuse without their production journey implementation; +the source plan is not evidence that those operations ran. Epic 5 remains open. + +#### 22.8 Production operator installation boundary + +The installed SDK now authors both exact-context operator statements using a +separate process and key, reconstructs the native signing preimage, verifies +the signatures and checks key separation from all packet actors. These are +technical operator statements; they assert no human review or qualification. + +Native installation now selects administrative AWS custody just as rotation +does: the restricted operator identity writes, while the distinct runtime +identity reads the stored commitment for a join. Neither IAM role is broadened. +The existing custody workflow supplies the reader identity and admits live +identity only for manual runs on main. The semantic closure is regenerated +with the native fixture generator after this source change. + +The private production controller binds the shipping executable, prepares two +hosts for each trusted context, requires PostgreSQL/TLS and AWS custody, and +compares all four installed tuples with the planned tuple. Complete protected +provider orchestration and hosted verification are still pending. diff --git a/formal/assurance-manifest-v1.toml b/formal/assurance-manifest-v1.toml index f1195d0ed..cce988bec 100644 --- a/formal/assurance-manifest-v1.toml +++ b/formal/assurance-manifest-v1.toml @@ -8733,7 +8733,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-055" @@ -8854,7 +8854,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-056" @@ -8989,7 +8989,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-104" @@ -10463,7 +10463,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-263" @@ -10559,7 +10559,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-264" @@ -10658,7 +10658,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-186" @@ -10918,7 +10918,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-237" @@ -11014,7 +11014,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-238" @@ -11113,7 +11113,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-175" @@ -15048,7 +15048,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-211" @@ -15143,7 +15143,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-212" @@ -15239,7 +15239,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-213" @@ -15335,7 +15335,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-214" @@ -15431,7 +15431,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-232" @@ -15530,7 +15530,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-233" @@ -15626,7 +15626,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-234" @@ -15722,7 +15722,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-235" @@ -15818,7 +15818,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-249" @@ -16485,7 +16485,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-257" @@ -16584,7 +16584,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-258" @@ -16683,7 +16683,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-259" @@ -16779,7 +16779,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-260" @@ -16878,7 +16878,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-070" @@ -23876,7 +23876,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-189" @@ -23972,7 +23972,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-190" @@ -24068,7 +24068,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-191" @@ -24164,7 +24164,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-192" @@ -24259,7 +24259,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-193" @@ -24358,7 +24358,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-194" @@ -24457,7 +24457,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-195" @@ -24558,7 +24558,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-196" @@ -24662,7 +24662,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-197" @@ -24767,7 +24767,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-198" @@ -24863,7 +24863,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-199" @@ -24968,7 +24968,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-200" @@ -25063,7 +25063,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-201" @@ -25158,7 +25158,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-271" @@ -25254,7 +25254,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-272" @@ -25350,7 +25350,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-273" @@ -25449,7 +25449,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-274" @@ -25548,7 +25548,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-275" @@ -25649,7 +25649,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-276" @@ -25749,7 +25749,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-277" @@ -25848,7 +25848,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-278" @@ -25948,7 +25948,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-279" @@ -26047,7 +26047,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-280" @@ -26148,7 +26148,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-281" @@ -26248,7 +26248,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-282" @@ -26348,7 +26348,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-283" @@ -26448,7 +26448,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-284" @@ -26547,7 +26547,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-285" @@ -26649,7 +26649,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-306" @@ -26745,7 +26745,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-307" @@ -26844,7 +26844,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-308" @@ -26940,7 +26940,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-309" @@ -27039,7 +27039,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-310" @@ -27139,7 +27139,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-311" @@ -27238,7 +27238,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" [[claims]] claim_id = "AP-FORMAL-RICH-312" @@ -27339,4 +27339,4 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" +sha256 = "b9545f8e955554f32a852d87029fab3d96a6faa5ebe07667b83c0b1b209b4439" diff --git a/formal/qualification/aeneas/source-closure.json b/formal/qualification/aeneas/source-closure.json index 029640dd5..c2ffeac9d 100644 --- a/formal/qualification/aeneas/source-closure.json +++ b/formal/qualification/aeneas/source-closure.json @@ -1,6 +1,6 @@ { "schema": "auths-proof-translation-source-closure/v2", - "digest": "f3b8cc9336cc95cfabab6629e583f58717601435d678375a62416160a2efb461", + "digest": "cdfc1e60823a53d8ed627e77d9bdaee386faa45e1d8bd440f8d69645a96dc6cc", "files": [ { "path": ".cargo/config.toml", @@ -8,7 +8,7 @@ }, { "path": "Cargo.lock", - "sha256": "e11d08de053ca87e33f265c6ff0bc55d4cca51777cf9b54f5c2fa39b4d032a24", + "sha256": "f84825e2ac0d4af57a406ab4b49ae5ce4e9360caf0a23e092906257743509b04", "normalization": "translated-cargo-closure-v1" }, { @@ -261,7 +261,7 @@ }, { "path": "xtask/Cargo.toml", - "sha256": "ec016a7130a9a604c146f56458e30fef96c9dedc82790f07047dabeac1142e2d" + "sha256": "66b2989994cb878392e352a3c1a632b6cc64074aa4e37e8e77361a191a60a0c5" }, { "path": "xtask/ci-plan/Cargo.toml", @@ -353,7 +353,7 @@ }, { "path": "xtask/src/main.rs", - "sha256": "dd5c06c25f6404e2647e8132e038cefd78fd1f239dda2149a3d8cd58a5522d38" + "sha256": "e897303e906ad3eab43badd52a60142ee1350a6f53e8e7ad8b2e505ac88240fe" }, { "path": "xtask/src/mcp_session_contract.rs", @@ -381,11 +381,15 @@ }, { "path": "xtask/src/release.rs", - "sha256": "46b7f15530d8cda42636f585381f00c9e952d0d4048a0e536e7fa23f0a0f659e" + "sha256": "777158590253b03652d94d988ad7a96b5a750481e4f2eaf5f1e0edc6d48f2e8f" }, { "path": "xtask/src/release_control.rs", - "sha256": "6027e030cd2db03818a23d816e3011f2b504c18599d100322d994387207db090" + "sha256": "0e3727745909381939280674d53309c0ef3d7c52dc58add091f860ba79ab2893" + }, + { + "path": "xtask/src/release_launch.rs", + "sha256": "1ace0de9a642e0e408997ce52c8244b628b2fb340b4cb354d9ffcddd44f357ce" }, { "path": "xtask/src/sdk_experience.rs", @@ -397,7 +401,7 @@ }, { "path": "xtask/src/semantic_freeze.rs", - "sha256": "3227a3dc0a2edc827e2408545063f142b3f89bb7277799fb6a9608669729d5f6" + "sha256": "9e36c78ae7ed8ce11bbc545ef7db8c1741c826038d01a7fb66d6e7fa5c91b18b" }, { "path": "xtask/src/stripe.rs", diff --git a/product/qualification/auths-recipe-qualification-issuance/src/bin/auths-qualification.rs b/product/qualification/auths-recipe-qualification-issuance/src/bin/auths-qualification.rs index 9d47232e9..6287529c9 100644 --- a/product/qualification/auths-recipe-qualification-issuance/src/bin/auths-qualification.rs +++ b/product/qualification/auths-recipe-qualification-issuance/src/bin/auths-qualification.rs @@ -5,16 +5,16 @@ //! written to a private file and is never an argument or printed. use auths_recipe_qualification::{ - EvidenceMemberKind, GitCommit, LiveEffects, MAX_INDEX_ENTRIES, ProviderContract, - QualificationId, QualificationInputs, QualificationRootId, QualificationSignerCertificate, - QualificationSignerId, QualificationTrustRoot, QualificationTuple, - RELEASE_ATTESTATIONS_DIRECTORY, RELEASE_INDEX_FILE, RELEASE_RECORDS_DIRECTORY, - RELEASE_REVOCATION_LIST_FILE, RELEASE_SIGNER_CERTIFICATE_FILE, VerifiedQualifications, - VerifierState, + CommissioningBinding, EvidenceMemberKind, GitCommit, LiveEffects, MAX_INDEX_ENTRIES, + ProviderContract, QualificationEvidence, QualificationId, QualificationInputs, + QualificationRootId, QualificationSignerCertificate, QualificationSignerId, + QualificationTrustRoot, QualificationTuple, RELEASE_ATTESTATIONS_DIRECTORY, RELEASE_INDEX_FILE, + RELEASE_RECORDS_DIRECTORY, RELEASE_REVOCATION_LIST_FILE, RELEASE_SIGNER_CERTIFICATE_FILE, + VerifiedQualifications, VerifierState, }; use auths_recipe_qualification_issuance::{ - CaseReport, CertificateRequest, IssuanceError, QualificationProposal, RecordDraft, - ReleaseSigner, RootSigner, SigningSeed, evidence, stages, + CaseReport, CertificateRequest, CommissioningProposal, CommissioningSigner, IssuanceError, + QualificationProposal, RecordDraft, ReleaseSigner, RootSigner, SigningSeed, evidence, stages, }; use base64ct::{Base64UrlUnpadded, Encoding as _}; use clap::{Parser, Subcommand}; @@ -77,24 +77,13 @@ enum Command { key_out: PathBuf, }, /// Offline ceremony: certify a release signer. - Certify { - #[arg(long)] - root_key: PathBuf, - #[arg(long)] - root: PathBuf, - #[arg(long)] - signer_id: String, - #[arg(long)] - public_key: String, - #[arg(long)] - issued_at: Option, - #[arg(long)] - not_before: u64, - #[arg(long)] - not_after: u64, - #[arg(long)] - out: PathBuf, - }, + Certify(CertificateOptions), + /// Offline ceremony: certify a commissioning-only signer. This key may + /// issue bounded run permits, never qualification attestations or indexes. + CertifyCommissioner(CertificateOptions), + /// Protected signer: recheck both offline evidence artifacts and sign one + /// finite reviewed commissioning binding. Grants no qualification state. + CommissioningSign(CommissioningSignOptions), /// Offline ceremony: issue the next revocation list. Revoke { #[arg(long)] @@ -207,6 +196,48 @@ enum Command { }, } +#[derive(clap::Args)] +struct CertificateOptions { + #[arg(long)] + root_key: PathBuf, + #[arg(long)] + root: PathBuf, + #[arg(long)] + signer_id: String, + #[arg(long)] + public_key: String, + #[arg(long)] + issued_at: Option, + #[arg(long)] + not_before: u64, + #[arg(long)] + not_after: u64, + #[arg(long)] + out: PathBuf, +} + +#[derive(clap::Args)] +struct CommissioningSignOptions { + #[arg(long)] + binding: PathBuf, + #[arg(long)] + conformance: PathBuf, + #[arg(long)] + differential: PathBuf, + #[arg(long)] + signer_key: PathBuf, + #[arg(long)] + certificate: PathBuf, + #[arg(long)] + issued_at: Option, + #[arg(long)] + not_before: u64, + #[arg(long)] + not_after: u64, + #[arg(long)] + out: PathBuf, +} + /// A failure: its stable token and what it concerns. Never a key. struct Failure(String); @@ -428,6 +459,19 @@ fn sign( .zip(&attestations) .collect(); let index = signer.index(issued_at, &listed)?; + // Publish the exact evidence already reverified at signing. The launch + // projection checks these bytes against the signed record's digests; + // counters or a case label alone cannot substitute for artifact closure. + for proposal in &proposals { + for artifact in proposal.evidence() { + write( + &out_dir + .join(EVIDENCE_DIRECTORY) + .join(format!("{}.json", artifact.digest().to_hex())), + artifact.canonical_bytes(), + )?; + } + } for (record, attestation) in &listed { let name = format!("{}.json", record.body().qualification_id.as_str()); write( @@ -448,6 +492,26 @@ fn sign( Ok(()) } +fn certify(options: &CertificateOptions, commissioning: bool) -> Result<(), Failure> { + let root = RootSigner::open(&read_key(&options.root_key)?, trust_root(&options.root)?)?; + let request = CertificateRequest { + signer_id: parsed( + QualificationSignerId::parse(&options.signer_id), + "signer-id", + )?, + public_key_b64: parsed(options.public_key.clone().try_into(), "public-key")?, + issued_at: now(options.issued_at)?, + not_before: options.not_before, + not_after: options.not_after, + }; + let certificate = if commissioning { + root.certify_commissioner(request)? + } else { + root.certify(request)? + }; + write(&options.out, certificate.canonical_bytes()) +} + fn ceremony(command: Command) -> Result<(), Failure> { match command { Command::RootInit { @@ -471,26 +535,8 @@ fn ceremony(command: Command) -> Result<(), Failure> { println!("public_key={}", seed.public_key().as_str()); Ok(()) } - Command::Certify { - root_key, - root, - signer_id, - public_key, - issued_at, - not_before, - not_after, - out, - } => { - let root = RootSigner::open(&read_key(&root_key)?, trust_root(&root)?)?; - let certificate = root.certify(CertificateRequest { - signer_id: parsed(QualificationSignerId::parse(signer_id), "signer-id")?, - public_key_b64: parsed(public_key.try_into(), "public-key")?, - issued_at: now(issued_at)?, - not_before, - not_after, - })?; - write(&out, certificate.canonical_bytes()) - } + Command::Certify(options) => certify(&options, false), + Command::CertifyCommissioner(options) => certify(&options, true), Command::Revoke { root_key, root, @@ -659,6 +705,32 @@ fn stage(command: Command) -> Result<(), Failure> { } } +fn commissioning_sign(options: &CommissioningSignOptions) -> Result<(), Failure> { + let binding: CommissioningBinding = json(&options.binding)?; + let offline = |path: &Path| { + QualificationEvidence::from_canonical_json(&read(path)?) + .map_err(|_| failure("invalid-evidence", path)) + }; + let proposal = CommissioningProposal::assemble( + binding, + offline(&options.conformance)?, + offline(&options.differential)?, + )?; + let certificate = + QualificationSignerCertificate::from_canonical_json(&read(&options.certificate)?) + .map_err(|_| failure("invalid-certificate", &options.certificate))?; + let signer = CommissioningSigner::open(&read_key(&options.signer_key)?, certificate)?; + let permit = signer.permit( + &proposal, + now(options.issued_at)?, + options.not_before, + options.not_after, + )?; + write(&options.out, permit.canonical_bytes())?; + println!("commissioning_permit={}", permit.digest().to_hex()); + Ok(()) +} + fn run(command: Command) -> Result<(), Failure> { match command { Command::RunStage { @@ -678,13 +750,15 @@ fn run(command: Command) -> Result<(), Failure> { ), Command::RootInit { .. } | Command::SignerInit { .. } - | Command::Certify { .. } + | Command::Certify(_) + | Command::CertifyCommissioner(_) | Command::Revoke { .. } => ceremony(command), Command::Evidence { .. } | Command::Assemble { .. } => build(command), Command::StageTrust { .. } | Command::StageFreshness { .. } | Command::StageRedaction { .. } | Command::ContractId { .. } => stage(command), + Command::CommissioningSign(options) => commissioning_sign(&options), Command::Sign { proposal_dirs, signer_key, diff --git a/product/qualification/auths-recipe-qualification-issuance/src/bin/qualification_runner/mod.rs b/product/qualification/auths-recipe-qualification-issuance/src/bin/qualification_runner/mod.rs index eba09ef59..2aa58e141 100644 --- a/product/qualification/auths-recipe-qualification-issuance/src/bin/qualification_runner/mod.rs +++ b/product/qualification/auths-recipe-qualification-issuance/src/bin/qualification_runner/mod.rs @@ -112,6 +112,7 @@ pub(super) fn run( ) -> Result<(), Failure> { let phase = match phase { "offline" => RunPhase::Offline, + "commissioning" => RunPhase::Commissioning, "live" => RunPhase::Live, _ => return Err(refused()), }; @@ -122,8 +123,17 @@ pub(super) fn run( let work = fs::canonicalize(work).map_err(|_| refused())?; let phase_token = match phase { RunPhase::Offline => "offline", + RunPhase::Commissioning => "commissioning", RunPhase::Live => "live", }; + for index in 0..auths_recipe_qualification_issuance::execution::MAX_RUN_CASES { + let path = work.join(format!("scan/trace/{phase_token}-{index:03}.json")); + match fs::remove_file(path) { + Ok(()) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(_) => return Err(refused()), + } + } for member in EvidenceMemberKind::ALL { let token = serde_json::to_value(member).map_err(|_| refused())?; let path = work.join(format!( @@ -136,8 +146,8 @@ pub(super) fn run( Err(_) => return Err(refused()), } } - if phase == RunPhase::Live { - match fs::remove_file(work.join("live-effects.json")) { + if phase != RunPhase::Offline { + match fs::remove_file(work.join(format!("{phase_token}-effects.json"))) { Ok(()) => (), Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), Err(_) => return Err(refused()), @@ -154,11 +164,17 @@ pub(super) fn run( entered: 0, confirmed_by_read_back: 0, }; - for case in corpus.cases.iter().filter(|case| case.phase == phase) { + for (case_index, case) in corpus + .cases + .iter() + .enumerate() + .filter(|(_, case)| case.phase == phase) + { + let mut observations = Vec::with_capacity(case.steps.len()); let (report, effects) = case.execute(&tuple, |index, step| { let operation = serde_json::to_value(step.operation).map_err(|_| IssuanceError::CaseFailed)?; - execute_step( + let observation = execute_step( &harness, case.id.as_str(), index, @@ -166,8 +182,24 @@ pub(super) fn run( &work, Duration::from_secs(timeout_seconds), ) - .map_err(|_| IssuanceError::CaseFailed) + .map_err(|_| IssuanceError::CaseFailed)?; + observations.push(observation.clone()); + Ok(observation) })?; + // Closed, bounded observations remain auditable and enter the existing + // trace scan. Provider bodies, credentials and child output never do. + let trace = serde_json::to_vec(&serde_json::json!({ + "schema": "auths.qualification-execution-trace/1", + "phase": phase, + "tuple_sha256": tuple.digest().map_err(|_| refused())?, + "case": case.id, + "observations": observations, + })) + .map_err(|_| refused())?; + write( + &work.join(format!("scan/trace/{phase_token}-{case_index:03}.json")), + &trace, + )?; let member = case.scenario.member(); let token = serde_json::to_value(member).map_err(|_| refused())?; reports @@ -186,7 +218,7 @@ pub(super) fn run( } } if reports.is_empty() - || (phase == RunPhase::Live + || (phase != RunPhase::Offline && (live.entered == 0 || live.entered != live.confirmed_by_read_back)) { return Err(refused()); @@ -197,9 +229,9 @@ pub(super) fn run( &cases, )?; } - if phase == RunPhase::Live { + if phase != RunPhase::Offline { let bytes = serde_json::to_vec(&live).map_err(|_| refused())?; - write(&work.join("live-effects.json"), &bytes)?; + write(&work.join(format!("{phase_token}-effects.json")), &bytes)?; } Ok(()) } diff --git a/product/qualification/auths-recipe-qualification-issuance/src/commissioning.rs b/product/qualification/auths-recipe-qualification-issuance/src/commissioning.rs new file mode 100644 index 000000000..0c7fb9812 --- /dev/null +++ b/product/qualification/auths-recipe-qualification-issuance/src/commissioning.rs @@ -0,0 +1,99 @@ +//! Release-side closure of a finite commissioning proposal. Provider-specific +//! resource/oracle expansion remains in the reviewed family harness, never in +//! the gateway or this shared artifact verifier. + +use crate::IssuanceError; +use auths_recipe_qualification::{ + ClosureFault, CommissioningBinding, EvidenceMemberKind, QualificationEvidence, Scenario, +}; + +/// Immutable reviewed run bindings and the offline evidence they name. +/// +/// Only [`Self::assemble`] constructs this value. It rederives both evidence +/// digests, source/tuple identity and all required offline scenarios. The +/// protected workflow must separately derive resources and allowed actions +/// from its reviewed reference before calling it; candidate results cannot +/// supply an expected request or evidence commitment. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommissioningProposal { + binding: CommissioningBinding, + conformance: QualificationEvidence, + differential: QualificationEvidence, +} + +impl CommissioningProposal { + /// Re-verifies the two canonical offline artifacts and the finite bindings. + /// + /// This is evidence closure, not a provider qualification. The live wall + /// does not exist yet and this value cannot enter a release index. + /// + /// # Errors + /// + /// Returns [`IssuanceError::Closure`] for a changed artifact, missing + /// required offline scenario or another candidate/member, and + /// [`IssuanceError::Format`] for nonproduction, empty, excessive or + /// unordered authority. Inputs are not sorted or silently repaired. + pub fn assemble( + binding: CommissioningBinding, + conformance: QualificationEvidence, + differential: QualificationEvidence, + ) -> Result { + binding.validate()?; + let tuple_digest = binding.tuple.digest()?; + for (artifact, member, expected_digest) in [ + ( + &conformance, + EvidenceMemberKind::Conformance, + binding.offline_evidence.conformance_sha256, + ), + ( + &differential, + EvidenceMemberKind::Differential, + binding.offline_evidence.differential_sha256, + ), + ] { + let body = artifact.body(); + if body.member != member { + return Err(ClosureFault::MemberSet.into()); + } + if artifact.digest() != expected_digest { + return Err(ClosureFault::Digest.into()); + } + if body.commit != binding.source_commit || body.tuple_sha256 != tuple_digest { + return Err(ClosureFault::Candidate.into()); + } + if body + .cases + .iter() + .any(|case| case.unauthorized_provider_entries != 0) + { + return Err(IssuanceError::CaseFailed); + } + for scenario in Scenario::ALL { + if scenario.always_required() + && scenario.member() == member + && !body.cases.iter().any(|case| case.scenario == scenario) + { + return Err(ClosureFault::Scenario.into()); + } + } + } + Ok(Self { + binding, + conformance, + differential, + }) + } + + /// Exact bindings checked by this proposal's constructor. + #[must_use] + pub const fn binding(&self) -> &CommissioningBinding { + &self.binding + } + + /// Re-verified conformance and differential evidence, in member order. + #[must_use] + pub const fn offline_evidence(&self) -> [&QualificationEvidence; 2] { + [&self.conformance, &self.differential] + } +} diff --git a/product/qualification/auths-recipe-qualification-issuance/src/execution.rs b/product/qualification/auths-recipe-qualification-issuance/src/execution.rs index 87cca3aef..1dc21ff9b 100644 --- a/product/qualification/auths-recipe-qualification-issuance/src/execution.rs +++ b/product/qualification/auths-recipe-qualification-issuance/src/execution.rs @@ -17,7 +17,7 @@ pub const MAX_RUN_CASES: usize = 256; /// Largest sequence of operations within one case. pub const MAX_CASE_STEPS: usize = 32; /// Schema of the reviewed executable corpus. -pub const CORPUS_SCHEMA: &str = "auths.qualification-corpus/1"; +pub const CORPUS_SCHEMA: &str = "auths.qualification-corpus/3"; /// Where an operation may execute. Live cases never execute on a pull request. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -25,6 +25,9 @@ pub const CORPUS_SCHEMA: &str = "auths.qualification-corpus/1"; pub enum RunPhase { /// No provider credential; an offline provider double is permitted. Offline, + /// First-run production evidence through finite private operator authority. + /// Ordinary installed clients must still demonstrate qualification refusal. + Commissioning, /// Disposable provider resources in a protected environment. Live, } @@ -35,6 +38,8 @@ pub enum RunPhase { pub enum Operation { /// Evaluate the family's pure request/evidence oracle. Oracle, + /// Credential-free native proof/request review, only in offline differential cases. + Review, /// Submit through the candidate gateway's application socket. Submit, /// Replay the same logical operation, possibly with a fresh challenge. @@ -103,6 +108,44 @@ pub struct ExpectedObservation { pub confirmed_by_read_back: u32, } +/// Source-owned evidence comparison. Every non-evidence fact stays exact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum EvidenceComparison { + /// Compare the complete verdict, including a precomputed evidence digest. + Static {}, + /// Compare an observed candidate response digest with a separately obtained + /// fresh provider response over this exact reviewed resource/action subject. + IndependentReadBack { + /// Domain-bound resource/action/expected-state commitment from the oracle. + subject_sha256: Sha256Digest, + }, + /// Compare the digest of a checked production doctor report for this tuple. + ProductionDoctor {}, +} + +/// Fresh evidence retained by the release harness separately from the candidate +/// verdict. The harness must execute the reviewed oracle/read independently; +/// copying the candidate's digest is not an independent witness. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum FreshEvidenceWitness { + /// Exact bytes from a new bounded read of the reviewed provider resource. + IndependentReadBack { + /// The same source-owned subject the corpus commits to. + subject_sha256: Sha256Digest, + /// SHA-256 of the independently read response bytes. + response_sha256: Sha256Digest, + }, + /// A source-reviewed production doctor check, including tuple equality. + ProductionDoctor { + /// The actual candidate tuple checked in the report. + tuple_sha256: Sha256Digest, + /// SHA-256 of the independently validated raw doctor report. + report_sha256: Sha256Digest, + }, +} + /// One actual observation. There is deliberately no `passed` member. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -111,6 +154,8 @@ pub struct RunObservation { pub tuple_sha256: Sha256Digest, /// Facts compared with the corpus, including independently measured counters. pub observed: ExpectedObservation, + /// An independently obtained fresh witness, only for the declared comparison. + pub fresh_evidence: Option, /// Provider entries not authorized by the exact action. pub unauthorized_provider_entries: u32, /// Whether the application or exported outputs exposed a secret. @@ -127,6 +172,8 @@ pub struct RunObservation { pub struct RunStep { /// Operation executed by the family harness on the candidate. pub operation: Operation, + /// Explicit evidence source; never an optional wildcard digest. + pub evidence_comparison: EvidenceComparison, /// Assertions made by the release runner, not the family harness. pub expected: ExpectedObservation, } @@ -215,7 +262,7 @@ impl RunCase { _ => false, }; let required = match self.scenario { - S::OracleAccepts | S::OracleRejects => before(Op::Oracle, Op::Submit), + S::OracleAccepts | S::OracleRejects => before(Op::Oracle, Op::Review), S::ProofReplay | S::FreshChallengeReplay => before(Op::Submit, Op::Replay), S::TwoInstanceRace => has(Op::Race), S::Restart => before(Op::Submit, Op::Restart) && before(Op::Restart, Op::Replay), @@ -229,6 +276,22 @@ impl RunCase { has(Op::InstalledConsumer) } S::DeclaredCapability => has(Op::Submit) || has(Op::Probe), + S::ProductionReadiness => { + self.phase == RunPhase::Live + && has(Op::Probe) + && self.steps.iter().all(|step| { + step.operation == Op::Probe + && step.expected.verdict.outcome == RunOutcome::Complete + && step.expected.verdict.code.as_str() == "production-readiness-passed" + && step.expected.verdict.request_sha256.is_none() + && (step.expected.verdict.evidence_sha256.is_some() + || step.evidence_comparison + == EvidenceComparison::ProductionDoctor {}) + && step.expected.credential_leases == 0 + && step.expected.provider_entries == 0 + && step.expected.confirmed_by_read_back == 0 + }) + } _ => has(Op::Probe), }; let live_only = matches!( @@ -240,13 +303,28 @@ impl RunCase { | S::InstalledJourney | S::NoRepositoryImport | S::NoProviderToken + | S::ProductionReadiness ); if !harness_scenario(self.scenario) + || self.steps.iter().any(|step| !step.valid_comparison(self)) + || self.steps.iter().any(|step| { + matches!(step.operation, Op::Oracle | Op::Review) + && (step.expected.credential_leases != 0 + || step.expected.provider_entries != 0 + || step.expected.confirmed_by_read_back != 0 + || !matches!( + step.expected.verdict.outcome, + RunOutcome::Complete | RunOutcome::Refused + )) + }) || !required || self.steps.is_empty() || self.steps.len() > MAX_CASE_STEPS - || (live_only && self.phase != RunPhase::Live) + || (live_only && self.phase == RunPhase::Offline) || (has(Op::Oracle) && self.phase != RunPhase::Offline) + || (has(Op::Review) + && (self.phase != RunPhase::Offline + || !matches!(self.scenario, S::OracleAccepts | S::OracleRejects))) || self .capabilities .iter() @@ -280,6 +358,13 @@ impl RunCase { mut observe: impl FnMut(usize, &RunStep) -> Result, ) -> Result<(CaseReport, LiveEffects), IssuanceError> { self.validate()?; + if (self.scenario == Scenario::ProductionReadiness || self.phase == RunPhase::Commissioning) + && (tuple.target.store_kind + != auths_recipe_qualification::LifecycleStoreKind::PostgresqlV1 + || !tuple.target.credential_store_kind.is_production()) + { + return Err(IssuanceError::CaseFailed); + } let tuple_digest = tuple.digest()?; let mut observations = Vec::with_capacity(self.steps.len()); let mut effects = LiveEffects { @@ -289,7 +374,7 @@ impl RunCase { for (index, step) in self.steps.iter().enumerate() { let actual = observe(index, step)?; if actual.tuple_sha256 != tuple_digest - || actual.observed != step.expected + || !step.matches(&actual, tuple_digest) || actual.unauthorized_provider_entries != 0 || actual.secret_exposed || actual.repository_imported @@ -297,14 +382,14 @@ impl RunCase { || (actual.observed.confirmed_by_read_back > 0 && (actual.observed.verdict.outcome != RunOutcome::Observed || actual.observed.verdict.evidence_sha256.is_none())) - || (step.operation == Operation::Oracle + || (matches!(step.operation, Operation::Oracle | Operation::Review) && (actual.observed.credential_leases != 0 || actual.observed.provider_entries != 0 || actual.observed.confirmed_by_read_back != 0)) { return Err(IssuanceError::CaseFailed); } - if step.operation != Operation::Oracle { + if !matches!(step.operation, Operation::Oracle | Operation::Review) { effects.entered = effects .entered .checked_add(actual.observed.provider_entries) @@ -332,6 +417,31 @@ impl RunCase { effects: LiveEffects, ) -> Result<(), IssuanceError> { use Scenario as S; + if self.scenario == S::InstalledJourney { + let ordinary_effect = self.phase == RunPhase::Live + && effects.entered > 0 + && effects.entered == effects.confirmed_by_read_back + && self.steps.iter().zip(observations).any(|(step, actual)| { + step.operation == Operation::InstalledConsumer + && actual.verdict.outcome == RunOutcome::Observed + && actual.provider_entries > 0 + && actual.provider_entries == actual.confirmed_by_read_back + }); + let first_run_refusal = self.phase == RunPhase::Commissioning + && observations.iter().all(|actual| { + actual.verdict.outcome == RunOutcome::Refused + && matches!( + actual.verdict.code.as_str(), + "gateway.qualification.unavailable" | "gateway.qualification.missing" + ) + && actual.credential_leases == 0 + && actual.provider_entries == 0 + && actual.confirmed_by_read_back == 0 + }); + if !ordinary_effect && !first_run_refusal { + return Err(IssuanceError::CaseFailed); + } + } let no_entry = matches!( self.scenario, S::ApplicationCannotReadSecret @@ -357,11 +467,31 @@ impl RunCase { { return Err(IssuanceError::CaseFailed); } - if self.steps.iter().zip(observations).any(|(step, actual)| { - step.operation == Operation::Replay - && (actual.provider_entries != 0 || actual.credential_leases != 0) - }) { - return Err(IssuanceError::CaseFailed); + let mut unresolved = false; + for (step, actual) in self.steps.iter().zip(observations) { + if step.operation == Operation::Replay { + let read_only_completion = unresolved + && actual.credential_leases <= 1 + && matches!( + actual.verdict.outcome, + RunOutcome::Unknown | RunOutcome::ResponseRecorded | RunOutcome::Observed + ); + if actual.provider_entries != 0 + || (actual.credential_leases != 0 && !read_only_completion) + { + return Err(IssuanceError::CaseFailed); + } + } + if step.operation != Operation::Oracle { + if actual.provider_entries > 0 { + unresolved = matches!( + actual.verdict.outcome, + RunOutcome::Unknown | RunOutcome::ResponseRecorded + ); + } else if actual.verdict.outcome == RunOutcome::Observed { + unresolved = false; + } + } } if matches!( self.scenario, @@ -386,7 +516,7 @@ impl RunCase { .map(|(_, actual)| &actual.verdict) }; let oracle = verdict(Operation::Oracle).ok_or(IssuanceError::CaseFailed)?; - if Some(oracle) != verdict(Operation::Submit) + if Some(oracle) != verdict(Operation::Review) || (oracle.outcome == RunOutcome::Refused) != (self.scenario == S::OracleRejects) || (self.scenario == S::OracleAccepts && oracle.request_sha256.is_none()) { @@ -414,3 +544,57 @@ impl RunCase { Ok(()) } } + +impl RunStep { + fn valid_comparison(&self, case: &RunCase) -> bool { + use EvidenceComparison as Comparison; + match self.evidence_comparison { + Comparison::Static {} => true, + Comparison::IndependentReadBack { .. } => { + case.phase != RunPhase::Offline + && self.expected.verdict.evidence_sha256.is_none() + && self.expected.verdict.outcome == RunOutcome::Observed + && matches!( + self.operation, + Operation::Submit + | Operation::Replay + | Operation::Race + | Operation::ReadBack + | Operation::InstalledConsumer + ) + } + Comparison::ProductionDoctor {} => { + case.phase == RunPhase::Live + && case.scenario == Scenario::ProductionReadiness + && self.operation == Operation::Probe + && self.expected.verdict.evidence_sha256.is_none() + } + } + } + + fn matches(&self, actual: &RunObservation, tuple: Sha256Digest) -> bool { + use EvidenceComparison as Comparison; + use FreshEvidenceWitness as Witness; + let expected_evidence = match (&self.evidence_comparison, &actual.fresh_evidence) { + (Comparison::Static {}, None) => return actual.observed == self.expected, + ( + Comparison::IndependentReadBack { subject_sha256 }, + Some(Witness::IndependentReadBack { + subject_sha256: witnessed, + response_sha256, + }), + ) if subject_sha256 == witnessed => Some(*response_sha256), + ( + Comparison::ProductionDoctor {}, + Some(Witness::ProductionDoctor { + tuple_sha256, + report_sha256, + }), + ) if tuple_sha256 == &tuple => Some(*report_sha256), + _ => return false, + }; + let mut expected = self.expected.clone(); + expected.verdict.evidence_sha256 = expected_evidence; + actual.observed == expected + } +} diff --git a/product/qualification/auths-recipe-qualification-issuance/src/lib.rs b/product/qualification/auths-recipe-qualification-issuance/src/lib.rs index 1d7b395d1..d4351cc3b 100644 --- a/product/qualification/auths-recipe-qualification-issuance/src/lib.rs +++ b/product/qualification/auths-recipe-qualification-issuance/src/lib.rs @@ -12,6 +12,7 @@ #![forbid(unsafe_code)] +mod commissioning; mod error; pub mod execution; mod proposal; @@ -20,6 +21,7 @@ pub mod stages; #[cfg(feature = "testkit")] pub mod testkit; +pub use commissioning::CommissioningProposal; pub use error::IssuanceError; pub use proposal::{CaseReport, NotApplicable, QualificationProposal, RecordDraft, evidence}; -pub use sign::{CertificateRequest, ReleaseSigner, RootSigner, SigningSeed}; +pub use sign::{CertificateRequest, CommissioningSigner, ReleaseSigner, RootSigner, SigningSeed}; diff --git a/product/qualification/auths-recipe-qualification-issuance/src/sign.rs b/product/qualification/auths-recipe-qualification-issuance/src/sign.rs index d00d9f73a..85dc2ffe3 100644 --- a/product/qualification/auths-recipe-qualification-issuance/src/sign.rs +++ b/product/qualification/auths-recipe-qualification-issuance/src/sign.rs @@ -1,11 +1,13 @@ -//! The two signing roles. A trust root signs signer certificates and +//! The distinct signing roles. A trust root signs signer certificates and //! revocation lists. A release signer signs attestations and the release -//! index. Neither type has a method for the other's artifacts. +//! index. A commissioning signer signs finite run permits. None can sign +//! another role's artifacts. -use crate::{IssuanceError, QualificationProposal}; +use crate::{CommissioningProposal, IssuanceError, QualificationProposal}; use auths_recipe_qualification::{ - ATTESTATION_SCHEMA, AttestationBody, AttestationStatement, PublicKeyB64, - QualificationArtifactKind, QualificationId, QualificationReleaseIndex, + ATTESTATION_SCHEMA, AttestationBody, AttestationStatement, COMMISSIONING_PERMIT_SCHEMA, + CommissioningPermitBody, CommissioningPermitStatement, PublicKeyB64, QualificationArtifactKind, + QualificationCommissioningPermit, QualificationId, QualificationReleaseIndex, QualificationRevocationList, QualificationRootId, QualificationSignatureSuite, QualificationSignerCertificate, QualificationSignerId, QualificationSignerKind, QualificationTrustRoot, RELEASE_INDEX_SCHEMA, REVOCATION_LIST_SCHEMA, @@ -141,6 +143,39 @@ impl RootSigner { pub fn certify( &self, request: CertificateRequest, + ) -> Result { + self.certify_for( + request, + vec![ + QualificationArtifactKind::QualificationReleaseIndex, + QualificationArtifactKind::RecipeQualificationAttestation, + ], + ) + } + + /// Certifies a commissioning signer for finite permits only. + /// + /// The certificate carries no attestation or release-index permission. + /// This offline ceremony does not authorize any action: a later protected + /// signer must re-verify a closed commissioning proposal. + /// + /// # Errors + /// + /// Returns [`IssuanceError::Format`] for an invalid certificate window. + pub fn certify_commissioner( + &self, + request: CertificateRequest, + ) -> Result { + self.certify_for( + request, + vec![QualificationArtifactKind::QualificationCommissioningPermit], + ) + } + + fn certify_for( + &self, + request: CertificateRequest, + permitted_artifact_kinds: Vec, ) -> Result { let mut body = SignerCertificateBody { statement: SignerCertificateStatement { @@ -152,10 +187,7 @@ impl RootSigner { issued_at: request.issued_at, not_before: request.not_before, not_after: request.not_after, - permitted_artifact_kinds: vec![ - QualificationArtifactKind::QualificationReleaseIndex, - QualificationArtifactKind::RecipeQualificationAttestation, - ], + permitted_artifact_kinds, root_id: self.root.body().root_id.clone(), }, root_signature_b64: SignatureB64::from_bytes(&[0; 64]), @@ -209,6 +241,98 @@ impl fmt::Debug for RootSigner { } } +/// A protected commissioning key, able to sign closed finite proposals only. +/// It has no release-index, attestation, certificate or revocation method. +pub struct CommissioningSigner { + key: SigningKey, + certificate: QualificationSignerCertificate, +} + +impl CommissioningSigner { + /// Opens the exact key under a commissioning-only certificate. + /// + /// # Errors + /// + /// Returns [`IssuanceError::NotPermitted`] for a release or mixed-purpose + /// certificate, or [`IssuanceError::KeyMismatch`] for another seed. Root + /// authentication is performed independently by the receiving verifier. + pub fn open( + seed: &SigningSeed, + certificate: QualificationSignerCertificate, + ) -> Result { + if certificate.body().statement.permitted_artifact_kinds + != [QualificationArtifactKind::QualificationCommissioningPermit] + { + return Err(IssuanceError::NotPermitted); + } + if seed.public_key() != certificate.body().statement.public_key_b64 { + return Err(IssuanceError::KeyMismatch); + } + Ok(Self { + key: seed.key(), + certificate, + }) + } + + /// The public purpose certificate, without any private key material. + #[must_use] + pub const fn certificate(&self) -> &QualificationSignerCertificate { + &self.certificate + } + + /// Signs one proposal after its exact offline evidence was checked. + /// + /// The receiving gateway still authenticates the certificate under its + /// pinned root, checks current revocations and runtime bindings, and + /// claims the immutable shared budget before any custody acquisition. + /// No qualification record or readiness assertion is produced. + /// + /// # Errors + /// + /// Returns [`IssuanceError::Window`] outside the certificate's window or + /// [`IssuanceError::Format`] for any permit bound, ordering or time fault. + pub fn permit( + &self, + proposal: &CommissioningProposal, + issued_at: u64, + not_before: u64, + not_after: u64, + ) -> Result { + let signer = &self.certificate.body().statement; + if issued_at < signer.issued_at + || not_before < signer.not_before + || not_after > signer.not_after + { + return Err(IssuanceError::Window); + } + let mut body = CommissioningPermitBody { + statement: CommissioningPermitStatement { + schema: COMMISSIONING_PERMIT_SCHEMA.to_owned(), + binding: proposal.binding().clone(), + signer_id: signer.signer_id.clone(), + signature_suite: QualificationSignatureSuite::Ed25519V1, + issued_at, + not_before, + not_after, + }, + signature_b64: SignatureB64::from_bytes(&[0; 64]), + }; + // Refuse malformed authority before producing any signature bytes. + QualificationCommissioningPermit::from_body(&body)?; + body.signature_b64 = signature(&self.key, &body.signing_preimage()?); + Ok(QualificationCommissioningPermit::from_body(&body)?) + } +} + +impl fmt::Debug for CommissioningSigner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CommissioningSigner") + .field("signer_id", &self.certificate.body().statement.signer_id) + .finish_non_exhaustive() + } +} + /// A release signer: the key a certificate names, able to sign attestations /// and the release index and nothing else. pub struct ReleaseSigner { diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/cli.rs b/product/qualification/auths-recipe-qualification-issuance/tests/cli.rs index 22a8e1e45..65e2fabab 100644 --- a/product/qualification/auths-recipe-qualification-issuance/tests/cli.rs +++ b/product/qualification/auths-recipe-qualification-issuance/tests/cli.rs @@ -56,6 +56,161 @@ fn member_token(member: EvidenceMemberKind) -> String { .to_owned() } +#[test] +#[cfg(unix)] +fn commissioning_cli_requires_its_own_purpose_and_rechecks_offline_evidence() { + use auths_recipe_qualification::{ + BoundedText, CommissioningBinding, CommissioningInputs, CommissioningOfflineEvidence, + ProviderEnvironmentClass, QualificationArtifactKind, QualificationCommissioningPermit, + QualificationSignerCertificate, QualificationTrustRoot, Sha256Digest, + VerifiedCommissioningPermit, + }; + let directory = tempfile::tempdir().expect("private ceremony"); + let at = |name: &str| path(directory.path(), name); + succeed(&[ + "root-init", + "--root-id", + "synthetic-cli-root", + "--key-out", + &at("root.key"), + "--root-out", + &at("root.json"), + ]); + let public_key = succeed(&["signer-init", "--key-out", &at("commissioner.key")]) + .trim() + .strip_prefix("public_key=") + .expect("public key") + .to_owned(); + let mut certificates = Vec::new(); + for (command, filename) in [ + ("certify-commissioner", "commissioner.json"), + ("certify", "release.json"), + ] { + succeed(&[ + command, + "--root-key", + &at("root.key"), + "--root", + &at("root.json"), + "--signer-id", + "synthetic-cli-signer", + "--public-key", + &public_key, + "--issued-at", + &(NOW - DAY).to_string(), + "--not-before", + &(NOW - DAY).to_string(), + "--not-after", + &(NOW + DAY).to_string(), + "--out", + &at(filename), + ]); + certificates.push( + QualificationSignerCertificate::from_canonical_json( + &fs::read(at(filename)).expect("certificate"), + ) + .expect("closed certificate"), + ); + } + assert_eq!( + certificates[0].body().statement.permitted_artifact_kinds, + vec![QualificationArtifactKind::QualificationCommissioningPermit] + ); + assert!( + !certificates[1] + .body() + .statement + .permitted_artifact_kinds + .contains(&QualificationArtifactKind::QualificationCommissioningPermit) + ); + succeed(&[ + "revoke", + "--root-key", + &at("root.key"), + "--root", + &at("root.json"), + "--sequence", + "1", + "--issued-at", + &(NOW - HOUR).to_string(), + "--next-update", + &(NOW + DAY).to_string(), + "--out", + &at("revocations.json"), + ]); + let conformance = common::member_evidence(EvidenceMemberKind::Conformance); + let differential = common::member_evidence(EvidenceMemberKind::Differential); + let binding = CommissioningBinding { + protected_run: BoundedText::parse("synthetic-run/attempt/1").expect("run"), + source_commit: common::commit(), + tuple: common::tuple(), + principal_sha256: Sha256Digest::from_bytes([0x61; 32]), + trusted_contexts_sha256: vec![Sha256Digest::from_bytes([0x62; 32])], + resources_sha256: Sha256Digest::from_bytes([0x63; 32]), + provider_environment_class: ProviderEnvironmentClass::ProviderTestMode, + offline_evidence: CommissioningOfflineEvidence { + conformance_sha256: conformance.digest(), + differential_sha256: differential.digest(), + }, + allowed_actions: vec![Sha256Digest::from_bytes([0x64; 32])], + maximum_credential_leases: 2, + }; + fs::write( + at("binding.json"), + serde_json::to_vec(&binding).expect("binding"), + ) + .expect("write"); + fs::write(at("conformance.json"), conformance.canonical_bytes()).expect("write"); + fs::write(at("differential.json"), differential.canonical_bytes()).expect("write"); + let arguments = |certificate: &str| { + vec![ + "commissioning-sign".to_owned(), + "--binding".to_owned(), + at("binding.json"), + "--conformance".to_owned(), + at("conformance.json"), + "--differential".to_owned(), + at("differential.json"), + "--signer-key".to_owned(), + at("commissioner.key"), + "--certificate".to_owned(), + at(certificate), + "--issued-at".to_owned(), + NOW.to_string(), + "--not-before".to_owned(), + NOW.to_string(), + "--not-after".to_owned(), + (NOW + HOUR).to_string(), + "--out".to_owned(), + at("permit.json"), + ] + }; + assert!(refuse(&borrowed(&arguments("release.json"))).starts_with("qualification.")); + assert!(!Path::new(&at("permit.json")).exists()); + succeed(&borrowed(&arguments("commissioner.json"))); + let permit_bytes = fs::read(at("permit.json")).expect("permit"); + let permit = QualificationCommissioningPermit::from_canonical_json(&permit_bytes) + .expect("closed permit"); + assert_eq!(&permit.body().statement.binding, &binding); + VerifiedCommissioningPermit::verify( + &QualificationTrustRoot::from_canonical_json(&fs::read(at("root.json")).expect("root")) + .expect("closed root"), + &CommissioningInputs { + signer_certificate: certificates[0].canonical_bytes(), + revocation_list: &fs::read(at("revocations.json")).expect("list"), + permit: &permit_bytes, + }, + ) + .expect("native artifact verification"); + // A mislabeled or changed offline member cannot replace the signed output. + fs::write(at("conformance.json"), differential.canonical_bytes()).expect("mutate member"); + assert!(refuse(&borrowed(&arguments("commissioner.json"))).starts_with("qualification.")); + assert_eq!( + fs::read(at("permit.json")).expect("retained permit"), + permit_bytes + ); +} + #[test] #[cfg(unix)] fn a_release_is_built_signed_and_verified_and_a_proposal_alone_is_not() { @@ -290,6 +445,49 @@ fn a_release_is_built_signed_and_verified_and_a_proposal_alone_is_not() { ); succeed(&borrowed(&sign("proposal", "signer.key"))); + // A consumer must be able to verify the signed record's closure using + // only the published release, after the unsigned proposal is unavailable. + let proposal_record = + auths_recipe_qualification::RecipeQualificationRecord::from_canonical_json( + &fs::read(at("proposal/record.json")).expect("proposal record"), + ) + .expect("record"); + let published_record = at(&format!( + "release/records/{}.json", + proposal_record.body().qualification_id.as_str() + )); + let record = auths_recipe_qualification::RecipeQualificationRecord::from_canonical_json( + &fs::read(&published_record).expect("published record"), + ) + .expect("published canonical record"); + assert_eq!(record.digest(), proposal_record.digest()); + fs::remove_dir_all(at("proposal")).expect("discard unsigned proposal"); + let artifacts: Vec = record + .body() + .evidence + .iter() + .map(|member| { + let artifact = auths_recipe_qualification::QualificationEvidence::from_canonical_json( + &fs::read(at(&format!( + "release/evidence/{}.json", + member.evidence_sha256.to_hex() + ))) + .expect("published evidence"), + ) + .expect("canonical evidence"); + assert_eq!(artifact.digest(), member.evidence_sha256); + artifact + }) + .collect(); + auths_recipe_qualification::verify_evidence_closure(&record, &artifacts) + .expect("published evidence closes over the signed record"); + assert_eq!( + fs::read_dir(at("release/evidence")) + .expect("published evidence directory") + .count(), + EvidenceMemberKind::ALL.len(), + "release includes exactly its evidence members" + ); fs::copy( at("unsigned/revocation-list.json"), at("release/revocation-list.json"), @@ -346,7 +544,7 @@ fn a_release_is_built_signed_and_verified_and_a_proposal_alone_is_not() { "--source", &format!("log={}", at("gateway.log")), "--source", - &format!("evidence={}", at("proposal/record.json")), + &format!("evidence={published_record}"), "--out", &at("redaction.cases.json"), ]); diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs b/product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs new file mode 100644 index 000000000..4cd220b08 --- /dev/null +++ b/product/qualification/auths-recipe-qualification-issuance/tests/commissioning.rs @@ -0,0 +1,746 @@ +//! Purpose separation, closed bounds and exact-run commissioning verification. +//! All keys and evidence are synthetic public test constants; no provider +//! qualification or durable lease consumption is claimed by these tests. + +#![allow(clippy::too_many_lines, reason = "explicit hostile case tables")] + +mod common; + +use auths_recipe_qualification::{ + BoundedText, ClosureFault, CommissioningBinding, CommissioningInputs, + CommissioningOfflineEvidence, CommissioningRefusal, CommissioningRequest, EvidenceMemberKind, + GitCommit, MAX_COMMISSIONING_ACTIONS, MAX_COMMISSIONING_CONTEXTS, MAX_COMMISSIONING_LEASES, + MAX_COMMISSIONING_PERMIT_BYTES, MAX_COMMISSIONING_SECONDS, ProviderEnvironmentClass, + QualificationArtifactKind, QualificationCommissioningPermit, QualificationEvidence, + QualificationFormatError, QualificationInputs, QualificationRevocationList, + QualificationRootId, QualificationSignerCertificate, QualificationSignerId, QualificationTuple, + RecipeQualificationState, Scenario, Sha256Digest, SignatureB64, VerifiedCommissioningPermit, + VerifiedQualifications, VerifierState, +}; +use auths_recipe_qualification_issuance::{ + CertificateRequest, CommissioningProposal, CommissioningSigner, IssuanceError, ReleaseSigner, + RootSigner, SigningSeed, +}; +use common::{DAY, HOUR, NOW, commit, member_evidence, tuple}; +use ed25519_dalek::{Signer as _, SigningKey}; +use serde_json::{Value, json}; +use zeroize::Zeroizing; + +fn digest(byte: u8) -> Sha256Digest { + Sha256Digest::from_bytes([byte; 32]) +} + +fn seed(byte: u8) -> SigningSeed { + SigningSeed::from_bytes(Zeroizing::new([byte; 32])) +} + +fn root() -> RootSigner { + RootSigner::create( + &seed(0x11), + QualificationRootId::parse("test-root").expect("root"), + ) + .expect("root") +} + +fn certificate_request() -> CertificateRequest { + CertificateRequest { + signer_id: QualificationSignerId::parse("test-commissioner").expect("signer"), + public_key_b64: seed(0x22).public_key(), + issued_at: NOW - DAY, + not_before: NOW - DAY, + not_after: NOW + DAY, + } +} + +fn commissioner(root: &RootSigner) -> CommissioningSigner { + CommissioningSigner::open( + &seed(0x22), + root.certify_commissioner(certificate_request()) + .expect("certificate"), + ) + .expect("commissioner") +} + +fn binding() -> CommissioningBinding { + CommissioningBinding { + protected_run: BoundedText::parse( + "github.com/auths-dev/auths-proof/actions/runs/1/attempts/1", + ) + .expect("run"), + source_commit: commit(), + tuple: tuple(), + principal_sha256: digest(0x60), + trusted_contexts_sha256: vec![digest(0x61), digest(0x65)], + resources_sha256: digest(0x62), + provider_environment_class: ProviderEnvironmentClass::ProviderTestMode, + offline_evidence: CommissioningOfflineEvidence { + conformance_sha256: member_evidence(EvidenceMemberKind::Conformance).digest(), + differential_sha256: member_evidence(EvidenceMemberKind::Differential).digest(), + }, + allowed_actions: vec![digest(0x63), digest(0x64)], + maximum_credential_leases: 32, + } +} + +fn proposal() -> CommissioningProposal { + CommissioningProposal::assemble( + binding(), + member_evidence(EvidenceMemberKind::Conformance), + member_evidence(EvidenceMemberKind::Differential), + ) + .expect("proposal") +} + +fn permit(signer: &CommissioningSigner) -> QualificationCommissioningPermit { + signer + .permit(&proposal(), NOW, NOW, NOW + MAX_COMMISSIONING_SECONDS) + .expect("permit") +} + +fn revocations(root: &RootSigner, sequence: u64) -> QualificationRevocationList { + root.revoke(sequence, NOW - HOUR, NOW + 24 * HOUR, vec![], vec![]) + .expect("list") +} + +fn verify( + root: &RootSigner, + certificate: &QualificationSignerCertificate, + list: &QualificationRevocationList, + permit: &QualificationCommissioningPermit, +) -> Result { + VerifiedCommissioningPermit::verify( + root.trust_root(), + &CommissioningInputs { + signer_certificate: certificate.canonical_bytes(), + revocation_list: list.canonical_bytes(), + permit: permit.canonical_bytes(), + }, + ) +} + +fn request(binding: &CommissioningBinding) -> CommissioningRequest<'_> { + CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &binding.tuple, + protected_run: &binding.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: binding.trusted_contexts_sha256[0], + resources_sha256: binding.resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + } +} + +#[test] +fn a_commissioning_permit_never_qualifies_ordinary_production() { + let root = root(); + let signer = commissioner(&root); + let permit = permit(&signer); + let list = revocations(&root, 1); + let verified = verify(&root, signer.certificate(), &list, &permit).expect("authority"); + assert_eq!(verified.permit(), &permit); + assert_eq!( + verified.evaluate(&request(&binding()), NOW, true, &VerifierState::default()), + Ok(()) + ); + // No permit bytes decode as a record, attestation or release index, even + // when the normal verifier receives the valid purpose certificate/list. + let ordinary = VerifiedQualifications::verify( + root.trust_root(), + &QualificationInputs { + signer_certificate: signer.certificate().canonical_bytes(), + revocation_list: list.canonical_bytes(), + release_index: permit.canonical_bytes(), + records: &[permit.canonical_bytes()], + attestations: &[permit.canonical_bytes()], + }, + ); + let verdict = ordinary.evaluate(&tuple(), NOW, true, &VerifierState::default()); + assert_eq!(verdict.state, RecipeQualificationState::Unqualified); + assert!(!verdict.permits_lease()); +} + +#[test] +fn commissioning_and_release_signers_have_separate_purposes() { + let root = root(); + let signer = commissioner(&root); + let release_certificate = root.certify(certificate_request()).expect("certificate"); + assert_eq!( + CommissioningSigner::open(&seed(0x22), release_certificate.clone()).map(|_| ()), + Err(IssuanceError::NotPermitted) + ); + assert_eq!( + CommissioningSigner::open(&seed(0x33), signer.certificate().clone()).map(|_| ()), + Err(IssuanceError::KeyMismatch) + ); + let release = ReleaseSigner::open(&seed(0x22), signer.certificate().clone()).expect("key"); + assert_eq!( + release.index(NOW, &[]).map(|_| ()), + Err(IssuanceError::NotPermitted) + ); + let record = auths_recipe_qualification_issuance::QualificationProposal::assemble( + common::draft(), + common::all_evidence(), + ) + .expect("record proposal"); + assert_eq!( + release.attest(&record, NOW, NOW, NOW + HOUR).map(|_| ()), + Err(IssuanceError::NotPermitted) + ); + let permit = permit(&signer); + let list = revocations(&root, 1); + assert_eq!( + verify(&root, &release_certificate, &list, &permit).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + + // Even an authentic mixed-purpose certificate is rejected. The offline + // authority must explicitly separate commissioning and qualification keys. + let mut mixed = signer.certificate().body().clone(); + mixed + .statement + .permitted_artifact_kinds + .push(QualificationArtifactKind::QualificationReleaseIndex); + mixed.root_signature_b64 = SignatureB64::from_bytes( + &SigningKey::from_bytes(seed(0x11).expose()) + .sign(&mixed.signing_preimage().expect("preimage")) + .to_bytes(), + ); + let mixed = QualificationSignerCertificate::from_body(&mixed).expect("mixed certificate"); + assert_eq!( + verify(&root, &mixed, &list, &permit).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + assert_eq!( + CommissioningSigner::open(&seed(0x22), mixed).map(|_| ()), + Err(IssuanceError::NotPermitted) + ); + assert!(!format!("{signer:?}").contains("key")); +} + +#[test] +fn each_signature_root_and_domain_is_required() { + let root = root(); + let signer = commissioner(&root); + let permit = permit(&signer); + let list = revocations(&root, 1); + let mut forged = permit.body().clone(); + forged.statement.binding.maximum_credential_leases += 1; + let forged = QualificationCommissioningPermit::from_body(&forged).expect("changed body"); + assert_eq!( + verify(&root, signer.certificate(), &list, &forged).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + let mut certificate = signer.certificate().body().clone(); + certificate.statement.not_after += 1; + let certificate = + QualificationSignerCertificate::from_body(&certificate).expect("changed certificate"); + assert_eq!( + verify(&root, &certificate, &list, &permit).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + let mut changed_list = list.body().clone(); + changed_list.statement.sequence += 1; + let changed_list = QualificationRevocationList::from_body(&changed_list).expect("changed list"); + assert_eq!( + verify(&root, signer.certificate(), &changed_list, &permit).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + let other_root = RootSigner::create( + &seed(0x33), + QualificationRootId::parse("test-root").expect("root"), + ) + .expect("root"); + assert_eq!( + verify(&other_root, signer.certificate(), &list, &permit).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + let mut wrong_domain = permit.body().clone(); + let preimage = wrong_domain.signing_preimage().expect("preimage"); + let statement_bytes = + &preimage[auths_recipe_qualification::COMMISSIONING_PERMIT_SCHEMA.len() + 1..]; + let substituted = [ + b"auths.recipe-qualification-attestation/1\0".as_slice(), + statement_bytes, + ] + .concat(); + wrong_domain.signature_b64 = SignatureB64::from_bytes( + &SigningKey::from_bytes(seed(0x22).expose()) + .sign(&substituted) + .to_bytes(), + ); + let wrong_domain = QualificationCommissioningPermit::from_body(&wrong_domain).expect("permit"); + assert_eq!( + verify(&root, signer.certificate(), &list, &wrong_domain).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); + let mut wrong_signer = permit.body().clone(); + wrong_signer.statement.signer_id = + QualificationSignerId::parse("another-commissioner").expect("signer"); + wrong_signer.signature_b64 = SignatureB64::from_bytes( + &SigningKey::from_bytes(seed(0x22).expose()) + .sign(&wrong_signer.signing_preimage().expect("preimage")) + .to_bytes(), + ); + let wrong_signer = QualificationCommissioningPermit::from_body(&wrong_signer).expect("permit"); + assert_eq!( + verify(&root, signer.certificate(), &list, &wrong_signer).map(|_| ()), + Err(CommissioningRefusal::Unavailable) + ); +} + +#[test] +fn every_runtime_binding_must_equal_the_reviewed_session() { + let root = root(); + let signer = commissioner(&root); + let verified = verify( + &root, + signer.certificate(), + &revocations(&root, 1), + &permit(&signer), + ) + .expect("authority"); + let evaluate = |request: &CommissioningRequest<'_>| { + verified.evaluate(request, NOW, true, &VerifierState::default()) + }; + let original = binding(); + let run = BoundedText::parse("github.com/auths-dev/auths-proof/actions/runs/2/attempts/1") + .expect("run"); + let other_commit = GitCommit::parse("f".repeat(40)).expect("commit"); + for request in [ + CommissioningRequest { + protected_run: &run, + ..request(&original) + }, + CommissioningRequest { + source_commit: &other_commit, + ..request(&original) + }, + CommissioningRequest { + principal_sha256: digest(0x70), + ..request(&original) + }, + CommissioningRequest { + trusted_context_sha256: digest(0x70), + ..request(&original) + }, + CommissioningRequest { + resources_sha256: digest(0x70), + ..request(&original) + }, + CommissioningRequest { + canonical_action_sha256: digest(0x70), + ..request(&original) + }, + ] { + assert_eq!( + evaluate(&request), + Err(CommissioningRefusal::BindingMismatch) + ); + } + for (pointer, changed) in [ + ("/recipe_family", json!("another-family")), + ("/compiled_recipe_sha256", json!(digest(0x70))), + ("/profile_lock_sha256", json!(digest(0x70))), + ("/provider_contract_id", json!(digest(0x70))), + ("/gateway_semantic_closure_sha256", json!(digest(0x70))), + ("/target/os", json!("macos")), + ("/target/arch", json!("aarch64")), + ("/target/gateway_package", json!("another-gateway")), + ("/target/gateway_version", json!("2.0.0")), + ("/target/gateway_build_sha256", json!(digest(0x70))), + ("/target/store_kind", json!("shared-file-v1")), + ("/target/store_schema", json!("another-schema")), + ("/target/credential_store_kind", json!("local-file-v1")), + ] { + let mut document = serde_json::to_value(tuple()).expect("tuple"); + *document.pointer_mut(pointer).expect("member") = changed; + let target: QualificationTuple = serde_json::from_value(document).expect("tuple shape"); + assert_eq!( + evaluate(&CommissioningRequest { + tuple: &target, + ..request(&original) + }), + Err(CommissioningRefusal::BindingMismatch), + "{pointer}" + ); + } + let mut another_allowed = request(&original); + another_allowed.canonical_action_sha256 = original.allowed_actions[1]; + assert_eq!(evaluate(&another_allowed), Ok(())); +} + +#[test] +fn commissioning_windows_are_half_open_and_require_trusted_time() { + let root = root(); + let signer = commissioner(&root); + let verified = verify( + &root, + signer.certificate(), + &revocations(&root, 1), + &permit(&signer), + ) + .expect("authority"); + let original = binding(); + for (time, trusted, expected) in [ + (NOW - 1, true, Err(CommissioningRefusal::ClockUntrusted)), + (NOW, false, Err(CommissioningRefusal::ClockUntrusted)), + (NOW, true, Ok(())), + (NOW + MAX_COMMISSIONING_SECONDS - 1, true, Ok(())), + ( + NOW + MAX_COMMISSIONING_SECONDS, + true, + Err(CommissioningRefusal::Expired), + ), + ] { + assert_eq!( + verified.evaluate( + &request(&original), + time, + trusted, + &VerifierState::default() + ), + expected + ); + } + let short_list = root + .revoke(1, NOW - HOUR, NOW + 1, vec![], vec![]) + .expect("list"); + let verified = + verify(&root, signer.certificate(), &short_list, &permit(&signer)).expect("authority"); + assert_eq!( + verified.evaluate( + &request(&original), + NOW + 1, + true, + &VerifierState::default() + ), + Err(CommissioningRefusal::RevocationStale) + ); + for (issued, start, end) in [ + (NOW, NOW, NOW), + (NOW, NOW + 1, NOW + MAX_COMMISSIONING_SECONDS + 1), + (NOW, NOW, NOW + MAX_COMMISSIONING_SECONDS + 1), + (NOW + 1, NOW, NOW + HOUR), + (NOW, NOW, u64::MAX), + ] { + assert_eq!( + signer.permit(&proposal(), issued, start, end).map(|_| ()), + if end == u64::MAX { + Err(IssuanceError::Window) + } else { + Err(IssuanceError::Format( + QualificationFormatError::InvalidTimeWindow, + )) + } + ); + } + assert_eq!( + signer + .permit( + &proposal(), + NOW - 2 * DAY, + NOW - 2 * DAY, + NOW - 2 * DAY + HOUR + ) + .map(|_| ()), + Err(IssuanceError::Window) + ); +} + +#[test] +fn revocations_are_permanent_and_an_older_list_cannot_restore_authority() { + let root = root(); + let signer = commissioner(&root); + let permit = permit(&signer); + let original = binding(); + let revoked = root + .revoke( + 2, + NOW - HOUR, + NOW + HOUR, + vec![certificate_request().signer_id], + vec![], + ) + .expect("revoked"); + let verified = verify(&root, signer.certificate(), &revoked, &permit).expect("signatures"); + let mut state = VerifierState::default(); + assert_eq!( + verified.evaluate(&request(&original), NOW, true, &state), + Err(CommissioningRefusal::Revoked) + ); + assert_eq!( + verified.evaluate(&request(&original), NOW + 2 * HOUR, false, &state), + Err(CommissioningRefusal::Revoked) + ); + verified.remember(&mut state); + let omitted = + verify(&root, signer.certificate(), &revocations(&root, 3), &permit).expect("signatures"); + omitted.remember(&mut state); + assert_eq!(state.accepted_revocation_sequence, 3); + assert_eq!( + omitted.evaluate(&request(&original), NOW, true, &state), + Err(CommissioningRefusal::Revoked) + ); + let unrevoked = + verify(&root, signer.certificate(), &revocations(&root, 1), &permit).expect("signatures"); + let mut floor = VerifierState { + accepted_revocation_sequence: 2, + ..VerifierState::default() + }; + unrevoked.remember(&mut floor); + assert_eq!(floor.accepted_revocation_sequence, 2); + assert_eq!( + unrevoked.evaluate(&request(&original), NOW, true, &floor), + Err(CommissioningRefusal::RevocationRollback) + ); +} + +#[test] +fn proposal_rechecks_exact_offline_artifacts_and_all_required_scenarios() { + let conformance = member_evidence(EvidenceMemberKind::Conformance); + let differential = member_evidence(EvidenceMemberKind::Differential); + for (member, source) in [ + (EvidenceMemberKind::Conformance, &conformance), + (EvidenceMemberKind::Differential, &differential), + ] { + for scenario in Scenario::ALL { + if scenario.member() != member || !scenario.always_required() { + continue; + } + let mut incomplete = source.body().clone(); + incomplete.cases.retain(|case| case.scenario != scenario); + let incomplete = + QualificationEvidence::from_body(&incomplete).expect("incomplete artifact"); + let mut changed = binding(); + let (c, d) = if member == EvidenceMemberKind::Conformance { + changed.offline_evidence.conformance_sha256 = incomplete.digest(); + (incomplete, differential.clone()) + } else { + changed.offline_evidence.differential_sha256 = incomplete.digest(); + (conformance.clone(), incomplete) + }; + assert_eq!( + CommissioningProposal::assemble(changed, c, d), + Err(IssuanceError::Closure(ClosureFault::Scenario)), + "{scenario:?}" + ); + } + } + let mut wrong_digest = binding(); + wrong_digest.offline_evidence.differential_sha256 = digest(0x70); + assert_eq!( + CommissioningProposal::assemble(wrong_digest, conformance.clone(), differential.clone()), + Err(IssuanceError::Closure(ClosureFault::Digest)) + ); + let mut wrong_commit = binding(); + wrong_commit.source_commit = GitCommit::parse("f".repeat(40)).expect("commit"); + assert_eq!( + CommissioningProposal::assemble(wrong_commit, conformance.clone(), differential.clone()), + Err(IssuanceError::Closure(ClosureFault::Candidate)) + ); + let mut wrong_tuple = binding(); + wrong_tuple.tuple.profile_lock_sha256 = digest(0x70); + assert_eq!( + CommissioningProposal::assemble(wrong_tuple, conformance.clone(), differential.clone()), + Err(IssuanceError::Closure(ClosureFault::Candidate)) + ); + assert_eq!( + CommissioningProposal::assemble(binding(), differential.clone(), conformance.clone()), + Err(IssuanceError::Closure(ClosureFault::MemberSet)) + ); + let mut unauthorized = conformance.body().clone(); + unauthorized.cases[0].unauthorized_provider_entries = 1; + let unauthorized = QualificationEvidence::from_body(&unauthorized).expect("reported entry"); + let mut changed = binding(); + changed.offline_evidence.conformance_sha256 = unauthorized.digest(); + assert_eq!( + CommissioningProposal::assemble(changed, unauthorized, differential), + Err(IssuanceError::CaseFailed) + ); + assert_eq!(proposal().offline_evidence()[0], &conformance); +} + +#[test] +fn action_and_lease_bounds_are_exact_and_never_silently_repaired() { + let root = root(); + let signer = commissioner(&root); + let base = permit(&signer).body().clone(); + for count in [ + 0, + 1, + MAX_COMMISSIONING_ACTIONS, + MAX_COMMISSIONING_ACTIONS + 1, + ] { + let mut body = base.clone(); + body.statement.binding.allowed_actions = (0..count) + .map(|index| { + let mut bytes = [0; 32]; + bytes[24..].copy_from_slice(&(index as u64).to_be_bytes()); + Sha256Digest::from_bytes(bytes) + }) + .collect(); + assert_eq!( + QualificationCommissioningPermit::from_body(&body).map(|_| ()), + if (1..=MAX_COMMISSIONING_ACTIONS).contains(&count) { + Ok(()) + } else { + Err(QualificationFormatError::ListBound) + }, + "{count}" + ); + } + for leases in [ + 0, + 1, + MAX_COMMISSIONING_LEASES, + MAX_COMMISSIONING_LEASES + 1, + u64::MAX, + ] { + let mut body = base.clone(); + body.statement.binding.maximum_credential_leases = leases; + assert_eq!( + QualificationCommissioningPermit::from_body(&body).map(|_| ()), + if (1..=MAX_COMMISSIONING_LEASES).contains(&leases) { + Ok(()) + } else if leases == u64::MAX { + // RFC 8785 serialization cannot round-trip this number as + // the typed u64. It refuses before the closed lease bound. + Err(QualificationFormatError::Malformed) + } else { + Err(QualificationFormatError::ListBound) + }, + "{leases}" + ); + } + for actions in [vec![digest(1), digest(1)], vec![digest(2), digest(1)]] { + let mut body = base.clone(); + body.statement.binding.allowed_actions = actions; + assert_eq!( + QualificationCommissioningPermit::from_body(&body).map(|_| ()), + Err(QualificationFormatError::ListOrder) + ); + } + for (pointer, changed) in [ + ("/target/store_kind", json!("shared-file-v1")), + ("/target/credential_store_kind", json!("local-file-v1")), + ] { + let mut body = base.clone(); + let mut target = serde_json::to_value(&body.statement.binding.tuple).expect("tuple"); + *target.pointer_mut(pointer).expect("member") = changed; + body.statement.binding.tuple = serde_json::from_value(target).expect("tuple"); + assert_eq!( + QualificationCommissioningPermit::from_body(&body).map(|_| ()), + Err(QualificationFormatError::Malformed) + ); + } + let permit = permit(&signer); + let mut noncanonical = permit.canonical_bytes().to_vec(); + noncanonical.push(b'\n'); + assert_eq!( + QualificationCommissioningPermit::from_canonical_json(&noncanonical).map(|_| ()), + Err(QualificationFormatError::NonCanonical) + ); + assert_eq!( + QualificationCommissioningPermit::from_canonical_json(&vec![ + b' '; + MAX_COMMISSIONING_PERMIT_BYTES + + 1 + ]) + .map(|_| ()), + Err(QualificationFormatError::Oversized) + ); + let mut extra: Value = serde_json::from_slice(permit.canonical_bytes()).expect("JSON"); + extra["statement"]["unreviewed"] = json!(true); + assert_eq!( + QualificationCommissioningPermit::from_canonical_json( + &serde_json::to_vec(&extra).expect("JSON") + ) + .map(|_| ()), + Err(QualificationFormatError::Malformed) + ); +} + +#[test] +fn renewal_cannot_change_the_durable_budget_identity_or_binding() { + let root = root(); + let signer = commissioner(&root); + let first = signer + .permit(&proposal(), NOW, NOW, NOW + HOUR) + .expect("first"); + let renewed = signer + .permit(&proposal(), NOW + HOUR, NOW + HOUR, NOW + 2 * HOUR) + .expect("renewed"); + assert_ne!(first.digest(), renewed.digest()); + let a = &first.body().statement.binding; + let b = &renewed.body().statement.binding; + assert_eq!(a.budget_key(), b.budget_key()); + assert_eq!(a.budget_binding(), b.budget_binding()); + let original = binding(); + for changed in [ + CommissioningBinding { + maximum_credential_leases: 33, + ..original.clone() + }, + CommissioningBinding { + principal_sha256: digest(0x70), + ..original.clone() + }, + CommissioningBinding { + resources_sha256: digest(0x70), + ..original.clone() + }, + CommissioningBinding { + allowed_actions: vec![digest(0x70)], + ..original.clone() + }, + CommissioningBinding { + trusted_contexts_sha256: vec![digest(0x70)], + ..original.clone() + }, + ] { + assert_eq!(original.budget_key(), changed.budget_key()); + assert_ne!(original.budget_binding(), changed.budget_binding()); + } + let mut another_run = original.clone(); + another_run.protected_run = BoundedText::parse("another-protected-run").expect("run"); + assert_ne!(original.budget_key(), another_run.budget_key()); + let mut another_family = original.clone(); + another_family.tuple.recipe_family = + auths_recipe_qualification::RecipeFamilyId::parse("another-family").expect("family"); + assert_ne!(original.budget_key(), another_family.budget_key()); +} + +#[test] +fn commissioning_artifact_fixture_is_current() { + let root = root(); + let signer = commissioner(&root); + let permit = permit(&signer); + let list = revocations(&root, 1); + let document = json!({ + "schema": "auths.qualification-commissioning-vectors/2", + "synthetic": true, + "trust_root": std::str::from_utf8(root.trust_root().canonical_bytes()).expect("UTF-8"), + "signer_certificate": std::str::from_utf8(signer.certificate().canonical_bytes()).expect("UTF-8"), + "revocation_list": std::str::from_utf8(list.canonical_bytes()).expect("UTF-8"), + "permit": std::str::from_utf8(permit.canonical_bytes()).expect("UTF-8"), + "permit_digest": permit.digest(), + "budget_scope_sha256": permit.body().statement.binding.budget_key().expect("key"), + "budget_binding_sha256": permit.body().statement.binding.budget_binding().expect("binding"), + "limits": { "actions": MAX_COMMISSIONING_ACTIONS, "contexts": MAX_COMMISSIONING_CONTEXTS, + "leases": MAX_COMMISSIONING_LEASES, "seconds": MAX_COMMISSIONING_SECONDS, + "bytes": MAX_COMMISSIONING_PERMIT_BYTES }, + }); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../bindings/fixtures/qualification/commissioning-v2.json"); + let mut encoded = serde_json::to_vec_pretty(&document).expect("fixture"); + encoded.push(b'\n'); + if std::env::var_os("AUTHS_UPDATE_FIXTURES").is_some() { + std::fs::write(path, encoded).expect("fixture write"); + } else { + assert_eq!( + std::fs::read(path).expect("fixture"), + encoded, + "regenerate commissioning fixtures with AUTHS_UPDATE_FIXTURES=1" + ); + } +} diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/common/mod.rs b/product/qualification/auths-recipe-qualification-issuance/tests/common/mod.rs index 81a49a17e..cce1734cc 100644 --- a/product/qualification/auths-recipe-qualification-issuance/tests/common/mod.rs +++ b/product/qualification/auths-recipe-qualification-issuance/tests/common/mod.rs @@ -124,6 +124,8 @@ pub fn executable_corpus() -> auths_recipe_qualification_issuance::execution::Ru }; let step = |operation, outcome, entries, confirmed| RunStep { operation, + evidence_comparison: + auths_recipe_qualification_issuance::execution::EvidenceComparison::Static {}, expected: ExpectedObservation { verdict: RunVerdict { outcome, @@ -141,17 +143,22 @@ pub fn executable_corpus() -> auths_recipe_qualification_issuance::execution::Ru .into_iter() .filter(|scenario| harness_scenario(*scenario)) { - if scenario == Scenario::ObserverRotation { + // This fixture exercises intermediate issuance. Production readiness + // requires an actual protected doctor report, not this subprocess double. + if matches!( + scenario, + Scenario::ObserverRotation | Scenario::ProductionReadiness + ) { continue; } let mut steps = match scenario { Scenario::OracleAccepts => vec![ - step(Op::Oracle, Outcome::ResponseRecorded, 0, 0), - step(Op::Submit, Outcome::ResponseRecorded, 1, 0), + step(Op::Oracle, Outcome::Complete, 0, 0), + step(Op::Review, Outcome::Complete, 0, 0), ], Scenario::OracleRejects => vec![ step(Op::Oracle, Outcome::Refused, 0, 0), - step(Op::Submit, Outcome::Refused, 0, 0), + step(Op::Review, Outcome::Refused, 0, 0), ], Scenario::ProofReplay | Scenario::FreshChallengeReplay => vec![ step(Op::Submit, Outcome::ResponseRecorded, 1, 0), @@ -290,7 +297,7 @@ if mode == 'failed': print('synthetic-canary-must-not-leave-child', file=sys.stderr) sys.exit(1) actual = {'tuple_sha256': (work / 'tuple-digest').read_text(), 'observed': step['expected'], - 'unauthorized_provider_entries': 0, 'secret_exposed': False, + 'fresh_evidence': None, 'unauthorized_provider_entries': 0, 'secret_exposed': False, 'repository_imported': False, 'provider_token_received': False} if operation == 'installed-consumer': assert pathlib.Path.cwd() == work diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/execution.rs b/product/qualification/auths-recipe-qualification-issuance/tests/execution.rs index fa3644693..19c134b45 100644 --- a/product/qualification/auths-recipe-qualification-issuance/tests/execution.rs +++ b/product/qualification/auths-recipe-qualification-issuance/tests/execution.rs @@ -18,6 +18,7 @@ fn run( let mut actual = RunObservation { tuple_sha256: tuple().digest().expect("digest"), observed: step.expected.clone(), + fresh_evidence: None, unauthorized_provider_entries: 0, secret_exposed: false, repository_imported: false, @@ -29,6 +30,145 @@ fn run( .map(|(_, effects)| effects) } +#[test] +fn production_readiness_requires_a_read_only_live_probe_on_a_production_target() { + use auths_recipe_qualification::{BoundedText, LifecycleStoreKind}; + use auths_recipe_qualification_issuance::execution::{Operation, RunPhase}; + let mut case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == Scenario::ApplicationCannotReadSecret) + .expect("probe"); + case.scenario = Scenario::ProductionReadiness; + case.phase = RunPhase::Live; + case.steps.truncate(1); + case.steps[0].operation = Operation::Probe; + let expected = &mut case.steps[0].expected; + expected.verdict.outcome = RunOutcome::Complete; + expected.verdict.code = BoundedText::parse("production-readiness-passed").expect("code"); + expected.verdict.request_sha256 = None; + expected.verdict.evidence_sha256 = Some(tuple().profile_lock_sha256); + expected.credential_leases = 0; + expected.provider_entries = 0; + expected.confirmed_by_read_back = 0; + assert!(run(&case, |_, _| {}).is_ok()); + for index in 0..4 { + let mut changed = case.clone(); + match index { + 0 => changed.phase = RunPhase::Offline, + 1 => changed.steps[0].expected.credential_leases = 1, + 2 => changed.steps[0].expected.provider_entries = 1, + _ => changed.steps[0].expected.verdict.evidence_sha256 = None, + } + assert_eq!(run(&changed, |_, _| {}), Err(IssuanceError::CaseFailed)); + } + let mut development = tuple(); + development.target.store_kind = LifecycleStoreKind::SharedFileV1; + let mut invoked = false; + assert_eq!( + case.execute(&development, |_, _| { + invoked = true; + Err(IssuanceError::CaseFailed) + }), + Err(IssuanceError::CaseFailed) + ); + assert!(!invoked); + case.phase = RunPhase::Commissioning; + assert_eq!(run(&case, |_, _| {}), Err(IssuanceError::CaseFailed)); +} + +#[test] +fn commissioning_client_refusal_cannot_replace_an_ordinary_installed_effect() { + use auths_recipe_qualification::{BoundedText, LifecycleStoreKind}; + use auths_recipe_qualification_issuance::execution::{Operation, RunPhase}; + let mut obsolete = executable_corpus(); + obsolete.schema = "auths.qualification-corpus/1".to_owned(); + assert!(obsolete.validate().is_err()); + let mut case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == Scenario::InstalledJourney) + .expect("installed client"); + assert!(run(&case, |_, _| {}).is_ok()); + let mut unrelated_effect = case.steps[0].clone(); + unrelated_effect.operation = Operation::Submit; + let expected = &mut case.steps[0].expected; + expected.verdict.outcome = RunOutcome::Refused; + expected.verdict.code = BoundedText::parse("gateway.qualification.unavailable").expect("code"); + expected.verdict.evidence_sha256 = None; + expected.credential_leases = 0; + expected.provider_entries = 0; + expected.confirmed_by_read_back = 0; + assert_eq!( + run(&case, |_, _| {}), + Err(IssuanceError::CaseFailed), + "ordinary qualification needs a confirmed installed-client effect" + ); + let mut masked = case.clone(); + masked.steps.push(unrelated_effect); + assert_eq!(run(&masked, |_, _| {}), Err(IssuanceError::CaseFailed)); + case.phase = RunPhase::Commissioning; + assert!(run(&case, |_, _| {}).is_ok()); + for mutation in 0..4 { + let mut changed = case.clone(); + match mutation { + 0 => changed.steps[0].expected.credential_leases = 1, + 1 => changed.steps[0].expected.provider_entries = 1, + 2 => { + changed.steps[0].expected.verdict.code = + BoundedText::parse("gateway.qualification.expired").expect("code"); + } + _ => changed.steps[0].expected.verdict.outcome = RunOutcome::Complete, + } + assert_eq!(run(&changed, |_, _| {}), Err(IssuanceError::CaseFailed)); + } + let mut development = tuple(); + development.target.store_kind = LifecycleStoreKind::SharedFileV1; + let mut invoked = false; + assert_eq!( + case.execute(&development, |_, _| { + invoked = true; + Err(IssuanceError::CaseFailed) + }), + Err(IssuanceError::CaseFailed) + ); + assert!(!invoked); +} + +#[test] +fn replay_can_complete_an_unresolved_read_back_but_never_write_or_reacquire_after_observation() { + use auths_recipe_qualification::BoundedText; + let mut case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == Scenario::AmbiguousResponse) + .expect("ambiguous response"); + let replay = &mut case.steps[1].expected; + replay.credential_leases = 1; + replay.verdict.outcome = RunOutcome::Unknown; + replay.verdict.code = BoundedText::parse("unknown").expect("code"); + assert!(run(&case, |_, _| {}).is_ok()); + let replay = &mut case.steps[1].expected; + replay.verdict.outcome = RunOutcome::Observed; + replay.verdict.evidence_sha256 = Some(tuple().profile_lock_sha256); + replay.confirmed_by_read_back = 1; + assert!(run(&case, |_, _| {}).is_ok()); + for mutation in 0..3 { + let mut changed = case.clone(); + match mutation { + 0 => changed.steps[1].expected.provider_entries = 1, + 1 => changed.steps[1].expected.credential_leases = 2, + _ => { + changed.steps[0].expected.verdict.outcome = RunOutcome::Observed; + changed.steps[0].expected.verdict.evidence_sha256 = + Some(tuple().profile_lock_sha256); + changed.steps[0].expected.confirmed_by_read_back = 1; + } + } + assert_eq!(run(&changed, |_, _| {}), Err(IssuanceError::CaseFailed)); + } +} + #[test] fn every_stage_executes_its_operations_and_a_missing_runner_refuses() { let corpus = executable_corpus(); @@ -40,6 +180,7 @@ fn every_stage_executes_its_operations_and_a_missing_runner_refuses() { Ok(RunObservation { tuple_sha256: tuple().digest().expect("digest"), observed: step.expected.clone(), + fresh_evidence: None, unauthorized_provider_entries: 0, secret_exposed: false, repository_imported: false, @@ -188,7 +329,28 @@ fn subprocess_failures_invalidate_reports_and_installed_consumers_get_no_credent ); } let executed = fs::read_to_string(work.join("executed")).expect("operations"); - for case in executable_corpus().cases { + for (case_index, case) in executable_corpus().cases.into_iter().enumerate() { + let phase = serde_json::to_value(case.phase).expect("phase"); + let trace: serde_json::Value = serde_json::from_slice( + &fs::read(work.join(format!( + "scan/trace/{}-{case_index:03}.json", + phase.as_str().expect("token"), + ))) + .expect("closed trace"), + ) + .expect("trace JSON"); + assert_eq!( + trace["tuple_sha256"], + tuple().digest().expect("tuple").to_hex() + ); + assert_eq!(trace["case"], case.id.as_str()); + assert_eq!( + trace["observations"] + .as_array() + .expect("observations") + .len(), + case.steps.len() + ); for (index, step) in case.steps.iter().enumerate() { let operation = serde_json::to_value(step.operation).expect("operation"); assert!(executed.lines().any(|line| line @@ -228,3 +390,178 @@ fn subprocess_failures_invalidate_reports_and_installed_consumers_get_no_credent "invalid corpus input also clears stale passing evidence" ); } + +#[test] +fn fresh_evidence_is_closed_to_the_reviewed_subject_and_exact_candidate_digest() { + use auths_recipe_qualification_issuance::execution::{ + EvidenceComparison, FreshEvidenceWitness, + }; + let mut case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == Scenario::InstalledJourney) + .expect("live journey"); + let subject = tuple().compiled_recipe_sha256; + let response = tuple().profile_lock_sha256; + case.steps[0].evidence_comparison = EvidenceComparison::IndependentReadBack { + subject_sha256: subject, + }; + case.steps[0].expected.verdict.evidence_sha256 = None; + let set_witness = |actual: &mut RunObservation| { + actual.observed.verdict.evidence_sha256 = Some(response); + actual.fresh_evidence = Some(FreshEvidenceWitness::IndependentReadBack { + subject_sha256: subject, + response_sha256: response, + }); + }; + assert!(run(&case, |_, actual| set_witness(actual)).is_ok()); + for index in 0..6 { + assert_eq!( + run(&case, |_, actual| { + set_witness(actual); + match index { + 0 => actual.fresh_evidence = None, + 1 => actual.observed.verdict.evidence_sha256 = None, + 2 => actual.observed.verdict.evidence_sha256 = Some(subject), + 3 => { + actual.fresh_evidence = Some(FreshEvidenceWitness::IndependentReadBack { + subject_sha256: response, + response_sha256: response, + }); + } + 4 => { + actual.fresh_evidence = Some(FreshEvidenceWitness::ProductionDoctor { + tuple_sha256: tuple().digest().expect("tuple"), + report_sha256: response, + }); + } + _ => actual.observed.provider_entries += 1, + } + }), + Err(IssuanceError::CaseFailed) + ); + } + let mut offline = case.clone(); + offline.phase = auths_recipe_qualification_issuance::execution::RunPhase::Offline; + assert!(run(&offline, |_, actual| set_witness(actual)).is_err()); + let mut static_case = case.clone(); + static_case.steps[0].evidence_comparison = EvidenceComparison::Static {}; + assert!(run(&static_case, |_, actual| set_witness(actual)).is_err()); + case.steps[0].expected.verdict.evidence_sha256 = Some(response); + assert!( + run(&case, |_, actual| set_witness(actual)).is_err(), + "two evidence sources are ambiguous" + ); +} + +#[test] +fn a_dynamic_doctor_witness_must_match_the_actual_production_tuple() { + use auths_recipe_qualification::{BoundedText, LifecycleStoreKind}; + use auths_recipe_qualification_issuance::execution::{ + EvidenceComparison, FreshEvidenceWitness, Operation, RunPhase, + }; + let mut case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == Scenario::ApplicationCannotReadSecret) + .expect("probe"); + case.scenario = Scenario::ProductionReadiness; + case.phase = RunPhase::Live; + case.steps.truncate(1); + case.steps[0].operation = Operation::Probe; + case.steps[0].evidence_comparison = EvidenceComparison::ProductionDoctor {}; + let expected = &mut case.steps[0].expected; + expected.verdict.outcome = RunOutcome::Complete; + expected.verdict.code = BoundedText::parse("production-readiness-passed").expect("code"); + expected.verdict.evidence_sha256 = None; + let report = tuple().profile_lock_sha256; + let set = |actual: &mut RunObservation| { + actual.observed.verdict.evidence_sha256 = Some(report); + actual.fresh_evidence = Some(FreshEvidenceWitness::ProductionDoctor { + tuple_sha256: tuple().digest().expect("tuple"), + report_sha256: report, + }); + }; + assert!(run(&case, |_, actual| set(actual)).is_ok()); + assert!( + run(&case, |_, actual| { + set(actual); + actual.fresh_evidence = Some(FreshEvidenceWitness::ProductionDoctor { + tuple_sha256: report, + report_sha256: report, + }); + }) + .is_err() + ); + let mut development = tuple(); + development.target.store_kind = LifecycleStoreKind::SharedFileV1; + let mut invoked = false; + assert!( + case.execute(&development, |_, _| { + invoked = true; + Err(IssuanceError::CaseFailed) + }) + .is_err() + ); + assert!(!invoked); + case.phase = RunPhase::Commissioning; + assert!(run(&case, |_, actual| set(actual)).is_err()); +} + +#[test] +fn offline_differential_requires_native_review_without_custody_or_provider_entry() { + use auths_recipe_qualification_issuance::execution::{Operation, RunPhase}; + for scenario in [Scenario::OracleAccepts, Scenario::OracleRejects] { + let case = executable_corpus() + .cases + .into_iter() + .find(|case| case.scenario == scenario) + .expect("differential case"); + assert_eq!(case.steps[1].operation, Operation::Review); + assert_eq!( + run(&case, |_, _| {}).expect("read-only comparison").entered, + 0 + ); + let mut impossible = case.clone(); + impossible.steps[1].expected.credential_leases = 1; + let mut invoked = false; + assert!( + impossible + .execute(&tuple(), |_, _| { + invoked = true; + Err(IssuanceError::CaseFailed) + }) + .is_err() + ); + assert!(!invoked, "invalid corpus must not start the harness"); + let mut old_submission = case.clone(); + old_submission.steps[1].operation = Operation::Submit; + assert!(run(&old_submission, |_, _| {}).is_err()); + for index in 0..3 { + assert!( + run(&case, |step, actual| { + if step == 1 { + match index { + 0 => actual.observed.credential_leases = 1, + 1 => actual.observed.provider_entries = 1, + _ => actual.observed.confirmed_by_read_back = 1, + } + } + }) + .is_err() + ); + } + let mut protected = case.clone(); + protected.phase = RunPhase::Commissioning; + let mut invoked = false; + assert!( + protected + .execute(&tuple(), |_, _| { + invoked = true; + Err(IssuanceError::CaseFailed) + }) + .is_err() + ); + assert!(!invoked); + } +} diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs b/product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs index dfc3332f0..bba502351 100644 --- a/product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs +++ b/product/qualification/auths-recipe-qualification-issuance/tests/protected_run.rs @@ -5,6 +5,10 @@ use std::collections::BTreeMap; use std::path::Path; +use auths_recipe_qualification::{ + QualificationRevocationList, QualificationSignerCertificate, QualificationTrustRoot, +}; + const WORKFLOW: &str = ".github/workflows/recipe-qualification.yml"; const PROTECTED: &str = "if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'"; @@ -42,6 +46,24 @@ fn jobs(text: &str) -> BTreeMap> { jobs } +#[test] +fn shared_custody_jobs_cannot_run_pull_request_source_with_live_identity() { + let text = + std::fs::read_to_string(repository().join(".github/workflows/gateway-custody-live.yml")) + .expect("custody workflow"); + let jobs = jobs(&text); + assert_eq!( + jobs.keys().map(String::as_str).collect::>(), + ["gateway-journey", "provider-journey", "store-contract"] + ); + for (name, lines) in jobs { + assert!( + lines.iter().any(|line| line == PROTECTED), + "{name} obtains a live identity only from reviewed main source" + ); + } +} + #[test] fn a_pull_request_reaches_no_secret_and_no_signing_job() { let text = std::fs::read_to_string(repository().join(WORKFLOW)).expect("workflow"); @@ -65,7 +87,16 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { let names: Vec<&str> = jobs.keys().map(String::as_str).collect(); assert_eq!( names, - ["assemble", "families", "live", "offline", "sign", "verify"] + [ + "candidate", + "families", + "live", + "offline", + "packet-author", + "sign", + "simulation", + "verify" + ] ); for (name, lines) in &jobs { let has = |needle: &str| lines.iter().any(|line| line.contains(needle)); @@ -78,8 +109,8 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { let protected = lines.iter().any(|line| line == PROTECTED); assert_eq!( protected, - !matches!(name.as_str(), "families" | "offline"), - "{name}: everything after offline evidence runs only by hand on the default branch" + matches!(name.as_str(), "live" | "sign" | "verify"), + "{name}: live evidence and signing run only by hand on the default branch" ); assert_eq!( has("QUALIFICATION_RELEASE_SIGNER_KEY"), @@ -103,7 +134,7 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { ); // The key is used only by a tool the signing job built itself, and no // privileged or signing-path job runs a binary another job produced. - for name in ["assemble", "sign", "verify"] { + for name in ["sign", "verify"] { let lines = &jobs[name]; assert!( lines @@ -120,8 +151,8 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { let live = &jobs["live"]; let scan = live .iter() - .position(|line| line.contains("redact.sh")) - .expect("the live job scans"); + .position(|line| line.contains("close_proposal.py")) + .expect("the live job closes and scans its final proposal"); let upload = live .iter() .position(|line| line.contains("upload-artifact")) @@ -131,7 +162,11 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { live.iter() .any(|line| line.contains("test ! -e") && line.contains("canaries")) ); + assert!(!text.contains("pull_request_target")); +} +#[test] +fn other_workflows_cannot_reach_qualification_signing() { // No other workflow names the signer's key, and nothing in the // repository runs on a trigger that gives a pull request's code secrets. for entry in std::fs::read_dir(repository().join(".github/workflows")).expect("workflows") { @@ -146,7 +181,11 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { ); } } - assert!(!text.contains("pull_request_target")); + assert!( + !std::fs::read_to_string(repository().join(WORKFLOW)) + .expect("workflow") + .contains("pull_request_target") + ); } /// The trust directory holds public artifacts only. @@ -154,7 +193,9 @@ fn a_pull_request_reaches_no_secret_and_no_signing_job() { fn the_repository_holds_no_qualification_key() { let trust = repository().join("qualification/trust"); for entry in std::fs::read_dir(trust).expect("trust directory") { - let name = entry.expect("entry").file_name(); + let entry = entry.expect("entry"); + assert!(entry.file_type().expect("type").is_file()); + let name = entry.file_name(); let name = name.to_string_lossy(); assert!( matches!( @@ -162,9 +203,54 @@ fn the_repository_holds_no_qualification_key() { ".gitkeep" | "qualification-trust-root.json" | "signer-certificate.json" + | "commissioning-signer-certificate.json" | "revocation-list.json" + | "ceremony.json" ), "{name} is not a public trust artifact" ); + let bytes = std::fs::read(entry.path()).expect("public artifact"); + match name.as_ref() { + "qualification-trust-root.json" => { + QualificationTrustRoot::from_canonical_json(&bytes).expect("closed public root"); + } + "signer-certificate.json" | "commissioning-signer-certificate.json" => { + QualificationSignerCertificate::from_canonical_json(&bytes) + .expect("closed public certificate"); + } + "revocation-list.json" => { + QualificationRevocationList::from_canonical_json(&bytes) + .expect("closed public revocations"); + } + "ceremony.json" => { + let ceremony: serde_json::Value = serde_json::from_slice(&bytes).expect("ceremony"); + let mut keys: Vec<&str> = ceremony + .as_object() + .expect("ceremony object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "assessment", + "created_at", + "operator", + "private_key_retention", + "public_artifacts", + "qualification_issued", + "schema", + "scope", + "stable_launch_ready" + ] + ); + assert_eq!(ceremony["schema"], "auths.qualification-root-ceremony/1"); + assert_eq!(ceremony["qualification_issued"], false); + assert_eq!(ceremony["stable_launch_ready"], false); + } + ".gitkeep" => assert!(bytes.is_empty()), + _ => unreachable!("filename checked above"), + } } } diff --git a/product/qualification/auths-recipe-qualification-issuance/tests/script_pipeline.rs b/product/qualification/auths-recipe-qualification-issuance/tests/script_pipeline.rs index 0068ada1f..79a77aea8 100644 --- a/product/qualification/auths-recipe-qualification-issuance/tests/script_pipeline.rs +++ b/product/qualification/auths-recipe-qualification-issuance/tests/script_pipeline.rs @@ -6,7 +6,7 @@ mod common; -use std::{fs, path::Path, process::Command}; +use std::{fs, os::unix::fs::PermissionsExt, path::Path, process::Command}; fn succeed(command: &mut Command) { let output = command.output().expect("command"); @@ -25,7 +25,10 @@ fn succeed(command: &mut Command) { fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refuses() { let temporary = tempfile::tempdir().expect("directory"); let root = temporary.path().join("repository"); - let family = root.join("qualification/families/example-refund-v1"); + // Exercise the actual closed resource projection with synthetic Stripe + // IDs. The subprocess double still establishes no provider qualification. + let family_name = "stripe-platform-refund-v1"; + let family = root.join(format!("qualification/families/{family_name}")); fs::create_dir_all(&family).expect("family"); let draft = common::draft_json(); let record = serde_json::json!({ @@ -35,6 +38,36 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus "residual_assumptions": draft["residual_assumptions"], "excluded_claims": draft["excluded_claims"], }); fs::write(family.join("record.json"), record.to_string()).expect("record"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../qualification"); + for (directory, names) in [ + ( + "reference", + &[ + "resource_summary.py", + "resource_io.py", + "fresh_evidence.py", + "common.py", + "stripe_platform.py", + "airtable_record.py", + ][..], + ), + ( + "run", + &[ + "assemble.sh", + "close_proposal.py", + "scan_publication.py", + "redact.sh", + ][..], + ), + ] { + let output = root.join("qualification").join(directory); + fs::create_dir_all(&output).expect("source-owned release scripts"); + for name in names { + fs::copy(source.join(directory).join(name), output.join(name)) + .expect("reviewed source script"); + } + } succeed(Command::new("git").args(["init", "--quiet"]).arg(&root)); succeed(Command::new("git").arg("-C").arg(&root).args(["add", "."])); succeed(Command::new("git").arg("-C").arg(&root).args([ @@ -60,6 +93,17 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus .to_owned(); let work = temporary.path().join("work"); let harness = common::stage_fixture(&work); + fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).expect("private work"); + let mut tuple = common::tuple_json(); + tuple["recipe_family"] = family_name.into(); + let typed: auths_recipe_qualification::QualificationTuple = + serde_json::from_value(tuple.clone()).expect("synthetic tuple"); + fs::write(work.join("tuple.json"), tuple.to_string()).expect("tuple"); + fs::write( + work.join("tuple-digest"), + typed.digest().expect("tuple digest").to_hex(), + ) + .expect("tuple digest"); let tool = env!("CARGO_BIN_EXE_auths-qualification"); for phase in ["offline", "live"] { succeed( @@ -82,7 +126,13 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus .expect("packages"); fs::write( work.join("resources.json"), - draft["provider_resources"].to_string(), + serde_json::json!({ + "schema": "auths.stripe-platform-qualification-resources/1", + "protected_run": "recipe-qualification/123/1", "platform": "acct_SYNTHETIC", + "payments": [{"id": "pi_SYNTHETIC", "amount_received": 2000, "currency": "usd", + "livemode": false, "run_metadata": "recipe-qualification/123/1"}], + }) + .to_string(), ) .expect("resources"); let facts = serde_json::json!({ @@ -93,24 +143,62 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus }); fs::write(work.join("facts.json"), facts.to_string()).expect("facts"); fs::write(work.join("canaries"), "synthetic-canary-not-a-credential\n").expect("canaries"); + fs::set_permissions(work.join("canaries"), fs::Permissions::from_mode(0o600)) + .expect("private canaries"); for kind in ["log", "trace", "metric", "support-bundle"] { let directory = work.join("scan").join(kind); fs::create_dir_all(&directory).expect("scan directory"); fs::write(directory.join("test-output"), "closed test states only").expect("output"); } - let scripts = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../qualification/run"); - succeed( - Command::new("bash") + let scripts = root.join("qualification/run"); + for name in [ + "commissioning-effects.json", + "facts.json", + "unexpected-public-output", + ] { + let path = work.join(name); + let original = fs::read(&path).ok(); + fs::write(&path, "synthetic-canary-not-a-credential").expect("planted leak"); + let refused = Command::new("bash") .arg(scripts.join("redact.sh")) .arg(&work) .env("AUTHS_QUALIFICATION", tool) + .current_dir(&root) + .output() + .expect("redaction refusal"); + assert!(!refused.status.success(), "unlisted output must be scanned"); + assert!( + work.join("canaries").exists(), + "retain private scan inputs on failure" + ); + assert!(!work.join("cases/redaction.scan.json").exists()); + assert!( + !String::from_utf8_lossy(&refused.stderr).contains("synthetic-canary-not-a-credential") + ); + match original { + Some(bytes) => fs::write(path, bytes).expect("restore public fact"), + None => fs::remove_file(path).expect("remove leak"), + } + } + // The real workflow retains its canaries until the actual final proposal + // has been assembled, rebuilt from the complete scan, and rescanned. + succeed( + Command::new("python3") + .arg("-B") + .arg(scripts.join("close_proposal.py")) + .args(["--family", family_name]) + .arg("--work") + .arg(&work) + .args(["--environment", "test-environment", "--run", "test-run"]) + .arg("--tool") + .arg(tool) .current_dir(&root), ); assert!(!work.join("canaries").exists()); let assemble = || { Command::new("bash") .arg(scripts.join("assemble.sh")) - .arg("example-refund-v1") + .arg(family_name) .arg(&work) .args(["test-environment", "test-run"]) .env("AUTHS_QUALIFICATION", tool) @@ -118,13 +206,18 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus .output() .expect("assemble") }; - let assembled = assemble(); - assert!( - assembled.status.success(), - "{}", - String::from_utf8_lossy(&assembled.stderr) - ); assert!(work.join("proposal/record.json").is_file()); + let record: serde_json::Value = serde_json::from_slice( + &fs::read(work.join("proposal/record.json")).expect("closed record"), + ) + .expect("canonical JSON"); + assert_eq!( + record["provider_resources"], + serde_json::json!([ + "stripe:test-payment:pi_SYNTHETIC", + "stripe:test-platform:acct_SYNTHETIC" + ]) + ); // A failed rerun cannot leave its old proposal usable by the signing job. fs::copy( work.join("cases/recovery.live.json"), @@ -137,6 +230,10 @@ fn stage_reports_flow_through_redaction_and_assembly_and_missing_execution_refus } #[test] +#[allow( + clippy::too_many_lines, + reason = "the four resource-session exits and stale phase outputs are checked together" +)] fn live_script_cleans_up_after_success_setup_failure_and_stage_failure() { let temporary = tempfile::tempdir().expect("directory"); let root = temporary.path().join("repository"); @@ -151,13 +248,37 @@ fn live_script_cleans_up_after_success_setup_failure_and_stage_failure() { family.join("corpus-manifest.json"), ) .expect("corpus"); - let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../qualification/run/live.sh"); + let scripts = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../qualification/run"); + fs::create_dir_all(work.join("cases")).expect("earlier phase report directory"); for mode in ["success", "setup-failed", "failed", "cleanup-failed"] { fs::write(work.join("fault"), mode).expect("fault"); + // A prior phase's outputs must also become unusable if the overall + // resource session fails. These are synthetic orchestration fixtures. + fs::write( + work.join("commissioning-effects.json"), + r#"{"entered":1,"confirmed_by_read_back":1}"#, + ) + .expect("earlier phase effects"); + fs::write(work.join("cases/live.commissioning.json"), "[]").expect("earlier phase report"); let output = Command::new("bash") - .arg(&script) + .arg(scripts.join("resource-session.sh")) .arg("example-refund-v1") .arg(&work) + .arg("bash") + .arg("-c") + .arg( + r#"set -e +test -f "$1/disposable-resource" +bash "$2" "$3" "$1" +test -f "$1/disposable-resource" +bash "$2" "$3" "$1" +test -f "$1/disposable-resource" +"#, + ) + .arg("protected-journey-test") + .arg(&work) + .arg(scripts.join("live.sh")) + .arg("example-refund-v1") .env( "AUTHS_QUALIFICATION", env!("CARGO_BIN_EXE_auths-qualification"), @@ -176,6 +297,15 @@ fn live_script_cleans_up_after_success_setup_failure_and_stage_failure() { mode == "cleanup-failed", "teardown runs even after partial setup or a refused stage" ); + assert_eq!( + work.join("cases/live.commissioning.json").exists(), + mode == "success", + "a failed resource session invalidates earlier commissioning evidence" + ); + assert_eq!( + work.join("commissioning-effects.json").exists(), + mode == "success" + ); assert!( !String::from_utf8_lossy(&output.stderr) .contains("synthetic-canary-must-not-leave-child"), diff --git a/product/qualification/auths-recipe-qualification/src/commissioning.rs b/product/qualification/auths-recipe-qualification/src/commissioning.rs new file mode 100644 index 000000000..07a796713 --- /dev/null +++ b/product/qualification/auths-recipe-qualification/src/commissioning.rs @@ -0,0 +1,443 @@ +//! Explicit authority for one finite commissioning session (ADR 0016). +//! +//! A permit is deliberately outside the qualification record/index types. +//! It proves no provider behavior and never derives production readiness. +//! This pure verifier checks signed authority only. Its runtime consumer must +//! additionally reserve the immutable lease budget in durable shared state +//! before custody access, and keep this authority off the application socket. + +use crate::canonical::{self, Artifact, Canonical, Sealed}; +use crate::verify::verifies; +use crate::{ + BoundedText, GitCommit, LifecycleStoreKind, ProviderEnvironmentClass, + QualificationArtifactKind, QualificationFormatError, QualificationRevocationList, + QualificationSignatureSuite, QualificationSignerCertificate, QualificationSignerId, + QualificationTrustRoot, QualificationTuple, Sha256Digest, SignatureB64, VerifierState, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +mod tests; + +/// The separate signature domain of a commissioning permit. +pub const COMMISSIONING_PERMIT_SCHEMA: &str = "auths.qualification-commissioning-permit/2"; +/// Public file within a protected commissioning artifact directory. +pub const COMMISSIONING_PERMIT_FILE: &str = "commissioning-permit.json"; +/// The domain of the stable run/family budget key. +pub const COMMISSIONING_BUDGET_KEY_DOMAIN: &str = "auths.qualification-commissioning-budget-key/1"; +/// The domain of the immutable budget binding, including its ceiling. +pub const COMMISSIONING_BUDGET_BINDING_DOMAIN: &str = + "auths.qualification-commissioning-budget-binding/2"; +/// The largest commissioning permit accepted before parsing. +pub const MAX_COMMISSIONING_PERMIT_BYTES: usize = 32 * 1024; +/// The maximum distinct exact actions one permit may authorize. +pub const MAX_COMMISSIONING_ACTIONS: usize = 256; +/// The maximum distinct operator-approved contexts in one immutable permit. +/// Fresh-challenge replay may change the request challenge, but cannot admit +/// an unlisted context or register a new lifetime budget. +pub const MAX_COMMISSIONING_CONTEXTS: usize = 4; +/// The maximum custody acquisitions one permit may authorize. +pub const MAX_COMMISSIONING_LEASES: u64 = 1024; +/// A commissioning permit lasts at most two hours. +pub const MAX_COMMISSIONING_SECONDS: u64 = 2 * 60 * 60; + +/// The two closed offline artifacts a protected signer must re-verify. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommissioningOfflineEvidence { + /// Source, recipe digest, compiler/interpreter and closed-enumeration cases. + pub conformance_sha256: Sha256Digest, + /// The independently reviewed oracle's acceptance/rejection corpus. + pub differential_sha256: Sha256Digest, +} + +/// Everything fixed for a run, excluding signer rotation and time renewal. +/// +/// Runtime identities come from the installed candidate and sealed verifier, +/// never from application assertions. Resource and action bindings are +/// expanded by the protected, reviewed reference before permit issuance. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommissioningBinding { + /// Exact protected workflow run identity, including its attempt. + pub protected_run: BoundedText<256>, + /// The installed candidate's immutable source revision. + pub source_commit: GitCommit, + /// Exact production recipe, contract, semantic closure, binary and target. + pub tuple: QualificationTuple, + /// Commitment to the ephemeral proof-authoring principal's canonical bytes. + pub principal_sha256: Sha256Digest, + /// Exact canonical operator-approved context commitments, sorted and unique. + /// The complete finite set is part of the immutable shared budget binding. + pub trusted_contexts_sha256: Vec, + /// Commitment to the reviewed disposable provider resource bindings. + pub resources_sha256: Sha256Digest, + /// What kind of disposable provider environment is actually exercised. + pub provider_environment_class: ProviderEnvironmentClass, + /// Offline evidence which the issuing signer checked for this candidate. + pub offline_evidence: CommissioningOfflineEvidence, + /// Exact canonical action commitments, strictly ascending and unique. + pub allowed_actions: Vec, + /// Lifetime custody-acquisition ceiling for this run and family. + pub maximum_credential_leases: u64, +} + +impl CommissioningBinding { + /// The durable budget's key, stable across renewals and signer rotation. + /// + /// Changing any other binding member must conflict with the original + /// durable registration at this key. It must never open a fresh counter. + /// + /// # Errors + /// + /// Returns [`QualificationFormatError::Malformed`] if canonical encoding + /// fails. No I/O or counter allocation occurs here. + pub fn budget_key(&self) -> Result { + #[derive(Serialize)] + struct Key<'a> { + protected_run: &'a BoundedText<256>, + recipe_family: &'a crate::RecipeFamilyId, + } + Ok(canonical::domain_digest( + COMMISSIONING_BUDGET_KEY_DOMAIN, + &canonical::canonical_bytes(&Key { + protected_run: &self.protected_run, + recipe_family: &self.tuple.recipe_family, + })?, + )) + } + + /// Commitment the durable store must bind immutably at [`Self::budget_key`]. + /// + /// # Errors + /// + /// Returns [`QualificationFormatError::Malformed`] if encoding fails. + pub fn budget_binding(&self) -> Result { + Ok(canonical::domain_digest( + COMMISSIONING_BUDGET_BINDING_DOMAIN, + &canonical::canonical_bytes(self)?, + )) + } + + /// Checks the production-only target, finite lease ceiling and exact list. + /// + /// # Errors + /// + /// Returns a format refusal for a development target or a broken hard + /// bound/order. This check authenticates neither evidence nor signatures. + pub fn validate(&self) -> Result<(), QualificationFormatError> { + if self.tuple.target.store_kind != LifecycleStoreKind::PostgresqlV1 + || !self.tuple.target.credential_store_kind.is_production() + { + return Err(QualificationFormatError::Malformed); + } + if self.allowed_actions.is_empty() + || self.allowed_actions.len() > MAX_COMMISSIONING_ACTIONS + || self.trusted_contexts_sha256.is_empty() + || self.trusted_contexts_sha256.len() > MAX_COMMISSIONING_CONTEXTS + || !(1..=MAX_COMMISSIONING_LEASES).contains(&self.maximum_credential_leases) + { + return Err(QualificationFormatError::ListBound); + } + canonical::strictly_ascending(&self.trusted_contexts_sha256)?; + canonical::strictly_ascending(&self.allowed_actions) + } +} + +/// The exact statement a purpose-certified commissioning signer signs. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommissioningPermitStatement { + /// Exactly [`COMMISSIONING_PERMIT_SCHEMA`]. + pub schema: String, + /// Run bindings which renewal cannot change or reset. + pub binding: CommissioningBinding, + /// The commissioning signer, distinct in purpose from a release signer. + pub signer_id: QualificationSignerId, + /// The signature suite. + pub signature_suite: QualificationSignatureSuite, + /// The actual issue time, no later than the start of the window. + pub issued_at: u64, + /// Start of the finite session. + pub not_before: u64, + /// End of the finite session, at most two hours after issue. + pub not_after: u64, +} + +/// The closed commissioning artifact: a statement and its purpose-bound signature. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommissioningPermitBody { + /// All signed bindings, signer and times. + pub statement: CommissioningPermitStatement, + /// Ed25519 signature over [`Self::signing_preimage`]. + pub signature_b64: SignatureB64, +} + +impl CommissioningPermitBody { + /// Exact signature preimage; no record, index or proof has this domain. + /// + /// # Errors + /// + /// Returns [`QualificationFormatError::Malformed`] if encoding fails. + pub fn signing_preimage(&self) -> Result, QualificationFormatError> { + Ok(canonical::preimage( + COMMISSIONING_PERMIT_SCHEMA, + &canonical::canonical_bytes(&self.statement)?, + )) + } +} + +impl Sealed for CommissioningPermitBody {} + +impl Artifact for CommissioningPermitBody { + const SCHEMA: &'static str = COMMISSIONING_PERMIT_SCHEMA; + const MAX_BYTES: usize = MAX_COMMISSIONING_PERMIT_BYTES; + + fn schema(&self) -> &str { + &self.statement.schema + } + + fn validate(&self) -> Result<(), QualificationFormatError> { + let statement = &self.statement; + canonical::valid_window( + statement.issued_at, + statement.not_before, + statement.not_after, + MAX_COMMISSIONING_SECONDS, + )?; + // Counting from issue prevents a long-dated dormant permit from + // reserving a future two-hour window beyond the maximum lifetime. + if statement.issued_at > statement.not_before + || statement.not_after - statement.issued_at > MAX_COMMISSIONING_SECONDS + { + return Err(QualificationFormatError::InvalidTimeWindow); + } + statement.binding.validate() + } +} + +/// Structurally validated bytes, without any claim of trusted authority. +pub type QualificationCommissioningPermit = Canonical; + +/// Untrusted canonical bytes the commissioning verifier authenticates. +#[derive(Clone, Copy, Debug)] +pub struct CommissioningInputs<'a> { + /// Root-issued, commissioning-purpose signer certificate. + pub signer_certificate: &'a [u8], + /// Root-issued revocation list. + pub revocation_list: &'a [u8], + /// Separately signed commissioning permit. + pub permit: &'a [u8], +} + +/// Runtime bindings derived independently of the supplied permit. +#[derive(Clone, Copy, Debug)] +pub struct CommissioningRequest<'a> { + /// Installed source revision. + pub source_commit: &'a GitCommit, + /// Current deployment tuple, never the caller's reported tuple. + pub tuple: &'a QualificationTuple, + /// Authenticated operator session's protected run identity. + pub protected_run: &'a BoundedText<256>, + /// Sealed verifier's exact proof-authoring principal commitment. + pub principal_sha256: Sha256Digest, + /// Operator-approved context actually executed by the verifier. + pub trusted_context_sha256: Sha256Digest, + /// Operator-approved disposable resource binding. + pub resources_sha256: Sha256Digest, + /// Native commitment of the verified canonical action. + pub canonical_action_sha256: Sha256Digest, +} + +/// A commissioning check's closed first refusal; never a qualification state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommissioningRefusal { + /// Missing, malformed, forged, wrong-root or wrong-purpose signed inputs. + Unavailable, + /// A currently or previously authenticated list revoked this signer. + Revoked, + /// The revocation sequence went backwards. + RevocationRollback, + /// Untrusted clock or time before signed issue/window start. + ClockUntrusted, + /// Root revocations are past their signed refresh deadline. + RevocationStale, + /// The permit or signer window ended. + Expired, + /// An exact session, principal, action, context, resource or target differs. + BindingMismatch, +} + +impl CommissioningRefusal { + /// Closed reason token, distinct from the qualification code family. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + Self::Revoked => "revoked", + Self::RevocationRollback => "revocation-rollback", + Self::ClockUntrusted => "clock-untrusted", + Self::RevocationStale => "revocation-stale", + Self::Expired => "expired", + Self::BindingMismatch => "binding-mismatch", + } + } +} + +/// Signature-checked commissioning authority, with no lease or readiness grant. +/// +/// Holding this sealed value authenticates bytes only. [`Self::evaluate`] +/// checks current time, remembered revocations and each runtime binding before +/// every lease. Only a private operator session may consume a successful +/// check, after an atomic durable budget claim. +#[derive(Clone, Debug)] +pub struct VerifiedCommissioningPermit { + certificate: QualificationSignerCertificate, + revocations: QualificationRevocationList, + permit: QualificationCommissioningPermit, +} + +impl VerifiedCommissioningPermit { + /// Authenticates all three closed artifacts under the pinned root. + /// + /// # Errors + /// + /// Returns [`CommissioningRefusal::Unavailable`] for any structural, + /// signature, root, signer, permission or certificate-window fault. No + /// supplied root or permit can upgrade ordinary application qualification. + pub fn verify( + root: &QualificationTrustRoot, + inputs: &CommissioningInputs<'_>, + ) -> Result { + let unavailable = CommissioningRefusal::Unavailable; + let certificate = + QualificationSignerCertificate::from_canonical_json(inputs.signer_certificate) + .map_err(|_| unavailable)?; + let revocations = QualificationRevocationList::from_canonical_json(inputs.revocation_list) + .map_err(|_| unavailable)?; + let permit = QualificationCommissioningPermit::from_canonical_json(inputs.permit) + .map_err(|_| unavailable)?; + let cert = certificate.body(); + let signer = &cert.statement; + let list = revocations.body(); + let permit_body = permit.body(); + let statement = &permit_body.statement; + let root_key = root.body().public_key_b64.to_bytes(); + let root_signature = |preimage: Result, QualificationFormatError>, + signature: &SignatureB64| { + preimage.is_ok_and(|bytes| verifies(&root_key, &bytes, &signature.to_bytes())) + }; + if signer.root_id != root.body().root_id + || list.statement.root_id != root.body().root_id + || !root_signature(cert.signing_preimage(), &cert.root_signature_b64) + || !root_signature(list.signing_preimage(), &list.root_signature_b64) + || signer.permitted_artifact_kinds + != [QualificationArtifactKind::QualificationCommissioningPermit] + || statement.signer_id != signer.signer_id + || statement.issued_at < signer.issued_at + || statement.not_before < signer.not_before + || statement.not_after > signer.not_after + || !permit_body.signing_preimage().is_ok_and(|bytes| { + verifies( + &signer.public_key_b64.to_bytes(), + &bytes, + &permit_body.signature_b64.to_bytes(), + ) + }) + { + return Err(unavailable); + } + Ok(Self { + certificate, + revocations, + permit, + }) + } + + /// Authenticated permit bytes; not proof of current validity or consumption. + #[must_use] + pub const fn permit(&self) -> &QualificationCommissioningPermit { + &self.permit + } + + /// Permanently remembers authenticated revocations and the largest sequence. + /// + /// A stale list still revokes what it names. The runtime must durably + /// persist this state before acknowledging an operator import. + pub fn remember(&self, state: &mut VerifierState) { + let list = &self.revocations.body().statement; + state + .revoked_signers + .extend(list.revoked_signers.iter().cloned()); + state + .revoked_qualifications + .extend(list.revoked_qualifications.iter().cloned()); + state.accepted_revocation_sequence = state.accepted_revocation_sequence.max(list.sequence); + } + + /// Checks signed freshness and exact runtime bindings for one lease attempt. + /// + /// A successful result is a necessary condition only: the runtime must + /// atomically claim one unit from the immutably registered shared budget + /// before credentials, and still execute ordinary proof/recipe/attempt + /// checks. This method cannot mint a [`crate::QualificationVerdict`]. + /// + /// # Errors + /// + /// Returns the first closed refusal. Earlier authenticated revocations + /// dominate time faults; rolled-back lists never restore authority. + pub fn evaluate( + &self, + request: &CommissioningRequest<'_>, + now: u64, + clock_trusted: bool, + state: &VerifierState, + ) -> Result<(), CommissioningRefusal> { + let signer = &self.certificate.body().statement; + let list = &self.revocations.body().statement; + let statement = &self.permit.body().statement; + let binding = &statement.binding; + if state.revoked_signers.contains(&signer.signer_id) + || list.revoked_signers.contains(&signer.signer_id) + { + return Err(CommissioningRefusal::Revoked); + } + if list.sequence < state.accepted_revocation_sequence { + return Err(CommissioningRefusal::RevocationRollback); + } + if !clock_trusted + || now < signer.issued_at + || now < signer.not_before + || now < list.issued_at + || now < statement.issued_at + || now < statement.not_before + { + return Err(CommissioningRefusal::ClockUntrusted); + } + // Windows are half-open. At the deadline, no lease can start. + if now >= list.next_update { + return Err(CommissioningRefusal::RevocationStale); + } + if now >= signer.not_after || now >= statement.not_after { + return Err(CommissioningRefusal::Expired); + } + if binding.source_commit != *request.source_commit + || binding.tuple != *request.tuple + || binding.protected_run != *request.protected_run + || binding.principal_sha256 != request.principal_sha256 + || binding + .trusted_contexts_sha256 + .binary_search(&request.trusted_context_sha256) + .is_err() + || binding.resources_sha256 != request.resources_sha256 + || binding + .allowed_actions + .binary_search(&request.canonical_action_sha256) + .is_err() + { + return Err(CommissioningRefusal::BindingMismatch); + } + Ok(()) + } +} diff --git a/product/qualification/auths-recipe-qualification/src/commissioning/tests.rs b/product/qualification/auths-recipe-qualification/src/commissioning/tests.rs new file mode 100644 index 000000000..65a29e910 --- /dev/null +++ b/product/qualification/auths-recipe-qualification/src/commissioning/tests.rs @@ -0,0 +1,149 @@ +//! Frozen public commissioning bytes, consumed without issuance code. + +use super::*; + +fn fixture() -> serde_json::Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../bindings/fixtures/qualification/commissioning-v2.json"); + serde_json::from_slice(&std::fs::read(path).expect("fixture")).expect("fixture JSON") +} + +#[test] +fn frozen_commissioning_bytes_authenticate_only_their_exact_binding() { + let fixture = fixture(); + let text = |member: &str| fixture[member].as_str().expect("artifact").as_bytes(); + let root = QualificationTrustRoot::from_canonical_json(text("trust_root")).expect("root"); + let verified = VerifiedCommissioningPermit::verify( + &root, + &CommissioningInputs { + signer_certificate: text("signer_certificate"), + revocation_list: text("revocation_list"), + permit: text("permit"), + }, + ) + .expect("signed permit"); + let permit = verified.permit(); + assert_eq!(permit.digest().to_hex(), fixture["permit_digest"]); + let binding = &permit.body().statement.binding; + assert_eq!( + binding.budget_key().expect("key").to_hex(), + fixture["budget_scope_sha256"] + ); + assert_eq!( + binding.budget_binding().expect("binding").to_hex(), + fixture["budget_binding_sha256"] + ); + let mut request = CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &binding.tuple, + protected_run: &binding.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: binding.trusted_contexts_sha256[0], + resources_sha256: binding.resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + }; + let now = permit.body().statement.not_before; + for context in &binding.trusted_contexts_sha256 { + request.trusted_context_sha256 = *context; + assert_eq!( + verified.evaluate(&request, now, true, &VerifierState::default()), + Ok(()) + ); + } + request.trusted_context_sha256 = Sha256Digest::from_bytes([0xfe; 32]); + assert_eq!( + verified.evaluate(&request, now, true, &VerifierState::default()), + Err(CommissioningRefusal::BindingMismatch) + ); + request.trusted_context_sha256 = binding.trusted_contexts_sha256[0]; + request.principal_sha256 = Sha256Digest::from_bytes([0xff; 32]); + assert_eq!( + verified.evaluate(&request, now, true, &VerifierState::default()), + Err(CommissioningRefusal::BindingMismatch) + ); +} + +#[test] +fn every_single_bit_signature_mutation_is_refused() { + let fixture = fixture(); + let text = |member: &str| fixture[member].as_str().expect("artifact").as_bytes(); + let root = QualificationTrustRoot::from_canonical_json(text("trust_root")).expect("root"); + let permit = + QualificationCommissioningPermit::from_canonical_json(text("permit")).expect("permit"); + let original = permit.body().signature_b64.to_bytes(); + for bit in 0..512 { + let mut body = permit.body().clone(); + let mut changed = original; + changed[bit / 8] ^= 1 << (bit % 8); + body.signature_b64 = SignatureB64::from_bytes(&changed); + let changed = QualificationCommissioningPermit::from_body(&body).expect("shape"); + assert_eq!( + VerifiedCommissioningPermit::verify( + &root, + &CommissioningInputs { + signer_certificate: text("signer_certificate"), + revocation_list: text("revocation_list"), + permit: changed.canonical_bytes(), + } + ) + .map(|_| ()), + Err(CommissioningRefusal::Unavailable), + "bit {bit}" + ); + } +} + +#[test] +fn unsigned_shape_never_accepts_extra_duplicate_or_unknown_members() { + let fixture = fixture(); + let permit = QualificationCommissioningPermit::from_canonical_json( + fixture["permit"].as_str().expect("artifact").as_bytes(), + ) + .expect("permit"); + for pointer in [ + "", + "/statement", + "/statement/binding", + "/statement/binding/tuple", + "/statement/binding/tuple/target", + "/statement/binding/offline_evidence", + ] { + let mut document = serde_json::to_value(permit.body()).expect("body"); + document.pointer_mut(pointer).expect("member")["extra"] = serde_json::json!(true); + assert_eq!( + QualificationCommissioningPermit::from_canonical_json( + &canonical::canonical_bytes(&document).expect("canonical") + ) + .map(|_| ()), + Err(QualificationFormatError::Malformed), + "{pointer}" + ); + } + let text = std::str::from_utf8(permit.canonical_bytes()).expect("UTF-8"); + let duplicate = text.replacen("\"issued_at\":", "\"issued_at\":0,\"issued_at\":", 1); + assert_eq!( + QualificationCommissioningPermit::from_canonical_json(duplicate.as_bytes()).map(|_| ()), + Err(QualificationFormatError::Malformed) + ); + let mut body = permit.body().clone(); + body.statement.schema = "auths.qualification-commissioning-permit/1".to_owned(); + assert_eq!( + QualificationCommissioningPermit::from_body(&body).map(|_| ()), + Err(QualificationFormatError::UnknownSchema) + ); + for contexts in [ + vec![], + vec![Sha256Digest::from_bytes([0x61; 32]); 2], + vec![ + Sha256Digest::from_bytes([0x65; 32]), + Sha256Digest::from_bytes([0x61; 32]), + ], + (0..=MAX_COMMISSIONING_CONTEXTS) + .map(|index| Sha256Digest::from_bytes([u8::try_from(index).expect("bound"); 32])) + .collect(), + ] { + let mut body = permit.body().clone(); + body.statement.binding.trusted_contexts_sha256 = contexts; + assert!(QualificationCommissioningPermit::from_body(&body).is_err()); + } +} diff --git a/product/qualification/auths-recipe-qualification/src/evidence.rs b/product/qualification/auths-recipe-qualification/src/evidence.rs index 35179a77b..6412aa971 100644 --- a/product/qualification/auths-recipe-qualification/src/evidence.rs +++ b/product/qualification/auths-recipe-qualification/src/evidence.rs @@ -107,6 +107,9 @@ pub enum Scenario { /// An application process without a credential cannot read secret /// material. ApplicationCannotReadSecret, + /// All required typed production doctor checks pass. Required for the + /// stable launch projection, never inferred from development readiness. + ProductionReadiness, /// A forged proof enters no provider. ForgedProof, /// An action altered after approval enters no provider. @@ -174,7 +177,7 @@ pub enum Scenario { impl Scenario { /// Every scenario, in the wall's order. - pub const ALL: [Self; 36] = [ + pub const ALL: [Self; 37] = [ Self::CleanSource, Self::RecipeDigestRederives, Self::RecipeVectors, @@ -182,6 +185,7 @@ impl Scenario { Self::OracleAccepts, Self::OracleRejects, Self::ApplicationCannotReadSecret, + Self::ProductionReadiness, Self::ForgedProof, Self::AlteredAction, Self::ProofReplay, @@ -220,7 +224,9 @@ impl Scenario { Self::CleanSource | Self::RecipeDigestRederives => WallRow::CleanSource, Self::RecipeVectors | Self::ClosedEnumerationHostile => WallRow::RecipeVectors, Self::OracleAccepts | Self::OracleRejects => WallRow::OracleAgreement, - Self::ApplicationCannotReadSecret => WallRow::SecretIsolation, + Self::ApplicationCannotReadSecret | Self::ProductionReadiness => { + WallRow::SecretIsolation + } Self::ForgedProof | Self::AlteredAction | Self::ProofReplay @@ -272,11 +278,15 @@ impl Scenario { } } - /// Whether every record needs a case for this scenario. The two that do - /// not are required exactly when a capability they show is exercised. + /// Whether every record needs a case for this scenario. Capability cases + /// are required when declared; production readiness is additionally + /// required by the stable launch projection. #[must_use] pub const fn always_required(self) -> bool { - !matches!(self, Self::ObserverRotation | Self::DeclaredCapability) + !matches!( + self, + Self::ObserverRotation | Self::DeclaredCapability | Self::ProductionReadiness + ) } /// Whether a case for this scenario may show `capability` exercised. diff --git a/product/qualification/auths-recipe-qualification/src/launch.rs b/product/qualification/auths-recipe-qualification/src/launch.rs new file mode 100644 index 000000000..21e600ecf --- /dev/null +++ b/product/qualification/auths-recipe-qualification/src/launch.rs @@ -0,0 +1,349 @@ +//! Inputs the release builder derives for the stable launch projection. + +use crate::{GitCommit, QualificationTarget, Sha256Digest}; + +/// Exact candidate identity, derived by the release builder rather than a +/// qualification record. A caller must read the candidate's executable and +/// maintained semantic closure to supply these facts. This value contains no +/// readiness flag and cannot authorize a gateway credential lease. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LaunchCandidate { + /// The clean candidate source commit. + pub commit: GitCommit, + /// The candidate's maintained gateway semantic closure. + pub gateway_semantic_closure_sha256: Sha256Digest, + /// The exact production deployment and candidate executable digest. + pub target: QualificationTarget, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vectors::{ + Keys, NOW, attestation, attestation_statement, bounded, certificate, certificate_statement, + digest_of, index, index_entry, index_statement, record, revocation, revocation_statement, + text, trust_root, tuple, + }; + use crate::{ + Canonical, CapabilityResult, EVIDENCE_SCHEMA, EvidenceBody, EvidenceCase, EvidenceMember, + EvidenceMemberKind, EvidenceResult, QualificationEvidence, QualificationInputs, + QualificationTrustRoot, RecipeFamilyId, RecipeQualificationRecord, RecordBody, Scenario, + VerifiedQualifications, VerifierState, + }; + use auths_connections::{CredentialStoreKind, ProviderKind}; + + struct Fixture { + records: Vec, + evidence: Vec, + expired: Option, + revoked: Option, + } + + impl Fixture { + fn new(claims: &[(&str, &str, &str)], readiness: bool) -> Self { + let mut records = Vec::new(); + let mut evidence = Vec::new(); + for (index, (family, provider, contract)) in claims.iter().enumerate() { + let mut deployment = tuple(); + deployment.recipe_family = RecipeFamilyId::parse(*family).expect("family"); + let mut declared = crate::vectors::contract(); + declared.api_release = bounded(contract); + deployment.provider_contract_id = + crate::vectors::decoded_contract(&declared).contract_id(); + let mut body = record( + u8::try_from(index + 1).expect("bounded fixture"), + deployment, + ); + body.provider_kind = ProviderKind::parse(*provider).expect("provider"); + body.provenance.environment = + bounded(&format!("recipe-qualification-live-{family}")); + let artifacts = artifacts(&body, readiness); + body.evidence = artifacts + .iter() + .map(|artifact| EvidenceMember { + member: artifact.body().member, + result: EvidenceResult::Passed, + evidence_sha256: artifact.digest(), + cases: u32::try_from(artifact.body().cases.len()).expect("bounded cases"), + unauthorized_provider_entries: 0, + }) + .collect(); + records.push(body); + evidence.extend(artifacts); + } + Self { + records, + evidence, + expired: None, + revoked: None, + } + } + + fn two() -> Self { + Self::new( + &[ + ("refund-v1", "stripe", "stripe contract"), + ("record-update-v1", "airtable", "airtable contract"), + ], + true, + ) + } + + fn candidate(&self) -> LaunchCandidate { + let record = &self.records[0]; + LaunchCandidate { + commit: record.provenance.commit.clone(), + gateway_semantic_closure_sha256: record.tuple.gateway_semantic_closure_sha256, + target: record.tuple.target.clone(), + } + } + + fn verified(&self, omit_record: bool) -> VerifiedQualifications { + let keys = Keys::fixed(); + let root = QualificationTrustRoot::from_body(&trust_root(&keys)).expect("root"); + let certificate = text(&certificate(certificate_statement(&keys), &keys.root)); + let mut revoked = revocation_statement(NOW); + if let Some(index) = self.revoked { + revoked + .revoked_qualifications + .push(self.records[index].qualification_id.clone()); + } + let revocations = text(&revocation(revoked, &keys.root)); + let records: Vec = self.records.iter().map(text).collect(); + let attestations: Vec = records + .iter() + .enumerate() + .map(|(index, record)| { + let mut statement = + attestation_statement(record, u8::try_from(index + 1).expect("id")); + if self.expired == Some(index) { + statement.not_after = NOW - 1; + } + text(&attestation(statement, &keys.signer)) + }) + .collect(); + let entries = records + .iter() + .zip(&attestations) + .enumerate() + .map(|(index, (record, attestation))| { + index_entry(u8::try_from(index + 1).expect("id"), record, attestation) + }) + .collect(); + let index = text(&index(index_statement(entries), &keys.signer)); + let records: Vec<&[u8]> = records + .iter() + .take(if omit_record { 1 } else { records.len() }) + .map(String::as_bytes) + .collect(); + let attestations: Vec<&[u8]> = attestations.iter().map(String::as_bytes).collect(); + VerifiedQualifications::verify( + &root, + &QualificationInputs { + signer_certificate: certificate.as_bytes(), + revocation_list: revocations.as_bytes(), + release_index: index.as_bytes(), + records: &records, + attestations: &attestations, + }, + ) + } + + fn ready(&self) -> bool { + self.verified(false).stable_launch_ready( + &self.candidate(), + &self.evidence, + NOW, + true, + &VerifierState::default(), + ) + } + } + + fn artifacts(record: &RecordBody, readiness: bool) -> Vec { + EvidenceMemberKind::ALL + .into_iter() + .map(|member| { + let mut cases: Vec = Scenario::ALL + .into_iter() + .filter(|scenario| { + scenario.member() == member + && (scenario.always_required() + || (readiness && *scenario == Scenario::ProductionReadiness)) + }) + .map(|scenario| EvidenceCase { + id: bounded(&format!("{scenario:?}")), + scenario, + capabilities: Vec::new(), + unauthorized_provider_entries: 0, + }) + .collect(); + for capability in &record.capabilities { + if capability.result == CapabilityResult::Exercised + && capability.capability.member() == member + { + let scenario = match capability.capability { + crate::CapabilityKind::Recovery => Scenario::ResponseLoss, + crate::CapabilityKind::ObserverRotation => Scenario::ObserverRotation, + _ => Scenario::DeclaredCapability, + }; + cases.push(EvidenceCase { + id: bounded(&format!("capability-{:?}", capability.capability)), + scenario, + capabilities: vec![capability.capability], + unauthorized_provider_entries: 0, + }); + } + } + cases.sort_by(|left, right| left.id.cmp(&right.id)); + Canonical::from_body(&EvidenceBody { + schema: EVIDENCE_SCHEMA.to_owned(), + member, + commit: record.provenance.commit.clone(), + tuple_sha256: record.tuple.digest().expect("tuple"), + cases, + live_effects: (member == EvidenceMemberKind::Live) + .then_some(record.live_effects), + }) + .expect("evidence") + }) + .collect() + } + + #[test] + fn two_signed_independent_production_claims_with_closed_evidence_are_ready() { + let fixture = Fixture::two(); + for record in &fixture.records { + RecipeQualificationRecord::from_body(record).expect("closed record"); + } + assert!(fixture.ready()); + } + + #[test] + fn each_independence_dimension_is_required() { + for claims in [ + [ + ("refund-v1", "stripe", "stripe contract"), + ("refund-v1", "airtable", "airtable contract"), + ], + [ + ("refund-v1", "stripe", "shared contract"), + ("record-update-v1", "airtable", "shared contract"), + ], + [ + ("refund-v1", "stripe", "one contract"), + ("record-update-v1", "stripe", "another contract"), + ], + ] { + assert!(!Fixture::new(&claims, true).ready()); + } + } + + #[test] + fn missing_or_tampered_evidence_and_missing_index_members_refuse() { + let fixture = Fixture::two(); + let verified = fixture.verified(false); + let candidate = fixture.candidate(); + let state = VerifierState::default(); + assert!(!verified.stable_launch_ready( + &candidate, + &fixture.evidence[1..], + NOW, + true, + &state + )); + let mut changed = fixture.evidence.clone(); + let mut body = changed[0].body().clone(); + body.cases[0].id = bounded("substituted-case"); + body.cases.sort_by(|left, right| left.id.cmp(&right.id)); + changed[0] = QualificationEvidence::from_body(&body).expect("changed evidence"); + assert!(!verified.stable_launch_ready(&candidate, &changed, NOW, true, &state)); + assert!(!fixture.verified(true).stable_launch_ready( + &candidate, + &fixture.evidence, + NOW, + true, + &state + )); + assert!( + !Fixture::new( + &[ + ("refund-v1", "stripe", "one"), + ("update-v1", "airtable", "two") + ], + false + ) + .ready() + ); + } + + #[test] + fn candidate_drift_development_custody_and_untrusted_time_refuse() { + let fixture = Fixture::two(); + let verified = fixture.verified(false); + let candidate = fixture.candidate(); + let state = VerifierState::default(); + for changed in [ + LaunchCandidate { + commit: GitCommit::parse("abcdef0123456789abcdef0123456789abcdef01") + .expect("commit"), + ..candidate.clone() + }, + LaunchCandidate { + gateway_semantic_closure_sha256: digest_of("changed closure"), + ..candidate.clone() + }, + LaunchCandidate { + target: crate::QualificationTarget { + gateway_build_sha256: digest_of("changed binary"), + ..candidate.target.clone() + }, + ..candidate.clone() + }, + LaunchCandidate { + target: crate::QualificationTarget { + store_kind: crate::LifecycleStoreKind::SharedFileV1, + ..candidate.target.clone() + }, + ..candidate.clone() + }, + LaunchCandidate { + target: crate::QualificationTarget { + credential_store_kind: CredentialStoreKind::LocalFileV1, + ..candidate.target.clone() + }, + ..candidate.clone() + }, + ] { + assert!(!verified.stable_launch_ready(&changed, &fixture.evidence, NOW, true, &state)); + } + assert!(!verified.stable_launch_ready(&candidate, &fixture.evidence, NOW, false, &state)); + assert!(!verified.stable_launch_ready( + &candidate, + &fixture.evidence, + NOW + crate::MAX_REVOCATION_SECONDS + 1, + true, + &state + )); + } + + #[test] + fn a_valid_family_entry_cannot_hide_an_expired_or_revoked_duplicate() { + for revoked in [false, true] { + let mut fixture = Fixture::new( + &[ + ("refund-v1", "stripe", "stripe contract"), + ("record-update-v1", "airtable", "airtable contract"), + ("refund-v1", "stripe", "stripe contract"), + ], + true, + ); + if revoked { + fixture.revoked = Some(2); + } else { + fixture.expired = Some(2); + } + assert!(!fixture.ready()); + } + } +} diff --git a/product/qualification/auths-recipe-qualification/src/lib.rs b/product/qualification/auths-recipe-qualification/src/lib.rs index 1e6e09a64..52f80fbda 100644 --- a/product/qualification/auths-recipe-qualification/src/lib.rs +++ b/product/qualification/auths-recipe-qualification/src/lib.rs @@ -18,9 +18,11 @@ mod canonical; mod closure; +mod commissioning; mod error; mod evidence; mod ids; +mod launch; mod model; mod release; mod verify; @@ -46,6 +48,15 @@ pub use closure::{ GatewaySemanticClosure, MAX_SEMANTIC_CLOSURE_BYTES, MAX_SEMANTIC_CLOSURE_FILES, SEMANTIC_CLOSURE_SCHEMA, SemanticClosureBody, SemanticClosureFile, }; +pub use commissioning::{ + COMMISSIONING_BUDGET_BINDING_DOMAIN, COMMISSIONING_BUDGET_KEY_DOMAIN, + COMMISSIONING_PERMIT_FILE, COMMISSIONING_PERMIT_SCHEMA, CommissioningBinding, + CommissioningInputs, CommissioningOfflineEvidence, CommissioningPermitBody, + CommissioningPermitStatement, CommissioningRefusal, CommissioningRequest, + MAX_COMMISSIONING_ACTIONS, MAX_COMMISSIONING_CONTEXTS, MAX_COMMISSIONING_LEASES, + MAX_COMMISSIONING_PERMIT_BYTES, MAX_COMMISSIONING_SECONDS, QualificationCommissioningPermit, + VerifiedCommissioningPermit, +}; pub use error::QualificationFormatError; pub use evidence::{ ClosureFault, EVIDENCE_SCHEMA, EvidenceBody, EvidenceCase, MAX_EVIDENCE_BYTES, @@ -56,6 +67,7 @@ pub use ids::{ BoundedText, GitCommit, InvalidIdentifier, PublicKeyB64, QualificationId, QualificationRootId, QualificationSignerId, RecipeFamilyId, Sha256Digest, SignatureB64, }; +pub use launch::LaunchCandidate; pub use model::{ CapabilityKind, CapabilityResult, ContractDeclarations, EvidenceMember, EvidenceMemberKind, EvidenceResult, ExercisedCapability, InstalledPackage, LifecycleStoreKind, LiveEffects, diff --git a/product/qualification/auths-recipe-qualification/src/release.rs b/product/qualification/auths-recipe-qualification/src/release.rs index 5bd73ca15..a6ce6e84b 100644 --- a/product/qualification/auths-recipe-qualification/src/release.rs +++ b/product/qualification/auths-recipe-qualification/src/release.rs @@ -75,6 +75,8 @@ pub enum QualificationSignerKind { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum QualificationArtifactKind { + /// A bounded first-qualification run, never a completed qualification. + QualificationCommissioningPermit, /// The release index. QualificationReleaseIndex, /// A qualification attestation. diff --git a/product/qualification/auths-recipe-qualification/src/vectors/mod.rs b/product/qualification/auths-recipe-qualification/src/vectors/mod.rs index 48d93f573..47d3314c2 100644 --- a/product/qualification/auths-recipe-qualification/src/vectors/mod.rs +++ b/product/qualification/auths-recipe-qualification/src/vectors/mod.rs @@ -280,7 +280,7 @@ pub(super) fn target() -> QualificationTarget { gateway_version: bounded("1.0.0-rc.1"), gateway_build_sha256: digest_of("gateway build"), store_kind: LifecycleStoreKind::PostgresqlV1, - store_schema: bounded("auths.lifecycle.postgresql/5"), + store_schema: bounded("auths.lifecycle.postgresql/6"), credential_store_kind: CredentialStoreKind::AwsSecretsManagerV1, } } diff --git a/product/qualification/auths-recipe-qualification/src/vectors/verification.rs b/product/qualification/auths-recipe-qualification/src/vectors/verification.rs index 8bdb1d134..8c5b62a6e 100644 --- a/product/qualification/auths-recipe-qualification/src/vectors/verification.rs +++ b/product/qualification/auths-recipe-qualification/src/vectors/verification.rs @@ -687,7 +687,7 @@ fn target_cases() -> Vec { deployed.target.store_kind = LifecycleStoreKind::SharedFileV1; }), drifted("wrong-store-schema", "wrong-store", |deployed| { - deployed.target.store_schema = super::bounded("auths.lifecycle.postgresql/6"); + deployed.target.store_schema = super::bounded("auths.lifecycle.postgresql/0"); }), drifted( "wrong-credential-store", diff --git a/product/qualification/auths-recipe-qualification/src/verify.rs b/product/qualification/auths-recipe-qualification/src/verify.rs index 9b376cb5f..6b1e3ce28 100644 --- a/product/qualification/auths-recipe-qualification/src/verify.rs +++ b/product/qualification/auths-recipe-qualification/src/verify.rs @@ -12,10 +12,11 @@ //! compared as a digest. use crate::{ - Canonical, QualificationArtifactKind, QualificationId, QualificationReleaseIndex, - QualificationRevocationList, QualificationSignerCertificate, QualificationSignerId, - QualificationTrustRoot, QualificationTuple, RecipeQualificationAttestation, - RecipeQualificationRecord, RecipeQualificationState, Sha256Digest, + Canonical, LaunchCandidate, LifecycleStoreKind, QualificationArtifactKind, + QualificationEvidence, QualificationId, QualificationReleaseIndex, QualificationRevocationList, + QualificationSignerCertificate, QualificationSignerId, QualificationTrustRoot, + QualificationTuple, RecipeQualificationAttestation, RecipeQualificationRecord, + RecipeQualificationState, Scenario, Sha256Digest, verify_evidence_closure, }; use ed25519_dalek::{Signature, VerifyingKey}; use std::collections::BTreeSet; @@ -126,7 +127,7 @@ pub struct QualificationInputs<'input> { pub attestations: &'input [&'input [u8]], } -fn verifies(key: &[u8; 32], preimage: &[u8], signature: &[u8; 64]) -> bool { +pub(crate) fn verifies(key: &[u8; 32], preimage: &[u8], signature: &[u8; 64]) -> bool { VerifyingKey::from_bytes(key).is_ok_and(|key| { key.verify_strict(preimage, &Signature::from_bytes(signature)) .is_ok() @@ -157,6 +158,120 @@ pub struct VerifiedQualifications { } impl VerifiedQualifications { + /// Computes AP-SPEC-066's technical stable launch gate for `candidate`. + /// + /// Every index entry must have its own usable attestation, match the clean + /// candidate's exact build, target and semantic closure, remain qualified + /// at the supplied trusted time, and close over the presented protected + /// evidence, including production readiness. At least two distinct recipe + /// families, contracts and provider kinds must be covered. Missing or + /// excess evidence, a development target, an unusable extra index entry, + /// or any failed condition returns false. This does not represent the + /// separate human release judgment or independent operator adoption. + #[must_use] + pub fn stable_launch_ready( + &self, + candidate: &LaunchCandidate, + evidence: &[QualificationEvidence], + now: u64, + clock_trusted: bool, + state: &VerifierState, + ) -> bool { + let Some(index) = self.current_index(state) else { + return false; + }; + if candidate.target.store_kind != LifecycleStoreKind::PostgresqlV1 + || !candidate.target.credential_store_kind.is_production() + || self.listed.len() != index.body().statement.entries.len() + || self.listed.len() < 2 + || evidence.len() != self.listed.len() * crate::EvidenceMemberKind::ALL.len() + { + return false; + } + let mut families = BTreeSet::new(); + let mut contracts = BTreeSet::new(); + let mut providers = BTreeSet::new(); + let mut used_evidence = BTreeSet::new(); + for listed in &self.listed { + let record = listed.record.body(); + if !self.launch_record_matches(listed, candidate, now, state) + || !self + .evaluate(&record.tuple, now, clock_trusted, state) + .permits_lease() + { + return false; + } + let artifacts: Option> = record + .evidence + .iter() + .map(|member| { + let artifact = evidence + .iter() + .find(|artifact| artifact.digest() == member.evidence_sha256)?; + used_evidence.insert(artifact.digest()); + Some(artifact.clone()) + }) + .collect(); + let Some(artifacts) = artifacts else { + return false; + }; + if verify_evidence_closure(&listed.record, &artifacts).is_err() + || !artifacts.iter().any(|artifact| { + artifact + .body() + .cases + .iter() + .any(|case| case.scenario == Scenario::ProductionReadiness) + }) + { + return false; + } + families.insert(record.tuple.recipe_family.clone()); + contracts.insert(record.tuple.provider_contract_id); + providers.insert(record.provider_kind.as_str()); + } + used_evidence.len() == evidence.len() + && families.len() >= 2 + && contracts.len() >= 2 + && providers.len() >= 2 + } + + fn launch_record_matches( + &self, + listed: &Listed, + candidate: &LaunchCandidate, + now: u64, + state: &VerifierState, + ) -> bool { + let record = listed.record.body(); + // Family evaluation selects its best attestation. Inspect this entry's + // own signed window and revocation too; an extra claim cannot hide + // behind a fresh unrevoked entry for the same family. + listed.attestation.as_ref().is_some_and(|attestation| { + let statement = &attestation.body().statement; + now >= statement.issued_at && now >= statement.not_before && now <= statement.not_after + }) && record.provenance.commit == candidate.commit + && record.tuple.target == candidate.target + && record.tuple.gateway_semantic_closure_sha256 + == candidate.gateway_semantic_closure_sha256 + && !state + .revoked_qualifications + .contains(&record.qualification_id) + && !self.revocations.as_ref().is_some_and(|list| { + list.body() + .statement + .revoked_qualifications + .contains(&record.qualification_id) + }) + && record.provenance.repository.as_str() == "github.com/auths-dev/auths-proof" + && record.provenance.workflow.as_str() == ".github/workflows/recipe-qualification.yml" + && record.provenance.environment.as_str() + == format!( + "recipe-qualification-live-{}", + record.tuple.recipe_family.as_str() + ) + } + /// Checks every signature under the pinned `root`. An input that does /// not decode or verify is kept as absent; nothing is trusted from it. #[must_use] diff --git a/product/runtime/auths-gateway/semantic-closure.json b/product/runtime/auths-gateway/semantic-closure.json index 560b8240e..5b0e63b68 100644 --- a/product/runtime/auths-gateway/semantic-closure.json +++ b/product/runtime/auths-gateway/semantic-closure.json @@ -1 +1 @@ -{"files":[{"path":"Cargo.lock","sha256":"c94ed342774d5e2823f01ecf74b8a4812c5f9a6b0cf811d31f2e2cecb671a275"},{"path":"Cargo.toml","sha256":"6e0240aa432bcdb5b4f855618994582d2e428afe5ee4e4f6a3f8d21a518f8d24"},{"path":"core/adapters/auths-did-keri/Cargo.toml","sha256":"86ba3e3f9a0a70d970568bfb3113b7ba7bbdd81c3eebbb52743dd574a0c0ecaa"},{"path":"core/adapters/auths-did-keri/src/lib.rs","sha256":"a75674842f8a7142004369ccd206ab43e476459293f0ff4bed4d4e659031b3e3"},{"path":"core/adapters/auths-did-key/Cargo.toml","sha256":"d5a3ae07ecde83f91eed4c3785051dd0f44eab6c891754f3309b6b40596707d7"},{"path":"core/adapters/auths-did-key/src/lib.rs","sha256":"b76a3f4528a725df53cfa9242e6e414e1d26caa48468aaed497e28b121f4497c"},{"path":"core/adapters/auths-raw-key/Cargo.toml","sha256":"f49616bc18c19e6aaa7e6a02ccebcdb9f5c1422c1278ef9869e0dbf22ceda7be"},{"path":"core/adapters/auths-raw-key/src/lib.rs","sha256":"45eec1b295dac3bed65cd79c942870b926b06b3f5d38eaebaeb6140ac69315fa"},{"path":"core/crates/auths-algebra-kernel/Cargo.toml","sha256":"0cb078915287b8c47d20fb97b6fa14135ed1c61c4985c4de2753855563c27bba"},{"path":"core/crates/auths-algebra-kernel/src/generated.rs","sha256":"ae4bcbef42a408c7ffa0c6a74a42f7de177a6f14479e51d205acf830bda05960"},{"path":"core/crates/auths-algebra-kernel/src/lib.rs","sha256":"6654667eeb9b353d6648954c9fd2e8838462a82ce6b2803eb49e1366892c68c6"},{"path":"core/crates/auths-assurance/Cargo.toml","sha256":"afcb12a97ddd09d7a264b9ecd3fcaefc865ff2c9b4539ee85b00883420ba333a"},{"path":"core/crates/auths-assurance/src/lib.rs","sha256":"0fb2ec844d674b25a9e20b1601452c89c2454901fa506adfa9bb38ef9a7fa213"},{"path":"core/crates/auths-author/Cargo.toml","sha256":"1e6f0e3bc079ddb558decf0db26b291c6e72fc47327b6df1b7650d41689b215f"},{"path":"core/crates/auths-author/src/lib.rs","sha256":"e14475f2f49715a1869d5fa6b11ff1fbb72497e39097e09f833b0bae7e5b3f2b"},{"path":"core/crates/auths-author/src/observations.rs","sha256":"dda7d6bfa6f4bd7e95a480ca283c2c0e996f69c5c15cb0c7197af328131a7066"},{"path":"core/crates/auths-authority/Cargo.toml","sha256":"4f68212938029f79ebd857b4f3038494eb85d0163d49f07e51376715cf959815"},{"path":"core/crates/auths-authority/src/lib.rs","sha256":"da88d5194de4e17449e69972c0fb042afa3c39798a8f570f30771e77c384f1eb"},{"path":"core/crates/auths-codec/Cargo.toml","sha256":"e20e51826e17814effd9d8925d18d5fbdd6c0d37bf50231e743ec2611314722c"},{"path":"core/crates/auths-codec/src/approval.rs","sha256":"fbff43bf4d57f69f8b7738a8647751cde75c5a932dee65d924a1fe9c7202f91f"},{"path":"core/crates/auths-codec/src/bounded_policy.rs","sha256":"d21f6728be2d8a04b6d94921f2b683f7e61cbb42e0ac6866f1c88a49caa534c3"},{"path":"core/crates/auths-codec/src/decode.rs","sha256":"ba2999584cdf53b1b517ecb9c7ea50d39efee17c666a9a38e2ad1ac94379f778"},{"path":"core/crates/auths-codec/src/encode.rs","sha256":"8491192053e2e15d62b4d2a491de22405b7487ead69ab6f6bca40669ce7ca5f7"},{"path":"core/crates/auths-codec/src/error.rs","sha256":"fecb0737c16deb42d859f1dc7727c698438d9ac12064fd99e0024629642c390c"},{"path":"core/crates/auths-codec/src/hash.rs","sha256":"f88e111f1dbb2cef6b7b5e9c948cb45ff92e366710c8a5f5c636138aa748479e"},{"path":"core/crates/auths-codec/src/lib.rs","sha256":"3b0e5c94c6f34f750b88a9333e1d5c8145746d845d3a23e0d2f61807cf592ea7"},{"path":"core/crates/auths-codec/src/observation.rs","sha256":"38328b0c71ff97a4196eeade8b4faa889527ff1f80bb168468bcd49082a3987e"},{"path":"core/crates/auths-composition/Cargo.toml","sha256":"331e13025eb82588b5269ed3975929eb41c823d588c49e4c296273d85c6fe80e"},{"path":"core/crates/auths-composition/src/lib.rs","sha256":"e42e3b0b6a9985b73c533aeb23799c638979aa27a9ce7d0f10a8d60340570fb1"},{"path":"core/crates/auths-model/Cargo.toml","sha256":"565c15302b0cc9270db1e28efa3862804846850ba933cc17aea8abcec4a65987"},{"path":"core/crates/auths-model/src/approval.rs","sha256":"54cf44a9ef747ec21546a4086aecfb130bc7803b0d646430e6082df51e6e305f"},{"path":"core/crates/auths-model/src/bounded.rs","sha256":"f90496abc0a06c028b4d850af6ddab7d850bfafeaa3d042f1b1246a41d8bbbc9"},{"path":"core/crates/auths-model/src/bounded_policy.rs","sha256":"306a8f049cc7cfc7635fe0e4de47eb7813eed63d3c14c3be2b51586aa4eea450"},{"path":"core/crates/auths-model/src/lib.rs","sha256":"e7cfe6bb9574c6b016aa63e70658c31e0b90903ec6479a49f4a2ceb64f5bad97"},{"path":"core/crates/auths-model/src/observation.rs","sha256":"b3ed3e3cc090afca2896a7d3de7988afccc1ee1ea0ebd91b51a8bc0f20ad8dbb"},{"path":"core/crates/auths-multikey/Cargo.toml","sha256":"858756c1197befcada06134742bffe8d58cf674cadd6c046bf710570e96efaf2"},{"path":"core/crates/auths-multikey/src/lib.rs","sha256":"f5177fffb11e40eeaaa665ba886c541fc1f24d5eacdd6983fb03dc20148733f0"},{"path":"core/crates/auths-ports/Cargo.toml","sha256":"596a9b0f5af3717c16a6d13f7054d8a106bcd9f01f5f72c2235dd2afcf0ab16c"},{"path":"core/crates/auths-ports/src/binding.rs","sha256":"a16c012d40aa4249e3ec54372b9611006d0bfe8c7dc3c741911c2194ad17736d"},{"path":"core/crates/auths-ports/src/diagnostics.rs","sha256":"aa596572503b5a65a918bb2ff38281c144ff53821ee914e9325c852828d852df"},{"path":"core/crates/auths-ports/src/lib.rs","sha256":"8da1debf86f9532df0660155d04298beb1ba3a1204508f8586e2e67cd2fb3449"},{"path":"core/crates/auths-ports/src/path.rs","sha256":"b68fc547b4e4d97a60cbbc1cc1939fc6b34a8233b81f840bd396914a707e3ea1"},{"path":"core/crates/auths-raw-key-core/Cargo.toml","sha256":"98c0bc8527a537512e43a74dd6ba9658c6f0cdc16610cf938e3f9b43785d836d"},{"path":"core/crates/auths-raw-key-core/src/lib.rs","sha256":"98c0500c41697aae28708ad0bf970b9a97583d96a3d2c18d23e7fa351e0f7d12"},{"path":"core/crates/auths-registries/Cargo.toml","sha256":"590e49e58b0622d9d6274fea46796dcdb07dec9550eff295cce6a6427ffa51e2"},{"path":"core/crates/auths-registries/src/lib.rs","sha256":"450d3886d85ee333b7e046317fb5b47cde62483d5f3e41d46265e2caf4c373ec"},{"path":"core/crates/auths-registries/src/template.rs","sha256":"1224039dd2b3ce6fa8357613d8cafbae40400493da7176da202b9ade62fa05e4"},{"path":"core/crates/auths-signature-core/Cargo.toml","sha256":"6bee2230eb30af1be0bd5c2d57bd3e1903497a70028d47290d4ac6e9e0e8bca0"},{"path":"core/crates/auths-signature-core/src/lib.rs","sha256":"6e3f9311ded5e49ea50d445eb21e6d8fa12442aa4362a73e0cb6fda15b7cd865"},{"path":"core/crates/auths-signature/Cargo.toml","sha256":"7f7b5682466ba439d8c59579022cb61068cad9fb4bd2d73e1cc21327c902b450"},{"path":"core/crates/auths-signature/src/lib.rs","sha256":"b3d9ac4cc3ba0797b9e1c09f2d6ac05089b25dac4e285a5af28c0165b33d925b"},{"path":"core/crates/auths-verifier/Cargo.toml","sha256":"c43358e06a33324b41a8b93e5cc7279c55f7d21f1f6cfaa539a3fc7b4c27a38f"},{"path":"core/crates/auths-verifier/src/approval.rs","sha256":"c3cae6a49e169051eb555bb092b1069a4d422516d6a2114623a89e5f31ff923f"},{"path":"core/crates/auths-verifier/src/causal.rs","sha256":"9635230f33b13a263573d319ac8a1976addbcc07677514a43e3c7bf28dc04587"},{"path":"core/crates/auths-verifier/src/lib.rs","sha256":"f22dec38dfa47ced8da4f605390da8453273bee1da010f57fd1fdfcb10e0b389"},{"path":"core/crates/auths-verifier/src/observation.rs","sha256":"0bb07f06a0f9524c88427df1c972b1de8ba038a9eeeb15d3134c25125ae84b1e"},{"path":"core/crates/auths-verifier/src/trace.rs","sha256":"2213d72023a1d9e0413a2cf2c51c3dad9e04775d86033a0caaa9d29a8f0036f8"},{"path":"exchange/crates/auths-proof-exchange-model/Cargo.toml","sha256":"34b2db64f93a288e942b5a3918d28cdcbef906e0dc9074ce848018d3d5f7a1fa"},{"path":"exchange/crates/auths-proof-exchange-model/src/lib.rs","sha256":"49fac5aff2339d3a57c41cb8bb2a82852538e8f77ecc4b157c88e264ff31fb55"},{"path":"exchange/crates/auths-proof-exchange-port/Cargo.toml","sha256":"7ff660f1b7579dc570c791090aeb450404fbbf44c92781e289f5ccdde746d739"},{"path":"exchange/crates/auths-proof-exchange-port/src/lib.rs","sha256":"b408fb40c6432edc1facb3d8b3e9433a033f8dc73fe2e0ba5a88c972ca675021"},{"path":"product/config/auths-config/Cargo.toml","sha256":"e4b568ab9f6d84f7d7e6d66443fe825f360db9c27bc7c31d6ddc0647ba3ab304"},{"path":"product/config/auths-config/src/lib.rs","sha256":"fecf00ea65cef9018fda9af2063326d7065b869266d0d9e73a4f06816f1102cc"},{"path":"product/errors/auths-errors/Cargo.toml","sha256":"3b3a199b6460ba75bda84475bb5accd35e1485448a3981038db85bf4772dd350"},{"path":"product/errors/auths-errors/src/lib.rs","sha256":"7feeb9069fa4003f94f1f07c1c94619cf1c883e0dbaaf2187f04d0f742264bdd"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/Cargo.toml","sha256":"716f1f2a117d6d69d8effe9ee9057e24ba3502f20af6a602b06facb638cdabed"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/api.rs","sha256":"9ea61dd7ddd93282b073b98e453e6b75b26dc49689c04e9782435d06a3a09e48"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/clock.rs","sha256":"73f0a6e0f5903883329fa6683f5888288f759512ac69e5c87efd2b6b87f7d79f"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/http.rs","sha256":"14c1fce14e24c984b2a275563a29b6326fb08367922b6e8d37ba38b9cb81aa2b"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/identity.rs","sha256":"186de11230c8b33375951c4368609f8976bf9795f171af3fd7fac26207a94361"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs","sha256":"0b4617d044f5254a5ab22f5a2578f91cc025206a8c1845be39c1c56dcdf7a5df"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/names.rs","sha256":"41bb23572d6002e23ee103d6fe70ecd68764861c6f2e48a1c53af998270a4afa"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/sigv4.rs","sha256":"39fed883f4802e68d3b41a68fea0aba0737810996b33d13577de70faba004c29"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/store.rs","sha256":"fc60ee9c49b2f4f063c449b4c78100c5c4872779e94ca168f796603b380ef6e4"},{"path":"product/integrations/auths-custody/Cargo.toml","sha256":"1e14034185ad1ec24a977fd5f41d0abee219606245c046ce2a82287fadef448b"},{"path":"product/integrations/auths-custody/src/conformance.rs","sha256":"8ed1afd58f18fc6becc268908aa6419c9aa404c79da0197963eb2599df44acf6"},{"path":"product/integrations/auths-custody/src/key.rs","sha256":"70cbb0fd21b6f5652157845a22c35be2d0f6c05b3d138fc4b9bb6dbdc3d81c40"},{"path":"product/integrations/auths-custody/src/lib.rs","sha256":"8ee593c1f59b0fd2126bdda9138026f5bc531beb64d4489a99785842e7b98b43"},{"path":"product/operations/auths-operations/Cargo.toml","sha256":"73dde1e914ba5e1386f8d49c0d730423cc72ae77ec44c6554a6a75cc8e8922b1"},{"path":"product/operations/auths-operations/src/explanation.rs","sha256":"b4a39bcbd5712cb9e735ee7deeb281f3fa6af5ddca973735bf3d87dce427ef11"},{"path":"product/operations/auths-operations/src/lib.rs","sha256":"3f401a56555113b98f57bca172a2660eaecedf76247b83a3fdbb089c133a29cd"},{"path":"product/operations/auths-operations/src/render/json.rs","sha256":"2ae042639960afee123730e945d373bc5dda1d2b3af20ae16c939a7e99b9e418"},{"path":"product/operations/auths-operations/src/render/mod.rs","sha256":"b3b7f6fe473f49227ace5a68bfc3d8dc14b5db8dd9888fd9e207070337b1b8bf"},{"path":"product/operations/auths-operations/src/render/text.rs","sha256":"f216cf3bdb00e264523271b16e7059535ac6b1aabf271cebd7935af155e4bfec"},{"path":"product/policy/auths-bounded-policy/Cargo.toml","sha256":"babd5d200923137ead71d8c2528f847cea323ce98da942a1320b054436612c47"},{"path":"product/policy/auths-bounded-policy/src/arithmetic.rs","sha256":"75e43c5d4eab8d1f71c1daf53839f5f9fe45a400afc1f63a30c6f55ce3632627"},{"path":"product/policy/auths-bounded-policy/src/commitment.rs","sha256":"c5706f2d583454e1a7f570f7ada138d4d864bef0205dfae7c1296774f396bb3f"},{"path":"product/policy/auths-bounded-policy/src/eligibility.rs","sha256":"c9acdfb793074e687fc36efad506be27dbd99ffbf40387bc66c8de39ef238432"},{"path":"product/policy/auths-bounded-policy/src/identifier.rs","sha256":"0006a32bae9eb16c4178cf797df418d2b0a781f2bc6474b741dc1a908141831c"},{"path":"product/policy/auths-bounded-policy/src/kernel.rs","sha256":"99187c28b161ac70bee1afed60711f7f645adb4d1671a01863f9849b369811d5"},{"path":"product/policy/auths-bounded-policy/src/lib.rs","sha256":"76a18638b810e62e594d6e951504679305ce21841388e0c282ad51cd7841b91c"},{"path":"product/policy/auths-bounded-policy/src/receipt.rs","sha256":"3b9eae82f349956d3e7f29b57b254bff5ce67cc703e6cf43445ce2827676d1ad"},{"path":"product/policy/auths-bounded-policy/src/registry.rs","sha256":"4e251ea5833559841f8ee0bd9b66c0c01c39fa5e3a21720a19c4e8adbfcb99d0"},{"path":"product/profiles/auths-profile-api/Cargo.toml","sha256":"7ae85761f58625b5d678f961fd052eebf3e000ef565c7312e3ac857763c0017f"},{"path":"product/profiles/auths-profile-api/src/lib.rs","sha256":"a194b82b20bf8aa864c2334ef6c6aec30b2154ded972862252d5dcefa488ab95"},{"path":"product/profiles/auths-profile-mcp/Cargo.toml","sha256":"db264fc25242f7b7e4133cbe5782545b3107b406c87168ae072683cfb59eac66"},{"path":"product/profiles/auths-profile-mcp/src/arguments.rs","sha256":"a5de696586d507e2aee4a49fee99ef1ef4c51a2e46603f8838f1af6d9009833f"},{"path":"product/profiles/auths-profile-mcp/src/lib.rs","sha256":"d0f8b733a09ef8408336cdebc8948d6a5861012ce6878598b11be7084dfd4280"},{"path":"product/profiles/auths-profile-mcp/src/session.rs","sha256":"166c281fa66333388e4760d6098dad02d3359e1d5de4b8f93312c014f97abb03"},{"path":"product/qualification/auths-recipe-qualification/Cargo.toml","sha256":"b7b15b30b9a3d96d4f089d514266d3a892de6608d30707aa44be31cca89ca9fe"},{"path":"product/qualification/auths-recipe-qualification/src/canonical.rs","sha256":"30a9b50777f0dcb4b606abfee2467d5a975d9f6ad941ab6c13ca3df5b359f667"},{"path":"product/qualification/auths-recipe-qualification/src/closure.rs","sha256":"0ad6aee9bd981e2863fa725f209ec84031db2ff12f4c7ac3aaf5120f0bb86039"},{"path":"product/qualification/auths-recipe-qualification/src/error.rs","sha256":"52d5cf720a1e4579008fdda90a3ac2c31f0b8db57985f121f65e54b4da2b7dd1"},{"path":"product/qualification/auths-recipe-qualification/src/evidence.rs","sha256":"1a4cb862f725b02f9efd827db99637ea8178a5236aa020ec271a363edbe7b362"},{"path":"product/qualification/auths-recipe-qualification/src/ids.rs","sha256":"e81a906d571497ab2da753e1532d4443706410e0b75fdd59a7d2d788c7aec403"},{"path":"product/qualification/auths-recipe-qualification/src/lib.rs","sha256":"195b19f46e1e94a0f3594ad5958e5715bc5e3ad415bdfe6428dfc1b6dd3f4631"},{"path":"product/qualification/auths-recipe-qualification/src/model.rs","sha256":"3484eb0ec287b23d4682ade7b1b0f8d1335985b3fb180f06fc28716076957d5f"},{"path":"product/qualification/auths-recipe-qualification/src/release.rs","sha256":"d816b53e2275e902c750ad03803061844e259e28560ab2d1c77e8a43981a4d6a"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/mod.rs","sha256":"7f15b68e05a04f202bcb1fb80de1d9a9a4fda8bfd6af2cd4e5c660fc75d96fe5"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/schemas.rs","sha256":"c59feec2805621f341a90f81fc005e2c12f7a70f9d264fdeacccb2be4fe1c046"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/verification.rs","sha256":"6a6b5834c5dae10af1ba8939b2deae43b85db71544dc8f077ff63b7fe68ac8a8"},{"path":"product/qualification/auths-recipe-qualification/src/verify.rs","sha256":"edd33589c54fb79bcfccc4cf4e6ccf95bbca088318bb7f8dfc27da684b4fa671"},{"path":"product/receipts/auths-receipts/Cargo.toml","sha256":"6001fa1375899953aafcb07c23a6da0c8f57ea7ec08bb9c950b12a60a8cc6f0f"},{"path":"product/receipts/auths-receipts/src/disclosure.rs","sha256":"ba9ed439be0a320c8a01f5b6e9b51361c1a37e076947490d8e4a484ba6bb119c"},{"path":"product/receipts/auths-receipts/src/lib.rs","sha256":"547a57c4160256719cafb7d0ab972d9fc80b0da14fbbce4148c70c414070ff36"},{"path":"product/runtime/auths-connections/Cargo.toml","sha256":"babeb6e1dda3dfb66291713ce1e43dce5c349833d0b50ea4dccbf986030026b2"},{"path":"product/runtime/auths-connections/src/credential.rs","sha256":"82921413bb053f0f6c7931fbd4e6a86c8cc1d8c6b51debd9c2cef33a8d5b1c51"},{"path":"product/runtime/auths-connections/src/kernel.rs","sha256":"a456af19526ef7b19278551fd8d97606b6e7aa5a195e70bdd85d1e0786090ce8"},{"path":"product/runtime/auths-connections/src/lib.rs","sha256":"79209249a0acba0e854d9937255ea0e30a9a6f8e1c2baad77ab83646d392de22"},{"path":"product/runtime/auths-connections/src/model.rs","sha256":"de34a089cac9702acb889b7602b3e4732306c9e61029ec892582b7e02b73fc77"},{"path":"product/runtime/auths-connections/src/registry.rs","sha256":"e57640e003e72ad3235c567c2bb944a262bd64e673f023b62ea091094cc7e946"},{"path":"product/runtime/auths-gateway-kernel/Cargo.toml","sha256":"34f864e67656af4ac7e5c7a347a23f01fc8ab31365b8e23c0ef474483b14a183"},{"path":"product/runtime/auths-gateway-kernel/src/construct.rs","sha256":"e9d65744bae273d65face4285e5d2da75c06a92ab48f7b8333d496604e31c63c"},{"path":"product/runtime/auths-gateway-kernel/src/lib.rs","sha256":"cfda6f8453426564c085475f183b6f790b438768a19668f1b65d33ba59dc8632"},{"path":"product/runtime/auths-gateway-kernel/src/order.rs","sha256":"e0045f877c4b7fe3860c291039853885064ddcdebba7c9b345ab65e81e646dda"},{"path":"product/runtime/auths-gateway-kernel/src/outcome.rs","sha256":"e9785b1afe70788c89b51b18655bcd5acff7b0c8e3d12fb79c6f05a202e67e6b"},{"path":"product/runtime/auths-gateway-kernel/src/ratio.rs","sha256":"7ace22264812e6fc1ef3a62afee402cf43494bb71ae0d3de219ad1b39d690bcb"},{"path":"product/runtime/auths-gateway-kernel/src/recovery.rs","sha256":"9f46dcd1685ab6e4d1ba04a3fc286a9f3afee8fb47829be1fbc670a7926a2dc6"},{"path":"product/runtime/auths-gateway-kernel/src/transition.rs","sha256":"cefb8f01d915880f6921f2501ff881ad94b9de61072c34d9cd19220992f97374"},{"path":"product/runtime/auths-gateway/Cargo.toml","sha256":"00b28be86921dcdc0ad90cce0c62c2961cafefe96ea6c2edf0e926c3d405ccad"},{"path":"product/runtime/auths-gateway/src/admin.rs","sha256":"2d5cc67afac42fbd15f36e2d1693cd1b20cedacf775040dce6ffe04ccc94c9ea"},{"path":"product/runtime/auths-gateway/src/app.rs","sha256":"6f80f63833ea0815bb44fbf3b0cc54624a08e9fb578b035819f2980dc04e8256"},{"path":"product/runtime/auths-gateway/src/audit.rs","sha256":"0edb2037eb7b5d6f13acfce85c54f62a82bf7fdd31bc7e626c4fec183cef61dd"},{"path":"product/runtime/auths-gateway/src/audit_tests.rs","sha256":"990969384a52d75bc2489d9d412a9e7dd814bca1c8f65c701d6205fae62a15d5"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway-harness.rs","sha256":"12b0c24d36b2e6a3be392d3c081a783b355fdd67dc5b54bacc0410e803116b8c"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway.rs","sha256":"8851f5e95b70540f64c1965f6f240e7d90968817c6396d59cda49a75e46f85fc"},{"path":"product/runtime/auths-gateway/src/binding.rs","sha256":"663b0ce878319511aa7d05722ab4ab2fa9be18817884ffb2ee9f131ff1e382d1"},{"path":"product/runtime/auths-gateway/src/bounds.rs","sha256":"9cfa0eae4973e5e047a198360b22f041708fa20276b0b90c2ec440f06915d64b"},{"path":"product/runtime/auths-gateway/src/bounds_aggregate_tests.rs","sha256":"ab34f227b7c8692ba1da88ccb32bb3687a66adf8353d8282fae905d4a88eb612"},{"path":"product/runtime/auths-gateway/src/bounds_tests.rs","sha256":"74136bc570da7b9338b5a1a1ed9d3fd892c55135160964f9d3ac0e2cf5741375"},{"path":"product/runtime/auths-gateway/src/connection.rs","sha256":"7b5d049c258a26ed1c93ecf860f51d051a8a65f9b93554af0b1ea93862398ec9"},{"path":"product/runtime/auths-gateway/src/credential_journal.rs","sha256":"7297fccaee5cd8622928e8e0a42bba50da7941970a6f682ecd300c41ee6509da"},{"path":"product/runtime/auths-gateway/src/echo_verify.rs","sha256":"6d453051665f823abd7354d5c50031d9524cf47d6092d2f09080ae814e3b9575"},{"path":"product/runtime/auths-gateway/src/engine.rs","sha256":"f1052aaef43e441b9e0befa3f8ad1e2fd5279a27cb9d63aac0cf9dfdbe85a052"},{"path":"product/runtime/auths-gateway/src/fuzzing.rs","sha256":"ed3b0028487eb546a3b0a91d115aa8f18bcea324b61620bfe8e35a9e6ab18758"},{"path":"product/runtime/auths-gateway/src/generation_floor.rs","sha256":"fe1510ec96ce0f67e9a78edbfa94b33eb7df64c0ac4d0a67901124dcd0164777"},{"path":"product/runtime/auths-gateway/src/harness.rs","sha256":"c87e479f3eb281960f6c9c80d43c7560d7ccb654b0154ec823d3fcec4884ac05"},{"path":"product/runtime/auths-gateway/src/lib.rs","sha256":"b182052835a2cd631d1f801a74788d6ac1ef9cae21ade9ab4ffc270cd6f667ed"},{"path":"product/runtime/auths-gateway/src/listener.rs","sha256":"aa08239bcbc1fcb55e8a212fbdf6a84f12985b0d436dcb51dc58b06e59e435f2"},{"path":"product/runtime/auths-gateway/src/observed_tests.rs","sha256":"a22be071695f89ca2578a4d82fd80d9cb82289213c9bdb8f0470f6b2dc3d9201"},{"path":"product/runtime/auths-gateway/src/observer.rs","sha256":"562ffcaea071c3856e0708dc2fe33516917e145d2438aabb75af0a2b6ba01046"},{"path":"product/runtime/auths-gateway/src/onboarding.rs","sha256":"0dfd7d67e7f02f18a28d1c86ca1b7751d7463f5efcdbe3825de0ad8cbccb3891"},{"path":"product/runtime/auths-gateway/src/operator.rs","sha256":"c3c36954a3e86723df035b47f43b85fc8ab3a6292983522b6258d5c6b4e6c4e4"},{"path":"product/runtime/auths-gateway/src/pending_vectors/attempts.rs","sha256":"47ef3c8cb1c5504d47ae9a0088705ce6715fe86bac1671a34a56fb38a64d0dd3"},{"path":"product/runtime/auths-gateway/src/pending_vectors/bounds.rs","sha256":"e612217307e1da5b0eb5ee6582a7458bb064b8aa8a529fc6555b1b334127ab68"},{"path":"product/runtime/auths-gateway/src/pending_vectors/codes.rs","sha256":"e00670a5ef24eb856cc2c6dcda8e5a263842f55c76bb939c0c27bd9df37e5522"},{"path":"product/runtime/auths-gateway/src/pending_vectors/keys.rs","sha256":"f84c56f2ea9cb34b2b79c72d13fbf2c6585010ec1ed450af58a78c80c1ea1edd"},{"path":"product/runtime/auths-gateway/src/pending_vectors/mod.rs","sha256":"5de8340f0ef71a0b57a09e323fefe473d3160c6ae952b9ba46f714825fbacafc"},{"path":"product/runtime/auths-gateway/src/pending_vectors/outcomes.rs","sha256":"8ec07b9511b48913af36ac999d2aa0e82924289f6acb23ddaeb9f00f99815166"},{"path":"product/runtime/auths-gateway/src/pending_vectors/production.rs","sha256":"f574cabfa4687a4fc846b8f6a234b68ec53b876aed548b11445729cac1a55ede"},{"path":"product/runtime/auths-gateway/src/pending_vectors/recipes.rs","sha256":"51693208270231c25c3aef2a32151d5962114c71e86b88cb9df83181c7af83d9"},{"path":"product/runtime/auths-gateway/src/pre_entry.rs","sha256":"06ea78ca140468bfaba17d9ab04912ad68673954c3203c98fed28272ff2a00c0"},{"path":"product/runtime/auths-gateway/src/property_tests.rs","sha256":"939c081e2e154aaf10186754044bdd65608932c899b970a04a71d0afaa999a93"},{"path":"product/runtime/auths-gateway/src/qualification.rs","sha256":"d5078a81dc89f30958a7d8fdabfe164c3411c441dd7d9015002681a58883b63a"},{"path":"product/runtime/auths-gateway/src/qualification_tests.rs","sha256":"4473add5729b97faf04d7be7fd8cf3bbacd7f545e1b61c7d2782563011906b4d"},{"path":"product/runtime/auths-gateway/src/quorum_tests.rs","sha256":"a78380b019104d688c11a4410082c7e08ded3106aab099954c4f8a1b4ae0fd89"},{"path":"product/runtime/auths-gateway/src/readiness.rs","sha256":"911c3f11892f94641574375e2da4c671c802738f71082f12c39832331052d43d"},{"path":"product/runtime/auths-gateway/src/recipe.rs","sha256":"fecf9af67e7afc48d080e9f8f55f8ce28e49d0e08988717a27908bbda461d6b6"},{"path":"product/runtime/auths-gateway/src/recipe/lower.rs","sha256":"8088b8751ed831dbb48247af953c6290ac95e2a438ccd5bd65eff54be06a1fd1"},{"path":"product/runtime/auths-gateway/src/recipe/review.rs","sha256":"c3eb7d9fdd98dacf52750b14b67ad7b7faaecbbdeafff1e0b049f67d0a2d0139"},{"path":"product/runtime/auths-gateway/src/recipe/runtime.rs","sha256":"c286f0d15f974402822c0810d8e97f98411dc1c557755cc23e540d85e2bb42d9"},{"path":"product/runtime/auths-gateway/src/recipe/source.rs","sha256":"1af50298fb1461e1b24cbdf560d43124d6b1c4c529586c911b1c4797214a69ff"},{"path":"product/runtime/auths-gateway/src/recipe/tests.rs","sha256":"aac551a2481bbc60864c64079bc667dcb232e45a0f41a04073ed84ab5dbe1fce"},{"path":"product/runtime/auths-gateway/src/recipe/validate.rs","sha256":"3711b17605cb7b0d639bc1b180449231f5882a306cab20e2c7782155cc63b0d4"},{"path":"product/runtime/auths-gateway/src/scenario_tests.rs","sha256":"d8271761b12597bfe1233f8eb5ad1d4e5498c5d03388e832e24ab818484a1de4"},{"path":"product/runtime/auths-gateway/src/separation.rs","sha256":"be9306848ca5d801767cf6aa14247c8618312ef0e202f00ce0c24c4844491061"},{"path":"product/runtime/auths-gateway/src/store.rs","sha256":"1e5288345c2cb1421aeda277b3158d07607404b0ec46943222ac107b63dcbb46"},{"path":"product/runtime/auths-gateway/src/store_testkit.rs","sha256":"88d82fee42f1b8ef521e8deef0216e938ebeaad822eb2f5020e70b89d145ea70"},{"path":"product/runtime/auths-gateway/src/submit.rs","sha256":"c78dbbba7c45b443febf655056c5edcd635e77be7b21c5b2c318097502415ffe"},{"path":"product/runtime/auths-gateway/src/support.rs","sha256":"9fd979029793a22b15d9776a7fc172476fd7c126a80d4080c65d720845becbcb"},{"path":"product/runtime/auths-gateway/src/transport.rs","sha256":"be2c6dc75b8daec110bf8749fedc3823f6067d40b999aa3eb21126896a97d3ad"},{"path":"product/runtime/auths-kernel-runtime/Cargo.toml","sha256":"926bcd0053e5edd703be8f0027ad009e2982db013c8174c1366edbf38b48087d"},{"path":"product/runtime/auths-kernel-runtime/src/lib.rs","sha256":"7108faae9ca4bc32dc093ddaf885ac8f74f3c9b8d317b7c7d0ed00d1d0ab5972"},{"path":"product/runtime/auths-lifecycle/Cargo.toml","sha256":"c5053c705f8d5554b44e432fe75287a2faf11d644e5a2552e45ad6cd94176af5"},{"path":"product/runtime/auths-lifecycle/src/codec.rs","sha256":"51c4b746749150e8bfcd8ff972b3f6c1ef40bec15583448ec1b471d9ad737dc0"},{"path":"product/runtime/auths-lifecycle/src/digest.rs","sha256":"55282cd7d8aba37713c2dcb5357c9877819edb72a30eadefb860d28d7d3a3eb7"},{"path":"product/runtime/auths-lifecycle/src/identifier.rs","sha256":"31620e8c16de68d3d2d3b84d6353c1a5b9d4151d8247f91fa389b906a8e660f1"},{"path":"product/runtime/auths-lifecycle/src/kernel.rs","sha256":"2abb8c094b36d35b364e74d78091d14c5d6f3d1eb6552cca3b5538dbd017ce23"},{"path":"product/runtime/auths-lifecycle/src/lib.rs","sha256":"afe5a345cd309b5a4f08ffba3170a6d7f16ff914892b3f2006d5208ed24ec736"},{"path":"product/runtime/auths-lifecycle/src/model.rs","sha256":"1af12bbeeeaa65ec02ddcebd7d2c8c92ec1e339fa40ae85b45ab9b01a722a4fa"},{"path":"product/runtime/auths-lifecycle/src/operation.rs","sha256":"78e6d05fcf5d7ea4f148032a3fa26fdfcab77eb346019effe927b08e0d64d732"},{"path":"product/runtime/auths-lifecycle/src/registry.rs","sha256":"53495fe6f7a77422593a41623e28716f1c140dcc1e00f87f6b0ffbfb93a5ef90"},{"path":"product/runtime/auths-lifecycle/src/sealed.rs","sha256":"2e0c03b5813c926c159598bb7890d1523825edbe57cd687d42a5dfb3374a0c19"},{"path":"product/runtime/auths-lifecycle/src/test_support.rs","sha256":"221d9a5c5ef8e1f9f8fd66927484eaf813571d0568470278ecd8fa08a5eec8cd"},{"path":"product/runtime/auths-lifecycle/src/transition.rs","sha256":"011805a28efdd541f44ccea54dd0e34a1e58f4004447ee58013703b4fb6b2822"},{"path":"product/runtime/auths-runtime/Cargo.toml","sha256":"dd29d80a8b4bb56c869f036a6c9998f6e6d1c0383ac5dc03fa42b24abe2a7ff7"},{"path":"product/runtime/auths-runtime/src/lib.rs","sha256":"132ca03d58c14905d3a822a4972f42a22a5ad33a45f2ba9ca44f3a57d810b4b0"},{"path":"product/runtime/auths-runtime/src/production.rs","sha256":"fd4eff4270c571c10fcff6cf27df28961ebf074de8a1a028f500fda1ca376820"},{"path":"product/sdk/auths-approval-quorum/Cargo.toml","sha256":"df904971fca87a24194c5816738a331588fe8f164acb5d70839a35dca5f57114"},{"path":"product/sdk/auths-approval-quorum/src/lib.rs","sha256":"72e4086088a1c0912e4e1a9e980f26a5c280bf5ab16ea39c9f0f58d5096ac2ce"},{"path":"product/sdk/auths-approval-quorum/src/remote.rs","sha256":"9fa029c7c63003adf260f8aec55160c545681c60c1bace0d934e7525862aac7b"},{"path":"product/sdk/auths-approval-quorum/src/remote_tests.rs","sha256":"8a09ce17cfee82a035a74edd535f3ebeb3db0e078784740d3997b17dbdb10197"},{"path":"product/sdk/auths-approval-quorum/src/tests.rs","sha256":"0acbf12be3e497dcc6c2d8f29c9af3b1a7ed8e11b75f6399570d4f217a785060"},{"path":"product/stores/auths-stores/Cargo.toml","sha256":"a32c4b76d17b264be5d40d24497ff237e1ed87822e8bab86229b3db93106dbe3"},{"path":"product/stores/auths-stores/src/gateway_attempt.rs","sha256":"185da8f4a220fba836d5b78a9311f03204681c43a3c53951008084d135bf4d21"},{"path":"product/stores/auths-stores/src/lib.rs","sha256":"e721262edf8a9ab5568ddc27639176312d4a366203b5f73e2d7c11fde2c554e9"},{"path":"product/stores/auths-stores/src/lifecycle.rs","sha256":"94f64d25bc178a9e60ba3c0ee6915d7fb3af7c4468792d76b4e70f43f4de1e2f"}],"schema":"auths.gateway-semantic-closure/1"} \ No newline at end of file +{"files":[{"path":"Cargo.lock","sha256":"0e75e2fdef44e79e57797ac6661295ae58ddb8de4a1990a6fd22ce6c54f78604"},{"path":"Cargo.toml","sha256":"6e0240aa432bcdb5b4f855618994582d2e428afe5ee4e4f6a3f8d21a518f8d24"},{"path":"core/adapters/auths-did-keri/Cargo.toml","sha256":"86ba3e3f9a0a70d970568bfb3113b7ba7bbdd81c3eebbb52743dd574a0c0ecaa"},{"path":"core/adapters/auths-did-keri/src/lib.rs","sha256":"a75674842f8a7142004369ccd206ab43e476459293f0ff4bed4d4e659031b3e3"},{"path":"core/adapters/auths-did-key/Cargo.toml","sha256":"d5a3ae07ecde83f91eed4c3785051dd0f44eab6c891754f3309b6b40596707d7"},{"path":"core/adapters/auths-did-key/src/lib.rs","sha256":"b76a3f4528a725df53cfa9242e6e414e1d26caa48468aaed497e28b121f4497c"},{"path":"core/adapters/auths-raw-key/Cargo.toml","sha256":"f49616bc18c19e6aaa7e6a02ccebcdb9f5c1422c1278ef9869e0dbf22ceda7be"},{"path":"core/adapters/auths-raw-key/src/lib.rs","sha256":"45eec1b295dac3bed65cd79c942870b926b06b3f5d38eaebaeb6140ac69315fa"},{"path":"core/crates/auths-algebra-kernel/Cargo.toml","sha256":"0cb078915287b8c47d20fb97b6fa14135ed1c61c4985c4de2753855563c27bba"},{"path":"core/crates/auths-algebra-kernel/src/generated.rs","sha256":"ae4bcbef42a408c7ffa0c6a74a42f7de177a6f14479e51d205acf830bda05960"},{"path":"core/crates/auths-algebra-kernel/src/lib.rs","sha256":"6654667eeb9b353d6648954c9fd2e8838462a82ce6b2803eb49e1366892c68c6"},{"path":"core/crates/auths-assurance/Cargo.toml","sha256":"afcb12a97ddd09d7a264b9ecd3fcaefc865ff2c9b4539ee85b00883420ba333a"},{"path":"core/crates/auths-assurance/src/lib.rs","sha256":"0fb2ec844d674b25a9e20b1601452c89c2454901fa506adfa9bb38ef9a7fa213"},{"path":"core/crates/auths-author/Cargo.toml","sha256":"1e6f0e3bc079ddb558decf0db26b291c6e72fc47327b6df1b7650d41689b215f"},{"path":"core/crates/auths-author/src/lib.rs","sha256":"e14475f2f49715a1869d5fa6b11ff1fbb72497e39097e09f833b0bae7e5b3f2b"},{"path":"core/crates/auths-author/src/observations.rs","sha256":"dda7d6bfa6f4bd7e95a480ca283c2c0e996f69c5c15cb0c7197af328131a7066"},{"path":"core/crates/auths-authority/Cargo.toml","sha256":"4f68212938029f79ebd857b4f3038494eb85d0163d49f07e51376715cf959815"},{"path":"core/crates/auths-authority/src/lib.rs","sha256":"da88d5194de4e17449e69972c0fb042afa3c39798a8f570f30771e77c384f1eb"},{"path":"core/crates/auths-codec/Cargo.toml","sha256":"e20e51826e17814effd9d8925d18d5fbdd6c0d37bf50231e743ec2611314722c"},{"path":"core/crates/auths-codec/src/approval.rs","sha256":"fbff43bf4d57f69f8b7738a8647751cde75c5a932dee65d924a1fe9c7202f91f"},{"path":"core/crates/auths-codec/src/bounded_policy.rs","sha256":"d21f6728be2d8a04b6d94921f2b683f7e61cbb42e0ac6866f1c88a49caa534c3"},{"path":"core/crates/auths-codec/src/decode.rs","sha256":"ba2999584cdf53b1b517ecb9c7ea50d39efee17c666a9a38e2ad1ac94379f778"},{"path":"core/crates/auths-codec/src/encode.rs","sha256":"8491192053e2e15d62b4d2a491de22405b7487ead69ab6f6bca40669ce7ca5f7"},{"path":"core/crates/auths-codec/src/error.rs","sha256":"fecb0737c16deb42d859f1dc7727c698438d9ac12064fd99e0024629642c390c"},{"path":"core/crates/auths-codec/src/hash.rs","sha256":"f88e111f1dbb2cef6b7b5e9c948cb45ff92e366710c8a5f5c636138aa748479e"},{"path":"core/crates/auths-codec/src/lib.rs","sha256":"3b0e5c94c6f34f750b88a9333e1d5c8145746d845d3a23e0d2f61807cf592ea7"},{"path":"core/crates/auths-codec/src/observation.rs","sha256":"38328b0c71ff97a4196eeade8b4faa889527ff1f80bb168468bcd49082a3987e"},{"path":"core/crates/auths-composition/Cargo.toml","sha256":"331e13025eb82588b5269ed3975929eb41c823d588c49e4c296273d85c6fe80e"},{"path":"core/crates/auths-composition/src/lib.rs","sha256":"e42e3b0b6a9985b73c533aeb23799c638979aa27a9ce7d0f10a8d60340570fb1"},{"path":"core/crates/auths-model/Cargo.toml","sha256":"565c15302b0cc9270db1e28efa3862804846850ba933cc17aea8abcec4a65987"},{"path":"core/crates/auths-model/src/approval.rs","sha256":"54cf44a9ef747ec21546a4086aecfb130bc7803b0d646430e6082df51e6e305f"},{"path":"core/crates/auths-model/src/bounded.rs","sha256":"f90496abc0a06c028b4d850af6ddab7d850bfafeaa3d042f1b1246a41d8bbbc9"},{"path":"core/crates/auths-model/src/bounded_policy.rs","sha256":"306a8f049cc7cfc7635fe0e4de47eb7813eed63d3c14c3be2b51586aa4eea450"},{"path":"core/crates/auths-model/src/lib.rs","sha256":"e7cfe6bb9574c6b016aa63e70658c31e0b90903ec6479a49f4a2ceb64f5bad97"},{"path":"core/crates/auths-model/src/observation.rs","sha256":"b3ed3e3cc090afca2896a7d3de7988afccc1ee1ea0ebd91b51a8bc0f20ad8dbb"},{"path":"core/crates/auths-multikey/Cargo.toml","sha256":"858756c1197befcada06134742bffe8d58cf674cadd6c046bf710570e96efaf2"},{"path":"core/crates/auths-multikey/src/lib.rs","sha256":"f5177fffb11e40eeaaa665ba886c541fc1f24d5eacdd6983fb03dc20148733f0"},{"path":"core/crates/auths-ports/Cargo.toml","sha256":"596a9b0f5af3717c16a6d13f7054d8a106bcd9f01f5f72c2235dd2afcf0ab16c"},{"path":"core/crates/auths-ports/src/binding.rs","sha256":"a16c012d40aa4249e3ec54372b9611006d0bfe8c7dc3c741911c2194ad17736d"},{"path":"core/crates/auths-ports/src/diagnostics.rs","sha256":"aa596572503b5a65a918bb2ff38281c144ff53821ee914e9325c852828d852df"},{"path":"core/crates/auths-ports/src/lib.rs","sha256":"8da1debf86f9532df0660155d04298beb1ba3a1204508f8586e2e67cd2fb3449"},{"path":"core/crates/auths-ports/src/path.rs","sha256":"b68fc547b4e4d97a60cbbc1cc1939fc6b34a8233b81f840bd396914a707e3ea1"},{"path":"core/crates/auths-raw-key-core/Cargo.toml","sha256":"98c0bc8527a537512e43a74dd6ba9658c6f0cdc16610cf938e3f9b43785d836d"},{"path":"core/crates/auths-raw-key-core/src/lib.rs","sha256":"98c0500c41697aae28708ad0bf970b9a97583d96a3d2c18d23e7fa351e0f7d12"},{"path":"core/crates/auths-registries/Cargo.toml","sha256":"590e49e58b0622d9d6274fea46796dcdb07dec9550eff295cce6a6427ffa51e2"},{"path":"core/crates/auths-registries/src/lib.rs","sha256":"450d3886d85ee333b7e046317fb5b47cde62483d5f3e41d46265e2caf4c373ec"},{"path":"core/crates/auths-registries/src/template.rs","sha256":"1224039dd2b3ce6fa8357613d8cafbae40400493da7176da202b9ade62fa05e4"},{"path":"core/crates/auths-signature-core/Cargo.toml","sha256":"6bee2230eb30af1be0bd5c2d57bd3e1903497a70028d47290d4ac6e9e0e8bca0"},{"path":"core/crates/auths-signature-core/src/lib.rs","sha256":"6e3f9311ded5e49ea50d445eb21e6d8fa12442aa4362a73e0cb6fda15b7cd865"},{"path":"core/crates/auths-signature/Cargo.toml","sha256":"7f7b5682466ba439d8c59579022cb61068cad9fb4bd2d73e1cc21327c902b450"},{"path":"core/crates/auths-signature/src/lib.rs","sha256":"b3d9ac4cc3ba0797b9e1c09f2d6ac05089b25dac4e285a5af28c0165b33d925b"},{"path":"core/crates/auths-verifier/Cargo.toml","sha256":"c43358e06a33324b41a8b93e5cc7279c55f7d21f1f6cfaa539a3fc7b4c27a38f"},{"path":"core/crates/auths-verifier/src/approval.rs","sha256":"c3cae6a49e169051eb555bb092b1069a4d422516d6a2114623a89e5f31ff923f"},{"path":"core/crates/auths-verifier/src/causal.rs","sha256":"9635230f33b13a263573d319ac8a1976addbcc07677514a43e3c7bf28dc04587"},{"path":"core/crates/auths-verifier/src/lib.rs","sha256":"f22dec38dfa47ced8da4f605390da8453273bee1da010f57fd1fdfcb10e0b389"},{"path":"core/crates/auths-verifier/src/observation.rs","sha256":"0bb07f06a0f9524c88427df1c972b1de8ba038a9eeeb15d3134c25125ae84b1e"},{"path":"core/crates/auths-verifier/src/trace.rs","sha256":"2213d72023a1d9e0413a2cf2c51c3dad9e04775d86033a0caaa9d29a8f0036f8"},{"path":"exchange/crates/auths-proof-exchange-model/Cargo.toml","sha256":"34b2db64f93a288e942b5a3918d28cdcbef906e0dc9074ce848018d3d5f7a1fa"},{"path":"exchange/crates/auths-proof-exchange-model/src/lib.rs","sha256":"49fac5aff2339d3a57c41cb8bb2a82852538e8f77ecc4b157c88e264ff31fb55"},{"path":"exchange/crates/auths-proof-exchange-port/Cargo.toml","sha256":"7ff660f1b7579dc570c791090aeb450404fbbf44c92781e289f5ccdde746d739"},{"path":"exchange/crates/auths-proof-exchange-port/src/lib.rs","sha256":"b408fb40c6432edc1facb3d8b3e9433a033f8dc73fe2e0ba5a88c972ca675021"},{"path":"product/config/auths-config/Cargo.toml","sha256":"e4b568ab9f6d84f7d7e6d66443fe825f360db9c27bc7c31d6ddc0647ba3ab304"},{"path":"product/config/auths-config/src/lib.rs","sha256":"fecf00ea65cef9018fda9af2063326d7065b869266d0d9e73a4f06816f1102cc"},{"path":"product/errors/auths-errors/Cargo.toml","sha256":"3b3a199b6460ba75bda84475bb5accd35e1485448a3981038db85bf4772dd350"},{"path":"product/errors/auths-errors/src/lib.rs","sha256":"7feeb9069fa4003f94f1f07c1c94619cf1c883e0dbaaf2187f04d0f742264bdd"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/Cargo.toml","sha256":"716f1f2a117d6d69d8effe9ee9057e24ba3502f20af6a602b06facb638cdabed"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/api.rs","sha256":"9ea61dd7ddd93282b073b98e453e6b75b26dc49689c04e9782435d06a3a09e48"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/clock.rs","sha256":"73f0a6e0f5903883329fa6683f5888288f759512ac69e5c87efd2b6b87f7d79f"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/http.rs","sha256":"14c1fce14e24c984b2a275563a29b6326fb08367922b6e8d37ba38b9cb81aa2b"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/identity.rs","sha256":"186de11230c8b33375951c4368609f8976bf9795f171af3fd7fac26207a94361"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs","sha256":"0b4617d044f5254a5ab22f5a2578f91cc025206a8c1845be39c1c56dcdf7a5df"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/names.rs","sha256":"41bb23572d6002e23ee103d6fe70ecd68764861c6f2e48a1c53af998270a4afa"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/sigv4.rs","sha256":"39fed883f4802e68d3b41a68fea0aba0737810996b33d13577de70faba004c29"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/store.rs","sha256":"fc60ee9c49b2f4f063c449b4c78100c5c4872779e94ca168f796603b380ef6e4"},{"path":"product/integrations/auths-custody/Cargo.toml","sha256":"1e14034185ad1ec24a977fd5f41d0abee219606245c046ce2a82287fadef448b"},{"path":"product/integrations/auths-custody/src/conformance.rs","sha256":"8ed1afd58f18fc6becc268908aa6419c9aa404c79da0197963eb2599df44acf6"},{"path":"product/integrations/auths-custody/src/key.rs","sha256":"70cbb0fd21b6f5652157845a22c35be2d0f6c05b3d138fc4b9bb6dbdc3d81c40"},{"path":"product/integrations/auths-custody/src/lib.rs","sha256":"8ee593c1f59b0fd2126bdda9138026f5bc531beb64d4489a99785842e7b98b43"},{"path":"product/operations/auths-operations/Cargo.toml","sha256":"73dde1e914ba5e1386f8d49c0d730423cc72ae77ec44c6554a6a75cc8e8922b1"},{"path":"product/operations/auths-operations/src/explanation.rs","sha256":"b4a39bcbd5712cb9e735ee7deeb281f3fa6af5ddca973735bf3d87dce427ef11"},{"path":"product/operations/auths-operations/src/lib.rs","sha256":"3f401a56555113b98f57bca172a2660eaecedf76247b83a3fdbb089c133a29cd"},{"path":"product/operations/auths-operations/src/render/json.rs","sha256":"2ae042639960afee123730e945d373bc5dda1d2b3af20ae16c939a7e99b9e418"},{"path":"product/operations/auths-operations/src/render/mod.rs","sha256":"b3b7f6fe473f49227ace5a68bfc3d8dc14b5db8dd9888fd9e207070337b1b8bf"},{"path":"product/operations/auths-operations/src/render/text.rs","sha256":"f216cf3bdb00e264523271b16e7059535ac6b1aabf271cebd7935af155e4bfec"},{"path":"product/policy/auths-bounded-policy/Cargo.toml","sha256":"babd5d200923137ead71d8c2528f847cea323ce98da942a1320b054436612c47"},{"path":"product/policy/auths-bounded-policy/src/arithmetic.rs","sha256":"75e43c5d4eab8d1f71c1daf53839f5f9fe45a400afc1f63a30c6f55ce3632627"},{"path":"product/policy/auths-bounded-policy/src/commitment.rs","sha256":"c5706f2d583454e1a7f570f7ada138d4d864bef0205dfae7c1296774f396bb3f"},{"path":"product/policy/auths-bounded-policy/src/eligibility.rs","sha256":"c9acdfb793074e687fc36efad506be27dbd99ffbf40387bc66c8de39ef238432"},{"path":"product/policy/auths-bounded-policy/src/identifier.rs","sha256":"0006a32bae9eb16c4178cf797df418d2b0a781f2bc6474b741dc1a908141831c"},{"path":"product/policy/auths-bounded-policy/src/kernel.rs","sha256":"99187c28b161ac70bee1afed60711f7f645adb4d1671a01863f9849b369811d5"},{"path":"product/policy/auths-bounded-policy/src/lib.rs","sha256":"76a18638b810e62e594d6e951504679305ce21841388e0c282ad51cd7841b91c"},{"path":"product/policy/auths-bounded-policy/src/receipt.rs","sha256":"3b9eae82f349956d3e7f29b57b254bff5ce67cc703e6cf43445ce2827676d1ad"},{"path":"product/policy/auths-bounded-policy/src/registry.rs","sha256":"4e251ea5833559841f8ee0bd9b66c0c01c39fa5e3a21720a19c4e8adbfcb99d0"},{"path":"product/profiles/auths-profile-api/Cargo.toml","sha256":"7ae85761f58625b5d678f961fd052eebf3e000ef565c7312e3ac857763c0017f"},{"path":"product/profiles/auths-profile-api/src/lib.rs","sha256":"a194b82b20bf8aa864c2334ef6c6aec30b2154ded972862252d5dcefa488ab95"},{"path":"product/profiles/auths-profile-mcp/Cargo.toml","sha256":"db264fc25242f7b7e4133cbe5782545b3107b406c87168ae072683cfb59eac66"},{"path":"product/profiles/auths-profile-mcp/src/arguments.rs","sha256":"a5de696586d507e2aee4a49fee99ef1ef4c51a2e46603f8838f1af6d9009833f"},{"path":"product/profiles/auths-profile-mcp/src/lib.rs","sha256":"d0f8b733a09ef8408336cdebc8948d6a5861012ce6878598b11be7084dfd4280"},{"path":"product/profiles/auths-profile-mcp/src/session.rs","sha256":"166c281fa66333388e4760d6098dad02d3359e1d5de4b8f93312c014f97abb03"},{"path":"product/qualification/auths-recipe-qualification/Cargo.toml","sha256":"b7b15b30b9a3d96d4f089d514266d3a892de6608d30707aa44be31cca89ca9fe"},{"path":"product/qualification/auths-recipe-qualification/src/canonical.rs","sha256":"30a9b50777f0dcb4b606abfee2467d5a975d9f6ad941ab6c13ca3df5b359f667"},{"path":"product/qualification/auths-recipe-qualification/src/closure.rs","sha256":"0ad6aee9bd981e2863fa725f209ec84031db2ff12f4c7ac3aaf5120f0bb86039"},{"path":"product/qualification/auths-recipe-qualification/src/commissioning.rs","sha256":"db135c55e4e3079a73253ebae275536523eb36b545d1c152b4d7dce3c6b76828"},{"path":"product/qualification/auths-recipe-qualification/src/commissioning/tests.rs","sha256":"4dde3736d206099b1bea4c14e0092c6c1287d1af9065958777d7c54331dd8686"},{"path":"product/qualification/auths-recipe-qualification/src/error.rs","sha256":"52d5cf720a1e4579008fdda90a3ac2c31f0b8db57985f121f65e54b4da2b7dd1"},{"path":"product/qualification/auths-recipe-qualification/src/evidence.rs","sha256":"ba45a7409d67a8af26ab811af306412014557477a101ab2a8eb0faf8f2bd4b91"},{"path":"product/qualification/auths-recipe-qualification/src/ids.rs","sha256":"e81a906d571497ab2da753e1532d4443706410e0b75fdd59a7d2d788c7aec403"},{"path":"product/qualification/auths-recipe-qualification/src/launch.rs","sha256":"41f2e2de90e1e7d6440a66eda3eb9bea8d5e0741bc232305a76a363ab5f813c1"},{"path":"product/qualification/auths-recipe-qualification/src/lib.rs","sha256":"0e24b0f81191df0d078c7f4d73ced784ba9472372bfcdff3c7d0e2fb1687b323"},{"path":"product/qualification/auths-recipe-qualification/src/model.rs","sha256":"3484eb0ec287b23d4682ade7b1b0f8d1335985b3fb180f06fc28716076957d5f"},{"path":"product/qualification/auths-recipe-qualification/src/release.rs","sha256":"ec478e53f70052e566aac7e15be2f9e1b1cd46837ac6990ae955fa864d311586"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/mod.rs","sha256":"fa23a962fd90331152eed5edbcdf271955f7aed79b8da1e065bfe69a20b487a9"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/schemas.rs","sha256":"c59feec2805621f341a90f81fc005e2c12f7a70f9d264fdeacccb2be4fe1c046"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/verification.rs","sha256":"bdf5746fed4d10a7f577f7878619a917d29aa8b7d522f00d9f774e53eff4c8c1"},{"path":"product/qualification/auths-recipe-qualification/src/verify.rs","sha256":"2ec2784435d11e07295de77b2ee1b2c48b2fcf9609a00454c6e7374b621aed5c"},{"path":"product/receipts/auths-receipts/Cargo.toml","sha256":"6001fa1375899953aafcb07c23a6da0c8f57ea7ec08bb9c950b12a60a8cc6f0f"},{"path":"product/receipts/auths-receipts/src/disclosure.rs","sha256":"ba9ed439be0a320c8a01f5b6e9b51361c1a37e076947490d8e4a484ba6bb119c"},{"path":"product/receipts/auths-receipts/src/lib.rs","sha256":"547a57c4160256719cafb7d0ab972d9fc80b0da14fbbce4148c70c414070ff36"},{"path":"product/runtime/auths-connections/Cargo.toml","sha256":"babeb6e1dda3dfb66291713ce1e43dce5c349833d0b50ea4dccbf986030026b2"},{"path":"product/runtime/auths-connections/src/credential.rs","sha256":"82921413bb053f0f6c7931fbd4e6a86c8cc1d8c6b51debd9c2cef33a8d5b1c51"},{"path":"product/runtime/auths-connections/src/kernel.rs","sha256":"a456af19526ef7b19278551fd8d97606b6e7aa5a195e70bdd85d1e0786090ce8"},{"path":"product/runtime/auths-connections/src/lib.rs","sha256":"79209249a0acba0e854d9937255ea0e30a9a6f8e1c2baad77ab83646d392de22"},{"path":"product/runtime/auths-connections/src/model.rs","sha256":"de34a089cac9702acb889b7602b3e4732306c9e61029ec892582b7e02b73fc77"},{"path":"product/runtime/auths-connections/src/registry.rs","sha256":"e57640e003e72ad3235c567c2bb944a262bd64e673f023b62ea091094cc7e946"},{"path":"product/runtime/auths-gateway-kernel/Cargo.toml","sha256":"34f864e67656af4ac7e5c7a347a23f01fc8ab31365b8e23c0ef474483b14a183"},{"path":"product/runtime/auths-gateway-kernel/src/construct.rs","sha256":"e9d65744bae273d65face4285e5d2da75c06a92ab48f7b8333d496604e31c63c"},{"path":"product/runtime/auths-gateway-kernel/src/lib.rs","sha256":"cfda6f8453426564c085475f183b6f790b438768a19668f1b65d33ba59dc8632"},{"path":"product/runtime/auths-gateway-kernel/src/order.rs","sha256":"e0045f877c4b7fe3860c291039853885064ddcdebba7c9b345ab65e81e646dda"},{"path":"product/runtime/auths-gateway-kernel/src/outcome.rs","sha256":"e9785b1afe70788c89b51b18655bcd5acff7b0c8e3d12fb79c6f05a202e67e6b"},{"path":"product/runtime/auths-gateway-kernel/src/ratio.rs","sha256":"7ace22264812e6fc1ef3a62afee402cf43494bb71ae0d3de219ad1b39d690bcb"},{"path":"product/runtime/auths-gateway-kernel/src/recovery.rs","sha256":"9f46dcd1685ab6e4d1ba04a3fc286a9f3afee8fb47829be1fbc670a7926a2dc6"},{"path":"product/runtime/auths-gateway-kernel/src/transition.rs","sha256":"cefb8f01d915880f6921f2501ff881ad94b9de61072c34d9cd19220992f97374"},{"path":"product/runtime/auths-gateway/Cargo.toml","sha256":"00b28be86921dcdc0ad90cce0c62c2961cafefe96ea6c2edf0e926c3d405ccad"},{"path":"product/runtime/auths-gateway/src/admin.rs","sha256":"0dee40a9fb67c5de10142fc37a7bbabdd8979fb0199fb26caf4bdb024e0a5a40"},{"path":"product/runtime/auths-gateway/src/app.rs","sha256":"6f80f63833ea0815bb44fbf3b0cc54624a08e9fb578b035819f2980dc04e8256"},{"path":"product/runtime/auths-gateway/src/audit.rs","sha256":"0edb2037eb7b5d6f13acfce85c54f62a82bf7fdd31bc7e626c4fec183cef61dd"},{"path":"product/runtime/auths-gateway/src/audit_tests.rs","sha256":"990969384a52d75bc2489d9d412a9e7dd814bca1c8f65c701d6205fae62a15d5"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway-harness.rs","sha256":"12b0c24d36b2e6a3be392d3c081a783b355fdd67dc5b54bacc0410e803116b8c"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway.rs","sha256":"6031dca90667d2db39a78736b23e27dd4790a7887d2230bd57f6851754603a11"},{"path":"product/runtime/auths-gateway/src/binding.rs","sha256":"663b0ce878319511aa7d05722ab4ab2fa9be18817884ffb2ee9f131ff1e382d1"},{"path":"product/runtime/auths-gateway/src/bounds.rs","sha256":"38251ad6f22f9d07ef9a219c79fa265ca36f1526eca5f147d7efa46b52ad609d"},{"path":"product/runtime/auths-gateway/src/bounds_aggregate_tests.rs","sha256":"ab34f227b7c8692ba1da88ccb32bb3687a66adf8353d8282fae905d4a88eb612"},{"path":"product/runtime/auths-gateway/src/bounds_tests.rs","sha256":"74136bc570da7b9338b5a1a1ed9d3fd892c55135160964f9d3ac0e2cf5741375"},{"path":"product/runtime/auths-gateway/src/commissioning_budget.rs","sha256":"fab29da3269438cc62330f3eab7b5bd4dc482b4db8af243aae47df5d3aa1deb8"},{"path":"product/runtime/auths-gateway/src/commissioning_budget/tests.rs","sha256":"da104a1643c37515deec6397bca6db2054f2c9158ec3d230a1b1ec9c7d0f659f"},{"path":"product/runtime/auths-gateway/src/commissioning_floor.rs","sha256":"7fa6991c5ccd5ae1403be84b3da390323fab09f36e15ac01fd7a38ea350c6fdb"},{"path":"product/runtime/auths-gateway/src/commissioning_floor/tests.rs","sha256":"6b663d6e556adc0b455b81845bc4394d82c6c4de0c5f43e1310f3ddc6e4e40f7"},{"path":"product/runtime/auths-gateway/src/commissioning_session.rs","sha256":"24b48d5eaeaaa3c620e2dfc7ee1f9255fc4519a1eb404b8608cea7af6a910ee2"},{"path":"product/runtime/auths-gateway/src/commissioning_session/tests.rs","sha256":"37a097fc8a9261d3fffdaef3f905f40b22e3e6601bb5101fadd9414f963c0824"},{"path":"product/runtime/auths-gateway/src/connection.rs","sha256":"7b5d049c258a26ed1c93ecf860f51d051a8a65f9b93554af0b1ea93862398ec9"},{"path":"product/runtime/auths-gateway/src/credential_journal.rs","sha256":"7297fccaee5cd8622928e8e0a42bba50da7941970a6f682ecd300c41ee6509da"},{"path":"product/runtime/auths-gateway/src/echo_verify.rs","sha256":"6d453051665f823abd7354d5c50031d9524cf47d6092d2f09080ae814e3b9575"},{"path":"product/runtime/auths-gateway/src/engine.rs","sha256":"1abff20b98bc894c45bfc5c4414c81a99966d1ec95d924a5b0cd6f4c5626ca24"},{"path":"product/runtime/auths-gateway/src/execution_witness.rs","sha256":"e8b1a8e41d5e3fa309760067849d390ef065feeb46701a83391832645a2f000c"},{"path":"product/runtime/auths-gateway/src/fuzzing.rs","sha256":"ed3b0028487eb546a3b0a91d115aa8f18bcea324b61620bfe8e35a9e6ab18758"},{"path":"product/runtime/auths-gateway/src/generation_floor.rs","sha256":"fe1510ec96ce0f67e9a78edbfa94b33eb7df64c0ac4d0a67901124dcd0164777"},{"path":"product/runtime/auths-gateway/src/harness.rs","sha256":"c87e479f3eb281960f6c9c80d43c7560d7ccb654b0154ec823d3fcec4884ac05"},{"path":"product/runtime/auths-gateway/src/lib.rs","sha256":"fad95d031f88072dd024c78facf0bb0267562b1ac743ddd7219fce47e64024f0"},{"path":"product/runtime/auths-gateway/src/listener.rs","sha256":"aa08239bcbc1fcb55e8a212fbdf6a84f12985b0d436dcb51dc58b06e59e435f2"},{"path":"product/runtime/auths-gateway/src/observed_tests.rs","sha256":"a22be071695f89ca2578a4d82fd80d9cb82289213c9bdb8f0470f6b2dc3d9201"},{"path":"product/runtime/auths-gateway/src/observer.rs","sha256":"562ffcaea071c3856e0708dc2fe33516917e145d2438aabb75af0a2b6ba01046"},{"path":"product/runtime/auths-gateway/src/onboarding.rs","sha256":"0dfd7d67e7f02f18a28d1c86ca1b7751d7463f5efcdbe3825de0ad8cbccb3891"},{"path":"product/runtime/auths-gateway/src/operator.rs","sha256":"c3c36954a3e86723df035b47f43b85fc8ab3a6292983522b6258d5c6b4e6c4e4"},{"path":"product/runtime/auths-gateway/src/pending_vectors/attempts.rs","sha256":"47ef3c8cb1c5504d47ae9a0088705ce6715fe86bac1671a34a56fb38a64d0dd3"},{"path":"product/runtime/auths-gateway/src/pending_vectors/bounds.rs","sha256":"e612217307e1da5b0eb5ee6582a7458bb064b8aa8a529fc6555b1b334127ab68"},{"path":"product/runtime/auths-gateway/src/pending_vectors/codes.rs","sha256":"e00670a5ef24eb856cc2c6dcda8e5a263842f55c76bb939c0c27bd9df37e5522"},{"path":"product/runtime/auths-gateway/src/pending_vectors/keys.rs","sha256":"f84c56f2ea9cb34b2b79c72d13fbf2c6585010ec1ed450af58a78c80c1ea1edd"},{"path":"product/runtime/auths-gateway/src/pending_vectors/mod.rs","sha256":"5de8340f0ef71a0b57a09e323fefe473d3160c6ae952b9ba46f714825fbacafc"},{"path":"product/runtime/auths-gateway/src/pending_vectors/outcomes.rs","sha256":"8ec07b9511b48913af36ac999d2aa0e82924289f6acb23ddaeb9f00f99815166"},{"path":"product/runtime/auths-gateway/src/pending_vectors/production.rs","sha256":"772d032e0a0d9c924fb6d6c188528db85a97e71c9e466ddc19eee277baf83614"},{"path":"product/runtime/auths-gateway/src/pending_vectors/recipes.rs","sha256":"51693208270231c25c3aef2a32151d5962114c71e86b88cb9df83181c7af83d9"},{"path":"product/runtime/auths-gateway/src/pre_entry.rs","sha256":"06ea78ca140468bfaba17d9ab04912ad68673954c3203c98fed28272ff2a00c0"},{"path":"product/runtime/auths-gateway/src/property_tests.rs","sha256":"939c081e2e154aaf10186754044bdd65608932c899b970a04a71d0afaa999a93"},{"path":"product/runtime/auths-gateway/src/qualification.rs","sha256":"c597c83de2b94f0374ae54e40a8b57c2a64ec6e254704dca32185f448a908aca"},{"path":"product/runtime/auths-gateway/src/qualification_simulation.rs","sha256":"4ee7cadfb5f46a92713c80f3e55f499cb074d04d8b21ea89d57762af38642bfa"},{"path":"product/runtime/auths-gateway/src/qualification_tests.rs","sha256":"a9178a2e58eea459d5e5dafc53a3999ea7c84dbbffefe6e5eb1f3d098827f028"},{"path":"product/runtime/auths-gateway/src/quorum_tests.rs","sha256":"a78380b019104d688c11a4410082c7e08ded3106aab099954c4f8a1b4ae0fd89"},{"path":"product/runtime/auths-gateway/src/readiness.rs","sha256":"911c3f11892f94641574375e2da4c671c802738f71082f12c39832331052d43d"},{"path":"product/runtime/auths-gateway/src/recipe.rs","sha256":"fecf9af67e7afc48d080e9f8f55f8ce28e49d0e08988717a27908bbda461d6b6"},{"path":"product/runtime/auths-gateway/src/recipe/lower.rs","sha256":"8088b8751ed831dbb48247af953c6290ac95e2a438ccd5bd65eff54be06a1fd1"},{"path":"product/runtime/auths-gateway/src/recipe/review.rs","sha256":"c3eb7d9fdd98dacf52750b14b67ad7b7faaecbbdeafff1e0b049f67d0a2d0139"},{"path":"product/runtime/auths-gateway/src/recipe/runtime.rs","sha256":"c286f0d15f974402822c0810d8e97f98411dc1c557755cc23e540d85e2bb42d9"},{"path":"product/runtime/auths-gateway/src/recipe/source.rs","sha256":"1af50298fb1461e1b24cbdf560d43124d6b1c4c529586c911b1c4797214a69ff"},{"path":"product/runtime/auths-gateway/src/recipe/tests.rs","sha256":"aac551a2481bbc60864c64079bc667dcb232e45a0f41a04073ed84ab5dbe1fce"},{"path":"product/runtime/auths-gateway/src/recipe/validate.rs","sha256":"3711b17605cb7b0d639bc1b180449231f5882a306cab20e2c7782155cc63b0d4"},{"path":"product/runtime/auths-gateway/src/scenario_tests.rs","sha256":"025b53473b3af6900e03bc2fee91aa63ed26a8778bc8c8296fe08f5540794c39"},{"path":"product/runtime/auths-gateway/src/separation.rs","sha256":"be9306848ca5d801767cf6aa14247c8618312ef0e202f00ce0c24c4844491061"},{"path":"product/runtime/auths-gateway/src/simulation_attestation.rs","sha256":"88705e5d45415f760f1e3c3a065c35c48f4f4065bf7b3feb7882246983814d6f"},{"path":"product/runtime/auths-gateway/src/store.rs","sha256":"884bcad4d70d53e12eae9c8517f6ecd0643068ee0be0370015119d04d6cc2516"},{"path":"product/runtime/auths-gateway/src/store_testkit.rs","sha256":"88d82fee42f1b8ef521e8deef0216e938ebeaad822eb2f5020e70b89d145ea70"},{"path":"product/runtime/auths-gateway/src/submit.rs","sha256":"fe2eacb5ad8152eeecbd232c47a356fc72db0098e34fda99532bdf6ef0cf895c"},{"path":"product/runtime/auths-gateway/src/support.rs","sha256":"9fd979029793a22b15d9776a7fc172476fd7c126a80d4080c65d720845becbcb"},{"path":"product/runtime/auths-gateway/src/transport.rs","sha256":"978fc9fb34a33b3db265b5d1c3ce11696a5b9f08529e46dd2a1e7bb62afb9220"},{"path":"product/runtime/auths-kernel-runtime/Cargo.toml","sha256":"926bcd0053e5edd703be8f0027ad009e2982db013c8174c1366edbf38b48087d"},{"path":"product/runtime/auths-kernel-runtime/src/lib.rs","sha256":"7108faae9ca4bc32dc093ddaf885ac8f74f3c9b8d317b7c7d0ed00d1d0ab5972"},{"path":"product/runtime/auths-lifecycle/Cargo.toml","sha256":"c5053c705f8d5554b44e432fe75287a2faf11d644e5a2552e45ad6cd94176af5"},{"path":"product/runtime/auths-lifecycle/src/codec.rs","sha256":"51c4b746749150e8bfcd8ff972b3f6c1ef40bec15583448ec1b471d9ad737dc0"},{"path":"product/runtime/auths-lifecycle/src/digest.rs","sha256":"55282cd7d8aba37713c2dcb5357c9877819edb72a30eadefb860d28d7d3a3eb7"},{"path":"product/runtime/auths-lifecycle/src/identifier.rs","sha256":"31620e8c16de68d3d2d3b84d6353c1a5b9d4151d8247f91fa389b906a8e660f1"},{"path":"product/runtime/auths-lifecycle/src/kernel.rs","sha256":"2abb8c094b36d35b364e74d78091d14c5d6f3d1eb6552cca3b5538dbd017ce23"},{"path":"product/runtime/auths-lifecycle/src/lib.rs","sha256":"afe5a345cd309b5a4f08ffba3170a6d7f16ff914892b3f2006d5208ed24ec736"},{"path":"product/runtime/auths-lifecycle/src/model.rs","sha256":"1af12bbeeeaa65ec02ddcebd7d2c8c92ec1e339fa40ae85b45ab9b01a722a4fa"},{"path":"product/runtime/auths-lifecycle/src/operation.rs","sha256":"78e6d05fcf5d7ea4f148032a3fa26fdfcab77eb346019effe927b08e0d64d732"},{"path":"product/runtime/auths-lifecycle/src/registry.rs","sha256":"53495fe6f7a77422593a41623e28716f1c140dcc1e00f87f6b0ffbfb93a5ef90"},{"path":"product/runtime/auths-lifecycle/src/sealed.rs","sha256":"2e0c03b5813c926c159598bb7890d1523825edbe57cd687d42a5dfb3374a0c19"},{"path":"product/runtime/auths-lifecycle/src/test_support.rs","sha256":"221d9a5c5ef8e1f9f8fd66927484eaf813571d0568470278ecd8fa08a5eec8cd"},{"path":"product/runtime/auths-lifecycle/src/transition.rs","sha256":"011805a28efdd541f44ccea54dd0e34a1e58f4004447ee58013703b4fb6b2822"},{"path":"product/runtime/auths-runtime/Cargo.toml","sha256":"dd29d80a8b4bb56c869f036a6c9998f6e6d1c0383ac5dc03fa42b24abe2a7ff7"},{"path":"product/runtime/auths-runtime/src/lib.rs","sha256":"132ca03d58c14905d3a822a4972f42a22a5ad33a45f2ba9ca44f3a57d810b4b0"},{"path":"product/runtime/auths-runtime/src/production.rs","sha256":"fd4eff4270c571c10fcff6cf27df28961ebf074de8a1a028f500fda1ca376820"},{"path":"product/sdk/auths-approval-quorum/Cargo.toml","sha256":"df904971fca87a24194c5816738a331588fe8f164acb5d70839a35dca5f57114"},{"path":"product/sdk/auths-approval-quorum/src/lib.rs","sha256":"72e4086088a1c0912e4e1a9e980f26a5c280bf5ab16ea39c9f0f58d5096ac2ce"},{"path":"product/sdk/auths-approval-quorum/src/remote.rs","sha256":"9fa029c7c63003adf260f8aec55160c545681c60c1bace0d934e7525862aac7b"},{"path":"product/sdk/auths-approval-quorum/src/remote_tests.rs","sha256":"8a09ce17cfee82a035a74edd535f3ebeb3db0e078784740d3997b17dbdb10197"},{"path":"product/sdk/auths-approval-quorum/src/tests.rs","sha256":"0acbf12be3e497dcc6c2d8f29c9af3b1a7ed8e11b75f6399570d4f217a785060"},{"path":"product/stores/auths-stores/Cargo.toml","sha256":"a32c4b76d17b264be5d40d24497ff237e1ed87822e8bab86229b3db93106dbe3"},{"path":"product/stores/auths-stores/src/gateway_attempt.rs","sha256":"218841602d3d9b23b44625f50e5b54cf275ccedc2d5c89f02d2482b8524472af"},{"path":"product/stores/auths-stores/src/lib.rs","sha256":"e721262edf8a9ab5568ddc27639176312d4a366203b5f73e2d7c11fde2c554e9"},{"path":"product/stores/auths-stores/src/lifecycle.rs","sha256":"3f2cecfda13fdcd7c4a944518646bcc8abd1237f900c0332d5822281f8f9f1cd"}],"schema":"auths.gateway-semantic-closure/1"} \ No newline at end of file diff --git a/product/runtime/auths-gateway/src/admin.rs b/product/runtime/auths-gateway/src/admin.rs index 55f01a0eb..bb67d56e2 100644 --- a/product/runtime/auths-gateway/src/admin.rs +++ b/product/runtime/auths-gateway/src/admin.rs @@ -35,6 +35,8 @@ pub enum AdminRequestCommand { }, /// Report connection state. Status {}, + /// Read process-local custody and transport measurements; grants no authority. + ExecutionWitness {}, /// Read the qualification inputs the operator placed on this host again /// and report the resulting state. QualificationReload {}, @@ -86,7 +88,12 @@ mod tests { parse("\"command\":\"qualification-reload\""), Ok(AdminRequestCommand::QualificationReload {}) ); + assert_eq!( + parse("\"command\":\"execution-witness\""), + Ok(AdminRequestCommand::ExecutionWitness {}) + ); for refused in [ + "\"command\":\"execution-witness\",\"reset\":true", "\"command\":\"qualification-reload\",\"state\":\"qualified\"", "\"command\":\"qualification-reload\",\"policy\":\"optional\"", "\"command\":\"qualification-import\"", diff --git a/product/runtime/auths-gateway/src/bin/auths-gateway.rs b/product/runtime/auths-gateway/src/bin/auths-gateway.rs index 2f49a1685..385ab665f 100644 --- a/product/runtime/auths-gateway/src/bin/auths-gateway.rs +++ b/product/runtime/auths-gateway/src/bin/auths-gateway.rs @@ -85,10 +85,6 @@ mod unix { const QUALIFICATION_STATE_FILE: &str = "qualification-state.json"; /// A development installation's own trust root. const QUALIFICATION_ROOT_FILE: &str = "qualification-trust-root.json"; - /// The qualification trust root this build pins. It is `None` until a - /// reviewed release pins the root the offline ceremony created; until - /// then a production gateway finds every recipe unqualified. - const PINNED_QUALIFICATION_ROOT: Option<&[u8]> = None; const OBSERVER_SEED: &str = "observer.seed"; const OPERATOR_ATTESTATION_FILE: &str = "operator-attestation.json"; use auths_gateway::admin::{ @@ -277,6 +273,23 @@ mod unix { #[arg(long)] profile_lock: PathBuf, }, + /// Offline native proof review: derive the exact credential-free + /// request and actors without installation, custody or provider I/O. + ReviewSubmission { + #[arg(long)] + recipe: PathBuf, + #[arg(long)] + profile_lock: PathBuf, + #[arg(long)] + trusted_context: PathBuf, + #[arg(long)] + proof: PathBuf, + #[arg(long)] + action: PathBuf, + /// Offline evaluation time; no lease or production clock override. + #[arg(long, value_parser = clap::value_parser!(u64).range(..=253_402_300_799))] + evaluated_at: Option, + }, /// Print the grant extension committing to a ceiling on one verified /// integer argument and a count per fixed, epoch-aligned window, and /// optionally a sum limit per listed partition value and a scope, as @@ -360,6 +373,39 @@ mod unix { #[arg(long, default_value_t = false)] tuple: bool, }, + /// Print the planned production tuple from this executable and the + /// reviewed recipe, without installing custody or contacting a provider. + /// This is candidate identity, never qualification or readiness. + QualificationCandidate { + #[arg(long)] + recipe: PathBuf, + #[arg(long)] + profile_lock: PathBuf, + #[arg(long)] + recipe_family: String, + #[arg(long)] + provider_contract_id: String, + }, + /// Authenticated operator-only fresh commissioning registration. + /// Registers finite capacity once; never resets existing consumption. + CommissioningInit { + #[command(flatten)] + session: CommissioningOptions, + }, + /// Operator-only exact proof submission under finite commissioning + /// authority. Does not open or change the ordinary application socket. + CommissioningSubmit { + #[command(flatten)] + session: CommissioningOptions, + #[arg(long)] + proof: PathBuf, + #[arg(long)] + action: PathBuf, + /// Optional new owner-private file of bounded, secret-free counter + /// snapshots. Read-only diagnostics; never a remote effect claim. + #[arg(long)] + witness_file: Option, + }, /// Write a redacted archive for a support request: versions, /// digests, closed states, stable codes, and the digest and stage of /// each stored attempt. It holds no proof, action, body, credential, @@ -384,6 +430,13 @@ mod unix { #[arg(long)] admin_socket: Option, }, + /// Read actual custody/HTTP boundary counts through the private socket. + ExecutionWitness { + #[arg(long)] + state_dir: PathBuf, + #[arg(long)] + admin_socket: Option, + }, /// Ask the private operator socket for one read-only re-observation /// of a stored attempt. Reobserve { @@ -608,6 +661,23 @@ mod unix { qualification_trust_root: Option, } + #[derive(clap::Args)] + struct CommissioningOptions { + /// Existing production installation owned by this operator UID. + #[arg(long)] + state_dir: PathBuf, + /// Protected directory containing the permit, signer certificate and + /// root-signed revocation list. Production uses its compiled trust root. + #[arg(long)] + from: PathBuf, + /// Exact run identity, including workflow run attempt. + #[arg(long)] + protected_run: String, + /// Reviewed resource file expanded before permit issuance. + #[arg(long)] + resource_binding: PathBuf, + } + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)] #[serde(rename_all = "kebab-case")] enum Deployment { @@ -747,24 +817,6 @@ mod unix { && !cfg!(feature = "testkit-production-plaintext") } - /// Opens the credential store the settings select, under the - /// deployment's policy. `unavailable` is the caller's code for a store - /// that is selected and permitted but cannot be opened. - fn open_credentials( - state_dir: &Path, - settings: &CredentialStoreSettings, - deployment: Deployment, - unavailable: &'static str, - ) -> Result, &'static str> { - open_credentials_for( - state_dir, - settings, - deployment, - unavailable, - CredentialAccess::Runtime, - ) - } - #[derive(Clone, Copy, Eq, PartialEq)] enum CredentialAccess { Runtime, @@ -893,6 +945,8 @@ mod unix { #[serde(skip_serializing_if = "Option::is_none")] status: Option, #[serde(skip_serializing_if = "Option::is_none")] + execution_witness: Option, + #[serde(skip_serializing_if = "Option::is_none")] result: Option, /// The reference commitment of a prepared successor. It names the /// stored secret without revealing it or where it is kept. @@ -932,6 +986,7 @@ mod unix { drained: None, in_flight: None, status: None, + execution_witness: None, result: None, commitment: None, qualification: None, @@ -1259,11 +1314,15 @@ mod unix { .map_err(|_| "gateway.install.attempt-store-unavailable")? .map_err(|_| "gateway.install.attempt-store-unavailable")? }; - let credentials = open_credentials( + // Installation writes custody and a join reads the stored commitment. + // Use the same separated writer/reader identities as administration; + // the operator role must not acquire GetSecretValue permission. + let credentials = open_credentials_for( &state_dir, &manifest.credential_store, deployment, "gateway.install.credential-store-unavailable", + CredentialAccess::Operator, )?; let shared = SharedConnection::new(attempts.store(), provider.clone(), alias.clone()); if join { @@ -1454,11 +1513,49 @@ mod unix { } fn write_verifier_state(state_dir: &Path, state: &VerifierState) -> Result<(), &'static str> { + // Concurrent imports must never erase a revocation remembered by an + // earlier authenticated operator command in this same installation. + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(i32::from_ne_bytes( + rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes(), + )) + .open(state_dir.join("qualification-state.lock")) + .map_err(|_| "gateway.qualification.unavailable")?; + let metadata = lock + .metadata() + .map_err(|_| "gateway.qualification.unavailable")?; + if !metadata.is_file() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.nlink() != 1 + { + return Err("gateway.qualification.unavailable"); + } + rustix::fs::flock(&lock, rustix::fs::FlockOperation::LockExclusive) + .map_err(|_| "gateway.qualification.unavailable")?; + let mut merged = read_verifier_state(state_dir)?; + merged.accepted_revocation_sequence = merged + .accepted_revocation_sequence + .max(state.accepted_revocation_sequence); + merged.accepted_index_issued_at = merged + .accepted_index_issued_at + .max(state.accepted_index_issued_at); + merged + .revoked_signers + .extend(state.revoked_signers.iter().cloned()); + merged + .revoked_qualifications + .extend(state.revoked_qualifications.iter().cloned()); let stored = StoredVerifierState { - accepted_revocation_sequence: state.accepted_revocation_sequence, - accepted_index_issued_at: state.accepted_index_issued_at, - revoked_signers: state.revoked_signers.iter().cloned().collect(), - revoked_qualifications: state.revoked_qualifications.iter().cloned().collect(), + accepted_revocation_sequence: merged.accepted_revocation_sequence, + accepted_index_issued_at: merged.accepted_index_issued_at, + revoked_signers: merged.revoked_signers.into_iter().collect(), + revoked_qualifications: merged.revoked_qualifications.into_iter().collect(), }; let bytes = serde_json::to_vec(&stored).map_err(|_| "gateway.qualification.unavailable")?; replace_private_file(&state_dir.join(QUALIFICATION_STATE_FILE), &bytes) @@ -1489,7 +1586,7 @@ mod unix { Some(bytes) } (Some(_), Deployment::Production) => return Err("gateway.serve.invalid-installation"), - (None, _) => PINNED_QUALIFICATION_ROOT.map(<[u8]>::to_vec), + (None, _) => auths_gateway::PINNED_QUALIFICATION_ROOT.map(<[u8]>::to_vec), }; bytes .map(|bytes| { @@ -1531,6 +1628,32 @@ mod unix { }) } + fn qualification_candidate( + recipe_path: &Path, + lock_path: &Path, + family: &str, + contract: &str, + ) -> Result { + let source = read_bounded(recipe_path, 65_536)?; + let lock = read_bounded(lock_path, 65_536)?; + let recipe = installable_recipe(&source, &lock)?; + let executable = std::env::current_exe() + .and_then(fs::read) + .map_err(|_| "gateway.qualification.unavailable")?; + deployment_tuple(&DeploymentFacts { + recipe_family: family, + provider_contract_id: contract, + compiled_recipe_sha256: *recipe.digest(), + profile_lock_sha256: Sha256::digest(&lock).into(), + gateway_build_sha256: Sha256::digest(&executable).into(), + store_kind: LifecycleStoreKind::PostgresqlV1, + store_schema: POSTGRES_STORE_SCHEMA, + credential_store_kind: CredentialStoreKind::parse("aws-secrets-manager-v1") + .map_err(|_| "gateway.qualification.unavailable")?, + }) + .ok_or_else(|| "gateway.qualification.unavailable".into()) + } + /// Builds the installation's gate and loads the inputs the operator /// imported. The policy is decided again from the deployment, so an /// edited manifest cannot relax production. Only a changed installation @@ -1956,6 +2079,7 @@ mod unix { RotatePrepare(Zeroizing>), RotateCommit([u8; 32]), Status, + ExecutionWitness, Reobserve(String), QualificationReload, } @@ -1975,6 +2099,7 @@ mod unix { AdminRequestCommand::Enable {} => Ok(AdminCommand::Enable), AdminRequestCommand::Revoke {} => Ok(AdminCommand::Revoke), AdminRequestCommand::Status {} => Ok(AdminCommand::Status), + AdminRequestCommand::ExecutionWitness {} => Ok(AdminCommand::ExecutionWitness), AdminRequestCommand::QualificationReload {} => Ok(AdminCommand::QualificationReload), AdminRequestCommand::Reobserve { operation_id } => { Ok(AdminCommand::Reobserve(operation_id)) @@ -2060,6 +2185,11 @@ mod unix { }, Err(code) => AdminResponse::refused(code), }, + Ok(AdminCommand::ExecutionWitness) => AdminResponse { + ok: true, + execution_witness: Some(engine.execution_witness()), + ..AdminResponse::refused("gateway.admin.execution-witness") + }, Ok(AdminCommand::QualificationReload) => { let reloading = Arc::clone(&engine); let directory = Arc::clone(&state_dir); @@ -2366,6 +2496,42 @@ mod unix { Ok(()) } + fn review_submission_files( + recipe_path: &Path, + lock_path: &Path, + context_path: &Path, + proof_path: &Path, + action_path: &Path, + evaluated_at: Option, + ) -> Result<(), Failure> { + let recipe = installable_recipe( + &read_bounded(recipe_path, 65_536)?, + &read_bounded(lock_path, 65_536)?, + )?; + let trust = read_bounded(context_path, 4 * 1024 * 1024)?; + let context = auths_codec::decode_verifier_context(&trust) + .map_err(|_| "gateway.verify.invalid-trust")?; + let proof = read_bounded(proof_path, 4 * 1024 * 1024)?; + let action = read_bounded(action_path, 64 * 1024)?; + let result = auths_gateway::review_submission( + &recipe, + &context, + match evaluated_at { + Some(timestamp) => timestamp, + None => now()?, + }, + &proof, + &action, + ); + let encoded = match result { + Ok(reviewed) => serde_json::to_string(&reviewed), + Err(refusal) => serde_json::to_string(&refusal), + } + .map_err(|_| "gateway.output")?; + println!("{encoded}"); + Ok(()) + } + /// Parses `=,...`. fn listed_values(text: &str) -> Result { let invalid = "gateway.policy.invalid-policy"; @@ -2491,6 +2657,208 @@ mod unix { .map_err(|error| Failure::at(code, socket, error)) } + /// A bounded diagnostic stream. Opening refuses an existing file or a + /// shared directory before submission; a subsequent recording failure + /// must never cancel a submission that may have entered a provider write. + struct CommissioningWitnessFile { + file: File, + last: Option, + frames: usize, + } + + impl CommissioningWitnessFile { + fn open(path: &Path) -> Result { + let parent = path + .parent() + .filter(|_| path.is_absolute() && path.file_name().is_some()) + .ok_or("gateway.commissioning.unsafe-witness-file")?; + check_private_directory(parent) + .map_err(|_| "gateway.commissioning.unsafe-witness-file")?; + if path + .components() + .any(|part| !matches!(part, Component::RootDir | Component::Normal(_))) + { + return Err("gateway.commissioning.unsafe-witness-file"); + } + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(i32::from_ne_bytes( + rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes(), + )) + .open(path) + .map_err(|_| "gateway.commissioning.unsafe-witness-file")?; + let metadata = file + .metadata() + .map_err(|_| "gateway.commissioning.unsafe-witness-file")?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.nlink() != 1 + || metadata.permissions().mode() & 0o077 != 0 + { + return Err("gateway.commissioning.unsafe-witness-file"); + } + Ok(Self { + file, + last: None, + frames: 0, + }) + } + + fn record( + &mut self, + snapshot: auths_gateway::GatewayExecutionWitness, + ) -> Result<(), &'static str> { + if self.last.as_ref() == Some(&snapshot) { + return Ok(()); + } + if self.frames >= 256 { + return Err("gateway.commissioning.witness-limit"); + } + let mut bytes = serde_json::to_vec(&snapshot) + .map_err(|_| "gateway.commissioning.witness-unavailable")?; + if bytes.len() >= 1024 { + return Err("gateway.commissioning.witness-limit"); + } + bytes.push(b'\n'); + self.file + .write_all(&bytes) + .map_err(|_| "gateway.commissioning.witness-unavailable")?; + self.frames += 1; + self.last = Some(snapshot); + Ok(()) + } + + fn finish(&self) -> Result<(), &'static str> { + self.file + .sync_all() + .map_err(|_| "gateway.commissioning.witness-unavailable") + } + } + + async fn commissioned_submission( + session: &auths_gateway::CommissioningSession<'_>, + engine: &GatewayEngine, + proof: &[u8], + action: &[u8], + witness_file: Option<&Path>, + before: &auths_gateway::GatewayExecutionWitness, + ) -> Result<(GatewaySubmitResult, Option<&'static str>), Failure> { + let Some(path) = witness_file else { + return Ok((session.submit(proof, action).await, None)); + }; + let mut witness = CommissioningWitnessFile::open(path)?; + witness.record(before.clone())?; + let submission = session.submit(proof, action); + tokio::pin!(submission); + let mut interval = tokio::time::interval(Duration::from_millis(10)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut diagnostic_failure = None; + let result = loop { + tokio::select! { + result = &mut submission => break result, + _ = interval.tick(), if diagnostic_failure.is_none() => { + diagnostic_failure = witness.record(engine.execution_witness()).err(); + } + } + }; + // A full or broken stream cannot interrupt an entered attempt. + // Report diagnostic failure only after that native attempt finishes. + let final_record = witness.record(engine.execution_witness()); + diagnostic_failure = diagnostic_failure.or(final_record.err()); + diagnostic_failure = diagnostic_failure.or(witness.finish().err()); + Ok((result, diagnostic_failure)) + } + + /// Direct operator process; ordinary application handlers never call this. + async fn commissioning( + options: &CommissioningOptions, + command: Option<(&Path, &Path)>, + witness_file: Option<&Path>, + ) -> Result<(), Failure> { + use auths_recipe_qualification::{ + BoundedText, COMMISSIONING_PERMIT_FILE, CommissioningInputs, + MAX_COMMISSIONING_PERMIT_BYTES, + }; + private_root(&options.state_dir)?; + if installation(&options.state_dir)?.deployment != Deployment::Production { + return Err("gateway.commissioning.binding-mismatch".into()); + } + let directory = options.state_dir.clone(); + let engine = tokio::task::spawn_blocking(move || load_engine(&directory)) + .await + .map_err(|_| "gateway.commissioning.unavailable")??; + let permit = read_bounded( + &options.from.join(COMMISSIONING_PERMIT_FILE), + MAX_COMMISSIONING_PERMIT_BYTES, + )?; + let certificate = read_bounded( + &options.from.join(RELEASE_SIGNER_CERTIFICATE_FILE), + MAX_SIGNER_CERTIFICATE_BYTES, + )?; + let revocations = read_bounded( + &options.from.join(RELEASE_REVOCATION_LIST_FILE), + MAX_REVOCATION_LIST_BYTES, + )?; + let resources = read_bounded(&options.resource_binding, 64 * 1024)?; + let attestation = read_attestation(&options.state_dir.join(OPERATOR_ATTESTATION_FILE))?; + let protected_run = BoundedText::parse(&options.protected_run) + .map_err(|_| "gateway.commissioning.binding-mismatch")?; + let opened = engine.commissioning_session(&auths_gateway::CommissioningSessionInputs { + artifacts: CommissioningInputs { + signer_certificate: &certificate, + revocation_list: &revocations, + permit: &permit, + }, + operator_attestation: &attestation, + protected_run: &protected_run, + resources: &resources, + floor_directory: &options.state_dir, + }); + // Authenticated lists remain remembered even when they deny opening. + // Persist before acknowledging an import or reaching any custody lease. + write_verifier_state(&options.state_dir, &engine.qualification().verifier_state())?; + let session = opened?; + match command { + None => { + session.initialize_budget().await?; + println!( + "{{\"outcome\":\"commissioning-registered\",\"qualification\":\"required\"}}" + ); + } + Some((proof, action)) => { + let proof = read_bounded(proof, 4 * 1024 * 1024)?; + let action = read_bounded(action, 64 * 1024)?; + let before = engine.execution_witness(); + let (result, diagnostic_failure) = commissioned_submission( + &session, + &engine, + &proof, + &action, + witness_file, + &before, + ) + .await?; + let measured = serde_json::json!({ + "schema": "auths.gateway-commissioning-execution/1", + "result": result, + "before": before, + "after": engine.execution_witness(), + }); + println!( + "{}", + serde_json::to_string(&measured) + .map_err(|_| "gateway.submit.invalid-response")? + ); + if let Some(code) = diagnostic_failure { + return Err(code.into()); + } + } + } + Ok(()) + } + async fn submit(socket: PathBuf, proof: PathBuf, action: PathBuf) -> Result<(), Failure> { let proof = read_bounded(&proof, 4 * 1024 * 1024)?; let action = read_bounded(&action, 64 * 1024)?; @@ -3256,6 +3624,36 @@ mod unix { .await .map_err(|_| "gateway.qualification.unavailable")? } + Command::QualificationCandidate { + recipe, + profile_lock, + recipe_family, + provider_contract_id, + } => { + let candidate = tokio::task::spawn_blocking(move || { + qualification_candidate( + &recipe, + &profile_lock, + &recipe_family, + &provider_contract_id, + ) + }) + .await + .map_err(|_| "gateway.qualification.unavailable")??; + println!( + "{}", + serde_json_canonicalizer::to_string(&candidate) + .map_err(|_| "gateway.qualification.unavailable")? + ); + Ok(()) + } + Command::CommissioningInit { session } => commissioning(&session, None, None).await, + Command::CommissioningSubmit { + session, + proof, + action, + witness_file, + } => commissioning(&session, Some((&proof, &action)), witness_file.as_deref()).await, #[cfg(feature = "loopback-provider")] Command::Serve { state_dir, @@ -3295,6 +3693,25 @@ mod unix { recipe, profile_lock, } => Ok(review(&recipe, &profile_lock)?), + Command::ReviewSubmission { + recipe, + profile_lock, + trusted_context, + proof, + action, + evaluated_at, + } => tokio::task::spawn_blocking(move || { + review_submission_files( + &recipe, + &profile_lock, + &trusted_context, + &proof, + &action, + evaluated_at, + ) + }) + .await + .map_err(|_| "gateway.verify.invalid-input")?, Command::BoundExtension(options) => Ok(bound_extension(&options)?), Command::Audit { bundle, @@ -3348,6 +3765,14 @@ mod unix { let command = serde_json::json!({"command": "revoke"}); admin_command(&state_dir, &admin_socket, command, None).await } + Command::ExecutionWitness { + state_dir, + admin_socket, + } => { + let admin_socket = admin_socket_path(&state_dir, admin_socket); + let command = serde_json::json!({"command": "execution-witness"}); + admin_command(&state_dir, &admin_socket, command, None).await + } Command::Status { state_dir, admin_socket, @@ -3533,8 +3958,183 @@ mod unix { #[cfg(test)] mod tests { use super::*; + + #[test] + fn commissioning_witness_refuses_shared_existing_and_linked_outputs() { + let directory = tempfile::tempdir().expect("private output"); + let parent = fs::canonicalize(directory.path()).expect("canonical output"); + let path = parent.join("counters.jsonl"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).expect("shared"); + assert!(CommissioningWitnessFile::open(&path).is_err()); + assert!(!path.exists()); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).expect("private"); + fs::write(&path, b"retain existing output").expect("existing"); + assert!(CommissioningWitnessFile::open(&path).is_err()); + assert_eq!( + fs::read(&path).expect("retained"), + b"retain existing output" + ); + let link = parent.join("linked.jsonl"); + std::os::unix::fs::symlink(&path, &link).expect("symbolic link"); + assert!(CommissioningWitnessFile::open(&link).is_err()); + assert!(CommissioningWitnessFile::open(Path::new("relative.jsonl")).is_err()); + } + + #[test] + fn commissioning_witness_bounds_changed_counter_frames_without_secret_fields() { + let directory = tempfile::tempdir().expect("private output"); + let parent = fs::canonicalize(directory.path()).expect("canonical output"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).expect("private"); + let path = parent.join("counters.jsonl"); + let mut stream = CommissioningWitnessFile::open(&path).expect("new output"); + let mut snapshot = auths_gateway::GatewayExecutionWitness { + schema: "auths.gateway-execution-witness/1".to_owned(), + scope: "00112233445566778899aabbccddeeff".to_owned(), + credential_lease_calls: 0, + write_transport_entries: 0, + read_transport_entries: 0, + }; + for reads in 0..256 { + snapshot.read_transport_entries = reads; + stream.record(snapshot.clone()).expect("changed snapshot"); + stream.record(snapshot.clone()).expect("duplicate omitted"); + } + snapshot.read_transport_entries = 256; + assert_eq!( + stream.record(snapshot), + Err("gateway.commissioning.witness-limit") + ); + stream.finish().expect("finished output"); + let bytes = fs::read(&path).expect("counter frames"); + assert!(bytes.len() <= 256 * 1024); + let lines = bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()); + let snapshots: Vec = lines + .map(|line| serde_json::from_slice(line).expect("closed typed snapshot")) + .collect(); + assert_eq!(snapshots.len(), 256); + assert!(snapshots.iter().enumerate().all(|(index, snapshot)| { + snapshot.read_transport_entries == index as u64 + && snapshot.credential_lease_calls == 0 + && snapshot.write_transport_entries == 0 + })); + assert_eq!( + fs::metadata(path).expect("mode").permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn candidate_identity_needs_no_installation_and_binds_the_running_bytes() { + let directory = tempfile::tempdir().expect("candidate input"); + let recipe = directory.path().join("recipe.json"); + let lock = directory.path().join("profile.lock.json"); + fs::write( + &recipe, + include_bytes!( + "../../../../../qualification/simulation/live/stripe-platform/recipe.json" + ), + ) + .expect("recipe"); + fs::write( + &lock, + include_bytes!( + "../../../../../qualification/simulation/live/stripe-platform/profile.lock.json" + ), + ) + .expect("lock"); + let contract = "1".repeat(64); + let tuple = + qualification_candidate(&recipe, &lock, "stripe-platform-refund-v1", &contract) + .expect("candidate"); + assert_eq!(tuple.target.store_kind, LifecycleStoreKind::PostgresqlV1); + assert_eq!(tuple.target.store_schema.as_str(), POSTGRES_STORE_SCHEMA); + assert!(tuple.target.credential_store_kind.is_production()); + assert_eq!( + tuple.target.gateway_build_sha256.to_hex(), + digest(&fs::read(std::env::current_exe().expect("executable")).expect("bytes")) + ); + assert_eq!(fs::read_dir(directory.path()).expect("inputs").count(), 2); + for (family, contract) in [ + ("../another-family", contract.as_str()), + ("stripe-platform-refund-v1", "wrong"), + ] { + assert!(qualification_candidate(&recipe, &lock, family, contract).is_err()); + } + let mut changed: serde_json::Value = + serde_json::from_slice(&fs::read(&recipe).expect("source")).expect("recipe"); + changed["tool"] = serde_json::json!("another_tool"); + fs::write(&recipe, serde_json::to_vec(&changed).expect("changed")).expect("source"); + assert!( + qualification_candidate( + &recipe, + &lock, + "stripe-platform-refund-v1", + &"1".repeat(64) + ) + .is_err() + ); + } use auths_gateway::listener::APP_CAPACITY; + #[test] + fn concurrent_operator_imports_keep_every_authenticated_revocation() { + let directory = tempfile::tempdir().expect("installation"); + let start = Arc::new(std::sync::Barrier::new(9)); + let workers: Vec<_> = (1..=8) + .map(|index| { + let directory = directory.path().to_owned(); + let start = start.clone(); + std::thread::spawn(move || { + let mut state = VerifierState { + accepted_revocation_sequence: index, + accepted_index_issued_at: index * 10, + ..VerifierState::default() + }; + state.revoked_signers.insert( + QualificationSignerId::parse(format!("synthetic-signer-{index}")) + .expect("signer"), + ); + start.wait(); + write_verifier_state(&directory, &state) + }) + }) + .collect(); + start.wait(); + for worker in workers { + worker.join().expect("worker").expect("persisted"); + } + write_verifier_state(directory.path(), &VerifierState::default()) + .expect("stale writer"); + let stored = read_verifier_state(directory.path()).expect("remembered"); + assert_eq!(stored.accepted_revocation_sequence, 8); + assert_eq!(stored.accepted_index_issued_at, 80); + assert_eq!(stored.revoked_signers.len(), 8); + } + + #[test] + fn application_and_admin_frames_cannot_select_a_commissioning_session() { + for field in ["commissioning", "permit", "authority", "operator_session"] { + let mut frame = serde_json::json!({"schema": APP_REQUEST_SCHEMA, + "proof_b64": "AA", "action_b64": "AA"}); + frame[field] = serde_json::json!("synthetic-untrusted-authority"); + assert!( + serde_json::from_value::(frame).is_err(), + "{field}" + ); + } + assert!( + parse_admin_request( + &serde_json::to_vec(&serde_json::json!({ + "schema": ADMIN_REQUEST_SCHEMA, "command": "commissioning-submit" + })) + .expect("frame") + ) + .is_err() + ); + } + fn serve_capacity(arguments: &[&str]) -> Result { let mut command = vec![ "auths-gateway", diff --git a/product/runtime/auths-gateway/src/bounds.rs b/product/runtime/auths-gateway/src/bounds.rs index 524eaf0ba..2f4a4feba 100644 --- a/product/runtime/auths-gateway/src/bounds.rs +++ b/product/runtime/auths-gateway/src/bounds.rs @@ -927,6 +927,9 @@ pub(crate) fn authorized_chains( /// What admission needs of the authorized branches. pub(crate) struct BranchFacts { + /// Distinct actors of the exact action IDs sealed by native verification. + #[cfg(unix)] + pub(crate) actors: Vec, /// Every observation requirement of every grant of every branch. pub(crate) requirements: Vec, /// The longest action validity window of any branch. @@ -956,6 +959,13 @@ pub(crate) fn authorized_branches( } } Ok(BranchFacts { + #[cfg(unix)] + actors: branches + .iter() + .map(|branch| branch.actor.clone()) + .collect::>() + .into_iter() + .collect(), approvers: counted_approvers(proof_cbor, verified)?, requirements, validity_seconds: branches diff --git a/product/runtime/auths-gateway/src/commissioning_budget.rs b/product/runtime/auths-gateway/src/commissioning_budget.rs new file mode 100644 index 000000000..c36ae65a9 --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_budget.rs @@ -0,0 +1,400 @@ +//! Permanent commissioning consumption on the existing opaque atomic store. +//! +//! This component reserves capacity; it neither leases a credential nor opens +//! the ordinary qualification gate. The authenticated operator creates the +//! record once during fresh setup. Runtime opening never initializes missing +//! state. Before custody, a private commissioning session must persist each +//! returned snapshot as a host-local floor retained independently of database +//! restores. A claimed unit is never refunded, including a crash before lease. + +use crate::{ + GatewayAttemptError, GatewayAttemptKey, GatewayAttemptStore, GatewayInsert, GatewayRecordEntry, + GatewayRecordKind, +}; +use auths_recipe_qualification::{ + CommissioningBinding, CommissioningRefusal, CommissioningRequest, MAX_REVOKED_SIGNERS, + QualificationSignerId, Sha256Digest, VerifiedCommissioningPermit, VerifierState, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, sync::Arc}; + +/// The permanent counter/floor format, separate from qualifications and attempts. +pub const COMMISSIONING_BUDGET_SCHEMA: &str = "auths.gateway-commissioning-budget/1"; +/// Fixed record limit checked before decoding store or floor bytes. +pub const MAX_COMMISSIONING_BUDGET_BYTES: usize = 16 * 1024; +/// A claim makes at most this many compare-and-swap attempts under contention. +pub const MAX_COMMISSIONING_CLAIM_RETRIES: usize = 16; + +/// A closed refusal before any credential acquisition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommissioningBudgetRefusal { + /// The signed authority does not satisfy its current runtime check. + Permit(CommissioningRefusal), + /// Shared state could not be read or committed. + Unavailable, + /// Required permanent registration is absent; runtime never recreates it. + Missing, + /// Noncanonical, malformed or inconsistent stored/floor state. + Corrupt, + /// A renewal changed the immutable binding or capacity at the run's key. + BindingMismatch, + /// Database consumption/time/revocation state is below the retained floor. + Rollback, + /// The lifetime ceiling has already been consumed. + Exhausted, + /// Bounded contention retries ended; no lease was authorized by this call. + Contention, +} + +impl CommissioningBudgetRefusal { + /// Stable commissioning code. These are never qualification verdicts. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Permit(CommissioningRefusal::Unavailable) => "gateway.commissioning.unavailable", + Self::Permit(CommissioningRefusal::Revoked) => "gateway.commissioning.revoked", + Self::Permit(CommissioningRefusal::RevocationRollback) => { + "gateway.commissioning.revocation-rollback" + } + Self::Permit(CommissioningRefusal::ClockUntrusted) => { + "gateway.commissioning.clock-untrusted" + } + Self::Permit(CommissioningRefusal::RevocationStale) => { + "gateway.commissioning.revocation-stale" + } + Self::Permit(CommissioningRefusal::Expired) => "gateway.commissioning.expired", + Self::Permit(CommissioningRefusal::BindingMismatch) | Self::BindingMismatch => { + "gateway.commissioning.binding-mismatch" + } + Self::Unavailable => "gateway.commissioning.store-unavailable", + Self::Missing => "gateway.commissioning.registration-missing", + Self::Corrupt => "gateway.commissioning.state-corrupt", + Self::Rollback => "gateway.commissioning.restore-rollback", + Self::Exhausted => "gateway.commissioning.exhausted", + Self::Contention => "gateway.commissioning.contention", + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Record { + schema: String, + budget_scope_sha256: Sha256Digest, + budget_binding_sha256: Sha256Digest, + maximum_credential_leases: u64, + consumed_credential_leases: u64, + latest_verifier_time: u64, + accepted_revocation_sequence: u64, + revoked_signers: BTreeSet, +} + +/// Validated immutable registration plus its monotone consumption/trust state. +/// This is a storage receipt, never a credential or provider-entry capability. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommissioningBudgetSnapshot { + record: Record, + bytes: Vec, +} + +impl CommissioningBudgetSnapshot { + /// Bytes the private session must durably retain before custody access. + #[must_use] + pub fn canonical_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Lifetime acquisitions consumed, including claims followed by crashes. + #[must_use] + pub const fn consumed_credential_leases(&self) -> u64 { + self.record.consumed_credential_leases + } + + /// Immutable signed ceiling at this run/family's stable budget key. + #[must_use] + pub const fn maximum_credential_leases(&self) -> u64 { + self.record.maximum_credential_leases + } + + /// Restores a floor from canonical bytes for this exact reviewed binding. + /// + /// # Errors + /// + /// Returns a closed refusal before parsing oversized input, or when the + /// schema, bounds, canonical form or immutable bindings differ. + pub fn from_canonical_json( + bytes: &[u8], + binding: &CommissioningBinding, + ) -> Result { + binding + .validate() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)?; + if bytes.is_empty() || bytes.len() > MAX_COMMISSIONING_BUDGET_BYTES { + return Err(CommissioningBudgetRefusal::Corrupt); + } + let record: Record = + serde_json::from_slice(bytes).map_err(|_| CommissioningBudgetRefusal::Corrupt)?; + let snapshot = Self::encode(record)?; + if snapshot.bytes != bytes { + return Err(CommissioningBudgetRefusal::Corrupt); + } + snapshot.matches(binding)?; + Ok(snapshot) + } + + fn encode(record: Record) -> Result { + if record.schema != COMMISSIONING_BUDGET_SCHEMA + || !(1..=auths_recipe_qualification::MAX_COMMISSIONING_LEASES) + .contains(&record.maximum_credential_leases) + || record.consumed_credential_leases > record.maximum_credential_leases + || record.accepted_revocation_sequence > (1 << 53) - 1 + || record.latest_verifier_time > 253_402_300_799 + || record.revoked_signers.len() > MAX_REVOKED_SIGNERS + { + return Err(CommissioningBudgetRefusal::Corrupt); + } + let bytes = serde_json_canonicalizer::to_vec(&record) + .map_err(|_| CommissioningBudgetRefusal::Corrupt)?; + if bytes.len() > MAX_COMMISSIONING_BUDGET_BYTES { + return Err(CommissioningBudgetRefusal::Corrupt); + } + Ok(Self { record, bytes }) + } + + fn matches(&self, binding: &CommissioningBinding) -> Result<(), CommissioningBudgetRefusal> { + if self.record.budget_scope_sha256 + != binding + .budget_key() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)? + || self.record.budget_binding_sha256 + != binding + .budget_binding() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)? + || self.record.maximum_credential_leases != binding.maximum_credential_leases + { + return Err(CommissioningBudgetRefusal::BindingMismatch); + } + Ok(()) + } + + pub(crate) fn not_below(&self, floor: &Self) -> bool { + self.record.budget_scope_sha256 == floor.record.budget_scope_sha256 + && self.record.budget_binding_sha256 == floor.record.budget_binding_sha256 + && self.record.maximum_credential_leases == floor.record.maximum_credential_leases + && self.record.consumed_credential_leases >= floor.record.consumed_credential_leases + && self.record.latest_verifier_time >= floor.record.latest_verifier_time + && self.record.accepted_revocation_sequence >= floor.record.accepted_revocation_sequence + && self + .record + .revoked_signers + .is_superset(&floor.record.revoked_signers) + } +} + +/// An atomically committed counter/trust update, which the session must persist +/// as its floor before acting on `refusal`. Refused requests consume no unit; +/// authenticated revocations are nevertheless durably remembered. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommissioningBudgetUpdate { + snapshot: CommissioningBudgetSnapshot, + refusal: Option, +} + +impl CommissioningBudgetUpdate { + /// Exact committed snapshot to retain outside database restores. + #[must_use] + pub const fn snapshot(&self) -> &CommissioningBudgetSnapshot { + &self.snapshot + } + + /// No credential acquisition when present, even though trust was recorded. + #[must_use] + pub const fn refusal(&self) -> Option { + self.refusal + } +} + +/// Capacity for one exact commissioning binding on an existing atomic store. +/// The production operator path must supply its production `PostgreSQL` backend; +/// development stores may exercise this mechanism without granting authority. +pub struct CommissioningBudget { + store: Arc, + key: GatewayAttemptKey, + binding: CommissioningBinding, +} + +impl CommissioningBudget { + /// Names the stable run/family key and its immutable reviewed binding. + /// + /// # Errors + /// + /// Returns [`CommissioningBudgetRefusal::BindingMismatch`] for a binding + /// outside the shipping permit's production target and finite bounds. + /// This constructor creates no state and opens no execution authority. + pub fn new( + store: Arc, + binding: CommissioningBinding, + ) -> Result { + binding + .validate() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)?; + let key = GatewayAttemptKey::from_bytes( + *binding + .budget_key() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)? + .as_bytes(), + ); + Ok(Self { + store, + key, + binding, + }) + } + + /// Registers capacity once during authenticated fresh operator setup. + /// Existing capacity is checked and returned, never reset or overwritten. + /// Runtime opening and renewal must use [`Self::load`] instead. + /// + /// # Errors + /// + /// Returns a store or binding refusal; a failed acknowledgement grants no + /// capacity or credential capability. Retain the returned initial floor. + pub fn initialize(&self) -> Result { + let initial = CommissioningBudgetSnapshot::encode(Record { + schema: COMMISSIONING_BUDGET_SCHEMA.to_owned(), + budget_scope_sha256: self + .binding + .budget_key() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)?, + budget_binding_sha256: self + .binding + .budget_binding() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)?, + maximum_credential_leases: self.binding.maximum_credential_leases, + consumed_credential_leases: 0, + latest_verifier_time: 0, + accepted_revocation_sequence: 0, + revoked_signers: BTreeSet::new(), + })?; + match self + .store + .insert_all(&[GatewayRecordEntry { + kind: GatewayRecordKind::CommissioningBudget, + key: self.key, + record: initial.bytes.clone(), + expires_at: None, + }]) + .map_err(store_refusal)? + { + GatewayInsert::Inserted => Ok(initial), + GatewayInsert::Exists { .. } => self.load(), + } + } + + /// Reads existing permanent capacity; absence never creates a fresh counter. + /// + /// # Errors + /// + /// Returns a missing, unavailable, corrupt or changed-binding refusal. + pub fn load(&self) -> Result { + let bytes = self + .store + .load(GatewayRecordKind::CommissioningBudget, &self.key) + .map_err(store_refusal)? + .ok_or(CommissioningBudgetRefusal::Missing)?; + CommissioningBudgetSnapshot::from_canonical_json(&bytes, &self.binding) + } + + /// Atomically claims one lifetime unit after current signed authority checks. + /// + /// `floor` is this host's independently retained prior snapshot and `state` + /// includes the installation's prior authenticated revocations. The caller + /// must durably retain the returned snapshot before custody, including on + /// a refusal. There is no refund or sweep operation. The normal gateway + /// proof, action, reservation and attempt checks remain mandatory. + /// + /// # Errors + /// + /// Returns a store/binding/rollback/contention refusal without authorizing + /// custody. Signed authority refusals and exhaustion are returned in the + /// committed update so their revocation memory can be retained. + pub fn claim( + &self, + authority: &VerifiedCommissioningPermit, + request: &CommissioningRequest<'_>, + now: u64, + clock_trusted: bool, + floor: &CommissioningBudgetSnapshot, + state: &VerifierState, + ) -> Result { + let authority_binding = &authority.permit().body().statement.binding; + floor.matches(&self.binding)?; + if authority_binding != &self.binding { + return Err(CommissioningBudgetRefusal::BindingMismatch); + } + for _ in 0..MAX_COMMISSIONING_CLAIM_RETRIES { + let current = self.load()?; + if !current.not_below(floor) { + return Err(CommissioningBudgetRefusal::Rollback); + } + let mut remembered = state.clone(); + remembered.accepted_revocation_sequence = remembered + .accepted_revocation_sequence + .max(current.record.accepted_revocation_sequence); + remembered + .revoked_signers + .extend(current.record.revoked_signers.iter().cloned()); + authority.remember(&mut remembered); + let mut next = current.record.clone(); + next.accepted_revocation_sequence = remembered.accepted_revocation_sequence; + next.revoked_signers.clone_from(&remembered.revoked_signers); + let refusal = authority + .evaluate(request, now, clock_trusted, &remembered) + .err() + .map(CommissioningBudgetRefusal::Permit) + .or({ + if now < current.record.latest_verifier_time { + Some(CommissioningBudgetRefusal::Rollback) + } else if current.record.consumed_credential_leases + == current.record.maximum_credential_leases + { + Some(CommissioningBudgetRefusal::Exhausted) + } else { + None + } + }); + if refusal.is_none() { + next.consumed_credential_leases += 1; + next.latest_verifier_time = now; + } + let next = CommissioningBudgetSnapshot::encode(next)?; + match self.store.replace( + GatewayRecordKind::CommissioningBudget, + &self.key, + ¤t.bytes, + &next.bytes, + ) { + Ok(()) => { + return Ok(CommissioningBudgetUpdate { + snapshot: next, + refusal, + }); + } + Err(GatewayAttemptError::Conflict) => {} + Err(error) => return Err(store_refusal(error)), + } + } + Err(CommissioningBudgetRefusal::Contention) + } +} + +const fn store_refusal(error: GatewayAttemptError) -> CommissioningBudgetRefusal { + match error { + GatewayAttemptError::Conflict => CommissioningBudgetRefusal::Contention, + GatewayAttemptError::Unavailable => CommissioningBudgetRefusal::Unavailable, + _ => CommissioningBudgetRefusal::Corrupt, + } +} + +#[cfg(test)] +mod tests; diff --git a/product/runtime/auths-gateway/src/commissioning_budget/tests.rs b/product/runtime/auths-gateway/src/commissioning_budget/tests.rs new file mode 100644 index 000000000..a2faec7d2 --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_budget/tests.rs @@ -0,0 +1,468 @@ +//! Identical capacity, race, crash and rollback checks on both atomic stores. +//! Public synthetic permits authorize no real credential or provider entry. + +use super::*; +use crate::store_testkit::{Backend, TestAttempts}; +use auths_recipe_qualification::{ + CommissioningInputs, QualificationCommissioningPermit, QualificationRevocationList, + QualificationSignerCertificate, QualificationTrustRoot, SignatureB64, +}; +use auths_recipe_qualification_issuance::{RootSigner, SigningSeed}; +use ed25519_dalek::{Signer as _, SigningKey}; +use proptest::prelude::*; +use std::sync::Barrier; +use zeroize::Zeroizing; + +struct Fixture { + root: QualificationTrustRoot, + certificate: QualificationSignerCertificate, + list: QualificationRevocationList, + permit: QualificationCommissioningPermit, +} + +impl Fixture { + fn load() -> Self { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../bindings/fixtures/qualification/commissioning-v2.json"); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).expect("fixture")).expect("JSON"); + let text = |name: &str| document[name].as_str().expect("artifact").as_bytes(); + Self { + root: QualificationTrustRoot::from_canonical_json(text("trust_root")).expect("root"), + certificate: QualificationSignerCertificate::from_canonical_json(text( + "signer_certificate", + )) + .expect("certificate"), + list: QualificationRevocationList::from_canonical_json(text("revocation_list")) + .expect("list"), + permit: QualificationCommissioningPermit::from_canonical_json(text("permit")) + .expect("permit"), + } + } + + fn authority(&self) -> VerifiedCommissioningPermit { + VerifiedCommissioningPermit::verify( + &self.root, + &CommissioningInputs { + signer_certificate: self.certificate.canonical_bytes(), + revocation_list: self.list.canonical_bytes(), + permit: self.permit.canonical_bytes(), + }, + ) + .expect("synthetic authority") + } + + fn binding(&self) -> &CommissioningBinding { + &self.permit.body().statement.binding + } + + fn now(&self) -> u64 { + self.permit.body().statement.not_before + } + + fn request(&self) -> CommissioningRequest<'_> { + let binding = self.binding(); + CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &binding.tuple, + protected_run: &binding.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: binding.trusted_contexts_sha256[0], + resources_sha256: binding.resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + } + } + + fn root_signer(&self) -> RootSigner { + RootSigner::open( + &SigningSeed::from_bytes(Zeroizing::new([0x11; 32])), + self.root.clone(), + ) + .expect("public test root") + } +} + +fn budget(store: &TestAttempts, fixture: &Fixture) -> CommissioningBudget { + CommissioningBudget::new(store.raw(), fixture.binding().clone()).expect("binding") +} + +fn consume( + budget: &CommissioningBudget, + fixture: &Fixture, + floor: &CommissioningBudgetSnapshot, +) -> CommissioningBudgetUpdate { + budget + .claim( + &fixture.authority(), + &fixture.request(), + fixture.now(), + true, + floor, + &VerifierState::default(), + ) + .expect("atomic claim") +} + +fn permanent_capacity_and_restart(backend: Backend) { + let store = TestAttempts::open(backend); + let fixture = Fixture::load(); + let budget = budget(&store, &fixture); + assert_eq!(budget.load(), Err(CommissioningBudgetRefusal::Missing)); + let initial = budget.initialize().expect("fresh setup"); + let claimed = consume(&budget, &fixture, &initial); + assert_eq!(claimed.refusal, None); + assert_eq!(claimed.snapshot.consumed_credential_leases(), 1); + // Simulate process loss immediately after the durable claim, before any + // credential acquisition. Reopening does not refund that consumed unit. + drop(budget); + let reopened = CommissioningBudget::new(store.reopen().store(), fixture.binding().clone()) + .expect("reopened budget"); + assert_eq!(reopened.load().expect("permanent record"), claimed.snapshot); + assert_eq!( + reopened.initialize().expect("idempotent setup"), + claimed.snapshot + ); + // A returned receipt is not reused to authorize a second lease: another + // claim always consumes another ordinal on the shared record. + let next = consume(&reopened, &fixture, &claimed.snapshot); + assert_eq!(next.snapshot.consumed_credential_leases(), 2); + let raw = store.raw(); + assert_eq!(raw.sweep_expired(u64::MAX / 2, 10).expect("sweep"), 0); + assert_eq!(reopened.load().expect("not swept"), next.snapshot); +} + +#[test] +fn capacity_survives_crash_restart_and_sweep_file() { + permanent_capacity_and_restart(Backend::File); +} + +#[test] +#[ignore = "requires TLS PostgreSQL fixture"] +fn capacity_survives_crash_restart_and_sweep_postgres() { + permanent_capacity_and_restart(Backend::Postgres); +} + +fn final_unit_race(backend: Backend) { + let store = TestAttempts::open(backend); + let fixture = Fixture::load(); + let budget = budget(&store, &fixture); + let mut floor = budget.initialize().expect("setup"); + for ordinal in 1..fixture.binding().maximum_credential_leases { + let claimed = consume(&budget, &fixture, &floor); + assert_eq!(claimed.refusal, None); + assert_eq!(claimed.snapshot.consumed_credential_leases(), ordinal); + floor = claimed.snapshot; + } + let barrier = Arc::new(Barrier::new(2)); + let handles: Vec<_> = (0..2) + .map(|context_index| { + // Distinct file handles or PostgreSQL pools model independent hosts. + let budget = CommissioningBudget::new(store.raw(), fixture.binding().clone()) + .expect("second host"); + let authority = fixture.authority(); + let binding = fixture.binding().clone(); + let floor = floor.clone(); + let barrier = Arc::clone(&barrier); + let now = fixture.now(); + std::thread::spawn(move || { + let request = CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &binding.tuple, + protected_run: &binding.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: binding.trusted_contexts_sha256[context_index], + resources_sha256: binding.resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + }; + barrier.wait(); + budget + .claim( + &authority, + &request, + now, + true, + &floor, + &VerifierState::default(), + ) + .expect("raced claim") + }) + }) + .collect(); + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("host")) + .collect(); + assert_eq!( + results + .iter() + .filter(|result| result.refusal.is_none()) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.refusal == Some(CommissioningBudgetRefusal::Exhausted)) + .count(), + 1 + ); + assert_eq!( + budget + .load() + .expect("shared capacity") + .consumed_credential_leases(), + fixture.binding().maximum_credential_leases + ); +} + +#[test] +fn exactly_one_host_claims_the_final_unit_file() { + final_unit_race(Backend::File); +} + +#[test] +#[ignore = "requires TLS PostgreSQL fixture"] +fn exactly_one_host_claims_the_final_unit_postgres() { + final_unit_race(Backend::Postgres); +} + +fn renewal_binding_and_rollback(backend: Backend) { + let store = TestAttempts::open(backend); + let mut fixture = Fixture::load(); + let budget = budget(&store, &fixture); + let initial = budget.initialize().expect("setup"); + let first = consume(&budget, &fixture, &initial); + let mut renewed = fixture.permit.body().clone(); + renewed.statement.issued_at += 1; + renewed.statement.not_before += 1; + renewed.signature_b64 = SignatureB64::from_bytes( + &SigningKey::from_bytes(&[0x22; 32]) + .sign(&renewed.signing_preimage().expect("preimage")) + .to_bytes(), + ); + fixture.permit = + QualificationCommissioningPermit::from_body(&renewed).expect("renewed authority"); + let second = consume(&budget, &fixture, &first.snapshot); + assert_eq!(second.refusal, None); + assert_eq!(second.snapshot.consumed_credential_leases(), 2); + let mut changed = fixture.binding().clone(); + changed.maximum_credential_leases += 1; + let changed = CommissioningBudget::new(store.raw(), changed).expect("finite changed ceiling"); + assert_eq!( + changed.initialize(), + Err(CommissioningBudgetRefusal::BindingMismatch) + ); + assert_eq!( + changed.load(), + Err(CommissioningBudgetRefusal::BindingMismatch) + ); + let mut changed_contexts = fixture.binding().clone(); + changed_contexts + .trusted_contexts_sha256 + .push(Sha256Digest::from_bytes([0x70; 32])); + let changed_contexts = CommissioningBudget::new(store.raw(), changed_contexts) + .expect("bounded changed context set"); + assert_eq!( + changed_contexts.initialize(), + Err(CommissioningBudgetRefusal::BindingMismatch) + ); + assert_eq!( + budget + .load() + .expect("original budget") + .consumed_credential_leases(), + 2 + ); + // Inject a restored older database record while retaining the host's + // separately persisted accepted snapshot. Opening never repairs it. + let raw = store.raw(); + raw.replace( + GatewayRecordKind::CommissioningBudget, + &budget.key, + second.snapshot.canonical_bytes(), + initial.canonical_bytes(), + ) + .expect("restore old record"); + assert_eq!( + budget.claim( + &fixture.authority(), + &fixture.request(), + fixture.now(), + true, + &second.snapshot, + &VerifierState::default() + ), + Err(CommissioningBudgetRefusal::Rollback) + ); +} + +#[test] +fn renewal_keeps_consumption_and_a_retained_floor_detects_restore_file() { + renewal_binding_and_rollback(Backend::File); +} + +#[test] +#[ignore = "requires TLS PostgreSQL fixture"] +fn renewal_keeps_consumption_and_a_retained_floor_detects_restore_postgres() { + renewal_binding_and_rollback(Backend::Postgres); +} + +fn refusals_and_permanent_revocations(backend: Backend) { + let store = TestAttempts::open(backend); + let mut fixture = Fixture::load(); + let budget = budget(&store, &fixture); + let initial = budget.initialize().expect("setup"); + let mut request = fixture.request(); + request.canonical_action_sha256 = Sha256Digest::from_bytes([0xff; 32]); + let denied = budget + .claim( + &fixture.authority(), + &request, + fixture.now(), + true, + &initial, + &VerifierState::default(), + ) + .expect("denied update"); + assert_eq!( + denied.refusal, + Some(CommissioningBudgetRefusal::Permit( + CommissioningRefusal::BindingMismatch + )) + ); + assert_eq!(denied.snapshot.consumed_credential_leases(), 0); + let clock = budget + .claim( + &fixture.authority(), + &fixture.request(), + fixture.now(), + false, + &denied.snapshot, + &VerifierState::default(), + ) + .expect("clock update"); + assert_eq!( + clock.refusal, + Some(CommissioningBudgetRefusal::Permit( + CommissioningRefusal::ClockUntrusted + )) + ); + assert_eq!(clock.snapshot.consumed_credential_leases(), 0); + let root = fixture.root_signer(); + fixture.list = root + .revoke( + 2, + fixture.now() - 1, + fixture.now() + 3600, + vec![fixture.certificate.body().statement.signer_id.clone()], + vec![], + ) + .expect("revoked list"); + let revoked = consume(&budget, &fixture, &clock.snapshot); + assert_eq!( + revoked.refusal, + Some(CommissioningBudgetRefusal::Permit( + CommissioningRefusal::Revoked + )) + ); + assert_eq!(revoked.snapshot.consumed_credential_leases(), 0); + fixture.list = root + .revoke(3, fixture.now() - 1, fixture.now() + 3600, vec![], vec![]) + .expect("omitted list"); + let still_revoked = consume(&budget, &fixture, &revoked.snapshot); + assert_eq!(still_revoked.refusal, revoked.refusal); + assert_eq!(still_revoked.snapshot.consumed_credential_leases(), 0); + assert!( + still_revoked + .snapshot + .record + .revoked_signers + .contains(&fixture.certificate.body().statement.signer_id) + ); +} + +#[test] +fn hostile_inputs_consume_nothing_and_revocation_survives_omission_file() { + refusals_and_permanent_revocations(Backend::File); +} + +#[test] +#[ignore = "requires TLS PostgreSQL fixture"] +fn hostile_inputs_consume_nothing_and_revocation_survives_omission_postgres() { + refusals_and_permanent_revocations(Backend::Postgres); +} + +#[test] +fn snapshots_reject_noncanonical_unknown_oversized_and_impossible_state() { + let store = TestAttempts::open(Backend::File); + let fixture = Fixture::load(); + let snapshot = budget(&store, &fixture).initialize().expect("setup"); + let decode = + |bytes: &[u8]| CommissioningBudgetSnapshot::from_canonical_json(bytes, fixture.binding()); + let mut bytes = snapshot.canonical_bytes().to_vec(); + bytes.push(b'\n'); + assert_eq!(decode(&bytes), Err(CommissioningBudgetRefusal::Corrupt)); + assert_eq!( + decode(&vec![b' '; MAX_COMMISSIONING_BUDGET_BYTES + 1]), + Err(CommissioningBudgetRefusal::Corrupt) + ); + let mut document = serde_json::to_value(&snapshot.record).expect("record"); + document["unreviewed"] = serde_json::json!(true); + assert_eq!( + decode(&serde_json_canonicalizer::to_vec(&document).expect("canonical")), + Err(CommissioningBudgetRefusal::Corrupt) + ); + for (pointer, value) in [ + ( + "/schema", + serde_json::json!("auths.gateway-commissioning-budget/0"), + ), + ("/consumed_credential_leases", serde_json::json!(1025)), + ("/maximum_credential_leases", serde_json::json!(0)), + ("/accepted_revocation_sequence", serde_json::json!(u64::MAX)), + ] { + let mut document = serde_json::to_value(&snapshot.record).expect("record"); + *document.pointer_mut(pointer).expect("member") = value; + let bytes = serde_json::to_vec(&document).expect("JSON"); + assert_eq!( + decode(&bytes), + Err(CommissioningBudgetRefusal::Corrupt), + "{pointer}" + ); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn consumption_never_exceeds_the_ceiling_or_charges_a_refused_action( + inputs in prop::collection::vec((any::(), any::()), 0..64) + ) { + let store = TestAttempts::open(Backend::File); + let fixture = Fixture::load(); + let authority = fixture.authority(); + let budget = budget(&store, &fixture); + let mut floor = budget.initialize().expect("setup"); + let mut eligible = 0; + for (exact_action, trusted_clock) in inputs { + let mut request = fixture.request(); + if !exact_action { + request.canonical_action_sha256 = Sha256Digest::from_bytes([0xff; 32]); + } + let before = floor.consumed_credential_leases(); + let updated = budget.claim(&authority, &request, fixture.now(), trusted_clock, + &floor, &VerifierState::default()).expect("claim or refusal"); + if exact_action && trusted_clock { eligible += 1; } + prop_assert_eq!(updated.snapshot.consumed_credential_leases(), + eligible.min(fixture.binding().maximum_credential_leases)); + prop_assert!(updated.snapshot.consumed_credential_leases() >= before); + if !exact_action || !trusted_clock { + prop_assert!(updated.refusal.is_some()); + prop_assert_eq!(updated.snapshot.consumed_credential_leases(), before); + } + floor = updated.snapshot; + } + prop_assert_eq!(budget.load().expect("permanent record"), floor); + } +} diff --git a/product/runtime/auths-gateway/src/commissioning_floor.rs b/product/runtime/auths-gateway/src/commissioning_floor.rs new file mode 100644 index 000000000..460931865 --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_floor.rs @@ -0,0 +1,222 @@ +//! Host-retained commissioning witness, outside the database restore set. +//! +//! A host lock spans reading the prior floor, claiming shared capacity and +//! durably replacing the witness. No successful claim is returned until the +//! witness and its directory have been synchronized. A crash or failed write +//! can burn a unit; it cannot refund one. Runtime never initializes a floor. + +use crate::commissioning_budget::{ + CommissioningBudget, CommissioningBudgetRefusal, CommissioningBudgetSnapshot, + CommissioningBudgetUpdate, MAX_COMMISSIONING_BUDGET_BYTES, +}; +use auths_recipe_qualification::{ + CommissioningBinding, CommissioningRequest, VerifiedCommissioningPermit, VerifierState, +}; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + path::PathBuf, +}; + +/// One host's monotone witness for an exact commissioning run/family. +/// The authenticated operator supplies an already-private installation +/// directory, retained independently of database backups. This object grants +/// no application access or credential authority. +#[derive(Clone)] +pub struct CommissioningFloor { + directory: PathBuf, + binding: CommissioningBinding, + leaf: String, +} + +impl CommissioningFloor { + /// Names the floor using the renewal-stable run/family budget key. + /// + /// # Errors + /// Refuses invalid bindings. Does not create files or database state. + pub fn new( + directory: PathBuf, + binding: CommissioningBinding, + ) -> Result { + binding + .validate() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)?; + let leaf = format!( + "commissioning-{}", + binding + .budget_key() + .map_err(|_| CommissioningBudgetRefusal::BindingMismatch)? + .to_hex() + ); + Ok(Self { + directory, + binding, + leaf, + }) + } + + /// Fresh authenticated setup retains the registered database snapshot. + /// An existing witness is never reset or overwritten by older state. + /// + /// # Errors + /// Refuses inaccessible, unsafe, corrupt, substituted or decreasing state. + /// Runtime startup and permit renewal must never use this initializer. + #[cfg(unix)] + pub fn initialize( + &self, + registered: &CommissioningBudgetSnapshot, + ) -> Result<(), CommissioningBudgetRefusal> { + let _lock = self.lock()?; + CommissioningBudgetSnapshot::from_canonical_json( + registered.canonical_bytes(), + &self.binding, + )?; + match self.read() { + Ok(previous) if !registered.not_below(&previous) => { + return Err(CommissioningBudgetRefusal::Rollback); + } + Ok(_) | Err(CommissioningBudgetRefusal::Missing) => {} + Err(error) => return Err(error), + } + self.persist(registered) + } + + /// Loads an existing host witness under its private lock. + /// + /// # Errors + /// Missing or unsafe witnesses refuse; no state is recreated. + #[cfg(unix)] + pub fn load(&self) -> Result { + let _lock = self.lock()?; + self.read() + } + + /// Claims shared capacity and durably retains the result before returning. + /// The private operator session must inspect `refusal` before custody. + /// Denial still remembers authenticated revocations. The host lock makes + /// concurrent submissions unable to overwrite a newer local witness. + /// + /// # Errors + /// Returns a typed refusal without authorizing custody. A failed witness + /// write after the database commit leaves its unit consumed permanently. + #[cfg(unix)] + pub fn claim( + &self, + budget: &CommissioningBudget, + authority: &VerifiedCommissioningPermit, + request: &CommissioningRequest<'_>, + now: u64, + clock_trusted: bool, + state: &VerifierState, + ) -> Result { + let _lock = self.lock()?; + let previous = self.read().map_err(|error| match error { + CommissioningBudgetRefusal::Missing => CommissioningBudgetRefusal::Rollback, + other => other, + })?; + let update = budget.claim(authority, request, now, clock_trusted, &previous, state)?; + self.persist(update.snapshot())?; + Ok(update) + } + + #[cfg(unix)] + fn lock(&self) -> Result { + let directory = fs::symlink_metadata(&self.directory) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + if !directory.is_dir() + || directory.permissions().mode() & 0o077 != 0 + || directory.uid() != rustix::process::geteuid().as_raw() + { + return Err(CommissioningBudgetRefusal::Rollback); + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(nofollow()) + .open(self.directory.join(format!("{}.lock", self.leaf))) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + let metadata = file + .metadata() + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + if !safe_file(&metadata) { + return Err(CommissioningBudgetRefusal::Rollback); + } + rustix::fs::flock(&file, rustix::fs::FlockOperation::LockExclusive) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + Ok(file) + } + + #[cfg(unix)] + fn read(&self) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(nofollow()) + .open(self.directory.join(format!("{}.json", self.leaf))) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + CommissioningBudgetRefusal::Missing + } else { + CommissioningBudgetRefusal::Rollback + } + })?; + let metadata = file + .metadata() + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + if !safe_file(&metadata) || metadata.len() > MAX_COMMISSIONING_BUDGET_BYTES as u64 { + return Err(CommissioningBudgetRefusal::Rollback); + } + let mut bytes = Vec::new(); + file.take(MAX_COMMISSIONING_BUDGET_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + CommissioningBudgetSnapshot::from_canonical_json(&bytes, &self.binding) + } + + #[cfg(unix)] + fn persist( + &self, + snapshot: &CommissioningBudgetSnapshot, + ) -> Result<(), CommissioningBudgetRefusal> { + let mut pending = tempfile::NamedTempFile::new_in(&self.directory) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + pending + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + pending + .write_all(snapshot.canonical_bytes()) + .and_then(|()| pending.as_file().sync_all()) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + pending + .persist(self.directory.join(format!("{}.json", self.leaf))) + .map_err(|_| CommissioningBudgetRefusal::Rollback)?; + File::open(&self.directory) + .and_then(|directory| directory.sync_all()) + .map_err(|_| CommissioningBudgetRefusal::Rollback) + } +} + +#[cfg(unix)] +fn nofollow() -> i32 { + i32::from_ne_bytes(rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes()) +} + +#[cfg(unix)] +#[allow( + clippy::verbose_bit_mask, + reason = "octal permission bits name the exact forbidden Unix access" +)] +fn safe_file(metadata: &fs::Metadata) -> bool { + metadata.is_file() + && metadata.permissions().mode() & 0o077 == 0 + && metadata.uid() == rustix::process::geteuid().as_raw() + && metadata.nlink() == 1 +} + +#[cfg(all(test, unix))] +mod tests; diff --git a/product/runtime/auths-gateway/src/commissioning_floor/tests.rs b/product/runtime/auths-gateway/src/commissioning_floor/tests.rs new file mode 100644 index 000000000..218790654 --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_floor/tests.rs @@ -0,0 +1,264 @@ +use super::*; +use crate::store_testkit::{Backend, TestAttempts}; +use auths_recipe_qualification::{CommissioningInputs, QualificationTrustRoot}; +use std::sync::{Arc, Barrier}; + +fn fixture() -> VerifiedCommissioningPermit { + let document: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../../../../bindings/fixtures/qualification/commissioning-v2.json" + )) + .expect("public synthetic fixture"); + let text = |name: &str| document[name].as_str().expect("artifact").as_bytes(); + VerifiedCommissioningPermit::verify( + &QualificationTrustRoot::from_canonical_json(text("trust_root")).expect("root"), + &CommissioningInputs { + signer_certificate: text("signer_certificate"), + revocation_list: text("revocation_list"), + permit: text("permit"), + }, + ) + .expect("synthetic authority") +} + +fn request(binding: &CommissioningBinding) -> CommissioningRequest<'_> { + CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &binding.tuple, + protected_run: &binding.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: binding.trusted_contexts_sha256[0], + resources_sha256: binding.resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + } +} + +fn setup() -> ( + TestAttempts, + tempfile::TempDir, + VerifiedCommissioningPermit, + CommissioningBudget, + CommissioningFloor, +) { + let store = TestAttempts::open(Backend::File); + let directory = tempfile::tempdir().expect("host floor"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).expect("private"); + let authority = fixture(); + let binding = authority.permit().body().statement.binding.clone(); + let budget = CommissioningBudget::new(store.raw(), binding.clone()).expect("budget"); + let floor = CommissioningFloor::new(directory.path().to_owned(), binding).expect("floor"); + (store, directory, authority, budget, floor) +} + +fn claim( + floor: &CommissioningFloor, + budget: &CommissioningBudget, + authority: &VerifiedCommissioningPermit, +) -> Result { + let statement = &authority.permit().body().statement; + floor.claim( + budget, + authority, + &request(&statement.binding), + statement.not_before, + true, + &VerifierState::default(), + ) +} + +#[test] +fn runtime_never_creates_a_missing_floor() { + let (_store, _directory, authority, budget, floor) = setup(); + budget.initialize().expect("registered"); + assert_eq!( + claim(&floor, &budget, &authority), + Err(CommissioningBudgetRefusal::Rollback) + ); + assert_eq!( + budget + .load() + .expect("database") + .consumed_credential_leases(), + 0 + ); + assert_eq!(floor.load(), Err(CommissioningBudgetRefusal::Missing)); +} + +#[test] +fn committed_floor_survives_restart_and_cannot_be_reset() { + let (_store, directory, authority, budget, floor) = setup(); + let initial = budget.initialize().expect("registered"); + floor.initialize(&initial).expect("fresh floor"); + assert_eq!( + claim(&floor, &budget, &authority).expect("claim").refusal(), + None + ); + let reopened = CommissioningFloor::new( + directory.path().to_owned(), + authority.permit().body().statement.binding.clone(), + ) + .expect("reopen"); + assert_eq!( + reopened + .load() + .expect("retained") + .consumed_credential_leases(), + 1 + ); + assert_eq!( + reopened.initialize(&initial), + Err(CommissioningBudgetRefusal::Rollback) + ); + assert_eq!( + claim(&reopened, &budget, &authority) + .expect("second") + .refusal(), + None + ); + assert_eq!( + reopened.load().expect("floor").consumed_credential_leases(), + 2 + ); +} + +#[test] +fn concurrent_local_claims_never_decrease_the_witness() { + let (_store, _directory, authority, budget, floor) = setup(); + floor + .initialize(&budget.initialize().expect("registration")) + .expect("floor"); + let budget = Arc::new(budget); + let start = Arc::new(Barrier::new(9)); + let workers: Vec<_> = (0..8) + .map(|_| { + let budget = budget.clone(); + let floor = floor.clone(); + let authority = authority.clone(); + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + claim(&floor, &budget, &authority) + }) + }) + .collect(); + start.wait(); + for worker in workers { + assert_eq!( + worker.join().expect("worker").expect("persisted").refusal(), + None + ); + } + assert_eq!(floor.load().expect("floor").consumed_credential_leases(), 8); + assert_eq!( + budget + .load() + .expect("database") + .consumed_credential_leases(), + 8 + ); +} + +#[test] +fn unsafe_witness_paths_refuse_before_capacity_claim() { + for fault in ["symlink", "hardlink", "public", "oversized", "malformed"] { + let (_store, directory, authority, budget, floor) = setup(); + floor + .initialize(&budget.initialize().expect("registration")) + .expect("floor"); + let path = directory.path().join(format!("{}.json", floor.leaf)); + match fault { + "symlink" => { + let target = directory.path().join("retained.json"); + fs::rename(&path, &target).expect("move"); + std::os::unix::fs::symlink(target, &path).expect("symlink"); + } + "hardlink" => { + fs::hard_link(&path, directory.path().join("alias.json")).expect("link"); + } + "public" => { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("mode"); + } + "oversized" => { + fs::write(&path, vec![b' '; MAX_COMMISSIONING_BUDGET_BYTES + 1]).expect("write"); + } + "malformed" => { + fs::write(&path, b"{}").expect("write"); + } + _ => unreachable!(), + } + assert!(claim(&floor, &budget, &authority).is_err(), "{fault}"); + assert_eq!( + budget + .load() + .expect("database") + .consumed_credential_leases(), + 0 + ); + } +} + +#[test] +fn database_restore_below_retained_witness_is_refused() { + let (store, _directory, authority, budget, floor) = setup(); + let initial = budget.initialize().expect("registration"); + floor.initialize(&initial).expect("floor"); + claim(&floor, &budget, &authority).expect("first claim"); + let latest = budget.load().expect("database"); + let key = crate::GatewayAttemptKey::from_bytes( + *authority + .permit() + .body() + .statement + .binding + .budget_key() + .expect("key") + .as_bytes(), + ); + store + .raw() + .replace( + crate::GatewayRecordKind::CommissioningBudget, + &key, + latest.canonical_bytes(), + initial.canonical_bytes(), + ) + .expect("simulate restore"); + assert_eq!( + claim(&floor, &budget, &authority), + Err(CommissioningBudgetRefusal::Rollback) + ); + assert_eq!( + floor.load().expect("retained").consumed_credential_leases(), + 1 + ); +} + +#[test] +fn database_claim_without_witness_acknowledgement_stays_spent() { + let (_store, _directory, authority, budget, floor) = setup(); + let initial = budget.initialize().expect("registration"); + floor.initialize(&initial).expect("floor"); + // Process loss after the shared commit and before the host witness write. + let statement = &authority.permit().body().statement; + budget + .claim( + &authority, + &request(&statement.binding), + statement.not_before, + true, + &initial, + &VerifierState::default(), + ) + .expect("database commit"); + assert_eq!( + floor + .load() + .expect("old witness") + .consumed_credential_leases(), + 0 + ); + claim(&floor, &budget, &authority).expect("restart claim"); + assert_eq!( + floor.load().expect("retained").consumed_credential_leases(), + 2 + ); +} diff --git a/product/runtime/auths-gateway/src/commissioning_session.rs b/product/runtime/auths-gateway/src/commissioning_session.rs new file mode 100644 index 000000000..9e0d02aa7 --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_session.rs @@ -0,0 +1,296 @@ +//! Authenticated operator-only commissioning of the exact shipped driver. +//! Ordinary `GatewayEngine::submit` never constructs or selects this session. + +use super::{EngineIo, GatewayEngine, GatewaySubmitResult, VerifiedCommand}; +use crate::commissioning_budget::{CommissioningBudget, CommissioningBudgetRefusal}; +use crate::commissioning_floor::CommissioningFloor; +use crate::submit::{self, SubmitContext}; +use auths_model::PrincipalId; +use auths_recipe_qualification::{ + BoundedText, CommissioningInputs, CommissioningRefusal, CommissioningRequest, + LifecycleStoreKind, QualificationTuple, Sha256Digest, VerifiedCommissioningPermit, +}; +use sha2::{Digest as _, Sha256}; +use std::{ + path::Path, + sync::{Arc, OnceLock}, + time::Instant, +}; + +/// Independent operator inputs. The installed root, target and clock come +/// from the engine's required qualification gate, never from these inputs. +pub struct CommissioningSessionInputs<'a> { + /// Closed permit, purpose certificate and authenticated revocation list. + pub artifacts: CommissioningInputs<'a>, + /// Signed operator attestation for this exact production installation. + pub operator_attestation: &'a [u8], + /// Run/attempt identity established by the protected operator workflow. + pub protected_run: &'a BoundedText<256>, + /// Exact reviewed resource file, SHA-256 bound by the permit; at most 64 KiB. + pub resources: &'a [u8], + /// Private host directory retained outside database restores. + pub floor_directory: &'a Path, +} + +/// SHA-256 of the normalized principal identifier's UTF-8 bytes. +/// Only actors sealed by native verification are compared at runtime. +#[must_use] +pub fn commissioning_principal_sha256(principal: &PrincipalId) -> Sha256Digest { + Sha256Digest::from_bytes(Sha256::digest(principal.as_str().as_bytes()).into()) +} + +/// A sealed operator capability tied to one engine and reviewed protected run. +/// It grants no qualification verdict, readiness state or ordinary app access. +/// Every submission uses native proof verification and the same ordered driver. +pub struct CommissioningSession<'a> { + engine: &'a GatewayEngine, + authority: VerifiedCommissioningPermit, + tuple: QualificationTuple, + protected_run: BoundedText<256>, + resources_sha256: Sha256Digest, + budget: Arc, + floor: CommissioningFloor, +} + +/// Obtained only from exact authorized branches and the closed verified request. +pub(super) struct CommissionedAction { + principal_sha256: Sha256Digest, + canonical_action_sha256: Sha256Digest, +} + +impl GatewayEngine { + /// Authenticates a private commissioning session against installed trust. + /// This does not initialize capacity or alter the ordinary application gate. + /// The caller must be the isolated operator process, not an application. + /// + /// # Errors + /// Refuses missing production identity, invalid operator/permit signatures, + /// time/revocation faults and every changed installed/run/resource binding. + pub fn commissioning_session( + &self, + inputs: &CommissioningSessionInputs<'_>, + ) -> Result, &'static str> { + let (root, tuple) = self + .qualification + .commissioning_target() + .ok_or("gateway.commissioning.unavailable")?; + if tuple.target.store_kind != LifecycleStoreKind::PostgresqlV1 + || tuple.target.store_schema.as_str() != crate::POSTGRES_STORE_SCHEMA + || !tuple.target.credential_store_kind.is_production() + || tuple.compiled_recipe_sha256.as_bytes() != self.recipe.digest() + || inputs.resources.is_empty() + || inputs.resources.len() > 64 * 1024 + { + return Err("gateway.commissioning.binding-mismatch"); + } + let (now, trusted) = self.qualification.commissioning_time(); + if !trusted { + return Err("gateway.commissioning.clock-untrusted"); + } + let expected = crate::OperatorInstallation { + recipe_digest: self.recipe.digest_hex(), + profile_lock_sha256: tuple.profile_lock_sha256.to_hex(), + trusted_context_sha256: self.trusted_context_sha256.to_hex(), + provider: self.connection.provider().as_str().to_owned(), + alias: self.connection.alias().as_str().to_owned(), + deployment: "production".to_owned(), + }; + let operator = + crate::verify_operator_attestation(inputs.operator_attestation, &expected, now) + .map_err(crate::OperatorAttestationError::code)?; + self.check_principal_separation(Some(&operator)) + .map_err(crate::PrincipalSeparationError::code)?; + let authority = VerifiedCommissioningPermit::verify(root, &inputs.artifacts) + .map_err(|_| "gateway.commissioning.unavailable")?; + self.qualification.remember_commissioning(&authority); + let binding = &authority.permit().body().statement.binding; + let resources_sha256 = Sha256Digest::from_bytes(Sha256::digest(inputs.resources).into()); + // The signature binds the candidate source revision to the executable + // digest independently derived by the installation's tuple. There is no + // caller-supplied source-commit override or runtime build-mode switch. + let request = CommissioningRequest { + source_commit: &binding.source_commit, + tuple, + protected_run: inputs.protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256: self.trusted_context_sha256, + resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + }; + authority + .evaluate(&request, now, trusted, &self.qualification.verifier_state()) + .map_err(permit_code)?; + let budget = Arc::new( + CommissioningBudget::new(self.attempts.store(), binding.clone()) + .map_err(CommissioningBudgetRefusal::code)?, + ); + let floor = CommissioningFloor::new(inputs.floor_directory.to_owned(), binding.clone()) + .map_err(CommissioningBudgetRefusal::code)?; + Ok(CommissioningSession { + engine: self, + tuple: tuple.clone(), + protected_run: inputs.protected_run.clone(), + resources_sha256, + authority, + budget, + floor, + }) + } +} + +impl CommissioningSession<'_> { + /// Fresh authenticated setup registers shared capacity once and retains its + /// floor before returning. Existing consumption/trust is never reset. + /// Startup and renewal use `submit`, which never initializes missing state. + /// + /// # Errors + /// Refuses unavailable, changed, rolled-back or unsafe permanent state. + pub async fn initialize_budget(&self) -> Result<(), &'static str> { + let budget = self.budget.clone(); + let floor = self.floor.clone(); + let authority = self.authority.clone(); + let state = self.engine.qualification.verifier_state(); + let tuple = self.tuple.clone(); + let protected_run = self.protected_run.clone(); + let resources_sha256 = self.resources_sha256; + let trusted_context_sha256 = self.engine.trusted_context_sha256; + let (now, _) = self.engine.qualification.commissioning_time(); + tokio::task::spawn_blocking(move || { + floor.initialize(&budget.initialize()?)?; + let binding = &authority.permit().body().statement.binding; + let request = CommissioningRequest { + source_commit: &binding.source_commit, + tuple: &tuple, + protected_run: &protected_run, + principal_sha256: binding.principal_sha256, + trusted_context_sha256, + resources_sha256, + canonical_action_sha256: binding.allowed_actions[0], + }; + // Setup records authenticated trust without permitting a lease. + // Deliberately withholding clock trust makes this a denied update + // with zero consumption; the shared record and retained floor + // nevertheless remember the root-signed list before acknowledgement. + floor.claim(&budget, &authority, &request, now, false, &state)?; + Ok(()) + }) + .await + .map_err(|_| "gateway.commissioning.store-unavailable")? + .map_err(CommissioningBudgetRefusal::code) + } + + /// Submits proof and canonical action as the authenticated operator. + /// No proof/request can select this method through an ordinary app socket. + /// The exact actor and action must match the finite signed permit; before + /// every custody acquisition an atomic lifetime unit and host floor commit. + pub async fn submit(&self, proof: &[u8], action: &[u8]) -> GatewaySubmitResult { + let io = EngineIo { + engine: self.engine, + proof, + action, + started: Instant::now(), + prepared: OnceLock::new(), + commissioning: Some(self), + commissioned_action: OnceLock::new(), + commissioning_refusal: OnceLock::new(), + }; + submit::run( + &SubmitContext { + recipe: &self.engine.recipe, + attempts: &self.engine.attempts, + context: &self.engine.trusted_context, + observer: self.engine.observer.as_ref(), + }, + &io, + ) + .await + } + + pub(super) fn bind_verified( + &self, + command: &VerifiedCommand, + ) -> Result { + let [actor] = command.actors.as_slice() else { + return Err("gateway.commissioning.binding-mismatch"); + }; + let action = CommissionedAction { + principal_sha256: commissioning_principal_sha256(actor), + canonical_action_sha256: Sha256Digest::from_bytes(*command.request.action_commitment()), + }; + self.check(&action)?; + Ok(action) + } + + pub(super) fn check(&self, action: &CommissionedAction) -> Result<(), &'static str> { + let (now, trusted) = self.engine.qualification.commissioning_time(); + self.authority + .evaluate( + &self.request(action), + now, + trusted, + &self.engine.qualification.verifier_state(), + ) + .map_err(permit_code) + } + + fn request<'a>(&'a self, action: &CommissionedAction) -> CommissioningRequest<'a> { + CommissioningRequest { + source_commit: &self + .authority + .permit() + .body() + .statement + .binding + .source_commit, + tuple: &self.tuple, + protected_run: &self.protected_run, + principal_sha256: action.principal_sha256, + trusted_context_sha256: self.engine.trusted_context_sha256, + resources_sha256: self.resources_sha256, + canonical_action_sha256: action.canonical_action_sha256, + } + } + + pub(super) async fn claim(&self, action: &CommissionedAction) -> Result<(), &'static str> { + let budget = self.budget.clone(); + let floor = self.floor.clone(); + let authority = self.authority.clone(); + let tuple = self.tuple.clone(); + let protected_run = self.protected_run.clone(); + let principal_sha256 = action.principal_sha256; + let canonical_action_sha256 = action.canonical_action_sha256; + let trusted_context_sha256 = self.engine.trusted_context_sha256; + let resources_sha256 = self.resources_sha256; + let state = self.engine.qualification.verifier_state(); + let (now, trusted) = self.engine.qualification.commissioning_time(); + let update = tokio::task::spawn_blocking(move || { + let request = CommissioningRequest { + source_commit: &authority.permit().body().statement.binding.source_commit, + tuple: &tuple, + protected_run: &protected_run, + principal_sha256, + trusted_context_sha256, + resources_sha256, + canonical_action_sha256, + }; + floor.claim(&budget, &authority, &request, now, trusted, &state) + }) + .await + .map_err(|_| "gateway.commissioning.store-unavailable")? + .map_err(CommissioningBudgetRefusal::code)?; + match update.refusal() { + Some(refusal) => Err(refusal.code()), + // A queued host lock or durable store write can outlast the permit + // window. Capacity stays spent, but custody requires fresh time. + None => self.check(action), + } + } +} + +fn permit_code(refusal: CommissioningRefusal) -> &'static str { + CommissioningBudgetRefusal::Permit(refusal).code() +} + +#[cfg(test)] +#[path = "commissioning_session/tests.rs"] +mod tests; diff --git a/product/runtime/auths-gateway/src/commissioning_session/tests.rs b/product/runtime/auths-gateway/src/commissioning_session/tests.rs new file mode 100644 index 000000000..0a27f81ac --- /dev/null +++ b/product/runtime/auths-gateway/src/commissioning_session/tests.rs @@ -0,0 +1,435 @@ +//! Synthetic authority over the installed engine I/O boundary, with real +//! native quorum proof verification and counted custody. No provider I/O, +//! production deployment or protected qualification is claimed here. + +use super::*; +use crate::engine::tests::administration::{Installation, installation}; +use crate::harness::Signer; +use crate::submit::SubmitIo as _; +use crate::{ + FixedClock, OperatorAttestation, OperatorEvidence, OperatorStatement, QualificationGate, + QualificationPolicy, +}; +use auths_recipe_qualification::{ + QualificationCommissioningPermit, QualificationTrustRoot, SignatureB64, VerifierState, +}; +use base64ct::{Base64UrlUnpadded, Encoding as _}; +use ed25519_dalek::{Signer as _, SigningKey}; +use serde_json::Value; +use std::{os::unix::fs::PermissionsExt as _, sync::atomic::Ordering}; + +const NOW: u64 = 1_790_000_000; + +#[tokio::test] +async fn offline_review_verifies_exact_actors_and_requests_without_custody() { + let setup = Setup::new().await; + let engine = setup.engine(); + let before = setup.leases(); + let reviewed = crate::review_submission( + &engine.recipe, + &engine.trusted_context, + NOW, + &setup.proof, + &setup.action, + ) + .expect("reviewed native proof"); + assert_eq!(reviewed.schema, "auths.gateway-submission-review/1"); + assert_eq!(reviewed.actors.len(), 1); + assert_eq!( + Sha256Digest::from_bytes(Sha256::digest(reviewed.actors[0].as_bytes()).into()), + setup.permit.body().statement.binding.principal_sha256 + ); + assert_eq!( + reviewed.action_commitment, + setup.permit.body().statement.binding.allowed_actions[0].to_hex() + ); + let serialized = serde_json::to_value(&reviewed).expect("public projection"); + assert_eq!(serialized["request"]["method"], "PATCH"); + assert!( + serialized["request"]["headers"] + .as_array() + .expect("headers") + .iter() + .all(|header| header[0] != "Authorization") + ); + let mut changed_action = setup.action.clone(); + changed_action.push(0); + let mut changed_proof = setup.proof.clone(); + let last = changed_proof.last_mut().expect("proof"); + *last ^= 1; + for (proof, action) in [ + (changed_proof.as_slice(), setup.action.as_slice()), + (setup.proof.as_slice(), changed_action.as_slice()), + (&[][..], setup.action.as_slice()), + ] { + assert!( + crate::review_submission(&engine.recipe, &engine.trusted_context, NOW, proof, action) + .is_err() + ); + } + assert_eq!(setup.leases(), before); +} + +struct Setup { + installation: Installation, + directory: tempfile::TempDir, + clock: Arc, + permit: QualificationCommissioningPermit, + certificate: Vec, + revocations: Vec, + attestation: Vec, + resources: Vec, + proof: Vec, + action: Vec, +} + +impl Setup { + #[allow( + clippy::too_many_lines, + reason = "one explicit fixture binds native proof, operator signature and permit to the same installed engine" + )] + async fn new() -> Self { + let mut installation = installation(8).await; + let document: Value = serde_json::from_slice(include_bytes!( + "../../../../../bindings/fixtures/gateway/approval-quorum.json" + )) + .expect("quorum fixture"); + let decode = |value: &Value| { + Base64UrlUnpadded::decode_vec(value.as_str().expect("base64")).expect("fixture bytes") + }; + let trust = decode(&document["trusted_context_b64"]); + let proof = decode(&document["cases"][0]["proof_b64"]); + let action = decode(&document["cases"][0]["action_b64"]); + let engine = &mut installation.first.engine; + engine.trusted_context = auths_codec::decode_verifier_context(&trust).expect("trust"); + engine.trusted_context_sha256 = Sha256Digest::from_bytes(Sha256::digest(&trust).into()); + let verified = super::super::verify_detailed( + &engine.recipe, + &engine.trusted_context, + NOW, + &proof, + &action, + ) + .expect("native fixture authorization"); + assert_eq!(verified.actors.len(), 1); + let artifacts: Value = serde_json::from_slice(include_bytes!( + "../../../../../bindings/fixtures/qualification/commissioning-v2.json" + )) + .expect("authority fixture"); + let text = |name: &str| artifacts[name].as_str().expect("artifact").as_bytes(); + let original = + QualificationCommissioningPermit::from_canonical_json(text("permit")).expect("permit"); + let mut body = original.body().clone(); + let resources = br#"{"schema":"synthetic-resources/1"}"#.to_vec(); + body.statement.binding.tuple.compiled_recipe_sha256 = + Sha256Digest::from_bytes(*engine.recipe.digest()); + body.statement.binding.tuple.target.store_schema = + BoundedText::parse(crate::POSTGRES_STORE_SCHEMA).expect("schema"); + body.statement.binding.trusted_contexts_sha256 = vec![engine.trusted_context_sha256]; + body.statement.binding.principal_sha256 = + commissioning_principal_sha256(&verified.actors[0]); + body.statement.binding.resources_sha256 = + Sha256Digest::from_bytes(Sha256::digest(&resources).into()); + body.statement.binding.allowed_actions = vec![Sha256Digest::from_bytes( + *verified.request.action_commitment(), + )]; + body.statement.binding.maximum_credential_leases = 1; + let permit = signed(body); + let clock = Arc::new(FixedClock::at(NOW)); + let root = QualificationTrustRoot::from_canonical_json(text("trust_root")).expect("root"); + engine.qualification = Arc::new(QualificationGate::new( + QualificationPolicy::Required, + Some(root), + Some(permit.body().statement.binding.tuple.clone()), + Box::new(clock.clone()), + VerifierState::default(), + )); + let operator = Signer::new(0x44); + let statement = OperatorStatement { + schema: crate::OPERATOR_ATTESTATION_SCHEMA.to_owned(), + operator_principal: operator.principal.as_str().to_owned(), + principal_method: "raw-key-v1".to_owned(), + verification_method: operator.principal.as_str().to_owned(), + signature_suite: "ed25519-v1".to_owned(), + installation: crate::OperatorInstallation { + recipe_digest: engine.recipe.digest_hex(), + profile_lock_sha256: permit + .body() + .statement + .binding + .tuple + .profile_lock_sha256 + .to_hex(), + trusted_context_sha256: engine.trusted_context_sha256.to_hex(), + provider: engine.connection.provider().as_str().to_owned(), + alias: engine.connection.alias().as_str().to_owned(), + deployment: "production".to_owned(), + }, + issued_at: NOW, + }; + let evidence = operator.evidence(); + let attestation = OperatorAttestation { + signature_b64: Base64UrlUnpadded::encode_string( + operator + .sign(&statement.preimage().expect("operator preimage")) + .as_slice(), + ), + statement, + evidence: vec![OperatorEvidence { + evidence_type: evidence.evidence_type().as_str().to_owned(), + media_type: evidence.media_type().as_str().to_owned(), + bytes_b64: Base64UrlUnpadded::encode_string(evidence.bytes()), + }], + }; + let directory = tempfile::tempdir().expect("retained witness"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .expect("private"); + Self { + installation, + directory, + clock, + permit, + certificate: text("signer_certificate").to_vec(), + revocations: text("revocation_list").to_vec(), + attestation: serde_json::to_vec(&attestation).expect("operator bytes"), + resources, + proof, + action, + } + } + + fn inputs(&self) -> CommissioningSessionInputs<'_> { + CommissioningSessionInputs { + artifacts: CommissioningInputs { + signer_certificate: &self.certificate, + revocation_list: &self.revocations, + permit: self.permit.canonical_bytes(), + }, + operator_attestation: &self.attestation, + protected_run: &self.permit.body().statement.binding.protected_run, + resources: &self.resources, + floor_directory: self.directory.path(), + } + } + + fn engine(&self) -> &GatewayEngine { + &self.installation.first.engine + } + fn leases(&self) -> u64 { + self.installation.first.leases.load(Ordering::SeqCst) + } +} + +fn signed( + mut body: auths_recipe_qualification::CommissioningPermitBody, +) -> QualificationCommissioningPermit { + // Public deterministic test key from the frozen commissioning corpus. + body.signature_b64 = SignatureB64::from_bytes( + &SigningKey::from_bytes(&[0x22; 32]) + .sign(&body.signing_preimage().expect("preimage")) + .to_bytes(), + ); + QualificationCommissioningPermit::from_body(&body).expect("signed synthetic permit") +} + +fn io<'a>(setup: &'a Setup, session: Option<&'a CommissioningSession<'a>>) -> EngineIo<'a> { + EngineIo { + engine: setup.engine(), + proof: &setup.proof, + action: &setup.action, + started: Instant::now(), + prepared: OnceLock::new(), + commissioning: session, + commissioned_action: OnceLock::new(), + commissioning_refusal: OnceLock::new(), + } +} + +#[tokio::test] +async fn ordinary_submission_cannot_select_commissioning_authority() { + let setup = Setup::new().await; + let session = setup + .engine() + .commissioning_session(&setup.inputs()) + .expect("operator"); + session.initialize_budget().await.expect("registered"); + let ordinary = io(&setup, None); + ordinary.verify(NOW).expect("native proof"); + assert_eq!( + ordinary.prepare().await, + Err("gateway.qualification.unavailable") + ); + assert!(ordinary.lease().await.is_none()); + assert_eq!(setup.leases(), 0); + assert_eq!(setup.engine().execution_witness().credential_lease_calls, 0); + assert_eq!( + session + .floor + .load() + .expect("floor") + .consumed_credential_leases(), + 0 + ); + assert!(!setup.engine().qualification().status().permits_lease()); +} + +#[tokio::test] +async fn exact_native_actor_and_action_require_a_durable_unit_before_custody() { + let setup = Setup::new().await; + let session = setup + .engine() + .commissioning_session(&setup.inputs()) + .expect("operator"); + session.initialize_budget().await.expect("registered"); + let commissioned = io(&setup, Some(&session)); + commissioned.verify(NOW).expect("exact native actor/action"); + commissioned + .prepare() + .await + .expect("same connection and transport"); + assert!(commissioned.lease().await.is_some()); + assert_eq!(setup.leases(), 1); + assert_eq!( + session + .floor + .load() + .expect("durable floor") + .consumed_credential_leases(), + 1 + ); + assert!( + commissioned.lease().await.is_none(), + "one-unit lifetime ceiling" + ); + assert_eq!( + commissioned.authority_refusal(), + Some("gateway.commissioning.exhausted") + ); + assert_eq!(setup.leases(), 1); + assert!(!setup.engine().qualification().status().permits_lease()); +} + +#[tokio::test] +async fn absent_registration_and_expiry_after_preparation_never_lease() { + for initialized in [false, true] { + let setup = Setup::new().await; + let session = setup + .engine() + .commissioning_session(&setup.inputs()) + .expect("operator"); + if initialized { + session.initialize_budget().await.expect("registered"); + } + let commissioned = io(&setup, Some(&session)); + commissioned.verify(NOW).expect("native proof"); + commissioned.prepare().await.expect("prepared"); + if initialized { + setup.clock.set(setup.permit.body().statement.not_after); + } + assert!(commissioned.lease().await.is_none()); + assert_eq!(setup.leases(), 0); + } +} + +#[tokio::test] +async fn another_signed_actor_or_action_is_refused_before_custody() { + for wrong_actor in [true, false] { + let mut setup = Setup::new().await; + let mut body = setup.permit.body().clone(); + if wrong_actor { + body.statement.binding.principal_sha256 = + commissioning_principal_sha256(&Signer::new(0x45).principal); + } else { + body.statement.binding.allowed_actions = vec![Sha256Digest::from_bytes([0x61; 32])]; + } + setup.permit = signed(body); + let session = setup + .engine() + .commissioning_session(&setup.inputs()) + .expect("operator"); + session.initialize_budget().await.expect("registered"); + let commissioned = io(&setup, Some(&session)); + assert_eq!( + commissioned.verify(NOW).map(|_| ()), + Err(super::super::not_entered( + "gateway.commissioning.binding-mismatch" + )) + ); + assert!(commissioned.lease().await.is_none()); + assert_eq!(setup.leases(), 0); + assert_eq!( + session + .floor + .load() + .expect("floor") + .consumed_credential_leases(), + 0 + ); + } +} + +#[tokio::test] +async fn changed_resources_run_operator_or_context_cannot_open_a_session() { + let setup = Setup::new().await; + let another_run = BoundedText::parse("another-protected-run:1").expect("run"); + for change in ["resources", "run", "operator", "context"] { + let mut inputs = setup.inputs(); + let mut changed = setup.permit.body().clone(); + changed.statement.binding.trusted_contexts_sha256 = + vec![Sha256Digest::from_bytes([0x61; 32])]; + let different_context = signed(changed); + match change { + "resources" => inputs.resources = b"different resources", + "run" => inputs.protected_run = &another_run, + "operator" => inputs.operator_attestation = b"{}", + "context" => inputs.artifacts.permit = different_context.canonical_bytes(), + _ => unreachable!(), + } + assert!( + setup.engine().commissioning_session(&inputs).is_err(), + "{change}" + ); + assert_eq!(setup.leases(), 0); + } +} + +#[tokio::test] +async fn execution_witness_measures_custody_calls_even_when_a_charged_call_fails() { + let setup = Setup::new().await; + let before = setup.engine().execution_witness(); + let session = setup + .engine() + .commissioning_session(&setup.inputs()) + .expect("operator"); + session.initialize_budget().await.expect("registered"); + let commissioned = io(&setup, Some(&session)); + commissioned.verify(NOW).expect("native exact actor"); + commissioned + .prepare() + .await + .expect("prepared with custody present"); + let record = commissioned.prepared.get().expect("entry").loaded.record(); + setup + .engine() + .credentials + .delete_connection(record.connection_id(), &[record.credential_generation()]) + .await + .expect("remove custody after preparation"); + assert!(commissioned.lease().await.is_none()); + assert_eq!(setup.leases(), 1, "the actual failing store call was made"); + let after = setup.engine().execution_witness(); + assert_eq!(after.scope, before.scope); + assert_eq!( + after.credential_lease_calls - before.credential_lease_calls, + 1 + ); + assert_eq!(after.write_transport_entries, 0); + assert_eq!(after.read_transport_entries, 0); + assert!( + commissioned.lease().await.is_none(), + "spent capacity cannot retry" + ); + assert_eq!(setup.engine().execution_witness(), after); + let restarted = Setup::new().await; + assert_ne!(restarted.engine().execution_witness().scope, after.scope); +} diff --git a/product/runtime/auths-gateway/src/engine.rs b/product/runtime/auths-gateway/src/engine.rs index f4e546bfa..50552c275 100644 --- a/product/runtime/auths-gateway/src/engine.rs +++ b/product/runtime/auths-gateway/src/engine.rs @@ -45,6 +45,14 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use thiserror::Error; +#[cfg(unix)] +#[path = "commissioning_session.rs"] +mod commissioning; +#[cfg(unix)] +pub use commissioning::{ + CommissioningSession, CommissioningSessionInputs, commissioning_principal_sha256, +}; + const MAX_PROOF_BYTES: usize = 4 * 1024 * 1024; const MAX_ACTION_BYTES: usize = 64 * 1024; const MAX_CONTEXT_BYTES: usize = 4 * 1024 * 1024; @@ -62,6 +70,9 @@ pub enum GatewayEngineConfigurationError { /// The installed recipe differs from the operator-approved digest. #[error("gateway recipe approval digest mismatch")] UnapprovedRecipe, + /// An independent process measurement scope could not be allocated. + #[error("gateway execution witness unavailable")] + WitnessUnavailable, } /// Closed, secret-free application result. A recorded response or matching @@ -106,6 +117,87 @@ pub struct GatewayEvidenceSummary { pub observed_at: u64, } +/// A verified submission's bounded, credential-free request projection for +/// offline operator review and differential qualification. It grants no +/// execution authority and records no claim, lease, provider entry or receipt. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct GatewaySubmissionReview { + /// Exactly `auths.gateway-submission-review/1`. + pub schema: &'static str, + /// Every exact actor whose action branch the native verifier authorized. + pub actors: Vec, + /// Native commitment to the verified canonical action bytes. + pub action_commitment: String, + /// Typed profile arguments decoded from the verified action. + pub arguments: Map, + /// The closed outbound request, before any credential is attached. + pub request: GatewayRequestReview, +} + +/// Public request facts used to compare an independently reviewed oracle. +/// No authorization header or provider response is included. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct GatewayRequestReview { + /// Closed HTTP method. + pub method: String, + /// Recipe-derived HTTPS URL. + pub url: String, + /// Recipe-derived body media type. + pub content_type: String, + /// Exact bounded UTF-8 JSON or form body. + pub body: String, + /// Ordered recipe-derived headers, without credential material. + pub headers: Vec<(String, String)>, + /// Declared derived idempotency key, if any. + pub idempotency_key: Option, +} + +/// Reviews proof and action through the same native verification and closed +/// request construction as submission, without opening custody or a store. +/// `now` is an offline evaluation input; this function grants no lease and +/// cannot replace the production deployment clock. +/// +/// # Errors +/// Returns the exact native/profile refusal for invalid or unauthorized input. +/// A successful review establishes only offline eligibility; lifecycle, +/// qualification, custody, guard, evidence and capacity checks still apply +/// when the operator submits through an installed gateway. +#[cfg(unix)] +pub fn review_submission( + recipe: &CompiledRecipe, + context: &TrustedContext, + now: u64, + proof: &[u8], + action: &[u8], +) -> Result { + let command = verify_detailed(recipe, context, now, proof, action)?; + let request = &command.request; + Ok(GatewaySubmissionReview { + schema: "auths.gateway-submission-review/1", + actors: command + .actors + .iter() + .map(|actor| actor.as_str().to_owned()) + .collect(), + action_commitment: hex::encode(request.action_commitment()), + arguments: command.arguments, + request: GatewayRequestReview { + method: request.method().as_str().to_owned(), + url: request.url().to_owned(), + content_type: request.content_type().to_owned(), + body: std::str::from_utf8(request.body()) + .map_err(|_| not_entered("gateway.action.projection"))? + .to_owned(), + headers: request + .headers() + .iter() + .map(|header| (header.name().to_owned(), header.value().to_owned())) + .collect(), + idempotency_key: request.idempotency_key().map(str::to_owned), + }, + }) +} + impl From<&GatewayProviderEvidence> for GatewayEvidenceSummary { fn from(evidence: &GatewayProviderEvidence) -> Self { Self { @@ -229,6 +321,8 @@ pub struct GatewayAdminStatus { pub struct GatewayEngine { recipe: CompiledRecipe, trusted_context: TrustedContext, + #[cfg(unix)] + trusted_context_sha256: auths_recipe_qualification::Sha256Digest, observer: Option, workload_id: String, profile: ConnectionProfile, @@ -240,6 +334,7 @@ pub struct GatewayEngine { qualification: Arc, attempts: GatewayAttempts, in_flight: AtomicU64, + execution_witness: Arc, /// The gateway clock of the last successful slot sweep; zero before one. last_sweep: AtomicU64, drain_limit: Duration, @@ -298,6 +393,13 @@ impl GatewayEngine { Ok(Self { recipe, trusted_context, + #[cfg(unix)] + trusted_context_sha256: { + use sha2::Digest as _; + auths_recipe_qualification::Sha256Digest::from_bytes( + sha2::Sha256::digest(trusted_context_cbor).into(), + ) + }, observer: None, workload_id, profile, @@ -307,6 +409,10 @@ impl GatewayEngine { qualification: Arc::new(crate::QualificationGate::unconfigured()), attempts, in_flight: AtomicU64::new(0), + execution_witness: Arc::new( + crate::execution_witness::ExecutionWitness::new() + .map_err(|_| GatewayEngineConfigurationError::WitnessUnavailable)?, + ), last_sweep: AtomicU64::new(0), drain_limit: DRAIN_LIMIT, retirement_delay: crate::CredentialRetirementDelay::FIXED.as_duration(), @@ -871,6 +977,14 @@ impl GatewayEngine { deleted.map(|()| outcome) } + /// Reads process-local execution counters without accessing custody, the + /// store, the network, or qualification authority. A snapshot is diagnostic + /// evidence only; it never confirms a provider effect. + #[must_use] + pub fn execution_witness(&self) -> crate::GatewayExecutionWitness { + self.execution_witness.snapshot() + } + /// Reads the shared record's state and this process's counts. Nothing is /// changed. /// @@ -974,6 +1088,12 @@ impl GatewayEngine { action: &[], started: Instant::now(), prepared: OnceLock::new(), + #[cfg(unix)] + commissioning: None, + #[cfg(unix)] + commissioned_action: OnceLock::new(), + #[cfg(unix)] + commissioning_refusal: OnceLock::new(), }; io.prepare().await?; let context = SubmitContext { @@ -1021,6 +1141,12 @@ impl GatewayEngine { action: action_cbor, started: Instant::now(), prepared: OnceLock::new(), + #[cfg(unix)] + commissioning: None, + #[cfg(unix)] + commissioned_action: OnceLock::new(), + #[cfg(unix)] + commissioning_refusal: OnceLock::new(), }; submit::run( &SubmitContext { @@ -1039,6 +1165,15 @@ impl GatewayEngine { /// hold the secret its credential generation names, and prepares the /// pinned transport. Nothing is stored. async fn prepare_entry(&self) -> Result { + self.prepare_entry_for(|| self.qualification.check()).await + } + + /// Both authority paths perform exactly the same connection and transport + /// checks; the ordinary path supplies only its qualification check. + async fn prepare_entry_for( + &self, + check_authority: impl FnOnce() -> Result<(), &'static str>, + ) -> Result { let loaded = match self.connection.load().await { Ok(Some(loaded)) => loaded, Err(SharedConnectionError::Rollback) => { @@ -1052,7 +1187,7 @@ impl GatewayEngine { } let descriptor = GatewayConnectionDescriptor::from_record(record, &self.recipe) .map_err(|_| "gateway.connection.recipe-mismatch")?; - self.qualification.check()?; + check_authority()?; if self.holds(record).await.is_err() { return Err("gateway.connection.credential-generation-missing"); } @@ -1063,11 +1198,17 @@ impl GatewayEngine { descriptor.credential(), port, ) - .map(|transport| PreparedEntry { loaded, transport }) + .map(|transport| PreparedEntry { + loaded, + transport: transport.with_witness(Arc::clone(&self.execution_witness)), + }) .map_err(|_| "gateway.transport.preparation"); } GatewayHttpTransport::prepare(&self.recipe, descriptor.credential()) - .map(|transport| PreparedEntry { loaded, transport }) + .map(|transport| PreparedEntry { + loaded, + transport: transport.with_witness(Arc::clone(&self.execution_witness)), + }) .map_err(|_| "gateway.transport.preparation") } @@ -1087,6 +1228,7 @@ impl GatewayEngine { // Time moved since the entry was prepared, so the gate is asked // again: nothing reaches the credential store unqualified. self.qualification.check().map_err(|_| ())?; + self.execution_witness.lease(); self.credentials .lease_secret( &prepared.loaded.record().credential_binding(), @@ -1194,23 +1336,41 @@ struct EngineIo<'a> { action: &'a [u8], started: Instant, prepared: OnceLock, + #[cfg(unix)] + commissioning: Option<&'a CommissioningSession<'a>>, + #[cfg(unix)] + commissioned_action: OnceLock, + #[cfg(unix)] + commissioning_refusal: OnceLock<&'static str>, } impl SubmitIo for EngineIo<'_> { type Lease = StoredSecretLease; fn clock(&self) -> Option { + #[cfg(unix)] + if self.commissioning.is_some() { + return Some(self.engine.qualification.commissioning_time().0); + } wall_clock_seconds() } fn verify(&self, now: u64) -> Result { - verify_detailed( + let verified = verify_detailed( &self.engine.recipe, &self.engine.trusted_context, now, self.proof, self.action, - ) + )?; + #[cfg(unix)] + if let Some(session) = self.commissioning { + let action = session.bind_verified(&verified).map_err(not_entered)?; + self.commissioned_action + .set(action) + .map_err(|_| not_entered("gateway.commissioning.binding-mismatch"))?; + } + Ok(verified) } fn bind_scope(&self, verified: &VerifiedCommand) -> Result<(), &'static str> { @@ -1222,6 +1382,20 @@ impl SubmitIo for EngineIo<'_> { } async fn prepare(&self) -> Result<(), &'static str> { + #[cfg(unix)] + let prepared = match self.commissioning { + Some(session) => { + let action = self + .commissioned_action + .get() + .ok_or("gateway.commissioning.binding-mismatch")?; + self.engine + .prepare_entry_for(|| session.check(action)) + .await? + } + None => self.engine.prepare_entry().await?, + }; + #[cfg(not(unix))] let prepared = self.engine.prepare_entry().await?; self.prepared .set(prepared) @@ -1229,6 +1403,18 @@ impl SubmitIo for EngineIo<'_> { } async fn reload(&self) -> bool { + #[cfg(unix)] + if let Some(session) = self.commissioning { + let checked = self + .commissioned_action + .get() + .ok_or("gateway.commissioning.binding-mismatch") + .and_then(|action| session.check(action)); + if let Err(code) = checked { + let _ = self.commissioning_refusal.set(code); + return false; + } + } match self.prepared.get() { Some(prepared) => self.engine.unchanged(prepared).await, None => false, @@ -1245,9 +1431,44 @@ impl SubmitIo for EngineIo<'_> { async fn lease(&self) -> Option { let prepared = self.prepared.get()?; + #[cfg(unix)] + if let Some(session) = self.commissioning { + if let Err(code) = session.claim(self.commissioned_action.get()?).await { + let _ = self.commissioning_refusal.set(code); + return None; + } + if !self.engine.unchanged(prepared).await { + return None; + } + if let Err(code) = session.check(self.commissioned_action.get()?) { + let _ = self.commissioning_refusal.set(code); + return None; + } + self.engine.execution_witness.lease(); + return self + .engine + .credentials + .lease_secret( + &prepared.loaded.record().credential_binding(), + Instant::now() + Duration::from_secs(30), + ) + .await + .ok(); + } self.engine.lease(prepared).await.ok() } + fn authority_refusal(&self) -> Option<&'static str> { + #[cfg(unix)] + { + self.commissioning_refusal.get().copied() + } + #[cfg(not(unix))] + { + None + } + } + fn secret_admitted(&self, lease: &StoredSecretLease, guard: &GuardChecks) -> bool { lease .expose(Instant::now()) @@ -1261,7 +1482,7 @@ impl SubmitIo for EngineIo<'_> { } fn within_entry_deadline(&self, evaluated_at: u64) -> bool { - wall_clock_seconds() + self.clock() .is_some_and(|now| now <= evaluated_at.saturating_add(ENTRY_DEADLINE_SECONDS)) && self.started.elapsed() <= Duration::from_secs(ENTRY_DEADLINE_SECONDS) } @@ -1381,6 +1602,9 @@ pub(crate) fn verify_command( /// need of its authorized branches. #[derive(Clone, Debug)] pub(crate) struct VerifiedCommand { + /// Exact actors of action IDs already sealed by the native verifier. + #[cfg(unix)] + pub(crate) actors: Vec, pub(crate) request: ClosedProviderRequest, /// The bounded branch's links and the counters its claim reserves. pub(crate) bound: Option, @@ -1461,6 +1685,8 @@ pub(crate) fn verify_detailed( .map_err(|_| not_entered("gateway.policy.proof-unavailable"))?; let observer_refusal = crate::separation::check_proof_observers(proof_cbor, action).err(); Ok(VerifiedCommand { + #[cfg(unix)] + actors: branches.actors, request, bound, approvers: branches.approvers, diff --git a/product/runtime/auths-gateway/src/execution_witness.rs b/product/runtime/auths-gateway/src/execution_witness.rs new file mode 100644 index 000000000..35be49b0c --- /dev/null +++ b/product/runtime/auths-gateway/src/execution_witness.rs @@ -0,0 +1,78 @@ +//! Secret-free measurements at custody and HTTP execution boundaries. +//! +//! These are process-local diagnostics, never authority or proof of a remote +//! effect. A runner must use snapshots from the same scope, wait for its calls +//! to finish, and independently read back every claimed provider effect. + +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// One process-local execution measurement, read through the private operator +/// channel. Counts include attempted calls that subsequently fail. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GatewayExecutionWitness { + /// Exactly `auths.gateway-execution-witness/1`. + pub schema: String, + /// Fresh random scope for this engine instance. Different scopes must + /// never be subtracted, including after a process restart. + pub scope: String, + /// Actual execution calls to the credential store's lease method. + pub credential_lease_calls: u64, + /// Closed write requests passed to the HTTP client's execution method. + /// This includes ambiguous failures, and does not prove remote receipt. + pub write_transport_entries: u64, + /// Read requests passed to the HTTP client's execution method, including + /// credential probes. These are not provider writes. + pub read_transport_entries: u64, +} + +pub(crate) struct ExecutionWitness { + scope: String, + leases: AtomicU64, + writes: AtomicU64, + reads: AtomicU64, +} + +impl ExecutionWitness { + pub(crate) fn new() -> Result { + let mut scope = [0; 16]; + getrandom::fill(&mut scope)?; + Ok(Self { + scope: hex::encode(scope), + leases: AtomicU64::new(0), + writes: AtomicU64::new(0), + reads: AtomicU64::new(0), + }) + } + + pub(crate) fn lease(&self) { + increment(&self.leases); + } + + pub(crate) fn write(&self) { + increment(&self.writes); + } + + pub(crate) fn read(&self) { + increment(&self.reads); + } + + pub(crate) fn snapshot(&self) -> GatewayExecutionWitness { + GatewayExecutionWitness { + schema: "auths.gateway-execution-witness/1".to_owned(), + scope: self.scope.clone(), + credential_lease_calls: self.leases.load(Ordering::SeqCst), + write_transport_entries: self.writes.load(Ordering::SeqCst), + read_transport_entries: self.reads.load(Ordering::SeqCst), + } + } +} + +fn increment(counter: &AtomicU64) { + // Saturation is explicit: an evidence consumer must refuse saturated + // snapshots. Wrapping could make a later snapshot appear to precede one. + let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| { + Some(value.saturating_add(1)) + }); +} diff --git a/product/runtime/auths-gateway/src/lib.rs b/product/runtime/auths-gateway/src/lib.rs index 094e32937..bba869b55 100644 --- a/product/runtime/auths-gateway/src/lib.rs +++ b/product/runtime/auths-gateway/src/lib.rs @@ -12,10 +12,20 @@ pub mod app; mod audit; mod binding; mod bounds; +pub mod commissioning_budget; +#[cfg(unix)] +pub mod commissioning_floor; +#[cfg(unix)] +pub use engine::{ + CommissioningSession, CommissioningSessionInputs, GatewayRequestReview, + GatewaySubmissionReview, commissioning_principal_sha256, review_submission, +}; mod connection; mod credential_journal; mod echo_verify; mod engine; +mod execution_witness; +pub use execution_witness::GatewayExecutionWitness; #[cfg(feature = "fuzzing")] pub mod fuzzing; mod generation_floor; @@ -52,6 +62,8 @@ mod quorum_tests; #[cfg(test)] mod scenario_tests; #[cfg(test)] +mod simulation_attestation; +#[cfg(test)] mod store_testkit; pub use audit::{ @@ -99,10 +111,10 @@ pub use operator::{ #[cfg(any(test, feature = "testkit-harness"))] pub use qualification::FixedClock; pub use qualification::{ - DeploymentClock, DeploymentFacts, DevelopmentClock, FILE_STORE_SCHEMA, POSTGRES_STORE_SCHEMA, - QUALIFICATION_POLICY_REFUSED, QualificationBundle, QualificationGate, QualificationPolicy, - QualificationStatus, SynchronizedHostClock, deployment_tuple, qualification_code, - qualification_policy, + DeploymentClock, DeploymentFacts, DevelopmentClock, FILE_STORE_SCHEMA, + PINNED_QUALIFICATION_ROOT, POSTGRES_STORE_SCHEMA, QUALIFICATION_POLICY_REFUSED, + QualificationBundle, QualificationGate, QualificationPolicy, QualificationStatus, + SynchronizedHostClock, deployment_tuple, qualification_code, qualification_policy, }; pub use readiness::{ ClockTrustState, CredentialRetirementDelay, ObserverCustodyState, PreconditionState, diff --git a/product/runtime/auths-gateway/src/pending_vectors/production.rs b/product/runtime/auths-gateway/src/pending_vectors/production.rs index 69fd3ba26..62632914b 100644 --- a/product/runtime/auths-gateway/src/pending_vectors/production.rs +++ b/product/runtime/auths-gateway/src/pending_vectors/production.rs @@ -63,6 +63,19 @@ const ROWS: &[&str] = &[ "gateway.qualification.unavailable qualification before-lease implemented 3 V:index-signature-forged", "gateway.qualification.revocation-stale qualification before-lease implemented 3 V:revocation-list-past-next-update", "gateway.qualification.clock-untrusted qualification before-lease implemented 3 V:clock-untrusted", + "gateway.commissioning.unavailable commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.revoked commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.revocation-rollback commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.clock-untrusted commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.revocation-stale commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.expired commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.binding-mismatch commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.store-unavailable commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.registration-missing commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.state-corrupt commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.restore-rollback commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.exhausted commissioning-budget before-custody implemented 5 -", + "gateway.commissioning.contention commissioning-budget before-custody implemented 5 -", "gateway.readiness.connection-disabled readiness doctor implemented 4 -", "gateway.readiness.trust-unavailable readiness doctor implemented 4 -", "gateway.readiness.store-unavailable readiness doctor implemented 4 -", diff --git a/product/runtime/auths-gateway/src/qualification.rs b/product/runtime/auths-gateway/src/qualification.rs index b5be98dcf..1a39a00b4 100644 --- a/product/runtime/auths-gateway/src/qualification.rs +++ b/product/runtime/auths-gateway/src/qualification.rs @@ -23,10 +23,16 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub const QUALIFICATION_POLICY_REFUSED: &str = "gateway.install.qualification-policy"; /// The schema identifier of the production lifecycle store. -pub const POSTGRES_STORE_SCHEMA: &str = "auths.lifecycle.postgresql/5"; +pub const POSTGRES_STORE_SCHEMA: &str = "auths.lifecycle.postgresql/6"; /// The schema identifier of the development file store. pub const FILE_STORE_SCHEMA: &str = "auths.gateway-attempt/3"; +/// The public qualification root this gateway build pins. A reviewed release +/// supplies only the offline ceremony's public artifact here. A root alone +/// qualifies nothing; certificates, fresh revocations, index and evidence must +/// still authenticate the exact installed candidate. +pub const PINNED_QUALIFICATION_ROOT: Option<&[u8]> = Some(br#"{"public_key_b64":"pVEllMJVwyOSnakYUAekqDemY0xzc21a9gSB7m4lBsc","root_id":"auths-qualification-root-2026-10","schema":"auths.qualification-trust-root/1","signature_suite":"ed25519-v1"}"#); + /// The stable code of one refusal. #[must_use] pub const fn qualification_code(refusal: QualificationRefusal) -> &'static str { @@ -439,6 +445,34 @@ impl QualificationGate { .clone() } + /// Private operator sessions reuse the configured root, target and clock; + /// application inputs cannot supply any of these values. + #[cfg(unix)] + pub(crate) fn commissioning_target( + &self, + ) -> Option<(&QualificationTrustRoot, &QualificationTuple)> { + if self.policy != QualificationPolicy::Required { + return None; + } + Some((self.root.as_ref()?, self.deployment.as_ref()?)) + } + + #[cfg(unix)] + pub(crate) fn commissioning_time(&self) -> (u64, bool) { + match self.clock.now() { + Some(now) => (now, self.clock.trust() == ClockTrustState::Trusted), + None => (0, false), + } + } + + #[cfg(unix)] + pub(crate) fn remember_commissioning( + &self, + permit: &auths_recipe_qualification::VerifiedCommissioningPermit, + ) { + permit.remember(&mut self.state.lock().unwrap_or_else(PoisonError::into_inner)); + } + fn verdict(&self) -> QualificationVerdict { let unavailable = QualificationVerdict { state: RecipeQualificationState::Unqualified, @@ -640,3 +674,93 @@ mod clock_tests { ); } } + +#[cfg(test)] +mod ceremony_tests { + use super::PINNED_QUALIFICATION_ROOT; + use auths_recipe_qualification::{ + QualificationArtifactKind, QualificationRevocationList, QualificationSignerCertificate, + QualificationTrustRoot, + }; + use ed25519_dalek::{Signature, VerifyingKey}; + use sha2::{Digest as _, Sha256}; + + #[test] + fn pinned_ceremony_authenticates_separate_signer_purposes() { + let root_bytes = + include_bytes!("../../../../qualification/trust/qualification-trust-root.json"); + assert_eq!(PINNED_QUALIFICATION_ROOT, Some(root_bytes.as_slice())); + let root = + QualificationTrustRoot::from_canonical_json(root_bytes).expect("canonical pinned root"); + let verification_key = VerifyingKey::from_bytes(&root.body().public_key_b64.to_bytes()) + .expect("root Ed25519 key"); + let commissioner_bytes = + include_bytes!("../../../../qualification/trust/commissioning-signer-certificate.json"); + let release_bytes = + include_bytes!("../../../../qualification/trust/signer-certificate.json"); + let commissioner = QualificationSignerCertificate::from_canonical_json(commissioner_bytes) + .expect("commissioning certificate"); + let release = QualificationSignerCertificate::from_canonical_json(release_bytes) + .expect("release certificate"); + for certificate in [&commissioner, &release] { + assert_eq!(certificate.body().statement.root_id, root.body().root_id); + verification_key + .verify_strict( + &certificate.body().signing_preimage().expect("preimage"), + &Signature::from_bytes(&certificate.body().root_signature_b64.to_bytes()), + ) + .expect("offline root signature"); + } + assert_ne!( + commissioner.body().statement.public_key_b64, + release.body().statement.public_key_b64 + ); + assert_eq!( + commissioner.body().statement.permitted_artifact_kinds, + vec![QualificationArtifactKind::QualificationCommissioningPermit] + ); + assert_eq!( + release.body().statement.permitted_artifact_kinds, + vec![ + QualificationArtifactKind::QualificationReleaseIndex, + QualificationArtifactKind::RecipeQualificationAttestation + ] + ); + let revocation_bytes = + include_bytes!("../../../../qualification/trust/revocation-list.json"); + let revocations = QualificationRevocationList::from_canonical_json(revocation_bytes) + .expect("root-signed list"); + assert_eq!(revocations.body().statement.root_id, root.body().root_id); + verification_key + .verify_strict( + &revocations.body().signing_preimage().expect("preimage"), + &Signature::from_bytes(&revocations.body().root_signature_b64.to_bytes()), + ) + .expect("revocation signature"); + let metadata: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../../../qualification/trust/ceremony.json" + )) + .expect("ceremony metadata"); + for (name, bytes) in [ + ("qualification-trust-root.json", root_bytes.as_slice()), + ( + "commissioning-signer-certificate.json", + commissioner_bytes.as_slice(), + ), + ("signer-certificate.json", release_bytes.as_slice()), + ("revocation-list.json", revocation_bytes.as_slice()), + ] { + assert_eq!( + metadata["public_artifacts"][name], + hex::encode(Sha256::digest(bytes)), + "{name}" + ); + } + assert_eq!( + metadata["assessment"], + "repository-owner-delegated-technical-assessment" + ); + assert_eq!(metadata["qualification_issued"], false); + assert_eq!(metadata["stable_launch_ready"], false); + } +} diff --git a/product/runtime/auths-gateway/src/qualification_simulation.rs b/product/runtime/auths-gateway/src/qualification_simulation.rs new file mode 100644 index 000000000..a31d90847 --- /dev/null +++ b/product/runtime/auths-gateway/src/qualification_simulation.rs @@ -0,0 +1,561 @@ +//! Executable, explicitly synthetic provider qualification rehearsals. +//! The independent request oracles below do not read the compiled recipe. +//! Submission enters at the verified-command boundary; native proof/socket +//! journeys are exercised separately by the installed SDK workflows. + +use super::*; +use serde::Serialize; + +const STRIPE_VERSION: &str = "2025-03-31.basil"; +const STRIPE_PAYMENT: &str = "pi_TEST0000000001"; + +fn stripe_setup() -> Value { + let corpus: Value = serde_json::from_slice(include_bytes!( + "../../../../bindings/fixtures/gateway/attempt-scenarios-v3.json" + )) + .expect("embedded Stripe fixture"); + let mut setup = corpus["defaults"]["stripe"].clone(); + // The platform-only profile has no connected-account argument or grant scope. + setup["grant_policy"] + .as_object_mut() + .expect("grant policy") + .remove("scope"); + setup +} + +#[derive(Clone, Copy)] +enum Family { + Stripe, + Airtable, +} + +impl Family { + fn command( + self, + recipe: &CompiledRecipe, + args: &Map, + commitment: [u8; 32], + ) -> VerifiedCommand { + self.admission(recipe, args, commitment).expect("admission") + } + + fn admission( + self, + recipe: &CompiledRecipe, + args: &Map, + commitment: [u8; 32], + ) -> Result { + let links = match self { + Self::Stripe => scenario_links(&stripe_setup()), + Self::Airtable => Vec::new(), + }; + admitted(recipe, args, commitment, Vec::new(), 3_600, links, NOW) + } + fn name(self) -> &'static str { + match self { + Self::Stripe => "stripe-platform-refund-v1", + Self::Airtable => "airtable-record-update-v1", + } + } + + fn read_back_url(self) -> &'static str { + match self { + Self::Stripe => "https://api.stripe.com/v1/refunds/re_TEST0000000001", + Self::Airtable => { + "https://api.airtable.com/v0/appTEST0000000001/tblTEST0000000001/recTEST0000000001" + } + } + } + + fn recipe(self) -> CompiledRecipe { + match self { + Self::Stripe => CompiledRecipe::compile( + include_bytes!( + "../../../../qualification/simulation/live/stripe-platform/recipe.json" + ), + include_bytes!( + "../../../../qualification/simulation/live/stripe-platform/profile.lock.json" + ), + ) + .expect("Stripe recipe"), + Self::Airtable => fixture_recipe("airtable"), + } + } + + fn arguments(self, operation: &str) -> Map { + match self { + Self::Stripe => json!({"operation_id": operation, "payment_intent": STRIPE_PAYMENT, + "amount": 500, "currency": "usd"}), + Self::Airtable => json!({"operation_id": operation, "record_id": RECORD, + "replacement": "Approved"}), + } + .as_object() + .expect("arguments") + .clone() + } + + /// Contract-owned wire oracle. Only admitted shared echo/idempotency + /// primitives are reused; mapping, encoding and resource IDs are fixed + /// by this independent vertical, never projected from candidate bytes. + fn oracle(self, operation: &str, commitment: &[u8; 32]) -> Value { + let namespace = crate::OperatorNamespace::parse(match self { + Self::Stripe => "stripe-platform-refunds", + Self::Airtable => "airtable-demo", + }) + .expect("namespace"); + let operation = LogicalOperationId::parse(operation).expect("operation"); + let echo = echo_token(&namespace, &operation, commitment); + match self { + Self::Stripe => { + let mut form = url::form_urlencoded::Serializer::new(String::new()); + form.append_pair("amount", "500"); + form.append_pair("metadata[auths_echo]", &echo); + form.append_pair("payment_intent", STRIPE_PAYMENT); + json!({"method": "POST", "url": "https://api.stripe.com/v1/refunds", + "content_type": "application/x-www-form-urlencoded", "body": form.finish(), + "headers": [["Stripe-Version", STRIPE_VERSION], + ["Idempotency-Key", crate::idempotency_key(&namespace, &operation)]], + "idempotency_key": crate::idempotency_key(&namespace, &operation)}) + } + Self::Airtable => json!({"method": "PATCH", + "url": format!("https://api.airtable.com/v0/appTEST0000000001/tblTEST0000000001/{RECORD}"), + "content_type": "application/json", + "body": serde_json_canonicalizer::to_string(&json!({"fields": { + "DemoStatus": "Approved", "auths_echo": echo}})).expect("oracle JSON"), + "headers": [], "idempotency_key": null}), + } + } +} + +fn request_facts(request: &ClosedProviderRequest) -> Value { + json!({"method": request.method().as_str(), "url": request.url(), + "content_type": request.content_type(), "body": std::str::from_utf8(request.body()).expect("body"), + "headers": request.headers().iter().map(|header| (header.name(), header.value())).collect::>(), + "idempotency_key": request.idempotency_key()}) +} + +/// The oracle checks the actual outbound request before the mutable provider +/// double applies it. Counting witnesses come from actual driver calls. +struct OracleProvider

{ + inner: P, + expected: Value, + checked: AtomicUsize, + read_back_url: &'static str, + read_backs: AtomicUsize, +} + +trait SimulationProvider: ProviderPort { + fn entries(&self) -> usize; +} + +impl SimulationProvider for TableProvider { + fn entries(&self) -> usize { + self.writes() + } +} + +impl SimulationProvider for RecordProvider { + fn entries(&self) -> usize { + self.writes() + } +} + +impl ProviderPort for OracleProvider

{ + async fn write( + &self, + request: &ClosedProviderRequest, + ) -> Result { + assert_eq!( + request_facts(request), + self.expected, + "independent wire oracle" + ); + self.checked.fetch_add(1, Ordering::SeqCst); + self.inner.write(request).await + } + + async fn action_read( + &self, + url: &str, + headers: &[RequestHeader], + maximum: usize, + ) -> Option { + if url == self.read_back_url { + self.read_backs.fetch_add(1, Ordering::SeqCst); + } else { + assert_eq!( + url, "https://api.stripe.com/v1/payment_intents/pi_TEST0000000001", + "only the independent ceiling read is admitted before the write" + ); + } + self.inner.action_read(url, headers, maximum).await + } + + async fn credential_read(&self, read: &ClosedCredentialRead) -> Option { + self.inner.credential_read(read).await + } +} + +#[derive(Serialize)] +struct Measurement { + case: String, + verdict: String, + credential_leases: usize, + provider_entries: usize, + confirmed_by_read_back: usize, +} + +fn measurement( + case: &str, + result: &GatewaySubmitResult, + leases: usize, + entries: usize, +) -> Measurement { + let verdict = match result { + GatewaySubmitResult::ObservedByProvider { .. } => "observed-by-provider".to_owned(), + GatewaySubmitResult::ResponseRecorded { .. } => "response-recorded".to_owned(), + GatewaySubmitResult::Unknown => "unknown".to_owned(), + GatewaySubmitResult::NotEntered { code } => code.clone(), + other => panic!("unexpected simulation result: {other:?}"), + }; + Measurement { + case: case.to_owned(), + verdict, + credential_leases: leases, + provider_entries: entries, + confirmed_by_read_back: usize::from(matches!( + result, + GatewaySubmitResult::ObservedByProvider { .. } + )), + } +} + +fn publish(family: Family, recipe: &CompiledRecipe, cases: &[Measurement]) { + let report = json!({"schema": "auths.recipe-qualification-simulation/1", + "simulation": true, "family": family.name(), "stable_launch_ready": false, + "compiled_recipe_sha256": recipe.digest_hex(), + "gateway_semantic_closure_sha256": crate::GATEWAY_SEMANTIC_CLOSURE_SHA256, + "store": "shared-file-v1", "custody": "test-only-counting-lease", + "clock": {"kind": "fixed-test-clock", "unix_seconds": NOW}, + "verification_boundary": "native-verified-command projection", + "provider": "in-process mutable double", "cases": cases, + "excluded_claims": ["live provider acceptance", "production qualification", + "production PostgreSQL/custody", "independent human onboarding"]}); + if let Some(directory) = std::env::var_os("AUTHS_QUALIFICATION_SIMULATION_OUTPUT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).expect("report directory"); + let bytes = serde_json::to_vec_pretty(&report).expect("report"); + let signature = crate::simulation_attestation::sign(&bytes, family.name()); + std::fs::write(directory.join(format!("{}.json", family.name())), bytes) + .expect("write report"); + std::fs::write( + directory.join(format!("{}.attestation.json", family.name())), + signature, + ) + .expect("write detached simulation signature"); + } +} + +/// Runs each lifecycle against real durable claims, with both original and +/// fresh proof commitments. A lost response or response-record crash must +/// use only recovery the recipe's derived capability permits: Airtable's +/// verified locator and echo can re-observe; Stripe needs its response locator. +async fn lifecycle( + family: Family, + provider: &OracleProvider

, + recording: &Arc, + store: &mut TestAttempts, + ambiguous: bool, +) -> Vec { + let recipe = family.recipe(); + let observer = GatewayObserver::from_test_seed(OBSERVER_SEED); + let args = family.arguments("qualification-1"); + let command = family.command(&recipe, &args, ORIGINAL); + let attempts = GatewayAttempts::new(recording.clone()); + let mut first = TestIo::new(provider, command.clone()); + first.recipe = Some(&recipe); + let result = run(&attempts, &recipe, &observer, &first).await; + assert_eq!(provider.checked.load(Ordering::SeqCst), 1); + if ambiguous { + assert!(matches!(result, GatewaySubmitResult::Unknown)); + } else { + assert!( + matches!(result, GatewaySubmitResult::ObservedByProvider { .. }), + "{result:?}" + ); + } + let mut cases = vec![measurement( + if ambiguous { + "ambiguous-write" + } else { + "exact-write-and-read-back" + }, + &result, + first.leases(), + provider.inner.entries(), + )]; + for (name, commitment) in [ + ("proof-replay", ORIGINAL), + ("fresh-challenge-replay", FRESH), + ("restart-replay", FRESH), + ] { + if name == "restart-replay" { + store.restart(); + } + let attempts = if name == "restart-replay" { + store.attempts() + } else { + &attempts + }; + let mut io = TestIo::new(provider, family.command(&recipe, &args, commitment)); + io.recipe = Some(&recipe); + let before = provider.checked.load(Ordering::SeqCst); + let entries_before = provider.inner.entries(); + let result = run(attempts, &recipe, &observer, &io).await; + let recovery = ambiguous && matches!(family, Family::Airtable) && commitment == ORIGINAL; + if recovery { + assert!( + matches!(result, GatewaySubmitResult::ObservedByProvider { .. }), + "{} {name} ambiguous={ambiguous}: {result:?}", + family.name() + ); + } else { + assert!(!matches!( + result, + GatewaySubmitResult::ObservedByProvider { .. } + )); + } + assert_eq!( + io.leases(), + usize::from(recovery), + "{name}: only declared read-back recovery leases" + ); + assert_eq!( + provider.checked.load(Ordering::SeqCst), + before, + "{name}: no second write" + ); + cases.push(measurement( + name, + &result, + io.leases(), + provider.inner.entries() - entries_before, + )); + } + cases +} + +async fn race(family: Family, provider: &OracleProvider

) -> Measurement { + let recipe = family.recipe(); + let observer = GatewayObserver::from_test_seed(OBSERVER_SEED); + let store = TestAttempts::open(Backend::File); + let other_store = store.reopen(); + let args = family.arguments("qualification-1"); + let mut first = TestIo::new(provider, family.command(&recipe, &args, ORIGINAL)); + let mut second = TestIo::new(provider, family.command(&recipe, &args, ORIGINAL)); + first.recipe = Some(&recipe); + second.recipe = Some(&recipe); + let (left, right) = tokio::join!( + run(store.attempts(), &recipe, &observer, &first), + run(&other_store, &recipe, &observer, &second) + ); + assert_eq!( + provider.checked.load(Ordering::SeqCst), + 1, + "two store handles: one write" + ); + let confirmations = [&left, &right] + .into_iter() + .filter(|result| matches!(result, GatewaySubmitResult::ObservedByProvider { .. })) + .count(); + assert!( + (1..=2).contains(&confirmations), + "the single write has fresh read-back" + ); + let leases = first.leases() + second.leases(); + // Either contender can reconcile once a locator is retained; Airtable + // can also recover its fixed locator while the first claim is in flight. + // Count actual read calls so an extra write lease cannot pass as recovery. + let reads = provider.read_backs.load(Ordering::SeqCst); + assert!((1..=2).contains(&reads), "bounded fresh read-back calls"); + assert_eq!( + leases, + 1 + reads, + "one write lease plus measured read leases" + ); + Measurement { + case: "two-instance-race".to_owned(), + verdict: "one-authorized-entry".to_owned(), + credential_leases: leases, + provider_entries: provider.inner.entries(), + confirmed_by_read_back: usize::from(confirmations > 0), + } +} + +async fn hostile( + family: Family, + provider: &OracleProvider

, +) -> Vec { + let recipe = family.recipe(); + let observer = GatewayObserver::from_test_seed(OBSERVER_SEED); + let store = TestAttempts::open(Backend::File); + let target = match family { + Family::Stripe => "payment_intent", + Family::Airtable => "record_id", + }; + let target_max = match family { + Family::Stripe => 255, + Family::Airtable => 43, + }; + let mut measured = Vec::new(); + for name in [ + "missing-target", + "overlong-target", + "wrong-target-type", + "invalid-value", + "unknown-field", + ] { + let mut args = family.arguments("hostile-1"); + match name { + "missing-target" => { + args.remove(target); + } + "overlong-target" => { + args.insert(target.into(), json!("a".repeat(target_max + 1))); + } + "wrong-target-type" => { + args.insert(target.into(), json!(true)); + } + "invalid-value" => match family { + Family::Stripe => { + args.insert("amount".into(), json!(100_000_000)); + } + Family::Airtable => { + args.insert("replacement".into(), json!("Rejected")); + } + }, + "unknown-field" => { + args.insert("provider_token".into(), json!("synthetic-untrusted-field")); + } + _ => unreachable!(), + } + let mut io = TestIo::answering(provider, family.admission(&recipe, &args, ORIGINAL)); + io.recipe = Some(&recipe); + let result = run(store.attempts(), &recipe, &observer, &io).await; + assert!( + matches!(result, GatewaySubmitResult::NotEntered { .. }), + "{name}: {result:?}" + ); + assert_eq!(io.leases(), 0, "{name}: refused before lease"); + assert_eq!( + provider.checked.load(Ordering::SeqCst), + 0, + "{name}: refused before provider" + ); + assert_eq!(provider.inner.entries(), 0, "{name}: provider witness"); + measured.push(measurement( + name, + &result, + io.leases(), + provider.inner.entries(), + )); + } + measured +} + +#[tokio::test] +async fn stripe_qualification_simulation() { + let family = Family::Stripe; + let recipe = family.recipe(); + let mut cases = Vec::new(); + let mut defaults = stripe_setup()["responses"].clone(); + defaults["GET /v1/balance"] = json!({"status": 200, + "headers": {"Stripe-Version": STRIPE_VERSION}, "body": {"livemode": false}}); + for mode in ["respond", "timeout-after-send", "crash-after-write"] { + let mut store = TestAttempts::open(Backend::File); + let recording = RecordingStore::new(store.raw()); + if mode == "crash-after-write" { + recording.lose_response_record.store(true, Ordering::SeqCst); + } + let provider = OracleProvider { + inner: TableProvider::new( + &recipe, + &defaults, + if mode == "timeout-after-send" { + mode + } else { + "respond" + }, + ), + expected: family.oracle("qualification-1", &ORIGINAL), + checked: AtomicUsize::new(0), + read_back_url: family.read_back_url(), + read_backs: AtomicUsize::new(0), + }; + let mut measured = + lifecycle(family, &provider, &recording, &mut store, mode != "respond").await; + assert_eq!(provider.inner.writes(), 1); + for case in &mut measured { + case.case = format!("{mode}/{}", case.case); + } + cases.extend(measured); + } + let provider = OracleProvider { + inner: TableProvider::new(&recipe, &defaults, "respond"), + expected: family.oracle("qualification-1", &ORIGINAL), + checked: AtomicUsize::new(0), + read_back_url: family.read_back_url(), + read_backs: AtomicUsize::new(0), + }; + cases.extend(hostile(family, &provider).await); + cases.push(race(family, &provider).await); + publish(family, &recipe, &cases); +} + +#[tokio::test] +async fn airtable_qualification_simulation() { + let family = Family::Airtable; + let recipe = family.recipe(); + let mut cases = Vec::new(); + for (label, delivery) in [ + ("respond", Delivery::Respond), + ("timeout-after-send", Delivery::TimeoutAfterApplying), + ("crash-after-write", Delivery::Respond), + ] { + let mut store = TestAttempts::open(Backend::File); + let recording = RecordingStore::new(store.raw()); + if label == "crash-after-write" { + recording.lose_response_record.store(true, Ordering::SeqCst); + } + let provider = OracleProvider { + inner: RecordProvider::new(delivery, Reading::Faithful), + expected: family.oracle("qualification-1", &ORIGINAL), + checked: AtomicUsize::new(0), + read_back_url: family.read_back_url(), + read_backs: AtomicUsize::new(0), + }; + let mut measured = lifecycle( + family, + &provider, + &recording, + &mut store, + label != "respond", + ) + .await; + assert_eq!(provider.inner.writes(), 1); + for case in &mut measured { + case.case = format!("{label}/{}", case.case); + } + cases.extend(measured); + } + let provider = OracleProvider { + inner: RecordProvider::new(Delivery::Respond, Reading::Faithful), + expected: family.oracle("qualification-1", &ORIGINAL), + checked: AtomicUsize::new(0), + read_back_url: family.read_back_url(), + read_backs: AtomicUsize::new(0), + }; + cases.extend(hostile(family, &provider).await); + cases.push(race(family, &provider).await); + publish(family, &recipe, &cases); +} diff --git a/product/runtime/auths-gateway/src/qualification_tests.rs b/product/runtime/auths-gateway/src/qualification_tests.rs index 09488f61c..193bd17fc 100644 --- a/product/runtime/auths-gateway/src/qualification_tests.rs +++ b/product/runtime/auths-gateway/src/qualification_tests.rs @@ -84,6 +84,10 @@ fn required( async fn host_with(gate: &Arc) -> (Installation, ()) { let mut installation = installation(8).await; + #[cfg(feature = "loopback-provider")] + { + installation.first.engine = installation.first.engine.with_loopback_provider(9); + } installation .first .engine @@ -113,6 +117,217 @@ async fn an_engine_without_a_configured_gate_leases_nothing() { assert_eq!(installation.first.leases.load(Ordering::SeqCst), 0); } +#[test] +fn verify_simulation_reports() { + use sha2::Digest as _; + use std::io::Read as _; + + let Some(directory) = std::env::var_os("AUTHS_QUALIFICATION_SIMULATION_OUTPUT") else { + return; + }; + let directory = std::path::PathBuf::from(directory); + let bounded = |path: &std::path::Path, maximum: u64| { + assert!( + std::fs::symlink_metadata(path) + .expect("metadata") + .file_type() + .is_file() + ); + let mut bytes = Vec::new(); + std::fs::File::open(path) + .expect("public file") + .take(maximum + 1) + .read_to_end(&mut bytes) + .expect("bounded public file"); + assert!(bytes.len() as u64 <= maximum); + bytes + }; + let mut verified = Vec::new(); + for family in ["stripe-platform-refund-v1", "airtable-record-update-v1"] { + let report = bounded(&directory.join(format!("{family}.json")), 1_048_576); + let signature = bounded(&directory.join(format!("{family}.attestation.json")), 4096); + assert!(crate::simulation_attestation::verify(&report, &signature)); + verified.push( + json!({"family": family, "report_sha256": hex::encode(sha2::Sha256::digest(&report)), + "signature_sha256": hex::encode(sha2::Sha256::digest(&signature))}), + ); + } + std::fs::write( + directory.join("provider-signature-verification.json"), + serde_json::to_vec_pretty( + &json!({"schema": "auths.provider-simulation-signature-verification/1", + "simulation": true, "stable_launch_ready": false, "verified": verified, + "scope": "detached self-signed simulation reports; no production trust"}), + ) + .expect("verification report"), + ) + .expect("write verification report"); +} + +/// Rehearses the first ceremony under fresh ephemeral trust. The signed +/// records are deliberately the issuance testkit's placeholder records; +/// their excluded claim says they stand for no provider run. Family driver +/// measurements are separate artifacts, never substituted for this corpus. +#[tokio::test] +async fn operator_bootstrap_qualification_simulation() { + use auths_recipe_qualification::{QualificationRootId, QualificationSignerId}; + use auths_recipe_qualification_issuance::{ + CertificateRequest, ReleaseSigner, RootSigner, SigningSeed, + }; + + let root_seed = SigningSeed::generate().expect("disposable root"); + let signer_seed = SigningSeed::generate().expect("disposable release signer"); + let root = RootSigner::create( + &root_seed, + QualificationRootId::parse("simulation-root").expect("root ID"), + ) + .expect("root"); + let certificate = root + .certify(CertificateRequest { + signer_id: QualificationSignerId::parse("simulation-signer").expect("signer ID"), + public_key_b64: signer_seed.public_key(), + issued_at: NOW - HOUR, + not_before: NOW - HOUR, + not_after: NOW + 24 * HOUR, + }) + .expect("certificate"); + let signer = ReleaseSigner::open(&signer_seed, certificate).expect("signer"); + let proposals = [ + proposal(1, &tuple_for("simulation-stripe-platform-refund-v1")), + proposal(2, &tuple_for("simulation-airtable-update-v1")), + ]; + let attestations: Vec<_> = proposals + .iter() + .map(|proposal| { + signer + .attest(proposal, NOW, NOW, NOW + 12 * HOUR) + .expect("attestation") + }) + .collect(); + let listed: Vec<_> = proposals + .iter() + .zip(&attestations) + .map(|(proposal, attestation)| (proposal.record(), attestation)) + .collect(); + let index = signer.index(NOW, &listed).expect("signed index"); + let list = root + .revoke(1, NOW - HOUR, NOW + 24 * HOUR, Vec::new(), Vec::new()) + .expect("list"); + let attested_bundle = QualificationBundle { + signer_certificate: signer.certificate().canonical_bytes().to_vec(), + revocation_list: list.canonical_bytes().to_vec(), + release_index: index.canonical_bytes().to_vec(), + records: proposals + .iter() + .map(|proposal| proposal.record().canonical_bytes().to_vec()) + .collect(), + attestations: attestations + .iter() + .map(|attestation| attestation.canonical_bytes().to_vec()) + .collect(), + }; + let clock = Arc::new(FixedClock::at(NOW)); + let mut measurements = Vec::new(); + for closed in &proposals { + let deployment = closed.record().body().tuple.clone(); + let gate = required(Some(root.trust_root().clone()), deployment.clone(), &clock); + let (installation, ()) = host_with(&gate).await; + let host = &installation.first; + assert_eq!( + refusal(host).await.as_deref(), + Some("gateway.qualification.unavailable") + ); + let mut unsigned = attested_bundle.clone(); + unsigned.attestations.clear(); + assert!(gate.load(&unsigned)); + assert_eq!( + refusal(host).await.as_deref(), + Some("gateway.qualification.missing") + ); + assert_eq!(host.leases.load(Ordering::SeqCst), 0); + let before_signature_leases = host.leases.load(Ordering::SeqCst); + assert!(gate.load(&attested_bundle)); + assert_eq!(refusal(host).await, None); + assert_eq!(host.leases.load(Ordering::SeqCst), 1); + let after_verified_import_leases = host.leases.load(Ordering::SeqCst); + let mut forged = attested_bundle.clone(); + let position = forged.attestations[0].len() / 2; + forged.attestations[0][position] ^= 1; + assert!(gate.load(&forged)); + // The untouched second record may remain qualified. A corrupt member + // cannot enable the record whose attestation was changed. + if deployment.recipe_family == proposals[0].record().body().tuple.recipe_family { + assert!(refusal(host).await.is_some()); + assert_eq!(host.leases.load(Ordering::SeqCst), 1); + } + assert!(gate.load(&attested_bundle)); + clock.set(NOW + 13 * HOUR); + assert_eq!( + refusal(host).await.as_deref(), + Some("gateway.qualification.expired") + ); + clock.set(NOW); + let no_root = required(None, deployment.clone(), &clock); + assert!(!no_root.load(&attested_bundle)); + let (untrusted, ()) = host_with(&no_root).await; + assert_eq!( + refusal(&untrusted.first).await.as_deref(), + Some("gateway.qualification.unavailable") + ); + assert_eq!(untrusted.first.leases.load(Ordering::SeqCst), 0); + measurements.push(json!({"family": deployment.recipe_family, + "before_signature_leases": before_signature_leases, + "after_verified_import_leases": after_verified_import_leases, + "unsigned_refused": true, "expired_refused": true, "missing_root_refused": true})); + } + if let Some(directory) = std::env::var_os("AUTHS_QUALIFICATION_SIMULATION_OUTPUT") { + let directory = std::path::PathBuf::from(directory).join("bootstrap"); + std::fs::create_dir_all(&directory).expect("public output directory"); + std::fs::write( + directory.join("root.json"), + root.trust_root().canonical_bytes(), + ) + .expect("public root"); + for (name, bytes) in [ + ( + "signer-certificate.json", + &attested_bundle.signer_certificate, + ), + ("revocation-list.json", &attested_bundle.revocation_list), + ("release-index.json", &attested_bundle.release_index), + ] { + std::fs::write(directory.join(name), bytes).expect("public artifact"); + } + for (position, closed) in proposals.iter().enumerate() { + std::fs::write( + directory.join(format!("record-{position}.json")), + closed.record().canonical_bytes(), + ) + .expect("record"); + std::fs::write( + directory.join(format!("attestation-{position}.json")), + &attested_bundle.attestations[position], + ) + .expect("attestation"); + for (member, evidence) in closed.evidence().iter().enumerate() { + std::fs::write( + directory.join(format!("fixture-evidence-{position}-{member}.json")), + evidence.canonical_bytes(), + ) + .expect("explicit fixture"); + } + } + std::fs::write(directory.join("simulation.json"), serde_json::to_vec_pretty(&json!({ + "schema": "auths.qualification-bootstrap-simulation/1", "simulation": true, + "stable_launch_ready": false, "ephemeral_keys_destroyed_on_return": true, + "clock": {"kind": "fixed-test-clock", "unix_seconds": NOW}, + "evidence_kind": "explicit placeholder fixtures: no provider-run claim", + "measurements": measurements, "production_root_changed": false, + "excluded_claims": ["production qualification", "live provider acceptance", "human review"] + })).expect("report")).expect("write report"); + } +} + #[tokio::test] async fn a_qualified_deployment_leases_and_each_fault_refuses_before_the_lease() { let release = TestRelease::new(NOW); diff --git a/product/runtime/auths-gateway/src/scenario_tests.rs b/product/runtime/auths-gateway/src/scenario_tests.rs index 1bcf85f4b..c2c147152 100644 --- a/product/runtime/auths-gateway/src/scenario_tests.rs +++ b/product/runtime/auths-gateway/src/scenario_tests.rs @@ -46,6 +46,9 @@ const FRESH: [u8; 32] = [0x22; 32]; const RECORD: &str = "recTEST0000000001"; const FOREIGN: &str = "auths-e1-0000000000000000000000000000000000000000000000000000000000000000"; +#[path = "qualification_simulation.rs"] +mod qualification_simulation; + fn corpus(name: &str) -> Value { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../../bindings/fixtures/gateway") @@ -325,6 +328,8 @@ pub(crate) fn admitted( .closed_request_from_arguments(&arguments, commitment) .map_err(|error| crate::engine::not_entered(error.code()))?; Ok(VerifiedCommand { + #[cfg(unix)] + actors: Vec::new(), request, bound, approvers: Vec::new(), @@ -1390,8 +1395,8 @@ async fn qualification_stages_use_gateway_replay_and_recovery_witnesses() { BoundedText, CapabilityKind, QualificationTuple, Scenario, Sha256Digest, }; use auths_recipe_qualification_issuance::execution::{ - ExpectedObservation, Operation, RunCase, RunObservation, RunOutcome, RunPhase, RunStep, - RunVerdict, + EvidenceComparison, ExpectedObservation, Operation, RunCase, RunObservation, RunOutcome, + RunPhase, RunStep, RunVerdict, }; for scenario in [ Scenario::ProofReplay, @@ -1437,10 +1442,12 @@ async fn qualification_stages_use_gateway_replay_and_recovery_witnesses() { vec![ RunStep { operation: Operation::DropResponse, + evidence_comparison: EvidenceComparison::Static {}, expected: expected(RunOutcome::Unknown, "unknown", 1, 1, 0), }, RunStep { operation: Operation::ReadBack, + evidence_comparison: EvidenceComparison::Static {}, expected: expected(RunOutcome::Observed, "observed-by-provider", 1, 0, 1), }, ] @@ -1448,10 +1455,12 @@ async fn qualification_stages_use_gateway_replay_and_recovery_witnesses() { vec![ RunStep { operation: Operation::Submit, + evidence_comparison: EvidenceComparison::Static {}, expected: expected(RunOutcome::Observed, "observed-by-provider", 2, 1, 1), }, RunStep { operation: Operation::Replay, + evidence_comparison: EvidenceComparison::Static {}, expected: expected(RunOutcome::Refused, "gateway.attempt.replay", 0, 0, 0), }, ] @@ -1489,6 +1498,7 @@ async fn qualification_stages_use_gateway_replay_and_recovery_witnesses() { other => panic!("unexpected result: {other:?}"), }; observations.push(RunObservation { + fresh_evidence: None, tuple_sha256: tuple.digest().expect("tuple digest"), observed: ExpectedObservation { verdict: RunVerdict { diff --git a/product/runtime/auths-gateway/src/semantic_closure.rs b/product/runtime/auths-gateway/src/semantic_closure.rs index 94260912d..ab5aaccab 100644 --- a/product/runtime/auths-gateway/src/semantic_closure.rs +++ b/product/runtime/auths-gateway/src/semantic_closure.rs @@ -10,7 +10,7 @@ /// SHA-256 of the schema, a NUL byte, and `semantic-closure.json`. /// Regenerate through `semantic_closure_is_current` with `AUTHS_UPDATE_FIXTURES=1`. pub const GATEWAY_SEMANTIC_CLOSURE_SHA256: &str = - "464c6c17b8b6f969da548b4df9bdebf8bfe58066727a5a2fe5f150b443854c52"; + "463b30c37a0e608fab12ce63f512896ebde9592a5aa48f2417e02e284e775ee8"; #[cfg(test)] mod tests { diff --git a/product/runtime/auths-gateway/src/simulation_attestation.rs b/product/runtime/auths-gateway/src/simulation_attestation.rs new file mode 100644 index 000000000..914a49ee5 --- /dev/null +++ b/product/runtime/auths-gateway/src/simulation_attestation.rs @@ -0,0 +1,148 @@ +//! Detached signatures for measured simulation reports, never qualifications. + +use auths_recipe_qualification_issuance::SigningSeed; +use base64ct::{Base64Unpadded, Encoding as _}; +use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +const SCHEMA: &str = "auths.provider-simulation-attestation/1"; +const DOMAIN: &[u8] = b"auths.provider-simulation-attestation/1\0"; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Statement { + schema: String, + simulation: bool, + stable_launch_ready: bool, + signer_kind: String, + family: String, + report_sha256: String, + public_key_b64: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Attestation { + statement: Statement, + signature_b64: String, +} + +fn preimage(statement: &Statement) -> Vec { + let mut bytes = DOMAIN.to_vec(); + bytes.extend(serde_json_canonicalizer::to_vec(statement).expect("simulation statement")); + bytes +} + +pub(crate) fn sign(report: &[u8], family: &str) -> Vec { + let seed = SigningSeed::generate().expect("ephemeral report signer"); + let key = SigningKey::from_bytes(seed.expose()); + let statement = Statement { + schema: SCHEMA.to_owned(), + simulation: true, + stable_launch_ready: false, + signer_kind: "disposable-self-signed-simulation-key".to_owned(), + family: family.to_owned(), + report_sha256: hex::encode(Sha256::digest(report)), + public_key_b64: Base64Unpadded::encode_string(&key.verifying_key().to_bytes()), + }; + let signature_b64 = Base64Unpadded::encode_string(&key.sign(&preimage(&statement)).to_bytes()); + let bytes = serde_json::to_vec_pretty(&Attestation { + statement, + signature_b64, + }) + .expect("simulation attestation"); + assert!(verify(report, &bytes), "verify before publishing"); + bytes +} + +pub(crate) fn verify(report: &[u8], attestation: &[u8]) -> bool { + if report.len() > 1_048_576 || attestation.len() > 4096 { + return false; + } + let Ok(envelope) = serde_json::from_slice::(attestation) else { + return false; + }; + let statement = &envelope.statement; + let Ok(body) = serde_json::from_slice::(report) else { + return false; + }; + if statement.schema != SCHEMA + || !statement.simulation + || statement.stable_launch_ready + || statement.signer_kind != "disposable-self-signed-simulation-key" + || statement.report_sha256 != hex::encode(Sha256::digest(report)) + || body["schema"] != "auths.recipe-qualification-simulation/1" + || body["simulation"] != true + || body["stable_launch_ready"] != false + || body["family"] != statement.family + { + return false; + } + let Ok(public) = Base64Unpadded::decode_vec(&statement.public_key_b64) else { + return false; + }; + let Ok(public) = <[u8; 32]>::try_from(public.as_slice()) else { + return false; + }; + let Ok(key) = VerifyingKey::from_bytes(&public) else { + return false; + }; + let Ok(signature) = Base64Unpadded::decode_vec(&envelope.signature_b64) else { + return false; + }; + let Ok(signature) = Signature::from_slice(&signature) else { + return false; + }; + key.verify_strict(&preimage(statement), &signature).is_ok() +} + +#[test] +fn altered_reports_and_scope_are_refused() { + let report = br#"{"schema":"auths.recipe-qualification-simulation/1","simulation":true,"stable_launch_ready":false,"family":"stripe-refund-v1","cases":[]}"#; + let attestation = sign(report, "stripe-refund-v1"); + assert!(verify(report, &attestation)); + let mut altered = report.to_vec(); + altered.push(b' '); + assert!(!verify(&altered, &attestation)); + let mut envelope: serde_json::Value = serde_json::from_slice(&attestation).expect("envelope"); + envelope["statement"]["simulation"] = false.into(); + assert!(!verify( + report, + &serde_json::to_vec(&envelope).expect("altered scope") + )); + envelope["statement"]["simulation"] = true.into(); + envelope["signature_b64"] = "A".repeat(86).into(); + assert!(!verify( + report, + &serde_json::to_vec(&envelope).expect("altered signature") + )); +} + +#[test] +fn installed_python_live_report_verifies_under_the_native_signature_contract() { + let report = include_bytes!( + "../../../../qualification/simulation/evidence/airtable-live-2026-10-07/report.json" + ); + let attestation = include_bytes!( + "../../../../qualification/simulation/evidence/airtable-live-2026-10-07/attestation.json" + ); + assert!(verify(report, attestation)); + let mut altered = report.to_vec(); + altered.push(b' '); + assert!(!verify(&altered, attestation)); +} + +#[test] +fn installed_python_platform_refund_report_verifies_under_the_native_signature_contract() { + let report = include_bytes!( + "../../../../qualification/simulation/evidence/stripe-platform-live-2026-10-07/report.json" + ); + let attestation = include_bytes!( + "../../../../qualification/simulation/evidence/stripe-platform-live-2026-10-07/attestation.json" + ); + assert!(verify(report, attestation)); + let mut altered = report.to_vec(); + altered.push(b' '); + assert!(!verify(&altered, attestation)); +} diff --git a/product/runtime/auths-gateway/src/store.rs b/product/runtime/auths-gateway/src/store.rs index c18ab29c0..745f786a6 100644 --- a/product/runtime/auths-gateway/src/store.rs +++ b/product/runtime/auths-gateway/src/store.rs @@ -7,7 +7,7 @@ //! `auths_gateway_kernel::transition::valid_transition`. Records persist //! through a [`GatewayAttemptStore`], which is only an all-or-none //! insert-once, compare-and-swap, and sweep mechanism over opaque bounded -//! bytes of four closed kinds: [`FileGatewayAttemptStore`] for one host, and +//! bytes of five closed kinds: [`FileGatewayAttemptStore`] for one host, and //! the multi-host `PostgresLifecycleStore`, whose production qualification is //! still open. @@ -922,7 +922,7 @@ pub enum GatewayInsert { }, } -/// Durable storage of opaque gateway records of four closed kinds. +/// Durable storage of opaque gateway records of five closed kinds. /// Implementations never interpret the bytes. /// /// Every implementation must pass the same conformance suite: one winner per @@ -996,7 +996,7 @@ pub trait GatewayAttemptStore: Send + Sync { /// Atomic file store for one host. This is not a multi-host store and does /// not establish credential isolation by itself. /// -/// Files are named by kind (`claim-`, `slot-`, `sum-`, `conn-`, then the hex +/// Files are named by kind (`claim-`, `slot-`, `sum-`, `conn-`, `commission-`, then the hex /// key and `.json`). Every mutation holds a host-wide exclusive `flock` on /// `.replace.lock` and every load holds it shared. A batch first writes /// `.batch.json` listing each target and its bytes, then creates the @@ -1079,6 +1079,7 @@ impl FileGatewayAttemptStore { GatewayRecordKind::CountSlot => "slot-", GatewayRecordKind::SumSlot => "sum-", GatewayRecordKind::Connection => "conn-", + GatewayRecordKind::CommissioningBudget => "commission-", }; format!("{prefix}{}.json", hex::encode(key.as_bytes())) } @@ -1279,7 +1280,7 @@ impl FileGatewayAttemptStore { /// A record or batch file name this store writes. fn valid_file_name(name: &str) -> bool { - let Some(rest) = ["claim-", "slot-", "sum-", "conn-"] + let Some(rest) = ["claim-", "slot-", "sum-", "conn-", "commission-"] .iter() .find_map(|prefix| name.strip_prefix(prefix)) else { diff --git a/product/runtime/auths-gateway/src/submit.rs b/product/runtime/auths-gateway/src/submit.rs index b6be2766f..1c8031e78 100644 --- a/product/runtime/auths-gateway/src/submit.rs +++ b/product/runtime/auths-gateway/src/submit.rs @@ -70,6 +70,13 @@ pub(crate) trait SubmitIo { /// Leases the credential. async fn lease(&self) -> Option; + /// A sealed operator authority's more specific pre-custody refusal, when + /// present. It refines diagnostics only; the translated driver still owns + /// the refusal transition and records no provider entry. + fn authority_refusal(&self) -> Option<&'static str> { + None + } + /// Whether the leased secret starts with a declared prefix. The secret /// never leaves the lease. fn secret_admitted(&self, lease: &Self::Lease, guard: &GuardChecks) -> bool; @@ -714,7 +721,13 @@ impl Run<'_, I> { let Some(claim) = self.claim.take() else { return false; }; - let code = refusal_code(refusal); + let code = match refusal { + Refusal::CredentialUnavailable | Refusal::ConnectionChanged => self + .io + .authority_refusal() + .unwrap_or_else(|| refusal_code(refusal)), + _ => refusal_code(refusal), + }; let pre_entry = (!self.pre_entry.is_empty()).then_some(&self.pre_entry); let recorded = claim.record_not_entered(code, pre_entry).await.is_ok(); if recorded { diff --git a/product/runtime/auths-gateway/src/transport.rs b/product/runtime/auths-gateway/src/transport.rs index f87aeef09..9ce6567af 100644 --- a/product/runtime/auths-gateway/src/transport.rs +++ b/product/runtime/auths-gateway/src/transport.rs @@ -179,9 +179,18 @@ pub(crate) struct GatewayHttpTransport { requirement: CredentialRequirement, /// Version headers every response must echo with these values. required_versions: Vec<(String, String)>, + witness: Option>, } impl GatewayHttpTransport { + pub(crate) fn with_witness( + mut self, + witness: std::sync::Arc, + ) -> Self { + self.witness = Some(witness); + self + } + /// Resolves and pins a public IPv4 address before claim or secret access. pub(crate) fn prepare( recipe: &CompiledRecipe, @@ -221,6 +230,7 @@ impl GatewayHttpTransport { target_origin: origin.to_owned(), requirement: connection_requirement.clone(), required_versions: recipe.required_response_versions(), + witness: None, }) } @@ -252,6 +262,7 @@ impl GatewayHttpTransport { target_origin: format!("http://{}:{port}", Ipv4Addr::LOCALHOST), requirement: connection_requirement.clone(), required_versions: recipe.required_response_versions(), + witness: None, }) } @@ -292,6 +303,9 @@ impl GatewayHttpTransport { .body(request.body().to_vec()) .build() .map_err(|_| GatewayTransportError::NotEntered)?; + if let Some(witness) = &self.witness { + witness.write(); + } let mut response = match self.client.execute(outbound).await { Ok(response) => response, Err(_) => return Ok(WriteTransportOutcome::Unknown), @@ -358,6 +372,9 @@ impl GatewayHttpTransport { ); } let outbound = outbound.build().ok()?; + if let Some(witness) = &self.witness { + witness.read(); + } let mut response = self.client.execute(outbound).await.ok()?; let status = response.status().as_u16(); let version_ok = self.versions_echoed(response.headers()); @@ -396,6 +413,7 @@ impl GatewayHttpTransport { target_origin: format!("http://{}:{port}", Ipv4Addr::LOCALHOST), requirement: review.credential().clone(), required_versions: recipe.required_response_versions(), + witness: None, } } @@ -635,7 +653,25 @@ mod tests { target_origin: format!("http://{}:{port}", Ipv4Addr::LOCALHOST), requirement: recipe.review().credential().clone(), required_versions: Vec::new(), + witness: None, }; + let witness = std::sync::Arc::new( + crate::execution_witness::ExecutionWitness::new().expect("scope"), + ); + let transport = transport.with_witness(std::sync::Arc::clone(&witness)); + assert!( + transport + .read( + reqwest::Method::GET, + "https://unapproved.example/", + &[], + 1024, + b"synthetic-transport-fixture", + ) + .await + .is_none() + ); + assert_eq!(witness.snapshot().read_transport_entries, 0); assert!(matches!( transport.write(&request, &lease).await, Ok(WriteTransportOutcome::ResponseRecorded { status: 200, .. }) @@ -655,6 +691,13 @@ mod tests { .and_then(|response| response.usable_body().map(<[u8]>::to_vec)) .is_some() ); + let measured = witness.snapshot(); + assert_eq!(measured.write_transport_entries, 1); + assert_eq!(measured.read_transport_entries, 1); + assert_eq!( + measured.credential_lease_calls, 0, + "transport never calls custody" + ); let heads = provider.await.expect("provider"); assert!(heads[0].starts_with("patch /v0/"), "{}", heads[0]); assert!(heads[1].starts_with("get /v0/"), "{}", heads[1]); diff --git a/product/stores/auths-stores/migrations/postgres_lifecycle_v5.sql b/product/stores/auths-stores/migrations/postgres_lifecycle_v6.sql similarity index 93% rename from product/stores/auths-stores/migrations/postgres_lifecycle_v5.sql rename to product/stores/auths-stores/migrations/postgres_lifecycle_v6.sql index af96aaabd..ed41d489c 100644 --- a/product/stores/auths-stores/migrations/postgres_lifecycle_v5.sql +++ b/product/stores/auths-stores/migrations/postgres_lifecycle_v6.sql @@ -1,13 +1,13 @@ CREATE TABLE auths_lifecycle_store_meta ( singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), - schema_version INTEGER NOT NULL CHECK (schema_version = 5), + schema_version INTEGER NOT NULL CHECK (schema_version = 6), contract_id TEXT NOT NULL CHECK ( contract_id = 'auths.lifecycle.transactional-store/4' ) ); INSERT INTO auths_lifecycle_store_meta (singleton, schema_version, contract_id) -VALUES (TRUE, 5, 'auths.lifecycle.transactional-store/4'); +VALUES (TRUE, 6, 'auths.lifecycle.transactional-store/4'); CREATE TABLE auths_lifecycle_records ( workflow_id TEXT PRIMARY KEY CHECK ( @@ -55,7 +55,7 @@ CREATE TABLE auths_recovery_leases ( CREATE TABLE auths_gateway_records ( record_key BYTEA PRIMARY KEY CHECK (octet_length(record_key) = 32), record_kind TEXT NOT NULL CHECK ( - record_kind IN ('attempt', 'count-slot', 'sum-slot', 'connection') + record_kind IN ('attempt', 'count-slot', 'sum-slot', 'connection', 'commissioning-budget') ), expires_at BIGINT NULL CHECK (expires_at IS NULL OR expires_at >= 0), record_bytes BYTEA NOT NULL CHECK ( diff --git a/product/stores/auths-stores/src/gateway_attempt.rs b/product/stores/auths-stores/src/gateway_attempt.rs index b2f85a5ba..d5d3732b4 100644 --- a/product/stores/auths-stores/src/gateway_attempt.rs +++ b/product/stores/auths-stores/src/gateway_attempt.rs @@ -2,7 +2,7 @@ //! //! This is a mechanism only: all-or-none insert-once batches, a //! compare-and-swap replacement, and a bounded sweep of expired slots, over -//! bounded bytes tagged with one of four closed record kinds. The gateway +//! bounded bytes tagged with one of five closed record kinds. The gateway //! owns every record format, its stages, and which replacements are valid. use crate::lifecycle::{PostgresLifecycleStore, map_postgres_error}; @@ -27,6 +27,8 @@ pub enum GatewayRecordKind { SumSlot, /// The shared connection record. Connection, + /// A commissioning run's immutable binding and consumed leases; never swept. + CommissioningBudget, } impl GatewayRecordKind { @@ -38,6 +40,7 @@ impl GatewayRecordKind { Self::CountSlot => "count-slot", Self::SumSlot => "sum-slot", Self::Connection => "connection", + Self::CommissioningBudget => "commissioning-budget", } } @@ -49,6 +52,7 @@ impl GatewayRecordKind { "count-slot" => Some(Self::CountSlot), "sum-slot" => Some(Self::SumSlot), "connection" => Some(Self::Connection), + "commissioning-budget" => Some(Self::CommissioningBudget), _ => None, } } @@ -357,12 +361,18 @@ mod tests { bounded(&vec![0; MAX_GATEWAY_RECORD_BYTES + 1]), Err(StoreError::LimitExceeded) ); - let schema = include_str!("../migrations/postgres_lifecycle_v5.sql"); + let schema = include_str!("../migrations/postgres_lifecycle_v6.sql"); assert!( schema.contains("octet_length(record_bytes) BETWEEN 1 AND 262144"), "the schema bound and the Rust bound are the same" ); - for kind in ["attempt", "count-slot", "sum-slot", "connection"] { + for kind in [ + "attempt", + "count-slot", + "sum-slot", + "connection", + "commissioning-budget", + ] { assert_eq!( GatewayRecordKind::parse(kind).map(GatewayRecordKind::as_str), Some(kind) @@ -374,6 +384,14 @@ mod tests { #[test] fn expiry_is_present_exactly_for_slots_and_keys_are_distinct() { + assert_eq!( + entry(GatewayRecordKind::CommissioningBudget, 1, None).validate(), + Ok(()) + ); + assert_eq!( + entry(GatewayRecordKind::CommissioningBudget, 1, Some(9)).validate(), + Err(StoreError::Corrupt) + ); assert_eq!( entry(GatewayRecordKind::Attempt, 1, None).validate(), Ok(()) diff --git a/product/stores/auths-stores/src/lifecycle.rs b/product/stores/auths-stores/src/lifecycle.rs index dff0264aa..d289f80da 100644 --- a/product/stores/auths-stores/src/lifecycle.rs +++ b/product/stores/auths-stores/src/lifecycle.rs @@ -34,9 +34,9 @@ use tokio_postgres_rustls::MakeRustlsConnect; const DATABASE_MAGIC: &[u8; 8] = b"AUTHSLF1"; const MAX_DATABASE_BYTES: usize = 256 * 1024 * 1024; const MAX_RECORD_BYTES: usize = auths_lifecycle::MAX_LIFECYCLE_RECORD_BYTES; -const SCHEMA_VERSION: i32 = 5; +const SCHEMA_VERSION: i32 = 6; const CONTRACT_ID: &str = "auths.lifecycle.transactional-store/4"; -const POSTGRES_SCHEMA: &str = include_str!("../migrations/postgres_lifecycle_v5.sql"); +const POSTGRES_SCHEMA: &str = include_str!("../migrations/postgres_lifecycle_v6.sql"); /// One closed capacity rule configured by a domain registration. #[derive(Clone, Debug, Eq, PartialEq)] @@ -533,7 +533,7 @@ impl PostgresStoreSummary { /// Returns the physical schema identity. #[must_use] pub const fn schema_id(self) -> &'static str { - "auths.lifecycle.postgresql/5" + "auths.lifecycle.postgresql/6" } /// Returns the transactional store contract identity. @@ -705,7 +705,7 @@ impl PostgresLifecycleStore { } let state = self.pool.state(); Ok(PostgresStoreHealth { - schema_version: 5, + schema_version: 6, pool_connections: state.connections, pool_idle_connections: state.idle_connections, }) diff --git a/qualification/README.md b/qualification/README.md index 64e9eab4a..cff34448a 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -6,8 +6,81 @@ The specification is §7 and §8; the run is described in [the protected-run plan](../docs/plans/RECIPE_QUALIFICATION_PROTECTED_RUN_PLAN.md). -No family is qualified and none is present here yet. The two launch families -arrive with their decision records. +No production family is qualified. The owner-directed disposable bootstrap and +Stripe/Airtable harnesses are in [simulation/](simulation/README.md); they run +without external credentials and publish explicitly simulated evidence. Proposed provider decisions +are [ADR 0014](../docs/adr/0014-stripe-refund-recipe-qualification.md) and +[ADR 0015](../docs/adr/0015-airtable-record-update-recipe-qualification.md). +They explicitly remain proposed until their executable corpora and protected +evidence exist. A separate [live operator rehearsal](simulation/live/README.md) +has now passed against Airtable using downloaded gateway/SDK artifacts, +disposable records and development custody; it does not qualify a production tuple. + +`cargo xtask release-check` generates `target/release-evidence/launch-readiness.json`. +Its `stable_launch_ready` value comes from the gateway build's pinned public +root, current signed inputs and their actual evidence; no checked-in flag can +set it. The current build pins the public ceremony root but has no signed qualification inputs, so it derives false. Missing +qualification permits a prerelease while preventing a stable launch claim. + +The release builder reads public signed inputs from +`target/qualification-release//`. These downloaded +public artifacts are build inputs, not a source commit containing a reference +to itself. The signing tool includes +the canonical evidence at `evidence/.json`, alongside the +index, records, attestations, certificate and revocation list. The projection +verifies every referenced artifact and requires two distinct families, +contracts and provider kinds on the same production candidate. Its clock +must pass the maintained synchronization check. Human release review remains +a separate requirement. + +Finalization re-evaluates the projection against current candidate inputs before +binding it into the final manifest. An edited readiness value, another commit, +drifted target or missing projection cannot become a signed launch claim. The +manifest contract requires exactly one digest-bound projection. + +## Bootstrap rehearsal and production evidence + +The owner assigned the agent an independent operator simulation, including the +first signing ceremony and both provider harnesses. `simulation/run.py` performs +that work with fresh in-memory signing keys, explicit placeholder trust-machine +records and measured provider-driver reports. Detached simulation signatures +bind the measured report bytes and are verified after publication. Their keys +are explicitly self-signed and have no protected-run authority. It imports signed fixtures under +required test trust and measures real engine credential-store calls. No private +keys or production trust inputs are written. This work does not wait for a human +ceremony or real provider credentials. + +The current production gateway refuses every lease without a current exact-tuple +attestation. The protected workflow gathers live effects before it can sign that +attestation. Therefore its first qualification cannot bootstrap itself. A +development installation or `testkit-production-unqualified` executable changes +the target or shipped bytes and cannot establish the required production claim. +That real-production authority question remains separate from the requested +simulation; the shipping lease gate has no bypass. The implemented commissioning authority is +[ADR 0016](../docs/adr/0016-bounded-qualification-commissioning-authority.md): +a finite signed permit for an authenticated private qualification-run session, +with exact action commitments, durable lease accounting and a fixed expiry. +Its verifier, durable budget and private operator commands are implemented. It cannot qualify a family or enable an ordinary application lease. The [reviewed references](reference/README.md) independently derive exact resource/action bindings; the protected family setup and workflow still need to execute them. + +The current executable corpus also fixes exact request/evidence digests before +running, while disposable live resource identifiers may be created during setup. +Family harnesses need a reviewed, bounded resource-binding and oracle expansion +before executing their cases. Copying the candidate's observed digest into an +expected result would invalidate the differential evidence. + +The rehearsal fixes synthetic resource IDs before execution, so its independent +oracles do not copy digests observed from candidate output. The offline production +root, protected signer and provider credentials remain prerequisites for real +protected evidence, not for the simulation. + +A `production-readiness` case is additionally required for each stable launch +claim. It runs only in the protected live phase against PostgreSQL and +production custody, without a lease or provider entry. The reviewed harness +must run the candidate's doctor and check every required typed row, including +qualification, then return `production-readiness-passed` and the digest of its +actual report. Development `not-ready` reports cannot satisfy it. This case +is optional for intermediate qualification records; its absence always makes +the stable launch projection false. ## `families//` @@ -17,7 +90,7 @@ One directory per recipe family, named by its `RecipeFamilyId`. | --- | --- | | `decision-record.md` | The family's decision record. Its digest goes into the record. | | `contract.json` | The canonical `auths.provider-contract/1` the run qualifies against. | -| `corpus-manifest.json` | The corpus the differential, hostile, and live stages run. | +| `corpus-manifest.json` | The reviewed source plan and exact compiler/oracle file hashes, expanded into the concrete native corpus. | | `record.json` | What the record states that no run decides: `provider_kind`, `validity_days` (at most 90), `not_applicable` (each capability the family lacks, with the reason the decision record fixes), `custody_descriptor`, `store_descriptor`, `residual_assumptions`, `excluded_claims`. | | `harness` | A reviewed executable implementing `prepare`, `prepare-live`, `step` and `cleanup`. | @@ -25,10 +98,19 @@ The harness owns the provider-specific operations and pure oracle. The release runner owns sequencing, assertions, counters, and the resulting case reports. No family harness writes `passed` reports or `live-effects.json`. -`corpus-manifest.json` is `auths.qualification-corpus/1`, decoded as +The checked-in manifest is `auths.qualification-reviewed-plan/1`. It binds the +exact source compiler, provider references, packet author, recipe, lock and ADR. +`python3 -B qualification/reference/generate_families.py` regenerates the closed +plans, contracts, record metadata and family entry points; `--check` refuses +source or artifact drift. The permit signer performs that check before deriving +the source-owned contract identity. + +The compiler independently derives each request and fresh-evidence subject from +the complete authenticated public pool. Its concrete `corpus.json` is +`auths.qualification-corpus/3`, decoded as `execution::RunCorpus` by `auths-qualification run-stage`. It contains at most 256 unique cases. Each case has `id`, `scenario`, `capabilities`, `phase` -(`offline` or `live`), and at most 32 ordered `steps`. Each step has a closed +(`offline`, `commissioning` or `live`), and at most 32 ordered `steps`. Each step has a closed `operation` and an `expected` observation: exact verdict (outcome, stable code, request and evidence digests), credential leases, provider entries, and writes confirmed by fresh read-back. All required non-trust/non-redaction scenarios @@ -43,13 +125,17 @@ harness step harness cleanup ``` -`prepare` installs the candidate in `/gateway-state`, installs the +`prepare` writes the reviewed recipe and lock into ``, installs the consumer packages, and writes `packages.json`. The candidate gateway itself -prints `tuple.json`; the release tool checks the declared contract ID against -`contract.json`. `prepare-live` acquires disposable resources, starts the live -candidate. `run/live.sh` always calls `cleanup`, including after setup or stage -failure; cleanup must remove disposable resources idempotently, and its failure -fails the run. Both setup commands receive `AUTHS_GATEWAY` and +prints its planned production `tuple.json` through `qualification-candidate`, +without custody or provider access; the release tool checks the declared contract ID against +`contract.json`. `prepare-live` installs production custody for the disposable resources and +compares `qualification-status --tuple` with that planned tuple before any +permit import or submission. A changed installation fails closed. The whole +`run/resource-session.sh` journey calls `cleanup`, including after setup or +stage failure; individual phase runners retain resources for the next phase. +Cleanup must remove disposable resources idempotently, and its failure fails +the run. Both setup commands receive `AUTHS_GATEWAY` and `AUTHS_QUALIFICATION`. Only the protected live environment supplies `AUTHS_QUALIFICATION_PROVIDER_CREDENTIAL`. @@ -67,7 +153,7 @@ these observations are evidence, not cryptographic proof of provider behavior. The runner independently compares oracle and gateway verdicts and request/ evidence commitments; accepted differential cases require a request digest. Replay, race, restart, crash, ambiguity and recovery cases may enter once only. -Replay operations cannot lease or enter again. Forged/altered inputs cannot +Replay never enters write transport again. An unresolved entered attempt may take one read-only lease to complete its declared observation; an already observed attempt takes none. Forged/altered inputs cannot lease. Recovery runs loss/delay followed by read-back and must remain `unknown` when no recovery capability is declared. Live effects count only successful writes with fresh read-back. Installed-consumer steps run outside the source @@ -84,8 +170,16 @@ live-member observations. The family additionally writes: outputs from the candidate. Missing categories prevent closure; a family must export the gateway's real support bundle once Epic 4 provides it. -`run/redact.sh` scans the outputs and evidence and removes canaries before -upload. The release tool itself executes signer rotation and freshness; the +`run/close_proposal.py` retains the real canaries through assembly, rescans the +new proposal and evidence, and rebuilds with that complete scan report. It +scans the actual final bytes again and requires the report to remain unchanged +before removing the live phase's canaries. Any failure removes the proposal +and evidence. The live workflow runs this only after the complete resource +session has returned successfully, including confirmed cleanup. It uploads +the closed proposal from that same job; assembly no longer happens after the +canaries have been discarded. Commissioning closure retains canaries for the +subsequent ordinary live phase. +The release tool itself executes signer rotation and freshness; the family cannot replace those reports. Assembly re-verifies scenario, candidate, capability, counter and digest closure before signing can begin. @@ -97,8 +191,31 @@ Public artifacts of the offline root ceremony, committed by the owner: - `signer-certificate.json`: the current release signer's certificate; - `revocation-list.json`: the current revocation list. -No private key is ever in this repository. The directory is empty until the -first ceremony, and until then the signing job refuses to run. +No private key is ever in this repository. `ceremony.json` records the first +offline ceremony and the exact public-artifact hashes. It is an explicitly +delegated technical assessment, not a human release review or provider +qualification. No qualification record or release index has been issued. + +The first qualification uses the separate finite authority in +[ADR 0016](../docs/adr/0016-bounded-qualification-commissioning-authority.md). +`auths-qualification certify-commissioner` certifies a separate key with only +the commissioning-permit purpose; normal `certify` grants only release purposes. +The root key remains offline and never enters either provider runner. Both purpose-separated signer keys were provisioned on 7 October 2026 into the reviewer-protected, main-only `recipe-qualification-signing` environment. No private root material was uploaded. + +After the protected reference expands disposable resources and exact actions, +`auths-qualification commissioning-sign` takes `--binding`, `--conformance`, +`--differential`, `--signer-key`, `--certificate`, `--not-before`, `--not-after` +and `--out`. It rechecks both canonical offline members, candidate/tuple/digest +closure and all mandatory scenarios before signing. The permit lasts at most +two hours and is never a qualification record or release-index member. The +reviewed protected reference must independently derive resources/actions; +successful issuance by itself does not establish that reference's correctness. + +The operator receives `commissioning-permit.json`, `signer-certificate.json` +and the current `revocation-list.json` in one artifact directory. Only the +gateway's private `commissioning-init` and `commissioning-submit` commands use +them, with `--from`, `--state-dir`, `--protected-run` and `--resource-binding`. +Ordinary application requests require current qualification throughout. ## `run/` @@ -114,3 +231,84 @@ credential is stored in it. The signing environment already has both rules. The harness of a family is trusted as reviewed code on the default branch; the run does not execute anything a harness leaves behind as a program in the jobs that assemble, sign, or verify. + +## First qualification and ordinary validation + +One reviewed corpus contains three closed phases. `offline` establishes native +proof, recipe and oracle agreement without custody. `commissioning` exercises +the exact production candidate through finite private operator authority; its +installed-client journey must show an ordinary qualification refusal with zero +leases/entries. It cannot include a production-readiness case. + +Assembly selects offline reports and exactly one protected phase, never mixes +stale commissioning and ordinary live reports. Commissioning records last at +most two hours and explicitly exclude ordinary installed-client success and +production readiness. They permit the existing qualification chain to unlock +the subsequent `live` phase on the same candidate/contract/recipe tuple. +That phase must show a confirmed effect from the installed ordinary client; a +refusal cannot satisfy it. Only that phase can contribute production doctor +evidence to a stable launch projection. A permit or an initial record alone +never establishes stable readiness. + +### Measured execution boundaries + +The private `auths-gateway execution-witness` command reads process-local +`auths.gateway-execution-witness/1` counters without consulting custody, the +store or the provider. The application channel cannot request or reset them. +Subtract snapshots only with the same random scope, after all measured calls +complete; reject decreasing or saturated counters. Aggregate each separately +scoped host for a two-instance race. Restart establishes a fresh scope. + +`commissioning-submit` returns `auths.gateway-commissioning-execution/1` with +its result and before/after snapshots from its own short-lived engine. A +credential lease count is an actual store call, including a failing call; +a write entry is the HTTP client's execution boundary, including ambiguity. +Neither is a claim that a provider received or performed a mutation. Reads, +including credential probes, are counted separately. First qualification must +still independently read back the reviewed disposable resource. + +The optional private `commissioning-submit --witness-file` names a new file in +an existing owner-private directory. During submission it appends only changed +typed counter snapshots, at most 256 frames of 1 KiB each; it never contains +proofs, actions, credentials or provider responses. Ten-millisecond sampling +is diagnostic and may skip intermediate states. A recording failure is +reported only after the native attempt completes, so it cannot cancel an +entered write. This stream adds no pause, fault injection or execution authority. + +### Fresh evidence comparisons + +Every corpus step declares `evidence_comparison`: `static`, +`independent-read-back` with a reviewed `subject_sha256`, or +`production-doctor`. Static comparisons require the complete precomputed +verdict and forbid a fresh witness. Dynamic comparisons require an absent +precomputed evidence digest; every other expected field remains exact. + +An independent read-back is allowed only for an observed protected operation. +The separate fresh witness must name the exact reviewed resource/action/state +subject, and its raw response digest must equal the candidate's evidence +digest. Different bytes fail the run, even if both responses appear successful. +The reference validates fresh provider state and echo before creating this +witness; it never accepts the candidate's locator or digest as the oracle. +A doctor witness is confined to a read-only production-readiness probe in the +ordinary live phase and must name the actual tuple. Unknown sources, omitted +witnesses, mismatched subjects/digests and extra fields fail closed. + +The runner retains closed actual observations under `scan/trace`, separately +from the pass reports. They contain no provider body or secret and undergo the +existing protected redaction scan before export. + +The credential-free `candidate` job builds and retains the actual shipping +Linux gateway and issuer without simulation features, even before a protected +family corpus is admitted. Its manifest binds exact file hashes, source commit +and workflow run. Native read-only recipe review exercises both maintained +recipes; no installation, custody lease or qualification is claimed. This +artifact is a candidate for operator inspection, not a GitHub release. + +Offline differential cases use the closed `review` operation after the pure +`oracle`. The candidate's `review-submission` checks native proof, actor, action +and exact request without installing or consulting qualification/custody. A +successful mapping is `complete`, never an HTTP response or effect. Both +operations must show zero leases, writes and read-back confirmations, and their +exact verdict/request commitments must agree. `review` cannot replace a submit +in a protected case. This avoids requiring first qualification merely to +collect the offline evidence needed to issue its finite commissioning permit. diff --git a/qualification/families/airtable-record-update-v1/contract.json b/qualification/families/airtable-record-update-v1/contract.json new file mode 100644 index 000000000..c8eeda692 --- /dev/null +++ b/qualification/families/airtable-record-update-v1/contract.json @@ -0,0 +1 @@ +{"api_release":"web-api-v0-reviewed-2026-10-07","corpus_manifest_sha256":"6f517e64678419c034ceb2f976e871dc0dd5bb7f8d9a7f8a1d45de01850b5a8d","declarations":{"idempotency_sha256":"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b","observation_sha256":"9dd79dd204fa13a2df3c667d106c66c0526d174a2c018631e2b2f6b897eaf311","recovery_sha256":"7081a92b4cd2d13dab5e7c30e640675a252b62bca20d340e14d62d9c8c048eca","retention_sha256":"4a4b5048c97b16e041af55b032c0b8ac20f5142c4bbcbf31b31c6674770a70d0"},"environment_class":"disposable-live-resources","manual_assumptions":["Airtable supplies no idempotency key for this PATCH; durable gateway admission prevents a second entry.","Fresh matching state and the action-derived echo are required; HTTP success is not effect confirmation.","Only a fresh GET with the exact replacement and echo can reconcile a lost response.","Only the closed run-owned resource ledger is in scope; every cleanup must be freshly confirmed.","The provider is independently operated; availability and concurrent outside writes are not controlled by Auths.","The reviewed fixed Airtable base and table contain only owner-authorized disposable qualification records."],"oracle_version":"auths-reviewed-reference-v2","provider":"airtable","schema":"auths.provider-contract/1"} \ No newline at end of file diff --git a/qualification/families/airtable-record-update-v1/corpus-manifest.json b/qualification/families/airtable-record-update-v1/corpus-manifest.json new file mode 100644 index 000000000..f5c45091d --- /dev/null +++ b/qualification/families/airtable-record-update-v1/corpus-manifest.json @@ -0,0 +1 @@ +{"compiled_corpus_schema":"auths.qualification-corpus/3","decision_record_sha256":"32241d9b4811a55abcdd224ef567df3857dbec34d761ef7a35d1044cdfa52d9f","family":"airtable-record-update-v1","maximum_credential_leases":64,"packet_plan_schema":"auths.qualification-packet-plan/4","phases":["offline","commissioning","live"],"profile_lock_sha256":"af7f984509ab11a3676cacc85eda2fa638c25bf91f7bfedf3ff3c119e47f3c41","public_packet_schema":"auths.qualification-public-packets/4","resource_count":16,"schema":"auths.qualification-reviewed-plan/1","source_files":{"airtable_record.py":"d5bf4d750fee75f02200d180f8654f426fe738bc86a281f1d015f391cc5e67fa","airtable_resources.py":"094f7d5c52a4557261c25fba6d5f1e4938091d946a05a422417b6dea22b7e76f","author_operator.py":"f3c66e5f6636bfdf317dc87915255f22a97466a34cbb5053b55cdf6e05d6921f","author_packets.py":"84d550b0f82c8501000ffde07ea13720f09caa9079b2c885f65a1f5cc08c2110","author_socket.py":"3d6a031c01d7d9a58213a8368f45b68c25081d059a8ff3cba4fe47c0f75a77fd","common.py":"604d3d6598648fbc09611e663b4048b22fa748d0f6c6f1dbbceca5d83c02a2ce","controller_socket.py":"10519cc775e963360870eac51c7fcac1ffb988b04d5de89dac1dbf3524ba6531","expand.py":"892cf7b04c3847336f150926bfbf3e82665e9e2bc1b154be42e4f57d1922b816","family_corpus.py":"e58dc84b60f5ff13e5c7b1b5aae4917c5427f31cc8ac041acdd85defc8cd5e92","family_harness.py":"ed0136600a25c9f12bdcca73b105c3968271ccd227f42ad03a884cf5b9430de6","family_operations.py":"f6f7e54714228af7b48f2691c4ee15b5b2b34ebd5eb7b19dab61d26734bf7ce5","fresh_evidence.py":"cb54ea66a4775c5af76fbc36c4f8c8a007c553de2b2303e80fd744aee1c54d2f","installed_submit.py":"f78b91ca7c57ea6909793973ca63fb33f18589ac8acbcb15e8886511e3d6fc50","measure.py":"b620be174bc012bc6b29f95e558fce1b6fb2cc99b5fa5a0f0c933c88696b4ca0","native_observation.py":"4896a5f16474cbb418decb719bcc2af4a08483eabeca996427b40560a16d02cf","network_fault.py":"986ca0c9d7d534c2a1ebdd09edb4e6f94c17bb4bfcb35d57307c97419ed20894","packet_plan.py":"cf993ba17ba40bd528a08d446bfc7c2e8cf0291f3e202337bcfdafa4ab269a7c","production_setup.py":"795f47b92d806171e866510b75557bb6ece1fd1d14f6f51bf5330c25bbf80519","provider_readback.py":"d576768208985b3544f146597d181d796f2a5e2eca5026dae3fa15cadcfe65b2","resource_io.py":"b0ed0223fd11a1a28c25278de539a9b2ed63881b26a8e00e386f25d4d9aa9384","resource_summary.py":"c75f3f23017529ce10197c09649e2d60b3c240b8b58992786b824e85869668e0","retained_author.py":"bfd01c7df44f5f525326dfbda13b5ebc6b854f15787a8b2923de883fe20fdcf9","stripe_platform.py":"2da918e72f0ec3aed4f5a80701748e0d37c71292806bfa38c74ffbe69055c4ec","stripe_resources.py":"2e050b4f5f8654cfb3a7cf9ddaa73b3515a039d5b37071d2d9c0dbafe2561489","tls_fault.py":"d19ad37a5d37712fe274dbaddfcf062e444aab356e3c28c8cb2f533833b33ece"},"source_recipe_sha256":"c0f21ff1d3c8e344fee20a9a0c25bb14a264417ed53418ad536789a6096fb16e"} \ No newline at end of file diff --git a/qualification/families/airtable-record-update-v1/decision-record.md b/qualification/families/airtable-record-update-v1/decision-record.md new file mode 100644 index 000000000..2e581f99b --- /dev/null +++ b/qualification/families/airtable-record-update-v1/decision-record.md @@ -0,0 +1,9 @@ +# airtable-record-update-v1 + +Status: source plan; no production qualification. + +The provider decision is [ADR](../../../docs/adr/0015-airtable-record-update-recipe-qualification.md). Its exact digest is in the reviewed plan. + +The source-owned compiler expands the complete closed packet pool into the native three-phase corpus. Offline operations execute the installed SDK and shipping native reviewer. Protected operations must use PostgreSQL, actual AWS custody, two processes, fresh read-back and measured counters. An absent protected implementation refuses and emits no observation. + +The contract hashes this reviewed source plan; the record separately hashes its concrete native corpus expansion. This avoids a source/candidate/actor commitment cycle. The signer must reconstruct the expansion from its own reviewed source before issuing authority. diff --git a/qualification/families/airtable-record-update-v1/harness b/qualification/families/airtable-record-update-v1/harness new file mode 100755 index 000000000..567a47de4 --- /dev/null +++ b/qualification/families/airtable-record-update-v1/harness @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'reference')) +from family_harness import main +main('airtable-record-update-v1', sys.argv[1:]) diff --git a/qualification/families/airtable-record-update-v1/record.json b/qualification/families/airtable-record-update-v1/record.json new file mode 100644 index 000000000..85a389e83 --- /dev/null +++ b/qualification/families/airtable-record-update-v1/record.json @@ -0,0 +1 @@ +{"custody_descriptor":"aws-secrets-manager-v1 with immutable versions and a customer-managed key","excluded_claims":["Gateway enforcement of the personal access token resource configuration.","Global exactly-once effects across other actors or deployments.","Provider availability, settlement, or indefinite retention."],"not_applicable":[{"capability":"account-binding","reason":"The recipe declares no account-binding probe."},{"capability":"budget","reason":"This recipe declares no numeric count/sum budget."},{"capability":"ceiling","reason":"The field-update profile carries no numeric amount or relative ceiling."},{"capability":"credential-guard","reason":"This recipe declares no provider credential guard."},{"capability":"denied-reads","reason":"The recipe declares no denied credential reads; token scope is an operator assumption."},{"capability":"idempotency","reason":"This PATCH declares no provider idempotency key."},{"capability":"observer-rotation","reason":"The qualified reference configures no signing observer."},{"capability":"response-locator","reason":"Observation uses the verified record ID, not a write-response locator."},{"capability":"version-pin","reason":"Airtable Web API v0 has no immutable response version pin."}],"provider_kind":"airtable","residual_assumptions":["Airtable supplies no idempotency key for this PATCH; durable gateway admission prevents a second entry.","Fresh matching state and the action-derived echo are required; HTTP success is not effect confirmation.","Only a fresh GET with the exact replacement and echo can reconcile a lost response.","Only the closed run-owned resource ledger is in scope; every cleanup must be freshly confirmed.","The provider is independently operated; availability and concurrent outside writes are not controlled by Auths.","The reviewed fixed Airtable base and table contain only owner-authorized disposable qualification records."],"store_descriptor":"postgresql-v1 schema 6 shared by two isolated gateway processes","validity_days":30} \ No newline at end of file diff --git a/qualification/families/stripe-platform-refund-v1/contract.json b/qualification/families/stripe-platform-refund-v1/contract.json new file mode 100644 index 000000000..7878317dd --- /dev/null +++ b/qualification/families/stripe-platform-refund-v1/contract.json @@ -0,0 +1 @@ +{"api_release":"2025-03-31.basil","corpus_manifest_sha256":"2b9f0e171966d6ee8fb7d65cfe6f657562083f56faf7c141efb7db2150ebbd15","declarations":{"idempotency_sha256":"425a0dca2f279765debfc847c4a01bff60fea5be68dd9d761ae6bb60ce0b8a7b","observation_sha256":"d0ce5a34d987821c69c0a839d9ed66805c625f8324dd42031fcccf0752a8821e","recovery_sha256":"bf2303abce165c175360ac975586431bbc4cf03167489ea317a27de2f0b04abb","retention_sha256":"846c2164e26d1144d9aa146034b4f7ea4dc305136a59ffd94f999d19f2c360ee"},"environment_class":"provider-test-mode","manual_assumptions":["Fresh matching state and the action-derived echo are required; HTTP success is not effect confirmation.","Only the closed run-owned resource ledger is in scope; every cleanup must be freshly confirmed.","Stripe idempotency is tested only inside the declared 86400-second retention interval.","Stripe test mode and the installed platform are checked by the declared guard on each lease.","The 50-percent payment basis is a read, not an atomic provider balance reservation.","The provider is independently operated; availability and concurrent outside writes are not controlled by Auths."],"oracle_version":"auths-reviewed-reference-v2","provider":"stripe","schema":"auths.provider-contract/1"} \ No newline at end of file diff --git a/qualification/families/stripe-platform-refund-v1/corpus-manifest.json b/qualification/families/stripe-platform-refund-v1/corpus-manifest.json new file mode 100644 index 000000000..d1826ba8e --- /dev/null +++ b/qualification/families/stripe-platform-refund-v1/corpus-manifest.json @@ -0,0 +1 @@ +{"compiled_corpus_schema":"auths.qualification-corpus/3","decision_record_sha256":"a72c557b69103a52eb477deb8364ded53cd58407b9ba7ab33ccbda74389fec1e","family":"stripe-platform-refund-v1","maximum_credential_leases":64,"packet_plan_schema":"auths.qualification-packet-plan/4","phases":["offline","commissioning","live"],"profile_lock_sha256":"99901e36cb7c4f5882897603c874698d582e859b5d6b08d53cd80975e89aa01c","public_packet_schema":"auths.qualification-public-packets/4","resource_count":16,"schema":"auths.qualification-reviewed-plan/1","source_files":{"airtable_record.py":"d5bf4d750fee75f02200d180f8654f426fe738bc86a281f1d015f391cc5e67fa","airtable_resources.py":"094f7d5c52a4557261c25fba6d5f1e4938091d946a05a422417b6dea22b7e76f","author_operator.py":"f3c66e5f6636bfdf317dc87915255f22a97466a34cbb5053b55cdf6e05d6921f","author_packets.py":"84d550b0f82c8501000ffde07ea13720f09caa9079b2c885f65a1f5cc08c2110","author_socket.py":"3d6a031c01d7d9a58213a8368f45b68c25081d059a8ff3cba4fe47c0f75a77fd","common.py":"604d3d6598648fbc09611e663b4048b22fa748d0f6c6f1dbbceca5d83c02a2ce","controller_socket.py":"10519cc775e963360870eac51c7fcac1ffb988b04d5de89dac1dbf3524ba6531","expand.py":"892cf7b04c3847336f150926bfbf3e82665e9e2bc1b154be42e4f57d1922b816","family_corpus.py":"e58dc84b60f5ff13e5c7b1b5aae4917c5427f31cc8ac041acdd85defc8cd5e92","family_harness.py":"ed0136600a25c9f12bdcca73b105c3968271ccd227f42ad03a884cf5b9430de6","family_operations.py":"f6f7e54714228af7b48f2691c4ee15b5b2b34ebd5eb7b19dab61d26734bf7ce5","fresh_evidence.py":"cb54ea66a4775c5af76fbc36c4f8c8a007c553de2b2303e80fd744aee1c54d2f","installed_submit.py":"f78b91ca7c57ea6909793973ca63fb33f18589ac8acbcb15e8886511e3d6fc50","measure.py":"b620be174bc012bc6b29f95e558fce1b6fb2cc99b5fa5a0f0c933c88696b4ca0","native_observation.py":"4896a5f16474cbb418decb719bcc2af4a08483eabeca996427b40560a16d02cf","network_fault.py":"986ca0c9d7d534c2a1ebdd09edb4e6f94c17bb4bfcb35d57307c97419ed20894","packet_plan.py":"cf993ba17ba40bd528a08d446bfc7c2e8cf0291f3e202337bcfdafa4ab269a7c","production_setup.py":"795f47b92d806171e866510b75557bb6ece1fd1d14f6f51bf5330c25bbf80519","provider_readback.py":"d576768208985b3544f146597d181d796f2a5e2eca5026dae3fa15cadcfe65b2","resource_io.py":"b0ed0223fd11a1a28c25278de539a9b2ed63881b26a8e00e386f25d4d9aa9384","resource_summary.py":"c75f3f23017529ce10197c09649e2d60b3c240b8b58992786b824e85869668e0","retained_author.py":"bfd01c7df44f5f525326dfbda13b5ebc6b854f15787a8b2923de883fe20fdcf9","stripe_platform.py":"2da918e72f0ec3aed4f5a80701748e0d37c71292806bfa38c74ffbe69055c4ec","stripe_resources.py":"2e050b4f5f8654cfb3a7cf9ddaa73b3515a039d5b37071d2d9c0dbafe2561489","tls_fault.py":"d19ad37a5d37712fe274dbaddfcf062e444aab356e3c28c8cb2f533833b33ece"},"source_recipe_sha256":"b1972d556f28433932a449636755077f7ee485ef90cca412f153bc7f18881368"} \ No newline at end of file diff --git a/qualification/families/stripe-platform-refund-v1/decision-record.md b/qualification/families/stripe-platform-refund-v1/decision-record.md new file mode 100644 index 000000000..41f19a55f --- /dev/null +++ b/qualification/families/stripe-platform-refund-v1/decision-record.md @@ -0,0 +1,9 @@ +# stripe-platform-refund-v1 + +Status: source plan; no production qualification. + +The provider decision is [ADR](../../../docs/adr/0014-stripe-refund-recipe-qualification.md). Its exact digest is in the reviewed plan. + +The source-owned compiler expands the complete closed packet pool into the native three-phase corpus. Offline operations execute the installed SDK and shipping native reviewer. Protected operations must use PostgreSQL, actual AWS custody, two processes, fresh read-back and measured counters. An absent protected implementation refuses and emits no observation. + +The contract hashes this reviewed source plan; the record separately hashes its concrete native corpus expansion. This avoids a source/candidate/actor commitment cycle. The signer must reconstruct the expansion from its own reviewed source before issuing authority. diff --git a/qualification/families/stripe-platform-refund-v1/harness b/qualification/families/stripe-platform-refund-v1/harness new file mode 100755 index 000000000..c54a3e8f8 --- /dev/null +++ b/qualification/families/stripe-platform-refund-v1/harness @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'reference')) +from family_harness import main +main('stripe-platform-refund-v1', sys.argv[1:]) diff --git a/qualification/families/stripe-platform-refund-v1/record.json b/qualification/families/stripe-platform-refund-v1/record.json new file mode 100644 index 000000000..04792bbc4 --- /dev/null +++ b/qualification/families/stripe-platform-refund-v1/record.json @@ -0,0 +1 @@ +{"custody_descriptor":"aws-secrets-manager-v1 with immutable versions and a customer-managed key","excluded_claims":["Connected-account selection or restrictions.","Global exactly-once effects across other actors or deployments.","Provider availability, settlement, or indefinite retention."],"not_applicable":[{"capability":"observer-rotation","reason":"The qualified reference configures no signing observer."},{"capability":"recovery","reason":"An unrecorded refund response has no verified response locator; loss remains unknown."}],"provider_kind":"stripe","residual_assumptions":["Fresh matching state and the action-derived echo are required; HTTP success is not effect confirmation.","Only the closed run-owned resource ledger is in scope; every cleanup must be freshly confirmed.","Stripe idempotency is tested only inside the declared 86400-second retention interval.","Stripe test mode and the installed platform are checked by the declared guard on each lease.","The 50-percent payment basis is a read, not an atomic provider balance reservation.","The provider is independently operated; availability and concurrent outside writes are not controlled by Auths."],"store_descriptor":"postgresql-v1 schema 6 shared by two isolated gateway processes","validity_days":30} \ No newline at end of file diff --git a/qualification/reference/README.md b/qualification/reference/README.md new file mode 100644 index 000000000..e8e1c8763 --- /dev/null +++ b/qualification/reference/README.md @@ -0,0 +1,296 @@ +# Reviewed commissioning references + +These are release-only provider references, outside shipping packages. They +neither hold provider credentials nor publish qualification evidence. + +`stripe_platform.py` independently derives the platform-account test refund; +`airtable_record.py` derives the dedicated table's one-field update. The +resource carrier is closed, unique, bounded and tied to one protected run. +Only the dedicated owner-authorized Airtable base/table may replace the two +synthetic fixed path members. Every other recipe member and the exact profile +lock must agree with reviewed source, including guards and ceilings. + +`expand.py` receives public packet files, resource bindings, the original +candidate binary, tuple and canonical conformance/differential evidence. It +hashes the candidate without executing it. It invokes only a reviewer binary +that the signing job builds from its own checkout, with an empty environment, +through `qualification-candidate` and `review-submission`. No program from a +downloaded artifact runs with a signing key. + +Native proof review supplies the exact actors, canonical action commitments +and decoded arguments. The provider reference supplies the expected request. +Their request mappings must agree byte-for-byte before an action enters the +unsigned binding. All actions must have one exact actor, the installed trust +must match, and the lease ceiling is a source-owned 64 acquisitions. The +native issuer separately rechecks both offline artifacts and their mandatory +scenarios before signing. An expansion is neither a live report nor a permit. + +`../run/commission.py` is the protected source-owned permit signer. It rederives +the contract identity from this checkout before expanding the complete public +packet pool. Only after that succeeds does it read the commissioning key and +invoke its own native issuer. The key remains outside the publication directory, +children inherit no key environment, and the public permit and trust artifacts +are scanned with the actual key canary before output survives. Approval delay +cannot extend the original two-hour author session. This entry point still +requires the admitted source contracts and protected workflow integration. + +```text +python qualification/reference/expand.py \ + --reviewer \ + --candidate \ + --tuple --recipe --profile-lock \ + --resources --packets \ + --conformance --differential \ + --source-commit \ + --protected-run recipe-qualification// \ + --out-dir +``` + +The public packet carrier is `auths.qualification-public-packets/4`, with +`protected_run`, `evaluated_at`, `not_after`, `trusted_contexts` (1–4 sorted +unique adjacent filenames), and 1–64 `packets`. Packet validity is at most five minutes; the +signing expansion reviews native proofs at their recorded offline evaluation +time, within the last two hours. This grants no production clock override. +Each packet contains `label`, `proof`, `action`, `trusted_context` (adjacent filenames), and its +expected `arguments`. Unknown fields, path traversal, changed source, wrong +production targets, resource widening, actor changes or an oracle mismatch +prevent output. Filenames never select code. Private author keys are absent. +The signing expansion reconstructs the entire original source-owned packet +pool, including both phase operations and both fresh-challenge replays. Missing, +additional, reordered or rebound packets cannot receive a permit. A one-packet +refresh is a private execution handoff, never a new signing input. + +The two family directories now contain reviewed source plans, provider +contracts and record metadata. `family_corpus.py` expands their fixed 16-resource +public pool into complete native case sequences. `family_harness.py` executes +the six offline scenarios with the actual installed SDK and shipping reviewer; +the native runner validates the full plan before selecting that phase. The +workflow retains real canonical conformance and differential evidence. +Protected operations still refuse without the production journey implementation. +No family is qualified by landing this code. + +The native commissioning permit is now schema 2. Its closed +`trusted_contexts_sha256` list contains 1–4 sorted unique exact canonical context +hashes. Every installation still authenticates its actual context separately; +all listed contexts share one immutable run/family lease budget. An unlisted +context, duplicate or reordered list, changed set, or schema 1 permit refuses. +The public packet author supplies two native challenges under one exact actor +and reviewed grants. Each phase's first resource has a `-fresh` replay packet with the same +logical operation and arguments, under the second context. Refresh never changes +that packet's assigned challenge, action or context. The full qualification plan +uses exactly 16 resources. Its closed pool has 42 Airtable or 54 Stripe packets, +including four distinct custody-drift operations per phase. Smaller synthetic +operator checks do not establish complete-corpus coverage. Stripe also has two exact +guard probes per phase: 1001 cents against a 2000-cent payment, and EUR against +that USD payment. Their native proofs authorize request construction; the real +relative-ceiling read must refuse them after one custody lease and before any +write. A permit never replaces that guard. The grant has bounded USD/EUR sum +partitions so the currency probe can reach the provider-read guard; every +payment remains scoped to the exact reviewed test ledger. Source plan schema 4 +refuses obsolete plans. The source-owned +protected corpus and native durable replay evidence still need integration. + +Stripe's count and sum boundary experiments use separate source-owned grants +under that same actor and trust. Count has capacity one and sum capacity 2000; +sum has count capacity two and sum capacity 1000. Each admits one 1000-cent +action at the exact 50% provider ceiling, followed by a distinct 500-cent +overflow operation. The count and sum positives use different test payments. +Their fixed windows are 2/3 days for commissioning and 5/7 days for ordinary +execution. Native counters already distinguish the exact subject, namespace +and window; no persisted counter is edited or reset. The default 64-count grant +cannot consume these experiment counters. The author expires before the next +real boundary of any of those windows. The live harness must measure the actual +capacity refusal and concurrent host behavior before any capability is claimed. + +`measure.py` validates and subtracts native execution snapshots: matching fresh +scope, no saturation/decrease, no duplicate host in an aggregate. Restarted +engines must be measured separately. Budget consumption is never substituted +for actual custody calls. + +`native_observation.py` projects actual native results and measured counter +deltas into corpus facts. A recorded HTTP response or unlinked value match +cannot become a confirmed effect. Linked evidence requires a new independent +provider read with the reviewed exact state/echo and identical raw response +digest. Its bounded journey ledger counts each measured entered write once; +read-only recovery can confirm that earlier write, and subsequent observations +cannot count it again. Changed tuples/actions, scope drift, duplicate entries, +unmeasured effects and additional result fields refuse. The helper does not +assert isolation, installed-consumer provenance or a passing wall; the family +harness must measure those separately and the native runner decides the case. + +`../run/tls_fault.py` is the external response-loss mechanism for a disposable +Linux gateway network namespace. UID-scoped REDIRECT rules select only the +reviewed provider's IPv4 addresses at port 443; the relay checks the original +destination and forwards the original TLS records. It has no TLS/private key, +decrypts nothing and changes no gateway setting. After the actual private +native counter stream shows one write entry, the relay holds encrypted server +records beyond the handshake. A root-only private control socket can drop or +release that hold. The source controller must independently confirm the +effect before dropping the response; traffic counts cannot confirm it. Scope, +inode, owner, frame, connection, buffer and time bounds fail closed. Disposable +TLS 1.2/1.3 source tests exercise real encrypted transport with synthetic +counters; native/provider qualification and network-namespace integration +remain separate required steps. + +`fresh_evidence.py` is the separate response oracle for the reviewed Stripe +refund and Airtable update. It validates the approved resource, exact intended +value, test-mode success (Stripe) and the action-derived echo, then hashes the +raw independently fetched response. It accepts no candidate response digest +or locator. Its subject binds the full reviewed request, resource ledger, +action and expected state. The corpus runner compares this fresh witness with +the candidate's own digest. Synthetic unit responses test refusals and exact +bytes; they are not qualification evidence. + +### Disposable provider resource lifecycle + +`stripe_resources.py` prepares platform-account test payments with `pm_card_visa` +and the run marker `metadata.auths_qualification`. It requires separate test +setup and restricted runtime keys, supplied as closed JSON on private stdin +(`setup`, `runtime`). Each planned intent is journaled before POST and has a +run/index-specific idempotency key. A lost response can be recovered for one +hour with that same key. Cleanup verifies platform, test mode, exact payment, +run marker and charge, refunds only the remaining test balance and confirms +full retirement with a fresh read. Recovering a pending setup during cleanup +may create and immediately retire its one pre-journaled test fixture. It never +uses Connect or a `Stripe-Account` header. + +`airtable_resources.py` accepts a personal access token on private stdin +(`token`) and uses only the reviewed dedicated base/table. Before any creation, +it journals the complete run/index-specific `Name` predicate. Exact filtered +fresh reads recover lost creation responses and determine the public ledger; +duplicate names cannot produce a qualification input. Cleanup discovers all +records under that predicate, rechecks ownership immediately before each +delete and confirms their absence. Other records, the table and the base remain. + +Both commands take `prepare --protected-run --count <1..32> +--journal --out ` or `cleanup --journal `. The output parent +must already be owner-private mode 0700. Public ledgers never contain tokens, +provider response bodies or payment client secrets. Journals are bounded, +written atomically and retained after partial failure for cleanup. Existing +outputs cannot be replaced. TLS requests refuse redirects and ambient proxies; +provider error bodies never enter diagnostics. + +`resource_summary.py` reconstructs the native record's sorted resource names +from the complete closed ledger. Stripe names the test platform and each test +payment; Airtable names the approved base, table and each owned record. The +projection checks run ownership, duplicate IDs, test mode and the native +96-byte name limit. Assembly uses this projection instead of accepting an +arbitrary string list or sorting the ledger object. + +Unit cases use synthetic providers, including lost responses, changed ownership, +foreign resources, duplicate records and unsafe files. Actual setup/cleanup +rehearsals are recorded separately and confer no protected qualification. + +Stripe's [Refund object](https://docs.stripe.com/api/refunds/object) has no +`livemode` attribute. Test mode is established by the test key/account balance +and the exact freshly checked PaymentIntent and Charge; the refund must bind +that same payment. An unexpected explicit live-mode marker is refused. Unit +responses follow the provider's actual Refund shape. + +### Installed author and delayed signing + +`packet_plan.py` derives public arguments for separate commissioning/live +operations on each closed resource. It preserves the exact reviewed lock and +the approved recipe substitutions, obtains the native verifier pin and Stripe +bounded extension, and scopes the Stripe grant to these payment identifiers. +No provider credential or caller-supplied argument enters the installed author. + +`author_packets.py` must run outside the checkout from an installed wheel, with +only `PATH`, `PYTHONNOUSERSITE` and optional locale variables. It refuses any +additional environment variable before importing the SDK. A fresh native key +authors one actor, the fixed reviewed grants, challenges and trust; no private key is exported. +The grant/session lasts at most two hours. Every action keeps the ordinary SDK +maximum of 300 seconds; a protected signing wait must not extend that limit. + +With `--serve`, the same isolated process holds its key in memory and accepts +bounded newline-delimited commands on private stdin. A refresh names only an +existing label and the next integer generation (1–1024); it cannot supply new +arguments, authority, actor, challenge or a destination path. It writes into a +new private `refresh-NNNN` directory. The refreshed proof uses current time, +while the canonical action, action commitment, exact grant, request and +installation context remain unchanged. Native context normalization preserves +the installation's request evaluation input; the gateway always rebinds its +own trusted current time. EOF, `close`, expiry, rollback or malformed commands +terminate the process. Keys and tokens are never transferred through artifacts. + +`check_packets.py` exercises the real installed author and shipping gateway with +explicitly synthetic resource carriers and no network/provider credential. It +checks both independent request mappings, original five-minute expiry, and an +actual refreshed native proof with the same actor/action/request/trust. CI +retains only its closed report. This is operator/release-tool evidence, not +protected live qualification. + +`author_socket.py serve` is the Linux runner connection for that same installed +author. It runs under a dedicated non-root UID, in an owner-private directory, +with the same empty-environment requirement. Its fixed `author.sock` has mode +0600, authenticates kernel peer credentials and accepts only the root operator +controller. `inspect`, `refresh` and `close` reconnect across runner steps; +generation state and the native key stay in the one process. A duplicate launch +cannot replace an existing socket. Refresh accepts only an original label and +copies public proof/action/context bytes into a new private controller directory, +after checking exact original arguments, action bytes, trust and normal validity. +An expired session, changed handoff or malformed command cannot export a packet. + +Keep the author's directory outside the publication tree; sockets and private +inputs are never artifacts. `check_socket.py` exercises the actual installed SDK +and shipping gateway under separate Linux UIDs, using synthetic resources and +no provider access. Its report explicitly confers no protected qualification. + +`run/artifact_wait.py` is the public mailbox reader for the protected sequence. +It accepts only this repository's main-branch manual qualification workflow, +exact source SHA, run and attempt, and five source-owned artifact roles. It +checks the authenticated run metadata and GitHub archive SHA-256, refuses +duplicates, expired artifacts, another attempt/repository, links, executable +files, traversal and oversized archives, and extracts only bounded public +protocol files with private permissions. Partial extraction is removed. +Missing artifacts are checked every ten minutes within a fixed two-hour +deadline. Only the GitHub Actions token enters the GitHub client; provider and +signer keys are absent from that child's environment. The native issuer/gateway +must still verify every permit/record: a transported artifact grants no authority. +The protected workflow has not yet connected this mailbox to admitted corpora. + +`author_operator.py` runs in the same credential-free installed environment as +packet authoring, in a separate process with a fresh operator key. It rechecks +the native recipe digest, lock and both exact contexts, reconstructs each +operator-request preimage, signs it with the SDK, and verifies the signature +with the native SDK verifier. The offline family job produces these actual +installation statements and checks that their operator differs from every +packet actor. This proves technical separation by key; production install +verification and human review are not claimed by its report. + +`production_setup.py` is the private controller for four real installations +(two process hosts and two exact challenge contexts). It binds the executable +bytes before install, requires the production PostgreSQL/TLS and AWS settings, +uses the operator writer and distinct runtime reader for install/join, and +checks each installed tuple byte-for-byte. Serving processes receive only the +runtime role. Private state, token paths and database credentials stay outside +publication; native counters come from the running process's admin socket. +The complete protected journey still needs to connect this controller to +resource preparation, mailbox exchanges and the family operations. + +`family_operations.py` retains measured effects across stage-runner processes. +Implemented handlers refresh only original packet labels, reauthenticate exact +action/context/request bindings, execute private commissioning or ordinary +application submissions, collect native counters, and require independent raw +provider read-back for linked outcomes. Replay, read-back, relative guards, +malformed proof/action, genuine credential rotation and the installed Python +client have handlers. Unimplemented cases refuse, so this partial operation +implementation cannot close either complete protected corpus. + +`controller_socket.py` connects those operations to the family harness using a +root-owned private socket with kernel peer checks. Requests carry only closed +family/case/index/operation coordinates. Credentials, command lines, expected +outcomes, packets and publication paths cannot be supplied over this interface. +The credential-free installed-author job exercises the real root peer transport. +`provider_readback.py` independently discovers Stripe refunds by the verified +action's echo within its owned payment, or reads the exact owned Airtable record; +it retains the provider's raw response bytes for digest comparison. + +The production controller's retained-author adapter now uses the same dedicated +installed-SDK process exercised by the credential-free socket job. Its private +signing session and refresh handoffs stay outside publication. The transparent +response-fault controller adds and removes individual UID-scoped rules; it +retains end-to-end TLS and requires actual native counters before arming. +Application and author UIDs can be denied both IPv4 and IPv6 egress. These +mechanisms do not establish protected case completion until the full journey +executes them and the native runner verifies its measured observations. diff --git a/qualification/reference/airtable_record.py b/qualification/reference/airtable_record.py new file mode 100644 index 000000000..1e817f48a --- /dev/null +++ b/qualification/reference/airtable_record.py @@ -0,0 +1,75 @@ +"""Independent Airtable field-update reference, confined to release tooling.""" + +import copy +import urllib.parse + +from common import canonical, closed, digest, echo, identifier, require, text + +FAMILY = 'airtable-record-update-v1' +SERVICE = 'airtable-gateway-demo' +TOOL = 'set_demo_status_v1' +ENVIRONMENT = 'disposable-live-resources' + +# The owner-authorized dedicated field-lab table. A different fixed base/table +# changes the recipe digest and requires an explicit reference review. +BASE = 'appQD3Qf0YFBCW9bV' +TABLE = 'tblBx2jwe0IsPYRKT' + + +def resources(value, protected_run): + closed(value, ['schema', 'protected_run', 'base', 'table', 'records']) + require(value['schema'] == 'auths.airtable-record-qualification-resources/1' + and value['protected_run'] == protected_run + and value['base'] == BASE and value['table'] == TABLE, + 'qualification.reference.resource-binding') + require(type(value['records']) is list and 1 <= len(value['records']) <= 32, + 'qualification.reference.resource-bound') + seen = set() + for record in value['records']: + closed(record, ['id', 'run_metadata']) + record_id = identifier(record['id'], r'rec[A-Za-z0-9]{14}') + require(record_id not in seen and record['run_metadata'] == protected_run, + 'qualification.reference.resource-binding') + seen.add(record_id) + return value + + +def request(arguments, bound_resources, action_commitment, recipe_digest): + closed(arguments, ['operator_namespace', 'operation_id', 'recipe_digest', + 'record_id', 'replacement']) + require(arguments['operator_namespace'] == 'airtable-demo' + and digest(arguments['recipe_digest']) == digest(recipe_digest), + 'qualification.reference.recipe-binding') + operation = text(arguments['operation_id'], maximum=128) + require(any(record['id'] == arguments['record_id'] for record in bound_resources['records']), + 'qualification.reference.resource-binding') + require(arguments['replacement'] in ['Approved', 'Pending'], + 'qualification.reference.replacement') + token = echo('airtable-demo', operation, action_commitment) + path = '/'.join(urllib.parse.quote(segment, safe='') for segment in + ['v0', BASE, TABLE, arguments['record_id']]) + return { + 'method': 'PATCH', 'url': 'https://api.airtable.com/' + path, + 'content_type': 'application/json', + 'body': canonical({'fields': {'DemoStatus': arguments['replacement'], + 'auths_echo': token}}).decode(), + 'headers': [], 'idempotency_key': None, + } + + +def recipe(template, bound_resources): + require(template['service'] == SERVICE and template['tool'] == TOOL + and template['origin'] == 'https://api.airtable.com', + 'qualification.reference.recipe-binding') + result = copy.deepcopy(template) + expected = [ + {'kind': 'fixed', 'value': 'v0'}, + {'kind': 'fixed', 'value': 'appTEST0000000001'}, + {'kind': 'fixed', 'value': 'tblTEST0000000001'}, + {'kind': 'field', 'name': 'record_id'}, + ] + for path in [result['write']['path'], result['observation']['path']]: + require(path == expected, 'qualification.reference.recipe-binding') + path[1]['value'] = bound_resources['base'] + path[2]['value'] = bound_resources['table'] + return result diff --git a/qualification/reference/airtable_resources.py b/qualification/reference/airtable_resources.py new file mode 100644 index 000000000..71c100e66 --- /dev/null +++ b/qualification/reference/airtable_resources.py @@ -0,0 +1,139 @@ +"""Prepare/retire owned records only in the reviewed disposable Airtable table.""" + +import argparse +from pathlib import Path +import urllib.parse + +from common import canonical, closed, identifier, integer, require +import airtable_record as reference +import resource_io as io + +SCHEMA = 'auths.airtable-resource-preparation/1' +TABLE_PATH = '/v0/' + reference.BASE + '/' + reference.TABLE + + +class Resources: + def __init__(self, token, api=None): + self.token = token + self.api = api or self.http + + def http(self, method, path, fields=None): + return io.request('https://api.airtable.com', method, path, self.token, + None if fields is None else canonical(fields), + {'Content-Type': 'application/json'} if fields is not None else None) + + @staticmethod + def names(run, count): + return ['Auths qualification ' + run + ' #' + str(index).zfill(2) + for index in range(count)] + + def discover(self, names): + # Names contain no quotes: run grammar is checked before constructing + # this exact predicate. No unrelated record is downloaded or selected. + formula = 'OR(' + ','.join("{Name}='" + name + "'" for name in names) + ')' + value = self.api('GET', TABLE_PATH + '?' + urllib.parse.urlencode({ + 'filterByFormula': formula, 'pageSize': 100, + })) + require(type(value.get('records')) is list and len(value['records']) <= 64 + and 'offset' not in value, 'qualification.resources.discovery-bound') + seen = set() + for record in value['records']: + record_id = identifier(record.get('id'), r'rec[A-Za-z0-9]{14}') + require(record_id not in seen and type(record.get('fields')) is dict + and record['fields'].get('Name') in names, + 'qualification.resources.record-binding') + seen.add(record_id) + return value['records'] + + def journal(self, path): + value = io.read(path) + closed(value, ['schema', 'protected_run', 'base', 'table', 'count', 'retired']) + require(value['schema'] == SCHEMA and value['base'] == reference.BASE + and value['table'] == reference.TABLE and type(value['retired']) is bool, + 'qualification.resources.ledger-binding') + io.run_id(value['protected_run']) + integer(value['count'], 1, 32) + return value + + def prepare(self, run, count, journal, output): + io.run_id(run) + integer(count, 1, 32) + journal, output = Path(journal), Path(output) + require(not output.exists(), 'qualification.resources.output-exists') + if journal.exists(): + value = self.journal(journal) + require(value['protected_run'] == run and value['count'] == count + and not value['retired'], 'qualification.resources.ledger-binding') + else: + value = {'schema': SCHEMA, 'protected_run': run, 'base': reference.BASE, + 'table': reference.TABLE, 'count': count, 'retired': False} + # The complete ownership predicate is durable before a POST. This + # also covers a lost creation response or interruption before save. + io.write(journal, value, new=True) + names = self.names(run, count) + existing = self.discover(names) + indexed = {} + for record in existing: + name = record['fields']['Name'] + require(name not in indexed, 'qualification.resources.ambiguous-record') + indexed[name] = record + for name in names: + if name in indexed: + continue + record = self.api('POST', TABLE_PATH, {'fields': {'Name': name, 'DemoStatus': 'Pending'}}) + identifier(record.get('id'), r'rec[A-Za-z0-9]{14}') + require(type(record.get('fields')) is dict and record['fields'].get('Name') == name + and record['fields'].get('DemoStatus') == 'Pending', + 'qualification.resources.record-binding') + # The fresh query, rather than a successful POST or remembered locator, + # determines the exact records the qualification ledger may expose. + fresh = self.discover(names) + require(len(fresh) == count and {record['fields']['Name'] for record in fresh} == set(names), + 'qualification.resources.ambiguous-record') + ledger = {'schema': 'auths.airtable-record-qualification-resources/1', + 'protected_run': run, 'base': reference.BASE, 'table': reference.TABLE, + 'records': [{'id': record['id'], 'run_metadata': run} + for record in sorted(fresh, key=lambda record: record['fields']['Name'])]} + reference.resources(ledger, run) + io.write(output, ledger, new=True) + + def cleanup(self, journal): + value = self.journal(journal) + if value['retired']: + return + names = self.names(value['protected_run'], value['count']) + # Includes ambiguous creations not retained in the public ledger. Even + # duplicate owned names are retired; no unrelated record is touched. + for record in self.discover(names): + fresh = self.api('GET', TABLE_PATH + '/' + record['id']) + require(fresh.get('id') == record['id'] and type(fresh.get('fields')) is dict + and fresh['fields'].get('Name') == record['fields']['Name'], + 'qualification.resources.record-binding') + removed = self.api('DELETE', TABLE_PATH + '/' + record['id']) + require(removed.get('id') == record['id'] and removed.get('deleted') is True, + 'qualification.resources.cleanup-refused') + require(not self.discover(names), 'qualification.resources.cleanup-unconfirmed') + value['retired'] = True + io.write(journal, value) + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest='command', required=True) + prepare = sub.add_parser('prepare') + prepare.add_argument('--protected-run', required=True) + prepare.add_argument('--count', type=int, default=1) + prepare.add_argument('--out', type=Path, required=True) + for command in [prepare, sub.add_parser('cleanup')]: + command.add_argument('--journal', type=Path, required=True) + args = parser.parse_args() + resources = Resources(io.credentials({'token': 'pat'})['token']) + if args.command == 'prepare': + resources.prepare(args.protected_run, args.count, args.journal, args.out) + else: + resources.cleanup(args.journal) + print('qualification.resources.' + args.command + '-complete') + + +if __name__ == '__main__': + io.finish(main) diff --git a/qualification/reference/author_operator.py b/qualification/reference/author_operator.py new file mode 100644 index 000000000..3e8455344 --- /dev/null +++ b/qualification/reference/author_operator.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Sign installation statements with a separate installed-SDK operator key. + +The credential-free process keeps its key in memory, signs only the source +installation's two contexts, and exports no key or qualification authority. +This establishes technical separation by key, never a human release review. +""" + +import argparse +import base64 +from pathlib import Path +import time + +from author_packets import installed_native +from common import canonical, closed, require, sha256 +from expand import child, decode, read +from resource_io import finish, write + +SCHEMA = 'auths.gateway-operator-attestation/1' +ALIAS = 'recipe-qualification' + + +def checked_statement(request, key, installation): + closed(request, ['statement', 'preimage_b64']) + statement = request['statement'] + closed(statement, ['schema', 'operator_principal', 'principal_method', + 'verification_method', 'signature_suite', 'installation', 'issued_at']) + now = int(time.time()) + require(statement['schema'] == SCHEMA + and statement['operator_principal'] == key.principal + and statement['principal_method'] == key.principal_method + and statement['verification_method'] == key.verification_method + and statement['signature_suite'] == key.suite + and statement['installation'] == installation + and type(statement['issued_at']) is int + and now - 60 <= statement['issued_at'] <= now, + 'qualification.operator.statement-binding') + preimage = SCHEMA.encode() + b'\0' + canonical(statement) + require(request['preimage_b64'] == base64.urlsafe_b64encode(preimage).rstrip(b'=').decode(), + 'qualification.operator.preimage-binding') + return statement, preimage + + +def author(gateway, work): + native, version = installed_native() + key = native.DevelopmentEd25519Key.generate() + tuple_value = decode(read(work / 'tuple.json', 65536)) + carrier = decode(read(work / 'public-packets.json', 65536)) + require(carrier['trusted_contexts'] == ['context-0.cbor', 'context-1.cbor'], + 'qualification.operator.context-binding') + family = tuple_value['recipe_family'] + require(family in ['stripe-platform-refund-v1', 'airtable-record-update-v1'], + 'qualification.operator.family') + provider = 'stripe' if family == 'stripe-platform-refund-v1' else 'airtable' + review = child(gateway, ['review', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json']) + require(review['recipe_digest'] == tuple_value['compiled_recipe_sha256'], + 'qualification.operator.recipe-binding') + for context in carrier['trusted_contexts']: + installation = {'recipe_digest': tuple_value['compiled_recipe_sha256'], + 'profile_lock_sha256': sha256(read(work / 'profile.lock.json', 65536)), + 'trusted_context_sha256': sha256(read(work / context, 4 * 1024 * 1024)), + 'provider': provider, 'alias': ALIAS, 'deployment': 'production'} + require(installation['profile_lock_sha256'] == tuple_value['profile_lock_sha256'], + 'qualification.operator.lock-binding') + request = child(gateway, ['operator-request', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json', '--trusted-context', work / context, + '--provider', provider, '--alias', ALIAS, '--deployment', 'production', + '--operator-principal', key.principal, '--principal-method', key.principal_method, + '--verification-method', key.verification_method, '--signature-suite', key.suite]) + statement, preimage = checked_statement(request, key, installation) + signature = bytes(key.sign(preimage)) + native.verify_ed25519_preimage_v1(bytes(key.public_key), preimage, signature) + write(work / (context + '.operator.json'), {'statement': statement, + 'signature_b64': base64.urlsafe_b64encode(signature).rstrip(b'=').decode(), + 'evidence': [{'evidence_type': key.evidence_type, 'media_type': key.media_type, + 'bytes_b64': base64.urlsafe_b64encode(bytes(key.evidence)).rstrip(b'=').decode()}]}, new=True) + write(work / 'operator-report.json', {'schema': 'auths.qualification-operator-author/1', + 'sdk_version': version, 'operator_principal': key.principal, + 'contexts': carrier['trusted_contexts'], 'private_key_exported': False, + 'provider_token_received': False, 'repository_imported': False, + 'human_review_claimed': False, 'production_install_verified': False, + 'qualification_issued': False}, new=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--gateway', type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--work', type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: author(args.gateway, args.work)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/author_packets.py b/qualification/reference/author_packets.py new file mode 100644 index 000000000..b8af937f0 --- /dev/null +++ b/qualification/reference/author_packets.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Author public qualification packets with an installed SDK and no credential. + +Run with an empty environment outside the checkout. Native SDK primitives own +every grant, context, signature, challenge and canonical protocol commitment. +No private signing key is written, exported or reused across author runs. +""" + +import argparse +import importlib.metadata +import os +from pathlib import Path +import select +import sys +import sysconfig +import time + +from common import Refusal, canonical, closed, integer, require, sha256 +from expand import decode, read +from packet_plan import REFERENCES, grant_for, validate +from resource_io import finish, write, write_bytes + + +def installed_native(): + # Environment is a positive allowlist, so an unanticipated credential name + # cannot silently reach this process. Callers must construct its environment. + require(set(os.environ) <= {'PATH', 'PYTHONNOUSERSITE', 'LC_CTYPE', 'LANG'}, + 'qualification.packets.consumer-environment') + try: + import auths + from auths import _native as native + except ImportError: + raise Refusal('qualification.packets.sdk-unavailable') from None + package = Path(auths.__file__).resolve() + installed_roots = [Path(sysconfig.get_path(name)).resolve() for name in ['purelib', 'platlib']] + require(any(package.is_relative_to(root) for root in installed_roots) + and 'site-packages' in package.parts, 'qualification.packets.repository-import') + require(Path(native.__file__).resolve().parent == package.parent, + 'qualification.packets.native-package') + return native, importlib.metadata.version('auths') + + +def create_session(plan_path): + plan = validate(decode(read(plan_path, 65536))) + now = int(time.time()) + require(plan['evaluated_at'] <= now <= plan['evaluated_at'] + 60, + 'qualification.packets.stale-plan') + native, version = installed_native() + reference = REFERENCES[plan['family']] + current, end = plan['evaluated_at'], plan['not_after'] + audience = 'mcp://' + reference.SERVICE + resource = audience + '/tools/' + reference.TOOL + key = native.DevelopmentEd25519Key.generate() + actor = native.Principal(key.principal) + extension = plan['extension'] + grants = {} + for name, body in ({'default': None} if extension is None else extension).items(): + extensions = [] if body is None else [(body['extension_id'], bytes.fromhex(body['extension_body_hex']))] + request = native.GrantRequest(actor, 'auths.mcp', 2, [('tools/call', resource)], + current - 60, end, [audience], None, None, 0, None, 'raw-key-baseline', extensions) + unsigned = native.root_grant(actor, request) + signing = native.prepare_signing(unsigned, key.principal_method, key.verification_method, key.suite) + grants[name] = signing.complete(key.sign(signing.signing_preimage)) + challenges = {name: native.generate_challenge_v1() for name in ['initial', 'fresh']} + require(challenges['initial'] != challenges['fresh'], 'qualification.packets.challenge-binding') + anchor = native.TrustAnchor(actor.value, actor, [key.principal_method], [('auths.mcp', 2)], + [('tools/call', resource)], [audience], [audience], current - 60, end, + None, 1, 'raw-key-baseline', None) + assurance = native.AssurancePolicy('raw-key-baseline', [ + ('root', 'every', 'self-certifying-identifier', None), + ('actor', 'every', 'self-certifying-identifier', None), + ('actor', 'every', 'offline-verifiable', None)]) + template = native.compile_trusted_context(bytes.fromhex(plan['configuration']), None, 1, 1, 1, + [anchor], assurance, None, None, 'none-v1', [key.evidence_type], + [] if extension is None else [extension['default']['extension_id']]) + contexts = {name: template.bind_request(audience, challenge, current) + for name, challenge in challenges.items()} + evidence = (key.evidence_type, key.media_type, key.evidence) + # The closure alone retains this native key. A refresh accepts a known + # label, never caller-supplied arguments, a new grant, actor or challenge. + retained_contexts, last_time = {}, current + def emit(work, label=None): + nonlocal last_time + current = int(time.time()) + require(last_time <= current < end, 'qualification.packets.session-expired') + last_time = current + validity = min(300, end - current) + selected = [packet for packet in plan['packets'] if label is None or packet['label'] == label] + require(bool(selected), 'qualification.packets.unknown-label') + packets, emitted_contexts = [], set() + for packet in selected: + label, arguments = packet['label'], packet['arguments'] + grant = grants[grant_for(label)] + context_name = packet['context'] + challenge, context = challenges[context_name], contexts[context_name] + context_file = 'context-' + str(['initial', 'fresh'].index(context_name)) + '.cbor' + call = native.mcp_call(reference.SERVICE, reference.TOOL, canonical(arguments)) + prepared = native.prepare_mcp_call_action(call, actor, grant, challenge, current, validity) + signing = native.prepare_signing(prepared.unsigned, key.principal_method, key.verification_method, key.suite) + signed_action = signing.complete(key.sign(signing.signing_preimage)) + proof, action, trust = native.assemble_mcp_proof(prepared, signed_action, [grant], + [[evidence]], [evidence], context) + # Assembly binds its returned context to the fresh envelope time. + # Keep each declared installation context exact: native normalization changes + # only the request evaluation input. The gateway always supplies + # its own synchronized current time when verifying this proof. + trust = bytes(native.inspect_trusted_context(native.parse_trusted_context(trust) + .bind_request(audience, challenge, plan['evaluated_at']))) + require(context_name not in retained_contexts or retained_contexts[context_name] == trust, + 'qualification.packets.context-binding') + retained_contexts[context_name] = bytes(trust) + write_bytes(work / (label + '.proof'), bytes(proof), new=True) + write_bytes(work / (label + '.action'), bytes(action), new=True) + if context_file not in emitted_contexts: + write_bytes(work / context_file, retained_contexts[context_name], new=True) + emitted_contexts.add(context_file) + packets.append({'label': label, 'proof': label + '.proof', 'action': label + '.action', + 'trusted_context': context_file, 'arguments': arguments}) + write(work / 'public-packets.json', {'schema': 'auths.qualification-public-packets/4', + 'protected_run': plan['protected_run'], 'evaluated_at': current, 'not_after': current + validity, + 'trusted_contexts': sorted(emitted_contexts), 'packets': packets}, new=True) + write(work / 'author-report.json', {'schema': 'auths.qualification-packet-author/3', + 'family': plan['family'], 'protected_run': plan['protected_run'], 'sdk_version': version, + 'packet_count': len(packets), 'trusted_contexts_sha256': { + name: sha256(read(work / name, 4 * 1024 * 1024)) for name in sorted(emitted_contexts)}, + 'private_key_exported': False, 'provider_token_received': False, + 'repository_imported': False, 'qualification_issued': False}, new=True) + return emit, end + + +def command(raw, generation): + require(0 < len(raw) <= 512 and raw.endswith(b'\n'), 'qualification.packets.command-bound') + value = decode(raw) + require(type(value) is dict, 'qualification.packets.command-bound') + if value.get('command') == 'close': + closed(value, ['command']) + return None + closed(value, ['command', 'label', 'generation']) + require(value['command'] == 'refresh' and type(value['label']) is str, + 'qualification.packets.command-bound') + require(integer(value['generation'], 1, 1024) == generation + 1, + 'qualification.packets.generation') + return value + + +def author(plan_path, work, serve=False): + emit, end = create_session(plan_path) + emit(work) + if not serve: + return + print('{"schema":"auths.qualification-author-session/1","state":"ready"}', flush=True) + generation = 0 + deadline = time.monotonic() + max(0, end - time.time()) + last_clock = time.time() + # Bounded pipe reads use os.read rather than buffered readline: select + # must not miss an already-buffered second command. Partial frames have + # the same finite grant deadline and cannot extend the process lifetime. + pending = b'' + while True: + now = time.time() + require(now >= last_clock, 'qualification.packets.session-expired') + last_clock = now + remaining = min(end - now, deadline - time.monotonic()) + require(remaining > 0, 'qualification.packets.session-expired') + ready, _, _ = select.select([sys.stdin.fileno()], [], [], remaining) + require(bool(ready), 'qualification.packets.session-expired') + chunk = os.read(sys.stdin.fileno(), 513) + if not chunk: + require(not pending, 'qualification.packets.command-bound') + return + pending += chunk + require(len(pending) <= 512, 'qualification.packets.command-bound') + if b'\n' not in pending: + continue + require(pending.count(b'\n') == 1 and pending.endswith(b'\n'), + 'qualification.packets.command-bound') + value = command(pending, generation) + pending = b'' + if value is None: + return + generation = value['generation'] + destination = work / ('refresh-' + str(generation).zfill(4)) + destination.mkdir(mode=0o700) + emit(destination, value['label']) + print(canonical({'schema': 'auths.qualification-author-session/1', + 'state': 'refreshed', 'generation': generation, 'label': value['label']}).decode(), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--plan', type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--work', type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--serve', action='store_true') + args = parser.parse_args() + finish(lambda: author(args.plan, args.work, args.serve)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/author_socket.py b/qualification/reference/author_socket.py new file mode 100644 index 000000000..3c3e95bdd --- /dev/null +++ b/qualification/reference/author_socket.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Keep the isolated installed author alive across protected runner steps. + +Linux only. The author runs under a dedicated unprivileged UID with an empty +environment; only the root operator controller may connect. This is a local +release tool, never a gateway channel or an executable artifact for a signer. +""" + +import argparse +import os +from pathlib import Path +import socket +import stat +import struct +import time + +from author_packets import command, create_session +from common import canonical, closed, require +from expand import decode, read +from resource_io import finish, write_bytes + +SCHEMA = 'auths.qualification-author-socket/1' + + +def private_work(work, *, author): + info = os.lstat(work) + require(stat.S_ISDIR(info.st_mode) and stat.S_IMODE(info.st_mode) == 0o700 + and (info.st_uid == os.getuid() if author else info.st_uid != 0), + 'qualification.packets.socket-directory') + require(hasattr(socket, 'SO_PEERCRED') and (os.getuid() != 0 if author else os.getuid() == 0), + 'qualification.packets.socket-isolation') + + +def peer_uid(connection): + return struct.unpack('3i', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))[1] + + +def receive(connection, deadline): + pending = b'' + while b'\n' not in pending: + remaining = min(30, deadline - time.monotonic()) + require(remaining > 0, 'qualification.packets.session-expired') + connection.settimeout(remaining) + chunk = connection.recv(513 - len(pending)) + require(bool(chunk), 'qualification.packets.command-bound') + pending += chunk + require(len(pending) <= 512, 'qualification.packets.command-bound') + require(pending.count(b'\n') == 1 and pending.endswith(b'\n'), + 'qualification.packets.command-bound') + return pending + + +def send(connection, value): + payload = canonical(value) + b'\n' + require(len(payload) <= 512, 'qualification.packets.command-bound') + connection.sendall(payload) + + +def serve(plan, work): + private_work(work, author=True) + endpoint = work / 'author.sock' + # bind refuses an existing path. Nothing unlinks an unknown socket, file + # or symlink; a second launch cannot replace the first author's authority. + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(endpoint)) + os.chmod(endpoint, 0o600) + identity = os.lstat(endpoint) + try: + server.listen(1) + emit, end = create_session(plan) + emit(work) + deadline = time.monotonic() + max(0, end - time.time()) + last_clock = time.time() + generation = 0 + while True: + now = time.time() + require(now >= last_clock, 'qualification.packets.session-expired') + last_clock = now + remaining = min(end - now, deadline - time.monotonic()) + require(remaining > 0, 'qualification.packets.session-expired') + server.settimeout(remaining) + connection, _ = server.accept() + with connection: + require(peer_uid(connection) == 0, 'qualification.packets.socket-peer') + connection.settimeout(min(30, remaining)) + send(connection, {'schema': SCHEMA, 'state': 'ready', 'generation': generation}) + raw = receive(connection, deadline) + if decode(raw) == {'command': 'inspect'}: + send(connection, {'schema': SCHEMA, 'state': 'ready', 'generation': generation}) + continue + value = command(raw, generation) + if value is None: + send(connection, {'schema': SCHEMA, 'state': 'closed'}) + return + generation = value['generation'] + destination = work / ('refresh-' + str(generation).zfill(4)) + destination.mkdir(mode=0o700) + emit(destination, value['label']) + send(connection, {'schema': SCHEMA, 'state': 'refreshed', + 'generation': generation, 'label': value['label']}) + finally: + # Only remove the exact socket we bound, even during failed setup. + if endpoint.exists(): + current = os.lstat(endpoint) + if stat.S_ISSOCK(current.st_mode) and (current.st_dev, current.st_ino) == (identity.st_dev, identity.st_ino): + endpoint.unlink() + + +def exchange(work, action, label=None): + private_work(work, author=False) + endpoint = work / 'author.sock' + info = os.lstat(endpoint) + require(stat.S_ISSOCK(info.st_mode) and stat.S_IMODE(info.st_mode) == 0o600 + and info.st_uid == os.lstat(work).st_uid, 'qualification.packets.socket-identity') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(30) + connection.connect(str(endpoint)) + require(peer_uid(connection) == info.st_uid, 'qualification.packets.socket-peer') + ready = decode(receive(connection, time.monotonic() + 30)) + closed(ready, ['schema', 'state', 'generation']) + require(ready['schema'] == SCHEMA and ready['state'] == 'ready' + and type(ready['generation']) is int and 0 <= ready['generation'] <= 1024, + 'qualification.packets.socket-response') + value = {'command': action} + if action == 'refresh': + value.update(label=label, generation=ready['generation'] + 1) + else: + require(action in ['inspect', 'close'], 'qualification.packets.command-bound') + send(connection, value) + result = decode(receive(connection, time.monotonic() + 30)) + expected = dict(ready) if action == 'inspect' else {'schema': SCHEMA, 'state': 'closed'} + if action == 'refresh': + expected = {'schema': SCHEMA, 'state': 'refreshed', + 'generation': value['generation'], 'label': label} + require(result == expected, 'qualification.packets.socket-response') + return result + + +def refresh(work, label, destination): + # A label can select only an original source-derived action. Check it + # before contacting the key holder; no caller-supplied arguments or paths + # enter its protocol. The root controller copies only public bytes out. + original = decode(read(work / 'public-packets.json', 65536)) + matches = [packet for packet in original['packets'] if packet['label'] == label] + require(len(matches) == 1, 'qualification.packets.unknown-label') + packet = matches[0] + require(packet['proof'] == label + '.proof' and packet['action'] == label + '.action' + and packet['trusted_context'] in ['context-0.cbor', 'context-1.cbor'] + and packet['trusted_context'] in original['trusted_contexts'], 'qualification.packets.socket-response') + require(not destination.exists(), 'qualification.packets.output-exists') + result = exchange(work, 'refresh', label) + source = work / ('refresh-' + str(result['generation']).zfill(4)) + fresh = decode(read(source / 'public-packets.json', 65536)) + now = int(time.time()) + require(set(fresh) == set(original) and fresh['schema'] == original['schema'] + and fresh['protected_run'] == original['protected_run'] + and fresh['trusted_contexts'] == [packet['trusted_context']] and fresh['packets'] == [packet] + and type(fresh['evaluated_at']) is int and type(fresh['not_after']) is int + and now - 30 <= fresh['evaluated_at'] <= now + and now + 60 <= fresh['not_after'] <= fresh['evaluated_at'] + 300, + 'qualification.packets.refresh-binding') + payloads = {name: read(source / name, bound) for name, bound in [ + (packet['proof'], 4 * 1024 * 1024), (packet['action'], 65536), + (packet['trusted_context'], 4 * 1024 * 1024), ('public-packets.json', 65536)]} + require(payloads[packet['action']] == read(work / packet['action'], 65536) + and payloads[packet['trusted_context']] == read(work / packet['trusted_context'], 4 * 1024 * 1024), + 'qualification.packets.refresh-binding') + destination.mkdir(mode=0o700) + for name, payload in payloads.items(): + write_bytes(destination / name, payload, new=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest='command', required=True) + for action in ['serve', 'inspect', 'refresh', 'close']: + command_parser = subparsers.add_parser(action) + command_parser.add_argument('--work', type=lambda value: Path(value).absolute(), required=True) + if action == 'serve': + command_parser.add_argument('--plan', type=lambda value: Path(value).absolute(), required=True) + if action == 'refresh': + command_parser.add_argument('--label', required=True) + command_parser.add_argument('--out', type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + def execute(): + if args.command == 'serve': + serve(args.plan, args.work) + elif args.command == 'refresh': + refresh(args.work, args.label, args.out) + else: + exchange(args.work, args.command) + finish(execute) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/check_packets.py b/qualification/reference/check_packets.py new file mode 100644 index 000000000..fc5fb5798 --- /dev/null +++ b/qualification/reference/check_packets.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Credential-free installed-author/native-review operator check. + +The resource carriers are explicitly synthetic; no provider is contacted and +no qualification is issued. The shipping gateway and installed SDK are real. +""" + +import argparse +import json +import os +from pathlib import Path +import select +import re +import subprocess +import time + +import airtable_record +import stripe_platform +from common import Refusal, canonical, require, sha256 +from expand import child, decode, read +from packet_plan import prepare, public_pool +from resource_io import finish, write + + +def response(process): + require(bool(select.select([process.stdout], [], [], 30)[0]), 'qualification.packets.author-timeout') + raw = process.stdout.readline(513) + if not raw: + _, stderr = process.communicate(timeout=10) + codes = re.findall(rb'qualification\.[a-z0-9.-]{1,128}', stderr[:65536]) + raise Refusal(codes[-1].decode() if codes else 'qualification.packets.author-refused') + require(0 < len(raw) <= 512 and raw.endswith(b'\n'), 'qualification.packets.author-response') + return decode(raw) + + +def review(binary, work, packet, evaluated_at): + return child(binary, ['review-submission', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json', '--trusted-context', work / packet['trusted_context'], + '--proof', work / packet['proof'], '--action', work / packet['action'], + '--evaluated-at', str(evaluated_at)]) + + +def check(args): + require(not args.work.exists(), 'qualification.packets.output-exists') + args.work.mkdir(mode=0o700) + run = 'packet-operator-rehearsal/' + str(int(time.time())) + '/1' + reports = [] + for reference in [stripe_platform, airtable_record]: + work = args.work / reference.FAMILY + work.mkdir(mode=0o700) + resources = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': run, 'platform': 'acct_SYNTHETIC', 'payments': [ + {'id': 'pi_SYNTHETIC', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run}, + {'id': 'pi_SYNTHETIC2', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run}]} if reference is stripe_platform else { + 'schema': 'auths.airtable-record-qualification-resources/1', 'protected_run': run, + 'base': airtable_record.BASE, 'table': airtable_record.TABLE, 'records': [ + {'id': 'recTEST0000000001', 'run_metadata': run}]} + write(work / 'resources.json', resources, new=True) + prepare(argparse.Namespace(family=reference.FAMILY, resources=work / 'resources.json', + gateway=args.gateway, work=work)) + # Python runs outside the checkout, with no editable package, provider + # token, home directory, ambient PYTHONPATH or repository import path. + process = subprocess.Popen([str(args.python), str(Path(__file__).with_name('author_packets.py')), + '--plan', str(work / 'packet-plan.json'), '--work', str(work), '--serve'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd='/', env={'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'}) + try: + require(response(process) == {'schema': 'auths.qualification-author-session/1', 'state': 'ready'}, + 'qualification.packets.author-response') + plan = decode(read(work / 'public-packets.json', 65536)) + public_pool(reference.FAMILY, resources, plan['packets'][0]['arguments']['recipe_digest'], plan) + reviewed = [] + for packet in plan['packets']: + value = review(args.gateway, work, packet, plan['evaluated_at']) + expected = reference.request(packet['arguments'], resources, value['action_commitment'], + packet['arguments']['recipe_digest']) + require(value['schema'] == 'auths.gateway-submission-review/1' + and value['arguments'] == packet['arguments'] and value['request'] == expected, + 'qualification.packets.oracle-mismatch') + reviewed.append(value) + require(plan['trusted_contexts'] == ['context-0.cbor', 'context-1.cbor'] + and read(work / 'context-0.cbor', 4 * 1024 * 1024) + != read(work / 'context-1.cbor', 4 * 1024 * 1024), + 'qualification.packets.challenge-binding') + by_label = {packet['label']: (packet, actual) for packet, actual in zip(plan['packets'], reviewed)} + for phase in ['commissioning', 'live']: + original_packet, original_review = by_label[phase + '-00'] + fresh_packet, fresh_review = by_label[phase + '-00-fresh'] + require(original_packet['arguments'] == fresh_packet['arguments'] + and original_packet['trusted_context'] != fresh_packet['trusted_context'] + and original_review['actors'] == fresh_review['actors'], + 'qualification.packets.challenge-binding') + # The old proof must really expire at the ordinary five-minute + # bound. This is offline review, never a production clock override. + expired = review(args.gateway, work, plan['packets'][0], plan['not_after'] + 1) + require(expired.get('schema') != 'auths.gateway-submission-review/1', + 'qualification.packets.expiry-not-enforced') + time.sleep(1.05) + process.stdin.write(canonical({'command': 'refresh', 'label': plan['packets'][0]['label'], + 'generation': 1}) + b'\n') + process.stdin.flush() + require(response(process)['state'] == 'refreshed', 'qualification.packets.author-response') + fresh = work / 'refresh-0001' + # Refresh uses the same reviewed recipe and exact installed trust. + for name in ['recipe.json', 'profile.lock.json']: + (fresh / name).write_bytes(read(work / name, 65536)) + fresh_plan = decode(read(fresh / 'public-packets.json', 65536)) + actual = review(args.gateway, fresh, fresh_plan['packets'][0], fresh_plan['evaluated_at']) + require(actual == reviewed[0] + and read(fresh / plan['packets'][0]['trusted_context'], 4 * 1024 * 1024) + == read(work / plan['packets'][0]['trusted_context'], 4 * 1024 * 1024) + and read(fresh / plan['packets'][0]['action'], 65536) == read(work / plan['packets'][0]['action'], 65536) + and read(fresh / plan['packets'][0]['proof'], 4 * 1024 * 1024) != read(work / plan['packets'][0]['proof'], 4 * 1024 * 1024), + 'qualification.packets.refresh-binding') + process.stdin.write(b'{"command":"close"}\n') + process.stdin.flush() + _, stderr = process.communicate(timeout=10) + require(process.returncode == 0 and not stderr, 'qualification.packets.author-refused') + reports.append({'family': reference.FAMILY, 'packet_count': len(reviewed), + 'same_actor_action_request_and_trust_after_refresh': True, + 'fresh_challenges_keep_exact_actor_and_logical_operation': True, + 'original_five_minute_expiry_enforced': True, + 'author': decode(read(work / 'author-report.json', 65536))}) + finally: + if process.poll() is None: + process.terminate() + process.communicate(timeout=10) + write(args.work / 'report.json', {'schema': 'auths.qualification-packet-operator-rehearsal/1', + 'gateway_sha256': sha256(read(args.gateway, 256 * 1024 * 1024)), + 'synthetic_resources': True, 'provider_contacted': False, 'protected_qualification': False, + 'qualification_issued': False, 'stable_launch_ready': False, 'families': reports}, new=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ['gateway', 'python', 'work']: + parser.add_argument('--' + name, type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: check(args)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/check_socket.py b/qualification/reference/check_socket.py new file mode 100644 index 000000000..28050034f --- /dev/null +++ b/qualification/reference/check_socket.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Exercise the real installed author under a separate Linux UID. + +Uses synthetic resources and no provider/network/qualification authority. +The root controller reconnects between steps, as a protected Actions runner +will after waiting for a signing artifact. Only public bytes leave the author. +""" + +import argparse +import os +from pathlib import Path +import time + +import airtable_record +import stripe_platform +from retained_author import RetainedAuthor +from check_packets import review +from common import require, sha256 +from expand import decode, read +from packet_plan import prepare, public_pool +from resource_io import finish, write + +AUTHOR_UID = 62002 + + +def check(args): + require(os.getuid() == 0 and not args.work.exists(), 'qualification.packets.socket-isolation') + args.work.mkdir(mode=0o711) + os.chmod(args.work, 0o711) + reports = [] + for reference in [stripe_platform, airtable_record]: + work = args.work / reference.FAMILY + work.mkdir(mode=0o700) + run = 'socket-operator-rehearsal/' + str(int(time.time())) + '/1' + resources = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': run, 'platform': 'acct_SYNTHETIC', 'payments': [ + {'id': 'pi_SYNTHETIC', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run}]} if reference is stripe_platform else { + 'schema': 'auths.airtable-record-qualification-resources/1', 'protected_run': run, + 'base': airtable_record.BASE, 'table': airtable_record.TABLE, 'records': [ + {'id': 'recTEST0000000001', 'run_metadata': run}]} + write(work / 'resources.json', resources, new=True) + prepare(argparse.Namespace(family=reference.FAMILY, resources=work / 'resources.json', + gateway=args.gateway, work=work)) + author = RetainedAuthor(args.python, Path(__file__).parent, work, + args.work / (reference.FAMILY + '-private')) + try: + original = decode(read(work / 'public-packets.json', 65536)) + public_pool(reference.FAMILY, resources, original['packets'][0]['arguments']['recipe_digest'], original) + initial = review(args.gateway, work, original['packets'][0], original['evaluated_at']) + # Separate connections and fresh output directories simulate the + # runner handing public packets across independent job steps. + for generation, packet in enumerate(original['packets'], 1): + destination = author.refresh(packet['label']) + fresh = decode(read(destination / 'public-packets.json', 65536)) + for name in ['recipe.json', 'profile.lock.json']: + (destination / name).write_bytes(read(work / name, 65536)) + actual = review(args.gateway, destination, fresh['packets'][0], fresh['evaluated_at']) + expected = reference.request(packet['arguments'], resources, + actual['action_commitment'], packet['arguments']['recipe_digest']) + require(actual['request'] == expected and actual['actors'] == initial['actors'], + 'qualification.packets.refresh-binding') + require(author.generation == generation, + 'qualification.packets.generation') + author.close() + reports.append({'family': reference.FAMILY, 'author_uid': AUTHOR_UID, + 'controller_uid': 0, 'separate_connections': len(original['packets']), + 'same_actor_action_request_and_trust': True, 'socket_removed_on_close': True}) + finally: + author.abort() + write(args.report, {'schema': 'auths.qualification-author-socket-rehearsal/1', + 'gateway_sha256': sha256(read(args.gateway, 256 * 1024 * 1024)), + 'synthetic_resources': True, 'provider_contacted': False, + 'protected_qualification': False, 'qualification_issued': False, + 'stable_launch_ready': False, 'families': reports}, new=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ['gateway', 'python', 'work', 'report']: + parser.add_argument('--' + name, type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: check(args)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/common.py b/qualification/reference/common.py new file mode 100644 index 000000000..acd1c1c23 --- /dev/null +++ b/qualification/reference/common.py @@ -0,0 +1,65 @@ +"""Bounded byte/encoding primitives for reviewed, release-only references.""" + +import hashlib +import json +import re + + +class Refusal(ValueError): + """A stable, secret-free reference refusal.""" + + +def require(condition, code): + if not condition: + raise Refusal(code) + + +def closed(value, fields): + require(type(value) is dict and set(value) == set(fields), + 'qualification.reference.closed-fields') + + +def text(value, minimum=1, maximum=256): + require(type(value) is str and minimum <= len(value.encode('utf-8')) <= maximum + and all(ord(character) >= 32 for character in value), + 'qualification.reference.text-bound') + return value + + +def identifier(value, pattern): + require(type(value) is str and re.fullmatch(pattern, value) is not None, + 'qualification.reference.resource-binding') + return value + + +def integer(value, minimum, maximum): + require(type(value) is int and minimum <= value <= maximum, + 'qualification.reference.integer-bound') + return value + + +def digest(value): + return identifier(value, r'[0-9a-f]{64}') + + +def canonical(value): + # Reference carriers contain no floating-point values. Native code owns + # CBOR and protocol commitments; this is only the ASCII request oracle. + return json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False, allow_nan=False).encode('utf-8') + + +def sha256(value): + return hashlib.sha256(value).hexdigest() + + +def echo(namespace, operation, action_commitment): + digest(action_commitment) + return 'auths-e1-' + sha256(b'auths.gateway-echo/1\0' + namespace.encode() + + b'\0' + operation.encode() + b'\0' + + bytes.fromhex(action_commitment)) + + +def idempotency(namespace, operation): + return 'auths-i1-' + sha256(b'auths.gateway-idempotency-key/1\0' + + namespace.encode() + b'\0' + operation.encode()) diff --git a/qualification/reference/controller_socket.py b/qualification/reference/controller_socket.py new file mode 100644 index 000000000..75369b5e7 --- /dev/null +++ b/qualification/reference/controller_socket.py @@ -0,0 +1,132 @@ +"""Private root-to-root operation transport for the reviewed stage harness. + +Only case coordinates cross this socket. A caller supplies no packet, secret, +command, expected verdict or output path. The owner retains actual measured +state across stage-runner processes and refuses unknown/unimplemented steps. +""" + +import os +from pathlib import Path +import re +import socket +import stat +import struct +import subprocess +import time + +from common import canonical, closed, Refusal, require +from expand import decode + +REQUEST = 'auths.qualification-controller-step/1' +REPLY = 'auths.qualification-controller-reply/1' +MAX_REPLY = 65536 + + +def endpoint(path, *, existing): + path = Path(path).absolute() + require(os.getuid() == 0 and hasattr(socket, 'SO_PEERCRED') + and len(str(path).encode()) <= 107 and path.resolve() == path, + 'qualification.controller.identity') + parent = os.lstat(path.parent) + require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0 + and stat.S_IMODE(parent.st_mode) == 0o700, + 'qualification.controller.private-directory') + if existing: + info = os.lstat(path) + require(stat.S_ISSOCK(info.st_mode) and info.st_uid == 0 + and stat.S_IMODE(info.st_mode) == 0o600, 'qualification.controller.socket') + else: + require(not path.exists() and not path.is_symlink(), 'qualification.controller.socket') + return path + + +def peer(connection): + _pid, uid, _gid = struct.unpack('3i', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + require(uid == 0, 'qualification.controller.peer') + + +def receive(connection, maximum): + result = b'' + while not result.endswith(b'\n'): + chunk = connection.recv(min(4096, maximum + 1 - len(result))) + require(chunk and len(result) + len(chunk) <= maximum, 'qualification.controller.message-bound') + result += chunk + require(result.count(b'\n') == 1, 'qualification.controller.message-bound') + value = decode(result) + require(result == canonical(value) + b'\n', 'qualification.controller.message-canonical') + return value + + +def checked_request(value, family): + closed(value, ['schema', 'family', 'case', 'index', 'operation']) + require(value['schema'] == REQUEST and value['family'] == family + and type(value['case']) is str and re.fullmatch(r'(commissioning|live)-[a-z0-9-]{1,64}', value['case']) + and type(value['index']) is int and 0 <= value['index'] < 16 + and value['operation'] in ['submit', 'probe', 'replay', 'race', 'restart', 'crash', + 'rotate', 'read-back', 'drop-response', 'delay-visibility', 'installed-consumer'], + 'qualification.controller.coordinates') + return value + + +def call(path, family, case, index, operation): + path = endpoint(path, existing=True) + request = checked_request({'schema': REQUEST, 'family': family, 'case': case, + 'index': index, 'operation': operation}, family) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(120) + connection.connect(str(path)) + peer(connection) + connection.sendall(canonical(request) + b'\n') + reply = receive(connection, MAX_REPLY) + if type(reply) is dict and set(reply) == {'schema', 'code'}: + require(reply['schema'] == REPLY and type(reply['code']) is str + and re.fullmatch(r'qualification\.[a-z0-9.-]{1,128}', reply['code']), + 'qualification.controller.reply') + raise Refusal(reply['code']) + closed(reply, ['schema', 'observation']) + require(reply['schema'] == REPLY and type(reply['observation']) is dict, + 'qualification.controller.reply') + return reply['observation'] + + +def serve(path, operations, deadline, stopping, ready=None): + path = endpoint(path, existing=False) + require(type(deadline) in [int, float] and time.monotonic() < deadline <= time.monotonic() + 7200, + 'qualification.controller.deadline') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(path)) + os.chmod(path, 0o600) + inode = os.lstat(path).st_ino + server.listen(1) + server.settimeout(0.25) + if ready is not None: + ready.set() + try: + while not stopping.is_set() and time.monotonic() < deadline: + try: + connection, _ = server.accept() + except socket.timeout: + continue + with connection: + connection.settimeout(120) + try: + peer(connection) + value = checked_request(receive(connection, 4096), operations.family) + observation = operations.step(value['case'], value['index'], value['operation']) + reply = {'schema': REPLY, 'observation': observation} + except (Refusal, OSError, ValueError, KeyError, TypeError, TimeoutError, subprocess.SubprocessError) as error: + code = str(error) if isinstance(error, Refusal) else 'qualification.controller.operation-refused' + if re.fullmatch(r'qualification\.[a-z0-9.-]{1,128}', code) is None: + code = 'qualification.controller.operation-refused' + reply = {'schema': REPLY, 'code': code} + payload = canonical(reply) + b'\n' + require(len(payload) <= MAX_REPLY, 'qualification.controller.message-bound') + try: + connection.sendall(payload) + except OSError: + # A lost report cannot undo an entered native attempt; + # its case coordinates remain consumed by Operations. + pass + finally: + if path.exists() and os.lstat(path).st_ino == inode: + path.unlink() diff --git a/qualification/reference/expand.py b/qualification/reference/expand.py new file mode 100644 index 000000000..716859a5d --- /dev/null +++ b/qualification/reference/expand.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Recompute finite commissioning bindings from reviewed source and public input. + +Run only the reviewer binary built by the signing job from its own checkout. +The downloaded candidate is hashed, never executed in the signing environment. +No program, expected request, actor or action commitment from an artifact is +trusted. The resulting binding is still unsigned and proves no live behavior. +""" + +import argparse +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import time + +import airtable_record +import stripe_platform +from common import Refusal, canonical, closed, digest, identifier, require, sha256, text + +REFERENCES = {reference.FAMILY: reference for reference in [stripe_platform, airtable_record]} +MAXIMUM_LEASES = 64 +REPOSITORY = Path(__file__).resolve().parents[2] +SOURCES = { + stripe_platform.FAMILY: REPOSITORY / 'qualification/simulation/live/stripe-platform', + airtable_record.FAMILY: REPOSITORY / 'bindings/fixtures/gateway/airtable', +} + + +def read(path, maximum): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, 'rb') as stream: + metadata = os.fstat(stream.fileno()) + require(stat.S_ISREG(metadata.st_mode) and 0 < metadata.st_size <= maximum, + 'qualification.reference.input-bound') + value = stream.read(maximum + 1) + require(0 < len(value) <= maximum, 'qualification.reference.input-bound') + return value + + +def unique_object(pairs): + value = {} + for key, item in pairs: + require(key not in value, 'qualification.reference.duplicate-field') + value[key] = item + return value + + +def decode(value): + return json.loads(value, object_pairs_hook=unique_object, + parse_constant=lambda _: (_ for _ in ()).throw(Refusal('qualification.reference.number'))) + + +def child(binary, arguments): + # Never inherit signer keys or provider credentials. A reviewed executable + # has no custody input, store, installation or network operation here. + result = subprocess.run([str(binary), *map(str, arguments)], capture_output=True, + timeout=15, env={'PATH': '/usr/bin:/bin'}, cwd='/') + require(result.returncode == 0 and 0 < len(result.stdout) <= 65536 + and len(result.stderr) <= 65536, 'qualification.reference.native-review') + return decode(result.stdout) + + +def member(path, name, commit, tuple_digest): + raw = read(path, 2 * 1024 * 1024) + body = decode(raw) + require(raw == canonical(body) and body['schema'] == 'auths.qualification-evidence/1' + and body['member'] == name and body['commit'] == commit + and body['tuple_sha256'] == tuple_digest, + 'qualification.reference.offline-binding') + # The native issuer subsequently validates the closed evidence type and + # every mandatory scenario. This hash is independently rederived here. + return sha256(body['schema'].encode() + b'\0' + raw) + + +def relative(work, value): + identifier(value, r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}') + return work / value + + +def expand(args): + commit = identifier(args.source_commit, r'[0-9a-f]{40}') + protected_run = text(args.protected_run, maximum=256) + require('GITHUB_SHA' not in os.environ or os.environ['GITHUB_SHA'] == commit, + 'qualification.reference.source-binding') + if 'GITHUB_RUN_ID' in os.environ: + expected = 'recipe-qualification/' + os.environ['GITHUB_RUN_ID'] + '/' + os.environ['GITHUB_RUN_ATTEMPT'] + require(protected_run == expected, 'qualification.reference.run-binding') + tuple_value = decode(read(args.tuple, 65536)) + reference = REFERENCES.get(tuple_value['recipe_family']) + require(reference is not None, 'qualification.reference.family') + target = tuple_value['target'] + require(target['os'] == 'linux' and target['arch'] == 'x86_64' + and target['store_kind'] == 'postgresql-v1' + and target['store_schema'] == 'auths.lifecycle.postgresql/6' + and target['credential_store_kind'] == 'aws-secrets-manager-v1', + 'qualification.reference.production-target') + require(sha256(read(args.candidate, 256 * 1024 * 1024)) == target['gateway_build_sha256'], + 'qualification.reference.candidate-bytes') + actual = child(args.reviewer, ['qualification-candidate', '--recipe', args.recipe, + '--profile-lock', args.profile_lock, '--recipe-family', reference.FAMILY, + '--provider-contract-id', tuple_value['provider_contract_id']]) + # Separate builds can differ in executable bytes. Their source-owned + # semantic closure, compiled recipe, lock and all other target fields must + # agree. Only the original candidate digest enters signed authority. + actual['target']['gateway_build_sha256'] = target['gateway_build_sha256'] + require(actual == tuple_value, 'qualification.reference.candidate-binding') + resources_raw = read(args.resources, 65536) + bound_resources = reference.resources(decode(resources_raw), protected_run) + require(resources_raw == canonical(bound_resources), 'qualification.reference.resource-canonical') + source = SOURCES[reference.FAMILY] + expected_recipe = reference.recipe(decode(read(source / 'recipe.json', 65536)), bound_resources) + require(decode(read(args.recipe, 65536)) == expected_recipe + and read(args.profile_lock, 65536) == read(source / 'profile.lock.json', 65536), + 'qualification.reference.reviewed-source') + plan = decode(read(args.packets, 65536)) + # Import after this module's byte/I/O helpers are defined: the packet + # planner also uses them when constructing the installed author's input. + from packet_plan import public_pool + public_pool(reference.FAMILY, bound_resources, tuple_value['compiled_recipe_sha256'], plan) + closed(plan, ['schema', 'protected_run', 'evaluated_at', 'not_after', 'trusted_contexts', 'packets']) + require(plan['schema'] == 'auths.qualification-public-packets/4' + and plan['protected_run'] == protected_run + and type(plan['evaluated_at']) is int and 60 <= plan['evaluated_at'] <= 253402300499 + and type(plan['not_after']) is int + and plan['evaluated_at'] < plan['not_after'] <= plan['evaluated_at'] + 300 + and int(time.time()) - 7200 <= plan['evaluated_at'] <= int(time.time()) + 60 + and type(plan['packets']) is list and 1 <= len(plan['packets']) <= 64, + 'qualification.reference.packet-bound') + work = args.packets.parent + context_names = plan['trusted_contexts'] + require(type(context_names) is list and 1 <= len(context_names) <= 4 + and all(type(name) is str and name in ['context-' + str(index) + '.cbor' for index in range(4)] + for name in context_names) + and context_names == sorted(set(context_names)), 'qualification.reference.context-bound') + context_hashes = {name: sha256(read(relative(work, name), 4 * 1024 * 1024)) for name in context_names} + require(len(set(context_hashes.values())) == len(context_names), 'qualification.reference.context-bound') + actors, commitments, labels, used_contexts, oracle = set(), set(), set(), set(), [] + for packet in plan['packets']: + closed(packet, ['label', 'proof', 'action', 'trusted_context', 'arguments']) + label = identifier(packet['label'], r'[a-z][a-z0-9-]{0,63}') + require(label not in labels, 'qualification.reference.packet-bound') + labels.add(label) + proof, action = relative(work, packet['proof']), relative(work, packet['action']) + read(proof, 4 * 1024 * 1024) + read(action, 65536) + require(packet['trusted_context'] in context_names, 'qualification.reference.context-bound') + context = relative(work, packet['trusted_context']) + used_contexts.add(packet['trusted_context']) + reviewed = child(args.reviewer, ['review-submission', '--recipe', args.recipe, + '--profile-lock', args.profile_lock, '--trusted-context', context, + '--proof', proof, '--action', action, + '--evaluated-at', str(plan['evaluated_at'])]) + require(reviewed.get('schema') == 'auths.gateway-submission-review/1' + and len(reviewed['actors']) == 1 and reviewed['arguments'] == packet['arguments'], + 'qualification.reference.proof-binding') + commitment = digest(reviewed['action_commitment']) + expected = reference.request(packet['arguments'], bound_resources, commitment, + tuple_value['compiled_recipe_sha256']) + require(reviewed['request'] == expected, 'qualification.reference.oracle-mismatch') + actors.add(reviewed['actors'][0]) + commitments.add(commitment) + oracle.append({'label': label, 'request_sha256': sha256(canonical(expected)), + 'action_commitment': commitment, 'trusted_context_sha256': context_hashes[packet['trusted_context']], + 'entry_policy_code': reference.entry_policy(packet['arguments'], bound_resources) + if reference is stripe_platform else None}) + require(len(actors) == 1, 'qualification.reference.principal-binding') + require(used_contexts == set(context_names), 'qualification.reference.context-bound') + tuple_digest = sha256(b'auths.qualification-tuple/1\0' + canonical(tuple_value)) + binding = { + 'protected_run': protected_run, 'source_commit': commit, 'tuple': tuple_value, + 'principal_sha256': sha256(next(iter(actors)).encode()), + 'trusted_contexts_sha256': sorted(context_hashes.values()), 'resources_sha256': sha256(resources_raw), + 'provider_environment_class': reference.ENVIRONMENT, + 'offline_evidence': { + 'conformance_sha256': member(args.conformance, 'conformance', commit, tuple_digest), + 'differential_sha256': member(args.differential, 'differential', commit, tuple_digest), + }, + 'allowed_actions': sorted(commitments), 'maximum_credential_leases': MAXIMUM_LEASES, + } + require(not args.out_dir.exists(), 'qualification.reference.output-exists') + args.out_dir.mkdir(mode=0o700) + (args.out_dir / 'binding.json').write_bytes(canonical(binding)) + (args.out_dir / 'oracle-commitments.json').write_bytes(canonical({ + 'schema': 'auths.qualification-reference-expansion/2', + 'protected_run': protected_run, 'source_commit': commit, + 'binding_sha256': sha256(canonical(binding)), 'oracle': oracle, + 'qualification_issued': False, + })) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for argument in ['tuple', 'candidate', 'reviewer', 'recipe', 'profile-lock', 'resources', + 'packets', 'conformance', 'differential', 'out-dir']: + parser.add_argument('--' + argument, type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--source-commit', required=True) + parser.add_argument('--protected-run', required=True) + args = parser.parse_args() + try: + expand(args) + except Refusal as error: + print(str(error), file=sys.stderr) + return 1 + except (OSError, KeyError, ValueError, TypeError, RecursionError, OverflowError, + subprocess.SubprocessError): + print('qualification.reference.invalid-input', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/qualification/reference/family_corpus.py b/qualification/reference/family_corpus.py new file mode 100644 index 000000000..bfaa5c3a5 --- /dev/null +++ b/qualification/reference/family_corpus.py @@ -0,0 +1,184 @@ +"""Source-owned corpus expansion; no outcome from a run supplies expectations. + +Native review authenticates actors/actions. These provider references derive +requests and fresh-evidence subjects independently. The native stage runner +owns coverage and the comparison of actual observations with this plan. +""" + +import airtable_record +import stripe_platform +import fresh_evidence +from common import canonical, closed, digest, require, sha256 +from expand import child, decode, read, SOURCES +from packet_plan import public_pool + +RESOURCES = 16 +REFERENCES = {r.FAMILY: r for r in [stripe_platform, airtable_record]} + + +def authenticate(family, work, gateway, tuple_value): + reference = REFERENCES.get(family) + require(reference is not None and tuple_value['recipe_family'] == family, + 'qualification.corpus.family') + resources = decode(read(work / 'resources.json', 65536)) + reference.resources(resources, resources['protected_run']) + values = resources['payments'] if reference is stripe_platform else resources['records'] + require(len(values) == RESOURCES, 'qualification.corpus.resource-count') + source = SOURCES[family] + require(decode(read(work / 'recipe.json', 65536)) == reference.recipe( + decode(read(source / 'recipe.json', 65536)), resources) + and read(work / 'profile.lock.json', 65536) == read(source / 'profile.lock.json', 65536), + 'qualification.corpus.reviewed-source') + carrier = decode(read(work / 'public-packets.json', 65536)) + packets = public_pool(family, resources, tuple_value['compiled_recipe_sha256'], carrier) + reviewed, actors = {}, set() + for packet in packets: + actual = child(gateway, ['review-submission', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json', + '--trusted-context', work / packet['trusted_context'], '--proof', work / packet['proof'], + '--action', work / packet['action'], '--evaluated-at', str(carrier['evaluated_at'])]) + closed(actual, ['schema', 'actors', 'action_commitment', 'arguments', 'request']) + require(actual['schema'] == 'auths.gateway-submission-review/1' + and len(actual['actors']) == 1 and actual['arguments'] == packet['arguments'], + 'qualification.corpus.proof-binding') + request = reference.request(actual['arguments'], resources, digest(actual['action_commitment']), + tuple_value['compiled_recipe_sha256']) + require(actual['request'] == request, 'qualification.corpus.request-binding') + actors.update(actual['actors']) + reviewed[packet['label']] = actual + require(len(actors) == 1, 'qualification.corpus.actor-binding') + return resources, reviewed + + +def compile_plan(family, resources, reviewed, recipe_digest): + """Closed provider-specific cases, including both protected phases. + + Administrative compound probes have source-owned completion codes. Their + harness must check the actual native refusals and counters before returning + that completion; it cannot copy this expected observation as its result. + """ + reference = REFERENCES.get(family) + require(reference is not None, 'qualification.corpus.family') + reference.resources(resources, resources['protected_run']) + values = resources['payments'] if reference is stripe_platform else resources['records'] + require(len(values) == RESOURCES, 'qualification.corpus.resource-count') + cases = [] + + def request(label): + value = reviewed[label] + result = reference.request(value['arguments'], resources, value['action_commitment'], recipe_digest) + require(value['request'] == result, 'qualification.corpus.request-binding') + return sha256(canonical(result)) + + def step(operation, outcome, code, label=None, leases=0, entries=0, confirmed=0, fresh=False): + comparison = {'kind': 'static'} + if fresh: + value = reviewed[label] + comparison = {'kind': 'independent-read-back', 'subject_sha256': fresh_evidence.subject( + family, value['arguments'], resources, value['action_commitment'], recipe_digest)} + return {'operation': operation, 'evidence_comparison': comparison, + 'expected': {'verdict': {'outcome': outcome, 'code': code, + 'request_sha256': None if label is None else request(label), 'evidence_sha256': None}, + 'credential_leases': leases, 'provider_entries': entries, + 'confirmed_by_read_back': confirmed}} + + def complete(operation, code): + return step(operation, 'complete', code) + + def observed(operation, label, leases=2, entries=1, confirmed=1): + return step(operation, 'observed', 'observed-by-provider', label, + leases, entries, confirmed, True) + + def unknown(operation, label, leases=0, entries=0): + return step(operation, 'unknown', 'unknown', label, leases, entries) + + def replay(): + return step('replay', 'refused', 'gateway.attempt.replay') + + def add(phase, identifier, scenario, steps, capabilities=()): + cases.append({'id': phase + '-' + identifier, 'scenario': scenario, + 'capabilities': list(capabilities), 'phase': phase, 'steps': steps}) + + for identifier, scenario, code in [ + ('source', 'clean-source', 'source-clean'), + ('digest', 'recipe-digest-rederives', 'recipe-digest-rederived'), + ('vectors', 'recipe-vectors', 'recipe-vectors-passed'), + ('closed', 'closed-enumeration-hostile', 'closed-enumeration-refused')]: + add('offline', identifier, scenario, [complete('probe', code)]) + label = 'commissioning-00' + add('offline', 'oracle-accept', 'oracle-accepts', [ + step(operation, 'complete', 'request-mapped', label) for operation in ['oracle', 'review']]) + add('offline', 'oracle-reject', 'oracle-rejects', [ + step(operation, 'refused', 'action-outside-validity') for operation in ['oracle', 'review']]) + + for phase in ['commissioning', 'live']: + label = lambda index: phase + '-' + str(index).zfill(2) + add(phase, 'fresh-replay', 'fresh-challenge-replay', + [observed('submit', label(0)), replay()]) + add(phase, 'proof-replay', 'proof-replay', [observed('submit', label(1)), replay()]) + # The held owner response keeps an entered operation unresolved while + # the second actual process races its claim. Airtable can take one + # read-only recovery lease; Stripe has no unrecorded response locator. + add(phase, 'two-host-race', 'two-instance-race', + [observed('race', label(2), leases=3 if reference is airtable_record else 2)]) + for index, operation in [(3, 'restart'), (4, 'crash')]: + ending = observed('replay', label(index), 1, 0, 1) if reference is airtable_record \ + else unknown('replay', label(index)) + add(phase, operation, operation, [unknown('submit', label(index), 1, 1), + complete(operation, 'gateway-' + operation + '-completed'), ending]) + add(phase, 'ambiguous', 'ambiguous-response', [unknown('drop-response', label(5), 1, 1), + observed('replay', label(5), 1, 0, 1) if reference is airtable_record + else unknown('replay', label(5))]) + for index, identifier, operation, scenario in [ + (6, 'response-loss', 'drop-response', 'response-loss'), + (7, 'visibility', 'delay-visibility', 'delayed-visibility')]: + ending = observed('read-back', label(index), 1, 0, 1) if reference is airtable_record \ + else unknown('read-back', label(index)) + add(phase, identifier, scenario, [unknown(operation, label(index), 1, 1), ending], + ('recovery',) if reference is airtable_record else ()) + add(phase, 'secret-rotation', 'provider-secret-rotation', + [complete('rotate', 'provider-secret-rotated'), observed('submit', label(8))]) + add(phase, 'read-back', 'read-back-confirms-write', [observed('submit', label(9)), + observed('read-back', label(9), 1, 0, 0)]) + capabilities = ['echo', 'observation'] + if reference is stripe_platform: + capabilities = ['credential-guard', 'version-pin', 'account-binding', 'denied-reads', + 'idempotency', 'response-locator', *capabilities] + add(phase, 'capabilities', 'declared-capability', [observed('submit', label(10)), + complete('probe', 'provider-capabilities-confirmed')], capabilities) + for index, language in [(11, 'python'), (12, 'typescript')]: + consumer = observed('installed-consumer', label(index)) if phase == 'live' else \ + step('installed-consumer', 'refused', 'gateway.qualification.missing') + add(phase, 'installed-' + language, 'installed-journey', [consumer]) + add(phase, language + '-no-source', 'no-repository-import', + [complete('installed-consumer', 'installed-package-provenance-confirmed')]) + add(phase, language + '-no-token', 'no-provider-token', + [complete('installed-consumer', 'provider-token-absent')]) + for identifier, scenario, code in [ + ('isolation', 'application-cannot-read-secret', 'application-secret-access-refused'), + ('direct-provider', 'direct-provider-attempt', 'direct-provider-access-refused')]: + add(phase, identifier, scenario, [complete('probe', code)]) + for identifier, scenario in [('forged', 'forged-proof'), ('altered', 'altered-action')]: + add(phase, identifier, scenario, + [step('probe', 'refused', 'gateway.verify.invalid-input')]) + for kind, scenario in [('kind', 'store-kind-drift'), ('generation', 'generation-drift'), + ('commitment', 'commitment-drift'), ('version', 'external-version-drift')]: + # Before-entry connection binding probes and actual post-claim + # custody-version failures are distinct measured boundaries. + leases = 1 if kind in ['commitment', 'version'] else 0 + add(phase, 'custody-' + kind, scenario, + [step('probe', 'complete', 'custody-' + kind + '-refused', leases=leases)]) + if reference is stripe_platform: + for kind, code in [('ceiling', 'gateway.relative-ceiling.above'), + ('currency', 'gateway.relative-ceiling.binding-mismatch')]: + add(phase, 'guard-' + kind, 'declared-capability', + [step('probe', 'refused', code, leases=1)], ('ceiling',)) + for kind in ['count', 'sum']: + budget_label = phase + '-budget-' + kind + add(phase, 'budget-' + kind, 'declared-capability', + [observed('race', budget_label), complete('probe', 'budget-' + kind + '-refusal-confirmed')], + ('budget', 'ceiling')) + doctor = complete('probe', 'production-readiness-passed') + doctor['evidence_comparison'] = {'kind': 'production-doctor'} + add('live', 'doctor', 'production-readiness', [doctor]) + return {'schema': 'auths.qualification-corpus/3', 'cases': cases} diff --git a/qualification/reference/family_harness.py b/qualification/reference/family_harness.py new file mode 100644 index 000000000..6b3d65abd --- /dev/null +++ b/qualification/reference/family_harness.py @@ -0,0 +1,235 @@ +"""Reviewed family harness. Offline steps perform actual native checks. + +Protected operations require the source-owned production journey. No missing +operation produces an observation or a passing report. +""" + +import copy +import os +from pathlib import Path +import subprocess +import sys + +import airtable_record +import stripe_platform +from common import canonical, closed, require, sha256 +from expand import child, decode, read, SOURCES +from family_corpus import authenticate, compile_plan, RESOURCES +from packet_plan import prepare as packet_prepare +from resource_io import finish, run_id, write, write_bytes + +ROOT = Path(__file__).resolve().parents[2] +REFERENCES = {r.FAMILY: r for r in [stripe_platform, airtable_record]} + + +def command(arguments, cwd=ROOT): + result = subprocess.run(list(map(str, arguments)), capture_output=True, timeout=90, + cwd=cwd, env={'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'}) + require(len(result.stdout) <= 65536 and len(result.stderr) <= 65536, + 'qualification.harness.output-bound') + return result + + +def gateway(): + return Path(os.environ['AUTHS_GATEWAY']).resolve(strict=True) + + +def consumer_python(): + # Resolving a venv launcher symlink selects the base interpreter and loses + # its installed wheel. Keep the absolute launcher path when executing it. + python = Path(os.environ['AUTHS_QUALIFICATION_CONSUMER_PYTHON']).absolute() + require(python.is_file() and os.access(python, os.X_OK), + 'qualification.harness.consumer-python') + return python + + +def source_commit(): + revision = command(['/usr/bin/git', 'rev-parse', 'HEAD']) + status = command(['/usr/bin/git', 'status', '--porcelain']) + require(revision.returncode == status.returncode == 0 and not status.stdout.strip(), + 'qualification.harness.source-not-clean') + commit = revision.stdout.decode('ascii').strip() + require('GITHUB_SHA' not in os.environ or os.environ['GITHUB_SHA'] == commit, + 'qualification.harness.source-binding') + return commit + + +def synthetic_resources(family, protected_run): + if family == stripe_platform.FAMILY: + return {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': protected_run, 'platform': 'acct_SYNTHETIC', 'payments': [ + {'id': 'pi_SYNTHETIC' + str(index).zfill(2), 'amount_received': 2000, + 'currency': 'usd', 'livemode': False, 'run_metadata': protected_run} + for index in range(RESOURCES)]} + return {'schema': 'auths.airtable-record-qualification-resources/1', + 'protected_run': protected_run, 'base': airtable_record.BASE, 'table': airtable_record.TABLE, + 'records': [{'id': 'recTEST' + str(index).zfill(10), 'run_metadata': protected_run} + for index in range(RESOURCES)]} + + +def candidate(family, work): + issuer = Path(os.environ['AUTHS_QUALIFICATION']).resolve(strict=True) + contract = command([issuer, 'contract-id', '--contract', + ROOT / 'qualification/families' / family / 'contract.json']) + require(contract.returncode == 0, 'qualification.harness.contract') + return child(gateway(), ['qualification-candidate', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json', '--recipe-family', family, + '--provider-contract-id', contract.stdout.decode('ascii').strip()]) + + +def prepare(family, work): + from generate_families import generate + generate(check=True) + source_commit() + require(work.is_dir() and not work.is_symlink(), 'qualification.harness.work-directory') + os.chmod(work, 0o700) + protected_run = run_id('recipe-qualification/' + os.environ['GITHUB_RUN_ID'] + '/' + os.environ['GITHUB_RUN_ATTEMPT']) + write(work / 'resources.json', synthetic_resources(family, protected_run), new=True) + packet_prepare(type('Inputs', (), {'family': family, 'resources': work / 'resources.json', + 'gateway': gateway(), 'work': work})()) + # The wheel and public author kit are installed/copied by the credential- + # free workflow. The SDK process never receives this checkout or its env. + python = consumer_python() + kit = Path(os.environ['AUTHS_QUALIFICATION_AUTHOR_KIT']).resolve(strict=True) + result = command([python, '-B', kit / 'author_packets.py', '--plan', work / 'packet-plan.json', + '--work', work], cwd=work) + require(result.returncode == 0, 'qualification.harness.installed-author') + tuple_value = candidate(family, work) + write(work / 'tuple.json', tuple_value, new=True) + operator = command([python, '-B', kit / 'author_operator.py', + '--gateway', gateway(), '--work', work], cwd=work) + require(operator.returncode == 0, 'qualification.harness.installed-operator') + resources, reviewed = authenticate(family, work, gateway(), tuple_value) + operator_report = decode(read(work / 'operator-report.json', 65536)) + require(all(operator_report['operator_principal'] not in value['actors'] + for value in reviewed.values()), 'qualification.harness.operator-separation') + corpus = compile_plan(family, resources, reviewed, tuple_value['compiled_recipe_sha256']) + write_bytes(work / 'corpus.json', canonical(corpus), new=True) + author = decode(read(work / 'author-report.json', 65536)) + wheel = Path(os.environ['AUTHS_QUALIFICATION_WHEEL']).resolve(strict=True) + write(work / 'packages.json', sorted([ + {'name': 'auths', 'version': author['sdk_version'], 'sha256': sha256(read(wheel, 64 * 1024 * 1024))}, + {'name': 'auths-gateway', 'version': tuple_value['target']['gateway_version'], + 'sha256': tuple_value['target']['gateway_build_sha256']}, + ], key=lambda package: package['name']), new=True) + + +def review(work, packet, evaluated_at): + return child(gateway(), ['review-submission', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json', '--trusted-context', work / packet['trusted_context'], + '--proof', work / packet['proof'], '--action', work / packet['action'], + '--evaluated-at', str(evaluated_at)]) + + +def hostile(work): + original = decode(read(work / 'recipe.json', 65536)) + changes = [lambda value: value.update(unreviewed=True), + lambda value: value.update(schema='auths.gateway-recipe-source/0'), + lambda value: value.update(origin='http://api.example.invalid'), + lambda value: value['credential'].update(kind='unreviewed-adapter'), + lambda value: value['write'].update(method='CONNECT'), + lambda value: value['write'].update(unreviewed=True), + lambda value: value['write']['path'][0].update(kind='caller-url'), + lambda value: value['observation'].update(unreviewed=True), + lambda value: value['echo']['write'].update(kind='caller-header')] + directory = work / 'hostile-recipes' + directory.mkdir(mode=0o700, exist_ok=True) + for index, change in enumerate(changes): + value = copy.deepcopy(original) + change(value) + path = directory / (str(index) + '.json') + write(path, value, new=not path.exists()) + refusal = command([gateway(), 'review', '--recipe', path, + '--profile-lock', work / 'profile.lock.json']) + require(refusal.returncode != 0 and b'gateway.' in refusal.stderr, + 'qualification.harness.hostile-recipe-admitted') + + +def offline(family, identifier, index, operation, work): + tuple_value = decode(read(work / 'tuple.json', 65536)) + resources = decode(read(work / 'resources.json', 65536)) + reference = REFERENCES[family] + reference.resources(resources, resources['protected_run']) + carrier = decode(read(work / 'public-packets.json', 65536)) + packet = carrier['packets'][0] + verdict = {'outcome': 'complete', 'code': None, 'request_sha256': None, 'evidence_sha256': None} + if identifier in ['source', 'digest', 'vectors', 'closed']: + require(index == 0 and operation == 'probe', 'qualification.harness.step') + if identifier == 'source': + source_commit() + require(candidate(family, work) == tuple_value, 'qualification.harness.candidate-changed') + verdict['code'] = 'source-clean' + elif identifier == 'digest': + value = child(gateway(), ['review', '--recipe', work / 'recipe.json', + '--profile-lock', work / 'profile.lock.json']) + require(value['recipe_digest'] == tuple_value['compiled_recipe_sha256'], + 'qualification.harness.recipe-digest') + require(read(work / 'profile.lock.json', 65536) == read(SOURCES[family] / 'profile.lock.json', 65536), + 'qualification.harness.lock-drift') + verdict['code'] = 'recipe-digest-rederived' + elif identifier == 'vectors': + authenticate(family, work, gateway(), tuple_value) + verdict['code'] = 'recipe-vectors-passed' + else: + hostile(work) + verdict['code'] = 'closed-enumeration-refused' + else: + require(identifier in ['oracle-accept', 'oracle-reject'] and index in [0, 1] + and operation == ['oracle', 'review'][index], 'qualification.harness.step') + if identifier == 'oracle-reject': + if operation == 'review': + actual = review(work, packet, carrier['evaluated_at'] - 1) + closed(actual, ['outcome', 'code']) + require(actual['outcome'] == 'denied' and actual['code'] == 'action-outside-validity', + 'qualification.harness.native-time-refusal') + # The source author starts every action at this recorded time. + # One second earlier is outside that closed action interval; + # this is offline evaluation, never a production-clock override. + verdict.update(outcome='refused', code='action-outside-validity') + else: + actual = review(work, packet, carrier['evaluated_at']) + expected = reference.request(packet['arguments'], resources, actual['action_commitment'], + tuple_value['compiled_recipe_sha256']) + if operation == 'review': + require(actual['arguments'] == packet['arguments'], 'qualification.harness.arguments') + outbound = actual['request'] + else: + # Only the authenticated action commitment is taken from + # native review. The oracle constructs every request byte. + outbound = expected + verdict.update(code='request-mapped', request_sha256=sha256(canonical(outbound))) + return {'tuple_sha256': sha256(b'auths.qualification-tuple/1\0' + canonical(tuple_value)), + 'observed': {'verdict': verdict, 'credential_leases': 0, 'provider_entries': 0, + 'confirmed_by_read_back': 0}, 'fresh_evidence': None, + 'unauthorized_provider_entries': 0, 'secret_exposed': False, + 'repository_imported': False, 'provider_token_received': False} + + +def main(family, arguments): + def dispatch(): + require(family in REFERENCES and type(arguments) is list, 'qualification.harness.family') + if len(arguments) == 2 and arguments[0] == 'prepare': + return prepare(family, Path(arguments[1]).absolute()) + if len(arguments) == 2 and arguments[0] == 'cleanup': + # No protected setup has been configured yet. Cleanup can complete + # only for the exact synthetic ledger this offline source prepared; + # it cannot claim retirement of any real resource. + work = Path(arguments[1]).absolute() + resources = decode(read(work / 'resources.json', 65536)) + require(resources == synthetic_resources(family, resources['protected_run']), + 'qualification.harness.real-resources-not-retired') + return + if len(arguments) == 6 and arguments[0] == 'step': + _, case, index, operation, directory, output = arguments + if case.startswith('offline-'): + value = offline(family, case.removeprefix('offline-'), int(index), operation, Path(directory).absolute()) + else: + controller = os.environ.get('AUTHS_QUALIFICATION_CONTROLLER') + require(controller is not None, 'qualification.harness.protected-journey-not-configured') + from controller_socket import call + value = call(controller, family, case, int(index), operation) + return write(Path(output).absolute(), value, new=True) + # No helper can silently complete an unimplemented protected step, + # provision development custody, or manufacture a live observation. + require(False, 'qualification.harness.protected-journey-not-configured') + finish(dispatch) diff --git a/qualification/reference/family_operations.py b/qualification/reference/family_operations.py new file mode 100644 index 000000000..dc6b6f202 --- /dev/null +++ b/qualification/reference/family_operations.py @@ -0,0 +1,204 @@ +"""Measured protected operations; absent operations refuse without a report. + +The controller retains the effect ledger across native stage-runner children. +Case IDs select only this source's fixed packet labels. Expectations from the +corpus never enter an operation or an observation. +""" + +from pathlib import Path + +import airtable_record +import stripe_platform +from common import canonical, closed, require, sha256 +from expand import child, decode, read +from native_observation import Effects +from packet_plan import public_pool +from resource_io import write_bytes + +POSITIVES = {'fresh-replay': 0, 'proof-replay': 1, 'two-host-race': 2, + 'restart': 3, 'crash': 4, 'ambiguous': 5, 'response-loss': 6, + 'visibility': 7, 'secret-rotation': 8, 'read-back': 9, 'capabilities': 10, + 'installed-python': 11, 'installed-typescript': 12} + + +class Operations: + def __init__(self, deployment, author, oracle, resources, reviewed): + self.deployment, self.author, self.oracle = deployment, author, oracle + self.resources, self.reviewed = resources, reviewed + self.tuple = deployment.tuple + self.family = self.tuple['recipe_family'] + self.reference = {stripe_platform.FAMILY: stripe_platform, airtable_record.FAMILY: airtable_record}[self.family] + carrier = decode(read(deployment.work / 'public-packets.json', 65536)) + self.packets = {packet['label']: packet for packet in public_pool( + self.family, resources, self.tuple['compiled_recipe_sha256'], carrier)} + require(set(self.packets) == set(reviewed), 'qualification.operations.pool-binding') + self.effects = Effects() + self.completed = set() + self.started = set() + self.phase = None + self.current_credential = None + self.rotation_keys = None + self.consumer_python = None + self.consumer_kit = None + + def configure_consumers(self, python, kit): + self.consumer_python, self.consumer_kit = Path(python).absolute(), Path(kit).absolute() + + def configure_rotation(self, first, second): + require(type(first) is bytes and type(second) is bytes and first != second + and first in self.deployment.canaries and second in self.deployment.canaries, + 'qualification.operations.genuine-rotation-required') + self.rotation_keys, self.current_credential = (first, second), first + + def begin(self, phase): + require(phase in ['commissioning', 'live'] + and (self.phase is None if phase == 'commissioning' else self.phase == 'commissioning'), + 'qualification.operations.phase') + if phase == 'commissioning': + require(self.deployment.permit is not None, 'qualification.operations.permit') + else: + for context in [0, 1]: + for host in [0, 1]: + status = self.deployment.command(['qualification-status', '--state-dir', + self.deployment.state(host, context)]) + require(status == {'policy': 'required', 'state': 'qualified', 'code': None}, + 'qualification.operations.first-release-not-qualified') + self.phase = phase + + def packet(self, label): + require(label in self.packets, 'qualification.operations.packet') + # The retained author accepts known source labels only. Refresh must + # preserve original action/context bytes, actor and request binding. + handoff = self.author.refresh(label) + packet = self.packets[label] + value = decode(read(Path(handoff) / 'public-packets.json', 65536)) + require(value['packets'] == [packet] and value['protected_run'] == self.resources['protected_run'], + 'qualification.operations.refresh-binding') + for name, bound in [(packet['action'], 65536), (packet['trusted_context'], 4 * 1024 * 1024)]: + require(read(Path(handoff) / name, bound) == read(self.deployment.work / name, bound), + 'qualification.operations.refresh-binding') + actual = child(self.deployment.binary, ['review-submission', '--recipe', self.deployment.work / 'recipe.json', + '--profile-lock', self.deployment.work / 'profile.lock.json', + '--trusted-context', Path(handoff) / packet['trusted_context'], + '--proof', Path(handoff) / packet['proof'], '--action', Path(handoff) / packet['action'], + '--evaluated-at', str(value['evaluated_at'])]) + require(actual == self.reviewed[label], 'qualification.operations.refresh-binding') + return handoff, packet + + def observation(self, observed, fresh=None): + return {'tuple_sha256': sha256(b'auths.qualification-tuple/1\0' + canonical(self.tuple)), + 'observed': observed, 'fresh_evidence': fresh, + 'unauthorized_provider_entries': 0, 'secret_exposed': False, + 'repository_imported': False, 'provider_token_received': False} + + def project(self, label, result, before, after): + reviewed = self.reviewed[label] + # A native linked result still needs a separate fresh provider read. + response = self.oracle.fresh(reviewed, self.tuple['compiled_recipe_sha256']) \ + if result['outcome'] == 'observed-by-provider' else None + observed, fresh = self.effects.project(self.tuple, reviewed, self.resources, + result, before, after, response) + return self.observation(observed, fresh) + + def submit(self, phase, label, host=0, context=0): + handoff, packet = self.packet(label) + if phase == 'commissioning': + execution = self.deployment.commissioning_submit(handoff, packet, host, context) + return self.project(label, execution['result'], execution['before'], execution['after']) + before = self.deployment.witness(host, context) + result = self.deployment.application_submit(handoff, packet, host, context) + after = self.deployment.witness(host, context) + return self.project(label, result, before, after) + + def read_back(self, label): + before = self.deployment.witness(0) + result = self.deployment.reobserve(self.reviewed[label]['arguments']['operation_id']) + after = self.deployment.witness(0) + return self.project(label, result, before, after) + + def completed_probe(self, code): + # Completion is constructed only after that source operation returned + # actual native/provider facts; a corpus's expected code is never read. + return self.observation({'verdict': {'outcome': 'complete', 'code': code, + 'request_sha256': None, 'evidence_sha256': None}, 'credential_leases': 0, + 'provider_entries': 0, 'confirmed_by_read_back': 0}) + + def rotate(self): + require(self.rotation_keys is not None, 'qualification.operations.genuine-rotation-required') + successor = next(key for key in self.rotation_keys if key != self.current_credential) + result = self.deployment.rotate(successor) + require(result.get('ok') is True and result.get('code') == 'gateway.admin.rotated', + 'qualification.operations.rotation-refused') + self.current_credential = successor + return self.completed_probe('provider-secret-rotated') + + def hostile(self, phase, identifier): + label = phase + '-13' + handoff, packet = self.packet(label) + directory = self.deployment.private / ('hostile-' + phase + '-' + identifier) + directory.mkdir(mode=0o700) + for name, bound in [(packet['proof'], 4 * 1024 * 1024), (packet['action'], 65536)]: + raw = read(Path(handoff) / name, bound) + if name == packet['proof' if identifier == 'forged' else 'action']: + raw += b'\0' + write_bytes(directory / name, raw, new=True) + if phase == 'commissioning': + execution = self.deployment.commissioning_submit(directory, packet) + return self.project(label, execution['result'], execution['before'], execution['after']) + before = self.deployment.witness(0) + result = self.deployment.application_submit(directory, packet) + return self.project(label, result, before, self.deployment.witness(0)) + + def python_consumer(self, phase): + require(self.consumer_python is not None and self.consumer_kit is not None, + 'qualification.operations.installed-python-not-configured') + label = phase + '-11' + handoff, packet = self.packet(label) + before = self.deployment.witness(0) + result = self.deployment.installed_python_submit(self.consumer_python, self.consumer_kit, handoff, packet) + return self.project(label, result, before, self.deployment.witness(0)) + + def step(self, case, index, operation): + require(type(case) is str and type(index) is int and type(operation) is str, + 'qualification.operations.step') + phase, separator, identifier = case.partition('-') + require(separator and phase in ['commissioning', 'live'] and self.phase == phase, + 'qualification.operations.phase') + require((case, index) not in self.started, 'qualification.operations.repeated-step') + self.started.add((case, index)) + # Source-owned sequences, independent of candidate/corpus outcomes. + if identifier in ['fresh-replay', 'proof-replay']: + require(index in [0, 1] and operation == ['submit', 'replay'][index], + 'qualification.operations.step') + label = phase + '-' + str(POSITIVES[identifier]).zfill(2) + if index == 1: + require((case, 0) in self.completed, 'qualification.operations.order') + context = int(index == 1 and identifier == 'fresh-replay') + result = self.submit(phase, label + ('-fresh' if context else ''), context=context) + elif identifier == 'read-back': + require(index in [0, 1] and operation == ['submit', 'read-back'][index], + 'qualification.operations.step') + label = phase + '-09' + if index == 1: + require((case, 0) in self.completed, 'qualification.operations.order') + result = self.submit(phase, label) if index == 0 else self.read_back(label) + elif identifier in ['guard-ceiling', 'guard-currency']: + require(self.reference is stripe_platform and index == 0 and operation == 'probe', + 'qualification.operations.step') + result = self.submit(phase, phase + '-' + identifier) + elif identifier in ['forged', 'altered']: + require(index == 0 and operation == 'probe', 'qualification.operations.step') + result = self.hostile(phase, identifier) + elif identifier == 'secret-rotation': + require(index in [0, 1] and operation == ['rotate', 'submit'][index], + 'qualification.operations.step') + if index == 1: + require((case, 0) in self.completed, 'qualification.operations.order') + result = self.rotate() if index == 0 else self.submit(phase, phase + '-08') + elif identifier == 'installed-python': + require(index == 0 and operation == 'installed-consumer', 'qualification.operations.step') + result = self.python_consumer(phase) + else: + require(False, 'qualification.operations.not-implemented') + self.completed.add((case, index)) + return result diff --git a/qualification/reference/fresh_evidence.py b/qualification/reference/fresh_evidence.py new file mode 100644 index 000000000..fb95be53d --- /dev/null +++ b/qualification/reference/fresh_evidence.py @@ -0,0 +1,72 @@ +"""Independent bounded response checks for the two reviewed provider families. + +This module performs no I/O and accepts no candidate result/digest/locator. +The protected reference harness supplies bytes from its own fresh read of a +resource selected from the reviewed run ledger and action, then the runner +compares the witness with the candidate separately. +""" + +import json +from common import canonical, digest, echo, identifier, require, sha256 +import airtable_record as airtable +import stripe_platform as stripe + + +def decode(response): + require(type(response) is bytes and 0 < len(response) <= 65536, + 'qualification.fresh.response-bound') + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'qualification.fresh.duplicate-member') + result[key] = value + return result + def invalid(_value): + require(False, 'qualification.fresh.invalid-json') + try: + value = json.loads(response, object_pairs_hook=unique, parse_constant=invalid) + except (ValueError, UnicodeError, RecursionError): + require(False, 'qualification.fresh.invalid-json') + require(type(value) is dict, 'qualification.fresh.invalid-json') + return value + + +def subject(family, arguments, resources, action_commitment, recipe_digest): + reference = {'stripe-platform-refund-v1': stripe, + 'airtable-record-update-v1': airtable}.get(family) + require(reference is not None, 'qualification.fresh.family') + # Re-run the complete independent request oracle; invalid actions cannot + # acquire a subject merely by hashing arbitrary input. + require(type(resources) is dict, 'qualification.fresh.resources') + reference.resources(resources, resources.get('protected_run')) + request = reference.request(arguments, resources, action_commitment, recipe_digest) + return sha256(b'auths.qualification-read-back-subject/1\0' + canonical({ + 'family': family, 'arguments': arguments, 'request': request, + 'resources_sha256': sha256(canonical(resources)), + 'action_commitment': digest(action_commitment), + 'recipe_digest': digest(recipe_digest), + })) + + +def witness(family, arguments, resources, action_commitment, recipe_digest, response): + expected_subject = subject(family, arguments, resources, action_commitment, recipe_digest) + value = decode(response) + token = echo(arguments['operator_namespace'], arguments['operation_id'], action_commitment) + if family == stripe.FAMILY: + identifier(value.get('id'), r're_[A-Za-z0-9]{1,128}') + require(value.get('object') == 'refund' and value.get('livemode', False) is False + and value.get('payment_intent') == arguments['payment_intent'] + and type(value.get('amount')) is int and value['amount'] == arguments['amount'] + and value.get('currency') == arguments['currency'] + and value.get('status') == 'succeeded' + and type(value.get('metadata')) is dict + and value['metadata'].get('auths_echo') == token, + 'qualification.fresh.state-mismatch') + else: + require(value.get('id') == arguments['record_id'] + and type(value.get('fields')) is dict + and value['fields'].get('DemoStatus') == arguments['replacement'] + and value['fields'].get('auths_echo') == token, + 'qualification.fresh.state-mismatch') + return {'kind': 'independent-read-back', 'subject_sha256': expected_subject, + 'response_sha256': sha256(response)} diff --git a/qualification/reference/generate_families.py b/qualification/reference/generate_families.py new file mode 100644 index 000000000..c5ebd355d --- /dev/null +++ b/qualification/reference/generate_families.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Regenerate the reviewed plan/contract artifacts from their exact source. + +These are unqualified source plans. This command issues no record, permit, +evidence, attestation or release index and reads no credential. +""" + +from pathlib import Path +import argparse + +from common import canonical, require, sha256 +import airtable_record +import stripe_platform +from expand import SOURCES, decode, read +from resource_io import finish + +ROOT = Path(__file__).resolve().parents[2] +REFERENCE = ROOT / 'qualification/reference' +SOURCES_TO_PIN = ['family_corpus.py', 'family_harness.py', 'family_operations.py', 'packet_plan.py', + 'author_packets.py', 'author_socket.py', 'retained_author.py', 'author_operator.py', 'production_setup.py', + 'network_fault.py', + 'controller_socket.py', 'installed_submit.py', 'common.py', 'fresh_evidence.py', + 'native_observation.py', 'measure.py', 'provider_readback.py', 'resource_io.py', 'resource_summary.py', + 'expand.py', 'stripe_platform.py', 'airtable_record.py', 'stripe_resources.py', 'airtable_resources.py'] + + +def generate(check=False): + for reference, adr in [(stripe_platform, '0014-stripe-refund-recipe-qualification.md'), + (airtable_record, '0015-airtable-record-update-recipe-qualification.md')]: + family = ROOT / 'qualification/families' / reference.FAMILY + if not check: family.mkdir(parents=True, exist_ok=True) + plan = {'schema': 'auths.qualification-reviewed-plan/1', 'family': reference.FAMILY, + 'resource_count': 16, 'compiled_corpus_schema': 'auths.qualification-corpus/3', + 'packet_plan_schema': 'auths.qualification-packet-plan/4', + 'public_packet_schema': 'auths.qualification-public-packets/4', + 'phases': ['offline', 'commissioning', 'live'], 'maximum_credential_leases': 64, + 'source_files': {**{name: sha256(read(REFERENCE / name, 2 * 1024 * 1024)) + for name in SOURCES_TO_PIN}, + 'tls_fault.py': sha256(read(ROOT / 'qualification/run/tls_fault.py', 2 * 1024 * 1024))}, + 'source_recipe_sha256': sha256(read(SOURCES[reference.FAMILY] / 'recipe.json', 65536)), + 'profile_lock_sha256': sha256(read(SOURCES[reference.FAMILY] / 'profile.lock.json', 65536)), + 'decision_record_sha256': sha256(read(ROOT / 'docs/adr' / adr, 65536))} + recipe = decode(read(SOURCES[reference.FAMILY] / 'recipe.json', 65536)) + stripe = reference is stripe_platform + assumptions = sorted([ + 'The provider is independently operated; availability and concurrent outside writes are not controlled by Auths.', + 'Fresh matching state and the action-derived echo are required; HTTP success is not effect confirmation.', + 'Only the closed run-owned resource ledger is in scope; every cleanup must be freshly confirmed.', + 'Stripe test mode and the installed platform are checked by the declared guard on each lease.' if stripe else + 'The reviewed fixed Airtable base and table contain only owner-authorized disposable qualification records.', + 'Stripe idempotency is tested only inside the declared 86400-second retention interval.' if stripe else + 'Airtable supplies no idempotency key for this PATCH; durable gateway admission prevents a second entry.', + 'The 50-percent payment basis is a read, not an atomic provider balance reservation.' if stripe else + 'Only a fresh GET with the exact replacement and echo can reconcile a lost response.', + ]) + declarations = { + 'idempotency_sha256': sha256(canonical(recipe['write'].get('idempotency'))), + 'observation_sha256': sha256(canonical({'observation': recipe['observation'], 'echo': recipe['echo']})), + 'recovery_sha256': sha256(canonical({'declared': not stripe, + 'locator': 'verified-record' if not stripe else 'recorded-response-only'})), + 'retention_sha256': sha256(canonical({'attestation_days': 30, + 'provider_idempotency_seconds': 86400 if stripe else None, + 'commissioning_seconds': 7200})), + } + contract = {'schema': 'auths.provider-contract/1', 'provider': 'stripe' if stripe else 'airtable', + 'api_release': '2025-03-31.basil' if stripe else 'web-api-v0-reviewed-2026-10-07', + 'manual_assumptions': assumptions, 'environment_class': reference.ENVIRONMENT, + 'corpus_manifest_sha256': sha256(canonical(plan)), + 'oracle_version': 'auths-reviewed-reference-v2', 'declarations': declarations} + reasons = {'observer-rotation': 'The qualified reference configures no signing observer.'} + if stripe: + reasons['recovery'] = 'An unrecorded refund response has no verified response locator; loss remains unknown.' + else: + reasons.update({ + 'credential-guard': 'This recipe declares no provider credential guard.', + 'version-pin': 'Airtable Web API v0 has no immutable response version pin.', + 'account-binding': 'The recipe declares no account-binding probe.', + 'denied-reads': 'The recipe declares no denied credential reads; token scope is an operator assumption.', + 'ceiling': 'The field-update profile carries no numeric amount or relative ceiling.', + 'budget': 'This recipe declares no numeric count/sum budget.', + 'idempotency': 'This PATCH declares no provider idempotency key.', + 'response-locator': 'Observation uses the verified record ID, not a write-response locator.', + }) + record = {'provider_kind': 'stripe' if stripe else 'airtable', 'validity_days': 30, + 'not_applicable': [{'capability': name, 'reason': reason} for name, reason in sorted(reasons.items())], + 'custody_descriptor': 'aws-secrets-manager-v1 with immutable versions and a customer-managed key', + 'store_descriptor': 'postgresql-v1 schema 6 shared by two isolated gateway processes', + 'residual_assumptions': assumptions, + 'excluded_claims': sorted(['Global exactly-once effects across other actors or deployments.', + 'Provider availability, settlement, or indefinite retention.', + 'Connected-account selection or restrictions.' if stripe else + 'Gateway enforcement of the personal access token resource configuration.'])} + outputs = {name: canonical(value) for name, value in [ + ('corpus-manifest.json', plan), ('contract.json', contract), ('record.json', record)]} + decision = ( + '# ' + reference.FAMILY + '\n\nStatus: source plan; no production qualification.\n\n' + 'The provider decision is [ADR](../../../docs/adr/' + adr + '). Its exact digest is in the reviewed plan.\n\n' + 'The source-owned compiler expands the complete closed packet pool into the native three-phase corpus. ' + 'Offline operations execute the installed SDK and shipping native reviewer. Protected operations ' + 'must use PostgreSQL, actual AWS custody, two processes, fresh read-back and measured counters. ' + 'An absent protected implementation refuses and emits no observation.\n\n' + 'The contract hashes this reviewed source plan; the record separately hashes its concrete native ' + 'corpus expansion. This avoids a source/candidate/actor commitment cycle. The signer must ' + 'reconstruct the expansion from its own reviewed source before issuing authority.\n') + harness = ('#!/usr/bin/env python3\nimport sys\nfrom pathlib import Path\n' + "sys.dont_write_bytecode = True\nsys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'reference'))\n" + 'from family_harness import main\nmain(' + repr(reference.FAMILY) + ', sys.argv[1:])\n') + outputs.update({'decision-record.md': decision.encode(), 'harness': harness.encode()}) + for name, value in outputs.items(): + if check: + require(read(family / name, 2 * 1024 * 1024) == value, + 'qualification.harness.reviewed-plan-drift') + else: + (family / name).write_bytes(value) + if not check: (family / 'harness').chmod(0o755) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + finish(lambda: generate(args.check)) diff --git a/qualification/reference/installed_submit.py b/qualification/reference/installed_submit.py new file mode 100644 index 000000000..8adaa96e9 --- /dev/null +++ b/qualification/reference/installed_submit.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Actual installed Python client with public proof/action and no credential.""" + +import argparse +import asyncio +from dataclasses import asdict +from pathlib import Path + +from author_packets import installed_native +from common import canonical, require +from expand import read +from resource_io import finish + + +def submit(endpoint, proof, action): + installed_native() + from auths.gateway import GatewayClient, GatewayEndpoint + result = asyncio.run(GatewayClient(GatewayEndpoint(endpoint)).submit( + proof=read(proof, 4 * 1024 * 1024), action=read(action, 65536))) + payload = canonical(asdict(result)) + require(len(payload) <= 65536, 'qualification.consumer.output-bound') + print(payload.decode()) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ['endpoint', 'proof', 'action']: + parser.add_argument('--' + name, type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: submit(args.endpoint, args.proof, args.action)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/measure.py b/qualification/reference/measure.py new file mode 100644 index 000000000..6806690b7 --- /dev/null +++ b/qualification/reference/measure.py @@ -0,0 +1,44 @@ +"""Release-only validation of native process-local boundary measurements.""" + +from common import closed, identifier, integer, require + +FIELDS = ['schema', 'scope', 'credential_lease_calls', 'write_transport_entries', + 'read_transport_entries'] +MAXIMUM = (1 << 64) - 1 + + +def snapshot(value): + closed(value, FIELDS) + require(value['schema'] == 'auths.gateway-execution-witness/1', + 'qualification.measure.schema') + identifier(value['scope'], r'[0-9a-f]{32}') + for field in FIELDS[2:]: + # Saturation is unusable evidence; do not infer a zero delta from it. + integer(value[field], 0, MAXIMUM - 1) + return value + + +def delta(before, after): + before, after = snapshot(before), snapshot(after) + require(before['scope'] == after['scope'], 'qualification.measure.changed-scope') + result = {} + for field in FIELDS[2:]: + require(after[field] >= before[field], 'qualification.measure.decreasing-counter') + result[field] = after[field] - before[field] + return result + + +def aggregate(pairs): + require(type(pairs) is list and 1 <= len(pairs) <= 2, + 'qualification.measure.host-bound') + seen, result = set(), dict.fromkeys(FIELDS[2:], 0) + for pair in pairs: + require(type(pair) in [tuple, list] and len(pair) == 2, 'qualification.measure.host-bound') + before, after = pair + measured = delta(before, after) + require(before['scope'] not in seen, 'qualification.measure.duplicate-host') + seen.add(before['scope']) + for field, count in measured.items(): + result[field] += count + integer(result[field], 0, (1 << 32) - 1) + return result diff --git a/qualification/reference/native_observation.py b/qualification/reference/native_observation.py new file mode 100644 index 000000000..7658e2636 --- /dev/null +++ b/qualification/reference/native_observation.py @@ -0,0 +1,137 @@ +"""Release-only projection of actual native results and independent read-back. + +No passed flag, expected verdict, budget consumption or HTTP success can +substitute for native execution counters or provider evidence. This helper +returns measured facts only; the native corpus runner compares them with the +source-owned case. It grants no authority and never performs provider I/O. +""" + +import re + +import airtable_record +import stripe_platform +from common import canonical, closed, digest, echo, integer, require, sha256, text +import fresh_evidence +import measure + + +REFERENCES = {reference.FAMILY: reference for reference in [stripe_platform, airtable_record]} + + +def result(value): + """Decode exactly one native result, retaining every meaningful distinction.""" + require(type(value) is dict and type(value.get('outcome')) is str, + 'qualification.observation.result') + kind = value['outcome'] + if kind in ['denied', 'indeterminate', 'not-entered']: + closed(value, ['outcome', 'code']) + code = text(value['code'], maximum=96) + require(re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9._-]{0,95}', code) is not None, + 'qualification.observation.code') + return 'refused', code, None + if kind == 'unknown': + closed(value, ['outcome']) + return 'unknown', kind, None + if kind == 'response-recorded': + closed(value, ['outcome', 'status']) + integer(value['status'], 100, 599) + return 'response-recorded', kind, None + if kind == 'observed': + # Value equality alone cannot produce the linked evidence required + # by these two reviewed families. Retain no positive effect claim. + closed(value, ['outcome', 'status', 'matched']) + integer(value['status'], 100, 599) + require(type(value['matched']) is bool, 'qualification.observation.result') + return 'response-recorded', 'unlinked-observation', None + require(kind == 'observed-by-provider', 'qualification.observation.result') + closed(value, ['outcome', 'status', 'evidence']) + if value['status'] is not None: + integer(value['status'], 100, 599) + evidence = value['evidence'] + closed(evidence, ['channel', 'echo', 'evidence_digest', 'observed_at']) + require(evidence['channel'] == 'read-back', 'qualification.observation.channel') + digest(evidence['evidence_digest']) + integer(evidence['observed_at'], 0, 253402300799) + text(evidence['echo'], maximum=128) + return 'observed', kind, evidence + + +class Effects: + """One journey's measured entries and confirmations, keyed by exact action. + + A read-only recovery can confirm the previously measured entered write. + Further read-backs cannot count that write again. A fresh engine scope + still needs its own before/after measurement; native scope validation is + never replaced by this ledger. + """ + + def __init__(self): + self.entries = {} + self.tuple_sha256 = None + + def project(self, tuple_value, reviewed, resources, native_result, before, after, response=None): + tuple_sha256 = sha256(b'auths.qualification-tuple/1\0' + canonical(tuple_value)) + require(self.tuple_sha256 in [None, tuple_sha256], 'qualification.observation.changed-tuple') + family = tuple_value['recipe_family'] + reference = REFERENCES.get(family) + require(reference is not None, 'qualification.observation.family') + reference.resources(resources, resources['protected_run']) + closed(reviewed, ['schema', 'actors', 'action_commitment', 'arguments', 'request']) + require(reviewed['schema'] == 'auths.gateway-submission-review/1' + and type(reviewed['actors']) is list and len(reviewed['actors']) == 1, + 'qualification.observation.review') + text(reviewed['actors'][0], maximum=1024) + commitment = digest(reviewed['action_commitment']) + recipe_digest = digest(tuple_value['compiled_recipe_sha256']) + arguments = reviewed['arguments'] + expected_request = reference.request(arguments, resources, commitment, recipe_digest) + require(reviewed['request'] == expected_request, 'qualification.observation.request') + counted = measure.delta(before, after) + leases = integer(counted['credential_lease_calls'], 0, (1 << 32) - 1) + writes = integer(counted['write_transport_entries'], 0, 1) + outcome, code, evidence = result(native_result) + require(outcome != 'refused' or writes == 0, 'qualification.observation.refused-entry') + key = (family, resources['protected_run'], arguments['operator_namespace'], arguments['operation_id']) + binding = sha256(canonical({'arguments': arguments, 'action_commitment': commitment, + 'resources': resources, 'recipe_digest': recipe_digest})) + prior = self.entries.get(key) + require(writes == 0 or prior is None, 'qualification.observation.duplicate-entry') + require(writes == 0 or len(self.entries) < 64, 'qualification.observation.entry-bound') + require(writes == 0 or leases > 0, 'qualification.observation.entry-without-lease') + require(outcome == 'refused' or prior is None or prior['binding'] == binding, + 'qualification.observation.changed-action') + fresh, confirmed = None, 0 + if evidence is None: + require(response is None, 'qualification.observation.unexpected-response') + else: + require(type(response) is bytes and (writes == 1 or prior is not None), + 'qualification.observation.unmeasured-effect') + require(native_result['status'] == 200 + or (native_result['status'] is None and reference is airtable_record), + 'qualification.observation.status') + require(evidence['echo'] == echo(arguments['operator_namespace'], arguments['operation_id'], commitment), + 'qualification.observation.echo') + fresh = fresh_evidence.witness(family, arguments, resources, commitment, recipe_digest, response) + require(fresh['response_sha256'] == evidence['evidence_digest'], + 'qualification.observation.response-digest') + confirmed = int(prior is None or not prior['confirmed']) + # Refusals, malformed results and evidence disagreements leave the + # ledger untouched. There is no usable projection on those failures. + if writes == 1: + self.entries[key] = {'binding': binding, 'confirmed': evidence is not None} + self.tuple_sha256 = tuple_sha256 + elif evidence is not None: + prior['confirmed'] = True + return {'verdict': {'outcome': outcome, 'code': code, + 'request_sha256': None if outcome == 'refused' else sha256(canonical(reviewed['request'])), + 'evidence_sha256': None if evidence is None else evidence['evidence_digest']}, + 'credential_leases': leases, 'provider_entries': writes, + 'confirmed_by_read_back': confirmed}, fresh + + def commissioning(self, tuple_value, reviewed, resources, execution, response=None): + """Read the private command's actual final envelope, not a report flag.""" + closed(execution, ['schema', 'result', 'before', 'after']) + require(execution['schema'] == 'auths.gateway-commissioning-execution/1', + 'qualification.observation.schema') + return self.project(tuple_value, reviewed, resources, execution['result'], + execution['before'], execution['after'], response) diff --git a/qualification/reference/network_fault.py b/qualification/reference/network_fault.py new file mode 100644 index 000000000..8b4da84e3 --- /dev/null +++ b/qualification/reference/network_fault.py @@ -0,0 +1,172 @@ +"""Disposable Linux UID isolation and transparent provider-response faults. + +Only reviewed provider addresses are redirected. TLS remains end to end, and +an independent provider read, rather than traffic, decides whether a response +can be lost. All rules are removed individually; no shared chain is flushed. +""" + +import asyncio +from contextlib import AbstractContextManager +import ipaddress +import os +from pathlib import Path +import socket +import subprocess +import sys +import threading +import time + +from common import Refusal, require +import measure +from production_setup import APPLICATION_UID, GATEWAY_UID + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'run')) +from tls_fault import Fault, ORIGINS, MAX_RECORD, relay + +AUTHOR_UID = 62002 + + +class Rules(AbstractContextManager): + def __init__(self): + require(sys.platform == 'linux' and os.getuid() == 0, 'qualification.network.identity') + self.removals = [] + + def add(self, executable, table, arguments): + require(executable in ['iptables', 'ip6tables'] and table in ['filter', 'nat'] + and arguments[0] == '-A', 'qualification.network.rule') + self.command(executable, table, arguments) + self.removals.append((executable, table, ['-D', *arguments[1:]])) + + @staticmethod + def command(executable, table, arguments): + result = subprocess.run([executable, '-w', '5', '-t', table, *map(str, arguments)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15, env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'}) + require(result.returncode == 0, 'qualification.network.rule-refused') + + def __exit__(self, kind, value, traceback): + failures = [] + for executable, table, arguments in reversed(self.removals): + try: + self.command(executable, table, arguments) + except (Refusal, OSError, subprocess.SubprocessError): + failures.append(True) + self.removals = [] + require(not failures, 'qualification.network.cleanup-refused') + + +class Isolation(Rules): + def __enter__(self): + try: + for executable in ['iptables', 'ip6tables']: + for owner in [AUTHOR_UID, APPLICATION_UID]: + self.add(executable, 'filter', ['-A', 'OUTPUT', '-m', 'owner', '--uid-owner', owner, + '-m', 'comment', '--comment', 'auths-qualification-' + str(os.getpid()), '-j', 'REJECT']) + except BaseException: + self.__exit__(None, None, None) + raise + return self + + +class NativeWitness: + """Read a live gateway's actual diagnostic; never synthesize a counter scope.""" + def __init__(self, deployment, host, context): + self.deployment, self.host, self.context = deployment, host, context + self.before = self.last = deployment.witness(host, context) + + def entered(self): + current = self.deployment.witness(self.host, self.context) + measure.delta(self.last, current) + counted = measure.delta(self.before, current) + require(counted['write_transport_entries'] <= 1, 'qualification.fault.multiple-writes') + self.last = current + return counted['write_transport_entries'] == 1 + + +class ResponseFault(Rules): + def __init__(self, family, witness): + super().__init__() + require(family in ORIGINS, 'qualification.fault.family') + self.witness = witness + self.approved = {entry[4][0] for entry in socket.getaddrinfo( + ORIGINS[family], 443, socket.AF_INET, socket.SOCK_STREAM)} + self.ipv6 = {entry[4][0] for entry in socket.getaddrinfo( + ORIGINS[family], 443, socket.AF_UNSPEC, socket.SOCK_STREAM) if entry[0] == socket.AF_INET6} + require(1 <= len(self.approved) <= 16 and len(self.ipv6) <= 16 + and all(ipaddress.ip_address(value).is_global for value in self.approved | self.ipv6), + 'qualification.fault.provider-address') + self.ready, self.finished = threading.Event(), threading.Event() + self.failure, self.loop, self.fault, self.port = None, None, None, None + self.thread = threading.Thread(target=self.run, name='auths-transparent-response-fault', daemon=True) + + async def serving(self): + self.loop = asyncio.get_running_loop() + self.fault = Fault(self.witness) + server = await asyncio.start_server(lambda r, w: relay(r, w, self.fault, self.approved), + '127.0.0.1', 0, limit=MAX_RECORD * 2) + self.port = server.sockets[0].getsockname()[1] + self.ready.set() + try: + async with server: + while not self.finished.is_set(): + await asyncio.sleep(0.05) + finally: + server.close() + await server.wait_closed() + + def run(self): + try: + asyncio.run(self.serving()) + except BaseException: + self.failure = True + self.ready.set() + + def __enter__(self): + self.thread.start() + try: + require(self.ready.wait(10) and not self.failure and self.port is not None, + 'qualification.fault.relay-not-ready') + for address in sorted(self.approved): + self.add('iptables', 'nat', ['-A', 'OUTPUT', '-m', 'owner', '--uid-owner', GATEWAY_UID, + '-p', 'tcp', '-d', address, '--dport', 443, '-j', 'REDIRECT', '--to-ports', self.port]) + # An alternate IPv6 route must not evade the held response. Reject + # only this provider's addresses; custody and database stay usable. + for address in sorted(self.ipv6): + self.add('ip6tables', 'filter', ['-A', 'OUTPUT', '-m', 'owner', '--uid-owner', GATEWAY_UID, + '-p', 'tcp', '-d', address, '--dport', 443, '-j', 'REJECT']) + except BaseException: + self.__exit__(None, None, None) + raise + return self + + def held(self): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + require(not self.failure and self.thread.is_alive(), 'qualification.fault.relay-refused') + if self.fault.armed and self.fault.held_connections > 0 and self.fault.buffered > 0: + require(self.witness.entered(), 'qualification.fault.unmeasured-entry') + return + time.sleep(0.05) + require(False, 'qualification.fault.response-not-held') + + def decide(self, decision): + require(decision in ['drop', 'release'], 'qualification.fault.control-state') + completed, failed = threading.Event(), [] + def apply(): + try: + self.fault.decide({'command': decision}) + except BaseException: + failed.append(True) + finally: + completed.set() + self.loop.call_soon_threadsafe(apply) + require(completed.wait(5) and not failed, 'qualification.fault.control-state') + + def __exit__(self, kind, value, traceback): + # First remove routing, then finish retained encrypted connections. + try: + super().__exit__(kind, value, traceback) + finally: + self.finished.set() + self.thread.join(timeout=10) + require(not self.thread.is_alive(), 'qualification.fault.relay-stop') diff --git a/qualification/reference/packet_plan.py b/qualification/reference/packet_plan.py new file mode 100644 index 000000000..6fe8f2b69 --- /dev/null +++ b/qualification/reference/packet_plan.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Prepare public packet inputs from the two reviewed resource carriers. + +No credential is accepted. The installed author receives only the recipe pin, +closed bounded arguments and a public grant extension from the native gateway. +""" + +import argparse +from pathlib import Path +import time + +import airtable_record +import stripe_platform +from common import canonical, closed, digest, require, sha256 +from expand import child, decode, read, SOURCES +from resource_io import finish, run_id, write, write_bytes + +REFERENCES = {reference.FAMILY: reference for reference in [stripe_platform, airtable_record]} +PACKET_VALIDITY = 7200 +# Distinct source-owned windows isolate the four boundary experiments from +# the ordinary grant and each other. Native counters retain their existing +# subject/namespace/window keys; nothing resets or edits their persisted state. +BUDGET_WINDOWS = {'commissioning-count': 2 * 86400, 'commissioning-sum': 3 * 86400, + 'live-count': 5 * 86400, 'live-sum': 7 * 86400} + + +def grant_for(label): + for phase in ['commissioning', 'live']: + for kind in ['count', 'sum']: + if label in [phase + '-budget-' + kind, phase + '-budget-' + kind + '-over']: + return phase + '-' + kind + return 'default' + + +def arguments(family, resources, recipe_digest): + """One separate operation per resource and phase; reuse never adds a write.""" + reference = REFERENCES.get(family) + require(reference is not None, 'qualification.packets.family') + reference.resources(resources, run_id(resources['protected_run'])) + recipe_digest = digest(recipe_digest) + namespace = reference.SERVICE if reference is stripe_platform else 'airtable-demo' + values = resources['payments'] if reference is stripe_platform else resources['records'] + packets = [] + for phase in ['commissioning', 'live']: + for index, resource in enumerate(values): + label = phase + '-' + str(index).zfill(2) + # The run marker makes logical operation identities distinct across + # reruns, while an exact replay retains the original operation ID. + operation = 'qlf-' + sha256(canonical([family, resources['protected_run'], label]))[:48] + value = {'operator_namespace': namespace, 'operation_id': operation, + 'recipe_digest': recipe_digest} + if reference is stripe_platform: + require(resource['amount_received'] == 2000, 'qualification.packets.payment-bound') + value.update(payment_intent=resource['id'], amount=500, currency='usd') + else: + value.update(record_id=resource['id'], replacement='Approved' if phase == 'commissioning' else 'Pending') + # This checks the full resource/action request contract before the + # author can turn these inputs into signed action bytes. + reference.request(value, resources, '0' * 64, recipe_digest) + packets.append({'label': label, 'context': 'initial', 'arguments': value}) + if index == 0: + # Same logical operation and exact arguments, under the other + # predeclared challenge. It is replay evidence, never a second + # planned write or caller-selected authoring authority. + packets.append({'label': label + '-fresh', 'context': 'fresh', 'arguments': dict(value)}) + if reference is stripe_platform: + for kind, changed in [('ceiling', {'amount': 1001}), ('currency', {'currency': 'eur'})]: + label = phase + '-guard-' + kind + probe = dict(value, **changed) + probe['operation_id'] = 'qlf-' + sha256(canonical([family, resources['protected_run'], label]))[:48] + # These valid native actions are deliberately outside the + # provider's relative guard, so the corpus can measure its + # real post-lease refusal rather than a permit refusal. + reference.request(probe, resources, '0' * 64, recipe_digest) + require(reference.entry_policy(probe, resources) is not None, + 'qualification.packets.probe-binding') + packets.append({'label': label, 'context': 'initial', 'arguments': probe}) + if len(values) == 16: + for kind in ['kind', 'generation', 'commitment', 'version']: + label = phase + '-custody-' + kind + probe = dict(value, payment_intent=values[13]['id'], amount=500, currency='usd') + probe['operation_id'] = 'qlf-' + sha256(canonical([family, resources['protected_run'], label]))[:48] + reference.request(probe, resources, '0' * 64, recipe_digest) + packets.append({'label': label, 'context': 'initial', 'arguments': probe}) + if len(values) >= 2: + for kind in ['count', 'sum']: + for overflow in [False, True]: + label = phase + '-budget-' + kind + ('-over' if overflow else '') + selected = -2 if kind == 'count' else -1 + if overflow: selected = -1 if kind == 'count' else -2 + probe = dict(value, payment_intent=values[selected]['id'], + amount=500 if overflow else 1000, currency='usd') + probe['operation_id'] = 'qlf-' + sha256(canonical([family, resources['protected_run'], label]))[:48] + reference.request(probe, resources, '0' * 64, recipe_digest) + require(reference.entry_policy(probe, resources) is None, + 'qualification.packets.budget-binding') + packets.append({'label': label, 'context': 'initial', 'arguments': probe}) + elif len(values) == 16: + for kind in ['kind', 'generation', 'commitment', 'version']: + label = phase + '-custody-' + kind + probe = dict(value, record_id=values[13]['id']) + probe['operation_id'] = 'qlf-' + sha256(canonical([family, resources['protected_run'], label]))[:48] + reference.request(probe, resources, '0' * 64, recipe_digest) + packets.append({'label': label, 'context': 'initial', 'arguments': probe}) + return packets + + +def public_pool(family, resources, recipe_digest, carrier): + """Reconstruct the entire source-owned pool before signing any authority. + + This checks the original carrier, not a one-packet refresh. Native review + still authenticates each proof and both distinct context byte strings. + """ + closed(carrier, ['schema', 'protected_run', 'evaluated_at', 'not_after', + 'trusted_contexts', 'packets']) + require(carrier['schema'] == 'auths.qualification-public-packets/4' + and carrier['protected_run'] == resources['protected_run'] + and carrier['trusted_contexts'] == ['context-0.cbor', 'context-1.cbor'], + 'qualification.packets.pool-binding') + planned = arguments(family, resources, recipe_digest) + expected = [{'label': packet['label'], 'proof': packet['label'] + '.proof', + 'action': packet['label'] + '.action', + 'trusted_context': 'context-' + str(['initial', 'fresh'].index(packet['context'])) + '.cbor', + 'arguments': packet['arguments']} for packet in planned] + require(4 <= len(expected) <= 64 and carrier['packets'] == expected, + 'qualification.packets.pool-binding') + return expected + + +def validate(plan): + closed(plan, ['schema', 'family', 'protected_run', 'evaluated_at', 'not_after', + 'configuration', 'extension', 'resources', 'packets']) + require(plan['schema'] == 'auths.qualification-packet-plan/4' + and type(plan['family']) is str and plan['family'] in REFERENCES, + 'qualification.packets.schema') + run_id(plan['protected_run']) + require(type(plan['evaluated_at']) is int and 60 <= plan['evaluated_at'] <= 253402293599 + and type(plan['not_after']) is int + and plan['not_after'] == (min(plan['evaluated_at'] + PACKET_VALIDITY, + *[(plan['evaluated_at'] // window + 1) * window + for window in [86400, *BUDGET_WINDOWS.values()]]) + if plan['family'] == stripe_platform.FAMILY else plan['evaluated_at'] + PACKET_VALIDITY), + 'qualification.packets.time-bound') + digest(plan['configuration']) + packets = plan['packets'] + require(type(packets) is list and 4 <= len(packets) <= 64, + 'qualification.packets.packet-bound') + labels, operations = set(), set() + for packet in packets: + closed(packet, ['label', 'context', 'arguments']) + require(type(packet['label']) is str and packet['label'] not in labels + and packet['label'] in ({phase + '-' + str(index).zfill(2) + suffix + for phase in ['commissioning', 'live'] for index in range(32) + for suffix in (['', '-fresh'] if index == 0 else [''])} + | ({phase + '-guard-' + kind for phase in ['commissioning', 'live'] + for kind in ['ceiling', 'currency']} if plan['family'] == stripe_platform.FAMILY else set()) + | ({phase + '-budget-' + kind + suffix for phase in ['commissioning', 'live'] + for kind in ['count', 'sum'] for suffix in ['', '-over']} + if plan['family'] == stripe_platform.FAMILY else set()) + | ({phase + '-custody-' + kind for phase in ['commissioning', 'live'] + for kind in ['kind', 'generation', 'commitment', 'version']})) + and packet['context'] == ('fresh' if packet['label'].endswith('-fresh') else 'initial'), + 'qualification.packets.packet-bound') + labels.add(packet['label']) + value = packet['arguments'] + fields = ['operator_namespace', 'operation_id', 'recipe_digest'] + fields += ['payment_intent', 'amount', 'currency'] if plan['family'] == stripe_platform.FAMILY else ['record_id', 'replacement'] + closed(value, fields) + operation = 'qlf-' + sha256(canonical([plan['family'], plan['protected_run'], + packet['label'].removesuffix('-fresh')]))[:48] + require(value['operation_id'] == operation + and (operation, packet['context']) not in operations, + 'qualification.packets.operation-binding') + digest(value['recipe_digest']) + operations.add((operation, packet['context'])) + require(len({packet['arguments']['recipe_digest'] for packet in packets}) == 1, + 'qualification.packets.recipe-binding') + require(packets == arguments(plan['family'], plan['resources'], packets[0]['arguments']['recipe_digest']) + and plan['resources']['protected_run'] == plan['protected_run'], + 'qualification.packets.resource-binding') + extension = plan['extension'] + if plan['family'] == stripe_platform.FAMILY: + closed(extension, ['default', *BUDGET_WINDOWS]) + for value in extension.values(): + closed(value, ['extension_id', 'extension_body_hex']) + require(value['extension_id'] == 'bounded-policy-commitment-v1' + and type(value['extension_body_hex']) is str + and 0 < len(value['extension_body_hex']) <= 8192 + and len(value['extension_body_hex']) % 2 == 0 + and all(character in '0123456789abcdef' for character in value['extension_body_hex']), + 'qualification.packets.extension-bound') + else: + require(extension is None, 'qualification.packets.extension-bound') + return plan + + +def prepare(args): + reference = REFERENCES.get(args.family) + require(reference is not None, 'qualification.packets.family') + resources = decode(read(args.resources, 65536)) + reference.resources(resources, run_id(resources['protected_run'])) + source = SOURCES[args.family] + recipe = reference.recipe(decode(read(source / 'recipe.json', 65536)), resources) + # The private output directory must already exist. No private author key + # or credential is ever created here, and existing outputs are refused. + write(args.work / 'recipe.json', recipe, new=True) + lock = read(source / 'profile.lock.json', 65536) + write_bytes(args.work / 'profile.lock.json', lock, new=True) + review = child(args.gateway, ['review', '--recipe', args.work / 'recipe.json', + '--profile-lock', args.work / 'profile.lock.json']) + require(review.get('schema') == 'auths.gateway-recipe-review/2', 'qualification.packets.native-review') + extension = None + if reference is stripe_platform: + extension = {} + for name in ['default', *BUDGET_WINDOWS]: + count = 64 if name == 'default' else (1 if name.endswith('-count') else 2) + total = 32000 if name == 'default' else (2000 if name.endswith('-count') else 1000) + derived = child(args.gateway, ['bound-extension', '--argument', 'amount', '--ceiling', '10000', + '--window-seconds', str(BUDGET_WINDOWS.get(name, 86400)), '--max-count', str(count), + '--sum-limit', str(total), '--partition', 'currency=usd,eur', + '--scope', 'payment_intent=' + ','.join(payment['id'] for payment in resources['payments'])]) + extension[name] = {field: derived[field] for field in ['extension_id', 'extension_body_hex']} + evaluated_at = int(time.time()) + # Crossing a declared window would change the capacity experiment. Limit + # the ordinary author lifetime to the real next boundary, never a fake clock. + ends = [(evaluated_at // window + 1) * window for window in [86400, *BUDGET_WINDOWS.values()]] + end = min(evaluated_at + PACKET_VALIDITY, *ends) if reference is stripe_platform else evaluated_at + PACKET_VALIDITY + plan = validate({'schema': 'auths.qualification-packet-plan/4', 'family': args.family, + 'protected_run': resources['protected_run'], 'evaluated_at': evaluated_at, + 'not_after': end, 'configuration': review['verifier_configuration'], + 'extension': extension, 'resources': resources, + 'packets': arguments(args.family, resources, review['recipe_digest'])}) + write(args.work / 'packet-plan.json', plan, new=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--family', choices=sorted(REFERENCES), required=True) + for name in ['resources', 'gateway', 'work']: + parser.add_argument('--' + name, type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: prepare(args)) + + +if __name__ == '__main__': + main() diff --git a/qualification/reference/production_setup.py b/qualification/reference/production_setup.py new file mode 100644 index 000000000..2d966eaf3 --- /dev/null +++ b/qualification/reference/production_setup.py @@ -0,0 +1,350 @@ +"""Source-owned production installation and process custody for qualification. + +This controller requires a real TLS database and existing web-identity token +files. It provisions no IAM grants and offers no development-store fallback. +Provider arguments, recipe identity and results remain owned by the two family +references and the shipping gateway. Nothing here issues an observation. +""" + +import os +from pathlib import Path +import re +import signal +import stat +import subprocess +import time + +from author_operator import ALIAS +from common import closed, require, sha256 +from expand import decode, read +from resource_io import write_bytes + +GATEWAY_UID = 62001 +APPLICATION_UID = 62004 +REGION = 'eu-west-1' +ROLE_PREFIX = 'arn:aws:iam::585985124542:role/auths-gateway-custody-' +OPERATOR_ROLE = ROLE_PREFIX + 'operator' +RUNTIME_ROLE = ROLE_PREFIX + 'runtime' +KMS_KEY = 'arn:aws:kms:eu-west-1:585985124542:key/1e1c85ae-7d9c-4f2d-978f-bd672b597907' +DATABASE_ENV = ['AUTHS_POSTGRES_URL', 'AUTHS_POSTGRES_CA_PEM', 'AUTHS_POSTGRES_SERVER_NAME'] + + +def private_file(path, owner): + path = Path(path).absolute() + info = os.lstat(path) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_uid == owner + and stat.S_IMODE(info.st_mode) == 0o600, + 'qualification.production.private-input') + # Native state paths reject symlinked ancestors as well as symlinked files. + require(all(not parent.is_symlink() for parent in [path, *path.parents]), + 'qualification.production.private-input') + return path + + +def native_output(result, canaries, *, required=True, json_output=True): + require(len(result.stdout) <= 65536 and len(result.stderr) <= 65536, + 'qualification.production.output-bound') + require(all(value not in result.stdout + result.stderr for value in canaries), + 'qualification.production.secret-exposed') + if required and result.returncode != 0: + # A native refusal keeps its stable code, never its external details. + match = re.match(rb'(gateway\.[a-z0-9.-]{1,128})(?:\s|$)', result.stderr) + require(False, 'qualification.production.' + (match[1].decode() if match else 'native-refused')) + if result.returncode != 0: + return None + return decode(result.stdout) if json_output else result.stdout + + +class Deployment: + def __init__(self, binary, work, private, environment, canaries): + require(os.name == 'posix' and os.getuid() == 0, + 'qualification.production.controller-identity') + self.binary, self.work, self.private = Path(binary).absolute(), Path(work).absolute(), Path(private).absolute() + self.tuple = decode(read(self.work / 'tuple.json', 65536)) + executable = read(self.binary, 256 * 1024 * 1024) + require(sha256(executable) == self.tuple['target']['gateway_build_sha256'], + 'qualification.production.candidate-bytes') + require(not self.private.exists() and not self.private.is_relative_to(self.work), + 'qualification.production.private-directory') + self.private.mkdir(mode=0o711) + os.chmod(self.private, 0o711) + # Runner temp ancestors may exclude the gateway/application UIDs. + # Copy these exact checked executable bytes into a traversable parent. + self.binary = self.private / 'auths-gateway' + fd = os.open(self.binary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755) + os.fchmod(fd, 0o755) + with os.fdopen(fd, 'wb') as stream: + stream.write(executable) + stream.flush() + os.fsync(stream.fileno()) + self.gateway = self.private / 'gateway' + self.gateway.mkdir(mode=0o700) + self.sockets = self.private / 'sockets' + self.sockets.mkdir(mode=0o750) + os.chmod(self.sockets, 0o750) + os.chown(self.sockets, GATEWAY_UID, GATEWAY_UID) + self.canaries = list(canaries) + require(self.canaries and all(type(value) is bytes and len(value) >= 8 for value in self.canaries), + 'qualification.production.canaries') + self.database = {} + for name in DATABASE_ENV: + value = environment[name] + require(type(value) is str and 0 < len(value) <= 8192 and '\n' not in value and '\0' not in value, + 'qualification.production.database-input') + self.database[name] = value + require('sslmode=require' in self.database['AUTHS_POSTGRES_URL'], + 'qualification.production.database-tls') + ca = Path(self.database['AUTHS_POSTGRES_CA_PEM']).absolute() + write_bytes(self.gateway / 'postgres-ca.pem', read(ca, 65536), new=True) + os.chown(self.gateway / 'postgres-ca.pem', GATEWAY_UID, GATEWAY_UID) + self.database['AUTHS_POSTGRES_CA_PEM'] = str(self.gateway / 'postgres-ca.pem') + self.operator_token = private_file(environment['AUTHS_QUALIFICATION_OPERATOR_TOKEN_FILE'], GATEWAY_UID) + self.runtime_token = private_file(environment['AUTHS_QUALIFICATION_RUNTIME_TOKEN_FILE'], GATEWAY_UID) + require(not self.operator_token.is_relative_to(self.work) + and not self.runtime_token.is_relative_to(self.work), + 'qualification.production.private-input') + self.namespace = environment['AUTHS_QUALIFICATION_CREDENTIAL_NAMESPACE'] + require(re.fullmatch(r'live-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}', self.namespace) is not None, + 'qualification.production.namespace') + carrier = decode(read(self.work / 'public-packets.json', 65536)) + require(carrier['trusted_contexts'] == ['context-0.cbor', 'context-1.cbor'], + 'qualification.production.contexts') + family = self.tuple['recipe_family'] + require(family in ['stripe-platform-refund-v1', 'airtable-record-update-v1'], + 'qualification.production.family') + self.provider = 'stripe' if family == 'stripe-platform-refund-v1' else 'airtable' + for name in ['recipe.json', 'profile.lock.json', 'resources.json', *carrier['trusted_contexts'], + *[context + '.operator.json' for context in carrier['trusted_contexts']]]: + path = self.gateway / name + write_bytes(path, read(self.work / name, 4 * 1024 * 1024), new=True) + os.chown(path, GATEWAY_UID, GATEWAY_UID) + os.chown(self.gateway, GATEWAY_UID, GATEWAY_UID) + self.processes = {} + self.handoff_generation = 0 + self.permit = None + + def owned_inputs(self, name, values, owner): + require(re.fullmatch(r'[a-z][a-z0-9-]{0,63}', name) is not None + and owner in [GATEWAY_UID, APPLICATION_UID], 'qualification.production.private-directory') + directory = self.private / name + directory.mkdir(mode=0o700) + for filename, payload in values.items(): + require(re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}', filename) is not None, + 'qualification.production.private-input') + path = directory / filename + write_bytes(path, payload, new=True) + os.chown(path, owner, GATEWAY_UID) + os.chown(directory, owner, GATEWAY_UID) + return directory + + def packet_inputs(self, handoff, packet, owner): + closed(packet, ['label', 'proof', 'action', 'trusted_context', 'arguments']) + require(re.fullmatch(r'[a-z][a-z0-9-]{0,63}', packet['label']) is not None + and packet['proof'] == packet['label'] + '.proof' + and packet['action'] == packet['label'] + '.action' + and packet['trusted_context'] in ['context-0.cbor', 'context-1.cbor'], + 'qualification.production.packet-binding') + self.handoff_generation += 1 + values = {name: read(Path(handoff) / name, bound) for name, bound in [ + (packet['proof'], 4 * 1024 * 1024), (packet['action'], 65536)]} + return self.owned_inputs('packet-' + str(self.handoff_generation), values, owner) + + def environment(self, administrative=False): + return {'PATH': '/usr/bin:/bin', **self.database, + 'AWS_ROLE_ARN': OPERATOR_ROLE if administrative else RUNTIME_ROLE, + 'AWS_WEB_IDENTITY_TOKEN_FILE': str(self.operator_token if administrative else self.runtime_token), + **({'AUTHS_GATEWAY_RUNTIME_ROLE_ARN': RUNTIME_ROLE, + 'AUTHS_GATEWAY_RUNTIME_TOKEN_FILE': str(self.runtime_token)} if administrative else {})} + + def command(self, arguments, *, administrative=False, secret=b'', required=True, json_output=True): + result = subprocess.run([str(self.binary), *map(str, arguments)], + input=secret, capture_output=True, timeout=90, cwd=self.gateway, + user=GATEWAY_UID, group=GATEWAY_UID, extra_groups=[], env=self.environment(administrative)) + return native_output(result, self.canaries, required=required, json_output=json_output) + + def state(self, host, context=0): + require(host in [0, 1] and context in [0, 1], 'qualification.production.host') + return self.gateway / ('host-' + str(host) + '-context-' + str(context)) + + def app_socket(self, host, context=0): + self.state(host, context) + return self.sockets / ('h' + str(host) + 'c' + str(context) + '.sock') + + def install(self, credential, account): + require(type(credential) is bytes and credential in self.canaries + and b'\n' not in credential and b'\0' not in credential, + 'qualification.production.credential-input') + for context in [0, 1]: + for host in [0, 1]: + state = self.state(host, context) + state.mkdir(mode=0o700) + os.chown(state, GATEWAY_UID, GATEWAY_UID) + trust = 'context-' + str(context) + '.cbor' + arguments = ['install', '--state-dir', state, + '--recipe', self.gateway / 'recipe.json', '--profile-lock', self.gateway / 'profile.lock.json', + '--trusted-context', self.gateway / trust, '--approve-digest', self.tuple['compiled_recipe_sha256'], + '--provider', self.provider, '--alias', ALIAS, '--credential-stdin', + '--deployment', 'production', '--operator-attestation', self.gateway / (trust + '.operator.json'), + '--credential-store', 'aws-secrets-manager-v1', '--credential-namespace', self.namespace, + '--aws-region', REGION, '--aws-kms-key', KMS_KEY, '--aws-identity', 'web-identity', + '--qualification-policy', 'required', '--recipe-family', self.tuple['recipe_family'], + '--provider-contract-id', self.tuple['provider_contract_id']] + arguments += ['--account-label', account] if host == context == 0 else ['--join'] + output = self.command(arguments, administrative=True, secret=credential + b'\n', json_output=False) + expected = ('installed' if host == context == 0 else 'joined') + ' recipe ' \ + + self.tuple['compiled_recipe_sha256'] + ' with separate gateway credential custody\n' + require(output == expected.encode(), 'qualification.production.install-output') + actual = self.command(['qualification-status', '--state-dir', state, '--tuple']) + require(actual == self.tuple, 'qualification.production.installed-tuple') + + def start(self, host, context=0): + key = (host, context) + require(key not in self.processes, 'qualification.production.host-running') + state, endpoint = self.state(host, context), self.app_socket(host, context) + require(len(str(endpoint).encode()) <= 107, 'qualification.production.socket-bound') + process = subprocess.Popen([str(self.binary), 'serve', '--state-dir', str(state), + '--app-socket', str(endpoint)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=self.gateway, user=GATEWAY_UID, group=GATEWAY_UID, extra_groups=[], env=self.environment()) + self.processes[key] = process + deadline = time.monotonic() + 30 + while process.poll() is None and time.monotonic() < deadline: + if endpoint.exists(): + self.witness(host, context) + return + time.sleep(0.05) + require(False, 'qualification.production.gateway-not-ready') + + def stop(self, host, context=0, *, crash=False): + process = self.processes.pop((host, context), None) + require(process is not None and process.poll() is None, 'qualification.production.gateway-not-running') + process.send_signal(signal.SIGKILL if crash else signal.SIGTERM) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + require(False, 'qualification.production.gateway-stop-timeout') + require(process.returncode == (-signal.SIGKILL if crash else 0), + 'qualification.production.gateway-stop-result') + + def witness(self, host, context=0): + from measure import snapshot + result = self.command(['execution-witness', '--state-dir', self.state(host, context)]) + closed(result, ['schema', 'ok', 'code', 'execution_witness']) + require(result['ok'] is True, 'qualification.production.witness-unavailable') + return snapshot(result['execution_witness']) + + def application_submit(self, handoff, packet, host=0, context=0): + inputs = self.packet_inputs(handoff, packet, APPLICATION_UID) + result = subprocess.run([str(self.binary), 'submit', '--app-socket', str(self.app_socket(host, context)), + '--proof', str(inputs / packet['proof']), '--action', str(inputs / packet['action'])], + stdin=subprocess.DEVNULL, capture_output=True, timeout=90, cwd=inputs, + user=APPLICATION_UID, group=GATEWAY_UID, extra_groups=[], env={'PATH': '/usr/bin:/bin'}) + return native_output(result, self.canaries) + + def installed_python_submit(self, python, kit, handoff, packet, host=0, context=0): + inputs = self.packet_inputs(handoff, packet, APPLICATION_UID) + result = subprocess.run([str(python), '-B', str(Path(kit) / 'installed_submit.py'), + '--endpoint', str(self.app_socket(host, context)), '--proof', str(inputs / packet['proof']), + '--action', str(inputs / packet['action'])], stdin=subprocess.DEVNULL, + capture_output=True, timeout=90, cwd=inputs, user=APPLICATION_UID, + group=GATEWAY_UID, extra_groups=[], env={'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'}) + return native_output(result, self.canaries) + + def register_commissioning(self, source): + require(self.permit is None, 'qualification.production.permit-already-registered') + names = ['commissioning-permit.json', 'signer-certificate.json', 'revocation-list.json'] + permit = self.owned_inputs('permit', {name: read(Path(source) / name, 2 * 1024 * 1024) + for name in names}, GATEWAY_UID) + result = self.command(self.commission_arguments('commissioning-init', permit, 0, 0)) + require(result == {'outcome': 'commissioning-registered', 'qualification': 'required'}, + 'qualification.production.permit-not-registered') + self.permit = permit + + def commission_arguments(self, operation, permit, host, context): + require(operation in ['commissioning-init', 'commissioning-submit'], + 'qualification.production.operation') + resources = decode(read(self.work / 'resources.json', 65536)) + return [operation, '--state-dir', self.state(host, context), '--from', permit, + '--protected-run', resources['protected_run'], '--resource-binding', self.gateway / 'resources.json'] + + def commissioning_submit(self, handoff, packet, host=0, context=0, witness=None): + require(self.permit is not None, 'qualification.production.permit-not-registered') + inputs = self.packet_inputs(handoff, packet, GATEWAY_UID) + arguments = [*self.commission_arguments('commissioning-submit', self.permit, host, context), + '--proof', inputs / packet['proof'], '--action', inputs / packet['action']] + if witness is not None: + require(Path(witness).parent == self.state(host, context) and not Path(witness).exists(), + 'qualification.production.private-input') + arguments += ['--witness-file', witness] + execution = self.command(arguments) + closed(execution, ['schema', 'result', 'before', 'after']) + require(execution['schema'] == 'auths.gateway-commissioning-execution/1', + 'qualification.production.execution') + return execution + + def reobserve(self, operation, host=0, context=0): + require(re.fullmatch(r'qlf-[0-9a-f]{48}', operation) is not None, + 'qualification.production.operation') + result = self.command(['reobserve', '--state-dir', self.state(host, context), '--operation-id', operation]) + closed(result, ['schema', 'ok', 'code', 'result']) + require(result['ok'] is True, 'qualification.production.reobserve') + return result['result'] + + def rotate(self, credential): + require(type(credential) is bytes and credential in self.canaries, + 'qualification.production.credential-input') + result = self.command(['rotate', '--state-dir', self.state(0), '--credential-stdin', '--operator-process'], + administrative=True, secret=credential + b'\n') + require(result.get('ok') is True and result.get('code') == 'gateway.admin.rotated', + 'qualification.production.rotation') + return result + + def import_release(self, source): + # Artifact transport already bounds the archive. Here only native + # release members are copied; no script or binary can enter custody. + names = ['signer-certificate.json', 'revocation-list.json', 'release-index.json'] + release = self.owned_inputs('first-release', {name: read(Path(source) / name, 2 * 1024 * 1024) + for name in names}, GATEWAY_UID) + for group in ['records', 'attestations']: + # Temporarily root-own the new directory while using owner-only I/O. + directory = release / group + directory.mkdir(mode=0o700) + entries = sorted((Path(source) / group).iterdir()) + require(1 <= len(entries) <= 64 and all(path.suffix == '.json' for path in entries), + 'qualification.production.release-bound') + for index, path in enumerate(entries): + destination = directory / (str(index).zfill(4) + '.json') + write_bytes(destination, read(path, 2 * 1024 * 1024), new=True) + os.chown(destination, GATEWAY_UID, GATEWAY_UID) + os.chown(directory, GATEWAY_UID, GATEWAY_UID) + for context in [0, 1]: + for host in [0, 1]: + # Import verifies the certificate, revocations, index and + # record closure under this shipping build's pinned root. + self.command(['qualification-import', '--state-dir', self.state(host, context), + '--from', release], json_output=False) + + def support(self, host=0, context=0): + return self.command(['support-bundle', '--state-dir', self.state(host, context)]) + + def doctor(self, host=0, context=0): + return self.command(['doctor', '--state-dir', self.state(host, context), + '--app-socket', self.app_socket(host, context), '--app-uid', APPLICATION_UID, '--app-gid', GATEWAY_UID]) + + def close(self): + for host, context in list(self.processes): + self.stop(host, context) + + def retire_credentials(self): + require(not self.processes, 'qualification.production.host-running') + result = self.command(['revoke', '--state-dir', self.state(0), '--store-only']) + require(result.get('state') == 'revoked' and result.get('credential_deletion') == 'not-attempted', + 'qualification.production.revoke') + for context in [0, 1]: + for host in [0, 1]: + result = self.command(['credential-collect', '--state-dir', self.state(host, context)], administrative=True) + closed(result, ['schema', 'deleted']) + require(result['schema'] == 'auths.gateway-credential-collection/1' + and type(result['deleted']) is int and result['deleted'] >= 0, + 'qualification.production.credential-collection') diff --git a/qualification/reference/provider_readback.py b/qualification/reference/provider_readback.py new file mode 100644 index 000000000..a15b940e6 --- /dev/null +++ b/qualification/reference/provider_readback.py @@ -0,0 +1,59 @@ +"""Fresh provider reads selected by authenticated actions and the owned pool. + +No candidate response locator, digest or claimed outcome chooses a read. Only +this source-owned reference holds the operator's read credential. Returned raw +bytes remain private; the independent witness publishes their digest only. +""" + +import urllib.parse + +import airtable_record +import stripe_platform +from common import closed, echo, identifier, require +from fresh_evidence import decode, witness +import resource_io + + +class ReadBack: + def __init__(self, family, resources, key, *, exchange=None): + self.family, self.resources, self.key = family, resources, key + self.exchange = exchange or resource_io.exchange + reference = {stripe_platform.FAMILY: stripe_platform, airtable_record.FAMILY: airtable_record}.get(family) + require(reference is not None, 'qualification.readback.family') + reference.resources(resources, resources['protected_run']) + self.reference = reference + + def get(self, path): + stripe = self.reference is stripe_platform + status, raw = self.exchange('https://api.stripe.com' if stripe else 'https://api.airtable.com', + 'GET', path, self.key, headers={'Stripe-Version': '2025-03-31.basil'} if stripe else {}) + require(status == 200 and type(raw) is bytes and 0 < len(raw) <= 65536, + 'qualification.readback.provider-unavailable') + return raw + + def fresh(self, reviewed, recipe_digest): + closed(reviewed, ['schema', 'actors', 'action_commitment', 'arguments', 'request']) + arguments = reviewed['arguments'] + require(reviewed['schema'] == 'auths.gateway-submission-review/1' + and reviewed['request'] == self.reference.request(arguments, self.resources, + reviewed['action_commitment'], recipe_digest), 'qualification.readback.review-binding') + if self.reference is airtable_record: + path = '/v0/' + airtable_record.BASE + '/' + airtable_record.TABLE + '/' + arguments['record_id'] + else: + # Discover the refund by its action-derived echo in the exact + # reviewed payment, rather than borrowing the candidate's locator. + path = '/v1/refunds?' + urllib.parse.urlencode({'payment_intent': arguments['payment_intent'], 'limit': 100}) + value = decode(self.get(path)) + require(type(value.get('data')) is list and len(value['data']) <= 100 + and value.get('has_more') is False, 'qualification.readback.refund-bound') + token = echo(arguments['operator_namespace'], arguments['operation_id'], reviewed['action_commitment']) + matches = [item for item in value['data'] if type(item) is dict + and type(item.get('metadata')) is dict and item['metadata'].get('auths_echo') == token] + require(len(matches) == 1, 'qualification.readback.ambiguous-refund') + refund = identifier(matches[0].get('id'), r're_[A-Za-z0-9]{1,128}') + require(matches[0].get('payment_intent') == arguments['payment_intent'], + 'qualification.readback.payment-binding') + path = '/v1/refunds/' + refund + raw = self.get(path) + witness(self.family, arguments, self.resources, reviewed['action_commitment'], recipe_digest, raw) + return raw diff --git a/qualification/reference/resource_io.py b/qualification/reference/resource_io.py new file mode 100644 index 000000000..6a850d97a --- /dev/null +++ b/qualification/reference/resource_io.py @@ -0,0 +1,139 @@ +"""Private local I/O for disposable resource preparation; never shipping code.""" + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import urllib.error +import urllib.request + +from common import Refusal, canonical, closed, require +from fresh_evidence import decode + + +def run_id(value): + require(type(value) is str and re.fullmatch(r'[a-z][a-z0-9-]{0,63}/[0-9]{1,20}/[0-9]{1,20}', value), + 'qualification.resources.run-binding') + return value + + +def credentials(fields): + raw = sys.stdin.buffer.read(8193) + require(0 < len(raw) <= 8192, 'qualification.resources.credential-bound') + try: + value = decode(raw) + closed(value, fields) + for name, prefix in fields.items(): + key = value[name] + require(type(key) is str and key.startswith(prefix) and 20 <= len(key) <= 2048 + and all(33 <= ord(character) <= 126 for character in key), + 'qualification.resources.credential-kind') + return value + finally: + raw = b'' + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise Refusal('qualification.resources.redirect-refused') + + +def exchange(origin, method, path, key, body=None, headers=None): + require(path.startswith('/') and not path.startswith('//') and '\r' not in path and '\n' not in path, + 'qualification.resources.path-bound') + values = {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'} + values.update(headers or {}) + outbound = urllib.request.Request(origin + path, method=method, data=body, headers=values) + try: + with urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect).open(outbound, timeout=25) as response: + raw = response.read(65537) + require(0 < len(raw) <= 65536, 'qualification.resources.response-bound') + return response.status, raw + except urllib.error.HTTPError as response: + # Error bytes are private inputs too. No exception/provider detail is + # printed; a reviewed denied-read probe may inspect only its status. + with response: + raw = response.read(65537) + require(len(raw) <= 65536, 'qualification.resources.response-bound') + return response.code, raw + except (urllib.error.URLError, TimeoutError, OSError): + # Provider errors can contain submitted credentials or resource data. + # They never become stdout, exception text or a saved report. + raise Refusal('qualification.resources.provider-unavailable') from None + + +def request(origin, method, path, key, body=None, headers=None): + status, raw = exchange(origin, method, path, key, body, headers) + require(200 <= status <= 299, 'qualification.resources.http-refused') + return decode(raw) + + +def read(path): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_size <= 65536, + 'qualification.resources.ledger-bound') + return decode(stream.read(65537)) + + +def write(path, value, *, new=False): + payload = canonical(value) + require(len(payload) <= 65536, 'qualification.resources.ledger-bound') + write_bytes(path, payload, new=new) + + +def write_bytes(path, payload, *, new=False): + """Durably write bounded public protocol bytes in an owner-private parent.""" + require(type(payload) is bytes and 0 < len(payload) <= 4 * 1024 * 1024, + 'qualification.resources.ledger-bound') + path = Path(path) + parent = path.parent + info = os.lstat(parent) + require(stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode) + and info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700, + 'qualification.resources.private-output') + if new: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + else: + # A ledger is replaced atomically, never followed through a symlink. + existing = os.lstat(path) + require(stat.S_ISREG(existing.st_mode) and existing.st_nlink == 1 + and existing.st_uid == os.getuid(), 'qualification.resources.ledger-bound') + temporary = path.with_name(path.name + '.next-' + os.urandom(8).hex()) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + fd = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def finish(command): + try: + command() + except Refusal as error: + code = str(error) + if not re.fullmatch(r'qualification\.[a-z0-9.-]{1,128}', code): + code = 'qualification.resources.refused' + print(code, file=sys.stderr) + raise SystemExit(1) from None + except (ValueError, OSError, KeyError, TypeError, ImportError, OverflowError, + RecursionError, subprocess.SubprocessError): + print('qualification.resources.refused', file=sys.stderr) + raise SystemExit(1) from None diff --git a/qualification/reference/resource_summary.py b/qualification/reference/resource_summary.py new file mode 100644 index 000000000..afeb389dd --- /dev/null +++ b/qualification/reference/resource_summary.py @@ -0,0 +1,45 @@ +"""Reconstruct public record resource names from a closed, run-bound ledger.""" + +import argparse +from pathlib import Path + +import airtable_record as airtable +import stripe_platform as stripe +from common import require, text +import resource_io as io + + +def summary(family, ledger): + require(type(ledger) is dict, 'qualification.reference.resource-binding') + run = io.run_id(ledger.get('protected_run')) + if family == stripe.FAMILY: + stripe.resources(ledger, run) + values = ['stripe:test-platform:' + ledger['platform']] + values += ['stripe:test-payment:' + payment['id'] for payment in ledger['payments']] + elif family == airtable.FAMILY: + airtable.resources(ledger, run) + table = ledger['base'] + '/' + ledger['table'] + values = ['airtable:base:' + ledger['base'], 'airtable:table:' + table] + values += ['airtable:record:' + table + '/' + record['id'] for record in ledger['records']] + else: + require(False, 'qualification.reference.family-unknown') + # Match the native record's BoundedText<96>. Refuse rather than truncate, + # hash away, or stringify an arbitrary object supplied by a harness. + for value in values: + text(value, maximum=96) + require(len(values) == len(set(values)) and 1 <= len(values) <= 32, + 'qualification.reference.resource-bound') + return sorted(values) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--family', required=True) + parser.add_argument('--resources', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + io.write(args.out, summary(args.family, io.read(args.resources)), new=True) + + +if __name__ == '__main__': + io.finish(main) diff --git a/qualification/reference/retained_author.py b/qualification/reference/retained_author.py new file mode 100644 index 000000000..51de534c3 --- /dev/null +++ b/qualification/reference/retained_author.py @@ -0,0 +1,87 @@ +"""Root controller adapter for one isolated installed-SDK author session. + +The dedicated UID retains its key in memory. The controller copies public +inputs and outputs only; refresh accepts one original source packet label. +""" + +import os +from pathlib import Path +import subprocess +import time + +from author_socket import exchange, refresh +from common import require +from expand import decode, read +from packet_plan import public_pool +from resource_io import write_bytes + +AUTHOR_UID = 62002 + + +class RetainedAuthor: + def __init__(self, python, kit, work, private): + require(os.getuid() == 0, 'qualification.packets.controller-identity') + self.work, self.private = Path(work).absolute(), Path(private).absolute() + self.python, self.kit = Path(python).absolute(), Path(kit).absolute() + require(not self.private.exists() and not self.private.is_relative_to(self.work) + and self.private.resolve() == self.private, + 'qualification.packets.private-session') + self.private.mkdir(mode=0o711) + os.chmod(self.private, 0o711) + self.author = self.private / 'author' + self.author.mkdir(mode=0o700) + self.outputs = self.private / 'handoffs' + self.outputs.mkdir(mode=0o700) + for name in ['packet-plan.json', 'resources.json', 'recipe.json', 'profile.lock.json']: + path = self.author / name + write_bytes(path, read(self.work / name, 65536), new=True) + os.chown(path, AUTHOR_UID, AUTHOR_UID) + os.chown(self.author, AUTHOR_UID, AUTHOR_UID) + plan = decode(read(self.work / 'packet-plan.json', 65536)) + resources = decode(read(self.work / 'resources.json', 65536)) + self.generation = 0 + self.process = subprocess.Popen([str(self.python), '-B', str(self.kit / 'author_socket.py'), + 'serve', '--plan', str(self.author / 'packet-plan.json'), '--work', str(self.author)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd='/', user=AUTHOR_UID, group=AUTHOR_UID, extra_groups=[], + env={'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'}) + try: + deadline = time.monotonic() + 30 + while not (self.author / 'author.sock').exists() and time.monotonic() < deadline: + require(self.process.poll() is None, 'qualification.packets.author-refused') + time.sleep(0.05) + require((self.author / 'author.sock').exists(), 'qualification.packets.author-timeout') + require(exchange(self.author, 'inspect')['generation'] == 0, + 'qualification.packets.generation') + carrier = decode(read(self.author / 'public-packets.json', 65536)) + packets = public_pool(plan['family'], resources, plan['packets'][0]['arguments']['recipe_digest'], carrier) + self.labels = {packet['label'] for packet in packets} + names = ['public-packets.json', 'author-report.json', *carrier['trusted_contexts']] + names += [packet[field] for packet in packets for field in ['proof', 'action']] + for name in names: + write_bytes(self.work / name, read(self.author / name, 4 * 1024 * 1024), new=True) + except BaseException: + self.abort() + raise + + def refresh(self, label): + require(label in self.labels and self.process.poll() is None, + 'qualification.packets.unknown-label') + self.generation += 1 + destination = self.outputs / ('refresh-' + str(self.generation).zfill(4)) + refresh(self.author, label, destination) + require(exchange(self.author, 'inspect')['generation'] == self.generation, + 'qualification.packets.generation') + return destination + + def close(self): + require(self.process.poll() is None, 'qualification.packets.author-refused') + exchange(self.author, 'close') + self.process.wait(timeout=10) + require(self.process.returncode == 0 and not (self.author / 'author.sock').exists(), + 'qualification.packets.author-refused') + + def abort(self): + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=10) diff --git a/qualification/reference/stripe_platform.py b/qualification/reference/stripe_platform.py new file mode 100644 index 000000000..727a342da --- /dev/null +++ b/qualification/reference/stripe_platform.py @@ -0,0 +1,83 @@ +"""Independent platform-refund reference. Never imported by shipping code.""" + +import urllib.parse + +from common import closed, digest, echo, idempotency, identifier, integer, require, text + +FAMILY = 'stripe-platform-refund-v1' +SERVICE = 'stripe-platform-refunds' +TOOL = 'create_refund_v1' +ENVIRONMENT = 'provider-test-mode' + + +def resources(value, protected_run): + closed(value, ['schema', 'protected_run', 'platform', 'payments']) + require(value['schema'] == 'auths.stripe-platform-qualification-resources/1' + and value['protected_run'] == protected_run, + 'qualification.reference.resource-binding') + identifier(value['platform'], r'acct_[A-Za-z0-9]{1,64}') + payments = value['payments'] + require(type(payments) is list and 1 <= len(payments) <= 32, + 'qualification.reference.resource-bound') + seen = set() + for payment in payments: + closed(payment, ['id', 'amount_received', 'currency', 'livemode', 'run_metadata']) + payment_id = identifier(payment['id'], r'pi_[A-Za-z0-9]{1,128}') + require(payment_id not in seen and payment['livemode'] is False + and payment['currency'] == 'usd' and payment['run_metadata'] == protected_run, + 'qualification.reference.resource-binding') + integer(payment['amount_received'], 1, 99999999) + seen.add(payment_id) + return value + + +def request(arguments, bound_resources, action_commitment, recipe_digest): + closed(arguments, ['operator_namespace', 'operation_id', 'recipe_digest', + 'payment_intent', 'amount', 'currency']) + require(arguments['operator_namespace'] == SERVICE + and digest(arguments['recipe_digest']) == digest(recipe_digest), + 'qualification.reference.recipe-binding') + operation = text(arguments['operation_id'], maximum=128) + amount = integer(arguments['amount'], 1, 99999999) + require(arguments['currency'] in ['usd', 'eur'], 'qualification.reference.currency-binding') + payment = next((item for item in bound_resources['payments'] + if item['id'] == arguments['payment_intent']), None) + require(payment is not None, 'qualification.reference.resource-binding') + # Request mapping also covers the two source-owned provider-read refusal + # probes. The native guard, not this encoder or the permit, must refuse + # them after custody and before write entry. No caller chooses their values. + require(amount <= 10000, + 'qualification.reference.ceiling') + token = echo(SERVICE, operation, action_commitment) + key = idempotency(SERVICE, operation) + body = urllib.parse.urlencode([ + ('amount', str(amount)), ('metadata[auths_echo]', token), + ('payment_intent', payment['id']), + ]) + return { + 'method': 'POST', 'url': 'https://api.stripe.com/v1/refunds', + 'content_type': 'application/x-www-form-urlencoded', 'body': body, + 'headers': [['Stripe-Version', '2025-03-31.basil'], ['Idempotency-Key', key]], + 'idempotency_key': key, + } + + +def entry_policy(arguments, bound_resources): + """Independent expected decision over the reviewed fresh payment facts.""" + request(arguments, bound_resources, '0' * 64, arguments['recipe_digest']) + payment = next(item for item in bound_resources['payments'] + if item['id'] == arguments['payment_intent']) + if arguments['currency'] != payment['currency']: + return 'gateway.relative-ceiling.binding-mismatch' + if arguments['amount'] * 10000 > payment['amount_received'] * 5000: + return 'gateway.relative-ceiling.above' + return None + + +def recipe(template, bound_resources): + # No resource or platform header is substituted into this family. + require(template['service'] == SERVICE and template['tool'] == TOOL + and template['origin'] == 'https://api.stripe.com' + and 'account_scope' not in template, + 'qualification.reference.recipe-binding') + return template diff --git a/qualification/reference/stripe_resources.py b/qualification/reference/stripe_resources.py new file mode 100644 index 000000000..ff54079aa --- /dev/null +++ b/qualification/reference/stripe_resources.py @@ -0,0 +1,173 @@ +"""Create and retire only this run's platform-account Stripe test payments.""" + +import argparse +import hashlib +import time +import urllib.parse +from pathlib import Path + +from common import closed, identifier, integer, require +import resource_io as io +import stripe_platform as reference + +SCHEMA = 'auths.stripe-platform-resource-preparation/1' + + +def key(run, index, operation): + return 'auths-resource-' + hashlib.sha256( + (run + '\0' + str(index) + '\0' + operation).encode()).hexdigest() + + +class Resources: + def __init__(self, keys, api=None): + self.keys = keys + self.api = api or self.http + + def http(self, method, path, fields=None, idempotency=None, runtime=False): + headers = {'Stripe-Version': '2025-03-31.basil'} + if idempotency: + headers['Idempotency-Key'] = idempotency + body = None + if fields is not None: + body = urllib.parse.urlencode(fields).encode() + headers['Content-Type'] = 'application/x-www-form-urlencoded' + return io.request('https://api.stripe.com', method, path, + self.keys['runtime' if runtime else 'setup'], body, headers) + + def account(self): + platform = self.api('GET', '/v1/account', runtime=True).get('id') + identifier(platform, r'acct_[A-Za-z0-9]{1,64}') + require(self.api('GET', '/v1/account').get('id') == platform + and self.api('GET', '/v1/balance', runtime=True).get('livemode') is False, + 'qualification.resources.platform-binding') + return platform + + def create(self, run, index): + return self.api('POST', '/v1/payment_intents', { + 'amount': 2000, 'currency': 'usd', 'payment_method': 'pm_card_visa', + 'confirm': 'true', 'automatic_payment_methods[enabled]': 'true', + 'automatic_payment_methods[allow_redirects]': 'never', + 'metadata[auths_qualification]': run, + }, idempotency=key(run, index, 'prepare')) + + @staticmethod + def payment(value, run, payment_id): + require(value.get('id') == payment_id and value.get('livemode') is False + and value.get('status') == 'succeeded' and value.get('amount') == 2000 + and value.get('amount_received') == 2000 and value.get('currency') == 'usd' + and type(value.get('metadata')) is dict + and value['metadata'].get('auths_qualification') == run, + 'qualification.resources.payment-binding') + + def journal(self, path): + value = io.read(path) + closed(value, ['schema', 'protected_run', 'platform', 'created_at', 'entries']) + require(value['schema'] == SCHEMA and self.account() == value['platform'], + 'qualification.resources.ledger-binding') + io.run_id(value['protected_run']) + integer(value['created_at'], 1, int(time.time())) + require(type(value['entries']) is list and 1 <= len(value['entries']) <= 32, + 'qualification.resources.resource-bound') + seen = set() + for entry in value['entries']: + closed(entry, ['id', 'retired']) + require(type(entry['retired']) is bool, 'qualification.resources.ledger-binding') + if entry['id'] is not None: + identifier(entry['id'], r'pi_[A-Za-z0-9]{1,128}') + require(entry['id'] not in seen, 'qualification.resources.duplicate-payment') + seen.add(entry['id']) + return value + + def prepare(self, run, count, journal, output): + io.run_id(run) + integer(count, 1, 32) + journal, output = Path(journal), Path(output) + require(not output.exists(), 'qualification.resources.output-exists') + if journal.exists(): + value = self.journal(journal) + require(value['protected_run'] == run and len(value['entries']) == count + and not any(entry['retired'] for entry in value['entries']), + 'qualification.resources.ledger-binding') + else: + value = {'schema': SCHEMA, 'protected_run': run, 'platform': self.account(), + 'created_at': int(time.time()), + 'entries': [{'id': None, 'retired': False} for _ in range(count)]} + io.write(journal, value, new=True) + # A pending provider response may be recovered only within its finite + # idempotency window; an old journal must never create a duplicate. + require(int(time.time()) - value['created_at'] <= 3600, + 'qualification.resources.preparation-expired') + payments = [] + for index, entry in enumerate(value['entries']): + payment = self.create(run, index) if entry['id'] is None else self.api( + 'GET', '/v1/payment_intents/' + entry['id']) + payment_id = identifier(payment.get('id'), r'pi_[A-Za-z0-9]{1,128}') + entry['id'] = payment_id + io.write(journal, value) + self.payment(payment, run, payment_id) + payments.append({'id': payment_id, 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run}) + ledger = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': run, 'platform': value['platform'], 'payments': payments} + reference.resources(ledger, run) + io.write(output, ledger, new=True) + + def cleanup(self, journal): + value = self.journal(journal) + run = value['protected_run'] + for index, entry in enumerate(value['entries']): + if entry['retired']: + continue + if entry['id'] is None: + # Replay only this run's pre-journaled test intent after an + # ambiguous preparation response. It may create then retire + # that one test fixture; it never searches unrelated payments. + require(int(time.time()) - value['created_at'] <= 3600, + 'qualification.resources.preparation-expired') + created = self.create(run, index) + entry['id'] = identifier(created.get('id'), r'pi_[A-Za-z0-9]{1,128}') + io.write(journal, value) + payment = self.api('GET', '/v1/payment_intents/' + entry['id']) + self.payment(payment, run, entry['id']) + charge_id = identifier(payment.get('latest_charge'), r'ch_[A-Za-z0-9]{1,128}') + charge = self.api('GET', '/v1/charges/' + charge_id) + require(charge.get('id') == charge_id and charge.get('payment_intent') == entry['id'] + and charge.get('livemode') is False and charge.get('amount') == 2000, + 'qualification.resources.charge-binding') + remaining = 2000 - integer(charge.get('amount_refunded'), 0, 2000) + if remaining: + refund = self.api('POST', '/v1/refunds', { + 'payment_intent': entry['id'], 'amount': remaining, + 'metadata[auths_qualification_cleanup]': run, + }, idempotency=key(run, index, 'cleanup-' + str(remaining))) + require(refund.get('status') == 'succeeded' and refund.get('livemode', False) is False + and refund.get('payment_intent') == entry['id'], + 'qualification.resources.cleanup-refused') + fresh = self.api('GET', '/v1/charges/' + charge_id) + require(fresh.get('livemode') is False and fresh.get('payment_intent') == entry['id'] + and fresh.get('amount_refunded') == 2000 and fresh.get('refunded') is True, + 'qualification.resources.cleanup-unconfirmed') + entry['retired'] = True + io.write(journal, value) + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest='command', required=True) + prepare = sub.add_parser('prepare') + prepare.add_argument('--protected-run', required=True) + prepare.add_argument('--count', type=int, default=1) + prepare.add_argument('--out', type=Path, required=True) + for command in [prepare, sub.add_parser('cleanup')]: + command.add_argument('--journal', type=Path, required=True) + args = parser.parse_args() + resources = Resources(io.credentials({'setup': 'sk_test_', 'runtime': 'rk_test_'})) + if args.command == 'prepare': + resources.prepare(args.protected_run, args.count, args.journal, args.out) + else: + resources.cleanup(args.journal) + print('qualification.resources.' + args.command + '-complete') + + +if __name__ == '__main__': + io.finish(main) diff --git a/qualification/reference/tests/test_artifact_wait.py b/qualification/reference/tests/test_artifact_wait.py new file mode 100644 index 000000000..cd41e60de --- /dev/null +++ b/qualification/reference/tests/test_artifact_wait.py @@ -0,0 +1,104 @@ +"""Artifact transport cannot choose code, another run or unbounded input.""" + +import copy +import hashlib +import io +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'run')) +import artifact_wait +from common import Refusal + + +class ArtifactWait(unittest.TestCase): + def artifact(self): + return {'id': 12, 'name': 'qualification-first-release-airtable-record-update-v1-123-1', + 'expired': False, 'size_in_bytes': 1024, 'digest': 'sha256:' + '1' * 64, + 'workflow_run': {'id': 123, 'head_sha': '2' * 40, 'head_branch': 'main', + 'repository_id': artifact_wait.REPOSITORY_ID, + 'head_repository_id': artifact_wait.REPOSITORY_ID}} + + def test_an_artifact_binds_exact_name_run_source_and_repository(self): + artifact = self.artifact() + def select(value): + return artifact_wait.select_artifact({'total_count': 1, 'artifacts': [value]}, + artifact['name'], '123', '2' * 40) + self.assertEqual(select(artifact), artifact) + for change in [lambda a: a.update(expired=True), lambda a: a.update(id=True), + lambda a: a.update(digest='1' * 64), + lambda a: a.update(size_in_bytes=artifact_wait.MAX_ARCHIVE + 1), + lambda a: a['workflow_run'].update(id=124), + lambda a: a['workflow_run'].update(head_sha='3' * 40), + lambda a: a['workflow_run'].update(head_branch='codex/unreviewed'), + lambda a: a['workflow_run'].update(head_repository_id=42)]: + changed = copy.deepcopy(artifact) + change(changed) + with self.assertRaises(Refusal): select(changed) + self.assertIsNone(select(dict(artifact, name='another-attempt'))) + with self.assertRaises(Refusal): + artifact_wait.select_artifact({'total_count': 2, 'artifacts': [artifact, artifact]}, + artifact['name'], '123', '2' * 40) + + def test_only_main_dispatch_of_the_exact_attempt_and_workflow_is_allowed(self): + environment = {'GITHUB_REPOSITORY': artifact_wait.REPOSITORY, 'GITHUB_EVENT_NAME': 'workflow_dispatch', + 'GITHUB_REF': 'refs/heads/main', 'GITHUB_RUN_ID': '123', 'GITHUB_RUN_ATTEMPT': '1', + 'GITHUB_SHA': '2' * 40} + self.assertEqual(artifact_wait.identity(environment), ('123', '1', '2' * 40)) + for name, value in [('GITHUB_REPOSITORY', 'fork/unreviewed'), ('GITHUB_EVENT_NAME', 'pull_request'), + ('GITHUB_REF', 'refs/pull/206/merge'), ('GITHUB_RUN_ATTEMPT', '01')]: + with self.assertRaises(Refusal): artifact_wait.identity(dict(environment, **{name: value})) + run = {'id': 123, 'run_attempt': 1, 'head_sha': '2' * 40, 'head_branch': 'main', + 'event': 'workflow_dispatch', 'path': artifact_wait.WORKFLOW, + 'repository': {'id': artifact_wait.REPOSITORY_ID}, + 'head_repository': {'id': artifact_wait.REPOSITORY_ID}} + artifact_wait.check_run(run, '123', '1', '2' * 40) + for name, value in [('run_attempt', 2), ('path', '.github/workflows/unreviewed.yml'), + ('event', 'pull_request')]: + with self.assertRaises(Refusal): artifact_wait.check_run(dict(run, **{name: value}), '123', '1', '2' * 40) + + def archive(self, entries): + output = io.BytesIO() + with zipfile.ZipFile(output, 'w') as archive: + for name, value in entries: + archive.writestr(name, value) + return output.getvalue() + + def test_archive_paths_links_executables_duplicates_and_decompression_are_refused(self): + link = zipfile.ZipInfo('linked.json') + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + invalid = [[('../record.json', b'{}')], [('/record.json', b'{}')], + [('a//record.json', b'{}')], [('record.json', b'{}'), ('record.json', b'{}')], + [('a.json', b'{}'), ('a.json/record.json', b'{}')], [(link, b'root.key')], + [('harness.py', b'pass')], [('hidden/.key.json', b'{}')], [('empty.json', b'')], + [('huge.json', b'x' * (artifact_wait.MAX_FILE + 1))], [('dir///', b'')]] + for entries in invalid: + with zipfile.ZipFile(io.BytesIO(self.archive(entries))) as archive: + with self.assertRaises(Refusal): artifact_wait.members(archive) + + def test_digest_checked_extraction_publishes_only_private_public_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) + work.chmod(0o700) + path = work / 'public.zip' + payload = self.archive([('record.json', b'{"source":"reviewed"}'), + ('evidence/conformance.json', b'{}')]) + path.write_bytes(payload) + artifact = {'digest': 'sha256:' + hashlib.sha256(payload).hexdigest()} + with self.assertRaises(Refusal): artifact_wait.unpack(path, {'digest': 'sha256:' + '0' * 64}, work / 'bad') + self.assertFalse((work / 'bad').exists()) + artifact_wait.unpack(path, artifact, work / 'accepted') + for file in ['record.json', 'evidence/conformance.json']: + self.assertEqual(stat.S_IMODE(os.lstat(work / 'accepted' / file).st_mode), 0o600) + self.assertEqual(stat.S_IMODE(os.lstat(work / 'accepted/evidence').st_mode), 0o700) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_author_socket.py b/qualification/reference/tests/test_author_socket.py new file mode 100644 index 000000000..7db799e9a --- /dev/null +++ b/qualification/reference/tests/test_author_socket.py @@ -0,0 +1,85 @@ +"""Local author channel refuses peers, framing and changed public handoffs.""" + +import os +from pathlib import Path +import socket +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import author_socket +from common import Refusal, canonical +from resource_io import write, write_bytes + + +class AuthorSocket(unittest.TestCase): + def test_frames_are_bounded_complete_and_serial(self): + for raw in [b'{"command":"inspect"}\n', b'x' * 512]: + sender, receiver = socket.socketpair() + with sender, receiver: + sender.sendall(raw) + sender.shutdown(socket.SHUT_WR) + if raw.endswith(b'\n'): + self.assertEqual(author_socket.receive(receiver, time.monotonic() + 2), raw) + else: + with self.assertRaises(Refusal): author_socket.receive(receiver, time.monotonic() + 2) + for raw in [b'x' * 513, b'one\ntwo\n', b'one\nunfinished']: + sender, receiver = socket.socketpair() + with sender, receiver: + sender.sendall(raw) + with self.assertRaises(Refusal): author_socket.receive(receiver, time.monotonic() + 2) + + @unittest.skipUnless(hasattr(socket, 'SO_PEERCRED'), 'Linux peer credentials required') + def test_peer_credentials_come_from_the_kernel(self): + first, second = socket.socketpair() + with first, second: + self.assertEqual(author_socket.peer_uid(first), os.getuid()) + self.assertEqual(author_socket.peer_uid(second), os.getuid()) + + def test_private_directory_checks_refuse_symlinks_shared_modes_and_root_authors(self): + with tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) + work.chmod(0o755) + with self.assertRaises(Refusal): author_socket.private_work(work, author=True) + work.chmod(0o700) + with patch('author_socket.os.getuid', return_value=0): + with self.assertRaises(Refusal): author_socket.private_work(work, author=True) + link = work / 'link' + link.symlink_to(work, target_is_directory=True) + with self.assertRaises(Refusal): author_socket.private_work(link, author=True) + + def test_refresh_never_exports_a_changed_action_context_or_aged_packet(self): + for change in ['action', 'context', 'arguments', 'age', 'extra-field']: + with tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) + work.chmod(0o700) + packet = {'label': 'live-00', 'proof': 'live-00.proof', + 'trusted_context': 'context-0.cbor', + 'action': 'live-00.action', 'arguments': {'closed': 'source'}} + now = int(time.time()) + original = {'schema': 'auths.qualification-public-packets/4', + 'protected_run': 'recipe-qualification/123/1', 'evaluated_at': now, + 'not_after': now + 300, 'trusted_contexts': ['context-0.cbor'], 'packets': [packet]} + write(work / 'public-packets.json', original, new=True) + write_bytes(work / 'live-00.action', b'source-action', new=True) + write_bytes(work / 'context-0.cbor', b'source-context', new=True) + fresh = work / 'refresh-0001' + fresh.mkdir(mode=0o700) + value = dict(original) + if change == 'arguments': value['packets'] = [dict(packet, arguments={'closed': 'changed'})] + if change == 'age': value.update(evaluated_at=now-120, not_after=now+30) + if change == 'extra-field': value['credential'] = 'synthetic-forbidden-input' + write(fresh / 'public-packets.json', value, new=True) + write_bytes(fresh / 'live-00.action', b'changed' if change == 'action' else b'source-action', new=True) + write_bytes(fresh / 'context-0.cbor', b'changed' if change == 'context' else b'source-context', new=True) + write_bytes(fresh / 'live-00.proof', b'public-proof', new=True) + with patch('author_socket.exchange', return_value={'generation': 1}): + with self.assertRaises(Refusal): author_socket.refresh(work, 'live-00', work / 'output') + self.assertFalse((work / 'output').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_commission_entry.py b/qualification/reference/tests/test_commission_entry.py new file mode 100644 index 000000000..1def91e5f --- /dev/null +++ b/qualification/reference/tests/test_commission_entry.py @@ -0,0 +1,43 @@ +"""Signer-source pinning only; fixtures issue no commissioning authority.""" + +import base64 +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'run')) +import commission +from common import Refusal, canonical + + +class Source(unittest.TestCase): + def test_contract_is_rederived_from_this_checkout_not_copied_from_input(self): + with tempfile.TemporaryDirectory() as temporary: + inputs = Path(temporary) + family = 'stripe-platform-refund-v1' + tuple_value = {'recipe_family': family, 'provider_contract_id': '1' * 64} + (inputs / 'tuple.json').write_bytes(canonical(tuple_value)) + issuer = commission.ROOT / 'target/release/auths-qualification' + with patch.object(commission, 'call', return_value=('1' * 64 + '\n').encode()) as called: + self.assertEqual(commission.source_contract(family, inputs, issuer), tuple_value) + self.assertEqual(called.call_args.args[0], [issuer, 'contract-id', '--contract', + commission.ROOT / 'qualification/families' / family / 'contract.json']) + for changed in [dict(tuple_value, provider_contract_id='2' * 64), + dict(tuple_value, recipe_family='airtable-record-update-v1')]: + (inputs / 'tuple.json').write_bytes(canonical(changed)) + with patch.object(commission, 'call', return_value=('1' * 64 + '\n').encode()): + with self.assertRaises(Refusal): commission.source_contract(family, inputs, issuer) + with self.assertRaises(Refusal): commission.source_contract('../../unreviewed', inputs, issuer) + + def test_seed_parser_has_no_filename_command_or_noncanonical_encoding_input(self): + synthetic = base64.urlsafe_b64encode(bytes(range(32))).rstrip(b'=').decode() + self.assertEqual(commission.signing_seed(synthetic), synthetic.encode()) + for value in [None, synthetic + '=', synthetic + '\n', '$(unreviewed)', + '/tmp/unreviewed', '!' * 43, synthetic[:-1] + 'v']: + with self.assertRaises(Refusal): commission.signing_seed(value) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_controller_socket.py b/qualification/reference/tests/test_controller_socket.py new file mode 100644 index 000000000..075cbe7cb --- /dev/null +++ b/qualification/reference/tests/test_controller_socket.py @@ -0,0 +1,77 @@ +"""Closed controller protocol and real root peer transport, no provider I/O.""" + +import os +from pathlib import Path +import socket +import sys +import tempfile +import threading +import time +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from common import canonical, Refusal +import controller_socket as controller + +FAMILY = 'stripe-platform-refund-v1' + + +class Stream: + def __init__(self, raw): self.raw = raw + def recv(self, maximum): + result, self.raw = self.raw[:maximum], self.raw[maximum:] + return result + + +class Controller(unittest.TestCase): + def request(self): + return {'schema': controller.REQUEST, 'family': FAMILY, + 'case': 'commissioning-proof-replay', 'index': 0, 'operation': 'submit'} + + def test_callers_supply_only_closed_source_case_coordinates(self): + request = self.request() + controller.checked_request(request, FAMILY) + for changed in [dict(request, command='sign'), dict(request, expected={'outcome': 'observed'}), + dict(request, provider_key='synthetic-only'), dict(request, family='other-family'), + dict(request, case='../escape'), dict(request, index=True), + dict(request, index=16), dict(request, operation='install')]: + with self.assertRaises(Refusal): controller.checked_request(changed, FAMILY) + + def test_messages_refuse_trailing_frames_duplicates_noncanonical_bytes_and_bounds(self): + raw = canonical(self.request()) + b'\n' + self.assertEqual(controller.receive(Stream(raw), 4096), self.request()) + for bad in [raw + raw, b'{"index":0,"index":1}\n', b'{} \n', b'{}', b'x' * 4097 + b'\n']: + with self.assertRaises((Refusal, ValueError)): controller.receive(Stream(bad), 4096) + + @unittest.skipUnless(os.name == 'posix' and os.getuid() == 0 and hasattr(socket, 'SO_PEERCRED'), + 'the credential-free packet-author job runs this test under root on Linux') + def test_actual_private_socket_returns_refusals_without_inventing_observations(self): + class Operations: + family = FAMILY + def step(self, case, index, operation): + raise Refusal('qualification.operations.not-implemented') + with tempfile.TemporaryDirectory(prefix='auths-controller-') as temporary: + directory = Path(temporary).resolve() + directory.chmod(0o700) + endpoint = directory / 'controller.sock' + stopping, ready = threading.Event(), threading.Event() + failures = [] + def run(): + try: controller.serve(endpoint, Operations(), time.monotonic() + 30, stopping, ready) + except BaseException as error: failures.append(error) + thread = threading.Thread(target=run) + thread.start() + try: + self.assertTrue(ready.wait(5)) + with self.assertRaisesRegex(Refusal, 'operations.not-implemented'): + controller.call(endpoint, FAMILY, 'commissioning-proof-replay', 0, 'submit') + finally: + stopping.set() + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertFalse(endpoint.exists()) + self.assertEqual(failures, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_family_plans.py b/qualification/reference/tests/test_family_plans.py new file mode 100644 index 000000000..4c180591e --- /dev/null +++ b/qualification/reference/tests/test_family_plans.py @@ -0,0 +1,64 @@ +"""Source/artifact drift and refusal boundaries, without provider evidence.""" + +import copy +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import generate_families +import family_corpus +import family_harness +from common import Refusal, canonical + + +class Plans(unittest.TestCase): + def test_consumer_executes_the_venv_launcher_with_its_installed_prefix(self): + with tempfile.TemporaryDirectory() as directory: + environment = Path(directory) / 'consumer' + subprocess.run([sys.executable, '-m', 'venv', '--without-pip', environment], check=True) + launcher = environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python') + with patch.dict(os.environ, {'AUTHS_QUALIFICATION_CONSUMER_PYTHON': str(launcher)}): + result = family_harness.command([family_harness.consumer_python(), '-c', + 'import sys; print(sys.prefix)']) + self.assertEqual(result.returncode, 0) + self.assertEqual(Path(result.stdout.decode().strip()).resolve(), environment.resolve()) + + def test_generated_source_plans_are_current_and_any_changed_member_refuses(self): + generate_families.generate(check=True) + original = generate_families.read + for name in ['contract.json', 'record.json', 'corpus-manifest.json', 'harness', 'decision-record.md']: + def altered(path, maximum): + raw = original(path, maximum) + return raw + b' ' if path.name == name else raw + with patch.object(generate_families, 'read', side_effect=altered): + with self.assertRaisesRegex(Refusal, 'reviewed-plan-drift'): + generate_families.generate(check=True) + + def test_complete_plan_requires_the_entire_fixed_resource_pool(self): + for family in family_harness.REFERENCES: + resources = family_harness.synthetic_resources(family, 'recipe-qualification/123/1') + member = 'payments' if 'payments' in resources else 'records' + self.assertEqual(len(resources[member]), 16) + for count in [0, 1, 15, 17]: + changed = copy.deepcopy(resources) + changed[member] = [dict(resources[member][0], id=( + 'pi_SYNTHETIC' + str(index) if member == 'payments' else 'recTEST' + str(index).zfill(10))) + for index in range(count)] + with self.assertRaises(Refusal): + family_corpus.compile_plan(family, changed, {}, '1' * 64) + + def test_a_protected_step_cannot_emit_a_placeholder_observation(self): + with patch.object(family_harness, 'write') as written: + with self.assertRaises(SystemExit): + family_harness.main('stripe-platform-refund-v1', + ['step', 'live-proof-replay', '0', 'submit', '/unconfigured', '/unconfigured-output']) + written.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_native_observation.py b/qualification/reference/tests/test_native_observation.py new file mode 100644 index 000000000..eab6f31d2 --- /dev/null +++ b/qualification/reference/tests/test_native_observation.py @@ -0,0 +1,112 @@ +"""Closed native/result projection boundaries; synthetic, never qualification.""" + +import copy +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_record as airtable +from common import Refusal, canonical, echo, sha256 +from native_observation import Effects, result + + +class Observations(unittest.TestCase): + def setUp(self): + self.run = 'recipe-qualification/123/1' + self.resources = {'schema': 'auths.airtable-record-qualification-resources/1', + 'protected_run': self.run, 'base': airtable.BASE, 'table': airtable.TABLE, + 'records': [{'id': 'recTEST0000000001', 'run_metadata': self.run}]} + self.tuple = {'recipe_family': airtable.FAMILY, 'compiled_recipe_sha256': '1' * 64} + self.arguments = {'operator_namespace': 'airtable-demo', 'operation_id': 'qualified-1', + 'recipe_digest': '1' * 64, 'record_id': 'recTEST0000000001', 'replacement': 'Approved'} + self.review = {'schema': 'auths.gateway-submission-review/1', + 'actors': ['raw:synthetic-test-only-actor'], 'action_commitment': '2' * 64, + 'arguments': self.arguments, + 'request': airtable.request(self.arguments, self.resources, '2' * 64, '1' * 64)} + self.before = {'schema': 'auths.gateway-execution-witness/1', 'scope': '1' * 32, + 'credential_lease_calls': 0, 'write_transport_entries': 0, 'read_transport_entries': 0} + self.after = dict(self.before, credential_lease_calls=1, write_transport_entries=1, + read_transport_entries=1) + token = echo('airtable-demo', 'qualified-1', '2' * 64) + self.response = canonical({'id': 'recTEST0000000001', + 'fields': {'DemoStatus': 'Approved', 'auths_echo': token}}) + self.observed = {'outcome': 'observed-by-provider', 'status': 200, + 'evidence': {'channel': 'read-back', 'echo': token, + 'evidence_digest': sha256(self.response), 'observed_at': 1000}} + + def project(self, ledger, value, before=None, after=None, response=None, review=None): + return ledger.project(self.tuple, review or self.review, self.resources, value, + before or self.before, after or self.after, response) + + def test_http_success_never_becomes_a_confirmed_effect(self): + facts, fresh = self.project(Effects(), {'outcome': 'response-recorded', 'status': 200}) + self.assertEqual(facts['verdict']['outcome'], 'response-recorded') + self.assertEqual(facts['confirmed_by_read_back'], 0) + self.assertIsNone(fresh) + self.assertEqual(result({'outcome': 'observed', 'status': 200, 'matched': True})[0], 'response-recorded') + for value in [{'outcome': 'observed-by-provider', 'status': 200}, + {'outcome': 'unknown', 'passed': True}, + {'outcome': 'response-recorded', 'status': True}, + {'outcome': 'refused', 'code': 'pretend-success'}]: + with self.assertRaises(Refusal): result(value) + + def test_only_one_actual_entered_write_can_be_confirmed_once(self): + ledger = Effects() + facts, fresh = self.project(ledger, self.observed, response=self.response) + self.assertEqual((facts['provider_entries'], facts['confirmed_by_read_back']), (1, 1)) + self.assertEqual(fresh['response_sha256'], sha256(self.response)) + repeated, _ = self.project(ledger, self.observed, before=self.after, after=self.after, + response=self.response) + self.assertEqual((repeated['provider_entries'], repeated['confirmed_by_read_back']), (0, 0)) + with self.assertRaisesRegex(Refusal, 'duplicate-entry'): + self.project(ledger, self.observed, response=self.response) + with self.assertRaisesRegex(Refusal, 'unmeasured-effect'): + self.project(Effects(), self.observed, before=self.after, after=self.after, response=self.response) + + def test_a_read_only_recovery_confirms_only_its_measured_unknown_write(self): + ledger = Effects() + unknown, fresh = self.project(ledger, {'outcome': 'unknown'}) + self.assertEqual(unknown['confirmed_by_read_back'], 0) + self.assertIsNone(fresh) + resumed = dict(self.after, credential_lease_calls=2, read_transport_entries=2) + recovered, _ = self.project(ledger, dict(self.observed, status=None), + before=self.after, after=resumed, response=self.response) + self.assertEqual((recovered['credential_leases'], recovered['provider_entries'], + recovered['confirmed_by_read_back']), (1, 0, 1)) + again, _ = self.project(ledger, self.observed, before=resumed, after=resumed, + response=self.response) + self.assertEqual(again['confirmed_by_read_back'], 0) + + def test_wrong_response_echo_request_scope_and_ambiguous_claims_refuse(self): + for problem in ['raw-bytes', 'echo', 'request', 'scope', 'extra-field', 'refused-entry']: + ledger, value, review = Effects(), copy.deepcopy(self.observed), copy.deepcopy(self.review) + after, response = dict(self.after), self.response + if problem == 'raw-bytes': response += b' ' + if problem == 'echo': value['evidence']['echo'] = 'forged-echo' + if problem == 'request': review['request']['url'] = 'https://attacker.invalid/write' + if problem == 'scope': after['scope'] = '2' * 32 + if problem == 'extra-field': value['evidence']['provider_token'] = 'synthetic-forbidden-input' + if problem == 'refused-entry': value = {'outcome': 'not-entered', 'code': 'gateway.attempt.replay'}; response = None + with self.subTest(problem=problem), self.assertRaises(Refusal): + self.project(ledger, value, after=after, response=response, review=review) + self.assertEqual(ledger.entries, {}) + + def test_candidate_or_action_drift_cannot_confirm_an_earlier_measured_entry(self): + ledger = Effects() + self.project(ledger, {'outcome': 'unknown'}) + changed_tuple = dict(self.tuple, gateway_semantic_closure_sha256='3' * 64) + with self.assertRaisesRegex(Refusal, 'changed-tuple'): + ledger.project(changed_tuple, self.review, self.resources, self.observed, + self.after, self.after, self.response) + changed = copy.deepcopy(self.review) + changed['action_commitment'] = '3' * 64 + changed['request'] = airtable.request(self.arguments, self.resources, '3' * 64, '1' * 64) + with self.assertRaisesRegex(Refusal, 'changed-action'): + self.project(ledger, self.observed, before=self.after, after=self.after, + response=self.response, review=changed) + self.assertFalse(next(iter(ledger.entries.values()))['confirmed']) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_network_fault.py b/qualification/reference/tests/test_network_fault.py new file mode 100644 index 000000000..cade70a3c --- /dev/null +++ b/qualification/reference/tests/test_network_fault.py @@ -0,0 +1,59 @@ +"""Rule rollback and actual-witness boundaries, without network or authority.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from common import Refusal +import network_fault as network + + +class Network(unittest.TestCase): + def rules(self): + value = object.__new__(network.Rules) + value.removals = [] + return value + + def test_only_own_rules_are_removed_in_reverse_order_even_if_one_removal_fails(self): + rules, calls = self.rules(), [] + def command(executable, table, arguments): + calls.append((executable, table, arguments)) + with patch.object(rules, 'command', command): + rules.add('iptables', 'nat', ['-A', 'OUTPUT', '-d', '192.0.2.1', '-j', 'REDIRECT']) + rules.add('ip6tables', 'filter', ['-A', 'OUTPUT', '-d', '2001:db8::1', '-j', 'REJECT']) + rules.__exit__(None, None, None) + self.assertEqual([item[2][0] for item in calls], ['-A', '-A', '-D', '-D']) + self.assertEqual(calls[2][0], 'ip6tables') + self.assertEqual(calls[3][0], 'iptables') + self.assertFalse(rules.removals) + rules.removals = [('iptables', 'nat', ['-D', 'first']), ('ip6tables', 'filter', ['-D', 'second'])] + calls.clear() + def fail(executable, table, arguments): + command(executable, table, arguments) + raise Refusal('qualification.network.rule-refused') + with patch.object(rules, 'command', fail), self.assertRaisesRegex(Refusal, 'cleanup-refused'): + rules.__exit__(None, None, None) + self.assertEqual(len(calls), 2) + self.assertFalse(rules.removals) + + def test_pending_native_scope_and_monotonicity_are_required_to_arm_a_fault(self): + before = {'schema': 'auths.gateway-execution-witness/1', 'scope': '1' * 32, + 'credential_lease_calls': 0, 'write_transport_entries': 0, 'read_transport_entries': 0} + class Deployment: + current = before + def witness(self, host, context): return self.current + deployment = Deployment() + witness = network.NativeWitness(deployment, 0, 0) + self.assertFalse(witness.entered()) + deployment.current = dict(before, credential_lease_calls=1, write_transport_entries=1) + self.assertTrue(witness.entered()) + for changed in [dict(deployment.current, scope='2' * 32), + dict(deployment.current, write_transport_entries=2), before]: + deployment.current = changed + with self.assertRaises(Refusal): witness.entered() + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_operator_author.py b/qualification/reference/tests/test_operator_author.py new file mode 100644 index 000000000..e656ad853 --- /dev/null +++ b/qualification/reference/tests/test_operator_author.py @@ -0,0 +1,52 @@ +"""Installation binding checks; no provider access or qualification issued.""" + +import base64 +import copy +from pathlib import Path +import sys +import time +from types import SimpleNamespace +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from author_operator import checked_statement, SCHEMA +from common import canonical, Refusal + + +class Operator(unittest.TestCase): + def setUp(self): + self.key = SimpleNamespace(principal='raw:synthetic-only-operator', + principal_method='raw-key-v1', verification_method='synthetic-only-method', suite='ed25519-v1') + self.installation = {'recipe_digest': '1' * 64, 'profile_lock_sha256': '2' * 64, + 'trusted_context_sha256': '3' * 64, 'provider': 'stripe', + 'alias': 'recipe-qualification', 'deployment': 'production'} + self.statement = {'schema': SCHEMA, 'operator_principal': self.key.principal, + 'principal_method': self.key.principal_method, 'verification_method': self.key.verification_method, + 'signature_suite': self.key.suite, 'installation': self.installation, 'issued_at': int(time.time())} + + def request(self, statement): + return {'statement': statement, 'preimage_b64': base64.urlsafe_b64encode( + SCHEMA.encode() + b'\0' + canonical(statement)).rstrip(b'=').decode()} + + def test_every_installation_field_is_bound_even_when_preimage_is_consistent(self): + checked_statement(self.request(self.statement), self.key, self.installation) + for field in self.installation: + statement = copy.deepcopy(self.statement) + statement['installation'][field] = 'changed' + with self.subTest(field=field), self.assertRaisesRegex(Refusal, 'statement-binding'): + checked_statement(self.request(statement), self.key, self.installation) + + def test_unknown_fields_other_keys_stale_time_and_preimage_substitution_refuse(self): + for change in ['unknown', 'principal', 'future', 'stale', 'preimage']: + request = self.request(copy.deepcopy(self.statement)) + if change == 'unknown': request['statement']['authority'] = 'unreviewed' + if change == 'principal': request['statement']['operator_principal'] = 'raw:other' + if change == 'future': request['statement']['issued_at'] += 300 + if change == 'stale': request['statement']['issued_at'] -= 300 + if change == 'preimage': request['preimage_b64'] = 'c3Vic3RpdHV0ZWQ' + with self.subTest(change=change), self.assertRaises(Refusal): + checked_statement(request, self.key, self.installation) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_packets.py b/qualification/reference/tests/test_packets.py new file mode 100644 index 000000000..c9961db8c --- /dev/null +++ b/qualification/reference/tests/test_packets.py @@ -0,0 +1,146 @@ +"""Packet confinement and refresh protocol; no protected evidence is claimed.""" + +import copy +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_record +import author_packets +import packet_plan +import stripe_platform +from common import Refusal, canonical + + +class Packets(unittest.TestCase): + def plan(self): + resources = {'schema': 'auths.airtable-record-qualification-resources/1', + 'protected_run': 'recipe-qualification/123/1', 'base': airtable_record.BASE, + 'table': airtable_record.TABLE, 'records': [ + {'id': 'recTEST0000000001', 'run_metadata': 'recipe-qualification/123/1'}]} + return {'schema': 'auths.qualification-packet-plan/4', 'family': airtable_record.FAMILY, + 'protected_run': resources['protected_run'], 'evaluated_at': 1000, 'not_after': 8200, + 'configuration': '1' * 64, 'extension': None, 'resources': resources, + 'packets': packet_plan.arguments(airtable_record.FAMILY, resources, '2' * 64)} + + def test_resource_action_and_phase_changes_cannot_choose_refresh_authority(self): + plan = self.plan() + self.assertEqual(packet_plan.validate(plan), plan) + changes = [lambda p: p['packets'][0]['arguments'].update(record_id='recOTHER000000001'), + lambda p: p['packets'][0]['arguments'].update(replacement='Deleted'), + lambda p: p['packets'][0]['arguments'].update(operation_id='another-run'), + lambda p: p['packets'][0].update(label='../../provider-secret'), + lambda p: p['resources'].update(base='appOTHER'), + lambda p: p.update(not_after=9000), lambda p: p.update(evaluated_at=True), + lambda p: p.update(credential='synthetic-forbidden-input'), + lambda p: p.update(schema='auths.qualification-packet-plan/2'), + lambda p: p.update(schema='auths.qualification-packet-plan/3'), + lambda p: p['packets'][0].update(context='fresh'), + lambda p: p['packets'][1]['arguments'].update(operation_id='new-operation'), + lambda p: p['packets'].reverse(), lambda p: p['packets'].pop()] + for change in changes: + changed = copy.deepcopy(plan) + change(changed) + with self.assertRaises(Refusal): packet_plan.validate(changed) + + def test_refresh_input_is_closed_bounded_and_monotonic(self): + value = {'command': 'refresh', 'label': 'live-00', 'generation': 1} + self.assertEqual(author_packets.command(canonical(value) + b'\n', 0), value) + self.assertIsNone(author_packets.command(b'{"command":"close"}\n', 0)) + for changed in [dict(value, generation=2), dict(value, generation=True), + dict(value, arguments={}), dict(value, credential='synthetic-forbidden-input'), + dict(value, label=[]), dict(value, command='sign-arbitrary')]: + with self.assertRaises(Refusal): author_packets.command(canonical(changed) + b'\n', 0) + for raw in [canonical(value), b' ' * 513 + b'\n', b'{"command":"close","command":"refresh"}\n']: + with self.assertRaises(Refusal): author_packets.command(raw, 0) + + def test_signing_pool_refuses_omitted_added_rebound_and_reordered_actions(self): + plan = self.plan() + packets = [{'label': item['label'], 'proof': item['label'] + '.proof', + 'action': item['label'] + '.action', + 'trusted_context': 'context-' + str(['initial', 'fresh'].index(item['context'])) + '.cbor', + 'arguments': item['arguments']} for item in plan['packets']] + carrier = {'schema': 'auths.qualification-public-packets/4', + 'protected_run': plan['protected_run'], 'evaluated_at': 1000, 'not_after': 1300, + 'trusted_contexts': ['context-0.cbor', 'context-1.cbor'], 'packets': packets} + self.assertEqual(packet_plan.public_pool(plan['family'], plan['resources'], '2' * 64, carrier), packets) + changes = [lambda c: c['packets'].pop(), lambda c: c['packets'].reverse(), + lambda c: c['packets'].append(copy.deepcopy(c['packets'][0])), + lambda c: c['packets'][0].update(proof='another.proof'), + lambda c: c['packets'][1].update(trusted_context='context-0.cbor'), + lambda c: c['packets'][0]['arguments'].update(operation_id='different-operation'), + lambda c: c['trusted_contexts'].pop(), lambda c: c.update(protected_run='recipe-qualification/999/1')] + for change in changes: + changed = copy.deepcopy(carrier) + change(changed) + with self.assertRaises(Refusal): + packet_plan.public_pool(plan['family'], plan['resources'], '2' * 64, changed) + + def test_stripe_guard_probes_are_fixed_valid_requests_with_independent_refusals(self): + run = 'recipe-qualification/123/1' + resources = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': run, 'platform': 'acct_TEST123', 'payments': [ + {'id': 'pi_TEST123', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run}]} + packets = packet_plan.arguments(stripe_platform.FAMILY, resources, '2' * 64) + self.assertEqual(len(packets), 8) + for phase in ['commissioning', 'live']: + probes = {item['label']: item for item in packets if item['label'].startswith(phase + '-guard-')} + self.assertEqual(set(probes), {phase + '-guard-ceiling', phase + '-guard-currency'}) + for kind, code in [('ceiling', 'gateway.relative-ceiling.above'), + ('currency', 'gateway.relative-ceiling.binding-mismatch')]: + value = probes[phase + '-guard-' + kind]['arguments'] + self.assertEqual(stripe_platform.request(value, resources, '1' * 64, '2' * 64)['method'], 'POST') + self.assertEqual(stripe_platform.entry_policy(value, resources), code) + for count, accepted in [(25, True), (26, False)]: + many = dict(resources, payments=[dict(resources['payments'][0], id='pi_TEST' + str(i)) for i in range(count)]) + expanded = packet_plan.arguments(stripe_platform.FAMILY, many, '2' * 64) + carrier = {'schema': 'auths.qualification-public-packets/4', 'protected_run': run, + 'evaluated_at': 1000, 'not_after': 1300, + 'trusted_contexts': ['context-0.cbor', 'context-1.cbor'], 'packets': [ + {'label': p['label'], 'proof': p['label'] + '.proof', 'action': p['label'] + '.action', + 'trusted_context': 'context-' + str(['initial', 'fresh'].index(p['context'])) + '.cbor', + 'arguments': p['arguments']} for p in expanded]} + if accepted: + self.assertEqual(len(packet_plan.public_pool(stripe_platform.FAMILY, many, '2' * 64, carrier)), 64) + else: + with self.assertRaises(Refusal): packet_plan.public_pool(stripe_platform.FAMILY, many, '2' * 64, carrier) + + def test_count_and_sum_experiments_have_distinct_fixed_native_windows(self): + run = 'recipe-qualification/123/1' + resources = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': run, 'platform': 'acct_TEST123', 'payments': [ + {'id': 'pi_TEST' + str(index), 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': run} for index in range(16)]} + packets = packet_plan.arguments(stripe_platform.FAMILY, resources, '2' * 64) + self.assertEqual(len(packets), 54) + self.assertEqual(len(set(packet_plan.BUDGET_WINDOWS.values()) | {86400}), 5) + for phase in ['commissioning', 'live']: + by_label = {p['label']: p for p in packets} + for kind, resource in [('count', 'pi_TEST14'), ('sum', 'pi_TEST15')]: + label = phase + '-budget-' + kind + positive, overflow = [by_label[label + suffix]['arguments'] for suffix in ['', '-over']] + self.assertEqual(positive['payment_intent'], resource) + self.assertEqual(positive['amount'], 1000) + self.assertEqual(overflow['amount'], 500) + self.assertNotEqual(positive['operation_id'], overflow['operation_id']) + self.assertEqual(packet_plan.grant_for(label), phase + '-' + kind) + self.assertEqual(packet_plan.grant_for(label + '-over'), phase + '-' + kind) + self.assertEqual(packet_plan.grant_for(phase + '-00'), 'default') + self.assertIsNone(stripe_platform.entry_policy(positive, resources)) + for kind in ['kind', 'generation', 'commitment', 'version']: + probe = by_label[phase + '-custody-' + kind] + self.assertEqual(probe['arguments']['payment_intent'], 'pi_TEST13') + self.assertEqual(packet_plan.grant_for(probe['label']), 'default') + + def test_an_unanticipated_credential_name_refuses_before_importing_the_sdk(self): + with patch.dict(os.environ, {'UNANTICIPATED_PROVIDER_KEY': 'synthetic-forbidden-input'}, clear=True): + with self.assertRaisesRegex(Refusal, 'consumer-environment'): + author_packets.installed_native() + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_production_setup.py b/qualification/reference/tests/test_production_setup.py new file mode 100644 index 000000000..cc048e2c0 --- /dev/null +++ b/qualification/reference/tests/test_production_setup.py @@ -0,0 +1,66 @@ +"""Private process boundaries, without credentials or provider evidence.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from common import Refusal +import production_setup as setup + + +class Production(unittest.TestCase): + def deployment(self): + value = object.__new__(setup.Deployment) + value.database = {'AUTHS_POSTGRES_URL': 'synthetic-only sslmode=require', + 'AUTHS_POSTGRES_CA_PEM': '/synthetic-only-ca', 'AUTHS_POSTGRES_SERVER_NAME': 'localhost'} + value.operator_token = Path('/synthetic-only-operator-token') + value.runtime_token = Path('/synthetic-only-runtime-token') + return value + + def test_runtime_cannot_inherit_writer_identity_or_ambient_credentials(self): + value = self.deployment() + environment = value.environment() + self.assertEqual(environment['AWS_ROLE_ARN'], setup.RUNTIME_ROLE) + self.assertEqual(environment['AWS_WEB_IDENTITY_TOKEN_FILE'], str(value.runtime_token)) + self.assertEqual(set(environment), {'PATH', 'AWS_ROLE_ARN', 'AWS_WEB_IDENTITY_TOKEN_FILE', *setup.DATABASE_ENV}) + operator = value.environment(administrative=True) + self.assertEqual(operator['AWS_ROLE_ARN'], setup.OPERATOR_ROLE) + self.assertEqual(operator['AUTHS_GATEWAY_RUNTIME_ROLE_ARN'], setup.RUNTIME_ROLE) + self.assertEqual(operator['AUTHS_GATEWAY_RUNTIME_TOKEN_FILE'], str(value.runtime_token)) + self.assertNotEqual(operator['AWS_ROLE_ARN'], operator['AUTHS_GATEWAY_RUNTIME_ROLE_ARN']) + + def test_native_refusal_never_exports_an_external_detail_or_secret(self): + refused = subprocess.CompletedProcess([], 1, b'', + b'gateway.install.credential-store-unavailable private-external-detail') + with self.assertRaisesRegex(Refusal, '^qualification.production.gateway.install.credential-store-unavailable$'): + setup.native_output(refused, [b'synthetic-only-sensitive-value']) + leaked = subprocess.CompletedProcess([], 0, b'{"secret":"synthetic-only-sensitive-value"}', b'') + with self.assertRaisesRegex(Refusal, 'secret-exposed'): + setup.native_output(leaked, [b'synthetic-only-sensitive-value']) + with self.assertRaisesRegex(Refusal, 'output-bound'): + setup.native_output(subprocess.CompletedProcess([], 0, b'x' * 65537, b''), []) + + def test_private_identity_inputs_refuse_links_wrong_owner_and_readable_files(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory).resolve() / 'token' + path.write_bytes(b'synthetic-only-token') + path.chmod(0o600) + self.assertEqual(setup.private_file(path, os.getuid()), path) + linked = path.with_name('linked') + linked.symlink_to(path) + with self.assertRaises(Refusal): setup.private_file(linked, os.getuid()) + hardlinked = path.with_name('hardlinked') + os.link(path, hardlinked) + with self.assertRaises(Refusal): setup.private_file(path, os.getuid()) + hardlinked.unlink() + with self.assertRaises(Refusal): setup.private_file(path, os.getuid() + 1) + path.chmod(0o644) + with self.assertRaises(Refusal): setup.private_file(path, os.getuid()) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_proposal_publication.py b/qualification/reference/tests/test_proposal_publication.py new file mode 100644 index 000000000..b87762fd5 --- /dev/null +++ b/qualification/reference/tests/test_proposal_publication.py @@ -0,0 +1,100 @@ +"""Control-plane closure tests; synthetic assembly is not provider evidence.""" + +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'run')) +import close_proposal +import scan_publication as publication + + +class Closure(unittest.TestCase): + def work(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + work = Path(temporary.name) + os.chmod(work, 0o700) + (work / 'cases').mkdir() + (work / 'canaries').write_bytes(b'synthetic-private-scan-canary\n') + (work / 'canaries').chmod(0o600) + (work / 'facts.json').write_bytes(b'{}') + return work + + def synthetic_scan(self, work, _tool, keep_canaries): + self.assertTrue(keep_canaries) + report = work / 'cases/redaction.scan.json' + report.unlink(missing_ok=True) + entries = publication.sources(work) + # Mirrors the native scanner's source-kind/ordinal report identities. + report.write_bytes(json.dumps([(i, kind) for i, (_, kind, _) in enumerate(entries)]).encode()) + self.scanned.append({name: publication.contents(work / name) for name, _, _ in entries}) + + def synthetic_assemble(self, _family, work, _environment, _run, _stage, _tool): + self.builds += 1 + proposal = work / 'proposal' + proposal.mkdir(exist_ok=True) + # Each assembly changes the bytes, while the complete set of output + # categories stays fixed. The last scan must see the rebuilt bytes. + (proposal / 'record.json').write_bytes(json.dumps({'assembly': self.builds}).encode()) + (proposal / 'redaction.json').write_bytes(publication.contents(work / 'cases/redaction.scan.json')) + + def run_close(self, work, stage='live', assembly=None, scan=None): + self.builds, self.scanned = 0, [] + with patch.object(close_proposal, 'assemble', assembly or self.synthetic_assemble), \ + patch.object(publication, 'scan', scan or self.synthetic_scan): + close_proposal.close('synthetic-family', work, 'synthetic-environment', + 'synthetic-run', stage, Path('/synthetic-native-issuer')) + + def test_final_bytes_and_complete_report_are_scanned_before_canary_removal(self): + work = self.work() + self.run_close(work) + self.assertEqual(self.builds, 2) + self.assertEqual(len(self.scanned), 3) + self.assertNotIn('proposal/record.json', self.scanned[0]) + self.assertEqual(self.scanned[-1]['proposal/record.json'], b'{"assembly": 2}') + self.assertEqual((work / 'proposal/redaction.json').read_bytes(), + (work / 'cases/redaction.scan.json').read_bytes()) + self.assertFalse((work / 'canaries').exists()) + + def test_commissioning_keeps_canaries_for_the_subsequent_ordinary_live_phase(self): + work = self.work() + self.run_close(work, stage='commissioning') + self.assertTrue((work / 'canaries').exists()) + self.assertTrue((work / 'proposal/record.json').exists()) + + def test_second_assembly_cannot_grow_the_publication_tree_and_leave_a_proposal(self): + work = self.work() + def expand(*arguments): + self.synthetic_assemble(*arguments) + if self.builds == 2: + (work / 'unexpected.json').write_bytes(b'{}') + with self.assertRaises(publication.Refusal): self.run_close(work, assembly=expand) + self.assertFalse((work / 'proposal').exists()) + self.assertFalse((work / 'cases/redaction.scan.json').exists()) + self.assertTrue((work / 'canaries').exists()) + + def test_assembly_or_final_scan_failure_invalidates_all_proposal_outputs(self): + for failure in ['assembly', 'scan']: + work = self.work() + def assemble(*args): + self.synthetic_assemble(*args) + if failure == 'assembly': raise publication.Refusal('synthetic-refusal') + def scan(*args, **kwargs): + self.synthetic_scan(*args, **kwargs) + if failure == 'scan' and self.builds == 2: + raise publication.Refusal('synthetic-refusal') + with self.assertRaises(publication.Refusal): + self.run_close(work, assembly=assemble, scan=scan) + self.assertFalse((work / 'proposal').exists()) + self.assertFalse((work / 'evidence').exists()) + self.assertFalse((work / 'cases/redaction.scan.json').exists()) + self.assertTrue((work / 'canaries').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_provider_readback.py b/qualification/reference/tests/test_provider_readback.py new file mode 100644 index 000000000..5919331c5 --- /dev/null +++ b/qualification/reference/tests/test_provider_readback.py @@ -0,0 +1,82 @@ +"""Independent selection and raw-byte boundaries using synthetic providers.""" + +import copy +import json +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_record as airtable +import stripe_platform as stripe +from common import canonical, echo, Refusal +from provider_readback import ReadBack + + +class ProviderReads(unittest.TestCase): + def setUp(self): + self.digest, self.commitment = '1' * 64, '2' * 64 + self.run = 'recipe-qualification/123/1' + self.resources = {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': self.run, 'platform': 'acct_SYNTHETIC', 'payments': [ + {'id': 'pi_SYNTHETIC', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': self.run}]} + arguments = {'operator_namespace': stripe.SERVICE, 'operation_id': 'synthetic-only-operation', + 'recipe_digest': self.digest, 'payment_intent': 'pi_SYNTHETIC', 'amount': 500, 'currency': 'usd'} + self.review = {'schema': 'auths.gateway-submission-review/1', 'actors': ['raw:synthetic-only-actor'], + 'action_commitment': self.commitment, 'arguments': arguments, + 'request': stripe.request(arguments, self.resources, self.commitment, self.digest)} + self.refund = {'id': 're_SYNTHETIC', 'object': 'refund', 'livemode': False, + 'payment_intent': 'pi_SYNTHETIC', 'amount': 500, 'currency': 'usd', 'status': 'succeeded', + 'metadata': {'auths_echo': echo(stripe.SERVICE, arguments['operation_id'], self.commitment)}} + + def test_refund_locator_is_discovered_independently_and_raw_read_bytes_are_retained(self): + calls, raw = [], json.dumps(self.refund, indent=2).encode() + def exchange(origin, method, path, key, **kwargs): + calls.append((origin, method, path)) + if '?' in path: + return 200, canonical({'data': [dict(self.refund, id='re_OTHER', metadata={}), self.refund], 'has_more': False}) + return 200, raw + reader = ReadBack(stripe.FAMILY, self.resources, 'synthetic-only-key', exchange=exchange) + self.assertEqual(reader.fresh(self.review, self.digest), raw) + self.assertEqual(calls, [('https://api.stripe.com', 'GET', '/v1/refunds?payment_intent=pi_SYNTHETIC&limit=100'), + ('https://api.stripe.com', 'GET', '/v1/refunds/re_SYNTHETIC')]) + + def test_ambiguous_paginated_wrong_payment_and_changed_fresh_state_refuse(self): + for problem in ['duplicate', 'pagination', 'wrong-payment', 'changed-fresh', 'status']: + listing = {'data': [copy.deepcopy(self.refund)], 'has_more': False} + fresh = copy.deepcopy(self.refund) + if problem == 'duplicate': listing['data'].append(copy.deepcopy(self.refund)) + if problem == 'pagination': listing['has_more'] = True + if problem == 'wrong-payment': listing['data'][0]['payment_intent'] = 'pi_OTHER' + if problem == 'changed-fresh': fresh['metadata']['auths_echo'] = 'changed' + def exchange(origin, method, path, key, **kwargs): + return (403 if problem == 'status' else 200), canonical(listing if '?' in path else fresh) + with self.subTest(problem=problem), self.assertRaises(Refusal): + ReadBack(stripe.FAMILY, self.resources, 'synthetic-only-key', exchange=exchange).fresh(self.review, self.digest) + + def test_airtable_reads_only_the_exact_owned_record_and_never_an_off_pool_record(self): + resources = {'schema': 'auths.airtable-record-qualification-resources/1', 'protected_run': self.run, + 'base': airtable.BASE, 'table': airtable.TABLE, + 'records': [{'id': 'recTEST0000000001', 'run_metadata': self.run}]} + arguments = {'operator_namespace': 'airtable-demo', 'operation_id': 'synthetic-only-operation', + 'recipe_digest': self.digest, 'record_id': 'recTEST0000000001', 'replacement': 'Approved'} + reviewed = dict(self.review, arguments=arguments, + request=airtable.request(arguments, resources, self.commitment, self.digest)) + raw = canonical({'id': arguments['record_id'], 'fields': {'DemoStatus': 'Approved', + 'auths_echo': echo('airtable-demo', arguments['operation_id'], self.commitment)}}) + calls = [] + def exchange(origin, method, path, key, **kwargs): + calls.append(path) + return 200, raw + reader = ReadBack(airtable.FAMILY, resources, 'synthetic-only-key', exchange=exchange) + self.assertEqual(reader.fresh(reviewed, self.digest), raw) + self.assertEqual(calls, ['/v0/' + airtable.BASE + '/' + airtable.TABLE + '/' + arguments['record_id']]) + changed = copy.deepcopy(reviewed) + changed['arguments']['record_id'] = 'recOTHER000000001' + with self.assertRaises(Refusal): reader.fresh(changed, self.digest) + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_publication.py b/qualification/reference/tests/test_publication.py new file mode 100644 index 000000000..f59acb27a --- /dev/null +++ b/qualification/reference/tests/test_publication.py @@ -0,0 +1,66 @@ +"""Publication-tree confinement; native leak decisions run in the Rust pipeline.""" + +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest + +path = Path(__file__).resolve().parents[2] / 'run/scan_publication.py' +spec = importlib.util.spec_from_file_location('scan_publication', path) +publication = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publication) + + +class Publication(unittest.TestCase): + def workspace(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + work = Path(temporary.name).resolve() + (work / 'cases').mkdir() + (work / 'canaries').write_bytes(b'synthetic-private-scan-canary\n') + (work / 'canaries').chmod(0o600) + (work / 'commissioning-effects.json').write_bytes(b'closed synthetic facts') + return work + + def test_unlisted_phase_files_and_all_output_kinds_enter_the_scan(self): + work = self.workspace() + for kind in ['log', 'trace', 'metric', 'support-bundle']: + directory = work / 'scan' / kind + directory.mkdir(parents=True) + (directory / 'retained-output').write_bytes(b'closed synthetic state') + (work / 'unexpected-output').write_bytes(b'closed synthetic public data') + values = publication.sources(work) + self.assertEqual(len(values), 6) + self.assertEqual({kind for _, kind, _ in values}, + {'log', 'trace', 'metric', 'support-bundle', 'evidence'}) + self.assertIn('commissioning-effects.json', {name for name, _, _ in values}) + self.assertIn('unexpected-output', {name for name, _, _ in values}) + self.assertNotIn('canaries', {name for name, _, _ in values}) + + def test_links_special_files_and_public_canaries_refuse(self): + for mode in ['file-link', 'directory-link', 'hard-link', 'fifo', 'public-canary']: + work = self.workspace() + if mode == 'file-link': + (work / 'linked-output').symlink_to(work / 'canaries') + elif mode == 'directory-link': + (work / 'linked-directory').symlink_to(work / 'cases', target_is_directory=True) + elif mode == 'hard-link': + os.link(work / 'canaries', work / 'linked-output') + elif mode == 'fifo': + os.mkfifo(work / 'fifo') + else: + (work / 'canaries').chmod(0o644) + with self.assertRaises(publication.Refusal, msg=mode): publication.sources(work) + + def test_no_executable_or_oversized_source_is_silently_excluded(self): + work = self.workspace() + binary = work / 'bin/auths-gateway' + binary.parent.mkdir() + with binary.open('wb') as stream: + stream.truncate(publication.MAX_BYTES + 1) + with self.assertRaises(publication.Refusal): publication.sources(work) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_reference.py b/qualification/reference/tests/test_reference.py new file mode 100644 index 000000000..2808c47a6 --- /dev/null +++ b/qualification/reference/tests/test_reference.py @@ -0,0 +1,285 @@ +"""Independent request mappings and finite input boundaries; no live claim.""" + +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import urllib.parse + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_record as airtable +import stripe_platform as stripe +from common import Refusal, echo, idempotency +from common import canonical, sha256 +import measure +import fresh_evidence as fresh +import expand as expansion +import packet_plan +from expand import decode, unique_object + + +RUN = 'recipe-qualification/123/1' +DIGEST = '1' * 64 +COMMITMENT = '2' * 64 + + +class References(unittest.TestCase): + def stripe_resources(self): + return {'schema': 'auths.stripe-platform-qualification-resources/1', 'protected_run': RUN, + 'platform': 'acct_TEST123', 'payments': [ + {'id': 'pi_TEST123', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': RUN}]} + + def stripe_arguments(self): + return {'operator_namespace': stripe.SERVICE, 'operation_id': 'qualified-1', + 'recipe_digest': DIGEST, 'payment_intent': 'pi_TEST123', 'amount': 1000, + 'currency': 'usd'} + + def airtable_resources(self): + return {'schema': 'auths.airtable-record-qualification-resources/1', 'protected_run': RUN, + 'base': airtable.BASE, 'table': airtable.TABLE, + 'records': [{'id': 'recTEST0000000001', 'run_metadata': RUN}]} + + def airtable_arguments(self): + return {'operator_namespace': 'airtable-demo', 'operation_id': 'qualified-1', + 'recipe_digest': DIGEST, 'record_id': 'recTEST0000000001', + 'replacement': 'Approved'} + + def test_native_measurements_refuse_restart_wrap_and_duplicate_hosts(self): + before = {'schema': 'auths.gateway-execution-witness/1', 'scope': '1' * 32, + 'credential_lease_calls': 0, 'write_transport_entries': 0, + 'read_transport_entries': 0} + after = dict(before, credential_lease_calls=1, write_transport_entries=1, + read_transport_entries=2) + self.assertEqual(measure.delta(before, after)['write_transport_entries'], 1) + for bad in [dict(after, scope='2' * 32), + dict(after, credential_lease_calls=(1 << 64) - 1), + dict(after, credential_lease_calls=True), dict(after, reset=False)]: + with self.assertRaises(Refusal): measure.delta(before, bad) + with self.assertRaises(Refusal): measure.delta(after, before) + with self.assertRaises(Refusal): measure.aggregate([(before, after), (before, after)]) + other = dict(before, scope='2' * 32) + self.assertEqual(measure.aggregate([(before, after), (other, other)])['credential_lease_calls'], 1) + + def test_fresh_stripe_evidence_checks_state_echo_and_raw_bytes(self): + resources, arguments = self.stripe_resources(), self.stripe_arguments() + value = {'id': 're_TEST123', 'object': 'refund', + 'payment_intent': arguments['payment_intent'], 'amount': arguments['amount'], + 'currency': 'usd', 'status': 'succeeded', + 'metadata': {'auths_echo': echo(stripe.SERVICE, arguments['operation_id'], COMMITMENT)}} + response = canonical(value) + witness = fresh.witness(stripe.FAMILY, arguments, resources, COMMITMENT, DIGEST, response) + self.assertEqual(witness['response_sha256'], sha256(response)) + self.assertEqual(witness['subject_sha256'], fresh.subject(stripe.FAMILY, arguments, resources, COMMITMENT, DIGEST)) + for field, bad in [('amount', True), ('amount', 999), ('livemode', True), + ('payment_intent', 'pi_OTHER'), ('status', 'pending'), + ('metadata', {'auths_echo': 'forged'})]: + changed = dict(value, **{field: bad}) + with self.assertRaises(Refusal): + fresh.witness(stripe.FAMILY, arguments, resources, COMMITMENT, DIGEST, canonical(changed)) + # Equal semantic JSON with different bytes still has a different witness. + spaced = json.dumps(value).encode() + self.assertNotEqual(fresh.witness(stripe.FAMILY, arguments, resources, COMMITMENT, DIGEST, spaced)['response_sha256'], witness['response_sha256']) + + def test_fresh_airtable_evidence_requires_exact_known_record_and_value(self): + resources, arguments = self.airtable_resources(), self.airtable_arguments() + value = {'id': arguments['record_id'], 'fields': { + 'DemoStatus': arguments['replacement'], + 'auths_echo': echo('airtable-demo', arguments['operation_id'], COMMITMENT)}} + witness = fresh.witness(airtable.FAMILY, arguments, resources, COMMITMENT, DIGEST, canonical(value)) + self.assertEqual(witness['response_sha256'], sha256(canonical(value))) + for changed in [dict(value, id='recOTHER000000001'), dict(value, fields={}), + dict(value, fields=dict(value['fields'], DemoStatus='Pending'))]: + with self.assertRaises(Refusal): + fresh.witness(airtable.FAMILY, arguments, resources, COMMITMENT, DIGEST, canonical(changed)) + for raw in [b'{"id":"one","id":"two"}', b'{"id":NaN}', b' ' * 65537]: + with self.assertRaises(Refusal): fresh.decode(raw) + + def test_stripe_boundary_and_exact_form_have_no_connect_header(self): + resources = stripe.resources(self.stripe_resources(), RUN) + arguments = self.stripe_arguments() + request = stripe.request(arguments, resources, COMMITMENT, DIGEST) + self.assertEqual(request['method'], 'POST') + self.assertEqual(request['url'], 'https://api.stripe.com/v1/refunds') + fields = urllib.parse.parse_qsl(request['body']) + self.assertEqual(fields, [('amount', '1000'), + ('metadata[auths_echo]', echo(stripe.SERVICE, 'qualified-1', COMMITMENT)), + ('payment_intent', 'pi_TEST123')]) + self.assertEqual(request['headers'], [ + ['Stripe-Version', '2025-03-31.basil'], + ['Idempotency-Key', idempotency(stripe.SERVICE, 'qualified-1')]]) + for amount in [0, -1, True, 10001, 99999999]: + with self.subTest(amount=amount), self.assertRaises(Refusal): + stripe.request({**arguments, 'amount': amount}, resources, COMMITMENT, DIGEST) + self.assertIsNone(stripe.entry_policy(arguments, resources)) + self.assertEqual(stripe.entry_policy({**arguments, 'amount': 1001}, resources), + 'gateway.relative-ceiling.above') + self.assertEqual(stripe.entry_policy({**arguments, 'currency': 'eur'}, resources), + 'gateway.relative-ceiling.binding-mismatch') + + def test_stripe_resources_are_test_only_unique_and_bound_to_the_run(self): + mutations = [ + lambda value: value.update(protected_run='another-run'), + lambda value: value['payments'][0].update(livemode=True), + lambda value: value['payments'][0].update(currency='eur'), + lambda value: value['payments'][0].update(run_metadata='another-run'), + lambda value: value['payments'][0].update(id='pi_TEST123/../../other'), + lambda value: value['payments'].append(copy.deepcopy(value['payments'][0])), + lambda value: value.update(payments=[]), + lambda value: value.update(credential='synthetic-forbidden-input'), + ] + for mutate in mutations: + value = self.stripe_resources() + mutate(value) + with self.assertRaises(Refusal): + stripe.resources(value, RUN) + + def test_airtable_mapping_updates_only_the_reviewed_record_field_and_echo(self): + resources = airtable.resources(self.airtable_resources(), RUN) + arguments = self.airtable_arguments() + request = airtable.request(arguments, resources, COMMITMENT, DIGEST) + self.assertEqual(request['method'], 'PATCH') + self.assertEqual(request['url'], + f'https://api.airtable.com/v0/{airtable.BASE}/{airtable.TABLE}/recTEST0000000001') + self.assertEqual(json.loads(request['body']), {'fields': { + 'DemoStatus': 'Approved', 'auths_echo': echo('airtable-demo', 'qualified-1', COMMITMENT)}}) + self.assertEqual(request['headers'], []) + self.assertIsNone(request['idempotency_key']) + for mutation in [{'replacement': 'Deleted'}, {'record_id': '../other'}, + {'recipe_digest': '3' * 64}, {'operator_namespace': 'other'}, + {'authorization': 'synthetic-forbidden-input'}]: + with self.subTest(mutation=mutation), self.assertRaises(Refusal): + airtable.request({**arguments, **mutation}, resources, COMMITMENT, DIGEST) + + def test_airtable_resource_expansion_preserves_every_other_recipe_member(self): + resources = airtable.resources(self.airtable_resources(), RUN) + root = Path(__file__).resolve().parents[3] + template = json.loads((root / 'bindings/fixtures/gateway/airtable/recipe.json').read_bytes()) + original = copy.deepcopy(template) + expanded = airtable.recipe(template, resources) + self.assertEqual(template, original) + for kind in ['write', 'observation']: + self.assertEqual(expanded[kind]['path'][1]['value'], airtable.BASE) + self.assertEqual(expanded[kind]['path'][2]['value'], airtable.TABLE) + expanded[kind]['path'] = original[kind]['path'] + self.assertEqual(expanded, original) + for key in ['base', 'table', 'protected_run']: + with self.assertRaises(Refusal): + airtable.resources({**resources, key: 'another-resource'}, RUN) + + def test_duplicate_json_fields_and_nonfinite_numbers_never_choose_authority(self): + for source in [b'{"resource":1,"resource":2}', b'{"resource":NaN}', b'{"resource":Infinity}']: + with self.assertRaises(Refusal): + decode(source) + with self.assertRaises(Refusal): + unique_object([('member', 'conformance'), ('member', 'differential')]) + + def test_binding_is_rederived_and_altered_source_or_native_observations_refuse(self): + # This isolated unit stub checks expansion plumbing, not cryptographic + # proof validity or a production tuple. Real native proof review has a + # separate Rust test over the frozen quorum corpus. + root = Path(__file__).resolve().parents[3] + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + source = root / 'qualification/simulation/live/stripe-platform' + for name in ['recipe.json', 'profile.lock.json']: + (work / name).write_bytes((source / name).read_bytes()) + (work / 'candidate').write_bytes(b'synthetic test-only candidate, not an executable') + (work / 'context-0.cbor').write_bytes(b'synthetic test-only context') + (work / 'context-1.cbor').write_bytes(b'synthetic fresh test-only context') + planned = packet_plan.arguments(stripe.FAMILY, self.stripe_resources(), DIGEST) + packets = [] + for packet in planned: + label = packet['label'] + (work / (label + '.proof')).write_bytes(b'synthetic test-only proof') + (work / (label + '.action')).write_bytes(b'synthetic test-only action') + packets.append({'label': label, 'trusted_context': + 'context-' + str(['initial', 'fresh'].index(packet['context'])) + '.cbor', + 'proof': label + '.proof', 'action': label + '.action', 'arguments': packet['arguments']}) + (work / 'resources.json').write_bytes(canonical(self.stripe_resources())) + (work / 'packets.json').write_bytes(canonical({ + 'schema': 'auths.qualification-public-packets/4', 'protected_run': RUN, + 'evaluated_at': 1000, 'not_after': 1300, + 'trusted_contexts': ['context-0.cbor', 'context-1.cbor'], 'packets': packets})) + artifacts = json.loads((root / 'bindings/fixtures/qualification/commissioning-v2.json').read_bytes()) + tuple_value = json.loads(artifacts['permit'])['statement']['binding']['tuple'] + tuple_value['recipe_family'] = stripe.FAMILY + tuple_value['compiled_recipe_sha256'] = DIGEST + tuple_value['target']['gateway_build_sha256'] = sha256((work / 'candidate').read_bytes()) + tuple_value['target']['store_schema'] = 'auths.lifecycle.postgresql/6' + (work / 'tuple.json').write_bytes(canonical(tuple_value)) + tuple_digest = sha256(b'auths.qualification-tuple/1\0' + canonical(tuple_value)) + for member_name in ['conformance', 'differential']: + (work / (member_name + '.json')).write_bytes(canonical({ + 'schema': 'auths.qualification-evidence/1', 'member': member_name, + 'commit': '1' * 40, 'tuple_sha256': tuple_digest, 'cases': [], + })) + args = SimpleNamespace(source_commit='1' * 40, protected_run=RUN, + tuple=work / 'tuple.json', candidate=work / 'candidate', reviewer=work / 'not-an-executable', + recipe=work / 'recipe.json', profile_lock=work / 'profile.lock.json', + resources=work / 'resources.json', packets=work / 'packets.json', + conformance=work / 'conformance.json', differential=work / 'differential.json', + out_dir=work / 'expanded') + def review(_binary, arguments): + if arguments[0] == 'qualification-candidate': + return copy.deepcopy(tuple_value) + label = Path(arguments[arguments.index('--proof') + 1]).stem + value = next(packet['arguments'] for packet in packets if packet['label'] == label) + commitment = sha256(canonical(value)) + return {'schema': 'auths.gateway-submission-review/1', + 'actors': ['raw:synthetic-test-only-actor'], 'action_commitment': commitment, + 'arguments': value, + 'request': stripe.request(value, self.stripe_resources(), commitment, DIGEST)} + with patch.dict(os.environ, {'GITHUB_SHA': '1' * 40, 'GITHUB_RUN_ID': '123', + 'GITHUB_RUN_ATTEMPT': '1'}), patch('expand.child', side_effect=review), \ + patch('expand.time.time', return_value=1000): + expansion.expand(args) + binding = json.loads((args.out_dir / 'binding.json').read_bytes()) + self.assertEqual(binding['allowed_actions'], sorted({sha256(canonical(packet['arguments'])) for packet in packets})) + self.assertEqual(binding['maximum_credential_leases'], 64) + self.assertEqual(binding['principal_sha256'], sha256(b'raw:synthetic-test-only-actor')) + self.assertEqual(binding['resources_sha256'], sha256((work / 'resources.json').read_bytes())) + self.assertEqual(binding['trusted_contexts_sha256'], sorted([ + sha256(b'synthetic test-only context'), sha256(b'synthetic fresh test-only context')])) + self.assertFalse(json.loads((args.out_dir / 'oracle-commitments.json').read_bytes())['qualification_issued']) + original_packets = json.loads(args.packets.read_bytes()) + for problem in ['unused', 'duplicate', 'unknown', 'empty', 'obsolete']: + changed = copy.deepcopy(original_packets) + if problem == 'unused': changed['trusted_contexts'].append('context-2.cbor') + if problem == 'duplicate': changed['trusted_contexts'].append('context-0.cbor') + if problem == 'unknown': changed['packets'][0]['trusted_context'] = 'context-2.cbor' + if problem == 'empty': changed['trusted_contexts'] = [] + if problem == 'obsolete': changed['schema'] = 'auths.qualification-public-packets/2' + args.packets.write_bytes(canonical(changed)) + args.out_dir = work / ('refused-context-' + problem) + with self.assertRaises(Refusal): expansion.expand(args) + self.assertFalse(args.out_dir.exists()) + args.packets.write_bytes(canonical(original_packets)) + args.out_dir = work / 'changed' + recipe = json.loads((work / 'recipe.json').read_bytes()) + del recipe['credential']['guard'] + (work / 'recipe.json').write_bytes(canonical(recipe)) + with self.assertRaisesRegex(Refusal, 'reviewed-source'): + expansion.expand(args) + self.assertFalse(args.out_dir.exists()) + (work / 'recipe.json').write_bytes((source / 'recipe.json').read_bytes()) + def mismatched_review(binary, arguments): + value = review(binary, arguments) + if arguments[0] == 'review-submission': + value['request']['url'] = 'https://attacker.invalid/write' + return value + with patch('expand.child', side_effect=mismatched_review), self.assertRaisesRegex(Refusal, 'oracle-mismatch'): + expansion.expand(args) + self.assertFalse(args.out_dir.exists()) + (work / 'candidate').write_bytes(b'changed synthetic candidate') + with self.assertRaisesRegex(Refusal, 'candidate-bytes'): + expansion.expand(args) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_resource_summary.py b/qualification/reference/tests/test_resource_summary.py new file mode 100644 index 000000000..0eee083aa --- /dev/null +++ b/qualification/reference/tests/test_resource_summary.py @@ -0,0 +1,70 @@ +"""Native record resource projection keeps exact, owned provider identities.""" + +import copy +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_record as airtable +import stripe_platform as stripe +from resource_summary import summary +from common import Refusal + +RUN = 'recipe-qualification/123/1' + + +class Summary(unittest.TestCase): + def stripe(self): + return {'schema': 'auths.stripe-platform-qualification-resources/1', + 'protected_run': RUN, 'platform': 'acct_SYNTHETIC', 'payments': [ + {'id': 'pi_SYNTHETIC', 'amount_received': 2000, 'currency': 'usd', + 'livemode': False, 'run_metadata': RUN}]} + + def airtable(self): + return {'schema': 'auths.airtable-record-qualification-resources/1', + 'protected_run': RUN, 'base': airtable.BASE, 'table': airtable.TABLE, + 'records': [{'id': 'recSYNTHETIC00001', 'run_metadata': RUN}]} + + def test_exact_provider_names_are_sorted_native_bounded_strings(self): + self.assertEqual(summary(stripe.FAMILY, self.stripe()), + ['stripe:test-payment:pi_SYNTHETIC', 'stripe:test-platform:acct_SYNTHETIC']) + table = airtable.BASE + '/' + airtable.TABLE + self.assertEqual(summary(airtable.FAMILY, self.airtable()), sorted([ + 'airtable:base:' + airtable.BASE, 'airtable:table:' + table, + 'airtable:record:' + table + '/recSYNTHETIC00001'])) + + def test_foreign_run_duplicate_live_and_open_ledger_cannot_be_summarized(self): + changes = [lambda v: v['payments'][0].update(run_metadata='recipe-qualification/999/1'), + lambda v: v['payments'].append(copy.deepcopy(v['payments'][0])), + lambda v: v['payments'][0].update(livemode=True), + lambda v: v.update(provider_resources=['unreviewed']), + lambda v: v.update(protected_run='../../another-run')] + for change in changes: + value = self.stripe() + change(value) + with self.assertRaises(Refusal): summary(stripe.FAMILY, value) + with self.assertRaises(Refusal): summary(airtable.FAMILY, self.stripe()) + with self.assertRaises(Refusal): summary('unknown-family', self.stripe()) + with self.assertRaises(Refusal): summary(stripe.FAMILY, ['unreviewed']) + + def test_oversized_native_name_is_refused_without_truncation(self): + value = self.stripe() + value['payments'][0]['id'] = 'pi_' + 'A' * 128 + with self.assertRaises(Refusal): summary(stripe.FAMILY, value) + + def test_record_bound_includes_platform_base_and_table_names(self): + value = self.stripe() + value['payments'] = [dict(value['payments'][0], id='pi_SYNTHETIC' + str(i)) for i in range(31)] + self.assertEqual(len(summary(stripe.FAMILY, value)), 32) + value['payments'].append(dict(value['payments'][0], id='pi_SYNTHETIC31')) + with self.assertRaises(Refusal): summary(stripe.FAMILY, value) + value = self.airtable() + value['records'] = [{'id': 'recSYNTHETIC' + str(i).zfill(5), 'run_metadata': RUN} for i in range(30)] + self.assertEqual(len(summary(airtable.FAMILY, value)), 32) + value['records'].append({'id': 'recSYNTHETIC00030', 'run_metadata': RUN}) + with self.assertRaises(Refusal): summary(airtable.FAMILY, value) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_resources.py b/qualification/reference/tests/test_resources.py new file mode 100644 index 000000000..c9abbf523 --- /dev/null +++ b/qualification/reference/tests/test_resources.py @@ -0,0 +1,200 @@ +"""Disposable setup recovery and ownership boundaries; synthetic providers only.""" + +import copy +import contextlib +import io +import json +import os +from pathlib import Path +import re +import sys +import tempfile +import unittest +import urllib.parse + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import airtable_resources as airtable +import resource_io +import stripe_resources as stripe +from common import Refusal + +RUN = 'recipe-qualification/123/1' + + +class Stripe: + def __init__(self): + self.payments = {} + self.charges = {} + self.keys = {} + self.refunds = 0 + self.lose_create = False + + def api(self, method, path, fields=None, idempotency=None, runtime=False): + if path == '/v1/account': + return {'id': 'acct_SYNTHETIC'} + if path == '/v1/balance': + return {'livemode': False} + if path == '/v1/payment_intents': + assert method == 'POST' and fields['payment_method'] == 'pm_card_visa' + if idempotency in self.keys: + return copy.deepcopy(self.payments[self.keys[idempotency]]) + number = str(len(self.payments) + 1) + payment_id, charge_id = 'pi_SYNTHETIC' + number, 'ch_SYNTHETIC' + number + payment = {'id': payment_id, 'livemode': False, 'status': 'succeeded', + 'amount': 2000, 'amount_received': 2000, 'currency': 'usd', + 'metadata': {'auths_qualification': fields['metadata[auths_qualification]']}, + 'latest_charge': charge_id} + self.payments[payment_id] = payment + self.keys[idempotency] = payment_id + self.charges[charge_id] = {'id': charge_id, 'payment_intent': payment_id, + 'livemode': False, 'amount': 2000, + 'amount_refunded': 0, 'refunded': False} + if self.lose_create: + self.lose_create = False + raise Refusal('synthetic-lost-response') + return copy.deepcopy(payment) + if path.startswith('/v1/payment_intents/'): + return copy.deepcopy(self.payments[path.rsplit('/', 1)[1]]) + if path.startswith('/v1/charges/'): + return copy.deepcopy(self.charges[path.rsplit('/', 1)[1]]) + assert method == 'POST' and path == '/v1/refunds' + payment = self.payments[fields['payment_intent']] + charge = self.charges[payment['latest_charge']] + assert 0 < fields['amount'] <= 2000 - charge['amount_refunded'] + self.refunds += 1 + charge['amount_refunded'] += fields['amount'] + charge['refunded'] = charge['amount_refunded'] == 2000 + return {'status': 'succeeded', 'payment_intent': payment['id']} + + +class Airtable: + def __init__(self): + self.records = {'recUNRELATED00001': {'id': 'recUNRELATED00001', + 'fields': {'Name': 'Unrelated owner record', 'DemoStatus': 'Pending'}}} + self.deleted = [] + self.lose_create = False + self.change_before_delete = False + + def api(self, method, path, fields=None): + assert path.startswith(airtable.TABLE_PATH) + if method == 'GET' and '?' in path: + query = urllib.parse.parse_qs(urllib.parse.urlsplit(path).query) + names = re.findall(r"\{Name\}='([^']+)'", query['filterByFormula'][0]) + assert names + return {'records': copy.deepcopy([record for record in self.records.values() + if record['fields']['Name'] in names])} + if method == 'POST': + record_id = 'recSYNTHETIC' + str(len(self.records)).zfill(5) + record = {'id': record_id, 'fields': copy.deepcopy(fields['fields'])} + self.records[record_id] = record + if self.lose_create: + self.lose_create = False + raise Refusal('synthetic-lost-response') + return copy.deepcopy(record) + record_id = path.rsplit('/', 1)[1] + if method == 'GET': + result = copy.deepcopy(self.records[record_id]) + if self.change_before_delete: + result['fields']['Name'] = 'Changed ownership' + return result + assert method == 'DELETE' + self.deleted.append(record_id) + del self.records[record_id] + return {'id': record_id, 'deleted': True} + + +class Resources(unittest.TestCase): + def workspace(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + work = Path(temporary.name) + os.chmod(work, 0o700) + return work + + def test_stripe_lost_creation_response_replays_only_the_same_test_fixture(self): + work, provider = self.workspace(), Stripe() + resources = stripe.Resources({}, provider.api) + provider.lose_create = True + with self.assertRaises(Refusal): + resources.prepare(RUN, 1, work / 'journal.json', work / 'resources.json') + self.assertFalse((work / 'resources.json').exists()) + self.assertEqual(len(provider.payments), 1) + resources.cleanup(work / 'journal.json') + self.assertEqual(len(provider.payments), 1, 'same idempotency key recovers the pending response') + self.assertEqual(provider.refunds, 1) + resources.cleanup(work / 'journal.json') + self.assertEqual(provider.refunds, 1, 'retired cleanup is idempotent') + + def test_stripe_setup_and_cleanup_refuse_changed_owner_and_live_mode(self): + work, provider = self.workspace(), Stripe() + resources = stripe.Resources({}, provider.api) + resources.prepare(RUN, 2, work / 'journal.json', work / 'resources.json') + self.assertEqual(len(resource_io.read(work / 'resources.json')['payments']), 2) + payment = next(iter(provider.payments.values())) + payment['metadata']['auths_qualification'] = 'another-run' + with self.assertRaises(Refusal): resources.cleanup(work / 'journal.json') + self.assertEqual(provider.refunds, 0) + payment['metadata']['auths_qualification'] = RUN + payment['livemode'] = True + with self.assertRaises(Refusal): resources.cleanup(work / 'journal.json') + self.assertEqual(provider.refunds, 0) + payment['livemode'] = False + resources.cleanup(work / 'journal.json') + self.assertEqual(provider.refunds, 2) + + def test_airtable_partial_setup_cleanup_discovers_only_owned_records(self): + work, provider = self.workspace(), Airtable() + resources = airtable.Resources('synthetic', provider.api) + provider.lose_create = True + with self.assertRaises(Refusal): + resources.prepare(RUN, 2, work / 'journal.json', work / 'resources.json') + resources.cleanup(work / 'journal.json') + self.assertEqual(set(provider.records), {'recUNRELATED00001'}) + self.assertEqual(len(provider.deleted), 1) + resources.cleanup(work / 'journal.json') + self.assertEqual(len(provider.deleted), 1) + + def test_airtable_changed_ownership_and_duplicate_names_cannot_qualify(self): + work, provider = self.workspace(), Airtable() + resources = airtable.Resources('synthetic', provider.api) + resources.prepare(RUN, 1, work / 'journal.json', work / 'resources.json') + provider.change_before_delete = True + with self.assertRaises(Refusal): resources.cleanup(work / 'journal.json') + self.assertFalse(provider.deleted) + provider.change_before_delete = False + owned = next(record for record in provider.records.values() if record['fields']['Name'].startswith('Auths')) + provider.records['recDUPLICATE00001'] = dict(copy.deepcopy(owned), id='recDUPLICATE00001') + with self.assertRaises(Refusal): + resources.prepare(RUN, 1, work / 'journal.json', work / 'another-output.json') + self.assertFalse((work / 'another-output.json').exists()) + resources.cleanup(work / 'journal.json') + self.assertEqual(set(provider.records), {'recUNRELATED00001'}) + + def test_cli_reports_closed_domain_codes_without_external_exception_text(self): + for error, expected in [(Refusal('qualification.resources.payment-binding'), + 'qualification.resources.payment-binding'), + (ValueError('synthetic-sensitive-external-detail'), + 'qualification.resources.refused'), + (Refusal('synthetic-sensitive-external-detail'), + 'qualification.resources.refused')]: + def fail(): raise error + output = io.StringIO() + with contextlib.redirect_stderr(output), self.assertRaises(SystemExit): + resource_io.finish(fail) + self.assertEqual(output.getvalue().strip(), expected) + + def test_ledger_outputs_refuse_symlinks_public_directories_and_overwrite(self): + work = self.workspace() + target = work / 'target.json' + target.write_text('{}') + linked = work / 'linked.json' + linked.symlink_to(target) + with self.assertRaises(OSError): resource_io.read(linked) + with self.assertRaises(Refusal): resource_io.write(linked, {}) + with self.assertRaises(OSError): resource_io.write(target, {}, new=True) + os.chmod(work, 0o755) + with self.assertRaises(Refusal): resource_io.write(work / 'new.json', {}, new=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/reference/tests/test_tls_fault.py b/qualification/reference/tests/test_tls_fault.py new file mode 100644 index 000000000..b1f258f78 --- /dev/null +++ b/qualification/reference/tests/test_tls_fault.py @@ -0,0 +1,171 @@ +"""Transparent fault mechanics with synthetic counters and disposable TLS. + +These tests demonstrate transport behavior, not native gateway/provider facts. +""" + +import asyncio +import json +import os +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'run')) +import tls_fault as fault + + +def snapshot(entries=0, scope='1' * 32): + return {'schema': 'auths.gateway-execution-witness/1', 'scope': scope, + 'credential_lease_calls': entries, 'write_transport_entries': entries, + 'read_transport_entries': 0} + + +def server_hello(version=None): + body = b'\x03\x03' + b'\0' * 32 + b'\0\x13\x01\0' + if version is not None: + extension = b'\0\x2b\0\x02' + version + body += len(extension).to_bytes(2, 'big') + extension + return b'\x02' + len(body).to_bytes(3, 'big') + body + + +class Parsing(unittest.TestCase): + def work(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + path = Path(temporary.name) / 'witness.ndjson' + path.write_text(json.dumps(snapshot()) + '\n') + path.chmod(0o600) + return path + + def test_fragmented_negotiation_keeps_handshake_records_unmodified(self): + for version, expected in [(None, 'tls12'), (b'\x03\x04', 'tls13')]: + hello = fault.Hello() + payload = server_hello(version) + hello.server(22, payload[:7]) + self.assertIsNone(hello.version) + hello.server(22, payload[7:]) + self.assertEqual(hello.version, expected) + self.assertFalse(hello.client_boundary(22)) + self.assertTrue(hello.client_boundary(23)) + hello.server(23, b'opaque-ciphertext-never-decoded') + self.assertEqual(hello.version, expected) + + def test_malformed_or_unknown_tls_negotiation_refuses(self): + for value in [server_hello(b'\x03\x05'), b'\x01\0\0\x26' + b'\0' * 38, + b'\x02\xff\xff\xff', server_hello(b'\x03\x04')[:-1] + b'\xff']: + with self.assertRaises(ValueError): fault.Hello().server(22, value) + + def test_witness_requires_actual_changed_scope_local_counters_without_saturation(self): + path = self.work() + witness = fault.Witness(path, os.getuid()) + self.assertFalse(witness.entered()) + with path.open('a') as out: + out.write(json.dumps(snapshot(1)) + '\n' + '{"schema":') + self.assertTrue(witness.entered(), 'a concurrent partial tail is not a new fact') + path.write_text(json.dumps(snapshot()) + '\n') + with self.assertRaises(ValueError): witness.entered() + for changed in [snapshot(1, scope='2' * 32), snapshot(2), + dict(snapshot(1), write_transport_entries=(1 << 64) - 1), + dict(snapshot(1), passed=True)]: + path = self.work() + with path.open('a') as out: out.write(json.dumps(changed) + '\n') + with self.assertRaises(ValueError): fault.Witness(path, os.getuid()).entered() + + def test_changed_inode_links_and_public_witness_are_refused(self): + for mode in ['replacement', 'symlink', 'hardlink', 'public']: + path = self.work() + witness = fault.Witness(path, os.getuid()) + witness.entered() + if mode == 'replacement': + replacement = path.with_name('replacement') + replacement.write_text(json.dumps(snapshot()) + '\n') + replacement.chmod(0o600) + os.replace(replacement, path) + elif mode == 'symlink': + target = path.with_name('target') + path.rename(target) + path.symlink_to(target) + elif mode == 'hardlink': + os.link(path, path.with_name('linked')) + else: + path.chmod(0o644) + with self.assertRaises((ValueError, OSError)): witness.entered() + + def test_control_cannot_invent_entry_or_choose_an_endpoint(self): + state = fault.Fault(None) + for command in [{'command': 'drop'}, {'command': 'drop', 'provider_url': 'unreviewed'}, + {'command': 'passed'}, {'passed': True}]: + with self.assertRaises(ValueError): state.decide(command) + state.armed, state.held_connections = True, 1 + state.decide({'command': 'drop'}) + self.assertEqual(state.status()['command'], 'drop') + with self.assertRaises(ValueError): state.decide({'command': 'release'}) + + +class Transport(unittest.IsolatedAsyncioTestCase): + async def test_disposable_tls12_and_tls13_drop_or_release_real_encrypted_records(self): + for version in [ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3]: + for command in ['release', 'drop']: + with self.subTest(version=version, command=command), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + # Ephemeral local test material stays outside the repository. + made = subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ed25519', '-nodes', + '-subj', '/CN=localhost', '-days', '1', '-keyout', str(root / 'key.pem'), + '-out', str(root / 'cert.pem')], capture_output=True, timeout=10) + self.assertEqual(made.returncode, 0) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = server_context.maximum_version = version + server_context.load_cert_chain(root / 'cert.pem', root / 'key.pem') + client_context = ssl.create_default_context(cafile=str(root / 'cert.pem')) + client_context.minimum_version = client_context.maximum_version = version + received = asyncio.Event() + request = b'POST /synthetic HTTP/1.1\r\nHost: localhost\r\n\r\n' + response = b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}' + async def provider(reader, writer): + try: + self.assertEqual(await reader.readuntil(b'\r\n\r\n'), request) + received.set() + writer.write(response) + await writer.drain() + finally: + writer.close() + upstream = await asyncio.start_server(provider, '127.0.0.1', 0, ssl=server_context) + endpoint = upstream.sockets[0].getsockname() + witness = root / 'witness.ndjson' + witness.write_text(json.dumps(snapshot()) + '\n' + json.dumps(snapshot(1)) + '\n') + witness.chmod(0o600) + state = fault.Fault(fault.Witness(witness, os.getuid())) + with patch.object(fault, 'destination', return_value=endpoint): + relay = await asyncio.start_server(lambda r, w: fault.relay(r, w, state, set()), + '127.0.0.1', 0) + endpoint = relay.sockets[0].getsockname() + reader, writer = await asyncio.wait_for(asyncio.open_connection( + *endpoint, ssl=client_context, server_hostname='localhost'), 5) + writer.write(request) + await writer.drain() + await asyncio.wait_for(received.wait(), 5) + pending = asyncio.create_task(reader.read(len(response))) + await asyncio.sleep(0.05) + self.assertFalse(pending.done(), 'response must be held after upstream receipt') + state.decide({'command': command}) + returned = await asyncio.wait_for(pending, 5) + self.assertEqual(returned, response if command == 'release' else b'') + self.assertTrue(state.armed) + self.assertGreater(state.buffered, 0) + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + relay.close() + await relay.wait_closed() + upstream.close() + await upstream.wait_closed() + + +if __name__ == '__main__': + unittest.main() diff --git a/qualification/run/artifact_wait.py b/qualification/run/artifact_wait.py new file mode 100644 index 000000000..f233f2ab1 --- /dev/null +++ b/qualification/run/artifact_wait.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Wait for one public artifact from this exact protected qualification run. + +No uploaded program is executed. Run/source/attempt identities and GitHub's +archive digest are checked before bounded public files are extracted. Native +permit/record verification is still mandatory: transport identity is not +qualification authority. The only credential passed to gh is its Actions token. +""" + +import argparse +import hashlib +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'reference')) +from common import Refusal, require +from expand import decode +from resource_io import finish, write_bytes + +REPOSITORY = 'auths-dev/auths-proof' +REPOSITORY_ID = 1310728509 +WORKFLOW = '.github/workflows/recipe-qualification.yml' +KINDS = ['commissioning-inputs', 'commissioning-permit', 'first-proposal', 'first-release', 'final-proposal'] +MAX_ARCHIVE = 32 * 1024 * 1024 +MAX_FILES = 256 +MAX_FILE = 2 * 1024 * 1024 +POLL_SECONDS = 600 + + +def identity(environment): + require(environment.get('GITHUB_REPOSITORY') == REPOSITORY + and environment.get('GITHUB_EVENT_NAME') == 'workflow_dispatch' + and environment.get('GITHUB_REF') == 'refs/heads/main', + 'qualification.artifact.protected-run') + run, attempt, commit = [environment.get(name, '') for name in + ['GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GITHUB_SHA']] + require(re.fullmatch(r'[1-9][0-9]{0,19}', run) and re.fullmatch(r'[1-9][0-9]{0,9}', attempt) + and re.fullmatch(r'[0-9a-f]{40}', commit), 'qualification.artifact.run-identity') + return run, attempt, commit + + +def api(path, destination=None): + # Never inherit provider keys, signer keys, proxy settings, alternate API + # hosts or GitHub configuration. gh handles the authenticated archive + # redirect; its token is for GitHub, not a provider or signing input. + token = os.environ.get('GH_TOKEN', '') + require(bool(token), 'qualification.artifact.token-missing') + environment = {'PATH': os.environ.get('PATH', '/usr/bin:/bin'), 'GH_TOKEN': token, + 'GH_PROMPT_DISABLED': '1', 'GH_CONFIG_DIR': '/nonexistent-auths-gh-config'} + arguments = ['gh', 'api', '--hostname', 'github.com', '-H', 'X-GitHub-Api-Version: 2022-11-28', + 'repos/' + REPOSITORY + '/' + path] + result = subprocess.run(arguments, env=environment, cwd='/', stdin=subprocess.DEVNULL, + stdout=destination if destination is not None else subprocess.PIPE, + stderr=subprocess.DEVNULL, timeout=60) + require(result.returncode == 0, 'qualification.artifact.transport-refused') + if destination is None: + require(0 < len(result.stdout) <= 256 * 1024, 'qualification.artifact.metadata-bound') + return decode(result.stdout) + + +def check_run(value, run, attempt, commit): + require(value.get('id') == int(run) and value.get('run_attempt') == int(attempt) + and value.get('head_sha') == commit and value.get('head_branch') == 'main' + and value.get('event') == 'workflow_dispatch' and value.get('path') == WORKFLOW + and value.get('repository', {}).get('id') == REPOSITORY_ID + and value.get('head_repository', {}).get('id') == REPOSITORY_ID, + 'qualification.artifact.run-binding') + + +def select_artifact(value, name, run, commit): + require(type(value) is dict and type(value.get('total_count')) is int + and 0 <= value['total_count'] <= 100 and type(value.get('artifacts')) is list + and len(value['artifacts']) == value['total_count'], 'qualification.artifact.metadata-bound') + selected = [artifact for artifact in value['artifacts'] if artifact.get('name') == name] + require(len(selected) <= 1, 'qualification.artifact.duplicate-name') + if not selected: + return None + artifact = selected[0] + source = artifact.get('workflow_run', {}) + require(type(artifact.get('id')) is int and artifact['id'] > 0 + and artifact.get('expired') is False + and type(artifact.get('size_in_bytes')) is int and 0 < artifact['size_in_bytes'] <= MAX_ARCHIVE + and type(artifact.get('digest')) is str + and re.fullmatch(r'sha256:[0-9a-f]{64}', artifact['digest']) + and source.get('id') == int(run) and source.get('head_sha') == commit + and source.get('head_branch') == 'main' + and source.get('repository_id') == REPOSITORY_ID + and source.get('head_repository_id') == REPOSITORY_ID, + 'qualification.artifact.source-binding') + return artifact + + +def members(archive): + entries = archive.infolist() + require(0 < len(entries) <= MAX_FILES * 2, 'qualification.artifact.archive-bound') + files, names, total = [], set(), 0 + for entry in entries: + name = entry.filename + path = PurePosixPath(name) + require(not path.is_absolute() and 1 <= len(path.parts) <= 4 + and all(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,95}', part) for part in path.parts) + and '\\' not in name and name == str(path) + ('/' if entry.is_dir() else '') + and str(path) not in names and not entry.flag_bits & 1, + 'qualification.artifact.archive-path') + names.add(str(path)) + mode = entry.external_attr >> 16 + kind = stat.S_IFMT(mode) + require(kind in [0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG], + 'qualification.artifact.archive-kind') + if entry.is_dir(): + require(entry.file_size == 0, 'qualification.artifact.archive-bound') + continue + require(path.suffix in ['.json', '.cbor', '.proof', '.action'] + and 0 < entry.file_size <= MAX_FILE, 'qualification.artifact.public-file') + total += entry.file_size + require(total <= MAX_ARCHIVE and len(files) < MAX_FILES, 'qualification.artifact.archive-bound') + files.append((entry, path)) + require(bool(files), 'qualification.artifact.archive-bound') + file_names = {str(path) for _, path in files} + require(all(str(parent) not in file_names for _, path in files + for parent in path.parents if str(parent) != '.'), 'qualification.artifact.archive-path') + return files + + +def unpack(path, artifact, destination): + info = os.lstat(path) + require(stat.S_ISREG(info.st_mode) and 0 < info.st_size <= MAX_ARCHIVE, + 'qualification.artifact.archive-bound') + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(256 * 1024), b''): + digest.update(chunk) + require(artifact['digest'] == 'sha256:' + digest.hexdigest(), 'qualification.artifact.archive-digest') + require(not destination.exists(), 'qualification.artifact.output-exists') + parent = os.lstat(destination.parent) + require(stat.S_ISDIR(parent.st_mode) and stat.S_IMODE(parent.st_mode) == 0o700 + and parent.st_uid == os.getuid(), 'qualification.artifact.private-output') + complete = False + try: + with zipfile.ZipFile(path) as archive: + files = members(archive) + destination.mkdir(mode=0o700) + for entry, relative in files: + output = destination / str(relative) + output.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with archive.open(entry) as stream: + payload = stream.read(MAX_FILE + 1) + require(len(payload) == entry.file_size, 'qualification.artifact.archive-bound') + write_bytes(output, payload, new=True) + complete = True + except (zipfile.BadZipFile, RuntimeError, NotImplementedError): + raise Refusal('qualification.artifact.archive-refused') from None + finally: + # A partial extraction, including a CRC/format/write failure, never + # becomes input to the native authority verifier. + if not complete and destination.exists(): + shutil.rmtree(destination) + + +def wait(args): + run, attempt, commit = identity(os.environ) + require(args.kind in KINDS and re.fullmatch(r'[a-z][a-z0-9-]{0,63}', args.family), + 'qualification.artifact.name') + name = 'qualification-' + args.kind + '-' + args.family + '-' + run + '-' + attempt + now = int(time.time()) + require(now < args.not_after <= now + 7200, 'qualification.artifact.deadline') + deadline = time.monotonic() + args.not_after - now + last_clock = time.time() + check_run(api('actions/runs/' + run), run, attempt, commit) + while True: + now = time.time() + require(now >= last_clock and now < args.not_after and time.monotonic() < deadline, + 'qualification.artifact.deadline') + last_clock = now + artifact = select_artifact(api('actions/runs/' + run + '/artifacts?per_page=100'), name, run, commit) + if artifact is not None: + # The current attempt must still be the one being operated on; + # rerunning a workflow cannot import the earlier attempt's mailbox. + check_run(api('actions/runs/' + run), run, attempt, commit) + with tempfile.TemporaryDirectory(prefix='auths-qualification-artifact-') as temporary: + path = Path(temporary) / 'public.zip' + with path.open('xb') as output: + os.chmod(path, 0o600) + api('actions/artifacts/' + str(artifact['id']) + '/zip', output) + require(last_clock <= time.time() < args.not_after and time.monotonic() < deadline, + 'qualification.artifact.deadline') + unpack(path, artifact, args.out) + return + time.sleep(min(POLL_SECONDS, max(0, deadline - time.monotonic()))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kind', choices=KINDS, required=True) + parser.add_argument('--family', required=True) + parser.add_argument('--not-after', type=int, required=True) + parser.add_argument('--out', type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: wait(args)) + + +if __name__ == '__main__': + main() diff --git a/qualification/run/assemble.sh b/qualification/run/assemble.sh index d0124a2a5..e6c37a152 100755 --- a/qualification/run/assemble.sh +++ b/qualification/run/assemble.sh @@ -14,23 +14,42 @@ family="${1:?usage: assemble.sh &2; exit 1; } root="$(git rev-parse --show-toplevel)" directory="${root}/qualification/families/${family}" tool="${AUTHS_QUALIFICATION:-${root}/target/release/auths-qualification}" +# Downloaded artifacts do not retain directory modes. Only this job's owned, +# real work directory may receive the reconstructed public proposal inputs. +[ -d "${work}" ] && [ ! -L "${work}" ] && [ -O "${work}" ] \ + || { echo "qualification.unsafe-work-directory" >&2; exit 1; } +chmod 0700 "${work}" + # A failed rerun must not leave an earlier proposal available to a signer. rm -rf "${work}/proposal" "${work}/evidence" +rm -f "${work}/provider-resources.json" -for required in tuple.json packages.json facts.json live-effects.json resources.json cases/redaction.scan.json; do +for required in tuple.json packages.json facts.json "${stage}-effects.json" resources.json cases/redaction.scan.json; do [ -s "${work}/${required}" ] || { echo "qualification.evidence-incomplete ${required}" >&2; exit 1; } done commit="$(jq -r .commit "${work}/facts.json")" [ "${commit}" = "$(git -C "${root}" rev-parse HEAD)" ] || { echo "qualification.commit-changed" >&2; exit 1; } +[ -z "$(git -C "${root}" status --porcelain)" ] \ + || { echo "qualification.source-not-clean" >&2; exit 1; } # The tuple names this family and nothing else's. [ "$(jq -r .recipe_family "${work}/tuple.json")" = "${family}" ] \ || { echo "qualification.tuple-names-another-family" >&2; exit 1; } +# The ledger is a closed family/run-bound object, not a caller-selected array +# of record strings. Reconstruct the native record summary from its exact IDs. +env -i PATH="${PATH}" PYTHONNOUSERSITE=1 python3 -B \ + "${root}/qualification/reference/resource_summary.py" \ + --family "${family}" --resources "${work}/resources.json" \ + --out "${work}/provider-resources.json" + # The trust stages are run here, by the tool this job built, whatever the # harness left under that name. "${tool}" stage-trust --tuple "${work}/tuple.json" --out "${work}/cases/rotation.trust.json" @@ -40,7 +59,7 @@ for member in conformance differential hostile live recovery rotation restart mu reports=() # Only the current runner's phase reports and release-owned trust/scan # outputs are inputs. Extra harness-authored or stale reports are ignored. - for phase in offline live; do + for phase in offline "${stage}"; do file="${work}/cases/${member}.${phase}.json" [ ! -f "${file}" ] || reports+=(--cases "${file}") done @@ -51,8 +70,8 @@ for member in conformance differential hostile live recovery rotation restart mu [ "${#reports[@]}" -gt 0 ] || { echo "qualification.member-missing ${member}" >&2; exit 1; } live=() if [ "${member}" = live ]; then - live=(--live-entered "$(jq -r .entered "${work}/live-effects.json")" - --live-confirmed "$(jq -r .confirmed_by_read_back "${work}/live-effects.json")") + live=(--live-entered "$(jq -r .entered "${work}/${stage}-effects.json")" + --live-confirmed "$(jq -r .confirmed_by_read_back "${work}/${stage}-effects.json")") fi "${tool}" evidence --member "${member}" --commit "${commit}" \ --tuple "${work}/tuple.json" "${reports[@]}" ${live[@]+"${live[@]}"} \ @@ -60,21 +79,22 @@ for member in conformance differential hostile live recovery rotation restart mu done now="$(date -u +%s)" -identifier="qlf_$(printf '%s\n%s\n%s' "${family}" "${commit}" "${run}" | shasum -a 256 | cut -c1-32)" +identifier="qlf_$(printf '%s\n%s\n%s\n%s' "${family}" "${commit}" "${run}" "${stage}" | shasum -a 256 | cut -c1-32)" jq -n \ --arg identifier "${identifier}" \ --argjson now "${now}" \ --arg environment "${environment}" \ + --arg stage "${stage}" \ --slurpfile record "${directory}/record.json" \ --slurpfile tuple "${work}/tuple.json" \ --slurpfile packages "${work}/packages.json" \ --slurpfile facts "${work}/facts.json" \ - --slurpfile resources "${work}/resources.json" \ + --slurpfile resources "${work}/provider-resources.json" \ '{qualification_id: $identifier, provider_kind: $record[0].provider_kind, tuple: $tuple[0], not_before: $now, - not_after: ($now + ($record[0].validity_days * 86400)), + not_after: ($now + (if $stage == "commissioning" then 7200 else ($record[0].validity_days * 86400) end)), provenance: {repository: "github.com/auths-dev/auths-proof", commit: $facts[0].commit, workflow: ".github/workflows/recipe-qualification.yml", environment: $environment}, source_closure_sha256: $facts[0].source_closure_sha256, @@ -87,7 +107,10 @@ jq -n \ custody_descriptor: $record[0].custody_descriptor, store_descriptor: $record[0].store_descriptor, residual_assumptions: ($record[0].residual_assumptions | sort), - excluded_claims: ($record[0].excluded_claims | sort)}' \ + excluded_claims: (($record[0].excluded_claims + + (if $stage == "commissioning" then + ["First-run commissioning: ordinary installed clients were refused; their qualified effect and production readiness require the subsequent live phase."] + else [] end)) | unique | sort)}' \ > "${work}/draft.json" "${tool}" assemble --draft "${work}/draft.json" --evidence-dir "${work}/evidence" \ diff --git a/qualification/run/close_proposal.py b/qualification/run/close_proposal.py new file mode 100644 index 000000000..db90faf37 --- /dev/null +++ b/qualification/run/close_proposal.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Close and scan a proposal while the operator still holds the real canaries. + +The first assembly introduces new retained files. Rescan those files, rebuild +with that complete redaction report, then scan the actual final bytes again. +The report must remain unchanged; expanding or changing the output categories +during the second assembly refuses publication. No authority is issued here. +""" + +import argparse +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys + +import scan_publication as publication + + +def invalidate(work): + for name in ['proposal', 'evidence']: + path = work / name + if path.is_symlink(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + cases = work / 'cases' + if cases.is_dir() and not cases.is_symlink(): + (cases / 'redaction.scan.json').unlink(missing_ok=True) + + +def assemble(family, work, environment, run, stage, tool): + # Only this checkout's assembler executes. No artifact supplies a program, + # callback, argument list, signer key or provider credential to it. + script = Path(__file__).resolve().with_name('assemble.sh') + result = subprocess.run( + ['bash', str(script), family, str(work), environment, run, stage], + capture_output=True, timeout=120, + env={'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'AUTHS_QUALIFICATION': str(tool)}, cwd=script.parents[2]) + publication.require(result.returncode == 0 + and len(result.stdout) <= publication.MAX_BYTES + and len(result.stderr) <= publication.MAX_BYTES) + + +def close(family, work, environment, run, stage, tool): + publication.require(stage in ['commissioning', 'live'] + and re.fullmatch(r'[a-z][a-z0-9-]{0,63}', family) is not None) + work = Path(work).absolute() + info = os.lstat(work) + publication.require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid() + and stat.S_IMODE(info.st_mode) == 0o700) + complete = False + try: + publication.scan(work, tool, keep_canaries=True) + assemble(family, work, environment, run, stage, tool) + publication.scan(work, tool, keep_canaries=True) + report = publication.contents(work / 'cases/redaction.scan.json') + assemble(family, work, environment, run, stage, tool) + publication.scan(work, tool, keep_canaries=True) + publication.require(publication.contents(work / 'cases/redaction.scan.json') == report) + publication.require((work / 'proposal').is_dir() + and not (work / 'proposal').is_symlink()) + if stage == 'live': + (work / 'canaries').unlink() + complete = True + finally: + if not complete: + invalidate(work) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--family', required=True) + parser.add_argument('--work', type=Path, required=True) + parser.add_argument('--environment', required=True) + parser.add_argument('--run', required=True) + parser.add_argument('--stage', choices=['commissioning', 'live'], default='live') + parser.add_argument('--tool', type=Path, required=True) + args = parser.parse_args() + try: + close(args.family, args.work, args.environment, args.run, args.stage, args.tool) + except (ValueError, OSError, subprocess.SubprocessError): + print('qualification.proposal.not-closed', file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == '__main__': + main() diff --git a/qualification/run/commission.py b/qualification/run/commission.py new file mode 100644 index 000000000..b09c8db8f --- /dev/null +++ b/qualification/run/commission.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Protected, source-owned commissioning signer entry point. + +The signer job builds its reviewer and issuer from this exact main checkout. +Downloaded candidate bytes are hashed, never executed here. Public inputs +select neither tools nor the reviewed contract, recipe, oracle or lease cap. +The root key and release signer are not inputs to this command. +""" + +import argparse +import base64 +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'reference')) +import expand +from common import digest, require +from resource_io import finish, write_bytes +import artifact_wait + +ROOT = Path(__file__).resolve().parents[2] + + +def call(arguments): + result = subprocess.run(list(map(str, arguments)), stdin=subprocess.DEVNULL, + capture_output=True, timeout=120, cwd=ROOT, env={'PATH': '/usr/bin:/bin'}) + require(result.returncode == 0 and len(result.stdout) <= 65536 and len(result.stderr) <= 65536, + 'qualification.commission.source-command') + return result.stdout + + +def source_contract(family, inputs, issuer): + require(family in expand.REFERENCES, 'qualification.commission.family') + from generate_families import generate + generate(check=True) + contract = ROOT / 'qualification/families' / family / 'contract.json' + # Hash the reviewed source contract through the native format, never use + # an artifact-supplied contract ID as its own expected value. + expected = digest(call([issuer, 'contract-id', '--contract', contract]).decode('ascii').strip()) + actual = expand.decode(expand.read(inputs / 'tuple.json', 65536)) + require(actual['recipe_family'] == family and actual['provider_contract_id'] == expected, + 'qualification.commission.contract-binding') + return actual + + +def signing_seed(value): + require(type(value) is str and re.fullmatch(r'[A-Za-z0-9_-]{43}', value) is not None, + 'qualification.commission.key-format') + raw = base64.urlsafe_b64decode(value + '=') + require(len(raw) == 32 and base64.urlsafe_b64encode(raw).rstrip(b'=').decode() == value, + 'qualification.commission.key-format') + return value.encode('ascii') + + +def issue(family, inputs, candidate, output): + run, attempt, commit = artifact_wait.identity(os.environ) + require(call(['/usr/bin/git', 'rev-parse', 'HEAD']).decode().strip() == commit + and not call(['/usr/bin/git', 'status', '--porcelain']).strip(), + 'qualification.commission.source-binding') + require(not output.exists() and not output.is_symlink(), 'qualification.commission.output-exists') + issuer, reviewer = [ROOT / 'target/release' / name for name in ['auths-qualification', 'auths-gateway']] + source_contract(family, inputs, issuer) + protected_run = 'recipe-qualification/' + run + '/' + attempt + packets = inputs / 'packets/public-packets.json' + plan = expand.decode(expand.read(packets, 65536)) + now = int(time.time()) + # Delayed approval cannot extend the original installed-author session. + require(type(plan.get('evaluated_at')) is int + and now - 7200 < plan['evaluated_at'] <= now + 60, + 'qualification.commission.author-expired') + not_after = min(now + 7200, plan['evaluated_at'] + 7200) + require(now < not_after, 'qualification.commission.author-expired') + output.mkdir(mode=0o700) + completed = False + try: + with tempfile.TemporaryDirectory(prefix='auths-source-commission-') as private: + private = Path(private) + expansion = private / 'reference' + expand.expand(SimpleNamespace(source_commit=commit, protected_run=protected_run, + tuple=inputs / 'tuple.json', candidate=candidate, reviewer=reviewer, + recipe=inputs / 'recipe.json', profile_lock=inputs / 'profile.lock.json', + resources=inputs / 'resources.json', packets=packets, + conformance=inputs / 'offline/conformance.json', + differential=inputs / 'offline/differential.json', out_dir=expansion)) + # Read the key only after source identity, contract and independent + # full-pool reconstruction passed. Children inherit no key env. + seed = signing_seed(os.environ.get('QUALIFICATION_COMMISSIONING_SIGNER_KEY')) + key = private / 'commissioner.key' + canaries = private / 'canaries' + write_bytes(key, seed, new=True) + write_bytes(canaries, seed + b'\n', new=True) + certificate = ROOT / 'qualification/trust/commissioning-signer-certificate.json' + call([issuer, 'commissioning-sign', '--binding', expansion / 'binding.json', + '--conformance', inputs / 'offline/conformance.json', + '--differential', inputs / 'offline/differential.json', '--signer-key', key, + '--certificate', certificate, '--issued-at', str(now), + '--not-before', str(now), '--not-after', str(not_after), + '--out', output / 'commissioning-permit.json']) + for name, source in [('signer-certificate.json', certificate), + ('revocation-list.json', ROOT / 'qualification/trust/revocation-list.json')]: + write_bytes(output / name, expand.read(source, 65536), new=True) + scan = [issuer, 'stage-redaction', '--canaries', canaries] + for name in ['commissioning-permit.json', 'signer-certificate.json', 'revocation-list.json']: + scan += ['--source', 'evidence=' + str(output / name)] + call([*scan, '--out', private / 'signer-publication-scan.json']) + require(int(time.time()) < not_after, 'qualification.commission.author-expired') + completed = True + finally: + if not completed: + shutil.rmtree(output) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--family', choices=sorted(expand.REFERENCES), required=True) + for name in ['inputs', 'candidate', 'out']: + parser.add_argument('--' + name, type=lambda value: Path(value).absolute(), required=True) + args = parser.parse_args() + finish(lambda: issue(args.family, args.inputs, args.candidate, args.out)) + + +if __name__ == '__main__': + main() diff --git a/qualification/run/live.sh b/qualification/run/live.sh index 912cb95eb..c05bac5ab 100755 --- a/qualification/run/live.sh +++ b/qualification/run/live.sh @@ -1,36 +1,22 @@ #!/usr/bin/env bash -# Execute protected live stages and tear down disposable resources on every -# exit, including a failed setup or runner. Cleanup must be idempotent. +# Execute one protected phase inside resource-session.sh. Resource preparation +# and cleanup belong to the complete journey, so commissioning cannot destroy +# the resources or author session needed by ordinary qualified execution. set -euo pipefail family="${1:?usage: live.sh }" work="${2:?usage: live.sh }" +stage="${3:-live}" +[[ "${stage}" = commissioning || "${stage}" = live ]] \ + || { echo "qualification.invalid-stage" >&2; exit 1; } +export AUTHS_QUALIFICATION_STAGE="${stage}" root="$(git rev-parse --show-toplevel)" [[ "${family}" =~ ^[a-z][a-z0-9-]{0,63}$ ]] || { echo "qualification.invalid-family" >&2; exit 1; } harness="${root}/qualification/families/${family}/harness" [ -x "${harness}" ] || { echo "qualification.family-unknown" >&2; exit 1; } tool="${AUTHS_QUALIFICATION:-${root}/target/release/auths-qualification}" -rm -f "${work}/live-effects.json" "${work}/cases/"*.live.json +rm -f "${work}/${stage}-effects.json" "${work}/cases/"*."${stage}".json -cleanup() { - status=$? - # The family writes scan sources privately. Never relay child output, - # which may contain credentials, to Actions logs. - if ! "${harness}" cleanup "${work}" >/dev/null 2>&1; then - echo "qualification.cleanup-failed" >&2 - status=1 - fi - if [ "${status}" -ne 0 ]; then - rm -f "${work}/live-effects.json" "${work}/cases/"*.live.json - fi - exit "${status}" -} -trap cleanup EXIT - -if ! "${harness}" prepare-live "${work}" >/dev/null 2>&1; then - echo "qualification.live-setup-failed" >&2 - exit 1 -fi -"${tool}" run-stage --phase live \ - --corpus "${root}/qualification/families/${family}/corpus-manifest.json" \ +"${tool}" run-stage --phase "${stage}" \ + --corpus "${work}/corpus.json" \ --harness "${harness}" --tuple "${work}/tuple.json" --work-dir "${work}" diff --git a/qualification/run/offline.sh b/qualification/run/offline.sh index 7bd56a660..d8976514e 100755 --- a/qualification/run/offline.sh +++ b/qualification/run/offline.sh @@ -3,8 +3,8 @@ # # offline.sh # -# Builds the release candidate from a clean tree, runs the family's offline -# harness, and runs the trust stages for the tuple the harness reported. +# Uses the credential-free job's exact candidate from a clean source revision, +# runs the family harness and runs the trust stages for its reported tuple. # Writes into : tuple.json, packages.json, cases/*.json, facts.json. set -euo pipefail @@ -23,15 +23,19 @@ if [ -n "$(git -C "${root}" status --porcelain)" ]; then fi mkdir -p "${work}/cases" -cargo build --locked --release -p auths-gateway --bin auths-gateway -cargo build --locked --release -p auths-recipe-qualification-issuance --bin auths-qualification -export AUTHS_GATEWAY="${root}/target/release/auths-gateway" -export AUTHS_QUALIFICATION="${root}/target/release/auths-qualification" +export AUTHS_GATEWAY="${AUTHS_GATEWAY:?qualification.candidate-not-supplied}" +export AUTHS_QUALIFICATION="${AUTHS_QUALIFICATION:?qualification.issuer-not-supplied}" +# Candidate bytes are built once by the credential-free job and kept outside +# the public evidence directory; the tuple binds that exact executable. "${directory}/harness" prepare "${work}" -# The tuple is obtained from the actual installed candidate, not authored -# by the harness. Installation and package acquisition remain family-owned. -"${AUTHS_GATEWAY}" qualification-status --state-dir "${work}/gateway-state" --tuple \ +# Derive the planned production identity without custody or provider access. +# The protected live runner must compare its actual installation byte-for-byte +# before importing a permit. This command grants no execution authority. +"${AUTHS_GATEWAY}" qualification-candidate \ + --recipe "${work}/recipe.json" --profile-lock "${work}/profile.lock.json" \ + --recipe-family "${family}" \ + --provider-contract-id "$("${AUTHS_QUALIFICATION}" contract-id --contract "${directory}/contract.json")" \ > "${work}/tuple.json" for required in tuple.json packages.json; do [ -s "${work}/${required}" ] || { echo "qualification.harness-incomplete ${required}" >&2; exit 1; } @@ -41,19 +45,28 @@ done "$("${AUTHS_QUALIFICATION}" contract-id --contract "${directory}/contract.json")" ] \ || { echo "qualification.contract-changed" >&2; exit 1; } "${AUTHS_QUALIFICATION}" run-stage --phase offline \ - --corpus "${directory}/corpus-manifest.json" --harness "${directory}/harness" \ + --corpus "${work}/corpus.json" --harness "${directory}/harness" \ --tuple "${work}/tuple.json" --work-dir "${work}" "${AUTHS_QUALIFICATION}" stage-trust --tuple "${work}/tuple.json" \ --out "${work}/cases/rotation.trust.json" +# The finite permit consumes actual canonical offline evidence, not a case +# list or an arbitrary report's passing flag. +mkdir -p "${work}/offline" +for member in conformance differential; do + "${AUTHS_QUALIFICATION}" evidence --member "${member}" \ + --tuple "${work}/tuple.json" --commit "$(git -C "${root}" rev-parse HEAD)" \ + --cases "${work}/cases/${member}.offline.json" --out "${work}/offline/${member}.json" +done + digest() { shasum -a 256 "$1" | cut -d' ' -f1; } jq -n \ --arg commit "$(git -C "${root}" rev-parse HEAD)" \ --arg source_closure "$(git -C "${root}" ls-tree -r HEAD | shasum -a 256 | cut -d' ' -f1)" \ --arg generated "$(cat "${AUTHS_GATEWAY}" "${AUTHS_QUALIFICATION}" | shasum -a 256 | cut -d' ' -f1)" \ --arg decision "$(digest "${directory}/decision-record.md")" \ - --arg corpus "$(digest "${directory}/corpus-manifest.json")" \ + --arg corpus "$(digest "${work}/corpus.json")" \ '{commit: $commit, source_closure_sha256: $source_closure, generated_artifacts_sha256: $generated, recipe_decision_record_sha256: $decision, corpus_manifest_sha256: $corpus}' \ diff --git a/qualification/run/redact.sh b/qualification/run/redact.sh index a6a300fef..278b782bb 100755 --- a/qualification/run/redact.sh +++ b/qualification/run/redact.sh @@ -3,38 +3,22 @@ # the canaries. Runs in the live job, the only place the canaries exist, so # nothing unscanned and no canary leaves that job. # -# redact.sh +# redact.sh [commissioning|live] # # Writes /cases/redaction.scan.json and deletes /canaries. set -euo pipefail work="${1:?usage: redact.sh }" +phase="${2:-live}" +[[ "${phase}" = commissioning || "${phase}" = live ]] \ + || { echo "qualification.invalid-stage" >&2; exit 1; } root="$(git rev-parse --show-toplevel)" tool="${AUTHS_QUALIFICATION:-${root}/target/release/auths-qualification}" [ -s "${work}/canaries" ] || { echo "qualification.evidence-incomplete canaries" >&2; exit 1; } -# One canary per line; a carriage return is not part of a canary. -tr -d '\r' < "${work}/canaries" > "${work}/canaries.clean" -mv "${work}/canaries.clean" "${work}/canaries" - -sources=() -for kind in log trace metric support-bundle; do - while IFS= read -r -d '' file; do - sources+=(--source "${kind}=${file}") - done < <(find "${work}/scan/${kind}" -type f -print0 2>/dev/null | sort -z) -done -# Everything else the run publishes: the case reports and the facts that -# enter the record. -while IFS= read -r -d '' file; do - sources+=(--source "evidence=${file}") -done < <(find "${work}/cases" -type f -name '*.json' -print0 | sort -z) -for published in tuple.json packages.json resources.json live-effects.json; do - [ -s "${work}/${published}" ] && sources+=(--source "evidence=${work}/${published}") -done - -rm -f "${work}/cases/redaction.scan.json" -"${tool}" stage-redaction --canaries "${work}/canaries" "${sources[@]}" \ - --out "${work}/redaction.cases.json" -mv "${work}/redaction.cases.json" "${work}/cases/redaction.scan.json" -rm -f "${work}/canaries" -echo "redaction scan passed; canaries removed" +arguments=(--work "${work}" --tool "${tool}") +if [[ "${phase}" = commissioning ]]; then + arguments+=(--keep-canaries) +fi +python3 "$(dirname "${BASH_SOURCE[0]}")/scan_publication.py" "${arguments[@]}" +echo "redaction scan passed for ${phase}" diff --git a/qualification/run/resource-session.sh b/qualification/run/resource-session.sh new file mode 100644 index 000000000..093406511 --- /dev/null +++ b/qualification/run/resource-session.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Hold disposable resources across the source-owned protected journey. The +# command and its arguments come from the reviewed workflow, never an artifact. +# Setup runs once and cleanup runs after the complete journey on every exit. +# +# resource-session.sh [arguments...] +set -euo pipefail + +family="${1:?usage: resource-session.sh [arguments...]}" +work="${2:?}" +shift 2 +[ "$#" -gt 0 ] || { echo "qualification.journey-missing" >&2; exit 1; } +root="$(git rev-parse --show-toplevel)" +[[ "${family}" =~ ^[a-z][a-z0-9-]{0,63}$ ]] || { echo "qualification.invalid-family" >&2; exit 1; } +harness="${root}/qualification/families/${family}/harness" +[ -x "${harness}" ] || { echo "qualification.family-unknown" >&2; exit 1; } + +invalidate() { + for phase in commissioning live; do + rm -f "${work}/${phase}-effects.json" "${work}/cases/"*."${phase}".json + done + rm -rf "${work}/proposal" "${work}/evidence" +} + +cleanup() { + status=$? + trap - EXIT + # Provider error bodies and private setup/cleanup diagnostics never enter + # the Actions log. A failed cleanup cannot leave a passing final proposal. + if ! "${harness}" cleanup "${work}" >/dev/null 2>&1; then + echo "qualification.cleanup-failed" >&2 + status=1 + fi + if [ "${status}" -ne 0 ]; then + invalidate + fi + exit "${status}" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if ! "${harness}" prepare-live "${work}" >/dev/null 2>&1; then + echo "qualification.live-setup-failed" >&2 + exit 1 +fi +"$@" diff --git a/qualification/run/scan_publication.py b/qualification/run/scan_publication.py new file mode 100644 index 000000000..4a07138a0 --- /dev/null +++ b/qualification/run/scan_publication.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Scan every retained public file, including newly added phase evidence. + +The one private canary file is excluded and never published. Executables and +other private inputs belong outside this publication tree. Symbolic/hard links, +special files, oversized sources and concurrent changes refuse publication. +""" + +import argparse +import hashlib +import os +from pathlib import Path +import stat +import subprocess +import sys + +MAX_FILES = 256 +MAX_BYTES = 2 * 1024 * 1024 +MAX_TOTAL_BYTES = 32 * 1024 * 1024 +KINDS = {'log', 'trace', 'metric', 'support-bundle'} + + +class Refusal(ValueError): + pass + + +def require(value): + if not value: + raise Refusal('qualification.publication.refused') + + +def contents(path): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, 'rb') as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_size <= MAX_BYTES) + payload = stream.read(MAX_BYTES + 1) + require(len(payload) <= MAX_BYTES) + return payload + + +def sources(work): + require(stat.S_ISDIR(os.lstat(work).st_mode)) + work = work.resolve() + result, total = [], 0 + for parent, directories, files in os.walk(work, followlinks=False): + for name in directories: + require(stat.S_ISDIR(os.lstat(Path(parent) / name).st_mode)) + for name in files: + path = Path(parent) / name + info = os.lstat(path) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1) + relative = path.relative_to(work) + if relative == Path('canaries'): + require(info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) & 0o077 == 0) + continue + require(relative != Path('cases/redaction.scan.json')) + payload = contents(path) + total += len(payload) + require(total <= MAX_TOTAL_BYTES and len(result) < MAX_FILES) + kind = relative.parts[1] if len(relative.parts) >= 3 and relative.parts[0] == 'scan' \ + and relative.parts[1] in KINDS else 'evidence' + result.append((relative.as_posix(), kind, hashlib.sha256(payload).hexdigest())) + require(result) + return sorted(result) + + +def scan(work, tool, keep_canaries=False): + require(stat.S_ISDIR(os.lstat(work).st_mode)) + work = work.resolve() + require(stat.S_ISDIR(os.lstat(work / 'cases').st_mode)) + report = work / 'cases/redaction.scan.json' + report.unlink(missing_ok=True) + before = sources(work) + arguments = [str(tool), 'stage-redaction', '--canaries', str(work / 'canaries')] + for name, kind, _digest in before: + arguments += ['--source', kind + '=' + str(work / name)] + arguments += ['--out', str(report)] + try: + result = subprocess.run(arguments, capture_output=True, timeout=120, + cwd='/', env={'PATH': '/usr/bin:/bin'}) + except subprocess.SubprocessError: + report.unlink(missing_ok=True) + raise + # Native diagnostics may contain input paths. They never enter job logs. + if result.returncode != 0: + report.unlink(missing_ok=True) + raise Refusal('qualification.publication.refused') + require(len(result.stdout) <= MAX_BYTES and len(result.stderr) <= MAX_BYTES) + # Recheck the complete tree, including unanticipated added files. A report + # is the only output allowed between the pre-scan and post-scan inventories. + report_bytes = contents(report) + report.unlink() + try: + require(sources(work) == before) + descriptor = os.open(report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'wb') as stream: + stream.write(report_bytes) + stream.flush() + os.fsync(stream.fileno()) + except (ValueError, OSError): + report.unlink(missing_ok=True) + raise + if not keep_canaries: + (work / 'canaries').unlink() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--work', type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--tool', type=lambda value: Path(value).absolute(), required=True) + parser.add_argument('--keep-canaries', action='store_true') + args = parser.parse_args() + try: + scan(args.work, args.tool, args.keep_canaries) + except (ValueError, OSError, subprocess.SubprocessError): + print('qualification.publication.refused', file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == '__main__': + main() diff --git a/qualification/run/tls_fault.py b/qualification/run/tls_fault.py new file mode 100644 index 000000000..ffc4b764f --- /dev/null +++ b/qualification/run/tls_fault.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Release-only transparent TLS response fault, outside the shipping gateway. + +Run in the gateway's disposable Linux network namespace with UID-scoped +REDIRECT rules for the reviewed provider's IPv4 addresses and port 443. The +original destination is retained. This relay has no TLS key, terminates no TLS, +and never decodes a provider request, response, credential or proof. Only the +unencrypted ServerHello selects the record boundary after the TLS handshake. + +An actual native write-counter change arms the response hold. A separate root +controller must independently observe the effect before sending `drop`. Cipher +bytes and traffic counts are not evidence that the provider applied a write. +The case additionally needs the native result and fresh provider read-back. +""" + +import argparse +import asyncio +import hashlib +import ipaddress +import os +from pathlib import Path +import socket +import stat +import struct +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'reference')) +from common import closed, require +from expand import decode +import measure +from resource_io import finish + +ORIGINS = {'stripe-platform-refund-v1': 'api.stripe.com', + 'airtable-record-update-v1': 'api.airtable.com'} +MAX_RECORD = 18432 +MAX_HELLO = 65536 +MAX_BUFFER = 2 * 1024 * 1024 +MAX_CONNECTIONS = 16 + + +class Hello: + """Read only plaintext TLS negotiation metadata, never encrypted content.""" + + def __init__(self): + self.pending = bytearray() + self.version = None + + def server(self, kind, payload): + if kind != 22 or self.version is not None: + return + self.pending.extend(payload) + require(len(self.pending) <= MAX_HELLO, 'qualification.fault.hello-bound') + if len(self.pending) < 4: + return + length = int.from_bytes(self.pending[1:4], 'big') + require(self.pending[0] == 2 and 38 <= length <= MAX_HELLO - 4, + 'qualification.fault.server-hello') + if len(self.pending) < length + 4: + return + body = bytes(self.pending[4:length + 4]) + require(body[:2] == b'\x03\x03', 'qualification.fault.tls-version') + sid = body[34] + offset = 35 + sid + 3 + require(sid <= 32 and offset <= len(body) and body[offset - 1] == 0, + 'qualification.fault.server-hello') + selected = None + if offset != len(body): + require(offset + 2 <= len(body), 'qualification.fault.server-hello') + size = int.from_bytes(body[offset:offset + 2], 'big') + offset += 2 + require(offset + size == len(body), 'qualification.fault.server-hello') + while offset < len(body): + require(offset + 4 <= len(body), 'qualification.fault.server-hello') + extension = int.from_bytes(body[offset:offset + 2], 'big') + size = int.from_bytes(body[offset + 2:offset + 4], 'big') + offset += 4 + require(offset + size <= len(body), 'qualification.fault.server-hello') + if extension == 43: + require(selected is None and size == 2, 'qualification.fault.tls-version') + selected = body[offset:offset + size] + offset += size + require(selected in [None, b'\x03\x03', b'\x03\x04'], 'qualification.fault.tls-version') + self.version = 'tls13' if selected == b'\x03\x04' else 'tls12' + self.pending.clear() + + def client_boundary(self, kind): + # TLS 1.3's first encrypted client record contains Finished. It can + # be forwarded while subsequent server application records are held. + # TLS 1.2 keeps Finished as content type 22; type 23 follows it. + return self.version is not None and kind == 23 + + +async def record(reader): + header = await reader.readexactly(5) + kind, version, size = header[0], header[1:3], int.from_bytes(header[3:5], 'big') + require(kind in [20, 21, 22, 23] and version in [b'\x03\x01', b'\x03\x02', b'\x03\x03'] + and 0 < size <= MAX_RECORD, 'qualification.fault.record-bound') + payload = await reader.readexactly(size) + return kind, payload, header + payload + + +class Witness: + """Pin one private, append-only native counter stream and its initial scope.""" + + def __init__(self, path, owner): + self.path, self.owner = Path(path), owner + self.inode, self.previous_size, self.before, self.last = None, 0, None, None + self.prefix_sha256 = None + + def entered(self): + fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 + and info.st_uid == self.owner and stat.S_IMODE(info.st_mode) == 0o600 + and 0 < info.st_size <= 256 * 1024, + 'qualification.fault.witness-file') + inode = info.st_dev, info.st_ino + require(self.inode in [None, inode] and info.st_size >= self.previous_size, + 'qualification.fault.changed-witness') + raw = stream.read(256 * 1024 + 1) + require(len(raw) <= 256 * 1024, 'qualification.fault.witness-bound') + require(self.prefix_sha256 is None or hashlib.sha256(raw[:self.previous_size]).digest() + == self.prefix_sha256, 'qualification.fault.changed-witness') + # A concurrent append may end halfway through one bounded frame. + frames = raw.split(b'\n')[:-1] + require(1 <= len(frames) <= 256 and all(0 < len(frame) < 1024 for frame in frames) + and len(raw.rsplit(b'\n', 1)[-1]) < 1024, 'qualification.fault.witness-bound') + values = [measure.snapshot(decode(frame)) for frame in frames] + for before, after in zip(values, values[1:]): + measure.delta(before, after) + require(self.before in [None, values[0]], 'qualification.fault.changed-witness') + if self.last is not None: + measure.delta(self.last, values[-1]) + self.inode, self.previous_size = inode, len(raw) + self.prefix_sha256 = hashlib.sha256(raw).digest() + self.before, self.last = values[0], values[-1] + count = measure.delta(self.before, self.last)['write_transport_entries'] + require(count <= 1, 'qualification.fault.multiple-writes') + return count == 1 + + +class Fault: + def __init__(self, witness): + self.witness = witness + self.decision = asyncio.Event() + self.command = None + self.buffered = self.held_connections = self.connections = 0 + self.armed = False + + def decide(self, value): + closed(value, ['command']) + require(value['command'] in ['drop', 'release'] and self.command is None + and self.armed and self.held_connections > 0, + 'qualification.fault.control-state') + self.command = value['command'] + self.decision.set() + + def status(self): + return {'schema': 'auths.qualification-tls-fault/1', 'armed': self.armed, + 'held_connections': self.held_connections, 'buffered_bytes': self.buffered, + 'command': self.command} + + +def destination(writer, approved): + stream = writer.get_extra_info('socket') + # Linux SO_ORIGINAL_DST preserves the gateway-selected address across + # REDIRECT. No command or artifact may select another upstream endpoint. + raw = stream.getsockopt(socket.SOL_IP, 80, 16) + family = struct.unpack_from('H', raw)[0] + port = int.from_bytes(raw[2:4], 'big') + address = socket.inet_ntoa(raw[4:8]) + require(family == socket.AF_INET and port == 443 and address in approved, + 'qualification.fault.destination') + return address, port + + +async def relay(reader, writer, fault, approved): + fault.connections += 1 + remote = None + tasks = [] + try: + require(fault.connections <= MAX_CONNECTIONS and fault.command is None, + 'qualification.fault.connection-bound') + upstream, remote = await asyncio.wait_for( + asyncio.open_connection(*destination(writer, approved), limit=MAX_RECORD * 2), 5) + hello = Hello() + held = False + + async def client(): + nonlocal held + while True: + kind, _payload, raw = await record(reader) + if not held and hello.client_boundary(kind) and fault.witness.entered(): + # Arm before forwarding Finished/the request so a fast + # upstream response cannot overtake this boundary. + held = True + fault.armed = True + fault.held_connections += 1 + remote.write(raw) + await remote.drain() + + async def server(): + while True: + kind, payload, raw = await record(upstream) + hello.server(kind, payload) + if held and kind == 23: + fault.buffered += len(raw) + require(fault.buffered <= MAX_BUFFER, 'qualification.fault.buffer-bound') + await fault.decision.wait() + if fault.command == 'drop': + return + writer.write(raw) + await writer.drain() + + tasks = [asyncio.create_task(client()), asyncio.create_task(server())] + _done, pending = await asyncio.wait(tasks, timeout=45, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + except (OSError, ValueError, asyncio.TimeoutError, asyncio.IncompleteReadError): + # Neither TLS ciphertext nor path/provider exception text is emitted. + pass + finally: + for task in tasks: + task.cancel() + for connection in [writer, remote]: + if connection is not None: + connection.close() + try: + await connection.wait_closed() + except OSError: + pass + + +async def control(reader, writer, fault): + try: + stream = writer.get_extra_info('socket') + _pid, uid, _gid = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + require(uid == 0, 'qualification.fault.control-peer') + raw = await asyncio.wait_for(reader.readuntil(b'\n'), 5) + require(len(raw) <= 128, 'qualification.fault.control-bound') + value = decode(raw) + if value == {'command': 'status'}: + pass + else: + fault.decide(value) + from common import canonical + writer.write(canonical(fault.status()) + b'\n') + await writer.drain() + except (OSError, ValueError, asyncio.TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError): + pass + finally: + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + + +async def serve(args): + require(sys.platform == 'linux' and os.getuid() == 0, 'qualification.fault.linux-root') + origin = ORIGINS[args.family] + approved = {result[4][0] for result in socket.getaddrinfo(origin, 443, socket.AF_INET, socket.SOCK_STREAM)} + require(1 <= len(approved) <= 16 and all(ipaddress.ip_address(value).is_global for value in approved), + 'qualification.fault.provider-address') + parent = os.lstat(args.control.parent) + require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0 + and stat.S_IMODE(parent.st_mode) == 0o700 and not args.control.exists() + and not args.control.is_symlink() and args.control.parent.resolve() == args.control.parent, + 'qualification.fault.private-control') + fault = Fault(Witness(args.witness, args.witness_owner)) + # Root's umask keeps the local control socket private at creation. + os.umask(0o077) + ipc = await asyncio.start_unix_server(lambda r, w: control(r, w, fault), args.control, limit=128) + os.chmod(args.control, 0o600) + inode = os.lstat(args.control).st_ino + try: + tcp = await asyncio.start_server(lambda r, w: relay(r, w, fault, approved), '127.0.0.1', args.port, + limit=MAX_RECORD * 2) + print('qualification.fault.ready', flush=True) + async with ipc, tcp: + await asyncio.wait_for(asyncio.gather(ipc.serve_forever(), tcp.serve_forever()), 120) + finally: + ipc.close() + await ipc.wait_closed() + if args.control.exists() and os.lstat(args.control).st_ino == inode: + args.control.unlink() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--family', choices=sorted(ORIGINS), required=True) + parser.add_argument('--witness', type=lambda v: Path(v).absolute(), required=True) + parser.add_argument('--witness-owner', type=int, required=True) + parser.add_argument('--control', type=lambda v: Path(v).absolute(), required=True) + parser.add_argument('--port', type=int, default=44443) + args = parser.parse_args() + require(1 <= args.port <= 65535 and 1 <= args.witness_owner <= (1 << 32) - 2, + 'qualification.fault.configuration') + finish(lambda: asyncio.run(serve(args))) + + +if __name__ == '__main__': + main() diff --git a/qualification/simulation/Dockerfile b/qualification/simulation/Dockerfile new file mode 100644 index 000000000..b62b1559e --- /dev/null +++ b/qualification/simulation/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* diff --git a/qualification/simulation/README.md b/qualification/simulation/README.md new file mode 100644 index 000000000..2606c4f92 --- /dev/null +++ b/qualification/simulation/README.md @@ -0,0 +1,60 @@ +# Independent operator rehearsal + +The owner requested simulated bootstrap and provider qualification. Run it: + +```sh +python3 qualification/simulation/run.py --out /tmp/auths-qualification-rehearsal +``` + +Use a fresh output directory. No provider credential, signing key file or +production environment is needed. The same run is a required job in recipe +qualification CI, and uploads its public reports and signing artifacts. + +The platform-account Stripe refund and Airtable record update harnesses compile the maintained +recipes and drive the native submission driver over durable file claims and +mutable counting providers. Independent vertical wire oracles check the actual +method, URL, headers, body and idempotency commitment before each write. Reports +measure credential leases, write entries and fresh read-back confirmations. +Each measured report has a detached Ed25519 simulation attestation from a fresh +in-memory key. A separate native stage re-reads the published files and verifies +their signatures and report digests. Changed bytes or simulation scope refuse. +These self-signed reports have no production or protected-run signing authority; +the disposable root ceremony below exercises that separate trust machinery. +They exercise exact writes, original/fresh proof replay, reopening the store, +lost responses, and crashes before the response record is durable. + +The first ceremony generates an ephemeral root and release signer in memory, +certifies the signer, signs two explicitly placeholder records, constructs the +index and revocation list, and imports them into the native required gate. +Actual engine credential-store counters show zero leases before signing and +one after verified import. Unsigned, expired and missing-root inputs refuse. +The placeholder records explicitly exclude every provider-run claim; they test +the trust transition, while provider measurements are separate artifacts. + +CI also packages the compiled native harness with its SHA-256 and commit. +It runs that kit in Docker with networking disabled and no source checkout +mounted. Download the `qualification-simulation-kit-*` artifact, make the +`qualification-harness` executable, and run with Python 3.9 or later: + +```sh +chmod +x /path/to/kit/qualification-harness +python3 /path/to/kit/run.py --candidate-kit /path/to/kit --out /tmp/rehearsal +``` + +The kit must match the host OS and architecture. Its request and policy fixtures +are compiled in. No Rust toolchain or repository import is needed for that run. + +These are simulation artifacts. They cannot enable the shipping gateway: +its pinned root is unchanged, the tuples use development stores, and the +bootstrap records name placeholder provenance. No file is installed under +`qualification/trust` or `qualification/families`, and readiness remains false. +Native proof/socket and installed-package journeys remain separate SDK workflow +checks. This rehearsal does not claim the full protected production corpus. + +The owner approved platform-only Stripe test refunds to avoid paid Connect +onboarding. Both maintained live harnesses passed using the downloaded gateway +and installed Python 0.0.1rc1 wheel in Docker. Their signed reports are retained +in `evidence/stripe-platform-live-2026-10-07/` and +`evidence/airtable-live-2026-10-07/`; see [live instructions](live/README.md). +Those actual provider runs use development custody and exclude protected +production qualification and full mandatory-corpus coverage. diff --git a/qualification/simulation/evidence/airtable-live-2026-10-07/attestation.json b/qualification/simulation/evidence/airtable-live-2026-10-07/attestation.json new file mode 100644 index 000000000..805262f2e --- /dev/null +++ b/qualification/simulation/evidence/airtable-live-2026-10-07/attestation.json @@ -0,0 +1,12 @@ +{ + "statement": { + "schema": "auths.provider-simulation-attestation/1", + "simulation": true, + "stable_launch_ready": false, + "signer_kind": "disposable-self-signed-simulation-key", + "family": "airtable-record-update-v1", + "report_sha256": "a12ce66e95a1be381d7192829703511462ee096fa3a8f4de7ba687603ad1bc16", + "public_key_b64": "E/fZPLxpY+38f21PI7ltrRGy13lb74+yOMQVnw1c9PE" + }, + "signature_b64": "OFQBeMCUhz/NZvYAyqH6K2r8tKw8msPotqzvy6ENbdiVg/Cglak07DpgrgLN/2hweL4kRQJdXHj0w6CNkSdtAQ" +} \ No newline at end of file diff --git a/qualification/simulation/evidence/airtable-live-2026-10-07/report.json b/qualification/simulation/evidence/airtable-live-2026-10-07/report.json new file mode 100644 index 000000000..217bd9acd --- /dev/null +++ b/qualification/simulation/evidence/airtable-live-2026-10-07/report.json @@ -0,0 +1,124 @@ +{ + "schema": "auths.recipe-qualification-simulation/1", + "simulation": true, + "stable_launch_ready": false, + "family": "airtable-record-update-v1", + "provider": "live Airtable Web API v0", + "verification_boundary": "installed SDK and native application socket", + "source_commit": "20837b56a61945d79dedf48a1ff237411dd01185", + "gateway_sha256": "85d6fd1c8b44fa5a7a34d4a012a39d3edf00dc867710c149b7d3da2bc6d2230e", + "compiled_recipe_sha256": "2b95034013998915950fd0f6ec0141bfcc7ca2e731f8e117fd8a5fd7670d5ea5", + "sdk_location": "/opt/consumer/lib/python3.12/site-packages/auths/__init__.py", + "sdk_version": "0.0.1rc1", + "wheel_sha256": "6d079aa4960256a754315d911b55d80bc02848b1ad14225420ea8eae4905656f", + "store": "shared-file-v1", + "custody": "local-file-v1", + "clock": "development-host-clock", + "resources": { + "base": "appQD3Qf0YFBCW9bV", + "table": "tblBx2jwe0IsPYRKT", + "record": "recgLJ1LFbmBY2iZP" + }, + "results": { + "race": [ + { + "evidence": { + "channel": "read-back", + "echo": "auths-e1-5bc7f10da8a385afc4330f72c3a448459e75f96cf791631aa6a354000a7f94d6", + "evidence_digest": "487a67efdee7633443cf483375befbc11fe985461d899cd46e9947de57645769", + "observed_at": 1791334499 + }, + "outcome": "observed-by-provider", + "status": 200 + }, + { + "code": "gateway.attempt.replay", + "outcome": "not-entered" + } + ], + "proof_replay": { + "code": "gateway.attempt.replay", + "outcome": "not-entered" + }, + "altered_action": { + "code": "malformed-proof", + "outcome": "denied" + }, + "restart_replay": { + "code": "gateway.attempt.replay", + "outcome": "not-entered" + }, + "fresh_read_back": { + "replacement_matches": true, + "echo_matches": true + } + }, + "cases": [ + { + "case": "review", + "seconds": 0.156, + "exit_code": 0 + }, + { + "case": "installed-consumer-author", + "seconds": 0.431, + "exit_code": 0 + }, + { + "case": "install", + "seconds": 0.225, + "exit_code": 0 + }, + { + "case": "join", + "seconds": 0.204, + "exit_code": 0 + }, + { + "case": "application-secret-isolation", + "seconds": 0.343, + "exit_code": 0 + }, + { + "case": "two-instance-submit-1", + "seconds": 1.892, + "exit_code": 0 + }, + { + "case": "two-instance-submit-0", + "seconds": 4.446, + "exit_code": 0 + }, + { + "case": "proof-replay", + "seconds": 0.369, + "exit_code": 0 + }, + { + "case": "altered-action", + "seconds": 0.351, + "exit_code": 0 + }, + { + "case": "support-bundle", + "seconds": 0.249, + "exit_code": 0 + }, + { + "case": "restart-replay", + "seconds": 0.445, + "exit_code": 0 + } + ], + "excluded_claims": [ + "protected production qualification", + "production PostgreSQL/AWS custody", + "complete qualification corpus", + "independent provider-entry and lease counters", + "token restriction to one base" + ], + "cleanup": { + "created_record_deleted": true, + "table_retained_for_future_rehearsals": true + } +} \ No newline at end of file diff --git a/qualification/simulation/evidence/airtable-live-2026-10-07/support-bundle.json b/qualification/simulation/evidence/airtable-live-2026-10-07/support-bundle.json new file mode 100644 index 000000000..3b436878e --- /dev/null +++ b/qualification/simulation/evidence/airtable-live-2026-10-07/support-bundle.json @@ -0,0 +1 @@ +{"attempts":{"by_stage":{"observed-by-provider":1},"identifiers":[{"key_sha256":"3378da2c91a437798ccb21aabb428d8d67cfcefa3e2da6744708530badc17e47","stage":"observed-by-provider"}],"listed":1,"truncated":false},"build_sha256":"85d6fd1c8b44fa5a7a34d4a012a39d3edf00dc867710c149b7d3da2bc6d2230e","codes":["gateway.qualification.unavailable"],"connection":{"credential_generation":1,"credential_held":true,"generation":1,"in_flight":0,"state":"active"},"credential_store_kind":"local-file-v1","deployment":"development","gateway_package":"auths-gateway","gateway_version":"1.0.0-rc.1","profile_lock_sha256":"af7f984509ab11a3676cacc85eda2fa638c25bf91f7bfedf3ff3c119e47f3c41","qualification":{"code":"gateway.qualification.unavailable","policy":"optional","state":"unqualified"},"recipe_sha256":"2b95034013998915950fd0f6ec0141bfcc7ca2e731f8e117fd8a5fd7670d5ea5","schema":"auths.gateway-support-bundle/1","semantic_closure_sha256":"11a256092b821eec54d27e3ad25cc05f7955da236ea23853cced71c32215764d","trusted_context_sha256":"1545ed2de0eca13e77038015fa9dd5982f2748473c31ebad40d462cacd3fd4be"} diff --git a/qualification/simulation/evidence/provider-resource-lifecycle-2026-10-07/report.json b/qualification/simulation/evidence/provider-resource-lifecycle-2026-10-07/report.json new file mode 100644 index 000000000..22f00f5b4 --- /dev/null +++ b/qualification/simulation/evidence/provider-resource-lifecycle-2026-10-07/report.json @@ -0,0 +1,29 @@ +{ + "deployment": "local-docker", + "production_gateway_exercised": false, + "protected_qualification": false, + "public_ledger_digests": { + "airtable/journal.json": "2914cc695d8d91b9dc9ca8d9e8564dfbeaf75c3aa47639f7441e89ca273ae98e", + "airtable/resources.json": "8cce10dc03cd6e2f4cf0113b6a834b4729e891ef3e0506f8d58ec27cb0c5917a", + "stripe/journal.json": "643efc1d141ebb761dde62d1dbca3d7e1ddd0ea373510ec200e6102bebf61db5", + "stripe/resources.json": "893a0159b8bd9c618d0991194a51df4b26e00641d734382166dfe178d4cacd0e" + }, + "results": [ + { + "family": "stripe", + "prepared": 1, + "protected_qualification": false, + "retired": true + }, + { + "family": "airtable", + "prepared": 1, + "protected_qualification": false, + "retired": true + } + ], + "schema": "auths.provider-resource-lifecycle-rehearsal/1", + "scope": "operator-resource-rehearsal/1791346245/1", + "source_commit": "632e2791504bd92d33832a7e6b582f57d7c2f5b6", + "stable_launch_ready": false +} diff --git a/qualification/simulation/evidence/stripe-platform-live-2026-10-07/attestation.json b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/attestation.json new file mode 100644 index 000000000..344848855 --- /dev/null +++ b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/attestation.json @@ -0,0 +1,12 @@ +{ + "statement": { + "schema": "auths.provider-simulation-attestation/1", + "simulation": true, + "stable_launch_ready": false, + "signer_kind": "disposable-self-signed-simulation-key", + "family": "stripe-platform-refund-v1", + "report_sha256": "3eaea0009cc3ab5c6ddc34e710d12f44397edc3f36d78ebb35928d46804a0571", + "public_key_b64": "UNsQ2mNZ8JQCFVx/zTHAqDH2+znv3gY6WIHC7X5cpH4" + }, + "signature_b64": "NBDD/ZkPEwoX/hyxMLW3jIFSuVFLPtxg1Y94KEdHXS0zx2vpAZZLkLgFDyyAtJ1QGRHPURJSJ/r7wcj8W7TiAQ" +} \ No newline at end of file diff --git a/qualification/simulation/evidence/stripe-platform-live-2026-10-07/report.json b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/report.json new file mode 100644 index 000000000..b56487bba --- /dev/null +++ b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/report.json @@ -0,0 +1,128 @@ +{ + "schema": "auths.recipe-qualification-simulation/1", + "simulation": true, + "stable_launch_ready": false, + "family": "stripe-platform-refund-v1", + "provider": "live Stripe test mode; platform account", + "source_commit": "20837b56a61945d79dedf48a1ff237411dd01185", + "compiled_recipe_sha256": "b258393c49779150ed521cba3a62a4cd546d349b91d4b2ac69da4a9a643858fb", + "gateway_sha256": "85d6fd1c8b44fa5a7a34d4a012a39d3edf00dc867710c149b7d3da2bc6d2230e", + "wheel_sha256": "6d079aa4960256a754315d911b55d80bc02848b1ad14225420ea8eae4905656f", + "sdk_version": "0.0.1rc1", + "sdk_location": "/opt/consumer/lib/python3.12/site-packages/auths/__init__.py", + "resources": { + "platform_account": "acct_1QekbYID70YvJ7mY", + "payment_intent": "pi_3UNjEgID70YvJ7mY0doGkVOE", + "refund": "re_3UNjEgID70YvJ7mY0MCV4aKv" + }, + "store": "shared-file-v1", + "custody": "local-file-v1", + "clock": "development-host-clock", + "verification_boundary": "installed SDK and native application socket", + "cases": [ + { + "case": "review", + "seconds": 0.164 + }, + { + "case": "bound-extension", + "seconds": 0.124 + }, + { + "case": "installed-consumer-author", + "seconds": 0.419 + }, + { + "case": "install", + "seconds": 2.014 + }, + { + "case": "application-secret-isolation", + "seconds": 0.344 + }, + { + "case": "above-grant", + "seconds": 0.383 + }, + { + "case": "above-ratio", + "seconds": 2.176 + }, + { + "case": "wrong-currency", + "seconds": 0.35 + }, + { + "case": "normal", + "seconds": 4.777 + }, + { + "case": "proof-replay", + "seconds": 0.364 + }, + { + "case": "altered-action", + "seconds": 0.342 + }, + { + "case": "support-bundle", + "seconds": 0.259 + }, + { + "case": "restart-replay", + "seconds": 0.548 + } + ], + "results": { + "above-grant": { + "code": "gateway.policy.above-ceiling", + "outcome": "not-entered" + }, + "above-ratio": { + "code": "gateway.relative-ceiling.above", + "outcome": "not-entered" + }, + "wrong-currency": { + "code": "gateway.policy.partition-denied", + "outcome": "not-entered" + }, + "normal": { + "evidence": { + "channel": "read-back", + "echo": "auths-e1-cd352f4d542ed7350a7effd035471407b1e87fc64aa66eeec525562d1b4b27ec", + "evidence_digest": "eb77bd43524e2bca9d585a2b0d1ea2fb4c5289ed7c546d5693ab4c9475898f03", + "observed_at": 1791334905 + }, + "outcome": "observed-by-provider", + "status": 200 + }, + "proof-replay": { + "code": "gateway.attempt.replay", + "outcome": "not-entered" + }, + "altered-action": { + "code": "malformed-proof", + "outcome": "denied" + }, + "restart-replay": { + "code": "gateway.attempt.replay", + "outcome": "not-entered" + } + }, + "fresh_read_back": { + "refund_count_before_cleanup": 1, + "amount_matches": true, + "echo_matches": true + }, + "excluded_claims": [ + "Connect/connected-account scope", + "protected production qualification", + "production PostgreSQL/AWS custody", + "complete qualification corpus", + "independent lease/provider-entry counters" + ], + "cleanup": { + "test_payment_fully_refunded": true, + "stripe_retains_test_payment_and_refund_records": true + } +} \ No newline at end of file diff --git a/qualification/simulation/evidence/stripe-platform-live-2026-10-07/support-bundle.json b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/support-bundle.json new file mode 100644 index 000000000..40ac25313 --- /dev/null +++ b/qualification/simulation/evidence/stripe-platform-live-2026-10-07/support-bundle.json @@ -0,0 +1 @@ +{"attempts":{"by_stage":{"not-entered":1,"observed-by-provider":1},"identifiers":[{"key_sha256":"0aa2afec3447e90c3680b4cf0403d7ab3bcf66f93cc91f949aeaa19fe012917b","stage":"not-entered"},{"key_sha256":"a5ac6189c4e69ddcdfc6b1aea6db6f4cca98ec27ed8a27077f9c85cfa95f2680","stage":"observed-by-provider"}],"listed":2,"truncated":false},"build_sha256":"85d6fd1c8b44fa5a7a34d4a012a39d3edf00dc867710c149b7d3da2bc6d2230e","codes":["gateway.qualification.unavailable"],"connection":{"credential_generation":1,"credential_held":true,"generation":1,"in_flight":0,"state":"active"},"credential_store_kind":"local-file-v1","deployment":"development","gateway_package":"auths-gateway","gateway_version":"1.0.0-rc.1","profile_lock_sha256":"99901e36cb7c4f5882897603c874698d582e859b5d6b08d53cd80975e89aa01c","qualification":{"code":"gateway.qualification.unavailable","policy":"optional","state":"unqualified"},"recipe_sha256":"b258393c49779150ed521cba3a62a4cd546d349b91d4b2ac69da4a9a643858fb","schema":"auths.gateway-support-bundle/1","semantic_closure_sha256":"11a256092b821eec54d27e3ad25cc05f7955da236ea23853cced71c32215764d","trusted_context_sha256":"9bd112b926302b1ec7dc95b89e070ec7e42b05211ead7bbe4f72dabb2babf007"} diff --git a/qualification/simulation/live/Dockerfile b/qualification/simulation/live/Dockerfile new file mode 100644 index 000000000..3e6fad3b7 --- /dev/null +++ b/qualification/simulation/live/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:24.04 +RUN DEBIAN_FRONTEND=noninteractive apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 python3-venv ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m venv /opt/consumer diff --git a/qualification/simulation/live/README.md b/qualification/simulation/live/README.md new file mode 100644 index 000000000..8724b40e9 --- /dev/null +++ b/qualification/simulation/live/README.md @@ -0,0 +1,87 @@ +# Live operator rehearsal + +`airtable.py` exercises a downloaded gateway operator package and an installed +Python wheel against a dedicated Airtable table. It fixes the base/table in the +recipe before review, creates a disposable record, authors proof/action with +native SDK primitives and the gateway's reviewed verifier configuration, and +runs two separate gateway processes against their shared durable file store. +The application UID receives no provider credential and cannot read the token +file or gateway state. The run checks live read-back/value/echo, proof replay, +altered action, process restart, and the real redacted support bundle. Cleanup +deletes the record even after a failed journey. The dedicated empty table is +retained for later rehearsals. + +The report and detached signature use the existing simulation schema and +Ed25519 signature domain. The installed SDK generates a fresh in-memory key, +signs the actual report bytes and re-reads/verifies their published digest and +signature. This signer has no protected release authority. Live provider state +is evidence; the report makes no production qualification, PostgreSQL/AWS, +complete-corpus, token-scope or independently measured lease/entry-counter claim. + +Run inside the maintained Docker image (`Dockerfile` in this directory) after installing the candidate wheel +with `/opt/consumer/bin/pip install --no-deps /wheel/.whl`. Mount only +the extracted package, public Airtable recipe/profile lock, this script and an +empty report directory. Supply the operator credential through Docker stdin. +Do not mount a source checkout or give a credential environment variable to the application. + +```text +/opt/consumer/bin/python /harness/airtable.py \ + --package /candidate/auths-gateway-operator \ + --inputs /inputs --wheel /wheel/.whl --credential-stdin \ + --base --table \ + --commit --out /reports/run +``` + +The stdin input holds `PERSONAL_ACCESS_TOKEN` in dotenv format. The operator +stages it in a root-owned private directory on the container filesystem, then +sends the token to gateway installation through stdin. Do not mount a credential +file: Docker Desktop's host sharing may make its owner match each caller and +can defeat the expected Unix access check. The application isolation check +must fail to read the staged credential and both gateway state directories. +Child output is bounded and secret-scanned before publication; +provider error response bodies never reach public logs. + +The owner authorized platform-account Stripe test refunds on 7 October 2026. +`stripe.py` uses the separately generated `stripe-platform/` profile and recipe, +which have no connected-account field or scope header. It checks the restricted +test key, platform identity, denied reads, grant/relative ceilings, currency, +refund value/echo read-back, proof replay, altered action, restart, secret +isolation, support redaction and exact report signature. Setup creates only a +test PaymentIntent; teardown fully refunds its remaining test balance and +independently reads the charge to confirm cleanup. Stripe retains test payment +and refund records. No Connect/account-scope behavior is claimed. The current +platform test profile is different from the existing scoped Connect example. +For Stripe, use the same mounts and installed wheel, with the public +`stripe-platform/` directory at `/inputs`: + +```text +/opt/consumer/bin/python /harness/stripe.py \ + --package /candidate/auths-gateway-operator \ + --inputs /inputs --wheel /wheel/.whl --credential-stdin \ + --commit --out /reports/run +``` + +Stripe stdin holds `STRIPE_TEST_API_KEY` and `STRIPE_TEST_RESTRICTED_KEY` in +dotenv format. Setup/cleanup use the full test key; gateway installation +receives only the restricted test key. Both keys must be test-mode keys. + +The first successful live rehearsal is preserved in +[`../evidence/airtable-live-2026-10-07/`](../evidence/airtable-live-2026-10-07/). +It used gateway commit `20837b56` and the downloaded `0.0.1rc1` Linux wheel. +The two-process submission returned one provider-observed outcome and one +`gateway.attempt.replay`; the original proof and the same proof after process +restart were refused. Altered action bytes were denied as `malformed-proof`. +An independent fresh GET matched both the approved replacement and the gateway +result's echo. The created record was deleted before the report was signed. + +The matching platform-only Stripe report, signature and real support bundle +are preserved in +[`../evidence/stripe-platform-live-2026-10-07/`](../evidence/stripe-platform-live-2026-10-07/). +It used the same downloaded gateway and installed `0.0.1rc1` wheel as Airtable. +A newly created 2,000-cent test payment had exactly one 500-cent gateway refund +before teardown, with fresh matching echo/value read-back. Grant ceiling, +relative ceiling and currency partition violations were refused, as were +original-proof replay, altered action and replay after restart. Teardown +refunded the remaining test balance and read the charge to confirm full refund. +The refund object has no `livemode` member; the test-mode evidence is the +credential/balance guard and the PaymentIntent/Charge observations. diff --git a/qualification/simulation/live/airtable.py b/qualification/simulation/live/airtable.py new file mode 100644 index 000000000..5e737ee0c --- /dev/null +++ b/qualification/simulation/live/airtable.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Source-free live-provider rehearsal with an isolated installed SDK consumer. + +This is development evidence, not a protected production qualification. +The operator reads the PAT; the application receives only proof and action. +""" +import argparse +import asyncio +import concurrent.futures +import base64 +from dataclasses import asdict +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import uuid + + +class Refusal(Exception): + pass + + +def require(value, code): + if not value: + raise Refusal(code) + + +def canonical(value): + # The signature statement contains only ASCII strings and booleans. + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode() + + +def author_packet(native, arguments, configuration): + """Author disposable trust using native primitives and the reviewed gateway pin.""" + current = int(time.time()) + audience = 'mcp://airtable-gateway-demo' + resource = audience + '/tools/set_demo_status_v1' + key = native.DevelopmentEd25519Key.generate() + actor = native.Principal(key.principal) + request = native.GrantRequest(actor, 'auths.mcp', 2, [('tools/call', resource)], + current - 60, current + 600, [audience], None, None, 0, None, 'raw-key-baseline', []) + grant_unsigned = native.root_grant(actor, request) + signing = native.prepare_signing(grant_unsigned, key.principal_method, key.verification_method, key.suite) + grant = signing.complete(key.sign(signing.signing_preimage)) + challenge = native.generate_challenge_v1() + call = native.mcp_call('airtable-gateway-demo', 'set_demo_status_v1', canonical(arguments)) + prepared = native.prepare_mcp_call_action(call, actor, grant, challenge, current, 30) + signing = native.prepare_signing(prepared.unsigned, key.principal_method, key.verification_method, key.suite) + action = signing.complete(key.sign(signing.signing_preimage)) + assurance = native.AssurancePolicy('raw-key-baseline', [ + ('root', 'every', 'self-certifying-identifier', None), + ('actor', 'every', 'self-certifying-identifier', None), + ('actor', 'every', 'offline-verifiable', None)]) + anchor = native.TrustAnchor(actor.value, actor, [key.principal_method], [('auths.mcp', 2)], + [('tools/call', resource)], [audience], [audience], current - 60, current + 600, + None, 1, 'raw-key-baseline', None) + template = native.compile_trusted_context(configuration, None, 1, 1, 1, [anchor], assurance, + None, None, 'none-v1', [key.evidence_type], []) + context = template.bind_request(audience, challenge, current) + evidence = (key.evidence_type, key.media_type, key.evidence) + return native.assemble_mcp_proof(prepared, action, [grant], [[evidence]], [evidence], context) + + +def consumer(verb, work, socket): + from auths import _native + from auths.gateway import GatewayClient, GatewayEndpoint + require('PERSONAL_ACCESS_TOKEN' not in os.environ and 'PYTHONPATH' not in os.environ, + 'live.consumer-environment') + if verb == 'author': + args = json.loads((work / 'arguments.json').read_text()) + packet = author_packet(_native, args, bytes.fromhex((work / 'configuration').read_text())) + for name, data in zip(['proof.cbor', 'action.cbor', 'context.cbor'], packet): + (work / name).write_bytes(data) + print(json.dumps({'sdk_location': __import__('auths').__file__, 'sdk_version': importlib.metadata.version('auths')})) + elif verb == 'isolation': + for label, path in [('credential', '/run/provider-secret/provider.env'), ('first-state', '/run/operator/first'), ('second-state', '/run/operator/second')]: + try: + Path(path).read_bytes() if path.endswith('.env') else list(Path(path).iterdir()) + except PermissionError: + continue + raise Refusal('live.application-can-read-' + label) + print('{"isolated":true}') + elif verb == 'sign': + report = (work / 'airtable-record-update-v1.json').read_bytes() + key = _native.DevelopmentEd25519Key.generate() + enc = lambda data: base64.b64encode(data).rstrip(b'=').decode() + statement = {'schema': 'auths.provider-simulation-attestation/1', 'simulation': True, + 'stable_launch_ready': False, 'signer_kind': 'disposable-self-signed-simulation-key', + 'family': 'airtable-record-update-v1', 'report_sha256': hashlib.sha256(report).hexdigest(), + 'public_key_b64': enc(bytes(key.public_key))} + preimage = b'auths.provider-simulation-attestation/1\0' + canonical(statement) + signature = bytes(key.sign(preimage)) + _native.verify_ed25519_preimage_v1(bytes(key.public_key), preimage, signature) + path = work / 'airtable-record-update-v1.attestation.json' + path.write_bytes(json.dumps({'statement': statement, 'signature_b64': enc(signature)}, indent=2).encode()) + # Re-read published bytes and verify the exact digest and signature. + published = json.loads(path.read_bytes()) + require(published['statement']['report_sha256'] == hashlib.sha256((work / 'airtable-record-update-v1.json').read_bytes()).hexdigest(), + 'live.signature-report-mismatch') + _native.verify_ed25519_preimage_v1(base64.b64decode(published['statement']['public_key_b64'] + '=='), + b'auths.provider-simulation-attestation/1\0' + canonical(published['statement']), + base64.b64decode(published['signature_b64'] + '==')) + print('{"persisted_signature_verified":true}') + else: + action = (work / 'action.cbor').read_bytes() + if verb == 'altered': + action += b'\0' + result = asyncio.run(GatewayClient(GatewayEndpoint(socket)).submit( + proof=(work / 'proof.cbor').read_bytes(), action=action)) + print(json.dumps(asdict(result), sort_keys=True)) + + +class Operator: + uid, gid, app_uid = 62001, 62000, 62002 + + def __init__(self, args): + self.args, self.processes, self.steps = args, [], [] + self.binary = args.package / 'bin/auths-gateway' + self.python = Path('/opt/consumer/bin/python') + self.work, self.app = Path('/run/operator'), Path('/run/app') + self.record = None + self.token = None + self.env = {'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'} + + def scan(self, data): + if self.token: + token = self.token.encode() + require(not any(v in data for v in [token, token.hex().encode(), base64.b64encode(token), + base64.urlsafe_b64encode(token)]), 'live.secret-exposure') + + def run(self, label, argv, stdin=b'', uid=None, allowed=(0,)): + start = time.monotonic() + result = subprocess.run(list(map(str, argv)), input=stdin, capture_output=True, timeout=60, + env=self.env, cwd='/run', user=self.uid if uid is None else uid, group=self.gid, extra_groups=[]) + self.scan(result.stdout + result.stderr) + require(len(result.stdout) + len(result.stderr) < 1048576, 'live.output-bound') + if result.returncode not in allowed: + codes = re.findall(rb'live\.[a-z0-9-]+', result.stderr) + raise Refusal('live.command-refused:' + label + (':' + codes[-1].decode() if codes else '')) + self.steps.append({'case': label, 'seconds': round(time.monotonic() - start, 3), + 'exit_code': result.returncode}) + return result.stdout + + def child(self, verb, socket=None): + return [self.python, Path(__file__).resolve(), '--consumer', verb, + '--consumer-work', self.app, '--consumer-socket', socket or '/run/sockets/first.sock'] + + def api(self, method, suffix, body=None): + require(suffix.startswith('/v0/'), 'live.invalid-api-path') + data = None if body is None else canonical(body) + request = urllib.request.Request('https://api.airtable.com' + suffix, data=data, method=method, + headers={'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(request, timeout=25) as response: + payload = response.read(65537) + require(len(payload) <= 65536, 'live.provider-response-bound') + return json.loads(payload) + except urllib.error.HTTPError as error: + raise Refusal('live.airtable-http-' + str(error.code)) from None + + def install(self, state, digest, join=False): + argv = [self.binary, 'install', '--state-dir', state, '--recipe', self.work / 'recipe.json', + '--profile-lock', self.work / 'profile.lock.json', '--trusted-context', self.app / 'context.cbor', + '--approve-digest', digest, '--provider', 'airtable', '--alias', 'live-rehearsal', + '--attempt-store', self.work / 'store', '--credential-stdin'] + argv += ['--join'] if join else ['--account-label', self.args.base] + self.run('join' if join else 'install', argv, stdin=(self.token + '\n').encode()) + + def start(self, name): + sock = Path('/run/sockets') / (name + '.sock') + log = tempfile.TemporaryFile() + process = subprocess.Popen([str(self.binary), 'serve', '--state-dir', str(self.work / name), + '--app-socket', str(sock)], stdout=subprocess.PIPE, stderr=log, env=self.env, cwd='/run', + user=self.uid, group=self.gid, extra_groups=[]) + self.processes.append((process, log)) + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + require(bool(selector.select(15)), 'live.gateway-start-timeout') + ready = process.stdout.readline(65537) + self.scan(ready) + require(ready.startswith(b'app socket ready'), 'live.gateway-start-refused') + return sock + + def stop(self): + for process, log in self.processes: + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise Refusal('live.gateway-stop-timeout') + log.seek(0) + self.scan(log.read(1048577)) + require(process.returncode == 0, 'live.gateway-stop-refused') + log.close() + process.stdout.close() + self.processes.clear() + + def exercise(self): + require(os.getuid() == 0, 'live.requires-container-root') + require(not self.args.out.exists(), 'live.output-must-be-new') + self.args.out.mkdir(parents=True) + manifest = json.loads((self.args.package / 'manifest.json').read_text()) + require(manifest['source_commit'] == self.args.commit, 'live.candidate-commit-mismatch') + for entry in manifest['files']: + path = self.args.package / entry['path'] + require(not path.is_symlink() and hashlib.sha256(path.read_bytes()).hexdigest() == entry['sha256'], + 'live.package-hash-mismatch') + require(re.fullmatch(r'app[A-Za-z0-9]{14}', self.args.base) and re.fullmatch(r'tbl[A-Za-z0-9]{14}', self.args.table), + 'live.invalid-resource') + require(self.args.credential_stdin and not sys.stdin.isatty(), 'live.credential-stdin-required') + credential = sys.stdin.buffer.read(8193) + require(len(credential) <= 8192, 'live.credential-input-bound') + private = Path('/run/provider-secret') + private.mkdir(mode=0o700) + descriptor = os.open(private / 'provider.env', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, 'wb') as output: + output.write(credential) + for line in credential.decode().splitlines(): + match = re.fullmatch(r'\s*PERSONAL_ACCESS_TOKEN\s*=\s*(.*?)\s*', line) + if match: + self.token = match[1].strip('"\'') + require(self.token and self.token.startswith('pat'), 'live.missing-pat') + for path, uid, mode in [(self.work, self.uid, 0o700), (self.app, self.app_uid, 0o750), + (Path('/run/sockets'), self.uid, 0o770)]: + path.mkdir(mode=mode) + os.chown(path, uid, self.gid) + recipe = json.loads((self.args.inputs / 'recipe.json').read_bytes()) + for member in ['write', 'observation']: + recipe[member]['path'][1]['value'] = self.args.base + recipe[member]['path'][2]['value'] = self.args.table + (self.work / 'recipe.json').write_bytes(canonical(recipe)) + (self.work / 'profile.lock.json').write_bytes((self.args.inputs / 'profile.lock.json').read_bytes()) + for path in self.work.iterdir(): + os.chown(path, self.uid, self.gid) + review = json.loads(self.run('review', [self.binary, 'review', '--recipe', self.work / 'recipe.json', + '--profile-lock', self.work / 'profile.lock.json'])) + record = self.api('POST', '/v0/' + self.args.base + '/' + self.args.table, + {'fields': {'Name': 'Auths disposable qualification ' + str(uuid.uuid4()), 'DemoStatus': 'Pending'}}) + self.record = record['id'] + require(re.fullmatch(r'rec[A-Za-z0-9]{14}', self.record), 'live.invalid-created-record') + operation = str(uuid.uuid4()) + args = {'operator_namespace': 'airtable-demo', 'operation_id': operation, + 'recipe_digest': review['recipe_digest'], 'record_id': self.record, 'replacement': 'Approved'} + (self.app / 'configuration').write_text(review['verifier_configuration']) + (self.app / 'arguments.json').write_bytes(canonical(args)) + os.chown(self.app / 'arguments.json', self.app_uid, self.gid) + authored = json.loads(self.run('installed-consumer-author', self.child('author'), uid=self.app_uid)) + require(authored['sdk_location'].startswith('/opt/consumer/lib/'), 'live.repository-import') + self.install(self.work / 'first', review['recipe_digest']) + self.install(self.work / 'second', review['recipe_digest'], join=True) + first, second = self.start('first'), self.start('second') + self.run('application-secret-isolation', self.child('isolation'), uid=self.app_uid) + # Distinct processes share the actual durable store. Both receive the + # same signed action; the gateway's durable claim chooses entry. + results = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(self.run, 'two-instance-submit-' + str(index), self.child('submit', sock), + uid=self.app_uid) for index, sock in enumerate([first, second])] + results = [json.loads(future.result()) for future in futures] + require(any(value.get('outcome') == 'observed-by-provider' for value in results), 'live.write-not-observed') + require(all(value.get('outcome') in ['observed-by-provider', 'not-entered'] for value in results), + 'live.race-unexpected-outcome') + observed = next(value for value in results if value.get('outcome') == 'observed-by-provider') + readback = self.api('GET', '/v0/' + self.args.base + '/' + self.args.table + '/' + self.record) + require(readback['fields']['DemoStatus'] == 'Approved' and + readback['fields']['auths_echo'] == observed['evidence']['echo'], 'live.fresh-read-back-mismatch') + replay = json.loads(self.run('proof-replay', self.child('submit', first), uid=self.app_uid)) + require(replay.get('outcome') == 'not-entered' and replay.get('code') == 'gateway.attempt.replay', 'live.replay-not-refused') + altered = json.loads(self.run('altered-action', self.child('altered', first), uid=self.app_uid)) + require(altered.get('outcome') in ['not-entered', 'denied', 'indeterminate'], 'live.altered-action-entered') + support = self.run('support-bundle', [self.binary, 'support-bundle', '--state-dir', self.work / 'first']) + require(json.loads(support)['deployment'] == 'development', 'live.production-claim') + (self.args.out / 'support-bundle.json').write_bytes(support) + self.stop() + restarted = self.start('first') + replay_after = json.loads(self.run('restart-replay', self.child('submit', restarted), uid=self.app_uid)) + require(replay_after.get('outcome') == 'not-entered' and replay_after.get('code') == 'gateway.attempt.replay', + 'live.restart-replay-entered') + self.stop() + report = {'schema': 'auths.recipe-qualification-simulation/1', 'simulation': True, + 'stable_launch_ready': False, 'family': 'airtable-record-update-v1', + 'provider': 'live Airtable Web API v0', 'verification_boundary': 'installed SDK and native application socket', + 'source_commit': self.args.commit, 'gateway_sha256': hashlib.sha256(self.binary.read_bytes()).hexdigest(), + 'compiled_recipe_sha256': review['recipe_digest'], 'sdk_location': authored['sdk_location'], + 'sdk_version': authored['sdk_version'], 'wheel_sha256': hashlib.sha256(self.args.wheel.read_bytes()).hexdigest(), + 'store': 'shared-file-v1', 'custody': 'local-file-v1', 'clock': 'development-host-clock', + 'resources': {'base': self.args.base, 'table': self.args.table, 'record': self.record}, + 'results': {'race': results, 'proof_replay': replay, 'altered_action': altered, 'restart_replay': replay_after, + 'fresh_read_back': {'replacement_matches': True, 'echo_matches': True}}, + 'cases': self.steps, + 'excluded_claims': ['protected production qualification', 'production PostgreSQL/AWS custody', + 'complete qualification corpus', 'independent provider-entry and lease counters', 'token restriction to one base']} + return report + + def cleanup(self): + try: + self.stop() + finally: + if self.record: + result = self.api('DELETE', '/v0/' + self.args.base + '/' + self.args.table + '/' + self.record) + require(result.get('deleted') is True and result.get('id') == self.record, 'live.cleanup-refused') + self.record = None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--consumer', choices=['author', 'submit', 'altered', 'isolation', 'sign']) + parser.add_argument('--consumer-work', type=Path) + parser.add_argument('--consumer-socket', type=Path) + parser.add_argument('--package', type=Path) + parser.add_argument('--inputs', type=Path) + parser.add_argument('--wheel', type=Path) + parser.add_argument('--credential-stdin', action='store_true') + parser.add_argument('--base') + parser.add_argument('--table') + parser.add_argument('--commit') + parser.add_argument('--out', type=Path) + args = parser.parse_args() + try: + if args.consumer: + consumer(args.consumer, args.consumer_work, args.consumer_socket) + return + operator = Operator(args) + def interrupted(_signal, _frame): + raise Refusal('live.interrupted') + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + try: + report = operator.exercise() + finally: + operator.cleanup() + report['cleanup'] = {'created_record_deleted': True, 'table_retained_for_future_rehearsals': True} + data = json.dumps(report, indent=2).encode() + operator.scan(data) + (args.out / 'airtable-record-update-v1.json').write_bytes(data) + # Sign with the installed SDK's existing Ed25519 primitive. No key is + # serialized or given protected qualification authority. + operator.run('sign-published-report', [operator.python, Path(__file__).resolve(), '--consumer', 'sign', + '--consumer-work', args.out], uid=0) + print('Live Airtable rehearsal passed; fresh read-back, replay refusals, isolation, cleanup and report signature verified.') + except Refusal as error: + print(str(error), file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/qualification/simulation/live/stripe-platform/generated.py b/qualification/simulation/live/stripe-platform/generated.py new file mode 100644 index 000000000..106517b8d --- /dev/null +++ b/qualification/simulation/live/stripe-platform/generated.py @@ -0,0 +1,51 @@ +"""Generated by auths; edit profile.toml, then regenerate.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, TypeVar + +from auths.self_hosted import ( + EnumField, + ExactMcpTool, + IntegerField, + StringField, +) + +PROFILE_NAME = "stripe-platform-refund" +PROFILE_VERSION = 1 +TOOL_NAME = "create_refund_v1" +SCHEMA_DIGEST = "d2f5b926a3b6989d13b3a4614372e298442de697d92d5e06330288f7de4e257a" + + +@dataclass(frozen=True) +class CreateRefund: + operator_namespace: Literal["stripe-platform-refunds"] + operation_id: str + recipe_digest: str + payment_intent: str + amount: int + currency: str + + +FIELDS = { + "operator_namespace": EnumField(('stripe-platform-refunds',)), + "operation_id": StringField(min_length=1, max_length=128), + "recipe_digest": StringField(min_length=64, max_length=64), + "payment_intent": StringField(min_length=3, max_length=255), + "amount": IntegerField(minimum=1, maximum=99999999), + "currency": StringField(min_length=3, max_length=3), +} + +CommandT = TypeVar("CommandT") + + +def contract_for(command_type: type[CommandT]) -> ExactMcpTool[CommandT]: + return ExactMcpTool( + service="stripe-platform-refunds", + name=TOOL_NAME, + command_type=command_type, + fields=FIELDS, + ) + + +CONTRACT = contract_for(CreateRefund) diff --git a/qualification/simulation/live/stripe-platform/profile.lock.json b/qualification/simulation/live/stripe-platform/profile.lock.json new file mode 100644 index 000000000..51543b425 --- /dev/null +++ b/qualification/simulation/live/stripe-platform/profile.lock.json @@ -0,0 +1 @@ +{"command_schema":{"fields":{"amount":{"kind":"integer","maximum":99999999,"minimum":1},"currency":{"kind":"string","maximum":3,"minimum":3},"operation_id":{"kind":"string","maximum":128,"minimum":1},"operator_namespace":{"type":"enum","variants":["stripe-platform-refunds"]},"payment_intent":{"kind":"string","maximum":255,"minimum":3},"recipe_digest":{"kind":"string","maximum":64,"minimum":64}},"kind":"object"},"generator_format":2,"profile":"stripe-platform-refund","schema":"auths.self-hosted-profile-lock/1","schema_digest":"d2f5b926a3b6989d13b3a4614372e298442de697d92d5e06330288f7de4e257a","service":"stripe-platform-refunds","tool":"create_refund_v1","version":1} diff --git a/qualification/simulation/live/stripe-platform/profile.toml b/qualification/simulation/live/stripe-platform/profile.toml new file mode 100644 index 000000000..1a1592f09 --- /dev/null +++ b/qualification/simulation/live/stripe-platform/profile.toml @@ -0,0 +1,38 @@ +[profile] +name = "stripe-platform-refund" +version = 1 +service = "stripe-platform-refunds" +tool = "create_refund" +command = "CreateRefund" + +[arguments] +type = "object" + +[arguments.fields.operator_namespace] +type = "enum" +variants = ["stripe-platform-refunds"] + +[arguments.fields.operation_id] +type = "string" +min_bytes = 1 +max_bytes = 128 + +[arguments.fields.recipe_digest] +type = "string" +min_bytes = 64 +max_bytes = 64 + +[arguments.fields.payment_intent] +type = "string" +min_bytes = 3 +max_bytes = 255 + +[arguments.fields.amount] +type = "integer" +minimum = 1 +maximum = 99999999 + +[arguments.fields.currency] +type = "string" +min_bytes = 3 +max_bytes = 3 diff --git a/qualification/simulation/live/stripe-platform/recipe.json b/qualification/simulation/live/stripe-platform/recipe.json new file mode 100644 index 000000000..9f9f90ce1 --- /dev/null +++ b/qualification/simulation/live/stripe-platform/recipe.json @@ -0,0 +1,171 @@ +{ + "schema": "auths.gateway-recipe-source/2", + "profile_schema_digest": "d2f5b926a3b6989d13b3a4614372e298442de697d92d5e06330288f7de4e257a", + "service": "stripe-platform-refunds", + "tool": "create_refund_v1", + "operator_namespace": "stripe-platform-refunds", + "credential": { + "kind": "bearer", + "guard": { + "prefixes": [ + "rk_test_" + ], + "probe": { + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "balance" + } + ], + "json_pointer": "/livemode", + "equals": false, + "maximum_response_bytes": 16384 + }, + "account": { + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "account" + } + ], + "json_pointer": "/id", + "maximum_response_bytes": 65536 + }, + "denied_reads": [ + { + "method": "GET", + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "customers" + } + ], + "refused_status": [ + 403 + ] + }, + { + "method": "GET", + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "payouts" + } + ], + "refused_status": [ + 403 + ] + } + ] + } + }, + "origin": "https://api.stripe.com", + "provider_headers": { + "Stripe-Version": "2025-03-31.basil" + }, + "bounds": { + "sum": { + "argument": "amount", + "partition": "currency" + } + }, + "relative_ceiling": { + "argument": "amount", + "basis_points": 5000, + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "payment_intents" + }, + { + "kind": "field", + "name": "payment_intent" + } + ], + "json_pointer": "/amount_received", + "bind": [ + { + "pointer": "/currency", + "field": "currency" + } + ], + "maximum_response_bytes": 65536 + }, + "write": { + "method": "POST", + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "refunds" + } + ], + "body": { + "kind": "form", + "fields": { + "payment_intent": { + "kind": "field", + "name": "payment_intent" + }, + "amount": { + "kind": "field", + "name": "amount" + } + } + }, + "idempotency": { + "kind": "derived-header", + "retention_seconds": 86400 + } + }, + "observation": { + "path": [ + { + "kind": "fixed", + "value": "v1" + }, + { + "kind": "fixed", + "value": "refunds" + }, + { + "kind": "response-field", + "pointer": "/id", + "max_bytes": 255 + } + ], + "json_pointer": "/amount", + "expected_field": "amount", + "maximum_response_bytes": 16384 + }, + "echo": { + "write": { + "kind": "form-field", + "name": "metadata[auths_echo]" + }, + "observe": "/metadata/auths_echo" + } +} diff --git a/qualification/simulation/live/stripe-platform/vectors.json b/qualification/simulation/live/stripe-platform/vectors.json new file mode 100644 index 000000000..5b75d713e --- /dev/null +++ b/qualification/simulation/live/stripe-platform/vectors.json @@ -0,0 +1 @@ +{"profile":"stripe-platform-refund","schema":"auths.self-hosted-profile-vectors/2","schema_digest":"d2f5b926a3b6989d13b3a4614372e298442de697d92d5e06330288f7de4e257a","service":"stripe-platform-refunds","tool":"create_refund_v1","valid_arguments_json":"{\"amount\":1,\"currency\":\"xxx\",\"operation_id\":\"x\",\"operator_namespace\":\"stripe-platform-refunds\",\"payment_intent\":\"xxx\",\"recipe_digest\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}","version":1} diff --git a/qualification/simulation/live/stripe.py b/qualification/simulation/live/stripe.py new file mode 100644 index 000000000..8cf14c54e --- /dev/null +++ b/qualification/simulation/live/stripe.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Live platform-account refund rehearsal; Connect is outside this tuple.""" +import argparse +import asyncio +import base64 +from dataclasses import asdict +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +class Refusal(Exception): + pass + + +def require(value, code): + if not value: + raise Refusal(code) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode() + + +def consumer(args): + from auths import _native as native + from auths.gateway import GatewayClient, GatewayEndpoint + require('STRIPE_TEST_API_KEY' not in os.environ and 'STRIPE_TEST_RESTRICTED_KEY' not in os.environ + and 'PYTHONPATH' not in os.environ, 'live.consumer-environment') + work = args.consumer_work + if args.consumer == 'author': + plan = json.loads((work / 'plan.json').read_bytes()) + current = int(time.time()) + audience = 'mcp://stripe-platform-refunds' + resource = audience + '/tools/create_refund_v1' + key = native.DevelopmentEd25519Key.generate() + actor = native.Principal(key.principal) + extension = plan['extension'] + ext = [(extension['extension_id'], bytes.fromhex(extension['extension_body_hex']))] + request = native.GrantRequest(actor, 'auths.mcp', 2, [('tools/call', resource)], current - 60, + current + 600, [audience], None, None, 0, None, 'raw-key-baseline', ext) + unsigned = native.root_grant(actor, request) + signing = native.prepare_signing(unsigned, key.principal_method, key.verification_method, key.suite) + grant = signing.complete(key.sign(signing.signing_preimage)) + challenge = native.generate_challenge_v1() + anchor = native.TrustAnchor(actor.value, actor, [key.principal_method], [('auths.mcp', 2)], + [('tools/call', resource)], [audience], [audience], current - 60, current + 600, + None, 1, 'raw-key-baseline', None) + assurance = native.AssurancePolicy('raw-key-baseline', [ + ('root', 'every', 'self-certifying-identifier', None), + ('actor', 'every', 'self-certifying-identifier', None), ('actor', 'every', 'offline-verifiable', None)]) + template = native.compile_trusted_context(bytes.fromhex(plan['configuration']), None, 1, 1, 1, + [anchor], assurance, None, None, 'none-v1', [key.evidence_type], [extension['extension_id']]) + context = template.bind_request(audience, challenge, current) + evidence = (key.evidence_type, key.media_type, key.evidence) + for label, arguments in plan['arguments'].items(): + call = native.mcp_call('stripe-platform-refunds', 'create_refund_v1', canonical(arguments)) + prepared = native.prepare_mcp_call_action(call, actor, grant, challenge, current, 120) + signing = native.prepare_signing(prepared.unsigned, key.principal_method, key.verification_method, key.suite) + action = signing.complete(key.sign(signing.signing_preimage)) + proof, action, trust = native.assemble_mcp_proof(prepared, action, [grant], [[evidence]], [evidence], context) + (work / (label + '.proof')).write_bytes(proof) + (work / (label + '.action')).write_bytes(action) + (work / 'context.cbor').write_bytes(trust) + print(json.dumps({'sdk_version': importlib.metadata.version('auths'), 'sdk_location': __import__('auths').__file__})) + elif args.consumer == 'isolation': + for path in ['/run/provider-secret/provider.env', '/run/operator/state']: + try: + Path(path).read_bytes() if path.endswith('.env') else list(Path(path).iterdir()) + except PermissionError: + continue + raise Refusal('live.application-can-read-secret') + print('{"isolated":true}') + elif args.consumer == 'sign': + key = native.DevelopmentEd25519Key.generate() + report = (work / 'report.json').read_bytes() + enc = lambda data: base64.b64encode(data).rstrip(b'=').decode() + statement = {'schema': 'auths.provider-simulation-attestation/1', 'simulation': True, + 'stable_launch_ready': False, 'signer_kind': 'disposable-self-signed-simulation-key', + 'family': 'stripe-platform-refund-v1', 'report_sha256': hashlib.sha256(report).hexdigest(), + 'public_key_b64': enc(bytes(key.public_key))} + preimage = b'auths.provider-simulation-attestation/1\0' + canonical(statement) + signature = bytes(key.sign(preimage)) + path = work / 'attestation.json' + path.write_bytes(json.dumps({'statement': statement, 'signature_b64': enc(signature)}, indent=2).encode()) + envelope = json.loads(path.read_bytes()) + require(envelope['statement']['report_sha256'] == hashlib.sha256((work / 'report.json').read_bytes()).hexdigest(), + 'live.signature-report-mismatch') + native.verify_ed25519_preimage_v1(base64.b64decode(envelope['statement']['public_key_b64'] + '=='), + b'auths.provider-simulation-attestation/1\0' + canonical(envelope['statement']), + base64.b64decode(envelope['signature_b64'] + '==')) + print('{"persisted_signature_verified":true}') + else: + label = args.case + action = (work / (label + '.action')).read_bytes() + if args.consumer == 'altered': + action += b'\0' + result = asyncio.run(GatewayClient(GatewayEndpoint(args.consumer_socket)).submit( + proof=(work / (label + '.proof')).read_bytes(), action=action)) + print(json.dumps(asdict(result), sort_keys=True)) + + +class Operator: + uid, app_uid, gid = 62001, 62002, 62000 + + def __init__(self, args): + self.args, self.steps = args, [] + self.binary = args.package / 'bin/auths-gateway' + self.python = Path('/opt/consumer/bin/python') + self.env = {'PATH': '/usr/bin:/bin', 'PYTHONNOUSERSITE': '1'} + self.work, self.app = Path('/run/operator'), Path('/run/app') + self.process, self.log, self.payment, self.keys = None, None, None, {} + + def scan(self, data): + for key in self.keys.values(): + raw = key.encode() + require(not any(value in data for value in [raw, raw.hex().encode(), base64.b64encode(raw), + base64.urlsafe_b64encode(raw)]), 'live.secret-exposure') + + def run(self, label, argv, stdin=b'', uid=None): + start = time.monotonic() + result = subprocess.run(list(map(str, argv)), input=stdin, capture_output=True, timeout=60, + env=self.env, cwd='/run', user=self.uid if uid is None else uid, group=self.gid, extra_groups=[]) + self.scan(result.stdout + result.stderr) + require(len(result.stdout) + len(result.stderr) <= 1048576, 'live.output-bound') + if result.returncode != 0: + codes = re.findall(rb'(?:live|gateway)\.[a-z0-9.-]+', result.stderr) + raise Refusal('live.command-refused:' + label + (':' + codes[-1].decode() if codes else '')) + self.steps.append({'case': label, 'seconds': round(time.monotonic() - start, 3)}) + return result.stdout + + def child(self, verb, label='normal', work=None): + return [self.python, Path(__file__).resolve(), '--consumer', verb, '--case', label, + '--consumer-work', work or self.app, '--consumer-socket', '/run/sockets/app.sock'] + + def api(self, method, path, fields=None, restricted=False, expected=200): + require(path.startswith('/v1/'), 'live.invalid-provider-path') + key = self.keys['STRIPE_TEST_RESTRICTED_KEY' if restricted else 'STRIPE_TEST_API_KEY'] + data = None if fields is None else urllib.parse.urlencode(fields).encode() + request = urllib.request.Request('https://api.stripe.com' + path, data=data, method=method, + headers={'Authorization': 'Bearer ' + key, 'Stripe-Version': '2025-03-31.basil'}) + try: + response = urllib.request.urlopen(request, timeout=25) + except urllib.error.HTTPError as error: + require(error.code == expected, 'live.stripe-http-' + str(error.code)) + error.close() + return {'http_status': expected} + with response: + require(response.status == expected, 'live.unexpected-provider-status') + body = response.read(65537) + require(len(body) <= 65536, 'live.provider-response-bound') + return json.loads(body) + + def start(self): + self.log = tempfile.TemporaryFile() + self.process = subprocess.Popen([str(self.binary), 'serve', '--state-dir', str(self.work / 'state'), + '--app-socket', '/run/sockets/app.sock'], stdout=subprocess.PIPE, stderr=self.log, + env=self.env, cwd='/run', user=self.uid, group=self.gid, extra_groups=[]) + with selectors.DefaultSelector() as selector: + selector.register(self.process.stdout, selectors.EVENT_READ) + require(bool(selector.select(15)), 'live.gateway-start-timeout') + require(self.process.stdout.readline(65537).startswith(b'app socket ready'), 'live.gateway-start-refused') + + def stop(self): + if self.process: + if self.process.poll() is None: + self.process.send_signal(signal.SIGTERM) + try: + self.process.wait(timeout=30) + except subprocess.TimeoutExpired: + self.process.kill(); self.process.wait() + raise Refusal('live.gateway-stop-timeout') + self.log.seek(0); self.scan(self.log.read(1048577)) + require(self.process.returncode == 0, 'live.gateway-stop-refused') + self.process.stdout.close(); self.log.close() + self.process = None + + def exercise(self): + require(os.getuid() == 0 and not self.args.out.exists(), 'live.private-container-required') + self.args.out.mkdir(parents=True) + require(self.args.credential_stdin and not sys.stdin.isatty(), 'live.credential-stdin-required') + raw = sys.stdin.buffer.read(8193) + require(len(raw) <= 8192, 'live.credential-input-bound') + private = Path('/run/provider-secret'); private.mkdir(mode=0o700) + with os.fdopen(os.open(private / 'provider.env', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as output: + output.write(raw) + for line in raw.decode().splitlines(): + match = re.fullmatch(r'\s*(STRIPE_TEST_API_KEY|STRIPE_TEST_RESTRICTED_KEY)\s*=\s*(.*?)\s*', line) + if match: self.keys[match[1]] = match[2].strip('"\'') + require(self.keys.get('STRIPE_TEST_API_KEY', '').startswith('sk_test_') and + self.keys.get('STRIPE_TEST_RESTRICTED_KEY', '').startswith('rk_test_'), 'live.test-keys-required') + manifest = json.loads((self.args.package / 'manifest.json').read_bytes()) + require(manifest['source_commit'] == self.args.commit, 'live.candidate-commit-mismatch') + for entry in manifest['files']: + path = self.args.package / entry['path'] + require(not path.is_symlink() and hashlib.sha256(path.read_bytes()).hexdigest() == entry['sha256'], 'live.package-hash-mismatch') + platform = self.api('GET', '/v1/account', restricted=True)['id'] + require(self.api('GET', '/v1/balance', restricted=True)['livemode'] is False, 'live.not-test-mode') + for path in ['/v1/customers', '/v1/payouts']: + self.api('GET', path, restricted=True, expected=403) + payment = self.api('POST', '/v1/payment_intents', {'amount': 2000, 'currency': 'usd', + 'payment_method': 'pm_card_visa', 'confirm': 'true', 'automatic_payment_methods[enabled]': 'true', + 'automatic_payment_methods[allow_redirects]': 'never', 'metadata[auths_simulation]': 'disposable-platform-refund'}) + self.payment = payment['id'] + require(payment['livemode'] is False and payment['status'] == 'succeeded', 'live.fixture-payment-not-ready') + for path, uid, mode in [(self.work, self.uid, 0o700), (self.app, self.app_uid, 0o750), + (Path('/run/sockets'), self.uid, 0o770)]: + path.mkdir(mode=mode); os.chown(path, uid, self.gid) + for name in ['recipe.json', 'profile.lock.json']: + (self.work / name).write_bytes((self.args.inputs / name).read_bytes()) + os.chown(self.work / name, self.uid, self.gid) + recipe = json.loads((self.work / 'recipe.json').read_bytes()) + require('account_scope' not in recipe, 'live.connect-scope-outside-contract') + review = json.loads(self.run('review', [self.binary, 'review', '--recipe', self.work / 'recipe.json', '--profile-lock', self.work / 'profile.lock.json'])) + extension = json.loads(self.run('bound-extension', [self.binary, 'bound-extension', '--argument', 'amount', + '--ceiling', '10000', '--window-seconds', '86400', '--max-count', '10', '--sum-limit', '10000', '--partition', 'currency=usd'])) + operation = str(uuid.uuid4()) + normal = {'operator_namespace': 'stripe-platform-refunds', 'operation_id': operation, + 'recipe_digest': review['recipe_digest'], 'payment_intent': self.payment, 'amount': 500, 'currency': 'usd'} + arguments = {'normal': normal, 'above-ratio': {**normal, 'operation_id': operation + '-ratio', 'amount': 1001}, + 'above-grant': {**normal, 'operation_id': operation + '-grant', 'amount': 10001}, + 'wrong-currency': {**normal, 'operation_id': operation + '-currency', 'currency': 'eur'}} + (self.app / 'plan.json').write_bytes(canonical({'configuration': review['verifier_configuration'], + 'extension': extension, 'arguments': arguments})) + os.chown(self.app / 'plan.json', self.app_uid, self.gid) + sdk = json.loads(self.run('installed-consumer-author', self.child('author'), uid=self.app_uid)) + require(sdk['sdk_location'].startswith('/opt/consumer/lib/'), 'live.repository-import') + self.run('install', [self.binary, 'install', '--state-dir', self.work / 'state', '--recipe', self.work / 'recipe.json', + '--profile-lock', self.work / 'profile.lock.json', '--trusted-context', self.app / 'context.cbor', + '--approve-digest', review['recipe_digest'], '--provider', 'stripe', '--alias', 'platform-rehearsal', + '--account-label', platform, '--credential-stdin'], stdin=(self.keys['STRIPE_TEST_RESTRICTED_KEY'] + '\n').encode()) + self.start() + self.run('application-secret-isolation', self.child('isolation'), uid=self.app_uid) + results = {} + for label in ['above-grant', 'above-ratio', 'wrong-currency', 'normal']: + results[label] = json.loads(self.run(label, self.child('submit', label), uid=self.app_uid)) + require(results['above-grant'].get('code') == 'gateway.policy.above-ceiling', 'live.grant-ceiling-not-refused') + require(results['above-ratio'].get('code') == 'gateway.relative-ceiling.above', 'live.ratio-not-refused') + require(results['wrong-currency'].get('outcome') == 'not-entered', 'live.currency-not-refused') + require(results['normal'].get('outcome') == 'observed-by-provider', 'live.refund-not-observed') + echo = results['normal']['evidence']['echo'] + refunds = self.api('GET', '/v1/refunds?' + urllib.parse.urlencode({'payment_intent': self.payment, 'limit': 10}))['data'] + matched = [refund for refund in refunds if refund['amount'] == 500 and refund['metadata'].get('auths_echo') == echo] + require(len(refunds) == 1 and len(matched) == 1 and matched[0]['payment_intent'] == self.payment, + 'live.fresh-refund-read-back-mismatch') + results['proof-replay'] = json.loads(self.run('proof-replay', self.child('submit'), uid=self.app_uid)) + require(results['proof-replay'].get('code') == 'gateway.attempt.replay', 'live.replay-entered') + results['altered-action'] = json.loads(self.run('altered-action', self.child('altered'), uid=self.app_uid)) + require(results['altered-action'].get('outcome') in ['denied', 'indeterminate', 'not-entered'], 'live.altered-action-entered') + support = self.run('support-bundle', [self.binary, 'support-bundle', '--state-dir', self.work / 'state']) + require(json.loads(support)['deployment'] == 'development', 'live.production-claim') + (self.args.out / 'support-bundle.json').write_bytes(support) + self.stop(); self.start() + results['restart-replay'] = json.loads(self.run('restart-replay', self.child('submit'), uid=self.app_uid)) + require(results['restart-replay'].get('code') == 'gateway.attempt.replay', 'live.restart-replay-entered') + self.stop() + return {'schema': 'auths.recipe-qualification-simulation/1', 'simulation': True, 'stable_launch_ready': False, + 'family': 'stripe-platform-refund-v1', 'provider': 'live Stripe test mode; platform account', + 'source_commit': self.args.commit, 'compiled_recipe_sha256': review['recipe_digest'], + 'gateway_sha256': hashlib.sha256(self.binary.read_bytes()).hexdigest(), + 'wheel_sha256': hashlib.sha256(self.args.wheel.read_bytes()).hexdigest(), **sdk, + 'resources': {'platform_account': platform, 'payment_intent': self.payment, 'refund': matched[0]['id']}, + 'store': 'shared-file-v1', 'custody': 'local-file-v1', 'clock': 'development-host-clock', + 'verification_boundary': 'installed SDK and native application socket', 'cases': self.steps, 'results': results, + 'fresh_read_back': {'refund_count_before_cleanup': len(refunds), 'amount_matches': True, 'echo_matches': True}, + 'excluded_claims': ['Connect/connected-account scope', 'protected production qualification', + 'production PostgreSQL/AWS custody', 'complete qualification corpus', 'independent lease/provider-entry counters']} + + def cleanup(self): + try: + self.stop() + finally: + if self.payment: + # Test setup/teardown uses the operator test key, never the app. + refunds = self.api('GET', '/v1/refunds?' + urllib.parse.urlencode({'payment_intent': self.payment, 'limit': 100}))['data'] + total = sum(refund['amount'] for refund in refunds) + if total < 2000: + refund = self.api('POST', '/v1/refunds', {'payment_intent': self.payment, 'amount': 2000 - total, + 'metadata[auths_simulation_cleanup]': 'true'}) + require(refund['status'] == 'succeeded' and refund['payment_intent'] == self.payment, 'live.cleanup-refund-refused') + payment = self.api('GET', '/v1/payment_intents/' + self.payment) + charge = self.api('GET', '/v1/charges/' + payment['latest_charge']) + require(payment['livemode'] is False and charge['livemode'] is False and + charge['refunded'] is True and charge['amount_refunded'] == 2000, 'live.cleanup-read-back-mismatch') + self.payment = None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--consumer', choices=['author', 'submit', 'altered', 'isolation', 'sign']) + parser.add_argument('--case', default='normal') + parser.add_argument('--consumer-work', type=Path) + parser.add_argument('--consumer-socket', type=Path) + for name in ['package', 'inputs', 'wheel', 'out']: + parser.add_argument('--' + name, type=Path) + parser.add_argument('--commit') + parser.add_argument('--credential-stdin', action='store_true') + args = parser.parse_args() + try: + if args.consumer: + consumer(args); return + operator = Operator(args) + def interrupted(_signal, _frame): + raise Refusal('live.interrupted') + signal.signal(signal.SIGTERM, interrupted); signal.signal(signal.SIGINT, interrupted) + try: + report = operator.exercise() + finally: + operator.cleanup() + report['cleanup'] = {'test_payment_fully_refunded': True, 'stripe_retains_test_payment_and_refund_records': True} + data = json.dumps(report, indent=2).encode(); operator.scan(data) + (args.out / 'report.json').write_bytes(data) + operator.run('sign-published-report', operator.child('sign', work=args.out), uid=0) + print('Live platform Stripe refund rehearsal passed; limits, read-back, replay, isolation, cleanup and signature verified.') + except Refusal as error: + print(str(error), file=sys.stderr); sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/qualification/simulation/pack.py b/qualification/simulation/pack.py new file mode 100644 index 000000000..e59bdf3c4 --- /dev/null +++ b/qualification/simulation/pack.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Package the native simulation harness selected from Cargo's artifact report.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-report", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + executables = [] + with args.build_report.open() as lines: + for line in lines: + if len(line) > 1_048_576: + parser.error("oversized build report line") + artifact = json.loads(line) + if (artifact.get("reason") == "compiler-artifact" + and artifact.get("profile", {}).get("test") is True + and artifact.get("target", {}).get("kind") == ["lib"] + and artifact.get("executable")): + executables.append(Path(artifact["executable"])) + if len(executables) != 1 or not executables[0].is_file(): + parser.error("build report must select exactly one native test harness") + if args.out.exists() and any(args.out.iterdir()): + parser.error("candidate kit directory must be empty") + args.out.mkdir(parents=True, exist_ok=True) + executable = args.out / "qualification-harness" + shutil.copyfile(executables[0], executable) + executable.chmod(0o755) + with executable.open("rb") as stream: + hasher = hashlib.sha256() + for chunk in iter(lambda: stream.read(131_072), b""): + hasher.update(chunk) + digest = hasher.hexdigest() + repository = Path(__file__).resolve().parents[2] + if subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=repository): + parser.error("candidate kit requires committed source") + commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + (args.out / "candidate.json").write_text(json.dumps({ + "schema": "auths.qualification-simulation-candidate/1", "simulation": True, + "source_commit": commit, "harness_sha256": digest, + "features": ["loopback-provider"], "production_gateway": False, + }, indent=2) + "\n") + shutil.copyfile(repository / "qualification/simulation/run.py", args.out / "run.py") + + +if __name__ == "__main__": + main() diff --git a/qualification/simulation/run.py b/qualification/simulation/run.py new file mode 100644 index 000000000..a6ccbf945 --- /dev/null +++ b/qualification/simulation/run.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Run native qualification rehearsals; publish public simulation artifacts only.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +REPORTS = ( + "stripe-platform-refund-v1.json", + "airtable-record-update-v1.json", + "bootstrap/simulation.json", + "provider-signature-verification.json", +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cargo", default="cargo") + parser.add_argument("--candidate-kit", type=Path, + help="run the downloaded native harness without a checkout or Rust") + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + repository = None if args.candidate_kit else Path(__file__).resolve().parents[2] + output = args.out.resolve() + # Public rehearsal outputs must never enter the production trust or family + # directories that the protected signing workflow consumes. + if repository is not None and (output == repository or any( + output.is_relative_to(repository / "qualification" / name) + for name in ("trust", "families") + )): + parser.error("simulation output overlaps production inputs") + output.mkdir(parents=True, exist_ok=True) + if any(output.iterdir()): + parser.error("use a new empty simulation output directory") + if args.candidate_kit: + kit = args.candidate_kit.resolve() + metadata_file = kit / "candidate.json" + if metadata_file.is_symlink() or metadata_file.stat().st_size > 4096: + parser.error("invalid candidate metadata") + metadata = json.loads(metadata_file.read_bytes()) + source_commit = metadata.get("source_commit") + if (metadata.get("schema") != "auths.qualification-simulation-candidate/1" + or metadata.get("simulation") is not True + or metadata.get("production_gateway") is not False + or metadata.get("features") != ["loopback-provider"] + or not isinstance(source_commit, str) + or not re.fullmatch(r"[0-9a-f]{40}", source_commit)): + parser.error("invalid simulation candidate") + harness = kit / "qualification-harness" + if harness.is_symlink() or not harness.is_file() or harness.stat().st_size > 536_870_912: + parser.error("invalid native harness") + with harness.open("rb") as stream: + digest = hashlib.sha256() + for chunk in iter(lambda: stream.read(131_072), b""): + digest.update(chunk) + harness_sha256 = digest.hexdigest() + if harness_sha256 != metadata.get("harness_sha256"): + parser.error("candidate harness digest mismatch") + source_dirty = False + command = [str(harness), "qualification_simulation", "--nocapture"] + verification = [str(harness), "qualification_tests::verify_simulation_reports", "--exact", "--nocapture"] + working_directory = output + else: + source_commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repository, text=True + ).strip() + source_dirty = bool(subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=no"], cwd=repository + )) + harness_sha256 = None + command = [args.cargo, "test", "--locked", "-p", "auths-gateway", + "--features", "loopback-provider", "--lib", "qualification_simulation", "--", "--nocapture"] + verification = command[:command.index("qualification_simulation")] + [ + "qualification_tests::verify_simulation_reports", "--", "--exact", "--nocapture"] + working_directory = repository + # Provider and signing credentials are not needed and do not reach tests. + environment = { + key: value for key, value in os.environ.items() + if key in {"PATH", "HOME", "CARGO_HOME", "RUSTUP_HOME", "CARGO_TARGET_DIR", "TMPDIR"} + } + environment["AUTHS_QUALIFICATION_SIMULATION_OUTPUT"] = str(output) + started = time.monotonic() + with tempfile.TemporaryFile() as log: + for stage in (command, verification): + result = subprocess.run(stage, cwd=working_directory, env=environment, stdout=log, stderr=subprocess.STDOUT, + timeout=1800, check=False) + if result.returncode: + log.seek(0, os.SEEK_END) + log.seek(max(0, log.tell() - 8192)) + raise SystemExit(log.read().decode("utf-8", errors="replace")) + summaries = [] + for name in REPORTS: + path = output / name + if path.is_symlink() or path.stat().st_size > 1_048_576: + raise SystemExit("invalid simulation report") + report = json.loads(path.read_bytes()) + if report.get("simulation") is not True or report.get("stable_launch_ready") is not False: + raise SystemExit("a rehearsal cannot publish production readiness") + summaries.append(report) + # The bootstrap artifacts are synthetic trust-machine fixtures. Their + # own signed records explicitly exclude every provider-run claim. + for path in sorted((output / "bootstrap").glob("record-*.json")): + record = json.loads(path.read_bytes()) + if record["excluded_claims"] != ["everything: this record stands for no run"]: + raise SystemExit("bootstrap fixture scope changed") + # The second native stage re-reads and cryptographically verifies these + # exact files after both family runs complete. They are detached simulation + # signatures, with no production root or protected-run authority. + for family in ("stripe-platform-refund-v1", "airtable-record-update-v1"): + signature = output / f"{family}.attestation.json" + if signature.is_symlink() or signature.stat().st_size > 4096: + raise SystemExit("invalid simulation signature") + public_members = [] + for path in sorted(output.rglob("*.json")): + if path.is_symlink() or path.stat().st_size > 1_048_576: + raise SystemExit("invalid public artifact") + public_members.append({"path": str(path.relative_to(output)), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}) + manifest = { + "schema": "auths.qualification-simulation-run/1", + "simulation": True, "stable_launch_ready": False, + "source_commit": source_commit, "source_dirty": source_dirty, + "harness_sha256": harness_sha256, + "source_free": bool(args.candidate_kit), + "seconds": round(time.monotonic() - started, 3), + "families": [summary.get("family") for summary in summaries[:2]], + "provider_case_count": sum(len(summary["cases"]) for summary in summaries[:2]), + "public_artifacts": public_members, + "provider_reports": "detached simulation signatures verified from published bytes", + "keys": "generated in memory and zeroized; no private key files", + "excluded_claims": ["protected live evidence", "production qualification", "human acceptance"], + } + (output / "run.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps({"simulation": True, "provider_cases": manifest["provider_case_count"], + "families": manifest["families"], "seconds": manifest["seconds"], "out": str(output)})) + + +if __name__ == "__main__": + main() diff --git a/qualification/trust/ceremony.json b/qualification/trust/ceremony.json new file mode 100644 index 000000000..c800234c2 --- /dev/null +++ b/qualification/trust/ceremony.json @@ -0,0 +1,16 @@ +{ + "schema": "auths.qualification-root-ceremony/1", + "created_at": "2026-10-07T03:01:16+00:00", + "assessment": "repository-owner-delegated-technical-assessment", + "operator": "Codex acting under the repository owner\u2019s standing authorization", + "scope": "Offline root ceremony and purpose-separated public signer certificates only", + "private_key_retention": "Owner-private directory outside every Git checkout; no root key in CI", + "qualification_issued": false, + "stable_launch_ready": false, + "public_artifacts": { + "qualification-trust-root.json": "ff4768642d4141eab6c1d8bdec78b5c2aa64aad516de51611b3eb17373204ed8", + "signer-certificate.json": "50e6657008b1e060b8e10ea1747105abf8fa3528d51fb97d027044e57446dbb9", + "commissioning-signer-certificate.json": "639fa51c6b4493b52aad880f9c8df8d4f5b58fceac53aef56f4a891e7ff82bf7", + "revocation-list.json": "c8b34e4cab39c768c40a284c9c8ffd0370f8360d0c715a6b1e0989bb2a990d33" + } +} diff --git a/qualification/trust/commissioning-signer-certificate.json b/qualification/trust/commissioning-signer-certificate.json new file mode 100644 index 000000000..8b4cd8172 --- /dev/null +++ b/qualification/trust/commissioning-signer-certificate.json @@ -0,0 +1 @@ +{"root_signature_b64":"1PVq4vS88l4h4WKOjELN8TjuKPLQIKSHqT-VHl86xQVH4yqwHhNL96R-t-N1Qi2-KioUAEbNoDudOgdd821RDg","statement":{"issued_at":1791342076,"not_after":1793934076,"not_before":1791342076,"permitted_artifact_kinds":["qualification-commissioning-permit"],"public_key_b64":"VRExEtVXOyEedChisGDuMi54eVsibhXuQOZ5aEcWD5I","root_id":"auths-qualification-root-2026-10","schema":"auths.qualification-signer-certificate/1","signature_suite":"ed25519-v1","signer_id":"auths-commissioner-2026-10","signer_kind":"protected-software-release-key-v1"}} \ No newline at end of file diff --git a/qualification/trust/qualification-trust-root.json b/qualification/trust/qualification-trust-root.json new file mode 100644 index 000000000..f1184d434 --- /dev/null +++ b/qualification/trust/qualification-trust-root.json @@ -0,0 +1 @@ +{"public_key_b64":"pVEllMJVwyOSnakYUAekqDemY0xzc21a9gSB7m4lBsc","root_id":"auths-qualification-root-2026-10","schema":"auths.qualification-trust-root/1","signature_suite":"ed25519-v1"} \ No newline at end of file diff --git a/qualification/trust/revocation-list.json b/qualification/trust/revocation-list.json new file mode 100644 index 000000000..83a7e8085 --- /dev/null +++ b/qualification/trust/revocation-list.json @@ -0,0 +1 @@ +{"root_signature_b64":"4gvszV4cHNILhMPi2NxDbtBrhRIf-lQQGt_YPzj5Ae4Av5ec5AuVUCXDPNcja_9MTogd_QPBdFXvPkb-VfgaAw","statement":{"issued_at":1791342076,"next_update":1791428476,"revoked_qualifications":[],"revoked_signers":[],"root_id":"auths-qualification-root-2026-10","schema":"auths.qualification-revocation-list/1","sequence":1}} \ No newline at end of file diff --git a/qualification/trust/signer-certificate.json b/qualification/trust/signer-certificate.json new file mode 100644 index 000000000..bb28c0123 --- /dev/null +++ b/qualification/trust/signer-certificate.json @@ -0,0 +1 @@ +{"root_signature_b64":"atJnLOjTzDXyZ_GWrZfRdKj_uhnDdV8Y77nNSQYtwNQeljUrsq45C-cSRrqPgASmN4XwDiDKMQXXhJSN1OvhAg","statement":{"issued_at":1791342076,"not_after":1806894076,"not_before":1791342076,"permitted_artifact_kinds":["qualification-release-index","recipe-qualification-attestation"],"public_key_b64":"Sg-H5x4f4DNtKM5UN_L6nAE7Z9sEFwnc3eIoRAp1A4Q","root_id":"auths-qualification-root-2026-10","schema":"auths.qualification-signer-certificate/1","signature_suite":"ed25519-v1","signer_id":"auths-release-signer-2026-10","signer_kind":"protected-software-release-key-v1"}} \ No newline at end of file diff --git a/release/release-manifest.contract-fixture.json b/release/release-manifest.contract-fixture.json index 843389df2..3d0ab2643 100644 --- a/release/release-manifest.contract-fixture.json +++ b/release/release-manifest.contract-fixture.json @@ -59,6 +59,10 @@ { "path": "evidence/conformance.json", "sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "path": "target/release-evidence/launch-readiness.json", + "sha256": "6666666666666666666666666666666666666666666666666666666666666666" } ], "benchmarks": [ diff --git a/release/release-manifest.schema.json b/release/release-manifest.schema.json index 00ea02223..c8d4dadca 100644 --- a/release/release-manifest.schema.json +++ b/release/release-manifest.schema.json @@ -66,7 +66,16 @@ "spdx": { "$ref": "#/$defs/nonEmptyDigestReferences" }, "provenance": { "$ref": "#/$defs/nonEmptyDigestReferences" }, "formalManifest": { "$ref": "#/$defs/digestReference" }, - "conformance": { "$ref": "#/$defs/nonEmptyDigestReferences" }, + "conformance": { + "allOf": [{ "$ref": "#/$defs/nonEmptyDigestReferences" }], + "contains": { + "type": "object", + "required": ["path", "sha256"], + "properties": { "path": { "const": "target/release-evidence/launch-readiness.json" } } + }, + "minContains": 1, + "maxContains": 1 + }, "benchmarks": { "$ref": "#/$defs/nonEmptyDigestReferences" }, "releaseNotes": { "$ref": "#/$defs/digestReference" } }, diff --git a/release/semantic-freeze-versions.toml b/release/semantic-freeze-versions.toml index e7c30e343..dbe88c8b7 100644 --- a/release/semantic-freeze-versions.toml +++ b/release/semantic-freeze-versions.toml @@ -1,4 +1,4 @@ -freeze_version = 369 +freeze_version = 383 # Semantic identity counters live outside the xtask source tree deliberately. # The formal source closure binds xtask's executable code, while the semantic @@ -7,7 +7,7 @@ freeze_version = 369 # converge. This file is a reviewed release input, never an automatic output. [entries] "auths.core.protocol" = 36 -"auths.frozen-bytes/architecture/dependency-graph.json" = 78 +"auths.frozen-bytes/architecture/dependency-graph.json" = 79 "auths.frozen-bytes/bindings/wasm/auths-proof-wasm/identity-abi-v1.json" = 4 "auths.frozen-bytes/bounded-domains.toml" = 2 "auths.frozen-bytes/core/conformance/v1/manifest.json" = 2 @@ -16,10 +16,10 @@ freeze_version = 369 "auths.frozen-bytes/core/fixtures/v1/manifest.json" = 15 "auths.frozen-bytes/core/formal-vectors/v1/manifest.json" = 1 "auths.frozen-bytes/demos/benchmarks/profiles/release.toml" = 1 -"auths.frozen-bytes/formal/assurance-manifest-v1.toml" = 58 +"auths.frozen-bytes/formal/assurance-manifest-v1.toml" = 59 "auths.frozen-bytes/formal/qualification/aeneas/generated" = 19 "auths.frozen-bytes/formal/qualification/aeneas/qualification.toml" = 16 -"auths.frozen-bytes/formal/qualification/aeneas/source-closure.json" = 97 +"auths.frozen-bytes/formal/qualification/aeneas/source-closure.json" = 99 "auths.frozen-bytes/product/conformance/v1/mechanism-profile-conformance.json" = 1 "auths.frozen-bytes/product/conformance/v1/simplified-product-waist.json" = 4 "auths.frozen-bytes/product/fixtures/v1/bounded-policy/manifest.json" = 3 @@ -45,7 +45,7 @@ freeze_version = 369 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-create/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-modify/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/v1/manifest.sha256.json" = 1 -"auths.identity.protocol" = 123 +"auths.identity.protocol" = 133 "auths.modular-components" = 20 "auths.portable-abi-bindings" = 121 "auths.product.bounded-domains" = 19 @@ -56,15 +56,15 @@ freeze_version = 369 "auths.product.external-custody" = 5 "auths.product.facade" = 26 "auths.product.github-issue-address-v2" = 4 -"auths.product.lifecycle" = 15 +"auths.product.lifecycle" = 16 "auths.product.mcp-closed-execution" = 31 "auths.product.mechanism-profile-conformance" = 12 "auths.product.operations" = 5 -"auths.product.public-sdk-contract" = 120 +"auths.product.public-sdk-contract" = 130 "auths.product.receipts" = 13 "auths.product.release-assurance" = 7 "auths.product.simplified-waist" = 19 "auths.product.vocabulary" = 24 "auths.release.benchmark-contract" = 1 "auths.release.evolution-contract" = 79 -"auths.release.public-surface" = 369 +"auths.release.public-surface" = 383 diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index ac0a07006..aed4faf3c 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 369, + "freezeVersion": 383, "publicSurface": { "rustRoots": [ "auths", @@ -94,7 +94,7 @@ }, { "id": "auths.frozen-bytes/architecture/dependency-graph.json", - "version": 78, + "version": 79, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -102,7 +102,7 @@ "owners": [ "architecture/dependency-graph.json" ], - "sha256": "9f16afa44d9d1220fdc7d7d219ec114b444978b8bb9288242ddd62b376f2f6c4" + "sha256": "83fed3f0d16736b93df13412e43e500f91840effd660aa17fb07ad79974c3d8f" }, { "id": "auths.frozen-bytes/bindings/wasm/auths-proof-wasm/identity-abi-v1.json", @@ -202,7 +202,7 @@ }, { "id": "auths.frozen-bytes/formal/assurance-manifest-v1.toml", - "version": 58, + "version": 59, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -210,7 +210,7 @@ "owners": [ "formal/assurance-manifest-v1.toml" ], - "sha256": "cc87f4480cc9eb559c810eba8534b844d05616e7e74c8732b39b5315616c65c3" + "sha256": "5f68f8b25a0294042ccfa121a2a13fbfcb5d3200f31bc55995c05f538d216cae" }, { "id": "auths.frozen-bytes/formal/qualification/aeneas/generated", @@ -238,7 +238,7 @@ }, { "id": "auths.frozen-bytes/formal/qualification/aeneas/source-closure.json", - "version": 97, + "version": 99, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -246,7 +246,7 @@ "owners": [ "formal/qualification/aeneas/source-closure.json" ], - "sha256": "32b4ba2c0a643b402d2109abc0b4dbe125827a20d4760911654e078dcfe02d29" + "sha256": "e827f63ea742ae566103c0c06aec972ca2e86a6db250484f6e2554c887092091" }, { "id": "auths.frozen-bytes/product/conformance/v1/mechanism-profile-conformance.json", @@ -550,7 +550,7 @@ }, { "id": "auths.identity.protocol", - "version": 123, + "version": 133, "classification": "frozen-meaning", "categories": [ "identity-protocol-versions", @@ -573,7 +573,7 @@ "core/fixtures/identity/v1/vectors.json", "core/spec/identity/v1" ], - "sha256": "c2496ff9803989ecb37dc36cf9c5cb7c1def8fa70789688645bff82dee578953" + "sha256": "9a803b61de925ce4ea7e91903b29fae1461f61f1a2e2a374a82b7e5cc9e0ea31" }, { "id": "auths.modular-components", @@ -784,7 +784,7 @@ }, { "id": "auths.product.lifecycle", - "version": 15, + "version": 16, "classification": "frozen-meaning", "categories": [ "reservation-state", @@ -796,12 +796,12 @@ "owners": [ "product/fixtures/v1/lifecycle/registry.toml", "product/runtime/auths-lifecycle/src", - "product/stores/auths-stores/migrations/postgres_lifecycle_v5.sql", + "product/stores/auths-stores/migrations/postgres_lifecycle_v6.sql", "product/stores/auths-stores/src/lifecycle.rs", "product/stores/auths-stores/tests/postgres_lifecycle.rs", "product/stores/auths-stores/tests/postgres_tls" ], - "sha256": "b464afa30111263a30a0c25dea4ca93098efab2dc13adf8ced2debe565f70cdd" + "sha256": "c331eb406c523401d940e8c67fce96200465720920fc74196b2cdbfa11b345d0" }, { "id": "auths.product.mcp-closed-execution", @@ -871,7 +871,7 @@ }, { "id": "auths.product.public-sdk-contract", - "version": 120, + "version": 130, "classification": "frozen-meaning", "categories": [ "rust-sdk-contract", @@ -889,7 +889,7 @@ "product/runtime/auths-runtime/src", "product/sdk/auths-sdk/src" ], - "sha256": "6fff68d41c5a1a2b40986b62a3310ff05086b68ad8fd23cbcccb1d37f24b2de4" + "sha256": "42d4e8dfbfc1b1639a093222cefefc854ee7a7dcbab2f2d4ed8c7bdce02f2d67" }, { "id": "auths.product.receipts", @@ -1011,7 +1011,7 @@ }, { "id": "auths.release.public-surface", - "version": 369, + "version": 383, "classification": "release-metadata", "categories": [ "package-names", @@ -1101,9 +1101,10 @@ "xtask/src/public_naming.rs", "xtask/src/release.rs", "xtask/src/release_control.rs", + "xtask/src/release_launch.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "2ec62100d0f39cb3cd4c6786286d1de77c279aefbb34bdce730867e764708039" + "sha256": "9e0d71e666dd24c0957b266ac430882dac80a36c117b8c1f70c35e6f7a1548a5" } ] } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index a90b553f4..911bd2ce3 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -30,12 +30,14 @@ auths-ci-plan = { path = "ci-plan", version = "1.0.0-rc.1" } auths-lab-matrix.workspace = true auths-lifecycle.workspace = true auths-github = { workspace = true, features = ["fixture-support"] } +auths-gateway.workspace = true auths-kubernetes.workspace = true auths-opentofu.workspace = true auths-postgresql.workspace = true auths-proof-exchange-testkit.workspace = true auths-radicle = { workspace = true, features = ["fixture-support"] } auths-records-api.workspace = true +auths-recipe-qualification.workspace = true auths-stripe = { workspace = true, features = ["fixture-support"] } base64ct.workspace = true ed25519-dalek.workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e6c26a436..5a4b0d94d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -26,6 +26,7 @@ mod product_waist; mod public_naming; mod release; mod release_control; +mod release_launch; mod sdk_experience; mod sdk_vocabulary; mod semantic_freeze; diff --git a/xtask/src/release.rs b/xtask/src/release.rs index fb702b627..72d18b381 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -297,7 +297,13 @@ pub(crate) fn release_evidence() -> Result<(), String> { let platform_path = evidence.join("platform.json"); platform_artifact(&platform_path)?; let mut evidence_checksums = BTreeMap::new(); + let launch_path = evidence.join("launch-readiness.json"); + let launch = release_launch::projection(&root(), &commit)?; + fs::write(&launch_path, pretty_json(&launch, "launch readiness")?) + .map_err(|error| format!("could not write launch projection: {error}"))?; + println!("stable_launch_ready: {}", launch["stable_launch_ready"]); for relative in [ + "target/release-evidence/launch-readiness.json", "target/release-evidence/platform.json", "target/release-evidence/platform.sha256", "target/compliance/inventory.json", @@ -1527,6 +1533,16 @@ pub(crate) fn validate_release_manifest_value(manifest: &Value) -> Result<(), St validate_digest_reference(reference)?; } } + if evidence["conformance"] + .as_array() + .ok_or("release manifest evidence has no conformance array")? + .iter() + .filter(|reference| reference["path"] == release_launch::REPORT_PATH) + .count() + != 1 + { + return Err("release manifest must bind exactly one launch projection".to_owned()); + } validate_digest_reference(&evidence["formalManifest"])?; validate_digest_reference(&evidence["releaseNotes"]) } @@ -1660,7 +1676,10 @@ mod tests { "spdx": [digest_reference("evidence/sbom.spdx.json")], "provenance": [digest_reference("evidence/provenance.sigstore.json")], "formalManifest": digest_reference("formal/assurance-manifest-v1.toml"), - "conformance": [digest_reference("evidence/conformance.json")], + "conformance": [ + digest_reference("evidence/conformance.json"), + digest_reference(release_launch::REPORT_PATH), + ], "benchmarks": [digest_reference("evidence/benchmarks.json")], "releaseNotes": digest_reference("evidence/RELEASE_CANDIDATE_NOTES.md"), }, @@ -1736,6 +1755,22 @@ mod tests { .expect("complete exact release manifest should pass"); } + #[test] + fn final_release_manifest_requires_one_launch_projection() { + for duplicate in [false, true] { + let mut manifest = valid_manifest(); + let references = manifest["evidence"]["conformance"] + .as_array_mut() + .expect("references"); + if duplicate { + references.push(digest_reference(release_launch::REPORT_PATH)); + } else { + references.retain(|reference| reference["path"] != release_launch::REPORT_PATH); + } + assert!(validate_release_manifest_value(&manifest).is_err()); + } + } + #[test] fn final_release_manifest_rejects_unknown_schema() { let mut manifest = valid_manifest(); diff --git a/xtask/src/release_control.rs b/xtask/src/release_control.rs index 4d474d28b..e55483b10 100644 --- a/xtask/src/release_control.rs +++ b/xtask/src/release_control.rs @@ -110,6 +110,7 @@ fn finalize_preparation( if subjects.is_empty() { return Err("release-manifest input has no subjects".to_owned()); } + release_launch::verify_projection(&root(), commit)?; let provenance_path = copy_evidence_file( provenance_source, @@ -178,6 +179,7 @@ fn finalize_preparation( "conformance": [ digest_reference("target/release-evidence/platform.json")?, digest_reference("target/compliance/report.json")?, + digest_reference(release_launch::REPORT_PATH)?, ], "benchmarks": [ digest_reference("demos/benchmarks/profiles/release.toml")?, diff --git a/xtask/src/release_launch.rs b/xtask/src/release_launch.rs new file mode 100644 index 000000000..e72bff1d7 --- /dev/null +++ b/xtask/src/release_launch.rs @@ -0,0 +1,270 @@ +//! The release projection reads only the gateway build's pinned root and +//! clean candidate inputs. Missing qualification does not prevent an RC; +//! it computes false and never promotes a human or production claim. + +use auths_gateway::{ClockTrustState, DeploymentClock as _}; +use auths_recipe_qualification::{ + GatewaySemanticClosure, GitCommit, LaunchCandidate, QualificationEvidence, QualificationInputs, + QualificationReleaseIndex, QualificationTarget, QualificationTrustRoot, + RecipeQualificationRecord, VerifiedQualifications, VerifierState, +}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use std::{fs, io::Read as _, path::Path, process::Command}; + +pub(crate) const REPORT_PATH: &str = "target/release-evidence/launch-readiness.json"; + +fn bounded(path: &Path, maximum: usize) -> Result, String> { + let metadata = fs::symlink_metadata(path).map_err(|_| "qualification input unavailable")?; + if !metadata.file_type().is_file() || metadata.len() > maximum as u64 { + return Err("qualification input invalid or oversized".to_owned()); + } + let mut bytes = Vec::new(); + fs::File::open(path) + .map_err(|_| "qualification input unavailable")? + .take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "qualification input unavailable")?; + if bytes.len() > maximum { + return Err("qualification input oversized".to_owned()); + } + Ok(bytes) +} + +fn candidate(repository: &Path, commit: &str) -> Result { + let status = Command::new("cargo") + .args([ + "build", + "--locked", + "--release", + "-p", + "auths-gateway", + "--bin", + "auths-gateway", + ]) + .current_dir(repository) + .status() + .map_err(|_| "candidate build unavailable")?; + if !status.success() { + return Err("candidate build failed".to_owned()); + } + let executable = bounded( + &repository.join("target/release/auths-gateway"), + 128 * 1024 * 1024, + )?; + let closure = GatewaySemanticClosure::from_canonical_json(&bounded( + &repository.join("product/runtime/auths-gateway/semantic-closure.json"), + auths_recipe_qualification::MAX_SEMANTIC_CLOSURE_BYTES, + )?) + .map_err(|_| "candidate closure invalid")?; + if closure.digest().to_hex() != auths_gateway::GATEWAY_SEMANTIC_CLOSURE_SHA256 { + return Err("candidate closure differs from compiled gateway".to_owned()); + } + let os = match std::env::consts::OS { + "linux" => "linux", + "macos" => "macos", + _ => return Err("candidate OS unsupported".to_owned()), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + _ => return Err("candidate architecture unsupported".to_owned()), + }; + let target: QualificationTarget = serde_json::from_value(json!({ + "os": os, "arch": arch, "gateway_package": "auths-gateway", + "gateway_version": env!("CARGO_PKG_VERSION"), + "gateway_build_sha256": hex::encode(Sha256::digest(&executable)), + "store_kind": "postgresql-v1", "store_schema": auths_gateway::POSTGRES_STORE_SCHEMA, + "credential_store_kind": "aws-secrets-manager-v1", + })) + .map_err(|_| "candidate target invalid")?; + Ok(LaunchCandidate { + commit: GitCommit::parse(commit).map_err(|_| "candidate commit invalid")?, + gateway_semantic_closure_sha256: closure.digest(), + target, + }) +} + +fn evaluate( + repository: &Path, + candidate: &LaunchCandidate, + root: &QualificationTrustRoot, + now: u64, +) -> Result { + let directory = repository + .join("target/qualification-release") + .join(candidate.commit.as_str()); + let index = bounded( + &directory.join(auths_recipe_qualification::RELEASE_INDEX_FILE), + auths_recipe_qualification::MAX_RELEASE_INDEX_BYTES, + )?; + let parsed_index = QualificationReleaseIndex::from_canonical_json(&index) + .map_err(|_| "qualification index invalid")?; + let certificate = bounded( + &directory.join(auths_recipe_qualification::RELEASE_SIGNER_CERTIFICATE_FILE), + auths_recipe_qualification::MAX_SIGNER_CERTIFICATE_BYTES, + )?; + let revocations = bounded( + &directory.join(auths_recipe_qualification::RELEASE_REVOCATION_LIST_FILE), + auths_recipe_qualification::MAX_REVOCATION_LIST_BYTES, + )?; + let mut records = Vec::new(); + let mut attestations = Vec::new(); + let mut evidence = Vec::new(); + for entry in &parsed_index.body().statement.entries { + let name = format!("{}.json", entry.qualification_id.as_str()); + let bytes = bounded( + &directory + .join(auths_recipe_qualification::RELEASE_RECORDS_DIRECTORY) + .join(&name), + auths_recipe_qualification::MAX_RECORD_BYTES, + )?; + let record = RecipeQualificationRecord::from_canonical_json(&bytes) + .map_err(|_| "qualification record invalid")?; + for member in &record.body().evidence { + let path = directory + .join("evidence") + .join(format!("{}.json", member.evidence_sha256.to_hex())); + evidence.push( + QualificationEvidence::from_canonical_json(&bounded( + &path, + auths_recipe_qualification::MAX_EVIDENCE_BYTES, + )?) + .map_err(|_| "qualification evidence invalid")?, + ); + } + records.push(bytes); + attestations.push(bounded( + &directory + .join(auths_recipe_qualification::RELEASE_ATTESTATIONS_DIRECTORY) + .join(name), + auths_recipe_qualification::MAX_ATTESTATION_BYTES, + )?); + } + let record_refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect(); + let attestation_refs: Vec<&[u8]> = attestations.iter().map(Vec::as_slice).collect(); + let verified = VerifiedQualifications::verify( + root, + &QualificationInputs { + signer_certificate: &certificate, + revocation_list: &revocations, + release_index: &index, + records: &record_refs, + attestations: &attestation_refs, + }, + ); + Ok(verified.stable_launch_ready(candidate, &evidence, now, true, &VerifierState::default())) +} + +pub(crate) fn projection(repository: &Path, commit: &str) -> Result { + let mut report = json!({"schema": "auths.launch-readiness/1", "source_commit": commit, + "gateway_semantic_closure_sha256": auths_gateway::GATEWAY_SEMANTIC_CLOSURE_SHA256, + "stable_launch_ready": false, "reason": "pinned-root-unavailable", + "human_release_review": "separate-required-gate"}); + let Some(root_bytes) = auths_gateway::PINNED_QUALIFICATION_ROOT else { + return Ok(report); + }; + let Ok(root) = QualificationTrustRoot::from_canonical_json(root_bytes) else { + report["reason"] = json!("pinned-root-invalid"); + return Ok(report); + }; + let clock = auths_gateway::SynchronizedHostClock; + let Some(now) = clock + .now() + .filter(|_| clock.trust() == ClockTrustState::Trusted) + else { + report["reason"] = json!("release-clock-untrusted"); + return Ok(report); + }; + let candidate = candidate(repository, commit)?; + report["evaluated_at"] = json!(now); + report["target"] = + serde_json::to_value(&candidate.target).map_err(|_| "candidate target unencodable")?; + match evaluate(repository, &candidate, &root, now) { + Ok(ready) => { + report["stable_launch_ready"] = json!(ready); + report["reason"] = json!(if ready { + "technical-gates-passed" + } else { + "qualification-gates-not-satisfied" + }); + } + Err(_) => { + report["reason"] = json!("qualification-inputs-unavailable-or-invalid"); + } + } + Ok(report) +} + +/// Finalization must not upgrade an edited projection into a signed claim. +/// Re-evaluate the technical verdict at finalization time; the recorded time +/// remains the release-check evaluation time, while the verdict must still hold. +pub(crate) fn verify_projection(repository: &Path, commit: &str) -> Result<(), String> { + let report: Value = serde_json::from_slice(&bounded(&repository.join(REPORT_PATH), 16 * 1024)?) + .map_err(|_| "launch projection invalid")?; + let current = projection(repository, commit)?; + compare_projection(&report, ¤t) +} + +fn compare_projection(report: &Value, current: &Value) -> Result<(), String> { + for field in [ + "schema", + "source_commit", + "gateway_semantic_closure_sha256", + "stable_launch_ready", + "reason", + "human_release_review", + "target", + ] { + if report[field] != current[field] { + return Err("launch projection differs from current candidate inputs".to_owned()); + } + } + if let Some(now) = current["evaluated_at"].as_u64() + && report["evaluated_at"] + .as_u64() + .is_none_or(|evaluated| evaluated > now) + { + return Err("launch projection evaluation time invalid".to_owned()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finalization_refuses_a_manual_readiness_flag_or_another_candidate() { + // Explicit test observation of a candidate with unavailable inputs. + // This test remains valid when a production root is eventually pinned. + let report = json!({"schema": "auths.launch-readiness/1", + "source_commit": "a".repeat(40), + "gateway_semantic_closure_sha256": "d".repeat(64), + "stable_launch_ready": false, "reason": "pinned-root-unavailable", + "human_release_review": "separate-required-gate"}); + compare_projection(&report, &report).expect("actual derived report"); + for (field, value) in [ + ("stable_launch_ready", json!(true)), + ("source_commit", json!("b".repeat(40))), + ("gateway_semantic_closure_sha256", json!("c".repeat(64))), + ] { + let mut changed = report.clone(); + changed[field] = value; + assert!(compare_projection(&changed, &report).is_err()); + } + assert!(compare_projection(&json!({}), &report).is_err()); + let mut ready = report; + ready["stable_launch_ready"] = json!(true); + ready["evaluated_at"] = json!(100); + let mut future = ready.clone(); + future["evaluated_at"] = json!(101); + assert!(compare_projection(&future, &ready).is_err()); + let mut missing_time = ready.clone(); + missing_time + .as_object_mut() + .expect("report") + .remove("evaluated_at"); + assert!(compare_projection(&missing_time, &ready).is_err()); + } +} diff --git a/xtask/src/semantic_freeze.rs b/xtask/src/semantic_freeze.rs index 5cf768a65..ccd1514e0 100644 --- a/xtask/src/semantic_freeze.rs +++ b/xtask/src/semantic_freeze.rs @@ -527,7 +527,7 @@ fn generate_inventory() -> Result { "product/fixtures/v1/lifecycle/registry.toml".to_owned(), "product/runtime/auths-lifecycle/src".to_owned(), "product/stores/auths-stores/src/lifecycle.rs".to_owned(), - "product/stores/auths-stores/migrations/postgres_lifecycle_v5.sql".to_owned(), + "product/stores/auths-stores/migrations/postgres_lifecycle_v6.sql".to_owned(), "product/stores/auths-stores/tests/postgres_lifecycle.rs".to_owned(), "product/stores/auths-stores/tests/postgres_tls".to_owned(), ], @@ -705,6 +705,7 @@ fn generate_inventory() -> Result { "xtask/src/public_naming.rs".to_owned(), "xtask/src/release.rs".to_owned(), "xtask/src/release_control.rs".to_owned(), + "xtask/src/release_launch.rs".to_owned(), "xtask/src/semantic_freeze.rs".to_owned(), ]); entries.push(freeze_entry(