From 430aa07ef8d780ee9b412d70e44f593b90294c1f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 13:41:58 -0400 Subject: [PATCH 01/18] feat: add public trust contracts v2 --- .../workflows/production-lifecycle-ref.yml | 99 +++ .../verify-production-release-admission.yml | 110 +++ evidence-registry.json | 10 +- production-evidence-policy.json | 33 + production-lifecycle-policy.json | 133 +-- schemas/evidence-registry.schema.json | 112 +-- ...tion-evidence-object-reference.schema.json | 35 + scripts/make_evidence_reference.py | 45 + scripts/production_trust.py | 830 ++++++++++++++++++ scripts/stage_production_evidence.py | 124 +++ scripts/validate_evidence_registry.py | 716 +++++++++++---- scripts/validate_production_lifecycle_ref.py | 63 ++ scripts/verify_evidence_reference.py | 47 + .../verify_production_release_admission.py | 267 ++++++ tests/test_evidence_registry.py | 320 ++++--- tests/test_production_trust.py | 289 ++++++ 16 files changed, 2736 insertions(+), 497 deletions(-) create mode 100644 .github/workflows/production-lifecycle-ref.yml create mode 100644 .github/workflows/verify-production-release-admission.yml create mode 100644 production-evidence-policy.json create mode 100644 schemas/production-evidence-object-reference.schema.json create mode 100644 scripts/make_evidence_reference.py create mode 100644 scripts/production_trust.py create mode 100644 scripts/stage_production_evidence.py create mode 100644 scripts/validate_production_lifecycle_ref.py create mode 100644 scripts/verify_evidence_reference.py create mode 100644 scripts/verify_production_release_admission.py create mode 100644 tests/test_production_trust.py diff --git a/.github/workflows/production-lifecycle-ref.yml b/.github/workflows/production-lifecycle-ref.yml new file mode 100644 index 0000000..5513d05 --- /dev/null +++ b/.github/workflows/production-lifecycle-ref.yml @@ -0,0 +1,99 @@ +name: Production lifecycle feed ref + +on: + workflow_dispatch: + inputs: + feed_update_json: + description: Closed openadapt.production-lifecycle-feed-update/v1 JSON + required: true + type: string + +permissions: + contents: read + +concurrency: + group: production-lifecycle-feed-ref + cancel-in-progress: false + +jobs: + authorize: + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + steps: + - name: Require the registered lifecycle App dispatcher + env: + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + shell: bash + run: | + set -euo pipefail + test "$ACTOR" = 'openadapt-lifecycle[bot]' + test "$ACTOR_ID" = '321544198' + test "$TRIGGERING_ACTOR" = 'openadapt-lifecycle[bot]' + + update: + needs: authorize + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: production-lifecycle-feed + steps: + - name: Check out current protected main + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Mint the lifecycle App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: '4730735' + private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: .github + + - name: Validate the exact feed commit + env: + FEED_UPDATE_JSON: ${{ inputs.feed_update_json }} + shell: bash + run: | + set -euo pipefail + new_commit=$(python3 -c 'import json,os; print(json.loads(os.environ["FEED_UPDATE_JSON"])["new_commit"])') + git fetch --no-tags origin "$new_commit" + python3 scripts/validate_production_lifecycle_ref.py \ + --update-json "$FEED_UPDATE_JSON" \ + --trusted-main "${{ github.sha }}" + + - name: Verify lifecycle App installation + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + shell: bash + run: | + set -euo pipefail + test "$(gh api /installation --jq '.app_id|tostring')" = '4730735' + test "$(gh api /installation --jq '.id|tostring')" = '156835618' + test "$(gh api /installation --jq '.account.id|tostring')" = '132681217' + + - name: Compare and swap the protected feed ref + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + FEED_UPDATE_JSON: ${{ inputs.feed_update_json }} + shell: bash + run: | + set -euo pipefail + expected_old=$(python3 -c 'import json,os; print(json.loads(os.environ["FEED_UPDATE_JSON"])["expected_old_commit"] or "")') + new_commit=$(python3 -c 'import json,os; print(json.loads(os.environ["FEED_UPDATE_JSON"])["new_commit"])') + current=$(gh api /repos/OpenAdaptAI/.github/git/ref/heads/production-lifecycle-feed \ + --jq .object.sha 2>/dev/null || true) + test "$current" = "$expected_old" + if [ -z "$expected_old" ]; then + gh api --method POST /repos/OpenAdaptAI/.github/git/refs \ + -f ref=refs/heads/production-lifecycle-feed -f sha="$new_commit" >/dev/null + else + test "$(gh api /repos/OpenAdaptAI/.github/git/ref/heads/production-lifecycle-feed --jq .object.sha)" = "$expected_old" + gh api --method PATCH /repos/OpenAdaptAI/.github/git/refs/heads/production-lifecycle-feed \ + -f sha="$new_commit" -F force=false >/dev/null + fi + test "$(gh api /repos/OpenAdaptAI/.github/git/ref/heads/production-lifecycle-feed --jq .object.sha)" = "$new_commit" diff --git a/.github/workflows/verify-production-release-admission.yml b/.github/workflows/verify-production-release-admission.yml new file mode 100644 index 0000000..50caf10 --- /dev/null +++ b/.github/workflows/verify-production-release-admission.yml @@ -0,0 +1,110 @@ +name: Verify Production release admission + +on: + workflow_call: + inputs: + admission_reference_json: {required: true, type: string} + artifact_inventory_json: {required: true, type: string} + candidate_artifact_name: {required: true, type: string} + expected_target: {required: true, type: string} + expected_repository: {required: true, type: string} + expected_repository_id: {required: true, type: string} + expected_source_commit: {required: true, type: string} + expected_version: {required: true, type: string} + expected_tag: {required: true, type: string} + outputs: + admission_object_sha256: + value: ${{ jobs.verify.outputs.admission_object_sha256 }} + release_sha256: + value: ${{ jobs.verify.outputs.release_sha256 }} + artifact_inventory_sha256: + value: ${{ jobs.verify.outputs.artifact_inventory_sha256 }} + publication_staging_json: + value: ${{ jobs.verify.outputs.publication_staging_json }} + publication_staging_sha256: + value: ${{ jobs.verify.outputs.publication_staging_sha256 }} + draft_release_id: + value: ${{ jobs.verify.outputs.draft_release_id }} + expires_at: + value: ${{ jobs.verify.outputs.expires_at }} + registry_source_commit: + value: ${{ jobs.verify.outputs.registry_source_commit }} + +permissions: + contents: read + attestations: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + admission_object_sha256: ${{ steps.verify.outputs.admission_object_sha256 }} + release_sha256: ${{ steps.verify.outputs.release_sha256 }} + artifact_inventory_sha256: ${{ steps.verify.outputs.artifact_inventory_sha256 }} + publication_staging_json: ${{ steps.verify.outputs.publication_staging_json }} + publication_staging_sha256: ${{ steps.verify.outputs.publication_staging_sha256 }} + draft_release_id: ${{ steps.verify.outputs.draft_release_id }} + expires_at: ${{ steps.verify.outputs.expires_at }} + registry_source_commit: ${{ steps.verify.outputs.registry_source_commit }} + steps: + - name: Check out the exact central verifier + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: OpenAdaptAI/.github + ref: ${{ github.workflow_sha }} + path: central-trust + persist-credentials: false + + - name: Download the exact candidate artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ inputs.candidate_artifact_name }} + path: candidate + + - name: Install the pinned GitHub attestation verifier + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/gh_2.98.0_linux_amd64.tar.gz" + curl --fail --location --silent --show-error \ + --output "$archive" \ + https://github.com/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz + echo "3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de $archive" \ + | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_2.98.0_linux_amd64/bin" >> "$GITHUB_PATH" + + - name: Materialize closed verifier inputs + shell: bash + env: + ADMISSION_REFERENCE_JSON: ${{ inputs.admission_reference_json }} + ARTIFACT_INVENTORY_JSON: ${{ inputs.artifact_inventory_json }} + run: | + set -euo pipefail + printf '%s' "$ADMISSION_REFERENCE_JSON" > "$RUNNER_TEMP/admission-reference.json" + printf '%s' "$ARTIFACT_INVENTORY_JSON" > "$RUNNER_TEMP/artifact-inventory.json" + + - name: Verify admission and exact candidate bytes + id: verify + shell: bash + env: + EXPECTED_TARGET: ${{ inputs.expected_target }} + EXPECTED_REPOSITORY: ${{ inputs.expected_repository }} + EXPECTED_REPOSITORY_ID: ${{ inputs.expected_repository_id }} + EXPECTED_SOURCE_COMMIT: ${{ inputs.expected_source_commit }} + EXPECTED_VERSION: ${{ inputs.expected_version }} + EXPECTED_TAG: ${{ inputs.expected_tag }} + run: | + set -euo pipefail + python3 central-trust/scripts/verify_production_release_admission.py \ + --admission-reference "$RUNNER_TEMP/admission-reference.json" \ + --artifact-inventory "$RUNNER_TEMP/artifact-inventory.json" \ + --artifact-root candidate \ + --expected-target "$EXPECTED_TARGET" \ + --expected-repository "$EXPECTED_REPOSITORY" \ + --expected-repository-id "$EXPECTED_REPOSITORY_ID" \ + --expected-source-commit "$EXPECTED_SOURCE_COMMIT" \ + --expected-version "$EXPECTED_VERSION" \ + --expected-tag "$EXPECTED_TAG" \ + --github-output "$GITHUB_OUTPUT" diff --git a/evidence-registry.json b/evidence-registry.json index 4594757..39eae8b 100644 --- a/evidence-registry.json +++ b/evidence-registry.json @@ -1,6 +1,12 @@ { "$schema": "schemas/evidence-registry.schema.json", - "schema_version": "openadapt.production-evidence-registry/v1", - "head_entry_sha256": null, + "schema_version": "openadapt.production-evidence-registry/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "revision": 1, + "previous_registry_head_sha256": null, + "registry_head_sha256": "sha256:1b0c8362c5e9d8f55006a350605b9d537fa33639b8f3d3bb6c293e7e49b3d2cf", + "signer_registry": null, "entries": [] } diff --git a/production-evidence-policy.json b/production-evidence-policy.json new file mode 100644 index 0000000..7beea49 --- /dev/null +++ b/production-evidence-policy.json @@ -0,0 +1,33 @@ +{ + "schema_version": "openadapt.production-evidence-verification-policy/v1", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "reference_schema_version": "openadapt.production-evidence-object-reference/v2", + "transport": "exact-commit-raw-github", + "signer_registry_schema_version": "openadapt.qualification-signer-registry/v2", + "sigstore": { + "tool": "gh", + "version": "2.98.0", + "cert_oidc_issuer": "https://token.actions.githubusercontent.com", + "deny_self_hosted_runners": true, + "allow_public_good": false, + "archives": [ + {"os":"linux","arch":"amd64","url":"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/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz","sha256":"sha256:3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de"}, + {"os":"linux","arch":"arm64","url":"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/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_arm64.tar.gz","sha256":"sha256:cf689084f3a3618f7eae4a2420d335d74626d65f5e594b9828d125d69f800d86"}, + {"os":"macos","arch":"amd64","url":"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/cli/cli/releases/download/v2.98.0/gh_2.98.0_macOS_amd64.zip","sha256":"sha256:734c7bbd0bc56a3974500ee9aea74d60f0e5b89be09e92b9d9148939a3a1e0e6"}, + {"os":"macos","arch":"arm64","url":"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/cli/cli/releases/download/v2.98.0/gh_2.98.0_macOS_arm64.zip","sha256":"sha256:8cfb027cc5310675f2b830eac8f9865c1155a45ffcf9757f699fdd5a22046ca4"} + ], + "certificate_identities": [ + {"kind":"production-acceptance-manifest","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, + {"kind":"production-acceptance-summary","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, + {"kind":"production-current-default","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-current-default.yml@refs/heads/main"}, + {"kind":"production-lifecycle-checkpoint","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-lifecycle-checkpoint.yml@refs/heads/main"}, + {"kind":"qualification-admission","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-qualification-admission.yml@refs/heads/main"}, + {"kind":"qualification-authority-state-receipt","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-authority-state.yml@refs/heads/main"}, + {"kind":"qualification-evidence-decision-receipt","issuer_repository":"OpenAdaptAI/openadapt-internal","certificate_identity":"/OpenAdaptAI/openadapt-internal/.github/workflows/issue-private-qualification-evidence-decision.yml@refs/heads/main"}, + {"kind":"qualification-release","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-production-release-admission.yml@refs/heads/main"} + ,{"kind":"qualification-revocation-state-receipt","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-revocation-state.yml@refs/heads/main"} + ] + } +} diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index dbd02cf..da57590 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -1,121 +1,22 @@ { "$schema": "schemas/production-lifecycle-policy.schema.json", - "schema_version": "openadapt.production-lifecycle-policy/v1", - "revision": 1, - "maximum_admission_days": 30, - "summary_authority": { - "repository": "OpenAdaptAI/openadapt-evals", - "workflow": ".github/workflows/production-lifecycle-evidence.yml", - "source_ref": "refs/heads/main", - "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_identity": "/OpenAdaptAI/openadapt-evals/.github/workflows/production-lifecycle-evidence.yml@refs/heads/main", - "summary_schema_version": "openadapt.production-lifecycle-evidence-summary/v1", - "private_certificate_schema_version": "openadapt.execute-live-acceptance-record/v2", - "evidence_manifest_schema_version": "openadapt.production-acceptance/v1", - "acceptance_policy_sha256": "sha256:9b1fe55bc6796ae0a46960ca4aa335d88de60b0562c383afa1e85fa0a0c204b8", - "release_identity_schema_version": "openadapt.monotonic-production-release/v1", - "production_channel": "production", - "signer_provenance_digest_domain": "OpenAdapt production certificate signer provenance v1\u0000" - }, + "schema_version": "openadapt.production-lifecycle-policy/v2", + "revision": 2, + "maximum_release_admission_days": 30, + "maximum_workflow_admission_days": 7, + "object_reference_schema_version": "openadapt.production-evidence-object-reference/v2", + "release_admission_schema_version": "openadapt.qualification-release/v1", + "workflow_admission_schema_version": "openadapt.qualification-admission/v3", + "lifecycle_checkpoint_schema_version": "openadapt.production-lifecycle-checkpoint/v1", + "lifecycle_feed_schema_version": "openadapt.production-lifecycle-feed/v1", + "lifecycle_feed_ref": "refs/heads/production-lifecycle-feed", "targets": [ - { - "id": "agent", - "display_name": "OpenAdapt Agent", - "lifecycle_scope": "repository", - "lifecycle_subject": "openadapt-agent", - "source_repository": "OpenAdaptAI/openadapt-agent", - "release_kind": "public_package", - "required_claim_scope": "qualified_agent_bridge_release", - "required_artifact_kinds": ["sdist", "wheel"], - "package_index_project": "openadapt-agent", - "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} - }, - { - "id": "capture", - "display_name": "OpenAdapt Capture", - "lifecycle_scope": "repository", - "lifecycle_subject": "openadapt-capture", - "source_repository": "OpenAdaptAI/openadapt-capture", - "release_kind": "public_package", - "required_claim_scope": "qualified_native_recorder_release", - "required_artifact_kinds": ["sdist", "wheel"], - "package_index_project": "openadapt-capture", - "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} - }, - { - "id": "cloud", - "display_name": "OpenAdapt Cloud", - "lifecycle_scope": "repository", - "lifecycle_subject": "openadapt-cloud", - "source_repository": "OpenAdaptAI/openadapt-cloud", - "release_kind": "private_deployment", - "required_claim_scope": "qualified_workflow_control_plane_deployment", - "required_artifact_kinds": [], - "package_index_project": null, - "artifact_authority_by_kind": {} - }, - { - "id": "desktop", - "display_name": "OpenAdapt Desktop", - "lifecycle_scope": "repository", - "lifecycle_subject": "openadapt-desktop", - "source_repository": "OpenAdaptAI/openadapt-desktop", - "release_kind": "public_package", - "required_claim_scope": "qualified_native_workflow_desktop_release", - "required_artifact_kinds": [ - "linux-installer", - "macos-installer", - "sdist", - "wheel", - "windows-installer" - ], - "package_index_project": "openadapt-desktop", - "artifact_authority_by_kind": { - "linux-installer": "github_release", - "macos-installer": "github_release", - "sdist": "pypi", - "wheel": "pypi", - "windows-installer": "github_release" - } - }, - { - "id": "docs", - "display_name": "OpenAdapt Documentation", - "lifecycle_scope": "public_surface", - "lifecycle_subject": "docs.openadapt.ai", - "source_repository": "OpenAdaptAI/openadapt-ops", - "release_kind": "public_deployment", - "required_claim_scope": "production_documentation_deployment", - "required_artifact_kinds": ["deployment-manifest", "site-archive"], - "package_index_project": null, - "artifact_authority_by_kind": { - "deployment-manifest": "managed_evidence", - "site-archive": "managed_evidence" - } - }, - { - "id": "flow", - "display_name": "OpenAdapt Flow", - "lifecycle_scope": "repository", - "lifecycle_subject": "openadapt-flow", - "source_repository": "OpenAdaptAI/openadapt-flow", - "release_kind": "public_package", - "required_claim_scope": "qualified_workflow_runtime_release", - "required_artifact_kinds": ["sdist", "wheel"], - "package_index_project": "openadapt-flow", - "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} - }, - { - "id": "openadapt", - "display_name": "OpenAdapt", - "lifecycle_scope": "repository", - "lifecycle_subject": "OpenAdapt", - "source_repository": "OpenAdaptAI/OpenAdapt", - "release_kind": "public_package", - "required_claim_scope": "qualified_workflow_launcher_release", - "required_artifact_kinds": ["sdist", "wheel"], - "package_index_project": "openadapt", - "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} - } + {"id":"agent","display_name":"OpenAdapt Agent","source_repository":"OpenAdaptAI/openadapt-agent","source_repository_id":"1136136670","release_kind":"package","claim_scope":"production_agent","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-agent"}, + {"id":"capture","display_name":"OpenAdapt Capture","source_repository":"OpenAdaptAI/openadapt-capture","source_repository_id":"1115283835","release_kind":"package","claim_scope":"production_capture","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-capture"}, + {"id":"cloud","display_name":"OpenAdapt Cloud","source_repository":"OpenAdaptAI/openadapt-cloud","source_repository_id":"1300570990","release_kind":"deployment","claim_scope":"production_cloud","required_artifact_kinds":["deployment-manifest"],"package_index_project":null}, + {"id":"desktop","display_name":"OpenAdapt Desktop","source_repository":"OpenAdaptAI/openadapt-desktop","source_repository_id":"1171291730","release_kind":"hybrid","claim_scope":"production_desktop","required_artifact_kinds":["linux-installer","macos-installer","python-sdist","python-wheel","windows-installer"],"package_index_project":"openadapt-desktop"}, + {"id":"docs","display_name":"OpenAdapt Documentation","source_repository":"OpenAdaptAI/openadapt-ops","source_repository_id":"1172011294","release_kind":"deployment","claim_scope":"production_docs","required_artifact_kinds":["deployment-manifest","site-archive"],"package_index_project":null}, + {"id":"flow","display_name":"OpenAdapt Flow","source_repository":"OpenAdaptAI/openadapt-flow","source_repository_id":"1291376938","release_kind":"package","claim_scope":"production_flow","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-flow"}, + {"id":"openadapt","display_name":"OpenAdapt","source_repository":"OpenAdaptAI/OpenAdapt","source_repository_id":"627024850","release_kind":"package","claim_scope":"production_openadapt","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt"} ] } diff --git a/schemas/evidence-registry.schema.json b/schemas/evidence-registry.schema.json index 3628351..0987e14 100644 --- a/schemas/evidence-registry.schema.json +++ b/schemas/evidence-registry.schema.json @@ -1,32 +1,37 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://openadapt.ai/schemas/evidence-registry.schema.json", - "title": "OpenAdapt production evidence registry", - "description": "Content-addressed, append-only, hash-chained registry of remote-safe acceptance evidence references. Entries bind an HTTPS URL to its exact SHA-256 digest; each entry commits to the digest of its predecessor.", + "$id": "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/OpenAdaptAI/.github/blob/main/schemas/evidence-registry.schema.json", + "title": "OpenAdapt production evidence registry v2", "type": "object", "additionalProperties": false, "required": [ - "$schema", - "schema_version", - "head_entry_sha256", - "entries" + "$schema", "schema_version", "repository", "repository_id", + "repository_owner_id", "revision", "previous_registry_head_sha256", + "registry_head_sha256", "signer_registry", "entries" ], "properties": { - "$schema": { - "const": "schemas/evidence-registry.schema.json" - }, - "schema_version": { - "const": "openadapt.production-evidence-registry/v1" - }, - "head_entry_sha256": { - "description": "Chained digest of the last entry, or null while the registry is empty.", + "$schema": {"const": "schemas/evidence-registry.schema.json"}, + "schema_version": {"const": "openadapt.production-evidence-registry/v2"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "revision": {"type": "integer", "minimum": 1}, + "previous_registry_head_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry": { "oneOf": [ + {"type": "null"}, { - "type": "null" - }, - { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "object_path", "object_sha256", "registry_identity_sha256", "registry_revision"], + "properties": { + "schema_version": {"const": "openadapt.qualification-signer-registry-pointer/v1"}, + "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.qualification-signer-registry\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "registry_identity_sha256": {"$ref": "#/$defs/digest"}, + "registry_revision": {"type": "integer", "minimum": 1} + } } ] }, @@ -36,62 +41,25 @@ "type": "object", "additionalProperties": false, "required": [ - "sequence", - "kind", - "url", - "sha256", - "size_bytes", - "recorded_at", - "prior_entry_sha256", - "entry_sha256" + "registry_entry_sha256", "kind", "object_schema_version", "object_path", + "object_sha256", "size_bytes", "object_media_type", + "semantic_identity_sha256", "subject_sha256" ], "properties": { - "sequence": { - "type": "integer", - "minimum": 1 - }, - "kind": { - "enum": [ - "evidence-summary", - "attestation-bundle", - "evidence-manifest", - "acceptance-run-record" - ] - }, - "url": { - "type": "string", - "maxLength": 2048, - "pattern": "^https://" - }, - "sha256": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - }, - "size_bytes": { - "type": "integer", - "minimum": 1 - }, - "recorded_at": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" - }, - "prior_entry_sha256": { - "oneOf": [ - { - "type": "null" - }, - { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - } - ] - }, - "entry_sha256": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - } + "registry_entry_sha256": {"$ref": "#/$defs/digest"}, + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,95}$"}, + "object_schema_version": {"type": "string", "minLength": 1, "maxLength": 160}, + "object_path": {"type": "string", "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.[a-z][a-z0-9-]{1,95}\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "object_media_type": {"type": "string", "minLength": 1, "maxLength": 200}, + "semantic_identity_sha256": {"$ref": "#/$defs/digest"}, + "subject_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]} } } } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} } } diff --git a/schemas/production-evidence-object-reference.schema.json b/schemas/production-evidence-object-reference.schema.json new file mode 100644 index 0000000..4c15180 --- /dev/null +++ b/schemas/production-evidence-object-reference.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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/OpenAdaptAI/.github/blob/main/schemas/production-evidence-object-reference.schema.json", + "title": "OpenAdapt production evidence object reference v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "repository", "repository_id", "repository_owner_id", + "registry_source_commit", "registry_revision", "registry_head_sha256", + "registry_entry_sha256", "kind", "object_schema_version", "object_path", + "object_sha256", "size_bytes", "object_media_type", + "semantic_identity_sha256", "subject_sha256" + ], + "properties": { + "schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "registry_source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "registry_revision": {"type": "integer", "minimum": 1}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "registry_entry_sha256": {"$ref": "#/$defs/digest"}, + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,95}$"}, + "object_schema_version": {"type": "string", "minLength": 1, "maxLength": 160}, + "object_path": {"type": "string", "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.[a-z][a-z0-9-]{1,95}\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "object_media_type": {"type": "string", "minLength": 1, "maxLength": 200}, + "semantic_identity_sha256": {"$ref": "#/$defs/digest"}, + "subject_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } +} diff --git a/scripts/make_evidence_reference.py b/scripts/make_evidence_reference.py new file mode 100644 index 0000000..e233819 --- /dev/null +++ b/scripts/make_evidence_reference.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Emit an exact-commit v2 reference for one registered entry.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import validate_evidence_registry as registry + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--registry", default=str(registry.REGISTRY_PATH)) + parser.add_argument("--registry-source-commit", required=True) + parser.add_argument("--object-sha256", required=True) + args = parser.parse_args(argv) + try: + value = json.loads(Path(args.registry).read_text(encoding="utf-8")) + entries = registry.validate_registry(value) + matches = [item for item in entries if item["object_sha256"] == args.object_sha256] + if len(matches) != 1: + raise registry.EvidenceRegistryError("object digest does not select one entry") + reference = { + "schema_version": registry.REFERENCE_SCHEMA, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "registry_source_commit": args.registry_source_commit, + "registry_revision": value["revision"], + "registry_head_sha256": value["registry_head_sha256"], + **matches[0], + } + registry.validate_reference(reference) + print(json.dumps(reference, sort_keys=True, separators=(",", ":"))) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/production_trust.py b/scripts/production_trust.py new file mode 100644 index 0000000..a8675e3 --- /dev/null +++ b/scripts/production_trust.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""Closed validators and digest rules for OpenAdapt Production trust objects.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import validate_evidence_registry as evidence + +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +DECIMAL_ID = re.compile(r"^[1-9][0-9]*$") +ENTITY_CLASS = re.compile(r"^[a-z][a-z0-9 -]{0,63}$") + +TARGETS = ("agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt") +CAMPAIGN_CLASSES = ( + "healthy", + "safe_halt", + "idempotency_replay", + "uncertain_delivery", + "declared_attended", + "governed_repair", +) +CAMPAIGN_COUNT_FIELDS = { + "task_condition_cell_count", + "minimum_trials_per_cell", + "observed_trial_count", + "silent_incorrect_success_count", + "over_halt_count", + "unsafe_effect_count", + "blind_retry_count", + "replay_dispatch_count", + "model_call_count", + "unplanned_intervention_count", + "reconciliation_required_count", + "authenticated_bound_decision_count", + "live_target_revalidation_count", + "policy_approved_repair_count", + "approved_repair_count", + "retained_repair_evidence_count", + "unverified_direct_action_count", +} + +ARTIFACT_INVENTORY_DOMAIN = b"OpenAdapt production release artifact inventory v1\0" +RELEASE_DOMAIN = b"OpenAdapt production release candidate v1\0" +RELEASE_ADMISSION_DOMAIN = b"OpenAdapt qualification release admission v1\0" +STAGING_DOMAIN = b"OpenAdapt production release staging evidence v1\0" +TAG_RULESETS_DOMAIN = b"OpenAdapt production release tag rulesets v1\0" +ADMISSION_DOMAIN = b"OpenAdapt qualification admission v3\0" +CHECKPOINT_DOMAIN = b"OpenAdapt production lifecycle checkpoint v1\0" +PROJECTION_DOMAIN = b"OpenAdapt production lifecycle projection v2\0" +RELEASE_SET_DOMAIN = b"OpenAdapt production release admission set v1\0" +WORKFLOW_SET_DOMAIN = b"OpenAdapt production workflow admission set v1\0" +FEED_UPDATE_IDEMPOTENCY_DOMAIN = ( + b"OpenAdapt production lifecycle feed update idempotency v1\0" +) + + +class TrustError(ValueError): + """A Production trust object is invalid.""" + + +def canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def digest_bytes(domain: bytes, value: Any) -> str: + return "sha256:" + hashlib.sha256(domain + canonical(value)).hexdigest() + + +def closed(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise TrustError(f"{label} must contain exactly {sorted(fields)}; got {actual}") + return value + + +def require_digest(value: Any, label: str) -> str: + if not isinstance(value, str) or DIGEST.fullmatch(value) is None: + raise TrustError(f"{label} must be a lowercase sha256 digest") + return value + + +def require_decimal_id(value: Any, label: str) -> str: + if not isinstance(value, str) or DECIMAL_ID.fullmatch(value) is None: + raise TrustError(f"{label} must be a decimal string") + return value + + +def require_timestamp(value: Any, label: str) -> datetime: + if not isinstance(value, str) or TIMESTAMP.fullmatch(value) is None: + raise TrustError(f"{label} must be an exact UTC timestamp") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError as exc: + raise TrustError(f"{label} is not a calendar timestamp") from exc + + +def require_positive_int(value: Any, label: str, minimum: int = 1) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < minimum: + raise TrustError(f"{label} must be an integer of at least {minimum}") + return value + + +def validate_window( + value: Mapping[str, Any], *, maximum: timedelta, now: datetime | None = None +) -> None: + issued = require_timestamp(value["issued_at"], "issued_at") + not_before = require_timestamp(value["not_before"], "not_before") + expires = require_timestamp(value["expires_at"], "expires_at") + if not issued <= not_before < expires <= issued + maximum: + raise TrustError("validity window is invalid") + if now is not None and not not_before <= now < expires: + raise TrustError("object is not active at the requested time") + + +def validate_reference_pair( + regular: Any, bundle: Any, *, kind: str +) -> tuple[dict[str, Any], dict[str, Any]]: + try: + regular_ref = evidence.validate_reference(regular) + bundle_ref = evidence.validate_reference(bundle) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + if regular_ref["kind"] != kind: + raise TrustError(f"regular reference kind must be {kind}") + if bundle_ref["kind"] != f"{kind}-sigstore-bundle": + raise TrustError(f"bundle reference kind must be {kind}-sigstore-bundle") + if ( + bundle_ref["registry_source_commit"] != regular_ref["registry_source_commit"] + or bundle_ref["registry_revision"] != regular_ref["registry_revision"] + or bundle_ref["registry_head_sha256"] != regular_ref["registry_head_sha256"] + or bundle_ref["subject_sha256"] != regular_ref["object_sha256"] + ): + raise TrustError("reference pair does not share a registry or bind its subject") + return regular_ref, bundle_ref + + +def validate_campaign_summary(value: Any) -> dict[str, Any]: + summary = closed(value, set(CAMPAIGN_CLASSES), "campaign summary") + for campaign_class in CAMPAIGN_CLASSES: + counts = closed( + summary[campaign_class], CAMPAIGN_COUNT_FIELDS, f"{campaign_class} counts" + ) + for key, count in counts.items(): + require_positive_int( + count, + f"{campaign_class} {key}", + minimum=3 if key == "minimum_trials_per_cell" else 0, + ) + cells = counts["task_condition_cell_count"] + trials = counts["observed_trial_count"] + if cells < 1 or trials < cells * counts["minimum_trials_per_cell"]: + raise TrustError(f"{campaign_class} does not have three trials per cell") + if counts["unsafe_effect_count"] or counts["silent_incorrect_success_count"] or counts["blind_retry_count"]: + raise TrustError(f"{campaign_class} contains a forbidden failure") + for campaign_class in ("healthy", "idempotency_replay"): + counts = summary[campaign_class] + if counts["model_call_count"] or counts["unplanned_intervention_count"]: + raise TrustError(f"{campaign_class} is not a zero-model healthy path") + uncertain = summary["uncertain_delivery"] + if uncertain["observed_trial_count"] < 3: + raise TrustError("uncertain delivery requires at least three fault trials") + if uncertain["reconciliation_required_count"] != uncertain["observed_trial_count"]: + raise TrustError("every uncertain-delivery trial must require reconciliation") + if uncertain["replay_dispatch_count"]: + raise TrustError("uncertain delivery cannot replay dispatch") + attended = summary["declared_attended"] + if ( + attended["authenticated_bound_decision_count"] != attended["observed_trial_count"] + or attended["live_target_revalidation_count"] != attended["observed_trial_count"] + ): + raise TrustError("attended trials require a bound decision and revalidation") + repair = summary["governed_repair"] + for field in ( + "policy_approved_repair_count", + "approved_repair_count", + "retained_repair_evidence_count", + "live_target_revalidation_count", + ): + if repair[field] != repair["observed_trial_count"]: + raise TrustError("governed repair evidence is incomplete") + if repair["unverified_direct_action_count"]: + raise TrustError("governed repair contains an unverified direct action") + return summary + + +def validate_artifacts(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list) or not value: + raise TrustError("artifact inventory must be a non-empty list") + result: list[dict[str, Any]] = [] + names: set[str] = set() + folded: set[str] = set() + for index, artifact_value in enumerate(value): + artifact = closed( + artifact_value, + {"name", "kind", "sha256", "size_bytes", "media_type", "publish_destinations"}, + f"artifact {index}", + ) + name = artifact["name"] + if ( + not isinstance(name, str) + or not name + or name != Path(name).name + or "/" in name + or "\\" in name + ): + raise TrustError(f"artifact {index} name is unsafe") + if name in names or name.casefold() in folded: + raise TrustError("artifact names must be unique under case folding") + names.add(name) + folded.add(name.casefold()) + if not isinstance(artifact["kind"], str) or not artifact["kind"]: + raise TrustError(f"artifact {index} kind is invalid") + require_digest(artifact["sha256"], f"artifact {index} digest") + require_positive_int(artifact["size_bytes"], f"artifact {index} size") + if not isinstance(artifact["media_type"], str) or "/" not in artifact["media_type"]: + raise TrustError(f"artifact {index} media type is invalid") + destinations = artifact["publish_destinations"] + if ( + not isinstance(destinations, list) + or not destinations + or destinations != sorted(set(destinations)) + or any(item not in {"deployment", "github-release", "pypi"} for item in destinations) + ): + raise TrustError(f"artifact {index} publish destinations are invalid") + result.append(artifact) + if result != sorted(result, key=lambda item: (item["kind"], item["name"], item["sha256"])): + raise TrustError("artifacts must be sorted by kind, name, and digest") + return result + + +def validate_artifact_inventory(value: Any) -> dict[str, Any]: + inventory = closed( + value, {"schema_version", "target", "claim_scope", "artifacts"}, "artifact inventory" + ) + if inventory["schema_version"] != "openadapt.production-release-artifact-inventory/v1": + raise TrustError("artifact inventory schema is not supported") + if inventory["target"] not in TARGETS: + raise TrustError("artifact inventory target is invalid") + if not isinstance(inventory["claim_scope"], str) or not inventory["claim_scope"]: + raise TrustError("artifact inventory claim scope is invalid") + validate_artifacts(inventory["artifacts"]) + return inventory + + +def artifact_inventory_digest(value: Mapping[str, Any]) -> str: + return digest_bytes( + ARTIFACT_INVENTORY_DOMAIN, + { + "target": value["target"], + "claim_scope": value["claim_scope"], + "artifacts": value["artifacts"], + }, + ) + + +def validate_tag_rulesets(value: Any, *, repository: str, repository_id: str) -> list[dict[str, Any]]: + if not isinstance(value, list) or len(value) != 2: + raise TrustError("tag rulesets must contain creation and immutability rulesets") + roles = ["creation_authority", "immutability"] + for index, ruleset_value in enumerate(value): + ruleset = closed( + ruleset_value, + { + "schema_version", "role", "repository", "repository_id", "ruleset_id", + "name", "target", "enforcement", "bypass_actors", "conditions", "rules", + }, + f"tag ruleset {index}", + ) + if ruleset["schema_version"] != "openadapt.production-release-tag-ruleset/v1": + raise TrustError("tag ruleset schema is not supported") + if ruleset["role"] != roles[index] or ruleset["repository"] != repository or ruleset["repository_id"] != repository_id: + raise TrustError("tag ruleset identity differs") + require_decimal_id(ruleset["ruleset_id"], "tag ruleset id") + if ruleset["target"] != "tag" or ruleset["enforcement"] != "active": + raise TrustError("tag ruleset is not active for tags") + expected_name = ( + "Production release tags: creation authority" + if index == 0 + else "Production release tags: immutable" + ) + if ruleset["name"] != expected_name: + raise TrustError("tag ruleset name differs from policy") + actors = ruleset["bypass_actors"] + expected_actors = [] if index else [{"actor_id": "4730708", "actor_type": "Integration", "bypass_mode": "always"}] + if actors != expected_actors: + raise TrustError("tag ruleset bypass authority differs") + conditions = closed(ruleset["conditions"], {"ref_name"}, "tag ruleset conditions") + ref_name = closed(conditions["ref_name"], {"include", "exclude"}, "tag ref conditions") + for key in ("include", "exclude"): + items = ref_name[key] + if not isinstance(items, list) or items != sorted(set(items)) or any(not isinstance(item, str) or not item for item in items): + raise TrustError("tag ref patterns must be sorted unique strings") + if ref_name != {"include": ["refs/tags/v*"], "exclude": []}: + raise TrustError("tag ruleset must match exactly refs/tags/v*") + rules = ruleset["rules"] + expected_rules = ( + [{"type": "creation"}] + if index == 0 + else [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "update", + "parameters": {"update_allows_fetch_and_merge": False}, + }, + ] + ) + if rules != expected_rules: + raise TrustError("tag ruleset rules differ from immutable policy") + return value + + +def validate_staging(value: Any) -> dict[str, Any]: + staging = closed( + value, + { + "schema_version", "repository", "repository_id", "draft_release_id", "tag", + "target_commitish", "draft", "prerelease", "release_app_id", + "release_app_installation_id", "release_app_bot_user_id", + "release_author_login", "assets", "immutable_releases_enabled", + "tag_rulesets", "tag_rulesets_sha256", "observed_at", + }, + "publication staging", + ) + if staging["schema_version"] != "openadapt.production-release-staging-evidence/v1": + raise TrustError("publication staging schema is not supported") + for field in ("repository_id", "draft_release_id", "release_app_id", "release_app_installation_id", "release_app_bot_user_id"): + require_decimal_id(staging[field], f"publication staging {field}") + if ( + staging["draft"] is not True + or staging["prerelease"] is not False + or staging["release_app_id"] != "4730708" + or staging["release_app_installation_id"] != "156835568" + or staging["release_app_bot_user_id"] != "321543906" + or staging["release_author_login"] != "openadapt-release[bot]" + or staging["immutable_releases_enabled"] is not True + ): + raise TrustError("publication staging authority or state differs") + if not isinstance(staging["target_commitish"], str) or HEX40.fullmatch(staging["target_commitish"]) is None: + raise TrustError("publication staging target commit must be exact") + if not isinstance(staging["tag"], str) or not staging["tag"]: + raise TrustError("publication staging tag is invalid") + assets = staging["assets"] + if not isinstance(assets, list) or not assets: + raise TrustError("publication staging assets are required") + names: set[str] = set() + ids: set[str] = set() + for index, asset_value in enumerate(assets): + asset = closed( + asset_value, + { + "asset_id", "name", "kind", "sha256", "size_bytes", "media_type", + "publish_destinations", "uploader_id", "uploader_login", + }, + f"staged asset {index}", + ) + require_decimal_id(asset["asset_id"], f"staged asset {index} id") + require_digest(asset["sha256"], f"staged asset {index} digest") + require_positive_int(asset["size_bytes"], f"staged asset {index} size") + if not isinstance(asset["media_type"], str) or "/" not in asset["media_type"]: + raise TrustError("staged asset media type is invalid") + destinations = asset["publish_destinations"] + if not isinstance(destinations, list) or not destinations or destinations != sorted(set(destinations)) or any(item not in {"deployment", "github-release", "pypi"} for item in destinations): + raise TrustError("staged asset publish destinations are invalid") + if asset["uploader_id"] != "321543906" or asset["uploader_login"] != "openadapt-release[bot]": + raise TrustError("staged asset uploader is not the release App") + if asset["name"] in names or asset["asset_id"] in ids: + raise TrustError("staged asset names and ids must be unique") + names.add(asset["name"]) + ids.add(asset["asset_id"]) + if assets != sorted(assets, key=lambda item: (item["name"], item["asset_id"])): + raise TrustError("staged assets must be sorted") + validate_tag_rulesets(staging["tag_rulesets"], repository=staging["repository"], repository_id=staging["repository_id"]) + if staging["tag_rulesets_sha256"] != digest_bytes(TAG_RULESETS_DOMAIN, staging["tag_rulesets"]): + raise TrustError("tag rulesets digest is invalid") + require_timestamp(staging["observed_at"], "publication staging observed_at") + return staging + + +def staging_digest(value: Mapping[str, Any]) -> str: + return digest_bytes(STAGING_DOMAIN, value) + + +def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, Any]: + release_admission = closed( + value, + { + "schema_version", "admission_id_sha256", "target", "verdict", "claim_scope", + "release_identity", "release", "release_sha256", "artifact_inventory_sha256", + "publication_staging", "publication_staging_sha256", + "production_acceptance_summary_reference", + "production_acceptance_summary_bundle_reference", "authority_state_sha256", + "revocation_state_sha256", "signer_registry_sha256", + "publication_policy_sha256", "issued_at", "not_before", "expires_at", "issuer", + }, + "qualification release", + ) + if release_admission["schema_version"] != "openadapt.qualification-release/v1" or release_admission["verdict"] != "accepted": + raise TrustError("qualification release schema or verdict is invalid") + if release_admission["target"] not in TARGETS: + raise TrustError("qualification release target is invalid") + identity = closed(release_admission["release_identity"], {"schema_version", "channel", "sequence", "previous_admission_sha256"}, "release identity") + if identity["schema_version"] != "openadapt.monotonic-production-release/v1" or identity["channel"] != "production": + raise TrustError("release identity schema or channel is invalid") + require_positive_int(identity["sequence"], "release sequence") + if identity["previous_admission_sha256"] is not None: + require_digest(identity["previous_admission_sha256"], "previous release admission") + release = closed(release_admission["release"], {"schema_version", "kind", "source_repository", "source_repository_id", "source_commit", "version", "tag", "deployment_id", "deployment_sha256", "artifacts"}, "release candidate") + if release["schema_version"] != "openadapt.production-release-candidate/v1" or release["kind"] not in {"package", "deployment", "hybrid"}: + raise TrustError("release candidate schema or kind is invalid") + require_decimal_id(release["source_repository_id"], "release source repository id") + if not isinstance(release["source_commit"], str) or HEX40.fullmatch(release["source_commit"]) is None: + raise TrustError("release source commit must be exact") + artifacts = validate_artifacts(release["artifacts"]) + inventory = {"target": release_admission["target"], "claim_scope": release_admission["claim_scope"], "artifacts": artifacts} + if release_admission["artifact_inventory_sha256"] != digest_bytes(ARTIFACT_INVENTORY_DOMAIN, inventory): + raise TrustError("release artifact inventory digest is invalid") + if release_admission["release_sha256"] != digest_bytes(RELEASE_DOMAIN, {"target": release_admission["target"], "claim_scope": release_admission["claim_scope"], "release": release}): + raise TrustError("release candidate digest is invalid") + staging = validate_staging(release_admission["publication_staging"]) + if release_admission["publication_staging_sha256"] != staging_digest(staging): + raise TrustError("publication staging digest is invalid") + if staging["repository"] != release["source_repository"] or staging["repository_id"] != release["source_repository_id"] or staging["target_commitish"] != release["source_commit"] or staging["tag"] != release["tag"]: + raise TrustError("publication staging differs from the release candidate") + bound_fields = ("name", "kind", "sha256", "size_bytes", "media_type", "publish_destinations") + staged = [tuple(item[field] if field != "publish_destinations" else tuple(item[field]) for field in bound_fields) for item in staging["assets"]] + admitted = [tuple(item[field] if field != "publish_destinations" else tuple(item[field]) for field in bound_fields) for item in artifacts] + if sorted(staged) != sorted(admitted): + raise TrustError("publication staging assets differ from the admitted inventory") + validate_reference_pair(release_admission["production_acceptance_summary_reference"], release_admission["production_acceptance_summary_bundle_reference"], kind="production-acceptance-summary") + for field in ("authority_state_sha256", "revocation_state_sha256", "signer_registry_sha256", "publication_policy_sha256"): + require_digest(release_admission[field], field) + issuer = closed(release_admission["issuer"], {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, "release issuer") + if issuer != { + "repository": "OpenAdaptAI/.github", "repository_id": "858454062", "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-production-release-admission.yml", "ref": "refs/heads/main", + "source_commit": issuer["source_commit"], "environment": "production-release-admission", + } or not isinstance(issuer["source_commit"], str) or HEX40.fullmatch(issuer["source_commit"]) is None: + raise TrustError("release issuer identity differs") + validate_window(release_admission, maximum=timedelta(days=30), now=now) + projection = dict(release_admission) + admission_id = projection.pop("admission_id_sha256") + if admission_id != digest_bytes(RELEASE_ADMISSION_DOMAIN, projection): + raise TrustError("qualification release admission id is invalid") + return release_admission + + +def verify_local_artifacts(root: Path, artifacts: Sequence[Mapping[str, Any]]) -> None: + if not root.is_dir(): + raise TrustError("artifact root is not a directory") + actual_files = sorted(path.name for path in root.iterdir() if path.is_file()) + expected_files = sorted(item["name"] for item in artifacts) + if actual_files != expected_files: + raise TrustError("candidate artifact contains missing or extra files") + if any(path.is_symlink() for path in root.rglob("*")): + raise TrustError("candidate artifact cannot contain symlinks") + if any(path.is_file() and path.parent != root for path in root.rglob("*")): + raise TrustError("candidate artifact files must be at the artifact root") + for artifact in artifacts: + path = root / artifact["name"] + raw = path.read_bytes() + if len(raw) != artifact["size_bytes"] or "sha256:" + hashlib.sha256(raw).hexdigest() != artifact["sha256"]: + raise TrustError(f"candidate artifact bytes differ: {artifact['name']}") + + +def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, Any]: + fields = { + "schema_version", "decision_commitment_sha256", "evidence_manifest_sha256", + "campaign_artifact_sha256", "organization_id_sha256", "workflow_id_sha256", + "workflow_version_id_sha256", "bundle_version", "bundle_sha256", + "admitted_runtime_sha256", "application_contract_sha256", + "environment_contract_sha256", "input_contract_sha256", "action_contract_sha256", + "identity_contract_sha256", "effect_contract_sha256", "policy_contract_sha256", + "evidence_authority_sha256", "campaign_permit_sha256", "signer_registry_sha256", + "revocation_state_sha256", "entity_class", "campaign_summary", "verdict", + "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", "signature", + } + receipt = closed(value, fields, "qualification evidence decision receipt") + if receipt["schema_version"] != "openadapt.qualification-evidence-decision-receipt/v1" or receipt["verdict"] != "accepted": + raise TrustError("decision receipt schema or verdict is invalid") + digest_fields = fields - { + "schema_version", "bundle_version", "entity_class", "campaign_summary", "verdict", + "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", "signature", + } + for field in digest_fields: + require_digest(receipt[field], field) + if len({receipt["decision_commitment_sha256"], receipt["evidence_manifest_sha256"], receipt["campaign_artifact_sha256"]}) != 3: + raise TrustError("decision, final manifest, and campaign commitments must be distinct") + require_positive_int(receipt["bundle_version"], "bundle version") + if not isinstance(receipt["entity_class"], str) or ENTITY_CLASS.fullmatch(receipt["entity_class"]) is None: + raise TrustError("entity class is not remote-safe") + validate_campaign_summary(receipt["campaign_summary"]) + if receipt["algorithm"] != "ed25519" or not isinstance(receipt["issuer_key_id"], str) or evidence.KEY_ID.fullmatch(receipt["issuer_key_id"]) is None: + raise TrustError("decision receipt signer is invalid") + signature = receipt["signature"] + if not isinstance(signature, str) or "=" in signature: + raise TrustError("decision receipt signature must be unpadded base64url") + try: + raw_signature = evidence.base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4)) + except Exception as exc: + raise TrustError("decision receipt signature is invalid") from exc + if len(raw_signature) != 64: + raise TrustError("decision receipt signature must contain 64 bytes") + validate_window(receipt, maximum=timedelta(days=7), now=now) + return receipt + + +def validate_local_identity_opening(value: Any) -> dict[str, Any]: + opening = closed( + value, + { + "schema_version", "algorithm", "required", "customer_controlled_secret_required", + "exact_contract_match_required", "revalidation_before_actuation", + "maximum_age_seconds", + }, + "local identity opening", + ) + if opening != { + "schema_version": "openadapt.qualification-local-identity-opening/v1", + "algorithm": "hmac-sha256", + "required": True, + "customer_controlled_secret_required": True, + "exact_contract_match_required": True, + "revalidation_before_actuation": True, + "maximum_age_seconds": 60, + }: + raise TrustError("local customer-bound identity opening contract differs") + return opening + + +def validate_qualification_admission( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "admission_id_sha256", "organization_id_sha256", + "workflow_id_sha256", "workflow_version_id_sha256", "bundle_version", + "bundle_sha256", "admitted_runtime_sha256", "application_contract_sha256", + "environment_contract_sha256", "input_contract_sha256", "action_contract_sha256", + "identity_contract_sha256", "effect_contract_sha256", "policy_contract_sha256", + "evidence_authority_sha256", "campaign_artifact_sha256", "campaign_permit_sha256", + "decision_receipt_reference", "decision_receipt_bundle_reference", + "signer_registry_sha256", "revocation_state_sha256", "entity_class", + "campaign_summary", "local_identity_opening", "verdict", "issued_at", + "not_before", "expires_at", "issuer", + } + admission = closed(value, fields, "qualification admission") + if admission["schema_version"] != "openadapt.qualification-admission/v3" or admission["verdict"] != "accepted": + raise TrustError("qualification admission schema or verdict is invalid") + for field in fields: + if field.endswith("_sha256"): + require_digest(admission[field], field) + require_positive_int(admission["bundle_version"], "bundle version") + if not isinstance(admission["entity_class"], str) or ENTITY_CLASS.fullmatch(admission["entity_class"]) is None: + raise TrustError("qualification admission entity class is not remote-safe") + validate_campaign_summary(admission["campaign_summary"]) + validate_local_identity_opening(admission["local_identity_opening"]) + receipt_ref, _ = validate_reference_pair( + admission["decision_receipt_reference"], + admission["decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + ) + if admission["campaign_artifact_sha256"] == receipt_ref["object_sha256"]: + raise TrustError("campaign commitment cannot alias the receipt object") + issuer = closed( + admission["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "qualification admission issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/.github" + or issuer["repository_id"] != "858454062" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] != ".github/workflows/issue-qualification-admission.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "qualification-admission" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("qualification admission issuer differs") + validate_window(admission, maximum=timedelta(days=7), now=now) + projection = dict(admission) + admission_id = projection.pop("admission_id_sha256") + if admission_id != digest_bytes(ADMISSION_DOMAIN, projection): + raise TrustError("qualification admission id is invalid") + return admission + + +def validate_current_default(value: Any, *, now: datetime | None = None) -> dict[str, Any]: + current = closed( + value, + { + "schema_version", "default_set_revision", "previous_default_set_sha256", + "targets", "issued_at", "not_before", "expires_at", "issuer", + }, + "production current default", + ) + if current["schema_version"] != "openadapt.production-current-default/v1": + raise TrustError("current default schema is not supported") + require_positive_int(current["default_set_revision"], "default set revision") + if current["previous_default_set_sha256"] is not None: + require_digest(current["previous_default_set_sha256"], "previous default set") + targets = current["targets"] + if not isinstance(targets, list) or [item.get("target") for item in targets if isinstance(item, dict)] != list(TARGETS): + raise TrustError("current default must contain all seven targets in order") + for index, target_value in enumerate(targets): + target = closed( + target_value, + { + "target", "release_sha256", "artifact_inventory_sha256", "version", "tag", + "deployment_id", "deployment_sha256", "qualification_release_sha256", + "default_identity_sha256", + }, + f"current default target {index}", + ) + for field in ("release_sha256", "artifact_inventory_sha256", "qualification_release_sha256", "default_identity_sha256"): + require_digest(target[field], field) + if target["deployment_sha256"] is not None: + require_digest(target["deployment_sha256"], "deployment digest") + if target["deployment_id"] is not None: + require_decimal_id(target["deployment_id"], "deployment id") + closed(current["issuer"], {"repository", "repository_id", "workflow", "ref", "source_commit"}, "current default issuer") + validate_window(current, maximum=timedelta(days=7), now=now) + return current + + +def validate_projection(value: Any) -> dict[str, Any]: + projection = closed(value, {"schema_version", "overall_state", "targets"}, "lifecycle projection") + if projection["schema_version"] != "openadapt.production-lifecycle-projection/v2" or projection["overall_state"] not in {"Production", "not actively admitted"}: + raise TrustError("lifecycle projection schema or state is invalid") + targets = projection["targets"] + if not isinstance(targets, list) or [item.get("target") for item in targets if isinstance(item, dict)] != list(TARGETS): + raise TrustError("lifecycle projection must contain all seven targets in order") + all_production = True + for index, target_value in enumerate(targets): + target = closed( + target_value, + { + "target", "state", "claim_scope", "release_sha256", + "artifact_inventory_sha256", "release_admission_object_sha256", + "default_identity_sha256", "not_before", "expires_at", "reason_codes", + }, + f"lifecycle projection target {index}", + ) + if target["state"] not in {"Production", "not actively admitted"}: + raise TrustError("target lifecycle state is invalid") + all_production = all_production and target["state"] == "Production" + for field in ("release_sha256", "artifact_inventory_sha256", "release_admission_object_sha256", "default_identity_sha256"): + require_digest(target[field], field) + require_timestamp(target["not_before"], "target not_before") + require_timestamp(target["expires_at"], "target expires_at") + reasons = target["reason_codes"] + if not isinstance(reasons, list) or reasons != sorted(set(reasons)) or any(not isinstance(reason, str) or not reason for reason in reasons): + raise TrustError("reason codes must be sorted unique strings") + if target["state"] == "Production" and reasons: + raise TrustError("Production target cannot have reason codes") + if (projection["overall_state"] == "Production") != all_production: + raise TrustError("overall lifecycle state differs from target states") + return projection + + +def _validate_reference_pair_array(value: Any, *, release: bool) -> list[dict[str, Any]]: + if not isinstance(value, list) or not value: + raise TrustError("admission reference set is empty") + for index, item_value in enumerate(value): + fields = {"target", "admission_reference", "admission_bundle_reference"} if release else {"admission_reference", "admission_bundle_reference"} + item = closed(item_value, fields, f"admission reference pair {index}") + validate_reference_pair( + item["admission_reference"], item["admission_bundle_reference"], + kind="qualification-release" if release else "qualification-admission", + ) + if release: + if [item["target"] for item in value] != list(TARGETS): + raise TrustError("release admission set must contain all seven targets in order") + else: + digests = [item["admission_reference"]["object_sha256"] for item in value] + if digests != sorted(set(digests)): + raise TrustError("workflow admission set must be sorted and unique") + return value + + +def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, Any]: + fields = { + "schema_version", "checkpoint_id_sha256", "checkpoint_revision", + "previous_checkpoint_sha256", "registry_source_commit", "registry_revision", + "registry_head_sha256", "signer_registry", "lifecycle_policy_sha256", + "current_default_reference", "current_default_bundle_reference", + "lifecycle_projection", "lifecycle_projection_sha256", "release_admissions", + "release_admission_set_sha256", "workflow_admissions", + "workflow_admission_set_sha256", "authority_state_reference", + "authority_state_bundle_reference", "authority_state_sha256", + "revocation_state_reference", "revocation_state_bundle_reference", + "revocation_state_sha256", "generated_at", "not_before", "expires_at", "issuer", + } + checkpoint = closed(value, fields, "production lifecycle checkpoint") + if checkpoint["schema_version"] != "openadapt.production-lifecycle-checkpoint/v1": + raise TrustError("lifecycle checkpoint schema is not supported") + require_positive_int(checkpoint["checkpoint_revision"], "checkpoint revision") + if checkpoint["previous_checkpoint_sha256"] is not None: + require_digest(checkpoint["previous_checkpoint_sha256"], "previous checkpoint") + if not isinstance(checkpoint["registry_source_commit"], str) or HEX40.fullmatch(checkpoint["registry_source_commit"]) is None: + raise TrustError("checkpoint registry commit must be exact") + require_positive_int(checkpoint["registry_revision"], "checkpoint registry revision") + require_digest(checkpoint["registry_head_sha256"], "checkpoint registry head") + if not isinstance(checkpoint["signer_registry"], dict): + raise TrustError("checkpoint signer registry pointer is required") + projection = validate_projection(checkpoint["lifecycle_projection"]) + if checkpoint["lifecycle_projection_sha256"] != digest_bytes(PROJECTION_DOMAIN, projection): + raise TrustError("lifecycle projection digest is invalid") + release_set = _validate_reference_pair_array(checkpoint["release_admissions"], release=True) + if checkpoint["release_admission_set_sha256"] != digest_bytes(RELEASE_SET_DOMAIN, release_set): + raise TrustError("release admission set digest is invalid") + workflow_set = _validate_reference_pair_array(checkpoint["workflow_admissions"], release=False) + if checkpoint["workflow_admission_set_sha256"] != digest_bytes(WORKFLOW_SET_DOMAIN, workflow_set): + raise TrustError("workflow admission set digest is invalid") + validate_reference_pair(checkpoint["current_default_reference"], checkpoint["current_default_bundle_reference"], kind="production-current-default") + authority, _ = validate_reference_pair(checkpoint["authority_state_reference"], checkpoint["authority_state_bundle_reference"], kind="qualification-authority-state-receipt") + revocation, _ = validate_reference_pair(checkpoint["revocation_state_reference"], checkpoint["revocation_state_bundle_reference"], kind="qualification-revocation-state-receipt") + if checkpoint["authority_state_sha256"] != authority["object_sha256"] or checkpoint["revocation_state_sha256"] != revocation["object_sha256"]: + raise TrustError("checkpoint state digests differ from their references") + require_digest(checkpoint["lifecycle_policy_sha256"], "lifecycle policy digest") + require_timestamp(checkpoint["generated_at"], "checkpoint generated_at") + issuer = closed( + checkpoint["issuer"], + { + "repository", "repository_id", "workflow", "ref", "source_commit", + "policy_repository", "policy_repository_id", "policy_source_commit", "policy_path", + }, + "checkpoint issuer", + ) + for field in ("source_commit", "policy_source_commit"): + if not isinstance(issuer[field], str) or HEX40.fullmatch(issuer[field]) is None: + raise TrustError("checkpoint issuer commit must be exact") + if issuer["policy_repository"] != "OpenAdaptAI/.github" or issuer["policy_repository_id"] != "858454062" or issuer["policy_path"] != "production-lifecycle-policy.json": + raise TrustError("checkpoint lifecycle policy source differs") + pseudo = {"issued_at": checkpoint["generated_at"], "not_before": checkpoint["not_before"], "expires_at": checkpoint["expires_at"]} + validate_window(pseudo, maximum=timedelta(days=7), now=now) + projection_without_id = dict(checkpoint) + checkpoint_id = projection_without_id.pop("checkpoint_id_sha256") + if checkpoint_id != digest_bytes(CHECKPOINT_DOMAIN, projection_without_id): + raise TrustError("checkpoint id is invalid") + return checkpoint + + +def validate_feed(value: Any, *, now: datetime | None = None) -> dict[str, Any]: + feed = closed( + value, + { + "schema_version", "repository", "repository_id", "repository_owner_id", "ref", + "feed_revision", "generated_at", "expires_at", "registry_source_commit", + "registry_revision", "registry_head_sha256", "signer_registry", "checkpoints", + }, + "production lifecycle feed", + ) + if feed["schema_version"] != "openadapt.production-lifecycle-feed/v1" or feed["repository"] != "OpenAdaptAI/.github" or feed["repository_id"] != "858454062" or feed["repository_owner_id"] != "132681217" or feed["ref"] != "refs/heads/production-lifecycle-feed": + raise TrustError("lifecycle feed identity differs") + require_positive_int(feed["feed_revision"], "feed revision") + if not isinstance(feed["registry_source_commit"], str) or HEX40.fullmatch(feed["registry_source_commit"]) is None: + raise TrustError("feed registry commit must be exact") + require_positive_int(feed["registry_revision"], "feed registry revision") + require_digest(feed["registry_head_sha256"], "feed registry head") + if not isinstance(feed["signer_registry"], dict): + raise TrustError("feed signer registry pointer is required") + checkpoints = feed["checkpoints"] + if not isinstance(checkpoints, list) or len(checkpoints) not in {1, 2}: + raise TrustError("feed must select one or two checkpoints") + for index, pair_value in enumerate(checkpoints): + pair = closed(pair_value, {"checkpoint_reference", "checkpoint_bundle_reference"}, f"feed checkpoint {index}") + validate_reference_pair(pair["checkpoint_reference"], pair["checkpoint_bundle_reference"], kind="production-lifecycle-checkpoint") + generated = require_timestamp(feed["generated_at"], "feed generated_at") + expires = require_timestamp(feed["expires_at"], "feed expires_at") + if not generated < expires <= generated + timedelta(days=7): + raise TrustError("feed lifetime is invalid") + if now is not None and not generated <= now < expires: + raise TrustError("feed is not current") + return feed + + +def validate_feed_update(value: Any) -> dict[str, Any]: + fields = { + "schema_version", "event_type", "source_repository", "source_repository_id", + "source_ref", "source_commit", "target_repository", "target_repository_id", + "target_ref", "expected_old_commit", "new_commit", "feed_path", "feed_sha256", + "checkpoint_sha256", "registry_head_sha256", "expires_at", "idempotency_key", + } + update = closed(value, fields, "lifecycle feed update") + constants = { + "schema_version": "openadapt.production-lifecycle-feed-update/v1", + "event_type": "production_lifecycle_feed_updated", + "source_repository": "OpenAdaptAI/openadapt-ops", + "source_repository_id": "1172011294", + "source_ref": "refs/heads/main", + "target_repository": "OpenAdaptAI/.github", + "target_repository_id": "858454062", + "target_ref": "refs/heads/production-lifecycle-feed", + "feed_path": "production-lifecycle-feed.json", + } + for field, expected in constants.items(): + if update[field] != expected: + raise TrustError(f"lifecycle feed update {field} differs") + for field in ("source_commit", "new_commit"): + if not isinstance(update[field], str) or HEX40.fullmatch(update[field]) is None: + raise TrustError(f"lifecycle feed update {field} must be exact") + if update["expected_old_commit"] is not None and (not isinstance(update["expected_old_commit"], str) or HEX40.fullmatch(update["expected_old_commit"]) is None): + raise TrustError("lifecycle feed expected old commit is invalid") + for field in ("feed_sha256", "checkpoint_sha256", "registry_head_sha256"): + require_digest(update[field], field) + require_timestamp(update["expires_at"], "feed update expires_at") + projection = dict(update) + idempotency_key = projection.pop("idempotency_key") + expected_key = "lifecycle-feed-update:" + hashlib.sha256( + FEED_UPDATE_IDEMPOTENCY_DOMAIN + canonical(projection) + ).hexdigest() + if idempotency_key != expected_key: + raise TrustError("lifecycle feed update idempotency key is invalid") + return update diff --git a/scripts/stage_production_evidence.py b/scripts/stage_production_evidence.py new file mode 100644 index 0000000..a324369 --- /dev/null +++ b/scripts/stage_production_evidence.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Append one regular object and its raw Sigstore bundle to the v2 registry.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +from pathlib import Path + +import validate_evidence_registry as registry + + +def entry_for(raw: bytes, *, kind: str, subject: str | None) -> dict: + schema, media = registry.OBJECT_KIND_CONTRACTS[kind] + object_sha = "sha256:" + hashlib.sha256(raw).hexdigest() + digest_hex = object_sha.removeprefix("sha256:") + if kind.endswith("-sigstore-bundle"): + identity_value = subject + else: + value = json.loads(raw) + if not isinstance(value, dict) or value.get("schema_version") != schema: + raise registry.EvidenceRegistryError("regular object schema differs from kind") + identity_value = value + entry = { + "kind": kind, + "object_schema_version": schema, + "object_path": ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ), + "object_sha256": object_sha, + "size_bytes": len(raw), + "object_media_type": media, + "semantic_identity_sha256": registry.semantic_identity_digest( + kind=kind, + object_schema_version=schema, + object_value=identity_value, + object_sha256=object_sha, + ), + "subject_sha256": subject, + } + entry["registry_entry_sha256"] = registry.entry_digest(entry) + return entry + + +def install_signer_registry(document: dict, source: Path, root: Path) -> None: + raw = source.read_bytes() + value = registry.validate_signer_registry(json.loads(raw)) + object_sha = "sha256:" + hashlib.sha256(raw).hexdigest() + digest_hex = object_sha.removeprefix("sha256:") + relative = ( + f"production-evidence/signer-registries/sha256/{digest_hex[:2]}/" + f"{digest_hex}.qualification-signer-registry.json" + ) + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and target.read_bytes() != raw: + raise registry.EvidenceRegistryError("signer registry content address collides") + target.write_bytes(raw) + document["signer_registry"] = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": relative, + "object_sha256": object_sha, + "registry_identity_sha256": registry.signer_registry_identity_digest(value), + "registry_revision": value["revision"], + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--registry", default=str(registry.REGISTRY_PATH)) + parser.add_argument("--kind", required=True) + parser.add_argument("--object", required=True) + parser.add_argument("--bundle", required=True) + parser.add_argument("--signer-registry") + args = parser.parse_args(argv) + try: + if args.kind not in registry.REGULAR_KIND_CONTRACTS: + raise registry.EvidenceRegistryError("kind is not a regular registered kind") + registry_path = Path(args.registry).resolve() + root = registry_path.parent + document = json.loads(registry_path.read_text(encoding="utf-8")) + registry.validate_registry(document, root=root) + previous_head = document["registry_head_sha256"] + if args.signer_registry: + install_signer_registry(document, Path(args.signer_registry), root) + if document["signer_registry"] is None: + raise registry.EvidenceRegistryError("signer registry must be installed first") + regular_raw = Path(args.object).read_bytes() + bundle_raw = Path(args.bundle).read_bytes() + json.loads(bundle_raw) + regular = entry_for(regular_raw, kind=args.kind, subject=None) + bundle = entry_for( + bundle_raw, + kind=f"{args.kind}-sigstore-bundle", + subject=regular["object_sha256"], + ) + existing = {item["object_sha256"] for item in document["entries"]} + if regular["object_sha256"] in existing or bundle["object_sha256"] in existing: + raise registry.EvidenceRegistryError("object is already registered") + for raw, item in ((regular_raw, regular), (bundle_raw, bundle)): + target = root / item["object_path"] + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and target.read_bytes() != raw: + raise registry.EvidenceRegistryError("content-addressed object collides") + target.write_bytes(raw) + document["revision"] += 1 + document["previous_registry_head_sha256"] = previous_head + document["entries"].extend((regular, bundle)) + document["registry_head_sha256"] = registry.registry_head_digest(document) + registry_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + registry.validate_registry(document, root=root) + print(json.dumps({"regular": regular, "bundle": bundle}, sort_keys=True)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index 782e92b..50ac193 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -1,64 +1,166 @@ #!/usr/bin/env python3 -"""Content-addressed central evidence registry. +"""Validate the public, content-addressed production evidence registry. -Every piece of remote-safe acceptance evidence that a Production admission can -reference must be registered here first, bound by its exact SHA-256 digest. -The registry is append-only and hash-chained: each entry commits to the digest -of the previous entry, so any reordering, removal, or edit of history breaks -the chain and validation fails closed. - -The registry stores only public, privacy-safe references: an HTTPS URL, a -digest, a byte length, and recording metadata. It never stores report bodies, -frames, parameters, or any private certificate material. +The registry never accepts a caller-supplied transport URL. A consumer derives +the supported transport from the pinned repository identity, an exact +40-character registry commit, and the registered object path. """ from __future__ import annotations import argparse +import base64 +import binascii import hashlib import json import re import sys -from pathlib import Path from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from pathlib import Path, PurePosixPath from typing import Any ROOT = Path(__file__).resolve().parents[1] REGISTRY_PATH = ROOT / "evidence-registry.json" -REGISTRY_SCHEMA = "openadapt.production-evidence-registry/v1" +REGISTRY_SCHEMA = "openadapt.production-evidence-registry/v2" +REFERENCE_SCHEMA = "openadapt.production-evidence-object-reference/v2" +SIGNER_POINTER_SCHEMA = "openadapt.qualification-signer-registry-pointer/v1" +SIGNER_REGISTRY_SCHEMA = "openadapt.qualification-signer-registry/v2" ENTRY_DIGEST_DOMAIN = b"OpenAdapt production evidence registry entry v1\0" +HEAD_DIGEST_DOMAIN = b"OpenAdapt production evidence registry head v2\0" +SEMANTIC_IDENTITY_DOMAIN = b"OpenAdapt production evidence semantic identity v1\0" +BUNDLE_IDENTITY_DOMAIN = b"OpenAdapt production evidence Sigstore bundle identity v1\0" +SIGNER_REGISTRY_IDENTITY_DOMAIN = b"OpenAdapt qualification signer registry v2\0" +REPOSITORY = "OpenAdaptAI/.github" +REPOSITORY_ID = "858454062" +REPOSITORY_OWNER_ID = "132681217" +RAW_GITHUB_ORIGIN = "https://raw.githubusercontent.com" + SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") -MILLISECOND_UTC = re.compile( - r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +KIND = re.compile(r"^[a-z][a-z0-9-]{1,95}$") +TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" ) -ENTRY_KINDS = ( - "evidence-summary", - "attestation-bundle", - "evidence-manifest", - "acceptance-run-record", +KEY_ID = re.compile(r"^qa-ed25519-[0-9a-f]{16}$") +WORKFLOW_IDENTITY = re.compile( + r"^https://github\.com/OpenAdaptAI/[A-Za-z0-9_.-]+/\.github/workflows/" + r"[A-Za-z0-9_.-]+\.ya?ml@refs/heads/[A-Za-z0-9._/-]+$" ) +BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json" + +REGULAR_KIND_CONTRACTS: dict[str, tuple[str, str]] = { + "production-acceptance-manifest": ( + "openadapt.production-acceptance/v2", + "application/vnd.openadapt.production-acceptance+json;version=2", + ), + "production-acceptance-summary": ( + "openadapt.production-lifecycle-evidence-summary/v2", + "application/vnd.openadapt.production-lifecycle-evidence-summary+json;version=2", + ), + "production-cloud-deploy-authorization": ( + "openadapt.production-cloud-deploy-authorization/v1", + "application/vnd.openadapt.production-cloud-deploy-authorization+json;version=1", + ), + "production-cloud-deployment-result": ( + "openadapt.production-cloud-deployment-result/v1", + "application/vnd.openadapt.production-cloud-deployment-result+json;version=1", + ), + "production-current-default": ( + "openadapt.production-current-default/v1", + "application/vnd.openadapt.production-current-default+json;version=1", + ), + "production-deployment-observation": ( + "openadapt.production-deployment-observation/v1", + "application/vnd.openadapt.production-deployment-observation+json;version=1", + ), + "production-lifecycle-checkpoint": ( + "openadapt.production-lifecycle-checkpoint/v1", + "application/vnd.openadapt.production-lifecycle-checkpoint+json;version=1", + ), + "qualification-admission": ( + "openadapt.qualification-admission/v3", + "application/vnd.openadapt.qualification-admission+json;version=3", + ), + "qualification-authority-state-receipt": ( + "openadapt.qualification-authority-state-receipt/v2", + "application/vnd.openadapt.qualification-authority-state-receipt+json;version=2", + ), + "qualification-campaign-permit": ( + "openadapt.qualification-campaign-permit/v3", + "application/vnd.openadapt.qualification-campaign-permit+json;version=3", + ), + "qualification-campaign-permit-policy": ( + "openadapt.qualification-campaign-permit-policy/v3", + "application/vnd.openadapt.qualification-campaign-permit-policy+json;version=3", + ), + "qualification-campaign-permit-receipt": ( + "openadapt.qualification-campaign-permit-receipt/v3", + "application/vnd.openadapt.qualification-campaign-permit-receipt+json;version=3", + ), + "qualification-campaign-permit-request": ( + "openadapt.qualification-campaign-permit-request/v3", + "application/vnd.openadapt.qualification-campaign-permit-request+json;version=3", + ), + "qualification-evidence-decision-receipt": ( + "openadapt.qualification-evidence-decision-receipt/v1", + "application/vnd.openadapt.qualification-evidence-decision-receipt+json;version=1", + ), + "qualification-release": ( + "openadapt.qualification-release/v1", + "application/vnd.openadapt.qualification-release+json;version=1", + ), + "qualification-revocation-state-receipt": ( + "openadapt.qualification-revocation-state-receipt/v1", + "application/vnd.openadapt.qualification-revocation-state-receipt+json;version=1", + ), +} +OBJECT_KIND_CONTRACTS: dict[str, tuple[str, str]] = dict(REGULAR_KIND_CONTRACTS) +for _kind in tuple(REGULAR_KIND_CONTRACTS): + OBJECT_KIND_CONTRACTS[f"{_kind}-sigstore-bundle"] = ( + BUNDLE_MEDIA_TYPE, + BUNDLE_MEDIA_TYPE, + ) + +ENTRY_FIELDS = { + "kind", + "object_media_type", + "object_path", + "object_schema_version", + "object_sha256", + "semantic_identity_sha256", + "size_bytes", + "subject_sha256", +} +REFERENCE_FIELDS = { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + *ENTRY_FIELDS, +} class EvidenceRegistryError(ValueError): - """The evidence registry is invalid or does not bind the referenced object.""" + """The registry, object, or exact-commit reference is invalid.""" -def _canonical(value: Any) -> bytes: +def canonical(value: Any) -> bytes: return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=False ).encode("utf-8") def _closed(value: Any, keys: set[str], label: str) -> dict[str, Any]: - if not isinstance(value, dict): - raise EvidenceRegistryError(f"{label} must be an object") - unexpected = set(value) - keys - missing = keys - set(value) - if unexpected or missing: + if not isinstance(value, dict) or set(value) != keys: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ raise EvidenceRegistryError( - f"{label} has unexpected fields {sorted(unexpected)} " - f"and missing fields {sorted(missing)}" + f"{label} must contain exactly {sorted(keys)}; got {actual}" ) return value @@ -69,208 +171,458 @@ def _digest(value: Any, label: str) -> str: return value -def _timestamp(value: Any, label: str) -> str: - if not isinstance(value, str) or MILLISECOND_UTC.fullmatch(value) is None: - raise EvidenceRegistryError(f"{label} must be a millisecond UTC timestamp") +def _positive_integer(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise EvidenceRegistryError(f"{label} must be a positive integer") return value -def _url(value: Any, label: str) -> str: - if not isinstance(value, str) or not value.startswith("https://"): - raise EvidenceRegistryError(f"{label} must be an HTTPS URL") - parsed = value.split("?", 1)[0].split("#", 1)[0] - if not parsed.startswith("https://") or len(value) > 2048: - raise EvidenceRegistryError(f"{label} is not a bounded HTTPS URL") +def _timestamp(value: Any, label: str) -> datetime: + if not isinstance(value, str) or TIMESTAMP.fullmatch(value) is None: + raise EvidenceRegistryError(f"{label} must be an exact UTC timestamp") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError as exc: + raise EvidenceRegistryError(f"{label} is not a calendar timestamp") from exc + + +def semantic_identity_digest( + *, kind: str, object_schema_version: str, object_value: Any, object_sha256: str +) -> str: + if kind.endswith("-sigstore-bundle"): + payload = { + "kind": kind, + "object_sha256": object_sha256, + "subject_sha256": object_value, + } + domain = BUNDLE_IDENTITY_DOMAIN + else: + payload = { + "kind": kind, + "object_schema_version": object_schema_version, + "object": object_value, + } + domain = SEMANTIC_IDENTITY_DOMAIN + return "sha256:" + hashlib.sha256(domain + canonical(payload)).hexdigest() + + +def signer_registry_identity_digest(value: Mapping[str, Any]) -> str: + return "sha256:" + hashlib.sha256( + SIGNER_REGISTRY_IDENTITY_DOMAIN + canonical(value) + ).hexdigest() + + +def validate_signer_registry(value: Any) -> dict[str, Any]: + registry = _closed( + value, + {"schema_version", "revision", "generated_at", "expires_at", "signers"}, + "signer registry", + ) + if registry["schema_version"] != SIGNER_REGISTRY_SCHEMA: + raise EvidenceRegistryError("signer registry schema is not supported") + _positive_integer(registry["revision"], "signer registry revision") + generated_at = _timestamp(registry["generated_at"], "signer registry generated_at") + expires_at = _timestamp(registry["expires_at"], "signer registry expires_at") + if not generated_at < expires_at <= generated_at + timedelta(days=7): + raise EvidenceRegistryError( + "signer registry lifetime must be positive and at most seven days" + ) + signers = registry["signers"] + if not isinstance(signers, list) or not signers: + raise EvidenceRegistryError("signer registry must contain at least one signer") + seen_ids: set[str] = set() + for index, signer_value in enumerate(signers): + label = f"signer registry signer {index}" + signer = _closed( + signer_value, + { + "algorithm", + "key_id", + "public_key", + "allowed_workflows", + "allowed_ref_prefixes", + "status", + "revoked_at", + }, + label, + ) + if signer["algorithm"] != "ed25519": + raise EvidenceRegistryError(f"{label} algorithm must be ed25519") + key_id = signer["key_id"] + if not isinstance(key_id, str) or KEY_ID.fullmatch(key_id) is None: + raise EvidenceRegistryError(f"{label} key id is invalid") + if key_id in seen_ids: + raise EvidenceRegistryError("signer registry key ids must be unique") + seen_ids.add(key_id) + public_key = signer["public_key"] + if not isinstance(public_key, str) or "=" in public_key: + raise EvidenceRegistryError( + f"{label} public key must be unpadded base64url" + ) + try: + key_bytes = base64.urlsafe_b64decode( + public_key + "=" * (-len(public_key) % 4) + ) + except (ValueError, binascii.Error) as exc: + raise EvidenceRegistryError(f"{label} public key is invalid") from exc + canonical_key = base64.urlsafe_b64encode(key_bytes).decode().rstrip("=") + if len(key_bytes) != 32 or canonical_key != public_key: + raise EvidenceRegistryError( + f"{label} public key must encode 32 canonical bytes" + ) + expected_key_id = "qa-ed25519-" + hashlib.sha256(key_bytes).hexdigest()[:16] + if key_id != expected_key_id: + raise EvidenceRegistryError( + f"{label} key id does not bind the public key" + ) + workflows = signer["allowed_workflows"] + if ( + not isinstance(workflows, list) + or not workflows + or workflows != sorted(set(workflows)) + or any( + not isinstance(item, str) + or WORKFLOW_IDENTITY.fullmatch(item) is None + for item in workflows + ) + ): + raise EvidenceRegistryError(f"{label} workflow allowlist is invalid") + prefixes = signer["allowed_ref_prefixes"] + if ( + not isinstance(prefixes, list) + or not prefixes + or prefixes != sorted(set(prefixes)) + or any( + not isinstance(item, str) + or not item.startswith("refs/") + or ".." in item + for item in prefixes + ) + ): + raise EvidenceRegistryError(f"{label} ref-prefix allowlist is invalid") + if signer["status"] == "active": + if signer["revoked_at"] is not None: + raise EvidenceRegistryError( + f"{label} active signer cannot have revoked_at" + ) + elif signer["status"] == "revoked": + revoked_at = _timestamp(signer["revoked_at"], f"{label} revoked_at") + if not generated_at <= revoked_at <= expires_at: + raise EvidenceRegistryError( + f"{label} revocation time is outside the registry lifetime" + ) + else: + raise EvidenceRegistryError(f"{label} status is invalid") + return registry + + +def _object_path(value: Any, kind: str, object_sha256: str) -> str: + if not isinstance(value, str) or not value or len(value) > 512: + raise EvidenceRegistryError("object path is invalid") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise EvidenceRegistryError("object path must be a relative normalized path") + digest_hex = object_sha256.removeprefix("sha256:") + expected = ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ) + if value != expected: + raise EvidenceRegistryError( + f"object path must be the content-addressed path {expected}" + ) return value -def _size(value: Any, label: str) -> int: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise EvidenceRegistryError(f"{label} must be a positive byte count") - return value +def _validate_entry_projection(value: Any, label: str) -> dict[str, Any]: + entry = _closed(value, ENTRY_FIELDS, label) + kind = entry["kind"] + if not isinstance(kind, str) or KIND.fullmatch(kind) is None: + raise EvidenceRegistryError(f"{label} kind is invalid") + contract = OBJECT_KIND_CONTRACTS.get(kind) + if contract is None: + raise EvidenceRegistryError(f"{label} kind is not registered: {kind}") + if (entry["object_schema_version"], entry["object_media_type"]) != contract: + raise EvidenceRegistryError(f"{label} schema or media type differs from policy") + object_sha256 = _digest(entry["object_sha256"], f"{label} object digest") + _digest(entry["semantic_identity_sha256"], f"{label} semantic identity") + _positive_integer(entry["size_bytes"], f"{label} size") + _object_path(entry["object_path"], kind, object_sha256) + subject = entry["subject_sha256"] + if kind.endswith("-sigstore-bundle"): + _digest(subject, f"{label} bundle subject") + elif subject is not None: + raise EvidenceRegistryError(f"{label} regular object subject must be null") + return entry def entry_digest(entry: Mapping[str, Any]) -> str: - """Return the chained digest of one exact registry entry.""" - - canonical = { - "kind": entry["kind"], - "prior_entry_sha256": entry["prior_entry_sha256"], - "recorded_at": entry["recorded_at"], - "sequence": entry["sequence"], - "sha256": entry["sha256"], - "size_bytes": entry["size_bytes"], - "url": entry["url"], + projection = {field: entry[field] for field in ENTRY_FIELDS} + return "sha256:" + hashlib.sha256( + ENTRY_DIGEST_DOMAIN + canonical(projection) + ).hexdigest() + + +def registry_head_digest(document: Mapping[str, Any]) -> str: + payload = { + "schema_version": document["schema_version"], + "repository": document["repository"], + "repository_id": document["repository_id"], + "repository_owner_id": document["repository_owner_id"], + "revision": document["revision"], + "previous_registry_head_sha256": document["previous_registry_head_sha256"], + "signer_registry": document["signer_registry"], + "entry_sha256s": [ + entry["registry_entry_sha256"] for entry in document["entries"] + ], } + return "sha256:" + hashlib.sha256( + HEAD_DIGEST_DOMAIN + canonical(payload) + ).hexdigest() + + +def raw_github_url(reference: Mapping[str, Any]) -> str: + """Derive the immutable raw GitHub URL from a validated reference.""" + + validated = validate_reference(reference) return ( - "sha256:" - + hashlib.sha256(ENTRY_DIGEST_DOMAIN + _canonical(canonical)).hexdigest() + f"{RAW_GITHUB_ORIGIN}/{validated['repository']}/" + f"{validated['registry_source_commit']}/{validated['object_path']}" ) -def build_entry( - *, - sequence: int, - kind: str, - url: str, - sha256: str, - size_bytes: int, - recorded_at: str, - prior_entry_sha256: str | None, -) -> dict[str, Any]: - """Build one validated registry entry with its computed chain digest.""" - - if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1: - raise EvidenceRegistryError("entry sequence must be a positive integer") - if kind not in ENTRY_KINDS: - raise EvidenceRegistryError(f"entry kind is not supported: {kind!r}") - entry = { - "sequence": sequence, - "kind": kind, - "url": _url(url, "entry url"), - "sha256": _digest(sha256, "entry digest"), - "size_bytes": _size(size_bytes, "entry size"), - "recorded_at": _timestamp(recorded_at, "entry recorded_at"), - "prior_entry_sha256": ( - None if prior_entry_sha256 is None - else _digest(prior_entry_sha256, "entry prior digest") - ), - } - entry["entry_sha256"] = entry_digest(entry) - return entry - +def validate_reference(value: Any) -> dict[str, Any]: + reference = _closed(value, REFERENCE_FIELDS, "production evidence reference") + if reference["schema_version"] != REFERENCE_SCHEMA: + raise EvidenceRegistryError("object reference schema is not supported") + if ( + reference["repository"], + reference["repository_id"], + reference["repository_owner_id"], + ) != (REPOSITORY, REPOSITORY_ID, REPOSITORY_OWNER_ID): + raise EvidenceRegistryError("object reference repository identity differs") + if not isinstance(reference["registry_source_commit"], str) or HEX40.fullmatch( + reference["registry_source_commit"] + ) is None: + raise EvidenceRegistryError("registry source commit must be exact lowercase hex") + _positive_integer(reference["registry_revision"], "registry revision") + _digest(reference["registry_head_sha256"], "registry head") + projection = {field: reference[field] for field in ENTRY_FIELDS} + _validate_entry_projection(projection, "production evidence reference") + if reference["registry_entry_sha256"] != entry_digest(projection): + raise EvidenceRegistryError("object reference entry digest is invalid") + return reference + + +def _validate_signer_pointer(value: Any) -> dict[str, Any] | None: + if value is None: + return None + pointer = _closed( + value, + { + "schema_version", + "object_path", + "object_sha256", + "registry_identity_sha256", + "registry_revision", + }, + "signer registry pointer", + ) + if pointer["schema_version"] != SIGNER_POINTER_SCHEMA: + raise EvidenceRegistryError("signer registry pointer schema is not supported") + digest = _digest(pointer["object_sha256"], "signer registry object digest") + _digest(pointer["registry_identity_sha256"], "signer registry identity") + _positive_integer(pointer["registry_revision"], "signer registry revision") + digest_hex = digest.removeprefix("sha256:") + expected = ( + f"production-evidence/signer-registries/sha256/{digest_hex[:2]}/" + f"{digest_hex}.qualification-signer-registry.json" + ) + if pointer["object_path"] != expected: + raise EvidenceRegistryError("signer registry pointer path is not content-addressed") + return pointer -def validate_registry(value: Any) -> list[dict[str, Any]]: - """Validate the complete registry document and return its entries.""" +def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, Any]]: document = _closed( value, - {"$schema", "schema_version", "head_entry_sha256", "entries"}, + { + "$schema", + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "revision", + "previous_registry_head_sha256", + "registry_head_sha256", + "signer_registry", + "entries", + }, "evidence registry", ) if document["$schema"] != "schemas/evidence-registry.schema.json": raise EvidenceRegistryError("evidence registry $schema is invalid") if document["schema_version"] != REGISTRY_SCHEMA: raise EvidenceRegistryError("evidence registry schema is not supported") + if ( + document["repository"], + document["repository_id"], + document["repository_owner_id"], + ) != (REPOSITORY, REPOSITORY_ID, REPOSITORY_OWNER_ID): + raise EvidenceRegistryError("evidence registry repository identity differs") + _positive_integer(document["revision"], "registry revision") + previous = document["previous_registry_head_sha256"] + if previous is not None: + _digest(previous, "previous registry head") + signer_pointer = _validate_signer_pointer(document["signer_registry"]) entries = document["entries"] if not isinstance(entries, list): - raise EvidenceRegistryError("evidence registry entries must be a list") - - previous_digest: str | None = None - for index, item in enumerate(entries): + raise EvidenceRegistryError("registry entries must be a list") + if entries and signer_pointer is None: + raise EvidenceRegistryError("a non-empty registry requires a signer registry pointer") + + seen_entries: set[str] = set() + seen_paths: set[str] = set() + previous_regular: dict[str, Any] | None = None + validated: list[dict[str, Any]] = [] + for index, value_entry in enumerate(entries): entry = _closed( - item, - { - "sequence", - "kind", - "url", - "sha256", - "size_bytes", - "recorded_at", - "prior_entry_sha256", - "entry_sha256", - }, + value_entry, + {"registry_entry_sha256", *ENTRY_FIELDS}, f"registry entry {index}", ) - if entry["sequence"] != index + 1: - raise EvidenceRegistryError( - f"registry entry {index} sequence must be exactly {index + 1}" - ) - expected_prior = previous_digest - actual_prior = entry["prior_entry_sha256"] - if expected_prior != actual_prior: - raise EvidenceRegistryError( - f"registry entry {index} does not chain to its predecessor" - ) - if entry["kind"] not in ENTRY_KINDS: - raise EvidenceRegistryError( - f"registry entry {index} kind is not supported" - ) - _url(entry["url"], f"registry entry {index} url") - _digest(entry["sha256"], f"registry entry {index} digest") - _size(entry["size_bytes"], f"registry entry {index} size") - _timestamp(entry["recorded_at"], f"registry entry {index} recorded_at") - computed = entry_digest(entry) - if entry["entry_sha256"] != computed: - raise EvidenceRegistryError( - f"registry entry {index} chained digest is invalid" + projection = {field: entry[field] for field in ENTRY_FIELDS} + _validate_entry_projection(projection, f"registry entry {index}") + computed = entry_digest(projection) + if entry["registry_entry_sha256"] != computed: + raise EvidenceRegistryError(f"registry entry {index} digest is invalid") + if computed in seen_entries or entry["object_path"] in seen_paths: + raise EvidenceRegistryError("registry entries must be unique") + seen_entries.add(computed) + seen_paths.add(entry["object_path"]) + if entry["kind"].endswith("-sigstore-bundle"): + expected_regular_kind = entry["kind"].removesuffix("-sigstore-bundle") + if ( + previous_regular is None + or previous_regular["kind"] != expected_regular_kind + or entry["subject_sha256"] != previous_regular["object_sha256"] + ): + raise EvidenceRegistryError( + "a Sigstore bundle must immediately follow and bind its regular object" + ) + previous_regular = None + else: + previous_regular = entry + if root is not None: + path = root / entry["object_path"] + try: + raw = path.read_bytes() + except OSError as exc: + raise EvidenceRegistryError(f"registered object is missing: {path}") from exc + actual = "sha256:" + hashlib.sha256(raw).hexdigest() + if actual != entry["object_sha256"] or len(raw) != entry["size_bytes"]: + raise EvidenceRegistryError(f"registered object bytes differ: {path}") + try: + object_value = json.loads(raw) + except json.JSONDecodeError as exc: + raise EvidenceRegistryError( + f"registered object is not JSON: {path}" + ) from exc + if entry["kind"].endswith("-sigstore-bundle"): + identity_value: Any = entry["subject_sha256"] + else: + if ( + not isinstance(object_value, dict) + or object_value.get("schema_version") + != entry["object_schema_version"] + ): + raise EvidenceRegistryError( + f"registered object schema differs: {path}" + ) + identity_value = object_value + expected_identity = semantic_identity_digest( + kind=entry["kind"], + object_schema_version=entry["object_schema_version"], + object_value=identity_value, + object_sha256=entry["object_sha256"], ) - previous_digest = computed - - head = document["head_entry_sha256"] - if entries: - if head != previous_digest: - raise EvidenceRegistryError("evidence registry head digest is stale") - elif head is not None: - raise EvidenceRegistryError("empty evidence registry must have a null head") - return entries + if entry["semantic_identity_sha256"] != expected_identity: + raise EvidenceRegistryError( + f"registered object semantic identity differs: {path}" + ) + validated.append(entry) + + if document["registry_head_sha256"] != registry_head_digest(document): + raise EvidenceRegistryError("registry head digest is invalid") + if root is not None and signer_pointer is not None: + signer_path = root / signer_pointer["object_path"] + try: + signer_raw = signer_path.read_bytes() + signer_value = json.loads(signer_raw) + except (OSError, json.JSONDecodeError) as exc: + raise EvidenceRegistryError("signer registry object is missing or invalid") from exc + if "sha256:" + hashlib.sha256(signer_raw).hexdigest() != signer_pointer["object_sha256"]: + raise EvidenceRegistryError("signer registry object digest differs") + signer_registry = validate_signer_registry(signer_value) + if signer_registry["revision"] != signer_pointer["registry_revision"]: + raise EvidenceRegistryError("signer registry revision differs") + if ( + signer_registry_identity_digest(signer_registry) + != signer_pointer["registry_identity_sha256"] + ): + raise EvidenceRegistryError("signer registry identity differs") + return validated def require_registered( - entries: list[dict[str, Any]], - *, - url: str, - sha256: str, - kind: str, - label: str, -) -> None: - """Fail closed unless the exact (url, digest) pair is registered.""" - - wanted_url = _url(url, f"{label} url") - wanted_digest = _digest(sha256, f"{label} digest") + entries: list[dict[str, Any]], *, reference: Mapping[str, Any], label: str +) -> dict[str, Any]: + wanted = validate_reference(reference) for entry in entries: - if entry["url"] == wanted_url and entry["sha256"] == wanted_digest: - if entry["kind"] != kind: - raise EvidenceRegistryError( - f"{label} is registered with kind {entry['kind']!r}, " - f"not {kind!r}" - ) - return - raise EvidenceRegistryError( - f"{label} is not registered in the central evidence registry" - ) + if entry["registry_entry_sha256"] == wanted["registry_entry_sha256"]: + for field in ENTRY_FIELDS: + if entry[field] != wanted[field]: + raise EvidenceRegistryError(f"{label} differs from its registry entry") + return entry + raise EvidenceRegistryError(f"{label} is not registered") def validate_append_only_history(previous_value: object, current_value: object) -> None: - """Reject registry rollback while allowing exact appends.""" - previous = validate_registry(previous_value) current = validate_registry(current_value) - if len(current) < len(previous): - raise EvidenceRegistryError("evidence registry history cannot remove entries") - for index, old_entry in enumerate(previous): - if current[index] != old_entry: - raise EvidenceRegistryError( - f"evidence registry entry {index} changed after recording" - ) + previous_doc = previous_value + current_doc = current_value + assert isinstance(previous_doc, dict) and isinstance(current_doc, dict) + if current_doc["revision"] != previous_doc["revision"] + 1: + raise EvidenceRegistryError("registry revision must increase by exactly one") + if current_doc["previous_registry_head_sha256"] != previous_doc["registry_head_sha256"]: + raise EvidenceRegistryError("registry revision does not bind the previous head") + if len(current) < len(previous) or current[: len(previous)] != previous: + raise EvidenceRegistryError("registry history must be an exact append") def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Validate the content-addressed evidence registry." - ) - parser.add_argument( - "--registry", - default=str(REGISTRY_PATH), - help="path to evidence-registry.json (default: repository root)", - ) - parser.add_argument( - "--previous-registry", - help="optional trusted previous registry; the current one may only " - "append exact entries", - ) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--registry", default=str(REGISTRY_PATH)) + parser.add_argument("--previous-registry") + parser.add_argument("--skip-object-readback", action="store_true") args = parser.parse_args(argv) + registry_path = Path(args.registry) try: - raw = Path(args.registry).read_text(encoding="utf-8") - value = json.loads(raw) - validate_registry(value) + value = json.loads(registry_path.read_text(encoding="utf-8")) + validate_registry( + value, + root=None if args.skip_object_readback else registry_path.resolve().parent, + ) if args.previous_registry: - previous_raw = Path(args.previous_registry).read_text(encoding="utf-8") - validate_append_only_history(json.loads(previous_raw), value) + previous = json.loads(Path(args.previous_registry).read_text(encoding="utf-8")) + validate_append_only_history(previous, value) except (OSError, json.JSONDecodeError, EvidenceRegistryError) as exc: print(f"REFUSED: {exc}", file=sys.stderr) return 1 - print("evidence registry is valid") + print("production evidence registry v2 is valid") return 0 diff --git a/scripts/validate_production_lifecycle_ref.py b/scripts/validate_production_lifecycle_ref.py new file mode 100644 index 0000000..af5b29f --- /dev/null +++ b/scripts/validate_production_lifecycle_ref.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Validate a closed lifecycle feed update against an exact local Git commit.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from datetime import datetime, timezone + +import production_trust as trust + + +def git(*args: str) -> bytes: + return subprocess.run( + ["git", *args], check=True, capture_output=True + ).stdout + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--update-json", required=True) + parser.add_argument("--trusted-main", required=True) + args = parser.parse_args(argv) + try: + update = trust.validate_feed_update(json.loads(args.update_json)) + new_commit = update["new_commit"] + commit = git("rev-parse", f"{new_commit}^{{commit}}").decode().strip() + if commit != new_commit: + raise trust.TrustError("new lifecycle commit is not exact") + parent = git("rev-parse", f"{new_commit}^").decode().strip() + if parent != args.trusted_main: + raise trust.TrustError("new lifecycle commit is not based on current main") + changed = git( + "diff", "--name-only", "--diff-filter=ACMR", args.trusted_main, new_commit + ).decode().splitlines() + if changed != ["production-lifecycle-feed.json"]: + raise trust.TrustError("lifecycle commit changes files outside the feed") + feed_raw = git("show", f"{new_commit}:production-lifecycle-feed.json") + if "sha256:" + hashlib.sha256(feed_raw).hexdigest() != update["feed_sha256"]: + raise trust.TrustError("feed bytes differ from update digest") + feed = trust.validate_feed( + json.loads(feed_raw), now=datetime.now(timezone.utc) + ) + if feed["expires_at"] != update["expires_at"] or feed["registry_head_sha256"] != update["registry_head_sha256"]: + raise trust.TrustError("feed state differs from update payload") + checkpoint_digests = { + pair["checkpoint_reference"]["object_sha256"] + for pair in feed["checkpoints"] + } + if update["checkpoint_sha256"] not in checkpoint_digests: + raise trust.TrustError("update checkpoint is not selected by the feed") + print("production lifecycle feed update is valid") + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_evidence_reference.py b/scripts/verify_evidence_reference.py new file mode 100644 index 0000000..4cac991 --- /dev/null +++ b/scripts/verify_evidence_reference.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Fetch and verify one registered regular evidence object and its paired bundle.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import verify_production_release_admission as verifier + +ROOT = Path(__file__).resolve().parents[1] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--reference", required=True) + parser.add_argument("--expected-kind", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + try: + reference = verifier.load_json_argument(args.reference) + if reference.get("kind") != args.expected_kind: + raise ValueError("reference kind differs from expected kind") + bundle_reference = verifier.derive_bundle_reference(reference) + regular_raw, bundle_raw, value = verifier.fetch_pair( + reference, bundle_reference + ) + policy = json.loads( + (ROOT / "production-evidence-policy.json").read_text(encoding="utf-8") + ) + verifier.verify_sigstore( + regular_raw, bundle_raw, kind=args.expected_kind, policy=policy + ) + Path(args.output).write_text( + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_production_release_admission.py b/scripts/verify_production_release_admission.py new file mode 100644 index 0000000..630f60e --- /dev/null +++ b/scripts/verify_production_release_admission.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Verify one exact Production release admission and its local candidate bytes.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import production_trust as trust +import validate_evidence_registry as evidence + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PATH = ROOT / "production-evidence-policy.json" + + +def load_json_argument(value: str) -> Any: + path = Path(value) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return json.loads(value) + + +def fetch(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "OpenAdapt-trust/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def raw_url(commit: str, path: str) -> str: + return f"https://raw.githubusercontent.com/OpenAdaptAI/.github/{commit}/{path}" + + +def verify_bytes(raw: bytes, reference: dict[str, Any], label: str) -> Any: + actual = "sha256:" + hashlib.sha256(raw).hexdigest() + if actual != reference["object_sha256"] or len(raw) != reference["size_bytes"]: + raise trust.TrustError(f"{label} bytes or size differ from the reference") + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise trust.TrustError(f"{label} is not JSON") from exc + identity_value = reference["subject_sha256"] if reference["kind"].endswith("-sigstore-bundle") else value + expected_identity = evidence.semantic_identity_digest( + kind=reference["kind"], + object_schema_version=reference["object_schema_version"], + object_value=identity_value, + object_sha256=reference["object_sha256"], + ) + if expected_identity != reference["semantic_identity_sha256"]: + raise trust.TrustError(f"{label} semantic identity differs") + return value + + +def fetch_pair( + regular_reference: dict[str, Any], bundle_reference: dict[str, Any] +) -> tuple[bytes, bytes, dict[str, Any]]: + regular, bundle = trust.validate_reference_pair( + regular_reference, + bundle_reference, + kind=regular_reference.get("kind", ""), + ) + commit = regular["registry_source_commit"] + registry_raw = fetch(raw_url(commit, "evidence-registry.json")) + try: + registry_value = json.loads(registry_raw) + entries = evidence.validate_registry(registry_value) + except (json.JSONDecodeError, evidence.EvidenceRegistryError) as exc: + raise trust.TrustError("the referenced registry is invalid") from exc + if ( + registry_value["revision"] != regular["registry_revision"] + or registry_value["registry_head_sha256"] != regular["registry_head_sha256"] + ): + raise trust.TrustError("reference registry revision or head differs") + try: + evidence.require_registered(entries, reference=regular, label="regular object") + evidence.require_registered(entries, reference=bundle, label="bundle object") + except evidence.EvidenceRegistryError as exc: + raise trust.TrustError(str(exc)) from exc + signer_pointer = registry_value["signer_registry"] + if signer_pointer is None: + raise trust.TrustError("the referenced registry has no signer registry") + signer_raw = fetch(raw_url(commit, signer_pointer["object_path"])) + if "sha256:" + hashlib.sha256(signer_raw).hexdigest() != signer_pointer["object_sha256"]: + raise trust.TrustError("signer registry bytes differ") + try: + signer_registry = evidence.validate_signer_registry(json.loads(signer_raw)) + except (json.JSONDecodeError, evidence.EvidenceRegistryError) as exc: + raise trust.TrustError("signer registry is invalid") from exc + if evidence.signer_registry_identity_digest(signer_registry) != signer_pointer["registry_identity_sha256"]: + raise trust.TrustError("signer registry identity differs") + now = datetime.now(timezone.utc) + if not evidence._timestamp(signer_registry["generated_at"], "generated_at") <= now < evidence._timestamp(signer_registry["expires_at"], "expires_at"): + raise trust.TrustError("signer registry is not active") + regular_raw = fetch(raw_url(commit, regular["object_path"])) + bundle_raw = fetch(raw_url(commit, bundle["object_path"])) + value = verify_bytes(regular_raw, regular, "regular object") + verify_bytes(bundle_raw, bundle, "bundle object") + return regular_raw, bundle_raw, value + + +def derive_bundle_reference(regular_reference: dict[str, Any]) -> dict[str, Any]: + regular = evidence.validate_reference(regular_reference) + commit = regular["registry_source_commit"] + registry_value = json.loads(fetch(raw_url(commit, "evidence-registry.json"))) + entries = evidence.validate_registry(registry_value) + for index, entry in enumerate(entries): + if entry["registry_entry_sha256"] != regular["registry_entry_sha256"]: + continue + if index + 1 >= len(entries): + break + bundle_entry = entries[index + 1] + return { + "schema_version": evidence.REFERENCE_SCHEMA, + "repository": evidence.REPOSITORY, + "repository_id": evidence.REPOSITORY_ID, + "repository_owner_id": evidence.REPOSITORY_OWNER_ID, + "registry_source_commit": commit, + "registry_revision": regular["registry_revision"], + "registry_head_sha256": regular["registry_head_sha256"], + **bundle_entry, + } + raise trust.TrustError("the admission bundle does not immediately follow its object") + + +def verify_sigstore( + regular_raw: bytes, bundle_raw: bytes, *, kind: str, policy: dict[str, Any] +) -> None: + sigstore = policy["sigstore"] + version = subprocess.run( + ["gh", "--version"], check=True, capture_output=True, text=True + ).stdout.splitlines()[0] + if version != f"gh version {sigstore['version']} (2026-08-20)": + raise trust.TrustError( + f"GitHub CLI version differs; required {sigstore['version']}; got {version}" + ) + identities = { + item["kind"]: item for item in sigstore["certificate_identities"] + } + identity = identities.get(kind) + if identity is None: + raise trust.TrustError(f"no keyless identity is registered for {kind}") + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + subject = directory / "subject.json" + bundle = directory / "bundle.json" + subject.write_bytes(regular_raw) + bundle.write_bytes(bundle_raw) + command = [ + "gh", "attestation", "verify", str(subject), "--bundle", str(bundle), + "--repo", identity["issuer_repository"], "--cert-identity", + identity["certificate_identity"], "--cert-oidc-issuer", + sigstore["cert_oidc_issuer"], "--deny-self-hosted-runners", + "--no-public-good", "--format", "json", + ] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode: + raise trust.TrustError( + "Sigstore bundle verification failed: " + result.stderr.strip() + ) + try: + statement = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise trust.TrustError("Sigstore verifier output is not JSON") from exc + if not statement: + raise trust.TrustError("Sigstore verifier returned no verified statement") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--admission-reference", required=True) + parser.add_argument("--admission-bundle-reference") + parser.add_argument("--artifact-inventory", required=True) + parser.add_argument("--artifact-root", required=True) + parser.add_argument("--expected-target", required=True) + parser.add_argument("--expected-repository", required=True) + parser.add_argument("--expected-repository-id", required=True) + parser.add_argument("--expected-source-commit", required=True) + parser.add_argument("--expected-version", required=True) + parser.add_argument("--expected-tag", required=True) + parser.add_argument("--github-output") + args = parser.parse_args(argv) + try: + reference = load_json_argument(args.admission_reference) + bundle_reference = ( + load_json_argument(args.admission_bundle_reference) + if args.admission_bundle_reference + else derive_bundle_reference(reference) + ) + inventory = trust.validate_artifact_inventory( + load_json_argument(args.artifact_inventory) + ) + regular_raw, bundle_raw, admission_value = fetch_pair( + reference, bundle_reference + ) + policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) + verify_sigstore( + regular_raw, bundle_raw, kind="qualification-release", policy=policy + ) + admission = trust.validate_release( + admission_value, now=datetime.now(timezone.utc) + ) + release = admission["release"] + expected = { + "target": args.expected_target, + "repository": args.expected_repository, + "repository_id": args.expected_repository_id, + "source_commit": args.expected_source_commit, + "version": args.expected_version, + "tag": args.expected_tag, + } + actual = { + "target": admission["target"], + "repository": release["source_repository"], + "repository_id": release["source_repository_id"], + "source_commit": release["source_commit"], + "version": release["version"], + "tag": release["tag"], + } + if actual != expected: + raise trust.TrustError("release identity differs from caller expectations") + if ( + inventory["target"] != admission["target"] + or inventory["claim_scope"] != admission["claim_scope"] + or inventory["artifacts"] != release["artifacts"] + or trust.artifact_inventory_digest(inventory) + != admission["artifact_inventory_sha256"] + ): + raise trust.TrustError("caller artifact inventory differs from admission") + trust.verify_local_artifacts(Path(args.artifact_root), release["artifacts"]) + outputs = { + "admission_object_sha256": reference["object_sha256"], + "release_sha256": admission["release_sha256"], + "artifact_inventory_sha256": admission["artifact_inventory_sha256"], + "publication_staging_json": trust.canonical( + admission["publication_staging"] + ).decode("utf-8"), + "publication_staging_sha256": admission["publication_staging_sha256"], + "draft_release_id": admission["publication_staging"]["draft_release_id"], + "expires_at": admission["expires_at"], + "registry_source_commit": reference["registry_source_commit"], + } + if args.github_output: + with Path(args.github_output).open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + print(json.dumps(outputs, sort_keys=True)) + except ( + OSError, + ValueError, + json.JSONDecodeError, + subprocess.SubprocessError, + ) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index e35e4a7..657cde6 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -1,10 +1,12 @@ -"""Fail-closed tests for the content-addressed evidence registry.""" +"""Fail-closed tests for production evidence object references v2.""" from __future__ import annotations import copy +import hashlib import json import sys +import tempfile import unittest from pathlib import Path @@ -14,140 +16,208 @@ import validate_evidence_registry as registry # noqa: E402 -def make_entry(sequence: int = 1, prior: str | None = None, **overrides) -> dict: - entry = registry.build_entry( - sequence=sequence, - kind=overrides.pop("kind", "evidence-summary"), - url=overrides.pop( - "url", "https://evidence.openadapt.ai/objects/" + "a" * 64 + "/summary.json" +def sha(character: str) -> str: + return "sha256:" + character * 64 + + +def entry(kind: str = "production-acceptance-manifest", **overrides) -> dict: + schema, media = registry.OBJECT_KIND_CONTRACTS[kind] + object_sha = overrides.pop("object_sha256", sha("a")) + digest_hex = object_sha.removeprefix("sha256:") + value = { + "kind": kind, + "object_schema_version": schema, + "object_path": ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" ), - sha256=overrides.pop("sha256", "sha256:" + "b" * 64), - size_bytes=overrides.pop("size_bytes", 128), - recorded_at=overrides.pop("recorded_at", "2026-08-21T00:00:00.000Z"), - prior_entry_sha256=prior, - ) - entry.update(overrides) - return entry - - -def wrap(entries: list[dict]) -> dict: - head = entries[-1]["entry_sha256"] if entries else None - return { + "object_sha256": object_sha, + "size_bytes": 123, + "object_media_type": media, + "semantic_identity_sha256": sha("b"), + "subject_sha256": None, + } + value.update(overrides) + value["registry_entry_sha256"] = registry.entry_digest(value) + return value + + +def document(entries: list[dict] | None = None, **overrides) -> dict: + values = entries or [] + value = { "$schema": "schemas/evidence-registry.schema.json", "schema_version": registry.REGISTRY_SCHEMA, - "head_entry_sha256": head, - "entries": entries, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "revision": 1, + "previous_registry_head_sha256": None, + "registry_head_sha256": sha("0"), + "signer_registry": None, + "entries": values, } + value.update(overrides) + value["registry_head_sha256"] = registry.registry_head_digest(value) + return value + + +def reference(value: dict, **overrides) -> dict: + result = { + "schema_version": registry.REFERENCE_SCHEMA, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "registry_source_commit": "c" * 40, + "registry_revision": 7, + "registry_head_sha256": sha("d"), + **value, + } + result.update(overrides) + return result class EvidenceRegistryTests(unittest.TestCase): - def test_empty_registry_is_valid(self) -> None: - self.assertEqual(registry.validate_registry(wrap([])), []) - - def test_chained_entries_are_valid(self) -> None: - first = make_entry(1, None) - second = make_entry(2, first["entry_sha256"]) - third = make_entry(3, second["entry_sha256"], kind="attestation-bundle") - result = registry.validate_registry(wrap([first, second, third])) - self.assertEqual(len(result), 3) - - def test_broken_chain_is_refused(self) -> None: - first = make_entry(1, None) - second = make_entry(2, "sha256:" + "0" * 64) - with self.assertRaisesRegex(registry.EvidenceRegistryError, "chain"): - registry.validate_registry(wrap([first, second])) - - def test_stale_head_is_refused(self) -> None: - first = make_entry(1, None) - document = wrap([first]) - document["head_entry_sha256"] = "sha256:" + "e" * 64 - with self.assertRaisesRegex(registry.EvidenceRegistryError, "head"): - registry.validate_registry(document) - - def test_sequence_gap_is_refused(self) -> None: - first = make_entry(1, None) - second = make_entry(3, first["entry_sha256"]) - with self.assertRaisesRegex(registry.EvidenceRegistryError, "sequence"): - registry.validate_registry(wrap([first, second])) - - def test_tampered_digest_breaks_the_chain(self) -> None: - first = make_entry(1, None) - tampered = dict(first) - tampered["sha256"] = "sha256:" + "f" * 64 - with self.assertRaisesRegex(registry.EvidenceRegistryError, "chained digest"): - registry.validate_registry(wrap([tampered])) - - def test_non_https_url_is_refused(self) -> None: - with self.assertRaisesRegex(registry.EvidenceRegistryError, "HTTPS"): - make_entry(1, None, url="http://evidence.openadapt.ai/summary.json") - - def test_unsupported_kind_is_refused(self) -> None: - entry = make_entry(1, None) - entry["kind"] = "screenshot" - with self.assertRaisesRegex(registry.EvidenceRegistryError, "kind"): - registry.validate_registry(wrap([entry])) - - def test_closed_schema_refuses_extra_fields(self) -> None: - document = wrap([make_entry(1, None)]) - document["extra"] = True - with self.assertRaisesRegex(registry.EvidenceRegistryError, "unexpected"): - registry.validate_registry(document) - - def test_require_registered_matches_exact_pair(self) -> None: - entry = make_entry(1, None) - entries = registry.validate_registry(wrap([entry])) - registry.require_registered( - entries, - url=entry["url"], - sha256=entry["sha256"], - kind="evidence-summary", - label="case summary", + def test_repository_registry_is_valid_v2(self) -> None: + value = json.loads((ROOT / "evidence-registry.json").read_text()) + self.assertEqual(registry.validate_registry(value, root=ROOT), []) + + def test_reference_has_exact_16_keys_and_derives_raw_url(self) -> None: + value = reference(entry()) + self.assertEqual(set(value), registry.REFERENCE_FIELDS) + registry.validate_reference(value) + self.assertEqual( + registry.raw_github_url(value), + "https://raw.githubusercontent.com/OpenAdaptAI/.github/" + + "c" * 40 + + "/" + + value["object_path"], ) - with self.assertRaisesRegex( - registry.EvidenceRegistryError, "not registered" - ): - registry.require_registered( - entries, - url=entry["url"], - sha256="sha256:" + "9" * 64, - kind="evidence-summary", - label="case summary", - ) - with self.assertRaisesRegex(registry.EvidenceRegistryError, "kind"): - registry.require_registered( - entries, - url=entry["url"], - sha256=entry["sha256"], - kind="attestation-bundle", - label="case bundle", - ) - def test_rewritten_history_is_refused(self) -> None: - first = make_entry(1, None) - second = make_entry(2, first["entry_sha256"]) - rewritten_first = dict(first) - rewritten_first["recorded_at"] = "2026-08-20T00:00:00.000Z" - # Rebuilding history from the edited first entry produces a different - # chained digest than the recorded one. - forged_second = registry.build_entry( - sequence=2, - kind="evidence-summary", - url=second["url"], - sha256=second["sha256"], - size_bytes=second["size_bytes"], - recorded_at=second["recorded_at"], - prior_entry_sha256=rewritten_first["entry_sha256"], + def test_url_field_is_refused(self) -> None: + value = reference(entry()) + value["url"] = "https://evidence.openadapt.ai/object.json" + with self.assertRaisesRegex(registry.EvidenceRegistryError, "exactly"): + registry.validate_reference(value) + + def test_repository_id_and_commit_are_pinned(self) -> None: + value = reference(entry(), repository_id="1") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "repository"): + registry.validate_reference(value) + value = reference(entry(), registry_source_commit="main") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "commit"): + registry.validate_reference(value) + + def test_object_path_must_bind_digest_and_kind(self) -> None: + value = entry() + value["object_path"] = "production-evidence/objects/sha256/aa/wrong.json" + value["registry_entry_sha256"] = registry.entry_digest(value) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "content-addressed"): + registry.validate_reference(reference(value)) + + def test_bundle_must_immediately_follow_and_bind_regular_object(self) -> None: + regular = entry() + bundle = entry( + "production-acceptance-manifest-sigstore-bundle", + object_sha256=sha("e"), + subject_sha256=regular["object_sha256"], + ) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "signer registry"): + registry.validate_registry(document([regular, bundle])) + signer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/ff/" + + "f" * 64 + + ".qualification-signer-registry.json" + ), + "object_sha256": sha("f"), + "registry_identity_sha256": sha("1"), + "registry_revision": 1, + } + valid = document([regular, bundle], signer_registry=signer) + registry.validate_registry(valid) + invalid = copy.deepcopy(valid) + invalid["entries"].reverse() + invalid["registry_head_sha256"] = registry.registry_head_digest(invalid) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "immediately"): + registry.validate_registry(invalid) + + def test_registry_readback_binds_exact_bytes_and_size(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + raw = b'{"schema_version":"openadapt.production-acceptance/v2"}\n' + object_sha = "sha256:" + hashlib.sha256(raw).hexdigest() + regular = entry( + object_sha256=object_sha, + size_bytes=len(raw), + semantic_identity_sha256=registry.semantic_identity_digest( + kind="production-acceptance-manifest", + object_schema_version="openadapt.production-acceptance/v2", + object_value=json.loads(raw), + object_sha256=object_sha, + ), + ) + path = root / regular["object_path"] + path.parent.mkdir(parents=True) + path.write_bytes(raw) + signer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/ff/" + + "f" * 64 + + ".qualification-signer-registry.json" + ), + "object_sha256": sha("f"), + "registry_identity_sha256": sha("1"), + "registry_revision": 1, + } + value = document([regular], signer_registry=signer) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "signer registry"): + registry.validate_registry(value, root=root) + path.write_bytes(raw + b" ") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "bytes differ"): + registry.validate_registry(value, root=root) + + def test_signer_registry_key_id_binds_canonical_key(self) -> None: + key = bytes(range(32)) + public_key = registry.base64.urlsafe_b64encode(key).decode().rstrip("=") + value = { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": 1, + "generated_at": "2026-08-27T00:00:00Z", + "expires_at": "2026-09-03T00:00:00Z", + "signers": [ + { + "algorithm": "ed25519", + "key_id": "qa-ed25519-" + hashlib.sha256(key).hexdigest()[:16], + "public_key": public_key, + "allowed_workflows": [ + "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/OpenAdaptAI/openadapt-internal/" + ".github/workflows/issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ], + "allowed_ref_prefixes": ["refs/heads/main"], + "status": "active", + "revoked_at": None, + } + ], + } + registry.validate_signer_registry(value) + value["signers"][0]["key_id"] = "qa-ed25519-" + "0" * 16 + with self.assertRaisesRegex(registry.EvidenceRegistryError, "bind"): + registry.validate_signer_registry(value) + + def test_append_only_revision_binds_previous_head(self) -> None: + previous = document() + current = document( + revision=2, + previous_registry_head_sha256=previous["registry_head_sha256"], ) - document = wrap([rewritten_first, forged_second]) - with self.assertRaisesRegex(registry.EvidenceRegistryError, "chained digest"): - registry.validate_registry(document) - - def test_repository_file_is_valid_and_empty(self) -> None: - raw = (ROOT / "evidence-registry.json").read_text(encoding="utf-8") - value = json.loads(raw) - self.assertEqual(value["entries"], []) - self.assertIsNone(value["head_entry_sha256"]) - registry.validate_registry(copy.deepcopy(value)) + registry.validate_append_only_history(previous, current) + current["previous_registry_head_sha256"] = sha("9") + current["registry_head_sha256"] = registry.registry_head_digest(current) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "previous head"): + registry.validate_append_only_history(previous, current) if __name__ == "__main__": diff --git a/tests/test_production_trust.py b/tests/test_production_trust.py new file mode 100644 index 0000000..505dddc --- /dev/null +++ b/tests/test_production_trust.py @@ -0,0 +1,289 @@ +"""Fail-closed tests for release, workflow, and lifecycle trust contracts.""" + +from __future__ import annotations + +import copy +import hashlib +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import production_trust as trust # noqa: E402 +import validate_evidence_registry as registry # noqa: E402 + + +def sha(character: str) -> str: + return "sha256:" + character * 64 + + +def reference(kind: str, object_character: str, *, subject: str | None = None) -> dict: + schema, media = registry.OBJECT_KIND_CONTRACTS[kind] + object_sha = sha(object_character) + digest_hex = object_sha.removeprefix("sha256:") + entry = { + "kind": kind, + "object_schema_version": schema, + "object_path": ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ), + "object_sha256": object_sha, + "size_bytes": 100, + "object_media_type": media, + "semantic_identity_sha256": sha("9"), + "subject_sha256": subject, + } + return { + "schema_version": registry.REFERENCE_SCHEMA, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "registry_source_commit": "a" * 40, + "registry_revision": 3, + "registry_head_sha256": sha("8"), + "registry_entry_sha256": registry.entry_digest(entry), + **entry, + } + + +def pair(kind: str, regular_character: str, bundle_character: str) -> tuple[dict, dict]: + regular = reference(kind, regular_character) + bundle = reference( + f"{kind}-sigstore-bundle", + bundle_character, + subject=regular["object_sha256"], + ) + return regular, bundle + + +def campaign_summary() -> dict: + result = {} + for campaign_class in trust.CAMPAIGN_CLASSES: + counts = {field: 0 for field in trust.CAMPAIGN_COUNT_FIELDS} + counts.update( + task_condition_cell_count=1, + minimum_trials_per_cell=3, + observed_trial_count=3, + ) + if campaign_class == "uncertain_delivery": + counts["reconciliation_required_count"] = 3 + if campaign_class == "declared_attended": + counts["authenticated_bound_decision_count"] = 3 + counts["live_target_revalidation_count"] = 3 + if campaign_class == "governed_repair": + for field in ( + "policy_approved_repair_count", + "approved_repair_count", + "retained_repair_evidence_count", + "live_target_revalidation_count", + ): + counts[field] = 3 + result[campaign_class] = counts + return result + + +def rulesets() -> list[dict]: + common = { + "schema_version": "openadapt.production-release-tag-ruleset/v1", + "repository": "OpenAdaptAI/openadapt-capture", + "repository_id": "1115283835", + "target": "tag", + "enforcement": "active", + "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + } + return [ + { + **common, + "name": "Production release tags: creation authority", + "role": "creation_authority", + "ruleset_id": "10", + "bypass_actors": [ + { + "actor_id": "4730708", + "actor_type": "Integration", + "bypass_mode": "always", + } + ], + "rules": [{"type": "creation"}], + }, + { + **common, + "name": "Production release tags: immutable", + "role": "immutability", + "ruleset_id": "11", + "bypass_actors": [], + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "update", + "parameters": {"update_allows_fetch_and_merge": False}, + }, + ], + }, + ] + + +def release_admission() -> dict: + artifact = { + "name": "openadapt_capture-1.0.0-py3-none-any.whl", + "kind": "python-wheel", + "sha256": sha("1"), + "size_bytes": 4, + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + } + normalized_rulesets = rulesets() + staging = { + "schema_version": "openadapt.production-release-staging-evidence/v1", + "repository": "OpenAdaptAI/openadapt-capture", + "repository_id": "1115283835", + "draft_release_id": "20", + "tag": "v1.0.0", + "target_commitish": "b" * 40, + "draft": True, + "prerelease": False, + "release_app_id": "4730708", + "release_app_installation_id": "156835568", + "release_app_bot_user_id": "321543906", + "release_author_login": "openadapt-release[bot]", + "assets": [ + { + "asset_id": "30", + "name": artifact["name"], + "sha256": artifact["sha256"], + "size_bytes": artifact["size_bytes"], + "kind": artifact["kind"], + "media_type": artifact["media_type"], + "publish_destinations": artifact["publish_destinations"], + "uploader_id": "321543906", + "uploader_login": "openadapt-release[bot]", + } + ], + "immutable_releases_enabled": True, + "tag_rulesets": normalized_rulesets, + "tag_rulesets_sha256": trust.digest_bytes( + trust.TAG_RULESETS_DOMAIN, normalized_rulesets + ), + "observed_at": "2026-08-27T12:00:00Z", + } + release = { + "schema_version": "openadapt.production-release-candidate/v1", + "kind": "package", + "source_repository": "OpenAdaptAI/openadapt-capture", + "source_repository_id": "1115283835", + "source_commit": "b" * 40, + "version": "1.0.0", + "tag": "v1.0.0", + "deployment_id": None, + "deployment_sha256": None, + "artifacts": [artifact], + } + summary, summary_bundle = pair("production-acceptance-summary", "2", "3") + value = { + "schema_version": "openadapt.qualification-release/v1", + "admission_id_sha256": sha("0"), + "target": "capture", + "verdict": "accepted", + "claim_scope": "production_capture", + "release_identity": { + "schema_version": "openadapt.monotonic-production-release/v1", + "channel": "production", + "sequence": 1, + "previous_admission_sha256": None, + }, + "release": release, + "release_sha256": trust.digest_bytes( + trust.RELEASE_DOMAIN, + { + "target": "capture", + "claim_scope": "production_capture", + "release": release, + }, + ), + "artifact_inventory_sha256": trust.digest_bytes( + trust.ARTIFACT_INVENTORY_DOMAIN, + { + "target": "capture", + "claim_scope": "production_capture", + "artifacts": [artifact], + }, + ), + "publication_staging": staging, + "publication_staging_sha256": trust.staging_digest(staging), + "production_acceptance_summary_reference": summary, + "production_acceptance_summary_bundle_reference": summary_bundle, + "authority_state_sha256": sha("4"), + "revocation_state_sha256": sha("5"), + "signer_registry_sha256": sha("6"), + "publication_policy_sha256": sha("7"), + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-09-03T12:00:00Z", + "issuer": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-production-release-admission.yml", + "ref": "refs/heads/main", + "source_commit": "c" * 40, + "environment": "production-release-admission", + }, + } + projection = dict(value) + projection.pop("admission_id_sha256") + value["admission_id_sha256"] = trust.digest_bytes( + trust.RELEASE_ADMISSION_DOMAIN, projection + ) + return value + + +class ProductionTrustTests(unittest.TestCase): + def test_campaign_requires_reconciliation_without_replay(self) -> None: + value = campaign_summary() + trust.validate_campaign_summary(value) + value["uncertain_delivery"]["replay_dispatch_count"] = 1 + with self.assertRaisesRegex(trust.TrustError, "replay"): + trust.validate_campaign_summary(value) + + def test_release_binds_exact_draft_assets_and_two_rulesets(self) -> None: + value = release_admission() + trust.validate_release(value) + invalid = copy.deepcopy(value) + invalid["publication_staging"]["tag_rulesets"][1]["bypass_actors"] = [ + { + "actor_id": "4730708", + "actor_type": "Integration", + "bypass_mode": "always", + } + ] + with self.assertRaisesRegex(trust.TrustError, "bypass"): + trust.validate_release(invalid) + + def test_local_artifact_inventory_refuses_extra_file(self) -> None: + value = release_admission() + artifacts = value["release"]["artifacts"] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / artifacts[0]["name"] + path.write_bytes(b"test") + artifacts[0]["sha256"] = "sha256:" + hashlib.sha256(b"test").hexdigest() + trust.verify_local_artifacts(root, artifacts) + (root / "SHA256SUMS").write_text("not admitted") + with self.assertRaisesRegex(trust.TrustError, "extra"): + trust.verify_local_artifacts(root, artifacts) + + def test_v2_reference_ids_are_strings_and_digests_are_prefixed(self) -> None: + regular, bundle = pair("qualification-release", "4", "5") + trust.validate_reference_pair(regular, bundle, kind="qualification-release") + regular["repository_id"] = 858454062 + with self.assertRaisesRegex(trust.TrustError, "repository"): + trust.validate_reference_pair(regular, bundle, kind="qualification-release") + + +if __name__ == "__main__": + unittest.main() From 92b53de1625c945d6f2dc0f1fd4fe46c5135dafc Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 13:41:30 -0400 Subject: [PATCH 02/18] feat: define production trust v2 schemas --- .../production-current-default.schema.json | 71 ++++ ...tion-evidence-object-reference.schema.json | 35 +- ...n-evidence-verification-policy.schema.json | 85 +++++ ...roduction-lifecycle-checkpoint.schema.json | 264 +++++++++++++ ...on-lifecycle-evidence-manifest.schema.json | 255 +++++-------- ...ion-lifecycle-evidence-summary.schema.json | 160 +++++--- ...oduction-lifecycle-feed-update.schema.json | 50 +++ schemas/production-lifecycle-feed.schema.json | 91 +++++ .../production-lifecycle-policy.schema.json | 131 +++---- ...ion-release-artifact-inventory.schema.json | 42 +++ schemas/qualification-admission.schema.json | 173 +++++++++ ...tion-evidence-decision-receipt.schema.json | 130 +++++++ schemas/qualification-release.schema.json | 271 ++++++++++++++ tests/test_public_trust_schemas.py | 352 ++++++++++++++++++ 14 files changed, 1798 insertions(+), 312 deletions(-) create mode 100644 schemas/production-current-default.schema.json create mode 100644 schemas/production-evidence-verification-policy.schema.json create mode 100644 schemas/production-lifecycle-checkpoint.schema.json create mode 100644 schemas/production-lifecycle-feed-update.schema.json create mode 100644 schemas/production-lifecycle-feed.schema.json create mode 100644 schemas/production-release-artifact-inventory.schema.json create mode 100644 schemas/qualification-admission.schema.json create mode 100644 schemas/qualification-evidence-decision-receipt.schema.json create mode 100644 schemas/qualification-release.schema.json create mode 100644 tests/test_public_trust_schemas.py diff --git a/schemas/production-current-default.schema.json b/schemas/production-current-default.schema.json new file mode 100644 index 0000000..e0e73fe --- /dev/null +++ b/schemas/production-current-default.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-current-default.schema.json", + "title": "OpenAdapt production current default v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "default_set_revision", + "previous_default_set_sha256", + "targets", + "issued_at", + "not_before", + "expires_at", + "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.production-current-default/v1"}, + "default_set_revision": {"type": "integer", "minimum": 1}, + "previous_default_set_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "targets": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/default_target"} + }, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "default_target": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", "release_sha256", "artifact_inventory_sha256", "version", "tag", + "deployment_id", "deployment_sha256", "qualification_release_sha256", "default_identity_sha256" + ], + "properties": { + "target": {"$ref": "#/$defs/target"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "version": {"oneOf": [{"type": "string", "minLength": 1, "maxLength": 80}, {"type": "null"}]}, + "tag": {"oneOf": [{"type": "string", "minLength": 1, "maxLength": 120}, {"type": "null"}]}, + "deployment_id": {"oneOf": [{"$ref": "#/$defs/decimal_id"}, {"type": "null"}]}, + "deployment_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "qualification_release_sha256": {"$ref": "#/$defs/digest"}, + "default_identity_sha256": {"$ref": "#/$defs/digest"} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "workflow", "ref", "source_commit"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "repository_id": {"const": "1172011294"}, + "workflow": {"const": ".github/workflows/production-current-default.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"} + } + } + } +} diff --git a/schemas/production-evidence-object-reference.schema.json b/schemas/production-evidence-object-reference.schema.json index 4c15180..54806b0 100644 --- a/schemas/production-evidence-object-reference.schema.json +++ b/schemas/production-evidence-object-reference.schema.json @@ -1,15 +1,26 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "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/OpenAdaptAI/.github/blob/main/schemas/production-evidence-object-reference.schema.json", + "$id": "production-evidence-object-reference.schema.json", "title": "OpenAdapt production evidence object reference v2", "type": "object", "additionalProperties": false, "required": [ - "schema_version", "repository", "repository_id", "repository_owner_id", - "registry_source_commit", "registry_revision", "registry_head_sha256", - "registry_entry_sha256", "kind", "object_schema_version", "object_path", - "object_sha256", "size_bytes", "object_media_type", - "semantic_identity_sha256", "subject_sha256" + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + "kind", + "object_schema_version", + "object_path", + "object_sha256", + "size_bytes", + "object_media_type", + "semantic_identity_sha256", + "subject_sha256" ], "properties": { "schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, @@ -22,12 +33,20 @@ "registry_entry_sha256": {"$ref": "#/$defs/digest"}, "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,95}$"}, "object_schema_version": {"type": "string", "minLength": 1, "maxLength": 160}, - "object_path": {"type": "string", "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.[a-z][a-z0-9-]{1,95}\.json$"}, + "object_path": { + "type": "string", + "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.[a-z][a-z0-9-]{1,95}\\.json$" + }, "object_sha256": {"$ref": "#/$defs/digest"}, "size_bytes": {"type": "integer", "minimum": 1}, "object_media_type": {"type": "string", "minLength": 1, "maxLength": 200}, "semantic_identity_sha256": {"$ref": "#/$defs/digest"}, - "subject_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]} + "subject_sha256": { + "oneOf": [ + {"$ref": "#/$defs/digest"}, + {"type": "null"} + ] + } }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} diff --git a/schemas/production-evidence-verification-policy.schema.json b/schemas/production-evidence-verification-policy.schema.json new file mode 100644 index 0000000..424bd5f --- /dev/null +++ b/schemas/production-evidence-verification-policy.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-evidence-verification-policy.schema.json", + "title": "OpenAdapt production evidence verification policy v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "reference_schema_version", + "transport", + "signer_registry_schema_version", + "sigstore" + ], + "properties": { + "schema_version": {"const": "openadapt.production-evidence-verification-policy/v1"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "reference_schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, + "transport": {"const": "exact-commit-raw-github"}, + "signer_registry_schema_version": {"const": "openadapt.qualification-signer-registry/v2"}, + "sigstore": {"$ref": "#/$defs/sigstore"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "archive": { + "type": "object", + "additionalProperties": false, + "required": ["os", "arch", "url", "sha256"], + "properties": { + "os": {"enum": ["linux", "macos"]}, + "arch": {"enum": ["amd64", "arm64"]}, + "url": {"type": "string", "pattern": "^https://github\\.com/cli/cli/releases/download/v[0-9]+\\.[0-9]+\\.[0-9]+/gh_"}, + "sha256": {"$ref": "#/$defs/digest"} + } + }, + "certificate_identity": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "issuer_repository", "certificate_identity"], + "properties": { + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "issuer_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "certificate_identity": {"type": "string", "pattern": "^https://github\\.com/OpenAdaptAI/[A-Za-z0-9._-]+/\\.github/workflows/[A-Za-z0-9._-]+\\.yml@refs/heads/main$"} + } + }, + "sigstore": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "version", + "cert_oidc_issuer", + "deny_self_hosted_runners", + "allow_public_good", + "archives", + "certificate_identities" + ], + "properties": { + "tool": {"const": "gh"}, + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "cert_oidc_issuer": {"const": "https://token.actions.githubusercontent.com"}, + "deny_self_hosted_runners": {"const": true}, + "allow_public_good": {"const": false}, + "archives": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": {"$ref": "#/$defs/archive"} + }, + "certificate_identities": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/certificate_identity"} + } + } + } + } +} diff --git a/schemas/production-lifecycle-checkpoint.schema.json b/schemas/production-lifecycle-checkpoint.schema.json new file mode 100644 index 0000000..8da0e9e --- /dev/null +++ b/schemas/production-lifecycle-checkpoint.schema.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-lifecycle-checkpoint.schema.json", + "title": "OpenAdapt production lifecycle checkpoint v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "checkpoint_id_sha256", + "checkpoint_revision", + "previous_checkpoint_sha256", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "lifecycle_policy_sha256", + "current_default_reference", + "current_default_bundle_reference", + "lifecycle_projection", + "lifecycle_projection_sha256", + "release_admissions", + "release_admission_set_sha256", + "workflow_admissions", + "workflow_admission_set_sha256", + "authority_state_reference", + "authority_state_bundle_reference", + "authority_state_sha256", + "revocation_state_reference", + "revocation_state_bundle_reference", + "revocation_state_sha256", + "generated_at", + "not_before", + "expires_at", + "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-checkpoint/v1"}, + "checkpoint_id_sha256": {"$ref": "#/$defs/digest"}, + "checkpoint_revision": {"type": "integer", "minimum": 1}, + "previous_checkpoint_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "registry_source_commit": {"$ref": "#/$defs/commit"}, + "registry_revision": {"type": "integer", "minimum": 1}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry": {"$ref": "#/$defs/signer_registry_pointer"}, + "lifecycle_policy_sha256": {"$ref": "#/$defs/digest"}, + "current_default_reference": {"$ref": "#/$defs/current_default_reference"}, + "current_default_bundle_reference": {"$ref": "#/$defs/current_default_bundle_reference"}, + "lifecycle_projection": {"$ref": "#/$defs/lifecycle_projection"}, + "lifecycle_projection_sha256": {"$ref": "#/$defs/digest"}, + "release_admissions": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/release_admission_pair"} + }, + "release_admission_set_sha256": {"$ref": "#/$defs/digest"}, + "workflow_admissions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/workflow_admission_pair"} + }, + "workflow_admission_set_sha256": {"$ref": "#/$defs/digest"}, + "authority_state_reference": {"$ref": "#/$defs/authority_state_reference"}, + "authority_state_bundle_reference": {"$ref": "#/$defs/authority_state_bundle_reference"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_reference": {"$ref": "#/$defs/revocation_state_reference"}, + "revocation_state_bundle_reference": {"$ref": "#/$defs/revocation_state_bundle_reference"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "generated_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "signer_registry_pointer": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "object_path", "object_sha256", "registry_identity_sha256", "registry_revision"], + "properties": { + "schema_version": {"const": "openadapt.qualification-signer-registry-pointer/v1"}, + "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-signer-registry\\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "registry_identity_sha256": {"$ref": "#/$defs/digest"}, + "registry_revision": {"type": "integer", "minimum": 1} + } + }, + "regular_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": {"subject_sha256": {"type": "null"}}} + ] + }, + "bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "current_default_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "production-current-default"}, + "object_schema_version": {"const": "openadapt.production-current-default/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.production-current-default+json;version=1"} + }} + ] + }, + "current_default_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "production-current-default-sigstore-bundle"}}} + ] + }, + "release_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-release"}, + "object_schema_version": {"const": "openadapt.qualification-release/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-release+json;version=1"} + }} + ] + }, + "release_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-release-sigstore-bundle"}}} + ] + }, + "workflow_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-admission"}, + "object_schema_version": {"const": "openadapt.qualification-admission/v3"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-admission+json;version=3"} + }} + ] + }, + "workflow_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-admission-sigstore-bundle"}}} + ] + }, + "authority_state_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-authority-state-receipt"}, + "object_schema_version": {"const": "openadapt.qualification-authority-state-receipt/v2"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-authority-state-receipt+json;version=2"} + }} + ] + }, + "authority_state_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-authority-state-receipt-sigstore-bundle"}}} + ] + }, + "revocation_state_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-revocation-state-receipt"}, + "object_schema_version": {"const": "openadapt.qualification-revocation-state-receipt/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-revocation-state-receipt+json;version=1"} + }} + ] + }, + "revocation_state_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-revocation-state-receipt-sigstore-bundle"}}} + ] + }, + "release_admission_pair": { + "type": "object", + "additionalProperties": false, + "required": ["target", "admission_reference", "admission_bundle_reference"], + "properties": { + "target": {"$ref": "#/$defs/target"}, + "admission_reference": {"$ref": "#/$defs/release_reference"}, + "admission_bundle_reference": {"$ref": "#/$defs/release_bundle_reference"} + } + }, + "workflow_admission_pair": { + "type": "object", + "additionalProperties": false, + "required": ["admission_reference", "admission_bundle_reference"], + "properties": { + "admission_reference": {"$ref": "#/$defs/workflow_reference"}, + "admission_bundle_reference": {"$ref": "#/$defs/workflow_bundle_reference"} + } + }, + "lifecycle_projection_target": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", "state", "claim_scope", "release_sha256", "artifact_inventory_sha256", + "release_admission_object_sha256", "default_identity_sha256", "not_before", "expires_at", "reason_codes" + ], + "properties": { + "target": {"$ref": "#/$defs/target"}, + "state": {"enum": ["Production", "not actively admitted"]}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "release_admission_object_sha256": {"$ref": "#/$defs/digest"}, + "default_identity_sha256": {"$ref": "#/$defs/digest"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "reason_codes": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}} + } + }, + "lifecycle_projection": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "overall_state", "targets"], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-projection/v2"}, + "overall_state": {"enum": ["Production", "not actively admitted"]}, + "targets": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/lifecycle_projection_target"} + } + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", "repository_id", "workflow", "ref", "source_commit", + "policy_repository", "policy_repository_id", "policy_source_commit", "policy_path" + ], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "repository_id": {"const": "1172011294"}, + "workflow": {"const": ".github/workflows/production-lifecycle-checkpoint.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "policy_repository": {"const": "OpenAdaptAI/.github"}, + "policy_repository_id": {"const": "858454062"}, + "policy_source_commit": {"$ref": "#/$defs/commit"}, + "policy_path": {"const": "production-lifecycle-policy.json"} + } + } + } +} diff --git a/schemas/production-lifecycle-evidence-manifest.schema.json b/schemas/production-lifecycle-evidence-manifest.schema.json index d0fa2c3..0b522f8 100644 --- a/schemas/production-lifecycle-evidence-manifest.schema.json +++ b/schemas/production-lifecycle-evidence-manifest.schema.json @@ -1,190 +1,111 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "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/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-evidence-manifest.schema.json", - "title": "OpenAdapt Production acceptance evidence manifest", + "$id": "production-lifecycle-evidence-manifest.schema.json", + "title": "OpenAdapt production acceptance manifest v2", "type": "object", "additionalProperties": false, "required": [ "schema_version", "target", - "claim_scope", "verdict", + "claim_scope", "acceptance_policy_sha256", "lifecycle_policy_sha256", - "target_release_sha256", - "target_artifact_inventory_sha256", - "evidence_identity_sha256", - "source_evidence", - "qualification", - "failure_taxonomy_counts", - "reliability", - "retention" + "release_identity", + "release", + "release_sha256", + "artifact_inventory", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", + "qualification_admission_bundle_reference", + "campaign_summary", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "issued_at", + "not_before", + "expires_at" ], "properties": { - "schema_version": {"const": "openadapt.production-acceptance/v1"}, - "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, - "claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "schema_version": {"const": "openadapt.production-acceptance/v2"}, + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, "verdict": {"const": "accepted"}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, "acceptance_policy_sha256": {"$ref": "#/$defs/digest"}, "lifecycle_policy_sha256": {"$ref": "#/$defs/digest"}, - "target_release_sha256": {"$ref": "#/$defs/digest"}, - "target_artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, - "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, - "source_evidence": { - "type": "object", - "additionalProperties": false, - "required": [ - "source_result_sha256", - "certificate_sha256", - "campaign_sha256", - "qualification_admission_sha256", - "attestation_sha256", - "attestation_bundle_sha256" - ], - "properties": { - "source_result_sha256": {"$ref": "#/$defs/digest"}, - "certificate_sha256": {"$ref": "#/$defs/digest"}, - "campaign_sha256": {"$ref": "#/$defs/digest"}, - "qualification_admission_sha256": {"$ref": "#/$defs/digest"}, - "attestation_sha256": {"$ref": "#/$defs/digest"}, - "attestation_bundle_sha256": {"$ref": "#/$defs/digest"} - } - }, - "qualification": { - "type": "object", - "additionalProperties": false, - "required": [ - "campaign_contract_sha256", - "campaign_outcomes_sha256", - "oracle_contract_sha256", - "task_count", - "condition_count", - "required_trial_count", - "observed_trial_count", - "minimum_trials_per_condition", - "excluded_trial_count", - "task_condition_inventory_sha256" - ], - "properties": { - "campaign_contract_sha256": {"$ref": "#/$defs/digest"}, - "campaign_outcomes_sha256": {"$ref": "#/$defs/digest"}, - "oracle_contract_sha256": {"$ref": "#/$defs/digest"}, - "task_count": {"type": "integer", "minimum": 1}, - "condition_count": {"type": "integer", "minimum": 1}, - "required_trial_count": {"type": "integer", "minimum": 3}, - "observed_trial_count": {"type": "integer", "minimum": 3}, - "minimum_trials_per_condition": {"type": "integer", "minimum": 3}, - "excluded_trial_count": {"const": 0}, - "task_condition_inventory_sha256": {"$ref": "#/$defs/digest"} - } - }, - "failure_taxonomy_counts": {"$ref": "#/$defs/failureTaxonomy"}, - "reliability": {"$ref": "#/$defs/reliability"}, - "retention": {"$ref": "#/$defs/retention"} + "release_identity": {"$ref": "qualification-release.schema.json#/$defs/release_identity"}, + "release": {"$ref": "qualification-release.schema.json#/$defs/release_candidate"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory": {"$ref": "production-release-artifact-inventory.schema.json"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "publication_staging": {"$ref": "qualification-release.schema.json#/$defs/publication_staging"}, + "publication_staging_sha256": {"$ref": "#/$defs/digest"}, + "qualification_evidence_decision_receipt_reference": {"$ref": "#/$defs/receipt_reference"}, + "qualification_evidence_decision_receipt_bundle_reference": {"$ref": "#/$defs/receipt_bundle_reference"}, + "qualification_admission_reference": {"$ref": "#/$defs/admission_reference"}, + "qualification_admission_bundle_reference": {"$ref": "#/$defs/admission_bundle_reference"}, + "campaign_summary": {"$ref": "qualification-admission.schema.json#/$defs/campaign_summary"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "millisecondTimestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"}, - "count": {"type": "integer", "minimum": 0}, - "failureTaxonomy": { - "type": "object", - "additionalProperties": false, - "required": [ - "collateral_effect", - "duplicate_effect", - "healthy_path_model_call", - "operator_intervention", - "over_halt", - "platform_failure", - "safe_halt", - "silent_incorrect_success", - "uncertain_delivery", - "verified", - "wrong_record" - ], - "properties": { - "collateral_effect": {"$ref": "#/$defs/count"}, - "duplicate_effect": {"$ref": "#/$defs/count"}, - "healthy_path_model_call": {"$ref": "#/$defs/count"}, - "operator_intervention": {"$ref": "#/$defs/count"}, - "over_halt": {"$ref": "#/$defs/count"}, - "platform_failure": {"$ref": "#/$defs/count"}, - "safe_halt": {"$ref": "#/$defs/count"}, - "silent_incorrect_success": {"$ref": "#/$defs/count"}, - "uncertain_delivery": {"$ref": "#/$defs/count"}, - "verified": {"$ref": "#/$defs/count"}, - "wrong_record": {"$ref": "#/$defs/count"} - } + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "regular_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": {"subject_sha256": {"type": "null"}}} + ] + }, + "bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "receipt_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-evidence-decision-receipt"}, + "object_schema_version": {"const": "openadapt.qualification-evidence-decision-receipt/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-evidence-decision-receipt+json;version=1"} + }} + ] + }, + "receipt_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-evidence-decision-receipt-sigstore-bundle"}}} + ] }, - "reliability": { - "type": "object", - "additionalProperties": false, - "required": [ - "silent_incorrect_success_count", - "over_halt_count", - "wrong_record_count", - "duplicate_effect_count", - "collateral_effect_count", - "operator_intervention_count", - "uncertain_delivery_count", - "model_call_count" - ], - "properties": { - "silent_incorrect_success_count": {"$ref": "#/$defs/count"}, - "over_halt_count": {"$ref": "#/$defs/count"}, - "wrong_record_count": {"$ref": "#/$defs/count"}, - "duplicate_effect_count": {"$ref": "#/$defs/count"}, - "collateral_effect_count": {"$ref": "#/$defs/count"}, - "operator_intervention_count": {"$ref": "#/$defs/count"}, - "uncertain_delivery_count": {"$ref": "#/$defs/count"}, - "model_call_count": {"$ref": "#/$defs/count"} - } + "admission_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-admission"}, + "object_schema_version": {"const": "openadapt.qualification-admission/v3"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-admission+json;version=3"} + }} + ] }, - "retention": { - "type": "object", - "additionalProperties": false, - "required": [ - "receipt_id", - "ciphertext_sha256", - "candidate_sha256", - "private_envelope_sha256", - "store_attestation_sha256", - "storage_identity_sha256", - "object_version_sha256", - "private_locator_version_sha256", - "kms_key_identity_sha256", - "uploader_identity_sha256", - "retention_mode", - "retention_until", - "retained_at", - "upload_verified", - "head_verified", - "object_lock_verified", - "private_locator_recorded", - "acceptance_verified_at", - "provenance_attestation" - ], - "properties": { - "receipt_id": {"type": "string", "pattern": "^retention:[0-9a-f]{32}$"}, - "ciphertext_sha256": {"$ref": "#/$defs/digest"}, - "candidate_sha256": {"$ref": "#/$defs/digest"}, - "private_envelope_sha256": {"$ref": "#/$defs/digest"}, - "store_attestation_sha256": {"$ref": "#/$defs/digest"}, - "storage_identity_sha256": {"$ref": "#/$defs/digest"}, - "object_version_sha256": {"$ref": "#/$defs/digest"}, - "private_locator_version_sha256": {"$ref": "#/$defs/digest"}, - "kms_key_identity_sha256": {"$ref": "#/$defs/digest"}, - "uploader_identity_sha256": {"$ref": "#/$defs/digest"}, - "retention_mode": {"const": "COMPLIANCE"}, - "retention_until": {"$ref": "#/$defs/millisecondTimestamp"}, - "retained_at": {"$ref": "#/$defs/millisecondTimestamp"}, - "upload_verified": {"const": true}, - "head_verified": {"const": true}, - "object_lock_verified": {"const": true}, - "private_locator_recorded": {"const": true}, - "acceptance_verified_at": {"$ref": "#/$defs/millisecondTimestamp"}, - "provenance_attestation": {"const": "github-artifact-attestation-v4"} - } + "admission_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-admission-sigstore-bundle"}}} + ] } } } diff --git a/schemas/production-lifecycle-evidence-summary.schema.json b/schemas/production-lifecycle-evidence-summary.schema.json index a0980c7..e733f2f 100644 --- a/schemas/production-lifecycle-evidence-summary.schema.json +++ b/schemas/production-lifecycle-evidence-summary.schema.json @@ -1,8 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "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/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-evidence-summary.schema.json", - "title": "OpenAdapt remote-safe Production lifecycle evidence summary", - "description": "An attested public projection. It binds a private acceptance certificate by digest and signer provenance only.", + "$id": "production-lifecycle-evidence-summary.schema.json", + "title": "OpenAdapt production acceptance summary v2", "type": "object", "additionalProperties": false, "required": [ @@ -15,77 +14,116 @@ "release_identity", "release_sha256", "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", "evidence_identity_sha256", - "private_certificate_binding", - "evidence_manifest", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", + "qualification_admission_bundle_reference", + "production_acceptance_manifest_reference", + "production_acceptance_manifest_bundle_reference", + "campaign_summary", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", "issued_at", - "expires_at", - "revoked_at" + "not_before", + "expires_at" ], "properties": { - "schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v1"}, - "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v2"}, + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, "verdict": {"const": "accepted"}, - "claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, "acceptance_policy_sha256": {"$ref": "#/$defs/digest"}, "lifecycle_policy_sha256": {"$ref": "#/$defs/digest"}, - "release_identity": { - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "channel", "sequence", "previous_admission_sha256"], - "properties": { - "schema_version": {"const": "openadapt.monotonic-production-release/v1"}, - "channel": {"const": "production"}, - "sequence": {"type": "integer", "minimum": 1}, - "previous_admission_sha256": { - "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] - } - } - }, + "release_identity": {"$ref": "qualification-release.schema.json#/$defs/release_identity"}, "release_sha256": {"$ref": "#/$defs/digest"}, "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "publication_staging": {"$ref": "qualification-release.schema.json#/$defs/publication_staging"}, + "publication_staging_sha256": {"$ref": "#/$defs/digest"}, "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, - "private_certificate_binding": { - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "sha256", - "signer_provenance_sha256", - "evidence_identity_sha256", - "target", - "target_release_sha256", - "target_artifact_inventory_sha256" - ], - "properties": { - "schema_version": {"const": "openadapt.execute-live-acceptance-record/v2"}, - "sha256": {"$ref": "#/$defs/digest"}, - "signer_provenance_sha256": { - "description": "SHA-256 over the domain-separated canonical normalized verified Cloud provenance plus the admission signer-registry digest and revision.", - "$ref": "#/$defs/digest" - }, - "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, - "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, - "target_release_sha256": {"$ref": "#/$defs/digest"}, - "target_artifact_inventory_sha256": {"$ref": "#/$defs/digest"} - } - }, - "evidence_manifest": { - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "url", "sha256"], - "properties": { - "schema_version": {"const": "openadapt.production-acceptance/v1"}, - "url": {"type": "string", "format": "uri", "pattern": "^https://"}, - "sha256": {"$ref": "#/$defs/digest"} - } - }, + "qualification_evidence_decision_receipt_reference": {"$ref": "#/$defs/receipt_reference"}, + "qualification_evidence_decision_receipt_bundle_reference": {"$ref": "#/$defs/receipt_bundle_reference"}, + "qualification_admission_reference": {"$ref": "#/$defs/admission_reference"}, + "qualification_admission_bundle_reference": {"$ref": "#/$defs/admission_bundle_reference"}, + "production_acceptance_manifest_reference": {"$ref": "#/$defs/manifest_reference"}, + "production_acceptance_manifest_bundle_reference": {"$ref": "#/$defs/manifest_bundle_reference"}, + "campaign_summary": {"$ref": "qualification-admission.schema.json#/$defs/campaign_summary"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, - "revoked_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]} + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"} + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "regular_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": {"subject_sha256": {"type": "null"}}} + ] + }, + "bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "receipt_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-evidence-decision-receipt"}, + "object_schema_version": {"const": "openadapt.qualification-evidence-decision-receipt/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-evidence-decision-receipt+json;version=1"} + }} + ] + }, + "receipt_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-evidence-decision-receipt-sigstore-bundle"}}} + ] + }, + "admission_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "qualification-admission"}, + "object_schema_version": {"const": "openadapt.qualification-admission/v3"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-admission+json;version=3"} + }} + ] + }, + "admission_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "qualification-admission-sigstore-bundle"}}} + ] + }, + "manifest_reference": { + "allOf": [ + {"$ref": "#/$defs/regular_reference"}, + {"properties": { + "kind": {"const": "production-acceptance-manifest"}, + "object_schema_version": {"const": "openadapt.production-acceptance/v2"}, + "object_media_type": {"const": "application/vnd.openadapt.production-acceptance+json;version=2"} + }} + ] + }, + "manifest_bundle_reference": { + "allOf": [ + {"$ref": "#/$defs/bundle_reference"}, + {"properties": {"kind": {"const": "production-acceptance-manifest-sigstore-bundle"}}} + ] + } } } diff --git a/schemas/production-lifecycle-feed-update.schema.json b/schemas/production-lifecycle-feed-update.schema.json new file mode 100644 index 0000000..ca63f2b --- /dev/null +++ b/schemas/production-lifecycle-feed-update.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-lifecycle-feed-update.schema.json", + "title": "OpenAdapt production lifecycle feed update v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_type", + "source_repository", + "source_repository_id", + "source_ref", + "source_commit", + "target_repository", + "target_repository_id", + "target_ref", + "expected_old_commit", + "new_commit", + "feed_path", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "idempotency_key" + ], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-feed-update/v1"}, + "event_type": {"const": "production_lifecycle_feed_updated"}, + "source_repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "source_repository_id": {"const": "1172011294"}, + "source_ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "target_repository": {"const": "OpenAdaptAI/.github"}, + "target_repository_id": {"const": "858454062"}, + "target_ref": {"const": "refs/heads/production-lifecycle-feed"}, + "expected_old_commit": {"oneOf": [{"$ref": "#/$defs/commit"}, {"type": "null"}]}, + "new_commit": {"$ref": "#/$defs/commit"}, + "feed_path": {"const": "production-lifecycle-feed.json"}, + "feed_sha256": {"$ref": "#/$defs/digest"}, + "checkpoint_sha256": {"$ref": "#/$defs/digest"}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "idempotency_key": {"type": "string", "pattern": "^lifecycle-feed-update:[0-9a-f]{64}$"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"} + } +} diff --git a/schemas/production-lifecycle-feed.schema.json b/schemas/production-lifecycle-feed.schema.json new file mode 100644 index 0000000..8e6e325 --- /dev/null +++ b/schemas/production-lifecycle-feed.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-lifecycle-feed.schema.json", + "title": "OpenAdapt production lifecycle feed v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "ref", + "feed_revision", + "generated_at", + "expires_at", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "checkpoints" + ], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-feed/v1"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "ref": {"const": "refs/heads/production-lifecycle-feed"}, + "feed_revision": {"type": "integer", "minimum": 1}, + "generated_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "registry_source_commit": {"$ref": "#/$defs/commit"}, + "registry_revision": {"type": "integer", "minimum": 1}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry": {"$ref": "#/$defs/signer_registry_pointer"}, + "checkpoints": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": {"$ref": "#/$defs/checkpoint_pair"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "signer_registry_pointer": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "object_path", "object_sha256", "registry_identity_sha256", "registry_revision"], + "properties": { + "schema_version": {"const": "openadapt.qualification-signer-registry-pointer/v1"}, + "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-signer-registry\\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "registry_identity_sha256": {"$ref": "#/$defs/digest"}, + "registry_revision": {"type": "integer", "minimum": 1} + } + }, + "checkpoint_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-lifecycle-checkpoint"}, + "object_schema_version": {"const": "openadapt.production-lifecycle-checkpoint/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.production-lifecycle-checkpoint+json;version=1"}, + "subject_sha256": {"type": "null"} + }} + ] + }, + "checkpoint_bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-lifecycle-checkpoint-sigstore-bundle"}, + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "checkpoint_pair": { + "type": "object", + "additionalProperties": false, + "required": ["checkpoint_reference", "checkpoint_bundle_reference"], + "properties": { + "checkpoint_reference": {"$ref": "#/$defs/checkpoint_reference"}, + "checkpoint_bundle_reference": {"$ref": "#/$defs/checkpoint_bundle_reference"} + } + } + } +} diff --git a/schemas/production-lifecycle-policy.schema.json b/schemas/production-lifecycle-policy.schema.json index 9f199ff..9fd9aae 100644 --- a/schemas/production-lifecycle-policy.schema.json +++ b/schemas/production-lifecycle-policy.schema.json @@ -1,96 +1,75 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "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/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-policy.schema.json", - "title": "OpenAdapt Production lifecycle policy", + "$id": "production-lifecycle-policy.schema.json", + "title": "OpenAdapt production lifecycle policy v2", "type": "object", "additionalProperties": false, "required": [ "$schema", "schema_version", "revision", - "maximum_admission_days", - "summary_authority", + "maximum_release_admission_days", + "maximum_workflow_admission_days", + "object_reference_schema_version", + "release_admission_schema_version", + "workflow_admission_schema_version", + "lifecycle_checkpoint_schema_version", + "lifecycle_feed_schema_version", + "lifecycle_feed_ref", "targets" ], "properties": { "$schema": {"const": "schemas/production-lifecycle-policy.schema.json"}, - "schema_version": {"const": "openadapt.production-lifecycle-policy/v1"}, - "revision": {"type": "integer", "minimum": 1}, - "maximum_admission_days": {"type": "integer", "minimum": 1, "maximum": 30}, - "summary_authority": { + "schema_version": {"const": "openadapt.production-lifecycle-policy/v2"}, + "revision": {"type": "integer", "minimum": 2}, + "maximum_release_admission_days": {"const": 30}, + "maximum_workflow_admission_days": {"const": 7}, + "object_reference_schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, + "release_admission_schema_version": {"const": "openadapt.qualification-release/v1"}, + "workflow_admission_schema_version": {"const": "openadapt.qualification-admission/v3"}, + "lifecycle_checkpoint_schema_version": {"const": "openadapt.production-lifecycle-checkpoint/v1"}, + "lifecycle_feed_schema_version": {"const": "openadapt.production-lifecycle-feed/v1"}, + "lifecycle_feed_ref": {"const": "refs/heads/production-lifecycle-feed"}, + "targets": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/target"} + } + }, + "$defs": { + "target": { "type": "object", "additionalProperties": false, "required": [ - "repository", - "workflow", - "source_ref", - "oidc_issuer", - "certificate_identity", - "summary_schema_version", - "private_certificate_schema_version", - "evidence_manifest_schema_version", - "acceptance_policy_sha256", - "release_identity_schema_version", - "production_channel", - "signer_provenance_digest_domain" + "id", + "display_name", + "source_repository", + "source_repository_id", + "release_kind", + "claim_scope", + "required_artifact_kinds", + "package_index_project" ], "properties": { - "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, - "workflow": {"type": "string", "pattern": "^\\.github/workflows/[A-Za-z0-9._-]+\\.ya?ml$"}, - "source_ref": {"const": "refs/heads/main"}, - "oidc_issuer": {"const": "https://token.actions.githubusercontent.com"}, - "certificate_identity": {"type": "string", "format": "uri"}, - "summary_schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v1"}, - "private_certificate_schema_version": {"const": "openadapt.execute-live-acceptance-record/v2"}, - "evidence_manifest_schema_version": {"const": "openadapt.production-acceptance/v1"}, - "acceptance_policy_sha256": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "release_identity_schema_version": {"const": "openadapt.monotonic-production-release/v1"}, - "production_channel": {"const": "production"}, - "signer_provenance_digest_domain": {"const": "OpenAdapt production certificate signer provenance v1\u0000"} - } - }, - "targets": { - "type": "array", - "minItems": 7, - "maxItems": 7, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "display_name", - "lifecycle_scope", - "lifecycle_subject", - "source_repository", - "release_kind", - "required_claim_scope", - "required_artifact_kinds", - "package_index_project", - "artifact_authority_by_kind" - ], - "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, - "display_name": {"type": "string", "minLength": 1}, - "lifecycle_scope": {"enum": ["repository", "public_surface"]}, - "lifecycle_subject": {"type": "string", "minLength": 1}, - "source_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, - "release_kind": {"enum": ["public_package", "private_deployment", "public_deployment"]}, - "required_claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, - "package_index_project": { - "oneOf": [ - {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]+$"}, - {"type": "null"} - ] - }, - "artifact_authority_by_kind": { - "type": "object", - "additionalProperties": {"enum": ["pypi", "github_release", "managed_evidence"]} - }, - "required_artifact_kinds": { - "type": "array", - "uniqueItems": true, - "items": {"type": "string", "minLength": 1} - } + "id": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, + "display_name": {"type": "string", "minLength": 1, "maxLength": 80}, + "source_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "source_repository_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "release_kind": {"enum": ["package", "deployment", "hybrid"]}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, + "required_artifact_kinds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"} + }, + "package_index_project": { + "oneOf": [ + {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + {"type": "null"} + ] } } } diff --git a/schemas/production-release-artifact-inventory.schema.json b/schemas/production-release-artifact-inventory.schema.json new file mode 100644 index 0000000..01d2494 --- /dev/null +++ b/schemas/production-release-artifact-inventory.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-release-artifact-inventory.schema.json", + "title": "OpenAdapt production release artifact inventory v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "target", "claim_scope", "artifacts"], + "properties": { + "schema_version": {"const": "openadapt.production-release-artifact-inventory/v1"}, + "target": {"$ref": "#/$defs/target"}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, + "artifacts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/artifact"} + } + }, + "$defs": { + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "sha256", "size_bytes", "media_type", "publish_destinations"], + "properties": { + "name": {"type": "string", "pattern": "^[^/\\\\]+$", "minLength": 1, "maxLength": 255}, + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "media_type": {"type": "string", "pattern": "^[^/]+/[^/]+$", "maxLength": 200}, + "publish_destinations": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": {"enum": ["deployment", "github-release", "pypi"]} + } + } + } + } +} diff --git a/schemas/qualification-admission.schema.json b/schemas/qualification-admission.schema.json new file mode 100644 index 0000000..3d4b9c1 --- /dev/null +++ b/schemas/qualification-admission.schema.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-admission.schema.json", + "title": "OpenAdapt qualification admission v3", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "admission_id_sha256", + "organization_id_sha256", + "workflow_id_sha256", + "workflow_version_id_sha256", + "bundle_version", + "bundle_sha256", + "admitted_runtime_sha256", + "application_contract_sha256", + "environment_contract_sha256", + "input_contract_sha256", + "action_contract_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "policy_contract_sha256", + "evidence_authority_sha256", + "campaign_artifact_sha256", + "campaign_permit_sha256", + "decision_receipt_reference", + "decision_receipt_bundle_reference", + "signer_registry_sha256", + "revocation_state_sha256", + "entity_class", + "campaign_summary", + "local_identity_opening", + "verdict", + "issued_at", + "not_before", + "expires_at", + "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-admission/v3"}, + "admission_id_sha256": {"$ref": "#/$defs/digest"}, + "organization_id_sha256": {"$ref": "#/$defs/digest"}, + "workflow_id_sha256": {"$ref": "#/$defs/digest"}, + "workflow_version_id_sha256": {"$ref": "#/$defs/digest"}, + "bundle_version": {"type": "integer", "minimum": 1}, + "bundle_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, + "application_contract_sha256": {"$ref": "#/$defs/digest"}, + "environment_contract_sha256": {"$ref": "#/$defs/digest"}, + "input_contract_sha256": {"$ref": "#/$defs/digest"}, + "action_contract_sha256": {"$ref": "#/$defs/digest"}, + "identity_contract_sha256": {"$ref": "#/$defs/digest"}, + "effect_contract_sha256": {"$ref": "#/$defs/digest"}, + "policy_contract_sha256": {"$ref": "#/$defs/digest"}, + "evidence_authority_sha256": {"$ref": "#/$defs/digest"}, + "campaign_artifact_sha256": {"$ref": "#/$defs/digest"}, + "campaign_permit_sha256": {"$ref": "#/$defs/digest"}, + "decision_receipt_reference": {"$ref": "#/$defs/decision_receipt_reference"}, + "decision_receipt_bundle_reference": {"$ref": "#/$defs/decision_receipt_bundle_reference"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "entity_class": {"type": "string", "pattern": "^[a-z][a-z0-9 -]{0,63}$"}, + "campaign_summary": {"$ref": "#/$defs/campaign_summary"}, + "local_identity_opening": {"$ref": "#/$defs/local_identity_opening"}, + "verdict": {"const": "accepted"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "decision_receipt_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "qualification-evidence-decision-receipt"}, + "object_schema_version": {"const": "openadapt.qualification-evidence-decision-receipt/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-evidence-decision-receipt+json;version=1"}, + "subject_sha256": {"type": "null"} + }} + ] + }, + "decision_receipt_bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "qualification-evidence-decision-receipt-sigstore-bundle"}, + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "campaign_counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_condition_cell_count", "minimum_trials_per_cell", "observed_trial_count", + "silent_incorrect_success_count", "over_halt_count", "unsafe_effect_count", + "blind_retry_count", "replay_dispatch_count", "model_call_count", + "unplanned_intervention_count", "reconciliation_required_count", + "authenticated_bound_decision_count", "live_target_revalidation_count", + "policy_approved_repair_count", "approved_repair_count", + "retained_repair_evidence_count", "unverified_direct_action_count" + ], + "properties": { + "task_condition_cell_count": {"type": "integer", "minimum": 1}, + "minimum_trials_per_cell": {"type": "integer", "minimum": 3}, + "observed_trial_count": {"type": "integer", "minimum": 3}, + "silent_incorrect_success_count": {"type": "integer", "minimum": 0}, + "over_halt_count": {"type": "integer", "minimum": 0}, + "unsafe_effect_count": {"type": "integer", "minimum": 0}, + "blind_retry_count": {"type": "integer", "minimum": 0}, + "replay_dispatch_count": {"type": "integer", "minimum": 0}, + "model_call_count": {"type": "integer", "minimum": 0}, + "unplanned_intervention_count": {"type": "integer", "minimum": 0}, + "reconciliation_required_count": {"type": "integer", "minimum": 0}, + "authenticated_bound_decision_count": {"type": "integer", "minimum": 0}, + "live_target_revalidation_count": {"type": "integer", "minimum": 0}, + "policy_approved_repair_count": {"type": "integer", "minimum": 0}, + "approved_repair_count": {"type": "integer", "minimum": 0}, + "retained_repair_evidence_count": {"type": "integer", "minimum": 0}, + "unverified_direct_action_count": {"type": "integer", "minimum": 0} + } + }, + "campaign_summary": { + "type": "object", + "additionalProperties": false, + "required": ["healthy", "safe_halt", "idempotency_replay", "uncertain_delivery", "declared_attended", "governed_repair"], + "properties": { + "healthy": {"$ref": "#/$defs/campaign_counts"}, + "safe_halt": {"$ref": "#/$defs/campaign_counts"}, + "idempotency_replay": {"$ref": "#/$defs/campaign_counts"}, + "uncertain_delivery": {"$ref": "#/$defs/campaign_counts"}, + "declared_attended": {"$ref": "#/$defs/campaign_counts"}, + "governed_repair": {"$ref": "#/$defs/campaign_counts"} + } + }, + "local_identity_opening": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "algorithm", "required", "customer_controlled_secret_required", + "exact_contract_match_required", "revalidation_before_actuation", "maximum_age_seconds" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-local-identity-opening/v1"}, + "algorithm": {"const": "hmac-sha256"}, + "required": {"const": true}, + "customer_controlled_secret_required": {"const": true}, + "exact_contract_match_required": {"const": true}, + "revalidation_before_actuation": {"const": true}, + "maximum_age_seconds": {"const": 60} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-qualification-admission.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "environment": {"const": "qualification-admission"} + } + } + } +} diff --git a/schemas/qualification-evidence-decision-receipt.schema.json b/schemas/qualification-evidence-decision-receipt.schema.json new file mode 100644 index 0000000..d30fd66 --- /dev/null +++ b/schemas/qualification-evidence-decision-receipt.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-evidence-decision-receipt.schema.json", + "title": "OpenAdapt qualification evidence decision receipt v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "decision_commitment_sha256", + "evidence_manifest_sha256", + "organization_id_sha256", + "workflow_id_sha256", + "workflow_version_id_sha256", + "bundle_version", + "bundle_sha256", + "admitted_runtime_sha256", + "application_contract_sha256", + "environment_contract_sha256", + "input_contract_sha256", + "action_contract_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "policy_contract_sha256", + "evidence_authority_sha256", + "campaign_permit_sha256", + "signer_registry_sha256", + "revocation_state_sha256", + "entity_class", + "campaign_summary", + "verdict", + "issued_at", + "not_before", + "expires_at", + "issuer_key_id", + "algorithm", + "signature", + "campaign_artifact_sha256" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-evidence-decision-receipt/v1"}, + "decision_commitment_sha256": {"$ref": "#/$defs/digest"}, + "evidence_manifest_sha256": {"$ref": "#/$defs/digest"}, + "organization_id_sha256": {"$ref": "#/$defs/digest"}, + "workflow_id_sha256": {"$ref": "#/$defs/digest"}, + "workflow_version_id_sha256": {"$ref": "#/$defs/digest"}, + "bundle_version": {"type": "integer", "minimum": 1}, + "bundle_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, + "application_contract_sha256": {"$ref": "#/$defs/digest"}, + "environment_contract_sha256": {"$ref": "#/$defs/digest"}, + "input_contract_sha256": {"$ref": "#/$defs/digest"}, + "action_contract_sha256": {"$ref": "#/$defs/digest"}, + "identity_contract_sha256": {"$ref": "#/$defs/digest"}, + "effect_contract_sha256": {"$ref": "#/$defs/digest"}, + "policy_contract_sha256": {"$ref": "#/$defs/digest"}, + "evidence_authority_sha256": {"$ref": "#/$defs/digest"}, + "campaign_permit_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "entity_class": {"type": "string", "pattern": "^[a-z][a-z0-9 -]{0,63}$"}, + "campaign_summary": {"$ref": "#/$defs/campaign_summary"}, + "verdict": {"const": "accepted"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, + "algorithm": {"const": "ed25519"}, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"}, + "campaign_artifact_sha256": {"$ref": "#/$defs/digest"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "campaign_counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_condition_cell_count", + "minimum_trials_per_cell", + "observed_trial_count", + "silent_incorrect_success_count", + "over_halt_count", + "unsafe_effect_count", + "blind_retry_count", + "replay_dispatch_count", + "model_call_count", + "unplanned_intervention_count", + "reconciliation_required_count", + "authenticated_bound_decision_count", + "live_target_revalidation_count", + "policy_approved_repair_count", + "approved_repair_count", + "retained_repair_evidence_count", + "unverified_direct_action_count" + ], + "properties": { + "task_condition_cell_count": {"type": "integer", "minimum": 1}, + "minimum_trials_per_cell": {"type": "integer", "minimum": 3}, + "observed_trial_count": {"type": "integer", "minimum": 3}, + "silent_incorrect_success_count": {"type": "integer", "minimum": 0}, + "over_halt_count": {"type": "integer", "minimum": 0}, + "unsafe_effect_count": {"type": "integer", "minimum": 0}, + "blind_retry_count": {"type": "integer", "minimum": 0}, + "replay_dispatch_count": {"type": "integer", "minimum": 0}, + "model_call_count": {"type": "integer", "minimum": 0}, + "unplanned_intervention_count": {"type": "integer", "minimum": 0}, + "reconciliation_required_count": {"type": "integer", "minimum": 0}, + "authenticated_bound_decision_count": {"type": "integer", "minimum": 0}, + "live_target_revalidation_count": {"type": "integer", "minimum": 0}, + "policy_approved_repair_count": {"type": "integer", "minimum": 0}, + "approved_repair_count": {"type": "integer", "minimum": 0}, + "retained_repair_evidence_count": {"type": "integer", "minimum": 0}, + "unverified_direct_action_count": {"type": "integer", "minimum": 0} + } + }, + "campaign_summary": { + "type": "object", + "additionalProperties": false, + "required": ["healthy", "safe_halt", "idempotency_replay", "uncertain_delivery", "declared_attended", "governed_repair"], + "properties": { + "healthy": {"$ref": "#/$defs/campaign_counts"}, + "safe_halt": {"$ref": "#/$defs/campaign_counts"}, + "idempotency_replay": {"$ref": "#/$defs/campaign_counts"}, + "uncertain_delivery": {"$ref": "#/$defs/campaign_counts"}, + "declared_attended": {"$ref": "#/$defs/campaign_counts"}, + "governed_repair": {"$ref": "#/$defs/campaign_counts"} + } + } + } +} diff --git a/schemas/qualification-release.schema.json b/schemas/qualification-release.schema.json new file mode 100644 index 0000000..b5d3ba8 --- /dev/null +++ b/schemas/qualification-release.schema.json @@ -0,0 +1,271 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-release.schema.json", + "title": "OpenAdapt qualification release v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "admission_id_sha256", + "target", + "verdict", + "claim_scope", + "release_identity", + "release", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "production_acceptance_summary_reference", + "production_acceptance_summary_bundle_reference", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "publication_policy_sha256", + "issued_at", + "not_before", + "expires_at", + "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-release/v1"}, + "admission_id_sha256": {"$ref": "#/$defs/digest"}, + "target": {"$ref": "#/$defs/target"}, + "verdict": {"const": "accepted"}, + "claim_scope": {"type": "string", "pattern": "^production_[a-z]+$"}, + "release_identity": {"$ref": "#/$defs/release_identity"}, + "release": {"$ref": "#/$defs/release_candidate"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "publication_staging": {"$ref": "#/$defs/publication_staging"}, + "publication_staging_sha256": {"$ref": "#/$defs/digest"}, + "production_acceptance_summary_reference": {"$ref": "#/$defs/summary_reference"}, + "production_acceptance_summary_bundle_reference": {"$ref": "#/$defs/summary_bundle_reference"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "publication_policy_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "target": {"enum": ["agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"]}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "release_identity": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "sequence", "previous_admission_sha256"], + "properties": { + "schema_version": {"const": "openadapt.monotonic-production-release/v1"}, + "channel": {"const": "production"}, + "sequence": {"type": "integer", "minimum": 1}, + "previous_admission_sha256": { + "oneOf": [ + {"$ref": "#/$defs/digest"}, + {"type": "null"} + ] + } + } + }, + "publish_destinations": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": {"enum": ["deployment", "github-release", "pypi"]} + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "sha256", "size_bytes", "media_type", "publish_destinations"], + "properties": { + "name": {"type": "string", "pattern": "^[^/\\\\]+$", "minLength": 1, "maxLength": 255}, + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "media_type": {"type": "string", "pattern": "^[^/]+/[^/]+$", "maxLength": 200}, + "publish_destinations": {"$ref": "#/$defs/publish_destinations"} + } + }, + "release_candidate": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "kind", "source_repository", "source_repository_id", + "source_commit", "version", "tag", "deployment_id", "deployment_sha256", "artifacts" + ], + "properties": { + "schema_version": {"const": "openadapt.production-release-candidate/v1"}, + "kind": {"enum": ["package", "deployment", "hybrid"]}, + "source_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "source_repository_id": {"$ref": "#/$defs/decimal_id"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "version": {"oneOf": [{"type": "string", "minLength": 1, "maxLength": 80}, {"type": "null"}]}, + "tag": {"oneOf": [{"type": "string", "minLength": 1, "maxLength": 120}, {"type": "null"}]}, + "deployment_id": {"oneOf": [{"$ref": "#/$defs/decimal_id"}, {"type": "null"}]}, + "deployment_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "artifacts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/artifact"} + } + } + }, + "staged_asset": { + "type": "object", + "additionalProperties": false, + "required": [ + "asset_id", "name", "kind", "sha256", "size_bytes", "media_type", + "publish_destinations", "uploader_id", "uploader_login" + ], + "properties": { + "asset_id": {"$ref": "#/$defs/decimal_id"}, + "name": {"type": "string", "pattern": "^[^/\\\\]+$", "minLength": 1, "maxLength": 255}, + "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "media_type": {"type": "string", "pattern": "^[^/]+/[^/]+$", "maxLength": 200}, + "publish_destinations": {"$ref": "#/$defs/publish_destinations"}, + "uploader_id": {"const": "321543906"}, + "uploader_login": {"const": "openadapt-release[bot]"} + } + }, + "tag_conditions": { + "type": "object", + "additionalProperties": false, + "required": ["ref_name"], + "properties": { + "ref_name": { + "type": "object", + "additionalProperties": false, + "required": ["include", "exclude"], + "properties": { + "include": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, + "exclude": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}} + } + } + } + }, + "tag_ruleset": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "role", "repository", "repository_id", "ruleset_id", + "name", "target", "enforcement", "bypass_actors", "conditions", "rules" + ], + "properties": { + "schema_version": {"const": "openadapt.production-release-tag-ruleset/v1"}, + "role": {"enum": ["creation_authority", "immutability"]}, + "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "repository_id": {"$ref": "#/$defs/decimal_id"}, + "ruleset_id": {"$ref": "#/$defs/decimal_id"}, + "name": {"type": "string", "minLength": 1, "maxLength": 100}, + "target": {"const": "tag"}, + "enforcement": {"const": "active"}, + "bypass_actors": {"type": "array"}, + "conditions": {"$ref": "#/$defs/tag_conditions"}, + "rules": {"type": "array"} + } + }, + "creation_ruleset": { + "allOf": [ + {"$ref": "#/$defs/tag_ruleset"}, + {"properties": { + "role": {"const": "creation_authority"}, + "bypass_actors": {"const": [{"actor_id": "4730708", "actor_type": "Integration", "bypass_mode": "always"}]}, + "rules": {"const": [{"type": "creation"}]} + }} + ] + }, + "immutability_ruleset": { + "allOf": [ + {"$ref": "#/$defs/tag_ruleset"}, + {"properties": { + "role": {"const": "immutability"}, + "bypass_actors": {"const": []}, + "rules": {"const": [{"type": "deletion"}, {"type": "non_fast_forward"}, {"type": "update"}]} + }} + ] + }, + "publication_staging": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "repository", "repository_id", "draft_release_id", "tag", + "target_commitish", "draft", "prerelease", "release_app_id", + "release_app_installation_id", "release_app_bot_user_id", "release_author_login", + "assets", "immutable_releases_enabled", "tag_rulesets", "tag_rulesets_sha256", "observed_at" + ], + "properties": { + "schema_version": {"const": "openadapt.production-release-staging-evidence/v1"}, + "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "repository_id": {"$ref": "#/$defs/decimal_id"}, + "draft_release_id": {"$ref": "#/$defs/decimal_id"}, + "tag": {"type": "string", "minLength": 1, "maxLength": 120}, + "target_commitish": {"$ref": "#/$defs/commit"}, + "draft": {"const": true}, + "prerelease": {"const": false}, + "release_app_id": {"const": "4730708"}, + "release_app_installation_id": {"const": "156835568"}, + "release_app_bot_user_id": {"const": "321543906"}, + "release_author_login": {"const": "openadapt-release[bot]"}, + "assets": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/staged_asset"}}, + "immutable_releases_enabled": {"const": true}, + "tag_rulesets": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + {"$ref": "#/$defs/creation_ruleset"}, + {"$ref": "#/$defs/immutability_ruleset"} + ], + "items": false + }, + "tag_rulesets_sha256": {"$ref": "#/$defs/digest"}, + "observed_at": {"$ref": "#/$defs/timestamp"} + } + }, + "summary_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-acceptance-summary"}, + "object_schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v2"}, + "object_media_type": {"const": "application/vnd.openadapt.production-lifecycle-evidence-summary+json;version=2"}, + "subject_sha256": {"type": "null"} + }} + ] + }, + "summary_bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-acceptance-summary-sigstore-bundle"}, + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-production-release-admission.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-release-admission"} + } + } + } +} diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py new file mode 100644 index 0000000..f67a4c1 --- /dev/null +++ b/tests/test_public_trust_schemas.py @@ -0,0 +1,352 @@ +"""Structural tests for the public Production trust JSON schemas.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_ROOT = ROOT / "schemas" + + +EXPECTED_TOP_LEVEL_FIELDS = { + "production-lifecycle-evidence-manifest.schema.json": { + "schema_version", + "target", + "verdict", + "claim_scope", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "release_identity", + "release", + "release_sha256", + "artifact_inventory", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", + "qualification_admission_bundle_reference", + "campaign_summary", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "issued_at", + "not_before", + "expires_at", + }, + "production-current-default.schema.json": { + "schema_version", + "default_set_revision", + "previous_default_set_sha256", + "targets", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "production-evidence-object-reference.schema.json": { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + "kind", + "object_schema_version", + "object_path", + "object_sha256", + "size_bytes", + "object_media_type", + "semantic_identity_sha256", + "subject_sha256", + }, + "production-evidence-verification-policy.schema.json": { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "reference_schema_version", + "transport", + "signer_registry_schema_version", + "sigstore", + }, + "production-lifecycle-checkpoint.schema.json": { + "schema_version", + "checkpoint_id_sha256", + "checkpoint_revision", + "previous_checkpoint_sha256", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "lifecycle_policy_sha256", + "current_default_reference", + "current_default_bundle_reference", + "lifecycle_projection", + "lifecycle_projection_sha256", + "release_admissions", + "release_admission_set_sha256", + "workflow_admissions", + "workflow_admission_set_sha256", + "authority_state_reference", + "authority_state_bundle_reference", + "authority_state_sha256", + "revocation_state_reference", + "revocation_state_bundle_reference", + "revocation_state_sha256", + "generated_at", + "not_before", + "expires_at", + "issuer", + }, + "production-lifecycle-evidence-summary.schema.json": { + "schema_version", + "target", + "verdict", + "claim_scope", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "release_identity", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "evidence_identity_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", + "qualification_admission_bundle_reference", + "production_acceptance_manifest_reference", + "production_acceptance_manifest_bundle_reference", + "campaign_summary", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "issued_at", + "not_before", + "expires_at", + }, + "production-lifecycle-feed.schema.json": { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "ref", + "feed_revision", + "generated_at", + "expires_at", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "checkpoints", + }, + "production-lifecycle-feed-update.schema.json": { + "schema_version", + "event_type", + "source_repository", + "source_repository_id", + "source_ref", + "source_commit", + "target_repository", + "target_repository_id", + "target_ref", + "expected_old_commit", + "new_commit", + "feed_path", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "idempotency_key", + }, + "production-lifecycle-policy.schema.json": { + "$schema", + "schema_version", + "revision", + "maximum_release_admission_days", + "maximum_workflow_admission_days", + "object_reference_schema_version", + "release_admission_schema_version", + "workflow_admission_schema_version", + "lifecycle_checkpoint_schema_version", + "lifecycle_feed_schema_version", + "lifecycle_feed_ref", + "targets", + }, + "production-release-artifact-inventory.schema.json": { + "schema_version", + "target", + "claim_scope", + "artifacts", + }, + "qualification-admission.schema.json": { + "schema_version", + "admission_id_sha256", + "organization_id_sha256", + "workflow_id_sha256", + "workflow_version_id_sha256", + "bundle_version", + "bundle_sha256", + "admitted_runtime_sha256", + "application_contract_sha256", + "environment_contract_sha256", + "input_contract_sha256", + "action_contract_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "policy_contract_sha256", + "evidence_authority_sha256", + "campaign_artifact_sha256", + "campaign_permit_sha256", + "decision_receipt_reference", + "decision_receipt_bundle_reference", + "signer_registry_sha256", + "revocation_state_sha256", + "entity_class", + "campaign_summary", + "local_identity_opening", + "verdict", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "qualification-evidence-decision-receipt.schema.json": { + "schema_version", + "decision_commitment_sha256", + "evidence_manifest_sha256", + "organization_id_sha256", + "workflow_id_sha256", + "workflow_version_id_sha256", + "bundle_version", + "bundle_sha256", + "admitted_runtime_sha256", + "application_contract_sha256", + "environment_contract_sha256", + "input_contract_sha256", + "action_contract_sha256", + "identity_contract_sha256", + "effect_contract_sha256", + "policy_contract_sha256", + "evidence_authority_sha256", + "campaign_permit_sha256", + "signer_registry_sha256", + "revocation_state_sha256", + "entity_class", + "campaign_summary", + "verdict", + "issued_at", + "not_before", + "expires_at", + "issuer_key_id", + "algorithm", + "signature", + "campaign_artifact_sha256", + }, + "qualification-release.schema.json": { + "schema_version", + "admission_id_sha256", + "target", + "verdict", + "claim_scope", + "release_identity", + "release", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "production_acceptance_summary_reference", + "production_acceptance_summary_bundle_reference", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "publication_policy_sha256", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, +} + + +def iter_nodes(value: Any): + yield value + if isinstance(value, dict): + for child in value.values(): + yield from iter_nodes(child) + elif isinstance(value, list): + for child in value: + yield from iter_nodes(child) + + +class PublicTrustSchemaTests(unittest.TestCase): + def test_every_schema_parses_and_declared_objects_are_closed(self) -> None: + paths = sorted(SCHEMA_ROOT.glob("*.schema.json")) + self.assertTrue(paths) + for path in paths: + with self.subTest(path=path.name): + schema = json.loads(path.read_text(encoding="utf-8")) + for node in iter_nodes(schema): + if not isinstance(node, dict): + continue + if node.get("type") != "object" or "properties" not in node: + continue + self.assertIs(node.get("additionalProperties"), False) + self.assertEqual(set(node.get("required", [])), set(node["properties"])) + + def test_frozen_top_level_contracts_are_exact(self) -> None: + for filename, expected in EXPECTED_TOP_LEVEL_FIELDS.items(): + with self.subTest(path=filename): + schema = json.loads((SCHEMA_ROOT / filename).read_text(encoding="utf-8")) + self.assertIs(schema["additionalProperties"], False) + self.assertEqual(set(schema["required"]), expected) + self.assertEqual(set(schema["properties"]), expected) + + def test_v2_reference_has_no_url_transport_field(self) -> None: + schema = json.loads( + (SCHEMA_ROOT / "production-evidence-object-reference.schema.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(len(schema["required"]), 16) + self.assertNotIn("url", schema["properties"]) + self.assertEqual( + schema["properties"]["schema_version"]["const"], + "openadapt.production-evidence-object-reference/v2", + ) + + def test_release_artifacts_bind_publish_destinations(self) -> None: + schema = json.loads( + (SCHEMA_ROOT / "qualification-release.schema.json").read_text( + encoding="utf-8" + ) + ) + artifact = schema["$defs"]["artifact"] + staged = schema["$defs"]["staged_asset"] + projection = { + "name", + "kind", + "sha256", + "size_bytes", + "media_type", + "publish_destinations", + } + self.assertEqual(set(artifact["required"]), projection) + self.assertEqual( + set(staged["required"]), + projection | {"asset_id", "uploader_id", "uploader_login"}, + ) + self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["minItems"], 2) + self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["maxItems"], 2) + + +if __name__ == "__main__": + unittest.main() From 4800bdae48ab534f0374763dd46b26b84c04c6dd Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:28:09 -0400 Subject: [PATCH 03/18] feat: harden production trust chain --- .../workflows/production-lifecycle-ref.yml | 48 + .../verify-production-release-admission.yml | 76 +- evidence-registry.json | 3 +- production-evidence-policy.json | 76 +- production-lifecycle-policy.json | 4 +- production-release-authority-policy.json | 1 + schemas/evidence-registry.schema.json | 25 +- ...ion-cloud-deploy-authorization.schema.json | 56 + ...uction-cloud-deployment-result.schema.json | 205 ++ ...n-evidence-verification-policy.schema.json | 84 +- ...on-lifecycle-evidence-manifest.schema.json | 21 +- ...ion-lifecycle-evidence-summary.schema.json | 21 +- ...ication-recovery-authorization.schema.json | 93 + ...ction-release-authority-policy.schema.json | 71 + schemas/qualification-admission.schema.json | 7 +- ...cation-authority-state-receipt.schema.json | 62 + ...tion-evidence-decision-receipt.schema.json | 84 +- ...ion-evidence-signing-statement.schema.json | 34 + schemas/qualification-release.schema.json | 68 +- ...ation-revocation-state-receipt.schema.json | 73 + .../qualification-signer-registry.schema.json | 89 + schemas/support-release-admission.schema.json | 68 + scripts/production_trust.py | 1997 ++++++++++++++++- scripts/stage_production_evidence.py | 32 + scripts/validate_evidence_registry.py | 170 +- scripts/validate_production_lifecycle_ref.py | 617 ++++- .../verify_production_release_admission.py | 404 +++- tests/test_evidence_registry.py | 115 +- tests/test_production_trust.py | 501 ++++- tests/test_public_trust_schemas.py | 138 +- 30 files changed, 5108 insertions(+), 135 deletions(-) create mode 100644 production-release-authority-policy.json create mode 100644 schemas/production-cloud-deploy-authorization.schema.json create mode 100644 schemas/production-cloud-deployment-result.schema.json create mode 100644 schemas/production-publication-recovery-authorization.schema.json create mode 100644 schemas/production-release-authority-policy.schema.json create mode 100644 schemas/qualification-authority-state-receipt.schema.json create mode 100644 schemas/qualification-evidence-signing-statement.schema.json create mode 100644 schemas/qualification-revocation-state-receipt.schema.json create mode 100644 schemas/qualification-signer-registry.schema.json create mode 100644 schemas/support-release-admission.schema.json diff --git a/.github/workflows/production-lifecycle-ref.yml b/.github/workflows/production-lifecycle-ref.yml index 5513d05..399f532 100644 --- a/.github/workflows/production-lifecycle-ref.yml +++ b/.github/workflows/production-lifecycle-ref.yml @@ -10,6 +10,7 @@ on: permissions: contents: read + attestations: read concurrency: group: production-lifecycle-feed-ref @@ -54,14 +55,61 @@ jobs: owner: OpenAdaptAI repositories: .github + - name: Install the pinned GitHub attestation verifier + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/gh_2.98.0_linux_amd64.tar.gz" + curl --fail --location --silent --show-error \ + --output "$archive" \ + https://github.com/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz + echo "3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de $archive" \ + | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_2.98.0_linux_amd64/bin" >> "$GITHUB_PATH" + + - name: Install the pinned message-signature verifier + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.0 + with: + cosign-release: v3.1.3 + + - name: Bind Cosign and the Sigstore trust material + shell: bash + run: | + set -euo pipefail + cosign_path=$(command -v cosign) + test "$(wc -c < "$cosign_path" | tr -d ' ')" = '141178250' + echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 $cosign_path" \ + | sha256sum --check --strict + trusted_root="$RUNNER_TEMP/sigstore-trusted-root.json" + signing_config="$RUNNER_TEMP/sigstore-signing-config.json" + curl --fail --location --silent --show-error \ + --output "$trusted_root" \ + https://raw.githubusercontent.com/sigstore/root-signing/c6f23ff62645fb0c46ebc7945675835b52f91aa8/targets/trusted_root.json + test "$(wc -c < "$trusted_root" | tr -d ' ')" = '6787' + echo "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66 $trusted_root" \ + | sha256sum --check --strict + curl --fail --location --silent --show-error \ + --output "$signing_config" \ + https://raw.githubusercontent.com/sigstore/root-signing/c6f23ff62645fb0c46ebc7945675835b52f91aa8/targets/signing_config.json + test "$(wc -c < "$signing_config" | tr -d ' ')" = '219' + echo "d358c75d032833f4193500f5b01b5760409410558fac962c599439adbb268b0f $signing_config" \ + | sha256sum --check --strict + echo "OPENADAPT_SIGSTORE_TRUSTED_ROOT=$trusted_root" >> "$GITHUB_ENV" + - name: Validate the exact feed commit env: FEED_UPDATE_JSON: ${{ inputs.feed_update_json }} + GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail new_commit=$(python3 -c 'import json,os; print(json.loads(os.environ["FEED_UPDATE_JSON"])["new_commit"])') + expected_old=$(python3 -c 'import json,os; print(json.loads(os.environ["FEED_UPDATE_JSON"])["expected_old_commit"] or "")') git fetch --no-tags origin "$new_commit" + if [ -n "$expected_old" ]; then + git fetch --no-tags origin "$expected_old" + fi python3 scripts/validate_production_lifecycle_ref.py \ --update-json "$FEED_UPDATE_JSON" \ --trusted-main "${{ github.sha }}" diff --git a/.github/workflows/verify-production-release-admission.yml b/.github/workflows/verify-production-release-admission.yml index 50caf10..4452210 100644 --- a/.github/workflows/verify-production-release-admission.yml +++ b/.github/workflows/verify-production-release-admission.yml @@ -12,6 +12,7 @@ on: expected_source_commit: {required: true, type: string} expected_version: {required: true, type: string} expected_tag: {required: true, type: string} + central_verifier_sha: {required: true, type: string} outputs: admission_object_sha256: value: ${{ jobs.verify.outputs.admission_object_sha256 }} @@ -33,6 +34,7 @@ on: permissions: contents: read attestations: read + id-token: write jobs: verify: @@ -48,11 +50,53 @@ jobs: expires_at: ${{ steps.verify.outputs.expires_at }} registry_source_commit: ${{ steps.verify.outputs.registry_source_commit }} steps: + - name: Bind this reusable call to the requested central commit + shell: bash + env: + CENTRAL_VERIFIER_SHA: ${{ inputs.central_verifier_sha }} + run: | + set -euo pipefail + if [[ ! "$CENTRAL_VERIFIER_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "central_verifier_sha must be one exact commit" >&2 + exit 1 + fi + audience="openadapt-production-release-verifier" + token_json="$(curl --fail --silent --show-error \ + --header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${audience}")" + TOKEN_JSON="$token_json" AUDIENCE="$audience" python3 - <<'PY' + import base64 + import json + import os + + response = json.loads(os.environ["TOKEN_JSON"]) + token = response.get("value") + if not isinstance(token, str) or token.count(".") != 2: + raise SystemExit("GitHub did not return one OIDC token") + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + commit = os.environ["CENTRAL_VERIFIER_SHA"] + expected = { + "aud": os.environ["AUDIENCE"], + "job_workflow_ref": ( + "OpenAdaptAI/.github/.github/workflows/" + f"verify-production-release-admission.yml@{commit}" + ), + "job_workflow_sha": commit, + "repository_owner_id": "132681217", + "runner_environment": "github-hosted", + } + for name, value in expected.items(): + if claims.get(name) != value: + raise SystemExit(f"OIDC claim {name} differs from the central verifier contract") + PY + - name: Check out the exact central verifier uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: OpenAdaptAI/.github - ref: ${{ github.workflow_sha }} + ref: ${{ inputs.central_verifier_sha }} path: central-trust persist-credentials: false @@ -75,6 +119,35 @@ jobs: tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" echo "$RUNNER_TEMP/gh_2.98.0_linux_amd64/bin" >> "$GITHUB_PATH" + - name: Install the pinned message-signature verifier + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.0 + with: + cosign-release: v3.1.3 + + - name: Bind Cosign and the Sigstore trust material + shell: bash + run: | + set -euo pipefail + cosign_path=$(command -v cosign) + test "$(wc -c < "$cosign_path" | tr -d ' ')" = '141178250' + echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 $cosign_path" \ + | sha256sum --check --strict + trusted_root="$RUNNER_TEMP/sigstore-trusted-root.json" + signing_config="$RUNNER_TEMP/sigstore-signing-config.json" + curl --fail --location --silent --show-error \ + --output "$trusted_root" \ + https://raw.githubusercontent.com/sigstore/root-signing/c6f23ff62645fb0c46ebc7945675835b52f91aa8/targets/trusted_root.json + test "$(wc -c < "$trusted_root" | tr -d ' ')" = '6787' + echo "6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66 $trusted_root" \ + | sha256sum --check --strict + curl --fail --location --silent --show-error \ + --output "$signing_config" \ + https://raw.githubusercontent.com/sigstore/root-signing/c6f23ff62645fb0c46ebc7945675835b52f91aa8/targets/signing_config.json + test "$(wc -c < "$signing_config" | tr -d ' ')" = '219' + echo "d358c75d032833f4193500f5b01b5760409410558fac962c599439adbb268b0f $signing_config" \ + | sha256sum --check --strict + echo "OPENADAPT_SIGSTORE_TRUSTED_ROOT=$trusted_root" >> "$GITHUB_ENV" + - name: Materialize closed verifier inputs shell: bash env: @@ -89,6 +162,7 @@ jobs: id: verify shell: bash env: + GH_TOKEN: ${{ github.token }} EXPECTED_TARGET: ${{ inputs.expected_target }} EXPECTED_REPOSITORY: ${{ inputs.expected_repository }} EXPECTED_REPOSITORY_ID: ${{ inputs.expected_repository_id }} diff --git a/evidence-registry.json b/evidence-registry.json index 39eae8b..53dcce7 100644 --- a/evidence-registry.json +++ b/evidence-registry.json @@ -6,7 +6,8 @@ "repository_owner_id": "132681217", "revision": 1, "previous_registry_head_sha256": null, - "registry_head_sha256": "sha256:1b0c8362c5e9d8f55006a350605b9d537fa33639b8f3d3bb6c293e7e49b3d2cf", + "registry_head_sha256": "sha256:3c88a4e945f1aa51ee080f05331c0d7a36b907ad288cf8a24b2fef08b1681a97", "signer_registry": null, + "signer_registry_history": [], "entries": [] } diff --git a/production-evidence-policy.json b/production-evidence-policy.json index 7beea49..064d657 100644 --- a/production-evidence-policy.json +++ b/production-evidence-policy.json @@ -6,6 +6,63 @@ "reference_schema_version": "openadapt.production-evidence-object-reference/v2", "transport": "exact-commit-raw-github", "signer_registry_schema_version": "openadapt.qualification-signer-registry/v2", + "embedded_signatures": [ + { + "kind": "qualification-authority-state-receipt", + "algorithm": "ed25519", + "profile": "ed25519-canonical-signing-statement", + "statement_schema_version": "openadapt.qualification-evidence-signing-statement/v1", + "signature_domain": "OpenAdapt qualification authority state receipt v2\u0000", + "projection": "canonical-object-minus-signature-and-signing-statement", + "encoding": "base64-rfc4648-padded", + "key_source": "active-qualification-signer-registry-v2" + }, + { + "kind": "qualification-evidence-decision-receipt", + "algorithm": "ed25519", + "profile": "ed25519-canonical-signing-statement", + "statement_schema_version": "openadapt.qualification-evidence-signing-statement/v1", + "signature_domain": "OpenAdapt qualification evidence decision receipt v1\u0000", + "projection": "canonical-object-minus-signature-and-signing-statement", + "encoding": "base64-rfc4648-padded", + "key_source": "active-qualification-signer-registry-v2" + }, + { + "kind": "qualification-revocation-state-receipt", + "algorithm": "ed25519", + "profile": "ed25519-canonical-signing-statement", + "statement_schema_version": "openadapt.qualification-evidence-signing-statement/v1", + "signature_domain": "OpenAdapt qualification revocation state receipt v1\u0000", + "projection": "canonical-object-minus-signature-and-signing-statement", + "encoding": "base64-rfc4648-padded", + "key_source": "active-qualification-signer-registry-v2" + } + ], + "message_signature": { + "profile": "sigstore-message-signature", + "tool": "cosign", + "version": "3.1.3", + "runner": "ubuntu-24.04", + "architecture": "linux-amd64", + "installer_action": "sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6", + "url": "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/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64", + "sha256": "sha256:4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71", + "size_bytes": 141178250, + "trusted_root": { + "repository": "sigstore/root-signing", + "commit": "c6f23ff62645fb0c46ebc7945675835b52f91aa8", + "path": "targets/trusted_root.json", + "sha256": "sha256:6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66", + "size_bytes": 6787 + }, + "signing_config": { + "repository": "sigstore/root-signing", + "commit": "c6f23ff62645fb0c46ebc7945675835b52f91aa8", + "path": "targets/signing_config.json", + "sha256": "sha256:d358c75d032833f4193500f5b01b5760409410558fac962c599439adbb268b0f", + "size_bytes": 219 + } + }, "sigstore": { "tool": "gh", "version": "2.98.0", @@ -19,15 +76,16 @@ {"os":"macos","arch":"arm64","url":"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/cli/cli/releases/download/v2.98.0/gh_2.98.0_macOS_arm64.zip","sha256":"sha256:8cfb027cc5310675f2b830eac8f9865c1155a45ffcf9757f699fdd5a22046ca4"} ], "certificate_identities": [ - {"kind":"production-acceptance-manifest","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, - {"kind":"production-acceptance-summary","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, - {"kind":"production-current-default","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-current-default.yml@refs/heads/main"}, - {"kind":"production-lifecycle-checkpoint","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-lifecycle-checkpoint.yml@refs/heads/main"}, - {"kind":"qualification-admission","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-qualification-admission.yml@refs/heads/main"}, - {"kind":"qualification-authority-state-receipt","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-authority-state.yml@refs/heads/main"}, - {"kind":"qualification-evidence-decision-receipt","issuer_repository":"OpenAdaptAI/openadapt-internal","certificate_identity":"/OpenAdaptAI/openadapt-internal/.github/workflows/issue-private-qualification-evidence-decision.yml@refs/heads/main"}, - {"kind":"qualification-release","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-production-release-admission.yml@refs/heads/main"} - ,{"kind":"qualification-revocation-state-receipt","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-revocation-state.yml@refs/heads/main"} + {"kind":"production-acceptance-manifest","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, + {"kind":"production-acceptance-summary","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-evals","certificate_identity":"/OpenAdaptAI/openadapt-evals/.github/workflows/issue-production-acceptance.yml@refs/heads/main"}, + {"kind":"production-current-default","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-current-default.yml@refs/heads/main"}, + {"kind":"production-lifecycle-checkpoint","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/production-lifecycle-checkpoint.yml@refs/heads/main"}, + {"kind":"qualification-admission","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-qualification-admission.yml@refs/heads/main"}, + {"kind":"qualification-authority-state-receipt","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-authority-state.yml@refs/heads/main"}, + {"kind":"qualification-evidence-decision-receipt","bundle_profile":"sigstore-message-signature","issuer_repository":"OpenAdaptAI/openadapt-internal","certificate_identity":"/OpenAdaptAI/openadapt-internal/.github/workflows/issue-private-qualification-evidence-decision.yml@refs/heads/main"}, + {"kind":"qualification-release","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-production-release-admission.yml@refs/heads/main"}, + {"kind":"qualification-revocation-state-receipt","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-revocation-state.yml@refs/heads/main"}, + {"kind":"support-release-admission","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-support-release-admission.yml@refs/heads/main"} ] } } diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index da57590..f510dbd 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -12,9 +12,9 @@ "lifecycle_feed_ref": "refs/heads/production-lifecycle-feed", "targets": [ {"id":"agent","display_name":"OpenAdapt Agent","source_repository":"OpenAdaptAI/openadapt-agent","source_repository_id":"1136136670","release_kind":"package","claim_scope":"production_agent","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-agent"}, - {"id":"capture","display_name":"OpenAdapt Capture","source_repository":"OpenAdaptAI/openadapt-capture","source_repository_id":"1115283835","release_kind":"package","claim_scope":"production_capture","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-capture"}, + {"id":"capture","display_name":"OpenAdapt Capture","source_repository":"OpenAdaptAI/openadapt-capture","source_repository_id":"1115283835","release_kind":"package","claim_scope":"production_capture","required_artifact_kinds":["chrome-extension-zip","python-sdist","python-wheel","spdx-sbom"],"package_index_project":"openadapt-capture"}, {"id":"cloud","display_name":"OpenAdapt Cloud","source_repository":"OpenAdaptAI/openadapt-cloud","source_repository_id":"1300570990","release_kind":"deployment","claim_scope":"production_cloud","required_artifact_kinds":["deployment-manifest"],"package_index_project":null}, - {"id":"desktop","display_name":"OpenAdapt Desktop","source_repository":"OpenAdaptAI/openadapt-desktop","source_repository_id":"1171291730","release_kind":"hybrid","claim_scope":"production_desktop","required_artifact_kinds":["linux-installer","macos-installer","python-sdist","python-wheel","windows-installer"],"package_index_project":"openadapt-desktop"}, + {"id":"desktop","display_name":"OpenAdapt Desktop","source_repository":"OpenAdaptAI/openadapt-desktop","source_repository_id":"1171291730","release_kind":"hybrid","claim_scope":"production_desktop","required_artifact_kinds":["linux-appimage","linux-deb","macos-dmg-arm64","macos-dmg-x86-64","python-sdist","python-wheel","windows-msi","windows-nsis"],"package_index_project":"openadapt-desktop"}, {"id":"docs","display_name":"OpenAdapt Documentation","source_repository":"OpenAdaptAI/openadapt-ops","source_repository_id":"1172011294","release_kind":"deployment","claim_scope":"production_docs","required_artifact_kinds":["deployment-manifest","site-archive"],"package_index_project":null}, {"id":"flow","display_name":"OpenAdapt Flow","source_repository":"OpenAdaptAI/openadapt-flow","source_repository_id":"1291376938","release_kind":"package","claim_scope":"production_flow","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt-flow"}, {"id":"openadapt","display_name":"OpenAdapt","source_repository":"OpenAdaptAI/OpenAdapt","source_repository_id":"627024850","release_kind":"package","claim_scope":"production_openadapt","required_artifact_kinds":["python-sdist","python-wheel"],"package_index_project":"openadapt"} diff --git a/production-release-authority-policy.json b/production-release-authority-policy.json new file mode 100644 index 0000000..d79fa8e --- /dev/null +++ b/production-release-authority-policy.json @@ -0,0 +1 @@ +{"$schema":"schemas/production-release-authority-policy.schema.json","schema_version":"openadapt.production-release-authority-policy/v1","release_app":{"app_id":"4730708","app_slug":"openadapt-release","installation_id":"156835568","bot_user_id":"321543906","bot_login":"openadapt-release[bot]"},"repository_selection":"selected","permissions":{"administration":"read","contents":"write","metadata":"read"},"repositories":[{"target":"agent","repository":"OpenAdaptAI/openadapt-agent","repository_id":"1136136670"},{"target":"capture","repository":"OpenAdaptAI/openadapt-capture","repository_id":"1115283835"},{"target":"cloud","repository":"OpenAdaptAI/openadapt-cloud","repository_id":"1300570990"},{"target":"desktop","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730"},{"target":"docs","repository":"OpenAdaptAI/openadapt-ops","repository_id":"1172011294"},{"target":"flow","repository":"OpenAdaptAI/openadapt-flow","repository_id":"1291376938"},{"target":"openadapt","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850"}],"tag_rulesets":{"creation_authority":"OpenAdapt policy: release tag creation","immutability":"OpenAdapt policy: immutable release tags"},"support_release_authorities":[{"id":"desktop-managed-ffmpeg-runtime","lifecycle":"Support","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730","tag_pattern":"refs/tags/ffmpeg-runtime-v*","creation_ruleset":"OpenAdapt policy: FFmpeg runtime tag creation","immutability_ruleset":"OpenAdapt policy: immutable FFmpeg runtime tags"}]} diff --git a/schemas/evidence-registry.schema.json b/schemas/evidence-registry.schema.json index 0987e14..047ef46 100644 --- a/schemas/evidence-registry.schema.json +++ b/schemas/evidence-registry.schema.json @@ -7,7 +7,7 @@ "required": [ "$schema", "schema_version", "repository", "repository_id", "repository_owner_id", "revision", "previous_registry_head_sha256", - "registry_head_sha256", "signer_registry", "entries" + "registry_head_sha256", "signer_registry", "signer_registry_history", "entries" ], "properties": { "$schema": {"const": "schemas/evidence-registry.schema.json"}, @@ -27,7 +27,7 @@ "required": ["schema_version", "object_path", "object_sha256", "registry_identity_sha256", "registry_revision"], "properties": { "schema_version": {"const": "openadapt.qualification-signer-registry-pointer/v1"}, - "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.qualification-signer-registry\.json$"}, + "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-signer-registry\\.json$"}, "object_sha256": {"$ref": "#/$defs/digest"}, "registry_identity_sha256": {"$ref": "#/$defs/digest"}, "registry_revision": {"type": "integer", "minimum": 1} @@ -35,6 +35,11 @@ } ] }, + "signer_registry_history": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/signer_registry_pointer"} + }, "entries": { "type": "array", "items": { @@ -49,7 +54,7 @@ "registry_entry_sha256": {"$ref": "#/$defs/digest"}, "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,95}$"}, "object_schema_version": {"type": "string", "minLength": 1, "maxLength": 160}, - "object_path": {"type": "string", "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\.[a-z][a-z0-9-]{1,95}\.json$"}, + "object_path": {"type": "string", "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.[a-z][a-z0-9-]{1,95}\\.json$"}, "object_sha256": {"$ref": "#/$defs/digest"}, "size_bytes": {"type": "integer", "minimum": 1}, "object_media_type": {"type": "string", "minLength": 1, "maxLength": 200}, @@ -60,6 +65,18 @@ } }, "$defs": { - "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "signer_registry_pointer": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "object_path", "object_sha256", "registry_identity_sha256", "registry_revision"], + "properties": { + "schema_version": {"const": "openadapt.qualification-signer-registry-pointer/v1"}, + "object_path": {"type": "string", "pattern": "^production-evidence/signer-registries/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-signer-registry\\.json$"}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "registry_identity_sha256": {"$ref": "#/$defs/digest"}, + "registry_revision": {"type": "integer", "minimum": 1} + } + } } } diff --git a/schemas/production-cloud-deploy-authorization.schema.json b/schemas/production-cloud-deploy-authorization.schema.json new file mode 100644 index 0000000..cbedb9d --- /dev/null +++ b/schemas/production-cloud-deploy-authorization.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-cloud-deploy-authorization.schema.json", + "title": "OpenAdapt production Cloud deploy authorization v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "authorization_sha256", "deployment_intent_sha256", + "profile_repository", "profile_repository_id", "profile_commit", + "profile_workflow", "profile_run_id", "profile_run_attempt", + "cloud_source_commitment_sha256", "expected_live_attestation_sha256", + "provider_idempotency_key", "audience", "signer_registry_sha256", + "revocation_state_sha256", "issued_at", "not_before", "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.production-cloud-deploy-authorization/v1"}, + "authorization_sha256": {"$ref": "#/$defs/digest"}, + "deployment_intent_sha256": {"$ref": "#/$defs/digest"}, + "profile_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "profile_repository_id": {"$ref": "#/$defs/decimal_id"}, + "profile_commit": {"$ref": "#/$defs/commit"}, + "profile_workflow": {"type": "string", "pattern": "^\\.github/workflows/[A-Za-z0-9._-]+\\.ya?ml$"}, + "profile_run_id": {"$ref": "#/$defs/decimal_id"}, + "profile_run_attempt": {"type": "integer", "const": 1}, + "cloud_source_commitment_sha256": {"$ref": "#/$defs/digest"}, + "expected_live_attestation_sha256": {"$ref": "#/$defs/digest"}, + "provider_idempotency_key": {"type": "string", "pattern": "^cloud-deploy:[0-9a-f]{64}$"}, + "audience": {"const": "openadapt-public-cloud-source-proof"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "repository_id": {"const": "1172011294"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/production-cloud-deploy-authorization.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-cloud-deploy-authorization"} + } + } + } +} diff --git a/schemas/production-cloud-deployment-result.schema.json b/schemas/production-cloud-deployment-result.schema.json new file mode 100644 index 0000000..1b0b754 --- /dev/null +++ b/schemas/production-cloud-deployment-result.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-cloud-deployment-result.schema.json", + "title": "OpenAdapt production Cloud deployment handoff v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "handoff_id_sha256", "authorization_reference", + "authorization_bundle_reference", "authorization_sha256", + "cloud_source_commitment_sha256", "profile_commit", "profile_run_id", + "profile_run_attempt", "source_proof_request", "source_proof_request_sha256", + "source_proof_response", "source_proof_response_sha256", + "reviewed_public_values_sha256", + "expected_live_attestation_sha256", "live_attestation_sha256", + "provider_idempotency_key", "audience", "signer_registry_sha256", + "revocation_state_sha256", "verdict", "issued_at", "not_before", + "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.production-cloud-deployment-result/v1"}, + "handoff_id_sha256": {"$ref": "#/$defs/digest"}, + "authorization_reference": {"$ref": "#/$defs/authorization_reference"}, + "authorization_bundle_reference": {"$ref": "#/$defs/authorization_bundle_reference"}, + "authorization_sha256": {"$ref": "#/$defs/digest"}, + "cloud_source_commitment_sha256": {"$ref": "#/$defs/digest"}, + "profile_commit": {"$ref": "#/$defs/commit"}, + "profile_run_id": {"$ref": "#/$defs/decimal_id"}, + "profile_run_attempt": {"const": "1"}, + "source_proof_request": {"$ref": "#/$defs/source_proof_request"}, + "source_proof_request_sha256": {"$ref": "#/$defs/digest"}, + "source_proof_response": {"$ref": "#/$defs/source_proof_response"}, + "source_proof_response_sha256": {"$ref": "#/$defs/digest"}, + "reviewed_public_values_sha256": {"$ref": "#/$defs/digest"}, + "expected_live_attestation_sha256": {"$ref": "#/$defs/digest"}, + "live_attestation_sha256": {"$ref": "#/$defs/digest"}, + "provider_idempotency_key": {"type": "string", "pattern": "^cloud-deploy:[0-9a-f]{64}$"}, + "audience": {"const": "openadapt-private-cloud-production-deploy"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "verdict": {"const": "accepted"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "raw_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "source_proof_request": { + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_sha256", "cloud_source_commit", + "expected_live_attestation_sha256", "expires_at", "issued_at", "nonce", + "production_origin", "profile_commit", "profile_environment", + "profile_repository", "profile_workflow_ref", "run_attempt", "run_id", + "schema_version", "site_id", "source_names" + ], + "properties": { + "schema_version": {"const": "openadapt.production-secret-source-proof-request/v2"}, + "authorization_sha256": {"$ref": "#/$defs/raw_digest"}, + "cloud_source_commit": {"$ref": "#/$defs/commit"}, + "expected_live_attestation_sha256": {"$ref": "#/$defs/raw_digest"}, + "profile_commit": {"$ref": "#/$defs/commit"}, + "production_origin": {"const": "https://app.openadapt.ai"}, + "profile_environment": {"const": "production-cloud-deploy"}, + "profile_repository": {"const": "OpenAdaptAI/.github"}, + "profile_workflow_ref": {"const": "OpenAdaptAI/.github/.github/workflows/production-cloud-deploy.yml@refs/heads/main"}, + "run_id": {"$ref": "#/$defs/decimal_id"}, + "run_attempt": {"const": "1"}, + "site_id": {"const": "ccf1a48a-d934-48ea-a9a8-36cbddf27cb5"}, + "nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"}, + "source_names": { + "const": [ + "BUSINESS_DECISION_ANSWER_HMAC_KEY", + "BUSINESS_DECISION_ANSWER_HMAC_KEY_ID", + "BUSINESS_DECISION_CLOUD_POLICY_HMAC_KEYS_JSON", + "BUSINESS_DECISION_PRINCIPAL_ALIAS_HMAC_KEY", + "BUSINESS_DECISION_QUALIFICATION_HMAC_KEYS_JSON", + "BUSINESS_DECISION_ROLE_MAPPING_HMAC_KEY", + "BUSINESS_DECISION_RUNNER_RECEIPT_HMAC_KEYS_JSON", + "BUSINESS_DECISION_SESSION_BINDING_HMAC_KEY", + "BUSINESS_DECISION_TASK_HMAC_KEYS_JSON", + "EXECUTE_ACCEPTANCE_CAMPAIGN_PROVISIONER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_BEARER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_SIGNING_PRIVATE_KEY", + "EXECUTE_ACCEPTANCE_TARGET_SIGNING_SECRET" + ] + }, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"} + } + }, + "source_proof_response": { + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_sha256", "live_attestation_sha256", "live_deployment", + "oidc_identity_sha256", "production_origin", "proofs", "public_values", + "request_sha256", "schema_version", "site_id" + ], + "properties": { + "schema_version": {"const": "openadapt.production-secret-source-proof-response/v2"}, + "request_sha256": {"$ref": "#/$defs/raw_digest"}, + "authorization_sha256": {"$ref": "#/$defs/raw_digest"}, + "live_attestation_sha256": {"$ref": "#/$defs/raw_digest"}, + "live_deployment": {"$ref": "#/$defs/live_deployment"}, + "oidc_identity_sha256": {"$ref": "#/$defs/raw_digest"}, + "production_origin": {"const": "https://app.openadapt.ai"}, + "site_id": {"const": "ccf1a48a-d934-48ea-a9a8-36cbddf27cb5"}, + "proofs": { + "type": "object", + "additionalProperties": false, + "required": [ + "BUSINESS_DECISION_ANSWER_HMAC_KEY", + "BUSINESS_DECISION_CLOUD_POLICY_HMAC_KEYS_JSON", + "BUSINESS_DECISION_PRINCIPAL_ALIAS_HMAC_KEY", + "BUSINESS_DECISION_QUALIFICATION_HMAC_KEYS_JSON", + "BUSINESS_DECISION_ROLE_MAPPING_HMAC_KEY", + "BUSINESS_DECISION_RUNNER_RECEIPT_HMAC_KEYS_JSON", + "BUSINESS_DECISION_SESSION_BINDING_HMAC_KEY", + "BUSINESS_DECISION_TASK_HMAC_KEYS_JSON", + "EXECUTE_ACCEPTANCE_CAMPAIGN_PROVISIONER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_BEARER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_SIGNING_PRIVATE_KEY", + "EXECUTE_ACCEPTANCE_TARGET_SIGNING_SECRET" + ], + "properties": { + "BUSINESS_DECISION_ANSWER_HMAC_KEY": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_CLOUD_POLICY_HMAC_KEYS_JSON": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_PRINCIPAL_ALIAS_HMAC_KEY": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_QUALIFICATION_HMAC_KEYS_JSON": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_ROLE_MAPPING_HMAC_KEY": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_RUNNER_RECEIPT_HMAC_KEYS_JSON": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_SESSION_BINDING_HMAC_KEY": {"$ref": "#/$defs/raw_digest"}, + "BUSINESS_DECISION_TASK_HMAC_KEYS_JSON": {"$ref": "#/$defs/raw_digest"}, + "EXECUTE_ACCEPTANCE_CAMPAIGN_PROVISIONER_TOKEN": {"$ref": "#/$defs/raw_digest"}, + "EXECUTE_ACCEPTANCE_OBSERVER_BEARER_TOKEN": {"$ref": "#/$defs/raw_digest"}, + "EXECUTE_ACCEPTANCE_OBSERVER_SIGNING_PRIVATE_KEY": {"$ref": "#/$defs/raw_digest"}, + "EXECUTE_ACCEPTANCE_TARGET_SIGNING_SECRET": {"$ref": "#/$defs/raw_digest"} + } + }, + "public_values": { + "type": "object", + "additionalProperties": false, + "required": ["BUSINESS_DECISION_ANSWER_HMAC_KEY_ID"], + "properties": { + "BUSINESS_DECISION_ANSWER_HMAC_KEY_ID": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$" + } + } + } + } + }, + "live_deployment": { + "type": "object", + "additionalProperties": false, + "required": ["build_id", "source_commit"], + "properties": { + "build_id": {"$ref": "#/$defs/digest"}, + "source_commit": {"$ref": "#/$defs/commit"} + } + }, + "authorization_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-cloud-deploy-authorization"}, + "object_schema_version": {"const": "openadapt.production-cloud-deploy-authorization/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.production-cloud-deploy-authorization+json;version=1"}, + "subject_sha256": {"type": "null"} + }} + ] + }, + "authorization_bundle_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "production-cloud-deploy-authorization-sigstore-bundle"}, + "object_schema_version": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "object_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "subject_sha256": {"$ref": "#/$defs/digest"} + }} + ] + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/production-cloud-deploy.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-cloud-deploy"} + } + } + } +} diff --git a/schemas/production-evidence-verification-policy.schema.json b/schemas/production-evidence-verification-policy.schema.json index 424bd5f..0a5c867 100644 --- a/schemas/production-evidence-verification-policy.schema.json +++ b/schemas/production-evidence-verification-policy.schema.json @@ -12,6 +12,8 @@ "reference_schema_version", "transport", "signer_registry_schema_version", + "embedded_signatures", + "message_signature", "sigstore" ], "properties": { @@ -22,6 +24,14 @@ "reference_schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, "transport": {"const": "exact-commit-raw-github"}, "signer_registry_schema_version": {"const": "openadapt.qualification-signer-registry/v2"}, + "embedded_signatures": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": {"$ref": "#/$defs/embedded_signature"} + }, + "message_signature": {"$ref": "#/$defs/message_signature"}, "sigstore": {"$ref": "#/$defs/sigstore"} }, "$defs": { @@ -40,13 +50,81 @@ "certificate_identity": { "type": "object", "additionalProperties": false, - "required": ["kind", "issuer_repository", "certificate_identity"], + "required": ["kind", "bundle_profile", "issuer_repository", "certificate_identity"], "properties": { "kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "bundle_profile": {"enum": ["github-attestation", "sigstore-message-signature"]}, "issuer_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, "certificate_identity": {"type": "string", "pattern": "^https://github\\.com/OpenAdaptAI/[A-Za-z0-9._-]+/\\.github/workflows/[A-Za-z0-9._-]+\\.yml@refs/heads/main$"} } }, + "pinned_document": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "commit", "path", "sha256", "size_bytes"], + "properties": { + "repository": {"const": "sigstore/root-signing"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "path": {"enum": ["targets/trusted_root.json", "targets/signing_config.json"]}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1} + } + }, + "embedded_signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", "algorithm", "profile", "statement_schema_version", + "signature_domain", "projection", "encoding", "key_source" + ], + "properties": { + "kind": { + "enum": [ + "qualification-authority-state-receipt", + "qualification-evidence-decision-receipt", + "qualification-revocation-state-receipt" + ] + }, + "algorithm": {"const": "ed25519"}, + "profile": {"const": "ed25519-canonical-signing-statement"}, + "statement_schema_version": { + "const": "openadapt.qualification-evidence-signing-statement/v1" + }, + "signature_domain": { + "enum": [ + "OpenAdapt qualification authority state receipt v2\u0000", + "OpenAdapt qualification evidence decision receipt v1\u0000", + "OpenAdapt qualification revocation state receipt v1\u0000" + ] + }, + "projection": { + "const": "canonical-object-minus-signature-and-signing-statement" + }, + "encoding": {"const": "base64-rfc4648-padded"}, + "key_source": {"const": "active-qualification-signer-registry-v2"} + } + }, + "message_signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", "tool", "version", "runner", "architecture", "installer_action", "url", + "sha256", "size_bytes", "trusted_root", "signing_config" + ], + "properties": { + "profile": {"const": "sigstore-message-signature"}, + "tool": {"const": "cosign"}, + "version": {"const": "3.1.3"}, + "runner": {"const": "ubuntu-24.04"}, + "architecture": {"const": "linux-amd64"}, + "installer_action": {"const": "sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6"}, + "url": {"const": "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/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "trusted_root": {"$ref": "#/$defs/pinned_document"}, + "signing_config": {"$ref": "#/$defs/pinned_document"} + } + }, "sigstore": { "type": "object", "additionalProperties": false, @@ -74,8 +152,8 @@ }, "certificate_identities": { "type": "array", - "minItems": 7, - "maxItems": 7, + "minItems": 10, + "maxItems": 10, "uniqueItems": true, "items": {"$ref": "#/$defs/certificate_identity"} } diff --git a/schemas/production-lifecycle-evidence-manifest.schema.json b/schemas/production-lifecycle-evidence-manifest.schema.json index 0b522f8..304856d 100644 --- a/schemas/production-lifecycle-evidence-manifest.schema.json +++ b/schemas/production-lifecycle-evidence-manifest.schema.json @@ -28,7 +28,8 @@ "signer_registry_sha256", "issued_at", "not_before", - "expires_at" + "expires_at", + "issuer" ], "properties": { "schema_version": {"const": "openadapt.production-acceptance/v2"}, @@ -54,11 +55,27 @@ "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"} + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-evals"}, + "repository_id": {"const": "1135998197"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-production-acceptance.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-acceptance"} + } + }, "regular_reference": { "allOf": [ {"$ref": "production-evidence-object-reference.schema.json"}, diff --git a/schemas/production-lifecycle-evidence-summary.schema.json b/schemas/production-lifecycle-evidence-summary.schema.json index e733f2f..f8f00b4 100644 --- a/schemas/production-lifecycle-evidence-summary.schema.json +++ b/schemas/production-lifecycle-evidence-summary.schema.json @@ -29,7 +29,8 @@ "signer_registry_sha256", "issued_at", "not_before", - "expires_at" + "expires_at", + "issuer" ], "properties": { "schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v2"}, @@ -56,11 +57,27 @@ "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"} + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-evals"}, + "repository_id": {"const": "1135998197"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-production-acceptance.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-acceptance"} + } + }, "regular_reference": { "allOf": [ {"$ref": "production-evidence-object-reference.schema.json"}, diff --git a/schemas/production-publication-recovery-authorization.schema.json b/schemas/production-publication-recovery-authorization.schema.json new file mode 100644 index 0000000..841e72b --- /dev/null +++ b/schemas/production-publication-recovery-authorization.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-publication-recovery-authorization.schema.json", + "title": "OpenAdapt production publication recovery authorization v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "authorization_id_sha256", + "qualification_release_reference", "qualification_release_object_sha256", + "release_sha256", "artifact_inventory_sha256", "publication_staging_sha256", + "repository", "repository_id", "target", "tag", "tag_ref", + "tag_object_id", "target_commit", "draft_release_id", "requested_effects", + "run_id", "run_attempt", "dispatcher_actor_id", "environment", + "release_app", "idempotency_key", "issued_at", "not_before", + "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.production-publication-recovery-authorization/v1"}, + "authorization_id_sha256": {"$ref": "#/$defs/digest"}, + "qualification_release_reference": {"$ref": "#/$defs/release_reference"}, + "qualification_release_object_sha256": {"$ref": "#/$defs/digest"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "publication_staging_sha256": {"$ref": "#/$defs/digest"}, + "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "repository_id": {"$ref": "#/$defs/decimal_id"}, + "target": {"enum": ["agent", "capture", "desktop", "flow", "openadapt"]}, + "tag": {"type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:[A-Za-z0-9._-]+)?$"}, + "tag_ref": {"type": "string", "pattern": "^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+(?:[A-Za-z0-9._-]+)?$"}, + "tag_object_id": {"$ref": "#/$defs/commit"}, + "target_commit": {"$ref": "#/$defs/commit"}, + "draft_release_id": {"$ref": "#/$defs/decimal_id"}, + "requested_effects": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": {"enum": ["github-release", "mcp-registry", "pypi"]} + }, + "run_id": {"$ref": "#/$defs/decimal_id"}, + "run_attempt": {"const": "1"}, + "dispatcher_actor_id": {"const": "774615"}, + "environment": {"const": "release-identity"}, + "release_app": {"$ref": "#/$defs/release_app"}, + "idempotency_key": {"type": "string", "pattern": "^publication-recovery:[0-9a-f]{64}$"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "release_reference": { + "allOf": [ + {"$ref": "production-evidence-object-reference.schema.json"}, + {"properties": { + "kind": {"const": "qualification-release"}, + "object_schema_version": {"const": "openadapt.qualification-release/v1"}, + "object_media_type": {"const": "application/vnd.openadapt.qualification-release+json;version=1"}, + "subject_sha256": {"type": "null"} + }} + ] + }, + "release_app": { + "type": "object", + "additionalProperties": false, + "required": ["app_id", "installation_id", "bot_user_id", "bot_login"], + "properties": { + "app_id": {"const": "4730708"}, + "installation_id": {"const": "156835568"}, + "bot_user_id": {"const": "321543906"}, + "bot_login": {"const": "openadapt-release[bot]"} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-production-publication-recovery.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "release-identity"} + } + } + } +} diff --git a/schemas/production-release-authority-policy.schema.json b/schemas/production-release-authority-policy.schema.json new file mode 100644 index 0000000..177aea3 --- /dev/null +++ b/schemas/production-release-authority-policy.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-release-authority-policy.schema.json", + "title": "OpenAdapt production release authority policy v1", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", "schema_version", "release_app", "repository_selection", + "permissions", "repositories", "tag_rulesets", "support_release_authorities" + ], + "properties": { + "$schema": {"const": "schemas/production-release-authority-policy.schema.json"}, + "schema_version": {"const": "openadapt.production-release-authority-policy/v1"}, + "release_app": { + "type": "object", + "additionalProperties": false, + "required": ["app_id", "app_slug", "installation_id", "bot_user_id", "bot_login"], + "properties": { + "app_id": {"const": "4730708"}, + "app_slug": {"const": "openadapt-release"}, + "installation_id": {"const": "156835568"}, + "bot_user_id": {"const": "321543906"}, + "bot_login": {"const": "openadapt-release[bot]"} + } + }, + "repository_selection": {"const": "selected"}, + "permissions": { + "type": "object", + "additionalProperties": false, + "required": ["administration", "contents", "metadata"], + "properties": { + "administration": {"const": "read"}, + "contents": {"const": "write"}, + "metadata": {"const": "read"} + } + }, + "repositories": { + "const": [ + {"target":"agent","repository":"OpenAdaptAI/openadapt-agent","repository_id":"1136136670"}, + {"target":"capture","repository":"OpenAdaptAI/openadapt-capture","repository_id":"1115283835"}, + {"target":"cloud","repository":"OpenAdaptAI/openadapt-cloud","repository_id":"1300570990"}, + {"target":"desktop","repository":"OpenAdaptAI/openadapt-desktop","repository_id":"1171291730"}, + {"target":"docs","repository":"OpenAdaptAI/openadapt-ops","repository_id":"1172011294"}, + {"target":"flow","repository":"OpenAdaptAI/openadapt-flow","repository_id":"1291376938"}, + {"target":"openadapt","repository":"OpenAdaptAI/OpenAdapt","repository_id":"627024850"} + ] + }, + "tag_rulesets": { + "type": "object", + "additionalProperties": false, + "required": ["creation_authority", "immutability"], + "properties": { + "creation_authority": {"const": "OpenAdapt policy: release tag creation"}, + "immutability": {"const": "OpenAdapt policy: immutable release tags"} + } + }, + "support_release_authorities": { + "const": [ + { + "id": "desktop-managed-ffmpeg-runtime", + "lifecycle": "Support", + "repository": "OpenAdaptAI/openadapt-desktop", + "repository_id": "1171291730", + "tag_pattern": "refs/tags/ffmpeg-runtime-v*", + "creation_ruleset": "OpenAdapt policy: FFmpeg runtime tag creation", + "immutability_ruleset": "OpenAdapt policy: immutable FFmpeg runtime tags" + } + ] + } + } +} diff --git a/schemas/qualification-admission.schema.json b/schemas/qualification-admission.schema.json index 3d4b9c1..79805df 100644 --- a/schemas/qualification-admission.schema.json +++ b/schemas/qualification-admission.schema.json @@ -42,7 +42,12 @@ "organization_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_version_id_sha256": {"$ref": "#/$defs/digest"}, - "bundle_version": {"type": "integer", "minimum": 1}, + "bundle_version": { + "type": "string", + "minLength": 5, + "maxLength": 64, + "pattern": "^(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" + }, "bundle_sha256": {"$ref": "#/$defs/digest"}, "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, "application_contract_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/qualification-authority-state-receipt.schema.json b/schemas/qualification-authority-state-receipt.schema.json new file mode 100644 index 0000000..51caabf --- /dev/null +++ b/schemas/qualification-authority-state-receipt.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-authority-state-receipt.schema.json", + "title": "OpenAdapt qualification authority state receipt v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "authority_state_sha256", "status", + "signer_registry_sha256", "signer_registry_identity_sha256", + "signer_registry_revision", "evidence_authority_sha256", "observed_at", + "not_before", "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-authority-state-receipt/v2"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "status": {"const": "active"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_identity_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_revision": {"type": "integer", "minimum": 1}, + "evidence_authority_sha256": {"$ref": "#/$defs/digest"}, + "observed_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, + "algorithm": {"const": "ed25519"}, + "signing_statement": { + "allOf": [ + {"$ref": "qualification-evidence-signing-statement.schema.json"}, + {"properties": { + "object_schema_version": { + "const": "openadapt.qualification-authority-state-receipt/v2" + }, + "signature_domain": { + "const": "OpenAdapt qualification authority state receipt v2\u0000" + } + }} + ] + }, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9+/]{86}==$"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "repository_id": {"const": "1172011294"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/qualification-authority-state.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "qualification-authority-state"} + } + } + } +} diff --git a/schemas/qualification-evidence-decision-receipt.schema.json b/schemas/qualification-evidence-decision-receipt.schema.json index d30fd66..fa869bf 100644 --- a/schemas/qualification-evidence-decision-receipt.schema.json +++ b/schemas/qualification-evidence-decision-receipt.schema.json @@ -6,6 +6,8 @@ "additionalProperties": false, "required": [ "schema_version", + "decision_identity_sha256", + "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", "organization_id_sha256", @@ -21,7 +23,7 @@ "identity_contract_sha256", "effect_contract_sha256", "policy_contract_sha256", - "evidence_authority_sha256", + "evidence_authority_contract_sha256", "campaign_permit_sha256", "signer_registry_sha256", "revocation_state_sha256", @@ -33,17 +35,26 @@ "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", - "campaign_artifact_sha256" + "campaign_artifact_sha256", + "issuer" ], "properties": { "schema_version": {"const": "openadapt.qualification-evidence-decision-receipt/v1"}, + "decision_identity_sha256": {"$ref": "#/$defs/digest"}, + "decision_revision": {"type": "integer", "minimum": 1}, "decision_commitment_sha256": {"$ref": "#/$defs/digest"}, "evidence_manifest_sha256": {"$ref": "#/$defs/digest"}, "organization_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_version_id_sha256": {"$ref": "#/$defs/digest"}, - "bundle_version": {"type": "integer", "minimum": 1}, + "bundle_version": { + "type": "string", + "minLength": 5, + "maxLength": 64, + "pattern": "^(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" + }, "bundle_sha256": {"$ref": "#/$defs/digest"}, "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, "application_contract_sha256": {"$ref": "#/$defs/digest"}, @@ -53,24 +64,63 @@ "identity_contract_sha256": {"$ref": "#/$defs/digest"}, "effect_contract_sha256": {"$ref": "#/$defs/digest"}, "policy_contract_sha256": {"$ref": "#/$defs/digest"}, - "evidence_authority_sha256": {"$ref": "#/$defs/digest"}, + "evidence_authority_contract_sha256": {"$ref": "#/$defs/digest"}, "campaign_permit_sha256": {"$ref": "#/$defs/digest"}, "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "revocation_state_sha256": {"$ref": "#/$defs/digest"}, "entity_class": {"type": "string", "pattern": "^[a-z][a-z0-9 -]{0,63}$"}, "campaign_summary": {"$ref": "#/$defs/campaign_summary"}, - "verdict": {"const": "accepted"}, + "verdict": {"const": "ADMIT"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, "expires_at": {"$ref": "#/$defs/timestamp"}, "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, "algorithm": {"const": "ed25519"}, - "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"}, - "campaign_artifact_sha256": {"$ref": "#/$defs/digest"} + "signing_statement": {"$ref": "#/$defs/signing_statement"}, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9+/]{86}==$"}, + "campaign_artifact_sha256": {"$ref": "#/$defs/digest"}, + "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "timestamp": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "signing_statement": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "object_schema_version", "signature_domain", + "unsigned_object_sha256", "unsigned_size_bytes", "commitment_scheme" + ], + "properties": { + "schema_version": { + "const": "openadapt.qualification-evidence-signing-statement/v1" + }, + "object_schema_version": { + "const": "openadapt.qualification-evidence-decision-receipt/v1" + }, + "signature_domain": { + "const": "OpenAdapt qualification evidence decision receipt v1\u0000" + }, + "unsigned_object_sha256": {"$ref": "#/$defs/digest"}, + "unsigned_size_bytes": {"type": "integer", "minimum": 1}, + "commitment_scheme": {"const": "sha256-canonical-json-lf"} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-internal"}, + "repository_id": {"const": "1170060695"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-private-qualification-evidence-decision.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "private-qualification-evidence-decision"} + } + }, "campaign_counts": { "type": "object", "additionalProperties": false, @@ -113,7 +163,7 @@ "unverified_direct_action_count": {"type": "integer", "minimum": 0} } }, - "campaign_summary": { + "campaign_classes": { "type": "object", "additionalProperties": false, "required": ["healthy", "safe_halt", "idempotency_replay", "uncertain_delivery", "declared_attended", "governed_repair"], @@ -125,6 +175,24 @@ "declared_attended": {"$ref": "#/$defs/campaign_counts"}, "governed_repair": {"$ref": "#/$defs/campaign_counts"} } + }, + "campaign_summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "minimum_trials_per_task_condition", + "task_count", + "classes" + ], + "properties": { + "schema_version": { + "const": "openadapt.qualification-evidence-decision-campaign-summary/v1" + }, + "minimum_trials_per_task_condition": {"const": 3}, + "task_count": {"type": "integer", "minimum": 1}, + "classes": {"$ref": "#/$defs/campaign_classes"} + } } } } diff --git a/schemas/qualification-evidence-signing-statement.schema.json b/schemas/qualification-evidence-signing-statement.schema.json new file mode 100644 index 0000000..8cfae6b --- /dev/null +++ b/schemas/qualification-evidence-signing-statement.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-evidence-signing-statement.schema.json", + "title": "OpenAdapt qualification evidence signing statement v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "object_schema_version", + "signature_domain", + "unsigned_object_sha256", + "unsigned_size_bytes", + "commitment_scheme" + ], + "properties": { + "schema_version": { + "const": "openadapt.qualification-evidence-signing-statement/v1" + }, + "object_schema_version": { + "type": "string", + "pattern": "^openadapt\\.[a-z0-9.-]+/v[1-9][0-9]*$" + }, + "signature_domain": { + "type": "string", + "pattern": "^OpenAdapt .+\\u0000$" + }, + "unsigned_object_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "unsigned_size_bytes": {"type": "integer", "minimum": 1}, + "commitment_scheme": {"const": "sha256-canonical-json-lf"} + } +} diff --git a/schemas/qualification-release.schema.json b/schemas/qualification-release.schema.json index b5d3ba8..387a369 100644 --- a/schemas/qualification-release.schema.json +++ b/schemas/qualification-release.schema.json @@ -115,7 +115,36 @@ "uniqueItems": true, "items": {"$ref": "#/$defs/artifact"} } - } + }, + "oneOf": [ + { + "properties": { + "kind": {"const": "package"}, + "version": {"type": "string", "minLength": 1, "maxLength": 80}, + "tag": {"type": "string", "minLength": 1, "maxLength": 120}, + "deployment_id": {"type": "null"}, + "deployment_sha256": {"type": "null"} + } + }, + { + "properties": { + "kind": {"const": "deployment"}, + "version": {"type": "null"}, + "tag": {"type": "null"}, + "deployment_id": {"$ref": "#/$defs/decimal_id"}, + "deployment_sha256": {"$ref": "#/$defs/digest"} + } + }, + { + "properties": { + "kind": {"const": "hybrid"}, + "version": {"type": "string", "minLength": 1, "maxLength": 80}, + "tag": {"type": "string", "minLength": 1, "maxLength": 120}, + "deployment_id": {"$ref": "#/$defs/decimal_id"}, + "deployment_sha256": {"$ref": "#/$defs/digest"} + } + } + ] }, "staged_asset": { "type": "object", @@ -146,8 +175,8 @@ "additionalProperties": false, "required": ["include", "exclude"], "properties": { - "include": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, - "exclude": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}} + "include": {"const": ["refs/tags/v*"]}, + "exclude": {"const": []} } } } @@ -178,6 +207,7 @@ {"$ref": "#/$defs/tag_ruleset"}, {"properties": { "role": {"const": "creation_authority"}, + "name": {"const": "OpenAdapt policy: release tag creation"}, "bypass_actors": {"const": [{"actor_id": "4730708", "actor_type": "Integration", "bypass_mode": "always"}]}, "rules": {"const": [{"type": "creation"}]} }} @@ -188,8 +218,13 @@ {"$ref": "#/$defs/tag_ruleset"}, {"properties": { "role": {"const": "immutability"}, + "name": {"const": "OpenAdapt policy: immutable release tags"}, "bypass_actors": {"const": []}, - "rules": {"const": [{"type": "deletion"}, {"type": "non_fast_forward"}, {"type": "update"}]} + "rules": {"const": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + {"type": "update", "parameters": {"update_allows_fetch_and_merge": false}} + ]} }} ] }, @@ -200,7 +235,9 @@ "schema_version", "repository", "repository_id", "draft_release_id", "tag", "target_commitish", "draft", "prerelease", "release_app_id", "release_app_installation_id", "release_app_bot_user_id", "release_author_login", - "assets", "immutable_releases_enabled", "tag_rulesets", "tag_rulesets_sha256", "observed_at" + "assets", "immutable_releases", "immutable_releases_sha256", + "tag_rulesets", "tag_rulesets_sha256", "tag_ref_state", + "tag_ref_state_sha256", "observed_at" ], "properties": { "schema_version": {"const": "openadapt.production-release-staging-evidence/v1"}, @@ -216,7 +253,16 @@ "release_app_bot_user_id": {"const": "321543906"}, "release_author_login": {"const": "openadapt-release[bot]"}, "assets": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/staged_asset"}}, - "immutable_releases_enabled": {"const": true}, + "immutable_releases": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "enforced_by_owner"], + "properties": { + "enabled": {"const": true}, + "enforced_by_owner": {"type": "boolean"} + } + }, + "immutable_releases_sha256": {"$ref": "#/$defs/digest"}, "tag_rulesets": { "type": "array", "minItems": 2, @@ -228,6 +274,16 @@ "items": false }, "tag_rulesets_sha256": {"$ref": "#/$defs/digest"}, + "tag_ref_state": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "exists"], + "properties": { + "ref": {"type": "string", "pattern": "^refs/tags/v[^/]+$"}, + "exists": {"const": false} + } + }, + "tag_ref_state_sha256": {"$ref": "#/$defs/digest"}, "observed_at": {"$ref": "#/$defs/timestamp"} } }, diff --git a/schemas/qualification-revocation-state-receipt.schema.json b/schemas/qualification-revocation-state-receipt.schema.json new file mode 100644 index 0000000..ffcbe3e --- /dev/null +++ b/schemas/qualification-revocation-state-receipt.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-revocation-state-receipt.schema.json", + "title": "OpenAdapt qualification revocation state receipt v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "revocation_state_sha256", "previous_revocation_state_sha256", + "revision", "status", "authority_state_sha256", "signer_registry_sha256", + "revocations", "observed_at", "not_before", "expires_at", "issuer_key_id", + "algorithm", "signing_statement", "signature", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-revocation-state-receipt/v1"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "previous_revocation_state_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "revision": {"type": "integer", "minimum": 1}, + "status": {"const": "current"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "revocations": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/revocation"}}, + "observed_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, + "algorithm": {"const": "ed25519"}, + "signing_statement": { + "allOf": [ + {"$ref": "qualification-evidence-signing-statement.schema.json"}, + {"properties": { + "object_schema_version": { + "const": "openadapt.qualification-revocation-state-receipt/v1" + }, + "signature_domain": { + "const": "OpenAdapt qualification revocation state receipt v1\u0000" + } + }} + ] + }, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9+/]{86}==$"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "revocation": { + "type": "object", + "additionalProperties": false, + "required": ["subject_kind", "subject_id", "revoked_at", "reason_code"], + "properties": { + "subject_kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "subject_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "revoked_at": {"$ref": "#/$defs/timestamp"}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/openadapt-ops"}, + "repository_id": {"const": "1172011294"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/qualification-revocation-state.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "qualification-revocation-state"} + } + } + } +} diff --git a/schemas/qualification-signer-registry.schema.json b/schemas/qualification-signer-registry.schema.json new file mode 100644 index 0000000..64fd7a0 --- /dev/null +++ b/schemas/qualification-signer-registry.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-signer-registry.schema.json", + "title": "OpenAdapt qualification signer registry v2", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "revision", "generated_at", "expires_at", "signers"], + "properties": { + "schema_version": {"const": "openadapt.qualification-signer-registry/v2"}, + "revision": {"type": "integer", "minimum": 1}, + "generated_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "signers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/signer"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "signer": { + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", "key_id", "public_key", "public_key_spki_der_base64", + "public_key_sha256", + "statement_schema_versions", "allowed_usages", "allowed_workflows", + "allowed_ref_prefixes", "status", "revoked_at" + ], + "properties": { + "algorithm": {"const": "ed25519"}, + "key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, + "public_key": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"}, + "public_key_spki_der_base64": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]{59}=$" + }, + "public_key_sha256": {"$ref": "#/$defs/digest"}, + "statement_schema_versions": { + "const": ["openadapt.qualification-evidence-signing-statement/v1"] + }, + "allowed_usages": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "private-qualification-evidence-decision", + "private-qualification-evidence-decision-final-manifest", + "private-qualification-evidence-decision-request", + "private-qualification-evidence-decision-storage-seal", + "private-qualification-evidence-manifest", + "qualification-authority-state-receipt", + "qualification-campaign-permit", + "qualification-evidence-decision-receipt", + "qualification-revocation-state-receipt", + "qualification-source-evidence-manifest", + "qualification-trial-receipt" + ] + } + }, + "allowed_workflows": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^https://github\\.com/OpenAdaptAI/[A-Za-z0-9_.-]+/\\.github/workflows/[A-Za-z0-9_.-]+\\.ya?ml@refs/heads/[A-Za-z0-9._/-]+$" + } + }, + "allowed_ref_prefixes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^refs/"} + }, + "status": {"enum": ["active", "revoked"]}, + "revoked_at": { + "oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}] + } + } + } + } +} diff --git a/schemas/support-release-admission.schema.json b/schemas/support-release-admission.schema.json new file mode 100644 index 0000000..8763bae --- /dev/null +++ b/schemas/support-release-admission.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "support-release-admission.schema.json", + "title": "OpenAdapt Support release admission v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "admission_id_sha256", "lifecycle_state", + "support_target", "verdict", "claim_scope", "release_identity", + "release", "release_sha256", "artifact_inventory_sha256", + "publication_staging", "publication_staging_sha256", + "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "support_policy_sha256", "issued_at", + "not_before", "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.support-release-admission/v1"}, + "admission_id_sha256": {"$ref": "#/$defs/digest"}, + "lifecycle_state": {"const": "Support"}, + "support_target": {"const": "openadapt-tray"}, + "verdict": {"const": "accepted"}, + "claim_scope": {"const": "support_openadapt_tray"}, + "release_identity": {"$ref": "#/$defs/release_identity"}, + "release": {"$ref": "qualification-release.schema.json#/$defs/release_candidate"}, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "publication_staging": {"$ref": "qualification-release.schema.json#/$defs/publication_staging"}, + "publication_staging_sha256": {"$ref": "#/$defs/digest"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "support_policy_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "release_identity": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "sequence", "previous_admission_sha256"], + "properties": { + "schema_version": {"const": "openadapt.monotonic-support-release/v1"}, + "channel": {"const": "support"}, + "sequence": {"type": "integer", "minimum": 1}, + "previous_admission_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]} + } + }, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-support-release-admission.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "support-release-admission"} + } + } + } +} diff --git a/scripts/production_trust.py b/scripts/production_trust.py index a8675e3..ecbb4f7 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -6,6 +6,8 @@ import hashlib import json import re +import subprocess +import tempfile from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from pathlib import Path @@ -14,14 +16,137 @@ import validate_evidence_registry as evidence DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +RAW_DIGEST = re.compile(r"^[0-9a-f]{64}$") HEX40 = re.compile(r"^[0-9a-f]{40}$") TIMESTAMP = re.compile( r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" ) DECIMAL_ID = re.compile(r"^[1-9][0-9]*$") ENTITY_CLASS = re.compile(r"^[a-z][a-z0-9 -]{0,63}$") +BUNDLE_VERSION = re.compile( + r"^(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\." + r"(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" +) TARGETS = ("agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt") +TARGET_CONTRACTS = { + "agent": { + "claim_scope": "production_agent", + "repository": "OpenAdaptAI/openadapt-agent", + "repository_id": "1136136670", + "release_kind": "package", + "artifacts": { + "python-sdist": ("application/gzip", ("github-release", "pypi")), + "python-wheel": ("application/zip", ("github-release", "pypi")), + }, + }, + "capture": { + "claim_scope": "production_capture", + "repository": "OpenAdaptAI/openadapt-capture", + "repository_id": "1115283835", + "release_kind": "package", + "artifacts": { + "chrome-extension-zip": ("application/zip", ("github-release",)), + "python-sdist": ("application/gzip", ("github-release", "pypi")), + "python-wheel": ("application/zip", ("github-release", "pypi")), + "spdx-sbom": ("application/spdx+json", ("github-release",)), + }, + }, + "cloud": { + "claim_scope": "production_cloud", + "repository": "OpenAdaptAI/openadapt-cloud", + "repository_id": "1300570990", + "release_kind": "deployment", + "artifacts": { + "deployment-manifest": ( + "application/vnd.openadapt.production-deployment-manifest+json;version=1", + ("deployment",), + ), + }, + }, + "desktop": { + "claim_scope": "production_desktop", + "repository": "OpenAdaptAI/openadapt-desktop", + "repository_id": "1171291730", + "release_kind": "package", + "artifacts": { + "cyclonedx-sbom": ( + "application/vnd.cyclonedx+json", + ("github-release",), + ), + "linux-appimage": ("application/vnd.appimage", ("github-release",)), + "linux-deb": ( + "application/vnd.debian.binary-package", + ("github-release",), + ), + "macos-dmg-arm64": ( + "application/x-apple-diskimage", + ("github-release",), + ), + "macos-dmg-x86-64": ( + "application/x-apple-diskimage", + ("github-release",), + ), + "python-sdist": ("application/gzip", ("github-release", "pypi")), + "python-wheel": ("application/zip", ("github-release", "pypi")), + "release-checksums": ("text/plain", ("github-release",)), + "verification-metadata-linux-x86-64": ( + "application/vnd.openadapt.desktop-platform-verification+json;version=1", + ("github-release",), + ), + "verification-metadata-macos-arm64": ( + "application/vnd.openadapt.desktop-platform-verification+json;version=1", + ("github-release",), + ), + "verification-metadata-macos-x86-64": ( + "application/vnd.openadapt.desktop-platform-verification+json;version=1", + ("github-release",), + ), + "verification-metadata-windows-x86-64": ( + "application/vnd.openadapt.desktop-platform-verification+json;version=1", + ("github-release",), + ), + "windows-msi": ("application/x-msi", ("github-release",)), + "windows-nsis": ( + "application/vnd.microsoft.portable-executable", + ("github-release",), + ), + }, + }, + "docs": { + "claim_scope": "production_docs", + "repository": "OpenAdaptAI/openadapt-ops", + "repository_id": "1172011294", + "release_kind": "deployment", + "artifacts": { + "deployment-manifest": ( + "application/vnd.openadapt.production-deployment-manifest+json;version=1", + ("deployment",), + ), + "site-archive": ("application/gzip", ("deployment",)), + }, + }, + "flow": { + "claim_scope": "production_flow", + "repository": "OpenAdaptAI/openadapt-flow", + "repository_id": "1291376938", + "release_kind": "package", + "artifacts": { + "python-sdist": ("application/gzip", ("github-release", "pypi")), + "python-wheel": ("application/zip", ("github-release", "pypi")), + }, + }, + "openadapt": { + "claim_scope": "production_openadapt", + "repository": "OpenAdaptAI/OpenAdapt", + "repository_id": "627024850", + "release_kind": "package", + "artifacts": { + "python-sdist": ("application/gzip", ("github-release", "pypi")), + "python-wheel": ("application/zip", ("github-release", "pypi")), + }, + }, +} CAMPAIGN_CLASSES = ( "healthy", "safe_halt", @@ -55,11 +180,71 @@ RELEASE_ADMISSION_DOMAIN = b"OpenAdapt qualification release admission v1\0" STAGING_DOMAIN = b"OpenAdapt production release staging evidence v1\0" TAG_RULESETS_DOMAIN = b"OpenAdapt production release tag rulesets v1\0" +IMMUTABLE_RELEASES_DOMAIN = b"OpenAdapt production immutable releases response v1\0" +TAG_REF_STATE_DOMAIN = b"OpenAdapt production release tag ref state v1\0" +PUBLICATION_RECOVERY_AUTHORIZATION_DOMAIN = ( + b"OpenAdapt production publication recovery authorization v1\0" +) +PUBLICATION_RECOVERY_IDEMPOTENCY_DOMAIN = ( + b"OpenAdapt production publication recovery idempotency v1\0" +) ADMISSION_DOMAIN = b"OpenAdapt qualification admission v3\0" CHECKPOINT_DOMAIN = b"OpenAdapt production lifecycle checkpoint v1\0" PROJECTION_DOMAIN = b"OpenAdapt production lifecycle projection v2\0" RELEASE_SET_DOMAIN = b"OpenAdapt production release admission set v1\0" WORKFLOW_SET_DOMAIN = b"OpenAdapt production workflow admission set v1\0" +AUTHORITY_STATE_IDENTITY_DOMAIN = ( + b"OpenAdapt qualification authority state identity v2\0" +) +REVOCATION_STATE_IDENTITY_DOMAIN = ( + b"OpenAdapt qualification revocation state identity v1\0" +) +PRODUCTION_EVIDENCE_IDENTITY_DOMAIN = ( + b"OpenAdapt production acceptance evidence identity v2\0" +) +DECISION_RECEIPT_SIGNATURE_DOMAIN = ( + b"OpenAdapt qualification evidence decision receipt v1\0" +) +AUTHORITY_STATE_SIGNATURE_DOMAIN = ( + b"OpenAdapt qualification authority state receipt v2\0" +) +REVOCATION_STATE_SIGNATURE_DOMAIN = ( + b"OpenAdapt qualification revocation state receipt v1\0" +) +SUPPORT_RELEASE_DOMAIN = b"OpenAdapt Support release candidate v1\0" +SUPPORT_ARTIFACT_INVENTORY_DOMAIN = ( + b"OpenAdapt Support release artifact inventory v1\0" +) +SUPPORT_RELEASE_ADMISSION_DOMAIN = b"OpenAdapt Support release admission v1\0" +CLOUD_AUTHORIZATION_DOMAIN = b"OpenAdapt production Cloud deploy authorization v1\0" +CLOUD_HANDOFF_DOMAIN = b"OpenAdapt production Cloud deployment handoff v1\0" +CLOUD_SOURCE_PROOF_REQUEST_DOMAIN = ( + b"OpenAdapt production secret source proof request v2\0" +) +CLOUD_SOURCE_COMMIT_DOMAIN = b"OpenAdapt Cloud source commit commitment v1\0" +CLOUD_PROVIDER_IDEMPOTENCY_DOMAIN = ( + b"OpenAdapt production Cloud provider idempotency v1\0" +) +CLOUD_PUBLIC_VALUES_DOMAIN = b"OpenAdapt production Cloud public values v1\0" +CLOUD_LIVE_SOURCE_NAMES = ( + "BUSINESS_DECISION_ANSWER_HMAC_KEY", + "BUSINESS_DECISION_ANSWER_HMAC_KEY_ID", + "BUSINESS_DECISION_CLOUD_POLICY_HMAC_KEYS_JSON", + "BUSINESS_DECISION_PRINCIPAL_ALIAS_HMAC_KEY", + "BUSINESS_DECISION_QUALIFICATION_HMAC_KEYS_JSON", + "BUSINESS_DECISION_ROLE_MAPPING_HMAC_KEY", + "BUSINESS_DECISION_RUNNER_RECEIPT_HMAC_KEYS_JSON", + "BUSINESS_DECISION_SESSION_BINDING_HMAC_KEY", + "BUSINESS_DECISION_TASK_HMAC_KEYS_JSON", + "EXECUTE_ACCEPTANCE_CAMPAIGN_PROVISIONER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_BEARER_TOKEN", + "EXECUTE_ACCEPTANCE_OBSERVER_SIGNING_PRIVATE_KEY", + "EXECUTE_ACCEPTANCE_TARGET_SIGNING_SECRET", +) +CLOUD_PUBLIC_SOURCE_NAME = "BUSINESS_DECISION_ANSWER_HMAC_KEY_ID" +CLOUD_SECRET_SOURCE_NAMES = tuple( + name for name in CLOUD_LIVE_SOURCE_NAMES if name != CLOUD_PUBLIC_SOURCE_NAME +) FEED_UPDATE_IDEMPOTENCY_DOMAIN = ( b"OpenAdapt production lifecycle feed update idempotency v1\0" ) @@ -92,6 +277,12 @@ def require_digest(value: Any, label: str) -> str: return value +def require_raw_digest(value: Any, label: str) -> str: + if not isinstance(value, str) or RAW_DIGEST.fullmatch(value) is None: + raise TrustError(f"{label} must be lowercase raw sha256 hex") + return value + + def require_decimal_id(value: Any, label: str) -> str: if not isinstance(value, str) or DECIMAL_ID.fullmatch(value) is None: raise TrustError(f"{label} must be a decimal string") @@ -121,7 +312,7 @@ def validate_window( issued = require_timestamp(value["issued_at"], "issued_at") not_before = require_timestamp(value["not_before"], "not_before") expires = require_timestamp(value["expires_at"], "expires_at") - if not issued <= not_before < expires <= issued + maximum: + if not not_before <= issued < expires <= not_before + maximum: raise TrustError("validity window is invalid") if now is not None and not not_before <= now < expires: raise TrustError("object is not active at the requested time") @@ -198,7 +389,9 @@ def validate_campaign_summary(value: Any) -> dict[str, Any]: return summary -def validate_artifacts(value: Any) -> list[dict[str, Any]]: +def validate_artifacts( + value: Any, *, target: str | None = None +) -> list[dict[str, Any]]: if not isinstance(value, list) or not value: raise TrustError("artifact inventory must be a non-empty list") result: list[dict[str, Any]] = [] @@ -240,6 +433,22 @@ def validate_artifacts(value: Any) -> list[dict[str, Any]]: result.append(artifact) if result != sorted(result, key=lambda item: (item["kind"], item["name"], item["sha256"])): raise TrustError("artifacts must be sorted by kind, name, and digest") + if target is not None: + contract = TARGET_CONTRACTS.get(target) + if contract is None: + raise TrustError("artifact target is invalid") + profile = contract["artifacts"] + if [item["kind"] for item in result] != sorted(profile): + raise TrustError(f"{target} artifact kinds differ from the target profile") + for artifact in result: + media_type, destinations = profile[artifact["kind"]] + if ( + artifact["media_type"] != media_type + or artifact["publish_destinations"] != list(destinations) + ): + raise TrustError( + f"{target} {artifact['kind']} media type or destinations differ" + ) return result @@ -253,7 +462,7 @@ def validate_artifact_inventory(value: Any) -> dict[str, Any]: raise TrustError("artifact inventory target is invalid") if not isinstance(inventory["claim_scope"], str) or not inventory["claim_scope"]: raise TrustError("artifact inventory claim scope is invalid") - validate_artifacts(inventory["artifacts"]) + validate_artifacts(inventory["artifacts"], target=inventory["target"]) return inventory @@ -289,9 +498,9 @@ def validate_tag_rulesets(value: Any, *, repository: str, repository_id: str) -> if ruleset["target"] != "tag" or ruleset["enforcement"] != "active": raise TrustError("tag ruleset is not active for tags") expected_name = ( - "Production release tags: creation authority" + "OpenAdapt policy: release tag creation" if index == 0 - else "Production release tags: immutable" + else "OpenAdapt policy: immutable release tags" ) if ruleset["name"] != expected_name: raise TrustError("tag ruleset name differs from policy") @@ -332,8 +541,10 @@ def validate_staging(value: Any) -> dict[str, Any]: "schema_version", "repository", "repository_id", "draft_release_id", "tag", "target_commitish", "draft", "prerelease", "release_app_id", "release_app_installation_id", "release_app_bot_user_id", - "release_author_login", "assets", "immutable_releases_enabled", - "tag_rulesets", "tag_rulesets_sha256", "observed_at", + "release_author_login", "assets", "immutable_releases", + "immutable_releases_sha256", "tag_rulesets", + "tag_rulesets_sha256", "tag_ref_state", "tag_ref_state_sha256", + "observed_at", }, "publication staging", ) @@ -348,9 +559,31 @@ def validate_staging(value: Any) -> dict[str, Any]: or staging["release_app_installation_id"] != "156835568" or staging["release_app_bot_user_id"] != "321543906" or staging["release_author_login"] != "openadapt-release[bot]" - or staging["immutable_releases_enabled"] is not True ): raise TrustError("publication staging authority or state differs") + immutable_releases = closed( + staging["immutable_releases"], + {"enabled", "enforced_by_owner"}, + "immutable releases response", + ) + if ( + immutable_releases["enabled"] is not True + or not isinstance(immutable_releases["enforced_by_owner"], bool) + ): + raise TrustError("immutable releases are not enabled") + if staging["immutable_releases_sha256"] != digest_bytes( + IMMUTABLE_RELEASES_DOMAIN, immutable_releases + ): + raise TrustError("immutable releases response digest is invalid") + tag_ref_state = closed( + staging["tag_ref_state"], {"ref", "exists"}, "tag ref state" + ) + if tag_ref_state != {"ref": f"refs/tags/{staging['tag']}", "exists": False}: + raise TrustError("release tag must not exist when the draft is staged") + if staging["tag_ref_state_sha256"] != digest_bytes( + TAG_REF_STATE_DOMAIN, tag_ref_state + ): + raise TrustError("tag ref state digest is invalid") if not isinstance(staging["target_commitish"], str) or HEX40.fullmatch(staging["target_commitish"]) is None: raise TrustError("publication staging target commit must be exact") if not isinstance(staging["tag"], str) or not staging["tag"]: @@ -414,6 +647,9 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An raise TrustError("qualification release schema or verdict is invalid") if release_admission["target"] not in TARGETS: raise TrustError("qualification release target is invalid") + target_contract = TARGET_CONTRACTS[release_admission["target"]] + if release_admission["claim_scope"] != target_contract["claim_scope"]: + raise TrustError("qualification release claim scope differs from target policy") identity = closed(release_admission["release_identity"], {"schema_version", "channel", "sequence", "previous_admission_sha256"}, "release identity") if identity["schema_version"] != "openadapt.monotonic-production-release/v1" or identity["channel"] != "production": raise TrustError("release identity schema or channel is invalid") @@ -423,10 +659,72 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An release = closed(release_admission["release"], {"schema_version", "kind", "source_repository", "source_repository_id", "source_commit", "version", "tag", "deployment_id", "deployment_sha256", "artifacts"}, "release candidate") if release["schema_version"] != "openadapt.production-release-candidate/v1" or release["kind"] not in {"package", "deployment", "hybrid"}: raise TrustError("release candidate schema or kind is invalid") + if ( + release["kind"] != target_contract["release_kind"] + or release["source_repository"] != target_contract["repository"] + or release["source_repository_id"] != target_contract["repository_id"] + ): + raise TrustError("release candidate differs from the target policy") require_decimal_id(release["source_repository_id"], "release source repository id") if not isinstance(release["source_commit"], str) or HEX40.fullmatch(release["source_commit"]) is None: raise TrustError("release source commit must be exact") - artifacts = validate_artifacts(release["artifacts"]) + if release["kind"] == "package": + if ( + not isinstance(release["version"], str) + or not release["version"] + or not isinstance(release["tag"], str) + or not release["tag"] + or release["deployment_id"] is not None + or release["deployment_sha256"] is not None + ): + raise TrustError("package release identity fields are invalid") + elif release["kind"] == "deployment": + if ( + release["version"] is not None + or release["tag"] is not None + or require_decimal_id(release["deployment_id"], "deployment id") is None + ): + raise TrustError("deployment release identity fields are invalid") + require_digest(release["deployment_sha256"], "deployment digest") + else: + if ( + not isinstance(release["version"], str) + or not release["version"] + or not isinstance(release["tag"], str) + or not release["tag"] + ): + raise TrustError("hybrid package identity fields are invalid") + require_decimal_id(release["deployment_id"], "deployment id") + require_digest(release["deployment_sha256"], "deployment digest") + artifacts = validate_artifacts( + release["artifacts"], target=release_admission["target"] + ) + if release_admission["target"] == "desktop": + if any( + token in item["name"].casefold() + for item in artifacts + for token in ("beta", "candidate", "adhoc", "unsigned") + ): + raise TrustError("Desktop release filenames contain a non-production label") + version = release["version"] + expected_metadata_names = { + "verification-metadata-linux-x86-64": ( + f"OpenAdapt-Desktop-v{version}-linux-x86_64-verification.json" + ), + "verification-metadata-macos-arm64": ( + f"OpenAdapt-Desktop-v{version}-macos-arm64-verification.json" + ), + "verification-metadata-macos-x86-64": ( + f"OpenAdapt-Desktop-v{version}-macos-x86_64-verification.json" + ), + "verification-metadata-windows-x86-64": ( + f"OpenAdapt-Desktop-v{version}-windows-x86_64-verification.json" + ), + } + for artifact in artifacts: + expected_name = expected_metadata_names.get(artifact["kind"]) + if expected_name is not None and artifact["name"] != expected_name: + raise TrustError("Desktop verification metadata filename differs") inventory = {"target": release_admission["target"], "claim_scope": release_admission["claim_scope"], "artifacts": artifacts} if release_admission["artifact_inventory_sha256"] != digest_bytes(ARTIFACT_INVENTORY_DOMAIN, inventory): raise TrustError("release artifact inventory digest is invalid") @@ -478,48 +776,701 @@ def verify_local_artifacts(root: Path, artifacts: Sequence[Mapping[str, Any]]) - raise TrustError(f"candidate artifact bytes differ: {artifact['name']}") +def validate_support_release( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "admission_id_sha256", "lifecycle_state", + "support_target", "verdict", "claim_scope", "release_identity", + "release", "release_sha256", "artifact_inventory_sha256", + "publication_staging", "publication_staging_sha256", + "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "support_policy_sha256", "issued_at", + "not_before", "expires_at", "issuer", + } + admission = closed(value, fields, "Support release admission") + if ( + admission["schema_version"] + != "openadapt.support-release-admission/v1" + or admission["lifecycle_state"] != "Support" + or admission["support_target"] != "openadapt-tray" + or admission["verdict"] != "accepted" + or admission["claim_scope"] != "support_openadapt_tray" + ): + raise TrustError("Support release admission identity differs") + for field in fields: + if field.endswith("_sha256"): + require_digest(admission[field], field) + identity = closed( + admission["release_identity"], + {"schema_version", "channel", "sequence", "previous_admission_sha256"}, + "Support release identity", + ) + if ( + identity["schema_version"] != "openadapt.monotonic-support-release/v1" + or identity["channel"] != "support" + ): + raise TrustError("Support release identity schema differs") + sequence = require_positive_int(identity["sequence"], "Support release sequence") + if sequence == 1: + if identity["previous_admission_sha256"] is not None: + raise TrustError("first Support release cannot have a previous admission") + else: + require_digest( + identity["previous_admission_sha256"], "previous Support release admission" + ) + release = closed( + admission["release"], + { + "schema_version", "kind", "source_repository", + "source_repository_id", "source_commit", "version", "tag", + "deployment_id", "deployment_sha256", "artifacts", + }, + "Support release candidate", + ) + if ( + release["schema_version"] != "openadapt.production-release-candidate/v1" + or release["kind"] != "package" + or release["source_repository"] != "OpenAdaptAI/openadapt-tray" + or release["source_repository_id"] != "1136122737" + or not isinstance(release["source_commit"], str) + or HEX40.fullmatch(release["source_commit"]) is None + or not isinstance(release["version"], str) + or not release["version"] + or release["tag"] != f"v{release['version']}" + or release["deployment_id"] is not None + or release["deployment_sha256"] is not None + ): + raise TrustError("Support release candidate differs from openadapt-tray") + artifacts = validate_artifacts(release["artifacts"]) + if [item["kind"] for item in artifacts] != ["python-sdist", "python-wheel"]: + raise TrustError("openadapt-tray Support release must contain wheel and sdist") + expected_profiles = { + "python-sdist": ("application/gzip", ["github-release", "pypi"]), + "python-wheel": ("application/zip", ["github-release", "pypi"]), + } + for artifact in artifacts: + media_type, destinations = expected_profiles[artifact["kind"]] + if ( + artifact["media_type"] != media_type + or artifact["publish_destinations"] != destinations + ): + raise TrustError("openadapt-tray Support artifact profile differs") + inventory = { + "support_target": admission["support_target"], + "claim_scope": admission["claim_scope"], + "artifacts": artifacts, + } + if admission["artifact_inventory_sha256"] != digest_bytes( + SUPPORT_ARTIFACT_INVENTORY_DOMAIN, inventory + ): + raise TrustError("Support artifact inventory digest is invalid") + if admission["release_sha256"] != digest_bytes( + SUPPORT_RELEASE_DOMAIN, + { + "support_target": admission["support_target"], + "claim_scope": admission["claim_scope"], + "release": release, + }, + ): + raise TrustError("Support release candidate digest is invalid") + staging = validate_staging(admission["publication_staging"]) + if ( + admission["publication_staging_sha256"] != staging_digest(staging) + or staging["repository"] != release["source_repository"] + or staging["repository_id"] != release["source_repository_id"] + or staging["target_commitish"] != release["source_commit"] + or staging["tag"] != release["tag"] + ): + raise TrustError("Support publication staging differs from the release") + bound_fields = ( + "name", "kind", "sha256", "size_bytes", "media_type", + "publish_destinations", + ) + staged = sorted( + tuple( + item[field] if field != "publish_destinations" else tuple(item[field]) + for field in bound_fields + ) + for item in staging["assets"] + ) + admitted = sorted( + tuple( + item[field] if field != "publish_destinations" else tuple(item[field]) + for field in bound_fields + ) + for item in artifacts + ) + if staged != admitted: + raise TrustError("Support staged assets differ from the admitted inventory") + issuer = closed( + admission["issuer"], + { + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", + }, + "Support release issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/.github" + or issuer["repository_id"] != "858454062" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] + != ".github/workflows/issue-support-release-admission.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "support-release-admission" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("Support release issuer differs") + validate_window(admission, maximum=timedelta(days=7), now=now) + projection = dict(admission) + identity_digest = projection.pop("admission_id_sha256") + if identity_digest != digest_bytes(SUPPORT_RELEASE_ADMISSION_DOMAIN, projection): + raise TrustError("Support release admission id is invalid") + return admission + + +def validate_publication_recovery_authorization( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "authorization_id_sha256", + "qualification_release_reference", "qualification_release_object_sha256", + "release_sha256", "artifact_inventory_sha256", + "publication_staging_sha256", "repository", "repository_id", "target", + "tag", "tag_ref", "tag_object_id", "target_commit", "draft_release_id", + "requested_effects", "run_id", "run_attempt", "dispatcher_actor_id", + "environment", "release_app", "idempotency_key", "issued_at", + "not_before", "expires_at", "issuer", + } + authorization = closed( + value, fields, "production publication recovery authorization" + ) + if ( + authorization["schema_version"] + != "openadapt.production-publication-recovery-authorization/v1" + or authorization["run_attempt"] != "1" + or authorization["dispatcher_actor_id"] != "774615" + or authorization["environment"] != "release-identity" + ): + raise TrustError("publication recovery authorization identity differs") + target = authorization["target"] + contract = TARGET_CONTRACTS.get(target) + if contract is None or contract["release_kind"] != "package": + raise TrustError("publication recovery applies only to package targets") + if ( + authorization["repository"] != contract["repository"] + or authorization["repository_id"] != contract["repository_id"] + ): + raise TrustError("publication recovery target repository differs") + for field in ( + "authorization_id_sha256", "qualification_release_object_sha256", + "release_sha256", "artifact_inventory_sha256", + "publication_staging_sha256", + ): + require_digest(authorization[field], field) + reference = evidence.validate_reference( + authorization["qualification_release_reference"] + ) + if ( + reference["kind"] != "qualification-release" + or reference["object_schema_version"] + != "openadapt.qualification-release/v1" + or reference["object_media_type"] + != "application/vnd.openadapt.qualification-release+json;version=1" + or reference["subject_sha256"] is not None + or reference["object_sha256"] + != authorization["qualification_release_object_sha256"] + ): + raise TrustError("publication recovery release reference differs") + if ( + not isinstance(authorization["tag"], str) + or not authorization["tag"].startswith("v") + or authorization["tag_ref"] != f"refs/tags/{authorization['tag']}" + or not isinstance(authorization["tag_object_id"], str) + or HEX40.fullmatch(authorization["tag_object_id"]) is None + or not isinstance(authorization["target_commit"], str) + or HEX40.fullmatch(authorization["target_commit"]) is None + ): + raise TrustError("publication recovery immutable tag binding differs") + require_decimal_id(authorization["draft_release_id"], "draft release id") + require_decimal_id(authorization["run_id"], "recovery run id") + effects = authorization["requested_effects"] + if ( + not isinstance(effects, list) + or not effects + or effects != sorted(set(effects)) + or any( + effect not in {"github-release", "mcp-registry", "pypi"} + for effect in effects + ) + or ("mcp-registry" in effects and target != "agent") + ): + raise TrustError("publication recovery effect set differs") + release_app = closed( + authorization["release_app"], + {"app_id", "installation_id", "bot_user_id", "bot_login"}, + "publication recovery release App", + ) + if release_app != { + "app_id": "4730708", + "installation_id": "156835568", + "bot_user_id": "321543906", + "bot_login": "openadapt-release[bot]", + }: + raise TrustError("publication recovery release App differs") + idempotency_projection = { + "qualification_release_object_sha256": authorization[ + "qualification_release_object_sha256" + ], + "repository": authorization["repository"], + "tag_ref": authorization["tag_ref"], + "tag_object_id": authorization["tag_object_id"], + "run_id": authorization["run_id"], + "run_attempt": authorization["run_attempt"], + "requested_effects": effects, + } + expected_idempotency = "publication-recovery:" + hashlib.sha256( + PUBLICATION_RECOVERY_IDEMPOTENCY_DOMAIN + + canonical(idempotency_projection) + ).hexdigest() + if authorization["idempotency_key"] != expected_idempotency: + raise TrustError("publication recovery idempotency key differs") + issuer = closed( + authorization["issuer"], + { + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", + }, + "publication recovery issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/.github" + or issuer["repository_id"] != "858454062" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] + != ".github/workflows/issue-production-publication-recovery.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "release-identity" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("publication recovery issuer differs") + validate_window(authorization, maximum=timedelta(minutes=5), now=now) + projection = dict(authorization) + identity = projection.pop("authorization_id_sha256") + if identity != digest_bytes( + PUBLICATION_RECOVERY_AUTHORIZATION_DOMAIN, projection + ): + raise TrustError("publication recovery authorization id differs") + return authorization + + def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, Any]: fields = { - "schema_version", "decision_commitment_sha256", "evidence_manifest_sha256", + "schema_version", "decision_identity_sha256", "decision_revision", + "decision_commitment_sha256", "evidence_manifest_sha256", "campaign_artifact_sha256", "organization_id_sha256", "workflow_id_sha256", "workflow_version_id_sha256", "bundle_version", "bundle_sha256", "admitted_runtime_sha256", "application_contract_sha256", "environment_contract_sha256", "input_contract_sha256", "action_contract_sha256", "identity_contract_sha256", "effect_contract_sha256", "policy_contract_sha256", - "evidence_authority_sha256", "campaign_permit_sha256", "signer_registry_sha256", + "evidence_authority_contract_sha256", "campaign_permit_sha256", "signer_registry_sha256", "revocation_state_sha256", "entity_class", "campaign_summary", "verdict", - "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", "signature", + "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", + "issuer", } receipt = closed(value, fields, "qualification evidence decision receipt") - if receipt["schema_version"] != "openadapt.qualification-evidence-decision-receipt/v1" or receipt["verdict"] != "accepted": + if receipt["schema_version"] != "openadapt.qualification-evidence-decision-receipt/v1" or receipt["verdict"] != "ADMIT": raise TrustError("decision receipt schema or verdict is invalid") digest_fields = fields - { - "schema_version", "bundle_version", "entity_class", "campaign_summary", "verdict", - "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", "signature", + "schema_version", "decision_revision", "bundle_version", "entity_class", + "campaign_summary", "verdict", + "issued_at", "not_before", "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", + "issuer", } for field in digest_fields: require_digest(receipt[field], field) if len({receipt["decision_commitment_sha256"], receipt["evidence_manifest_sha256"], receipt["campaign_artifact_sha256"]}) != 3: raise TrustError("decision, final manifest, and campaign commitments must be distinct") - require_positive_int(receipt["bundle_version"], "bundle version") + require_positive_int(receipt["decision_revision"], "decision revision") + if ( + not isinstance(receipt["bundle_version"], str) + or len(receipt["bundle_version"]) > 64 + or BUNDLE_VERSION.fullmatch(receipt["bundle_version"]) is None + ): + raise TrustError("bundle version is not canonical") if not isinstance(receipt["entity_class"], str) or ENTITY_CLASS.fullmatch(receipt["entity_class"]) is None: raise TrustError("entity class is not remote-safe") - validate_campaign_summary(receipt["campaign_summary"]) + campaign_summary = closed( + receipt["campaign_summary"], + { + "schema_version", "minimum_trials_per_task_condition", + "task_count", "classes", + }, + "decision receipt campaign summary", + ) + if ( + campaign_summary["schema_version"] + != "openadapt.qualification-evidence-decision-campaign-summary/v1" + or campaign_summary["minimum_trials_per_task_condition"] != 3 + ): + raise TrustError("decision receipt campaign summary identity differs") + require_positive_int(campaign_summary["task_count"], "campaign task count") + validate_campaign_summary(campaign_summary["classes"]) + if any( + counts["task_condition_cell_count"] < campaign_summary["task_count"] + for counts in campaign_summary["classes"].values() + ): + raise TrustError("decision receipt campaign summary omits a task/class cell") if receipt["algorithm"] != "ed25519" or not isinstance(receipt["issuer_key_id"], str) or evidence.KEY_ID.fullmatch(receipt["issuer_key_id"]) is None: raise TrustError("decision receipt signer is invalid") signature = receipt["signature"] - if not isinstance(signature, str) or "=" in signature: - raise TrustError("decision receipt signature must be unpadded base64url") + if not isinstance(signature, str) or re.fullmatch(r"[A-Za-z0-9+/]{86}==", signature) is None: + raise TrustError("decision receipt signature must be canonical padded base64") try: - raw_signature = evidence.base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4)) + raw_signature = evidence.base64.b64decode(signature, validate=True) except Exception as exc: raise TrustError("decision receipt signature is invalid") from exc if len(raw_signature) != 64: raise TrustError("decision receipt signature must contain 64 bytes") + validate_signing_statement( + receipt, + object_schema_version="openadapt.qualification-evidence-decision-receipt/v1", + signature_domain=DECISION_RECEIPT_SIGNATURE_DOMAIN, + ) + issuer = closed( + receipt["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "decision receipt issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-internal" + or issuer["repository_id"] != "1170060695" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] + != ".github/workflows/issue-private-qualification-evidence-decision.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "private-qualification-evidence-decision" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("decision receipt issuer differs") validate_window(receipt, maximum=timedelta(days=7), now=now) return receipt +def signing_statement( + value: Mapping[str, Any], + *, + object_schema_version: str, + signature_domain: bytes, +) -> dict[str, Any]: + unsigned = dict(value) + unsigned.pop("signature", None) + unsigned.pop("signing_statement", None) + unsigned_bytes = canonical(unsigned) + b"\n" + return { + "schema_version": "openadapt.qualification-evidence-signing-statement/v1", + "object_schema_version": object_schema_version, + "signature_domain": signature_domain.decode("utf-8"), + "unsigned_object_sha256": ( + "sha256:" + hashlib.sha256(unsigned_bytes).hexdigest() + ), + "unsigned_size_bytes": len(unsigned_bytes), + "commitment_scheme": "sha256-canonical-json-lf", + } + + +def validate_signing_statement( + value: Mapping[str, Any], + *, + object_schema_version: str, + signature_domain: bytes, +) -> dict[str, Any]: + statement = closed( + value.get("signing_statement"), + { + "schema_version", "object_schema_version", "signature_domain", + "unsigned_object_sha256", "unsigned_size_bytes", "commitment_scheme", + }, + "qualification evidence signing statement", + ) + expected = signing_statement( + value, + object_schema_version=object_schema_version, + signature_domain=signature_domain, + ) + if statement != expected: + raise TrustError("qualification evidence signing statement differs") + return statement + + +def verify_embedded_signature( + value: Mapping[str, Any], + *, + signer_registry: Any, + object_schema_version: str, + signature_domain: bytes, + usage: str, + now: datetime | None = None, +) -> None: + try: + registry = evidence.validate_signer_registry(signer_registry) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + requested_time = now or datetime.now(timezone.utc) + generated = evidence._timestamp(registry["generated_at"], "generated_at") + expires = evidence._timestamp(registry["expires_at"], "expires_at") + if not generated <= requested_time < expires: + raise TrustError("signer registry is not active") + key_id = value.get("issuer_key_id") + matches = [item for item in registry["signers"] if item["key_id"] == key_id] + if len(matches) != 1 or matches[0]["status"] != "active": + raise TrustError("embedded signature key is not an active registered signer") + signer = matches[0] + if usage not in signer["allowed_usages"]: + raise TrustError("embedded signature usage is not allowed for the key") + issuer = value.get("issuer") + if not isinstance(issuer, dict): + raise TrustError("signed object has no issuer identity") + workflow_identity = ( + f"/{issuer['repository']}/{issuer['workflow']}" + f"@{issuer['ref']}" + ) + if workflow_identity not in signer["allowed_workflows"]: + raise TrustError("embedded signature workflow is not allowed for the key") + ref = issuer["ref"] + if not any( + ref == prefix or ref.startswith(prefix.rstrip("/") + "/") + for prefix in signer["allowed_ref_prefixes"] + ): + raise TrustError("embedded signature ref is not allowed for the key") + if signer["statement_schema_versions"] != [ + "openadapt.qualification-evidence-signing-statement/v1" + ]: + raise TrustError("embedded signature statement schema is not allowed") + statement = validate_signing_statement( + value, + object_schema_version=object_schema_version, + signature_domain=signature_domain, + ) + statement_bytes = canonical(statement) + b"\n" + try: + public_key = evidence.base64.urlsafe_b64decode( + signer["public_key"] + "=" * (-len(signer["public_key"]) % 4) + ) + signature = evidence.base64.b64decode(value["signature"], validate=True) + except Exception as exc: + raise TrustError("embedded signature bytes are invalid") from exc + spki = bytes.fromhex("302a300506032b6570032100") + public_key + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + key_path = directory / "key.der" + statement_path = directory / "statement.json" + signature_path = directory / "signature.bin" + key_path.write_bytes(spki) + statement_path.write_bytes(statement_bytes) + signature_path.write_bytes(signature) + result = subprocess.run( + [ + "openssl", "pkeyutl", "-verify", "-pubin", "-keyform", "DER", + "-inkey", str(key_path), "-rawin", "-in", str(statement_path), + "-sigfile", str(signature_path), + ], + capture_output=True, + text=True, + ) + if result.returncode: + raise TrustError("embedded Ed25519 signing statement verification failed") + + +def _validate_embedded_signature( + value: Mapping[str, Any], + label: str, + *, + object_schema_version: str, + signature_domain: bytes, +) -> None: + if ( + value["algorithm"] != "ed25519" + or not isinstance(value["issuer_key_id"], str) + or evidence.KEY_ID.fullmatch(value["issuer_key_id"]) is None + ): + raise TrustError(f"{label} signer is invalid") + signature = value["signature"] + if ( + not isinstance(signature, str) + or re.fullmatch(r"[A-Za-z0-9+/]{86}==", signature) is None + ): + raise TrustError(f"{label} signature must be canonical padded base64") + try: + raw = evidence.base64.b64decode(signature, validate=True) + except Exception as exc: + raise TrustError(f"{label} signature is invalid") from exc + if len(raw) != 64: + raise TrustError(f"{label} signature must contain 64 bytes") + validate_signing_statement( + value, + object_schema_version=object_schema_version, + signature_domain=signature_domain, + ) + + +def validate_authority_state( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "authority_state_sha256", "status", + "signer_registry_sha256", "signer_registry_identity_sha256", + "signer_registry_revision", "evidence_authority_sha256", "observed_at", + "not_before", "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", "issuer", + } + state = closed(value, fields, "qualification authority state receipt") + if ( + state["schema_version"] + != "openadapt.qualification-authority-state-receipt/v2" + or state["status"] != "active" + ): + raise TrustError("qualification authority state is not active") + for field in ( + "signer_registry_sha256", "signer_registry_identity_sha256", + "evidence_authority_sha256", + ): + require_digest(state[field], field) + require_positive_int(state["signer_registry_revision"], "signer registry revision") + projection = dict(state) + identity = projection.pop("authority_state_sha256") + projection.pop("signature") + projection.pop("signing_statement") + if identity != digest_bytes(AUTHORITY_STATE_IDENTITY_DOMAIN, projection): + raise TrustError("qualification authority state identity is invalid") + _validate_embedded_signature( + state, + "qualification authority state", + object_schema_version="openadapt.qualification-authority-state-receipt/v2", + signature_domain=AUTHORITY_STATE_SIGNATURE_DOMAIN, + ) + issuer = closed( + state["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "authority state issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-ops" + or issuer["repository_id"] != "1172011294" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] != ".github/workflows/qualification-authority-state.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "qualification-authority-state" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("authority state issuer differs") + validate_window( + { + "issued_at": state["observed_at"], + "not_before": state["not_before"], + "expires_at": state["expires_at"], + }, + maximum=timedelta(days=7), + now=now, + ) + return state + + +def validate_revocation_state( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "revocation_state_sha256", + "previous_revocation_state_sha256", "revision", "status", + "authority_state_sha256", "signer_registry_sha256", "revocations", + "observed_at", "not_before", "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", "issuer", + } + state = closed(value, fields, "qualification revocation state receipt") + if ( + state["schema_version"] + != "openadapt.qualification-revocation-state-receipt/v1" + or state["status"] != "current" + ): + raise TrustError("qualification revocation state is not current") + for field in ("authority_state_sha256", "signer_registry_sha256"): + require_digest(state[field], field) + if state["previous_revocation_state_sha256"] is not None: + require_digest( + state["previous_revocation_state_sha256"], "previous revocation state" + ) + require_positive_int(state["revision"], "revocation state revision") + revocations = state["revocations"] + if not isinstance(revocations, list): + raise TrustError("revocations must be an array") + identities: list[tuple[str, str]] = [] + for index, item_value in enumerate(revocations): + item = closed( + item_value, + {"subject_kind", "subject_id", "revoked_at", "reason_code"}, + f"revocation {index}", + ) + if not all( + isinstance(item[field], str) and item[field] + for field in ("subject_kind", "subject_id", "reason_code") + ): + raise TrustError("revocation identity or reason is invalid") + if ( + item["subject_kind"] == "qualification-campaign-permit" + and DIGEST.fullmatch(item["subject_id"]) is None + ): + raise TrustError("campaign permit revocation subject must be opaque") + require_timestamp(item["revoked_at"], "revoked_at") + identities.append((item["subject_kind"], item["subject_id"])) + if identities != sorted(set(identities)): + raise TrustError("revocations must be sorted and unique") + projection = dict(state) + identity = projection.pop("revocation_state_sha256") + projection.pop("signature") + projection.pop("signing_statement") + if identity != digest_bytes(REVOCATION_STATE_IDENTITY_DOMAIN, projection): + raise TrustError("qualification revocation state identity is invalid") + _validate_embedded_signature( + state, + "qualification revocation state", + object_schema_version="openadapt.qualification-revocation-state-receipt/v1", + signature_domain=REVOCATION_STATE_SIGNATURE_DOMAIN, + ) + issuer = closed( + state["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "revocation state issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-ops" + or issuer["repository_id"] != "1172011294" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] != ".github/workflows/qualification-revocation-state.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "qualification-revocation-state" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("revocation state issuer differs") + validate_window( + { + "issued_at": state["observed_at"], + "not_before": state["not_before"], + "expires_at": state["expires_at"], + }, + maximum=timedelta(days=7), + now=now, + ) + return state + + def validate_local_identity_opening(value: Any) -> dict[str, Any]: opening = closed( value, @@ -564,7 +1515,12 @@ def validate_qualification_admission( for field in fields: if field.endswith("_sha256"): require_digest(admission[field], field) - require_positive_int(admission["bundle_version"], "bundle version") + if ( + not isinstance(admission["bundle_version"], str) + or len(admission["bundle_version"]) > 64 + or BUNDLE_VERSION.fullmatch(admission["bundle_version"]) is None + ): + raise TrustError("qualification admission bundle version is not canonical") if not isinstance(admission["entity_class"], str) or ENTITY_CLASS.fullmatch(admission["entity_class"]) is None: raise TrustError("qualification admission entity class is not remote-safe") validate_campaign_summary(admission["campaign_summary"]) @@ -600,6 +1556,682 @@ def validate_qualification_admission( return admission +def _validate_release_identity(value: Any) -> dict[str, Any]: + identity = closed( + value, + { + "schema_version", "channel", "sequence", + "previous_admission_sha256", + }, + "release identity", + ) + if ( + identity["schema_version"] + != "openadapt.monotonic-production-release/v1" + or identity["channel"] != "production" + ): + raise TrustError("release identity schema or channel is invalid") + sequence = require_positive_int(identity["sequence"], "release sequence") + previous = identity["previous_admission_sha256"] + if sequence == 1: + if previous is not None: + raise TrustError("the first release identity previous admission must be null") + else: + require_digest(previous, "previous release admission") + return identity + + +def _validate_acceptance_issuer(value: Any) -> dict[str, Any]: + issuer = closed( + value, + { + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", + }, + "production acceptance issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-evals" + or issuer["repository_id"] != "1135998197" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] + != ".github/workflows/issue-production-acceptance.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "production-acceptance" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("production acceptance issuer differs") + return issuer + + +def _validate_receipt_admission_binding( + receipt_value: Any, + admission_value: Any, + *, + now: datetime | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + receipt = validate_receipt(receipt_value, now=now) + admission = validate_qualification_admission(admission_value, now=now) + validate_reference_pair( + admission["decision_receipt_reference"], + admission["decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + ) + bindings = { + "organization_id_sha256": "organization_id_sha256", + "workflow_id_sha256": "workflow_id_sha256", + "workflow_version_id_sha256": "workflow_version_id_sha256", + "bundle_version": "bundle_version", + "bundle_sha256": "bundle_sha256", + "admitted_runtime_sha256": "admitted_runtime_sha256", + "application_contract_sha256": "application_contract_sha256", + "environment_contract_sha256": "environment_contract_sha256", + "input_contract_sha256": "input_contract_sha256", + "action_contract_sha256": "action_contract_sha256", + "identity_contract_sha256": "identity_contract_sha256", + "effect_contract_sha256": "effect_contract_sha256", + "policy_contract_sha256": "policy_contract_sha256", + "evidence_authority_sha256": "evidence_authority_contract_sha256", + "campaign_artifact_sha256": "campaign_artifact_sha256", + "campaign_permit_sha256": "campaign_permit_sha256", + "signer_registry_sha256": "signer_registry_sha256", + "revocation_state_sha256": "revocation_state_sha256", + "entity_class": "entity_class", + } + for admission_field, receipt_field in bindings.items(): + if admission[admission_field] != receipt[receipt_field]: + raise TrustError( + f"qualification admission {admission_field} differs from the receipt" + ) + if admission["campaign_summary"] != receipt["campaign_summary"]["classes"]: + raise TrustError("qualification admission campaign summary differs from the receipt") + receipt_not_before = require_timestamp(receipt["not_before"], "receipt not_before") + receipt_issued = require_timestamp(receipt["issued_at"], "receipt issued_at") + receipt_expires = require_timestamp(receipt["expires_at"], "receipt expires_at") + admission_not_before = require_timestamp( + admission["not_before"], "qualification admission not_before" + ) + admission_issued = require_timestamp( + admission["issued_at"], "qualification admission issued_at" + ) + admission_expires = require_timestamp( + admission["expires_at"], "qualification admission expires_at" + ) + if not ( + receipt_not_before <= receipt_issued <= admission_issued + and admission_not_before >= receipt_not_before + and admission_expires <= receipt_expires + ): + raise TrustError("qualification admission validity exceeds the receipt") + return receipt, admission + + +def validate_acceptance_manifest( + value: Any, + *, + receipt: Any, + qualification_admission: Any, + now: datetime | None = None, +) -> dict[str, Any]: + fields = { + "schema_version", "target", "verdict", "claim_scope", + "acceptance_policy_sha256", "lifecycle_policy_sha256", + "release_identity", "release", "release_sha256", "artifact_inventory", + "artifact_inventory_sha256", "publication_staging", + "publication_staging_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", "qualification_admission_bundle_reference", + "campaign_summary", "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "issued_at", "not_before", "expires_at", "issuer", + } + manifest = closed(value, fields, "production acceptance manifest") + if ( + manifest["schema_version"] != "openadapt.production-acceptance/v2" + or manifest["verdict"] != "accepted" + or manifest["target"] not in TARGETS + or manifest["claim_scope"] + != TARGET_CONTRACTS[manifest["target"]]["claim_scope"] + ): + raise TrustError("production acceptance manifest identity differs") + for field in fields: + if field.endswith("_sha256"): + require_digest(manifest[field], field) + _validate_release_identity(manifest["release_identity"]) + _validate_acceptance_issuer(manifest["issuer"]) + release = closed( + manifest["release"], + { + "schema_version", "kind", "source_repository", "source_repository_id", + "source_commit", "version", "tag", "deployment_id", + "deployment_sha256", "artifacts", + }, + "manifest release candidate", + ) + contract = TARGET_CONTRACTS[manifest["target"]] + if ( + release["schema_version"] != "openadapt.production-release-candidate/v1" + or release["kind"] != contract["release_kind"] + or release["source_repository"] != contract["repository"] + or release["source_repository_id"] != contract["repository_id"] + or not isinstance(release["source_commit"], str) + or HEX40.fullmatch(release["source_commit"]) is None + ): + raise TrustError("manifest release candidate differs from the target policy") + artifacts = validate_artifacts(release["artifacts"], target=manifest["target"]) + if manifest["release_sha256"] != digest_bytes( + RELEASE_DOMAIN, + { + "target": manifest["target"], + "claim_scope": manifest["claim_scope"], + "release": release, + }, + ): + raise TrustError("manifest release candidate digest is invalid") + inventory = validate_artifact_inventory(manifest["artifact_inventory"]) + if ( + inventory["target"] != manifest["target"] + or inventory["claim_scope"] != manifest["claim_scope"] + or inventory["artifacts"] != artifacts + or manifest["artifact_inventory_sha256"] + != artifact_inventory_digest(inventory) + ): + raise TrustError("manifest artifact inventory differs from the release") + staging = validate_staging(manifest["publication_staging"]) + if ( + manifest["publication_staging_sha256"] != staging_digest(staging) + or staging["repository"] != release["source_repository"] + or staging["repository_id"] != release["source_repository_id"] + or staging["target_commitish"] != release["source_commit"] + or (release["tag"] is not None and staging["tag"] != release["tag"]) + ): + raise TrustError("manifest publication staging differs from the release") + bound_fields = ( + "name", "kind", "sha256", "size_bytes", "media_type", + "publish_destinations", + ) + staged_projection = sorted( + tuple( + item[field] if field != "publish_destinations" else tuple(item[field]) + for field in bound_fields + ) + for item in staging["assets"] + ) + admitted_projection = sorted( + tuple( + item[field] if field != "publish_destinations" else tuple(item[field]) + for field in bound_fields + ) + for item in artifacts + ) + if staged_projection != admitted_projection: + raise TrustError("manifest staged assets differ from the release inventory") + validate_reference_pair( + manifest["qualification_evidence_decision_receipt_reference"], + manifest["qualification_evidence_decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + ) + validate_reference_pair( + manifest["qualification_admission_reference"], + manifest["qualification_admission_bundle_reference"], + kind="qualification-admission", + ) + bound_receipt, bound_admission = _validate_receipt_admission_binding( + receipt, qualification_admission, now=now + ) + if ( + manifest["qualification_evidence_decision_receipt_reference"] + != bound_admission["decision_receipt_reference"] + or manifest["qualification_evidence_decision_receipt_bundle_reference"] + != bound_admission["decision_receipt_bundle_reference"] + or manifest["campaign_summary"] + != bound_receipt["campaign_summary"]["classes"] + or manifest["signer_registry_sha256"] + != bound_receipt["signer_registry_sha256"] + or manifest["revocation_state_sha256"] + != bound_receipt["revocation_state_sha256"] + ): + raise TrustError("production acceptance manifest evidence bindings differ") + validate_campaign_summary(manifest["campaign_summary"]) + validate_window(manifest, maximum=timedelta(days=7), now=now) + issued = require_timestamp(manifest["issued_at"], "manifest issued_at") + not_before = require_timestamp(manifest["not_before"], "manifest not_before") + expires = require_timestamp(manifest["expires_at"], "manifest expires_at") + if require_timestamp( + manifest["publication_staging"]["observed_at"], "staging observed_at" + ) > issued: + raise TrustError("publication staging was observed after manifest issuance") + for child, label in ((bound_receipt, "receipt"), (bound_admission, "admission")): + if ( + not_before < require_timestamp(child["not_before"], f"{label} not_before") + or issued < require_timestamp(child["issued_at"], f"{label} issued_at") + or expires > require_timestamp(child["expires_at"], f"{label} expires_at") + ): + raise TrustError(f"manifest validity exceeds the {label}") + return manifest + + +def acceptance_summary_identity(value: Mapping[str, Any]) -> str: + projection = { + field: value[field] + for field in ( + "target", "claim_scope", "release_identity", "release_sha256", + "artifact_inventory_sha256", "publication_staging_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_admission_reference", + "production_acceptance_manifest_reference", "campaign_summary", + "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "issuer", + ) + } + return digest_bytes(PRODUCTION_EVIDENCE_IDENTITY_DOMAIN, projection) + + +def validate_acceptance_summary( + value: Any, + *, + manifest: Any, + receipt: Any, + qualification_admission: Any, + now: datetime | None = None, +) -> dict[str, Any]: + fields = { + "schema_version", "target", "verdict", "claim_scope", + "acceptance_policy_sha256", "lifecycle_policy_sha256", + "release_identity", "release_sha256", "artifact_inventory_sha256", + "publication_staging", "publication_staging_sha256", + "evidence_identity_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", "qualification_admission_bundle_reference", + "production_acceptance_manifest_reference", + "production_acceptance_manifest_bundle_reference", "campaign_summary", + "authority_state_sha256", "revocation_state_sha256", "signer_registry_sha256", + "issued_at", "not_before", "expires_at", "issuer", + } + summary = closed(value, fields, "production acceptance summary") + if ( + summary["schema_version"] + != "openadapt.production-lifecycle-evidence-summary/v2" + or summary["verdict"] != "accepted" + or summary["target"] not in TARGETS + or summary["claim_scope"] + != TARGET_CONTRACTS[summary["target"]]["claim_scope"] + ): + raise TrustError("production acceptance summary identity differs") + for field in fields: + if field.endswith("_sha256"): + require_digest(summary[field], field) + _validate_release_identity(summary["release_identity"]) + _validate_acceptance_issuer(summary["issuer"]) + validate_reference_pair( + summary["qualification_evidence_decision_receipt_reference"], + summary["qualification_evidence_decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + ) + validate_reference_pair( + summary["qualification_admission_reference"], + summary["qualification_admission_bundle_reference"], + kind="qualification-admission", + ) + validate_reference_pair( + summary["production_acceptance_manifest_reference"], + summary["production_acceptance_manifest_bundle_reference"], + kind="production-acceptance-manifest", + ) + bound_manifest = validate_acceptance_manifest( + manifest, + receipt=receipt, + qualification_admission=qualification_admission, + now=now, + ) + for field in ( + "target", "claim_scope", "acceptance_policy_sha256", + "lifecycle_policy_sha256", "release_identity", "release_sha256", + "artifact_inventory_sha256", "publication_staging", + "publication_staging_sha256", + "qualification_evidence_decision_receipt_reference", + "qualification_evidence_decision_receipt_bundle_reference", + "qualification_admission_reference", "qualification_admission_bundle_reference", + "campaign_summary", "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "issuer", + ): + if summary[field] != bound_manifest[field]: + raise TrustError(f"production acceptance summary {field} differs from the manifest") + validate_window(summary, maximum=timedelta(days=7), now=now) + summary_not_before = require_timestamp(summary["not_before"], "summary not_before") + summary_issued = require_timestamp(summary["issued_at"], "summary issued_at") + summary_expires = require_timestamp(summary["expires_at"], "summary expires_at") + if ( + summary_not_before + < require_timestamp(bound_manifest["not_before"], "manifest not_before") + or summary_issued + < require_timestamp(bound_manifest["issued_at"], "manifest issued_at") + or summary_expires + > require_timestamp(bound_manifest["expires_at"], "manifest expires_at") + ): + raise TrustError("summary validity exceeds the manifest") + if summary["evidence_identity_sha256"] != acceptance_summary_identity(summary): + raise TrustError("production acceptance evidence identity differs") + return summary + + +def validate_release_evidence_chain( + release_value: Any, + *, + summary: Any, + manifest: Any, + receipt: Any, + qualification_admission: Any, + now: datetime | None = None, +) -> dict[str, Any]: + release = validate_release(release_value, now=now) + bound_summary = validate_acceptance_summary( + summary, + manifest=manifest, + receipt=receipt, + qualification_admission=qualification_admission, + now=now, + ) + bound_manifest = dict(manifest) + for field in ( + "target", "claim_scope", "release_identity", "release_sha256", + "artifact_inventory_sha256", "publication_staging", + "publication_staging_sha256", "authority_state_sha256", + "revocation_state_sha256", "signer_registry_sha256", + ): + if release[field] != bound_summary[field]: + raise TrustError(f"release admission {field} differs from its evidence chain") + if release["release"] != bound_manifest["release"]: + raise TrustError("release admission candidate differs from its manifest") + if ( + bound_manifest["artifact_inventory"]["target"] != release["target"] + or bound_manifest["artifact_inventory"]["claim_scope"] + != release["claim_scope"] + or bound_manifest["artifact_inventory"]["artifacts"] + != release["release"]["artifacts"] + ): + raise TrustError("release admission inventory differs from its manifest") + if release["publication_policy_sha256"] != bound_summary["acceptance_policy_sha256"]: + raise TrustError("release publication policy differs from acceptance policy") + release_not_before = require_timestamp(release["not_before"], "release not_before") + release_issued = require_timestamp(release["issued_at"], "release issued_at") + release_expires = require_timestamp(release["expires_at"], "release expires_at") + if ( + release_not_before + < require_timestamp(bound_summary["not_before"], "summary not_before") + or release_issued + < require_timestamp(bound_summary["issued_at"], "summary issued_at") + or release_expires + > require_timestamp(bound_summary["expires_at"], "summary expires_at") + ): + raise TrustError("release validity exceeds its acceptance summary") + return release + + +def validate_cloud_deploy_authorization( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "authorization_sha256", "deployment_intent_sha256", + "profile_repository", "profile_repository_id", "profile_commit", + "profile_workflow", "profile_run_id", "profile_run_attempt", + "cloud_source_commitment_sha256", "expected_live_attestation_sha256", + "provider_idempotency_key", "audience", "signer_registry_sha256", + "revocation_state_sha256", "issued_at", "not_before", "expires_at", "issuer", + } + authorization = closed(value, fields, "production Cloud deploy authorization") + if ( + authorization["schema_version"] + != "openadapt.production-cloud-deploy-authorization/v1" + or authorization["audience"] != "openadapt-public-cloud-source-proof" + or authorization["profile_run_attempt"] != 1 + ): + raise TrustError("production Cloud deploy authorization identity differs") + for field in fields: + if field.endswith("_sha256"): + require_digest(authorization[field], field) + for field in ("profile_repository_id", "profile_run_id"): + require_decimal_id(authorization[field], field) + if HEX40.fullmatch(authorization["profile_commit"]) is None: + raise TrustError("Cloud profile commit must be exact") + if ( + not isinstance(authorization["provider_idempotency_key"], str) + or re.fullmatch(r"cloud-deploy:[0-9a-f]{64}", authorization["provider_idempotency_key"]) + is None + ): + raise TrustError("Cloud provider idempotency key is invalid") + issuer = closed( + authorization["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "Cloud authorization issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-ops" + or issuer["repository_id"] != "1172011294" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] + != ".github/workflows/production-cloud-deploy-authorization.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "production-cloud-deploy-authorization" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("Cloud authorization issuer differs") + validate_window(authorization, maximum=timedelta(minutes=5), now=now) + projection = dict(authorization) + identity = projection.pop("authorization_sha256") + if identity != digest_bytes(CLOUD_AUTHORIZATION_DOMAIN, projection): + raise TrustError("Cloud deploy authorization identity is invalid") + return authorization + + +def validate_cloud_deployment_handoff( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: + fields = { + "schema_version", "handoff_id_sha256", "authorization_reference", + "authorization_bundle_reference", "authorization_sha256", + "cloud_source_commitment_sha256", "profile_commit", "profile_run_id", + "profile_run_attempt", "source_proof_request", "source_proof_request_sha256", + "source_proof_response", "source_proof_response_sha256", + "reviewed_public_values_sha256", + "expected_live_attestation_sha256", "live_attestation_sha256", + "provider_idempotency_key", "audience", "signer_registry_sha256", + "revocation_state_sha256", "verdict", "issued_at", "not_before", + "expires_at", "issuer", + } + handoff = closed(value, fields, "production Cloud deployment handoff") + if ( + handoff["schema_version"] + != "openadapt.production-cloud-deployment-result/v1" + or handoff["audience"] != "openadapt-private-cloud-production-deploy" + or handoff["verdict"] != "accepted" + or handoff["profile_run_attempt"] != "1" + ): + raise TrustError("production Cloud deployment handoff identity differs") + for field in fields: + if field.endswith("_sha256"): + require_digest(handoff[field], field) + require_decimal_id(handoff["profile_run_id"], "profile run id") + if HEX40.fullmatch(handoff["profile_commit"]) is None: + raise TrustError("Cloud handoff profile commit must be exact") + validate_reference_pair( + handoff["authorization_reference"], + handoff["authorization_bundle_reference"], + kind="production-cloud-deploy-authorization", + ) + request_fields = { + "schema_version", "authorization_sha256", "cloud_source_commit", + "expected_live_attestation_sha256", "expires_at", "issued_at", "nonce", + "production_origin", "profile_commit", "profile_environment", + "profile_repository", "profile_workflow_ref", "run_attempt", "run_id", + "site_id", "source_names", + } + request = closed(handoff["source_proof_request"], request_fields, "Cloud source proof request") + if ( + request["schema_version"] + != "openadapt.production-secret-source-proof-request/v2" + or request["run_attempt"] != "1" + or HEX40.fullmatch(request["cloud_source_commit"]) is None + or request["production_origin"] != "https://app.openadapt.ai" + or request["site_id"] != "ccf1a48a-d934-48ea-a9a8-36cbddf27cb5" + or request["profile_repository"] != "OpenAdaptAI/.github" + or request["profile_environment"] != "production-cloud-deploy" + or request["profile_workflow_ref"] + != ( + "OpenAdaptAI/.github/.github/workflows/" + "production-cloud-deploy.yml@refs/heads/main" + ) + ): + raise TrustError("Cloud source proof request identity differs") + for field in ("authorization_sha256", "expected_live_attestation_sha256"): + require_raw_digest(request[field], f"Cloud request {field}") + nonce = request["nonce"] + if not isinstance(nonce, str) or "=" in nonce: + raise TrustError("Cloud source proof nonce is not canonical base64url") + try: + nonce_bytes = evidence.base64.urlsafe_b64decode( + nonce + "=" * (-len(nonce) % 4) + ) + except Exception as exc: + raise TrustError("Cloud source proof nonce is not canonical base64url") from exc + if ( + len(nonce_bytes) != 32 + or evidence.base64.urlsafe_b64encode(nonce_bytes).decode().rstrip("=") + != nonce + ): + raise TrustError("Cloud source proof nonce must encode exactly 32 bytes") + source_names = request["source_names"] + if ( + not isinstance(source_names, list) + or source_names != list(CLOUD_LIVE_SOURCE_NAMES) + ): + raise TrustError("Cloud source names differ from the frozen live source set") + if handoff["source_proof_request_sha256"] != digest_bytes( + CLOUD_SOURCE_PROOF_REQUEST_DOMAIN, request + ): + raise TrustError("Cloud source proof request digest differs") + expected_idempotency = "cloud-deploy:" + hashlib.sha256( + CLOUD_PROVIDER_IDEMPOTENCY_DOMAIN + + canonical( + { + "authorization_sha256": handoff["authorization_sha256"], + "source_proof_request_sha256": handoff[ + "source_proof_request_sha256" + ], + "profile_run_id": handoff["profile_run_id"], + "profile_run_attempt": handoff["profile_run_attempt"], + } + ) + ).hexdigest() + if handoff["provider_idempotency_key"] != expected_idempotency: + raise TrustError("Cloud provider idempotency key differs") + commitment = "sha256:" + hashlib.sha256( + CLOUD_SOURCE_COMMIT_DOMAIN + request["cloud_source_commit"].encode("ascii") + ).hexdigest() + if handoff["cloud_source_commitment_sha256"] != commitment: + raise TrustError("Cloud source commit commitment differs") + response_fields = { + "schema_version", "request_sha256", "authorization_sha256", + "live_attestation_sha256", "live_deployment", "oidc_identity_sha256", + "production_origin", "proofs", "public_values", "site_id", + } + response = closed(handoff["source_proof_response"], response_fields, "Cloud source proof response") + if response["schema_version"] != "openadapt.production-secret-source-proof-response/v2": + raise TrustError("Cloud source proof response schema differs") + for field in response_fields: + if field.endswith("_sha256"): + require_raw_digest(response[field], field) + live_deployment = closed( + response["live_deployment"], + {"build_id", "source_commit"}, + "Cloud live deployment", + ) + if ( + not isinstance(live_deployment["build_id"], str) + or DIGEST.fullmatch(live_deployment["build_id"]) is None + or not isinstance(live_deployment["source_commit"], str) + or HEX40.fullmatch(live_deployment["source_commit"]) is None + ): + raise TrustError("Cloud live deployment identity differs") + proofs = response["proofs"] + if not isinstance(proofs, dict) or sorted(proofs) != list(CLOUD_SECRET_SOURCE_NAMES): + raise TrustError("Cloud source proof set differs from the request") + for digest in proofs.values(): + require_raw_digest(digest, "Cloud HMAC proof") + public_values = response["public_values"] + if ( + not isinstance(public_values, dict) + or set(public_values) != {CLOUD_PUBLIC_SOURCE_NAME} + or not isinstance(public_values[CLOUD_PUBLIC_SOURCE_NAME], str) + or not public_values[CLOUD_PUBLIC_SOURCE_NAME] + ): + raise TrustError("reviewed public values differ") + if sorted([*proofs, *public_values]) != list(CLOUD_LIVE_SOURCE_NAMES): + raise TrustError("Cloud source proof partition differs from the request") + if handoff["reviewed_public_values_sha256"] != digest_bytes( + CLOUD_PUBLIC_VALUES_DOMAIN, public_values + ): + raise TrustError("reviewed public values digest differs") + if handoff["source_proof_response_sha256"] != "sha256:" + hashlib.sha256(canonical(response)).hexdigest(): + raise TrustError("Cloud source proof response digest differs") + equalities = { + "authorization_sha256": handoff["authorization_sha256"].removeprefix( + "sha256:" + ), + "profile_commit": handoff["profile_commit"], + "run_id": handoff["profile_run_id"], + "run_attempt": handoff["profile_run_attempt"], + "expected_live_attestation_sha256": handoff[ + "expected_live_attestation_sha256" + ].removeprefix("sha256:"), + "issued_at": handoff["issued_at"], + "expires_at": handoff["expires_at"], + } + if any(request[field] != expected for field, expected in equalities.items()): + raise TrustError("Cloud source proof request differs from the handoff") + if ( + "sha256:" + response["request_sha256"] + != handoff["source_proof_request_sha256"] + or "sha256:" + response["authorization_sha256"] + != handoff["authorization_sha256"] + or "sha256:" + request["authorization_sha256"] + != handoff["authorization_sha256"] + or "sha256:" + request["expected_live_attestation_sha256"] + != handoff["expected_live_attestation_sha256"] + or "sha256:" + response["live_attestation_sha256"] + != handoff["live_attestation_sha256"] + or response["production_origin"] != request["production_origin"] + or response["site_id"] != request["site_id"] + or live_deployment["source_commit"] != request["cloud_source_commit"] + ): + raise TrustError("Cloud source proof response differs from the handoff") + issuer = closed( + handoff["issuer"], + {"repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"}, + "Cloud handoff issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/.github" + or issuer["repository_id"] != "858454062" + or issuer["repository_owner_id"] != "132681217" + or issuer["workflow"] != ".github/workflows/production-cloud-deploy.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "production-cloud-deploy" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("Cloud handoff issuer differs") + validate_window(handoff, maximum=timedelta(minutes=5), now=now) + projection = dict(handoff) + identity = projection.pop("handoff_id_sha256") + if identity != digest_bytes(CLOUD_HANDOFF_DOMAIN, projection): + raise TrustError("Cloud deployment handoff identity differs") + return handoff + + def validate_current_default(value: Any, *, now: datetime | None = None) -> dict[str, Any]: current = closed( value, @@ -633,7 +2265,21 @@ def validate_current_default(value: Any, *, now: datetime | None = None) -> dict require_digest(target["deployment_sha256"], "deployment digest") if target["deployment_id"] is not None: require_decimal_id(target["deployment_id"], "deployment id") - closed(current["issuer"], {"repository", "repository_id", "workflow", "ref", "source_commit"}, "current default issuer") + issuer = closed( + current["issuer"], + {"repository", "repository_id", "workflow", "ref", "source_commit"}, + "current default issuer", + ) + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-ops" + or issuer["repository_id"] != "1172011294" + or issuer["workflow"] + != ".github/workflows/production-current-default.yml" + or issuer["ref"] != "refs/heads/main" + or not isinstance(issuer["source_commit"], str) + or HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise TrustError("current default issuer differs") validate_window(current, maximum=timedelta(days=7), now=now) return current @@ -745,10 +2391,25 @@ def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, for field in ("source_commit", "policy_source_commit"): if not isinstance(issuer[field], str) or HEX40.fullmatch(issuer[field]) is None: raise TrustError("checkpoint issuer commit must be exact") - if issuer["policy_repository"] != "OpenAdaptAI/.github" or issuer["policy_repository_id"] != "858454062" or issuer["policy_path"] != "production-lifecycle-policy.json": + if ( + issuer["repository"] != "OpenAdaptAI/openadapt-ops" + or issuer["repository_id"] != "1172011294" + or issuer["workflow"] + != ".github/workflows/production-lifecycle-checkpoint.yml" + or issuer["ref"] != "refs/heads/main" + or issuer["policy_repository"] != "OpenAdaptAI/.github" + or issuer["policy_repository_id"] != "858454062" + or issuer["policy_source_commit"] != checkpoint["registry_source_commit"] + or issuer["policy_path"] != "production-lifecycle-policy.json" + ): raise TrustError("checkpoint lifecycle policy source differs") - pseudo = {"issued_at": checkpoint["generated_at"], "not_before": checkpoint["not_before"], "expires_at": checkpoint["expires_at"]} - validate_window(pseudo, maximum=timedelta(days=7), now=now) + generated = require_timestamp(checkpoint["generated_at"], "checkpoint generated_at") + not_before = require_timestamp(checkpoint["not_before"], "checkpoint not_before") + expires = require_timestamp(checkpoint["expires_at"], "checkpoint expires_at") + if not generated <= not_before < expires <= generated + timedelta(days=7): + raise TrustError("checkpoint scheduled validity window is invalid") + if now is not None and not not_before <= now < expires: + raise TrustError("checkpoint is not active at the requested time") projection_without_id = dict(checkpoint) checkpoint_id = projection_without_id.pop("checkpoint_id_sha256") if checkpoint_id != digest_bytes(CHECKPOINT_DOMAIN, projection_without_id): @@ -756,6 +2417,124 @@ def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, return checkpoint +def validate_checkpoint_expiry_containment( + checkpoint_value: Any, + *, + signer_registry: Any, + current_default: Any, + release_admissions: Sequence[Any], + workflow_admissions: Sequence[Any], + authority_state: Any, + revocation_state: Any, + acceptance_summaries: Sequence[Any] = (), + acceptance_manifests: Sequence[Any] = (), + decision_receipts: Sequence[Any] = (), + now: datetime | None = None, +) -> dict[str, Any]: + """Require the retained checkpoint window to fit every verified child. + + ``now`` selects an active checkpoint for a runtime read. Issuance callers + omit it so that they can construct a checkpoint before its scheduled + ``not_before``. A child must already exist when the checkpoint is issued, + and its complete validity window must contain the checkpoint window. + """ + + checkpoint = validate_checkpoint(checkpoint_value, now=now) + try: + registry = evidence.validate_signer_registry(signer_registry) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + current = validate_current_default(current_default) + releases = [validate_release(item) for item in release_admissions] + workflows = [ + validate_qualification_admission(item) + for item in workflow_admissions + ] + authority = validate_authority_state(authority_state) + revocation = validate_revocation_state(revocation_state) + summaries = [validate_acceptance_summary(item) for item in acceptance_summaries] + manifests = [validate_acceptance_manifest(item) for item in acceptance_manifests] + receipts = [validate_evidence_decision_receipt(item) for item in decision_receipts] + if len(releases) != len(checkpoint["release_admissions"]): + raise TrustError("checkpoint release object count differs") + if len(workflows) != len(checkpoint["workflow_admissions"]): + raise TrustError("checkpoint workflow object count differs") + + checkpoint_not_before = require_timestamp( + checkpoint["not_before"], "checkpoint not_before" + ) + checkpoint_expires = require_timestamp( + checkpoint["expires_at"], "checkpoint expires_at" + ) + checkpoint_generated = require_timestamp( + checkpoint["generated_at"], "checkpoint generated_at" + ) + windows = [ + ( + evidence._timestamp(registry["generated_at"], "generated_at"), + evidence._timestamp(registry["expires_at"], "expires_at"), + evidence._timestamp(registry["generated_at"], "generated_at"), + "signer registry", + ), + ( + require_timestamp(current["not_before"], "current default not_before"), + require_timestamp(current["expires_at"], "current default expires_at"), + require_timestamp(current["issued_at"], "current default issued_at"), + "current default", + ), + ( + require_timestamp(authority["not_before"], "authority not_before"), + require_timestamp(authority["expires_at"], "authority expires_at"), + require_timestamp(authority["observed_at"], "authority observed_at"), + "authority state", + ), + ( + require_timestamp(revocation["not_before"], "revocation not_before"), + require_timestamp(revocation["expires_at"], "revocation expires_at"), + require_timestamp(revocation["observed_at"], "revocation observed_at"), + "revocation state", + ), + ] + windows.extend( + ( + require_timestamp(item["not_before"], "release not_before"), + require_timestamp(item["expires_at"], "release expires_at"), + require_timestamp(item["issued_at"], "release issued_at"), + f"{item['target']} release", + ) + for item in releases + ) + windows.extend( + ( + require_timestamp(item["not_before"], "workflow not_before"), + require_timestamp(item["expires_at"], "workflow expires_at"), + require_timestamp(item["issued_at"], "workflow issued_at"), + "workflow admission", + ) + for item in workflows + ) + for items, label in ( + (summaries, "acceptance summary"), + (manifests, "acceptance manifest"), + (receipts, "qualification decision receipt"), + ): + windows.extend( + ( + require_timestamp(item["not_before"], f"{label} not_before"), + require_timestamp(item["expires_at"], f"{label} expires_at"), + require_timestamp(item["issued_at"], f"{label} issued_at"), + label, + ) + for item in items + ) + for child_not_before, child_expires, child_issued, label in windows: + if child_issued > checkpoint_generated: + raise TrustError(f"{label} was issued after the checkpoint") + if checkpoint_not_before < child_not_before or checkpoint_expires > child_expires: + raise TrustError(f"checkpoint validity exceeds {label} validity") + return checkpoint + + def validate_feed(value: Any, *, now: datetime | None = None) -> dict[str, Any]: feed = closed( value, @@ -790,6 +2569,174 @@ def validate_feed(value: Any, *, now: datetime | None = None) -> dict[str, Any]: return feed +def validate_feed_expiry_containment( + feed_value: Any, + *, + checkpoints: Sequence[Any], + signer_registry: Any, + now: datetime | None = None, +) -> dict[str, Any]: + """Validate one active checkpoint or one exact future rotation. + + Two-checkpoint feeds are newest-first. The older checkpoint is active at + feed issuance. The newer checkpoint starts at the exact instant that the + older checkpoint expires. Signed validity intervals are half-open and do + not use clock-skew grace. + """ + + feed = validate_feed(feed_value, now=now) + try: + registry = evidence.validate_signer_registry(signer_registry) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + pointer = evidence._validate_signer_pointer(feed["signer_registry"]) + assert pointer is not None + registry_raw = evidence.canonical(registry) + b"\n" + if ( + pointer["object_sha256"] + != "sha256:" + hashlib.sha256(registry_raw).hexdigest() + or pointer["registry_identity_sha256"] + != evidence.signer_registry_identity_digest(registry) + or pointer["registry_revision"] != registry["revision"] + ): + raise TrustError("feed signer registry pointer differs from the registry") + if len(checkpoints) != len(feed["checkpoints"]): + raise TrustError("feed checkpoint object count differs") + # A two-checkpoint feed intentionally contains a checkpoint whose + # not-before is in the future. Validate structure here and select the + # active checkpoint against the feed/runtime time below. + resolved = [validate_checkpoint(item) for item in checkpoints] + revisions = [item["checkpoint_revision"] for item in resolved] + if revisions != sorted(set(revisions), reverse=True): + raise TrustError("feed checkpoints must be ordered by decreasing revision") + if len(resolved) == 2: + older_reference = feed["checkpoints"][1]["checkpoint_reference"] + if ( + revisions[0] != revisions[1] + 1 + or resolved[0]["previous_checkpoint_sha256"] + != older_reference["object_sha256"] + ): + raise TrustError("feed checkpoints are not one exact raw hash-chain step") + + generated = require_timestamp(feed["generated_at"], "feed generated_at") + expires = require_timestamp(feed["expires_at"], "feed expires_at") + registry_generated = evidence._timestamp( + registry["generated_at"], "signer registry generated_at" + ) + registry_expires = evidence._timestamp( + registry["expires_at"], "signer registry expires_at" + ) + if generated < registry_generated or expires > registry_expires: + raise TrustError("feed validity exceeds signer registry validity") + + checkpoint_windows: list[tuple[datetime, datetime]] = [] + for index, (pair, checkpoint) in enumerate( + zip(feed["checkpoints"], resolved, strict=True) + ): + reference = pair["checkpoint_reference"] + raw = canonical(checkpoint) + b"\n" + if reference["object_sha256"] != ( + "sha256:" + hashlib.sha256(raw).hexdigest() + ): + raise TrustError(f"feed checkpoint {index} bytes differ from its reference") + checkpoint_not_before = require_timestamp( + checkpoint["not_before"], "checkpoint not_before" + ) + checkpoint_expires = require_timestamp( + checkpoint["expires_at"], "checkpoint expires_at" + ) + checkpoint_generated = require_timestamp( + checkpoint["generated_at"], "checkpoint generated_at" + ) + if checkpoint_generated > generated: + raise TrustError(f"feed predates checkpoint {index}") + checkpoint_windows.append((checkpoint_not_before, checkpoint_expires)) + + if len(checkpoint_windows) == 1: + checkpoint_not_before, checkpoint_expires = checkpoint_windows[0] + if generated < checkpoint_not_before or expires > checkpoint_expires: + raise TrustError("feed validity exceeds checkpoint 0 validity") + else: + newer_not_before, newer_expires = checkpoint_windows[0] + older_not_before, older_expires = checkpoint_windows[1] + if older_expires != newer_not_before: + raise TrustError("feed checkpoint handoff has an overlap or gap") + if not older_not_before <= generated < older_expires: + raise TrustError("older checkpoint is not active at feed issuance") + if not newer_not_before < expires <= newer_expires: + raise TrustError("feed does not cross the exact checkpoint handoff") + + if now is not None: + active = [ + index + for index, (not_before, checkpoint_expires) in enumerate( + checkpoint_windows + ) + if not_before <= now < checkpoint_expires + ] + if len(active) != 1: + raise TrustError("feed does not select exactly one active checkpoint") + + current = resolved[0] + current_reference = feed["checkpoints"][0]["checkpoint_reference"] + if ( + feed["registry_source_commit"] != current["registry_source_commit"] + or feed["registry_revision"] != current["registry_revision"] + or feed["registry_head_sha256"] != current["registry_head_sha256"] + or feed["signer_registry"] != current["signer_registry"] + or current_reference["registry_source_commit"] + != feed["registry_source_commit"] + or current_reference["registry_revision"] != feed["registry_revision"] + or current_reference["registry_head_sha256"] + != feed["registry_head_sha256"] + ): + raise TrustError("feed current checkpoint or registry binding differs") + return feed + + +def validate_feed_transition(previous_value: Any, current_value: Any) -> dict[str, Any]: + """Reject feed revision, registry, and checkpoint rollback. + + The protected ref compare-and-swap binds the exact previous commit. This + function binds the state transition inside those two commits. + """ + + previous = validate_feed(previous_value) + current = validate_feed(current_value) + if current["feed_revision"] != previous["feed_revision"] + 1: + raise TrustError("feed revision is not one exact forward step") + if require_timestamp(current["generated_at"], "current feed generated_at") <= ( + require_timestamp(previous["generated_at"], "previous feed generated_at") + ): + raise TrustError("feed generation time did not advance") + if current["registry_revision"] < previous["registry_revision"]: + raise TrustError("feed signer registry revision rolled back") + if ( + current["registry_revision"] == previous["registry_revision"] + and current["signer_registry"] != previous["signer_registry"] + ): + raise TrustError("feed signer registry changed without a new revision") + + previous_reference = previous["checkpoints"][0]["checkpoint_reference"] + current_reference = current["checkpoints"][0]["checkpoint_reference"] + previous_revision = previous_reference["semantic_identity_sha256"] + current_revision = current_reference["semantic_identity_sha256"] + # The semantic identities need not be ordered. Exact equality means the + # same checkpoint is retained. Any replacement must retain the prior raw + # checkpoint in the two-entry rotation until its validity ends. + if current_reference["object_sha256"] == previous_reference["object_sha256"]: + if current_revision != previous_revision: + raise TrustError("retained checkpoint semantic identity changed") + else: + retained = [ + pair["checkpoint_reference"]["object_sha256"] + for pair in current["checkpoints"][1:] + ] + if retained != [previous_reference["object_sha256"]]: + raise TrustError("feed checkpoint transition skips or forks history") + return current + + def validate_feed_update(value: Any) -> dict[str, Any]: fields = { "schema_version", "event_type", "source_repository", "source_repository_id", diff --git a/scripts/stage_production_evidence.py b/scripts/stage_production_evidence.py index a324369..a564aeb 100644 --- a/scripts/stage_production_evidence.py +++ b/scripts/stage_production_evidence.py @@ -23,6 +23,10 @@ def entry_for(raw: bytes, *, kind: str, subject: str | None) -> dict: value = json.loads(raw) if not isinstance(value, dict) or value.get("schema_version") != schema: raise registry.EvidenceRegistryError("regular object schema differs from kind") + if raw != registry.canonical(value) + b"\n": + raise registry.EvidenceRegistryError( + "regular object bytes must be canonical JSON followed by one LF" + ) identity_value = value entry = { "kind": kind, @@ -98,6 +102,34 @@ def main(argv: list[str] | None = None) -> int: kind=f"{args.kind}-sigstore-bundle", subject=regular["object_sha256"], ) + for index, item in enumerate(document["entries"]): + if ( + item["kind"] != regular["kind"] + or item["semantic_identity_sha256"] + != regular["semantic_identity_sha256"] + ): + continue + if index + 1 >= len(document["entries"]): + raise registry.EvidenceRegistryError( + "existing regular object has no adjacent bundle" + ) + existing_bundle = document["entries"][index + 1] + if ( + item["object_sha256"] != regular["object_sha256"] + or existing_bundle["kind"] != bundle["kind"] + or existing_bundle["object_sha256"] != bundle["object_sha256"] + or existing_bundle["subject_sha256"] + != regular["object_sha256"] + ): + raise registry.EvidenceRegistryError( + "semantic identity conflicts with an existing object pair" + ) + print( + json.dumps( + {"regular": item, "bundle": existing_bundle}, sort_keys=True + ) + ) + return 0 existing = {item["object_sha256"] for item in document["entries"]} if regular["object_sha256"] in existing or bundle["object_sha256"] in existing: raise registry.EvidenceRegistryError("object is already registered") diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index 50ac193..f1d5d48 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -32,6 +32,22 @@ SEMANTIC_IDENTITY_DOMAIN = b"OpenAdapt production evidence semantic identity v1\0" BUNDLE_IDENTITY_DOMAIN = b"OpenAdapt production evidence Sigstore bundle identity v1\0" SIGNER_REGISTRY_IDENTITY_DOMAIN = b"OpenAdapt qualification signer registry v2\0" +SIGNER_USAGES = { + "private-qualification-evidence-manifest", + "private-qualification-evidence-decision-request", + "qualification-campaign-permit", + "qualification-source-evidence-manifest", + "qualification-trial-receipt", + "private-qualification-evidence-decision", + "private-qualification-evidence-decision-storage-seal", + "private-qualification-evidence-decision-final-manifest", + "qualification-evidence-decision-receipt", + "qualification-authority-state-receipt", + "qualification-revocation-state-receipt", +} +DECISION_RECEIPT_IDENTITY_DOMAIN = ( + b"OpenAdapt qualification decision receipt series identity v1\0" +) REPOSITORY = "OpenAdaptAI/.github" REPOSITORY_ID = "858454062" REPOSITORY_OWNER_ID = "132681217" @@ -115,6 +131,10 @@ "openadapt.qualification-revocation-state-receipt/v1", "application/vnd.openadapt.qualification-revocation-state-receipt+json;version=1", ), + "support-release-admission": ( + "openadapt.support-release-admission/v1", + "application/vnd.openadapt.support-release-admission+json;version=1", + ), } OBJECT_KIND_CONTRACTS: dict[str, tuple[str, str]] = dict(REGULAR_KIND_CONTRACTS) for _kind in tuple(REGULAR_KIND_CONTRACTS): @@ -198,6 +218,32 @@ def semantic_identity_digest( "subject_sha256": object_value, } domain = BUNDLE_IDENTITY_DOMAIN + elif kind == "qualification-evidence-decision-receipt": + if not isinstance(object_value, dict): + raise EvidenceRegistryError("decision receipt identity object is invalid") + decision_identity = object_value.get("decision_identity_sha256") + decision_revision = object_value.get("decision_revision") + _digest(decision_identity, "decision identity") + _positive_integer(decision_revision, "decision revision") + payload = { + "decision_identity_sha256": decision_identity, + "decision_revision": decision_revision, + } + domain = DECISION_RECEIPT_IDENTITY_DOMAIN + elif kind in { + "qualification-authority-state-receipt", + "qualification-revocation-state-receipt", + }: + if not isinstance(object_value, dict): + raise EvidenceRegistryError("qualification state identity object is invalid") + identity_field = ( + "authority_state_sha256" + if kind == "qualification-authority-state-receipt" + else "revocation_state_sha256" + ) + identity = object_value.get(identity_field) + _digest(identity, f"{kind} identity") + return identity else: payload = { "kind": kind, @@ -241,6 +287,10 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: "algorithm", "key_id", "public_key", + "public_key_spki_der_base64", + "public_key_sha256", + "statement_schema_versions", + "allowed_usages", "allowed_workflows", "allowed_ref_prefixes", "status", @@ -277,6 +327,32 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: raise EvidenceRegistryError( f"{label} key id does not bind the public key" ) + spki = bytes.fromhex("302a300506032b6570032100") + key_bytes + expected_spki = base64.b64encode(spki).decode() + if signer["public_key_spki_der_base64"] != expected_spki: + raise EvidenceRegistryError( + f"{label} SPKI does not bind the public key" + ) + if signer["public_key_sha256"] != ( + "sha256:" + hashlib.sha256(spki).hexdigest() + ): + raise EvidenceRegistryError( + f"{label} public key fingerprint does not bind the public key" + ) + if signer["statement_schema_versions"] != [ + "openadapt.qualification-evidence-signing-statement/v1" + ]: + raise EvidenceRegistryError( + f"{label} signing statement schema is invalid" + ) + usages = signer["allowed_usages"] + if ( + not isinstance(usages, list) + or not usages + or usages != sorted(set(usages)) + or any(item not in SIGNER_USAGES for item in usages) + ): + raise EvidenceRegistryError(f"{label} usage allowlist is invalid") workflows = signer["allowed_workflows"] if ( not isinstance(workflows, list) @@ -374,6 +450,7 @@ def registry_head_digest(document: Mapping[str, Any]) -> str: "revision": document["revision"], "previous_registry_head_sha256": document["previous_registry_head_sha256"], "signer_registry": document["signer_registry"], + "signer_registry_history": document["signer_registry_history"], "entry_sha256s": [ entry["registry_entry_sha256"] for entry in document["entries"] ], @@ -458,6 +535,7 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, "previous_registry_head_sha256", "registry_head_sha256", "signer_registry", + "signer_registry_history", "entries", }, "evidence registry", @@ -477,6 +555,27 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, if previous is not None: _digest(previous, "previous registry head") signer_pointer = _validate_signer_pointer(document["signer_registry"]) + history_value = document["signer_registry_history"] + if not isinstance(history_value, list): + raise EvidenceRegistryError("signer registry history must be a list") + signer_history = [_validate_signer_pointer(item) for item in history_value] + if any(item is None for item in signer_history): + raise EvidenceRegistryError("signer registry history cannot contain null") + typed_history = [item for item in signer_history if item is not None] + revisions = [item["registry_revision"] for item in typed_history] + if revisions != sorted(set(revisions)) or any( + current != previous + 1 + for previous, current in zip(revisions, revisions[1:]) + ): + raise EvidenceRegistryError( + "signer registry history must use unique consecutive revisions" + ) + if (signer_pointer is None) != (not typed_history) or ( + signer_pointer is not None and signer_pointer != typed_history[-1] + ): + raise EvidenceRegistryError( + "current signer registry must be the last historical pointer" + ) entries = document["entries"] if not isinstance(entries, list): raise EvidenceRegistryError("registry entries must be a list") @@ -485,6 +584,7 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, seen_entries: set[str] = set() seen_paths: set[str] = set() + seen_regular_identities: set[tuple[str, str]] = set() previous_regular: dict[str, Any] | None = None validated: list[dict[str, Any]] = [] for index, value_entry in enumerate(entries): @@ -514,6 +614,18 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, ) previous_regular = None else: + regular_identity = ( + entry["kind"], entry["semantic_identity_sha256"] + ) + if regular_identity in seen_regular_identities: + raise EvidenceRegistryError( + "a regular semantic identity can be registered only once" + ) + seen_regular_identities.add(regular_identity) + if previous_regular is not None: + raise EvidenceRegistryError( + "every regular object must be immediately followed by its Sigstore bundle" + ) previous_regular = entry if root is not None: path = root / entry["object_path"] @@ -541,6 +653,10 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, raise EvidenceRegistryError( f"registered object schema differs: {path}" ) + if raw != canonical(object_value) + b"\n": + raise EvidenceRegistryError( + f"registered regular object is not canonical JSON plus LF: {path}" + ) identity_value = object_value expected_identity = semantic_identity_digest( kind=entry["kind"], @@ -554,25 +670,36 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, ) validated.append(entry) + if previous_regular is not None: + raise EvidenceRegistryError( + "every regular object must be immediately followed by its Sigstore bundle" + ) + if document["registry_head_sha256"] != registry_head_digest(document): raise EvidenceRegistryError("registry head digest is invalid") - if root is not None and signer_pointer is not None: - signer_path = root / signer_pointer["object_path"] - try: - signer_raw = signer_path.read_bytes() - signer_value = json.loads(signer_raw) - except (OSError, json.JSONDecodeError) as exc: - raise EvidenceRegistryError("signer registry object is missing or invalid") from exc - if "sha256:" + hashlib.sha256(signer_raw).hexdigest() != signer_pointer["object_sha256"]: - raise EvidenceRegistryError("signer registry object digest differs") - signer_registry = validate_signer_registry(signer_value) - if signer_registry["revision"] != signer_pointer["registry_revision"]: - raise EvidenceRegistryError("signer registry revision differs") - if ( - signer_registry_identity_digest(signer_registry) - != signer_pointer["registry_identity_sha256"] - ): - raise EvidenceRegistryError("signer registry identity differs") + if root is not None: + for historical_pointer in typed_history: + signer_path = root / historical_pointer["object_path"] + try: + signer_raw = signer_path.read_bytes() + signer_value = json.loads(signer_raw) + except (OSError, json.JSONDecodeError) as exc: + raise EvidenceRegistryError( + "signer registry object is missing or invalid" + ) from exc + if ( + "sha256:" + hashlib.sha256(signer_raw).hexdigest() + != historical_pointer["object_sha256"] + ): + raise EvidenceRegistryError("signer registry object digest differs") + historical_registry = validate_signer_registry(signer_value) + if ( + historical_registry["revision"] + != historical_pointer["registry_revision"] + or signer_registry_identity_digest(historical_registry) + != historical_pointer["registry_identity_sha256"] + ): + raise EvidenceRegistryError("signer registry identity differs") return validated @@ -601,6 +728,15 @@ def validate_append_only_history(previous_value: object, current_value: object) raise EvidenceRegistryError("registry revision does not bind the previous head") if len(current) < len(previous) or current[: len(previous)] != previous: raise EvidenceRegistryError("registry history must be an exact append") + previous_signers = previous_doc["signer_registry_history"] + current_signers = current_doc["signer_registry_history"] + if ( + len(current_signers) < len(previous_signers) + or current_signers[: len(previous_signers)] != previous_signers + ): + raise EvidenceRegistryError( + "signer registry pointer history must be an exact append" + ) def main(argv: list[str] | None = None) -> int: diff --git a/scripts/validate_production_lifecycle_ref.py b/scripts/validate_production_lifecycle_ref.py index af5b29f..fce6f1d 100644 --- a/scripts/validate_production_lifecycle_ref.py +++ b/scripts/validate_production_lifecycle_ref.py @@ -9,8 +9,17 @@ import subprocess import sys from datetime import datetime, timezone +from pathlib import Path import production_trust as trust +import validate_evidence_registry as evidence +from verify_production_release_admission import verify_bytes, verify_sigstore + + +ROOT = Path(__file__).resolve().parents[1] +POLICY = json.loads( + (ROOT / "production-evidence-policy.json").read_text(encoding="utf-8") +) def git(*args: str) -> bytes: @@ -19,6 +28,408 @@ def git(*args: str) -> bytes: ).stdout +def ensure_commit(commit: str) -> None: + try: + git("cat-file", "-e", f"{commit}^{{commit}}") + except subprocess.CalledProcessError: + subprocess.run( + ["git", "fetch", "--no-tags", "origin", commit], check=True + ) + + +def git_file(commit: str, path: str) -> bytes: + ensure_commit(commit) + return git("show", f"{commit}:{path}") + + +def registered_object( + reference: dict[str, object], *, label: str +) -> tuple[bytes, dict[str, object]]: + validated = evidence.validate_reference(reference) + commit = validated["registry_source_commit"] + registry_raw = git_file(commit, "evidence-registry.json") + try: + registry_value = json.loads(registry_raw) + entries = evidence.validate_registry(registry_value) + except (json.JSONDecodeError, evidence.EvidenceRegistryError) as exc: + raise trust.TrustError(f"{label} registry is invalid") from exc + if ( + registry_value["revision"] != validated["registry_revision"] + or registry_value["registry_head_sha256"] + != validated["registry_head_sha256"] + ): + raise trust.TrustError(f"{label} registry identity differs") + try: + evidence.require_registered(entries, reference=validated, label=label) + except evidence.EvidenceRegistryError as exc: + raise trust.TrustError(str(exc)) from exc + raw = git_file(commit, validated["object_path"]) + value = verify_bytes(raw, validated, label) + if not isinstance(value, dict): + raise trust.TrustError(f"{label} must be one JSON object") + return raw, value + + +def registered_pair( + pair: dict[str, object], *, kind: str, prefix: str +) -> dict[str, object]: + regular_reference, bundle_reference = trust.validate_reference_pair( + pair["checkpoint_reference"] + if kind == "production-lifecycle-checkpoint" + else pair["admission_reference"], + pair["checkpoint_bundle_reference"] + if kind == "production-lifecycle-checkpoint" + else pair["admission_bundle_reference"], + kind=kind, + ) + regular_raw, regular = registered_object( + regular_reference, label=f"{prefix} object" + ) + bundle_raw, _ = registered_object( + bundle_reference, label=f"{prefix} bundle" + ) + verify_sigstore( + regular_raw, + bundle_raw, + kind=kind, + object_value=regular, + policy=POLICY, + ) + return regular + + +def reference_pair( + regular_reference: dict[str, object], + bundle_reference: dict[str, object], + *, + kind: str, + prefix: str, +) -> dict[str, object]: + regular, bundle = trust.validate_reference_pair( + regular_reference, bundle_reference, kind=kind + ) + regular_raw, value = registered_object(regular, label=f"{prefix} object") + bundle_raw, _ = registered_object(bundle, label=f"{prefix} bundle") + verify_sigstore( + regular_raw, + bundle_raw, + kind=kind, + object_value=value, + policy=POLICY, + ) + return value + + +def signer_registry(feed: dict[str, object]) -> dict[str, object]: + commit = feed["registry_source_commit"] + registry_document = json.loads(git_file(commit, "evidence-registry.json")) + evidence.validate_registry(registry_document) + pointer = evidence._validate_signer_pointer(feed["signer_registry"]) + if pointer is None or registry_document["signer_registry"] != pointer: + raise trust.TrustError("feed signer registry pointer differs") + raw = git_file(commit, pointer["object_path"]) + if ( + "sha256:" + hashlib.sha256(raw).hexdigest() + != pointer["object_sha256"] + or raw[-1:] != b"\n" + ): + raise trust.TrustError("feed signer registry bytes differ") + value = evidence.validate_signer_registry(json.loads(raw)) + if ( + evidence.canonical(value) + b"\n" != raw + or evidence.signer_registry_identity_digest(value) + != pointer["registry_identity_sha256"] + or value["revision"] != pointer["registry_revision"] + ): + raise trust.TrustError("feed signer registry identity differs") + return value + + +def historical_signer_registry( + reference: dict[str, object], object_value: dict[str, object] +) -> dict[str, object]: + commit = reference["registry_source_commit"] + registry_document = json.loads(git_file(commit, "evidence-registry.json")) + evidence.validate_registry(registry_document) + identity = object_value.get("signer_registry_identity_sha256") + if identity is None: + identity = object_value.get("signer_registry_sha256") + matches = [ + pointer + for pointer in registry_document["signer_registry_history"] + if pointer["registry_identity_sha256"] == identity + ] + if len(matches) != 1: + raise trust.TrustError( + "signed object registry is not in append-only pointer history" + ) + pointer = matches[0] + raw = git_file(commit, pointer["object_path"]) + if ( + "sha256:" + hashlib.sha256(raw).hexdigest() != pointer["object_sha256"] + or raw[-1:] != b"\n" + ): + raise trust.TrustError("historical signer registry bytes differ") + value = evidence.validate_signer_registry(json.loads(raw)) + if ( + evidence.canonical(value) + b"\n" != raw + or evidence.signer_registry_identity_digest(value) + != pointer["registry_identity_sha256"] + or value["revision"] != pointer["registry_revision"] + ): + raise trust.TrustError("historical signer registry identity differs") + return value + + +def checkpoint_children(checkpoint: dict[str, object]) -> tuple[ + dict[str, object], + list[dict[str, object]], + list[dict[str, object]], + dict[str, object], + dict[str, object], +]: + current = reference_pair( + checkpoint["current_default_reference"], + checkpoint["current_default_bundle_reference"], + kind="production-current-default", + prefix="current default", + ) + releases = [ + reference_pair( + item["admission_reference"], + item["admission_bundle_reference"], + kind="qualification-release", + prefix=f"{item['target']} release admission", + ) + for item in checkpoint["release_admissions"] + ] + workflows = [ + reference_pair( + item["admission_reference"], + item["admission_bundle_reference"], + kind="qualification-admission", + prefix="workflow admission", + ) + for item in checkpoint["workflow_admissions"] + ] + authority = reference_pair( + checkpoint["authority_state_reference"], + checkpoint["authority_state_bundle_reference"], + kind="qualification-authority-state-receipt", + prefix="authority state", + ) + revocation = reference_pair( + checkpoint["revocation_state_reference"], + checkpoint["revocation_state_bundle_reference"], + kind="qualification-revocation-state-receipt", + prefix="revocation state", + ) + return current, releases, workflows, authority, revocation + + +def resolve_release_chain( + release: dict[str, object], + *, + active_signer_registry: dict[str, object], + verification_time: datetime, +) -> tuple[dict[str, object], dict[str, object], dict[str, object], dict[str, object]]: + summary = reference_pair( + release["production_acceptance_summary_reference"], + release["production_acceptance_summary_bundle_reference"], + kind="production-acceptance-summary", + prefix=f"{release['target']} production acceptance summary", + ) + manifest = reference_pair( + summary["production_acceptance_manifest_reference"], + summary["production_acceptance_manifest_bundle_reference"], + kind="production-acceptance-manifest", + prefix=f"{release['target']} production acceptance manifest", + ) + receipt = reference_pair( + summary["qualification_evidence_decision_receipt_reference"], + summary["qualification_evidence_decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + prefix=f"{release['target']} qualification decision receipt", + ) + admission = reference_pair( + summary["qualification_admission_reference"], + summary["qualification_admission_bundle_reference"], + kind="qualification-admission", + prefix=f"{release['target']} workflow admission", + ) + receipt_signer_registry = historical_signer_registry( + summary["qualification_evidence_decision_receipt_reference"], receipt + ) + trust.verify_embedded_signature( + receipt, + signer_registry=receipt_signer_registry, + object_schema_version="openadapt.qualification-evidence-decision-receipt/v1", + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=trust.require_timestamp(receipt["issued_at"], "receipt issued_at"), + ) + trust.verify_embedded_signature( + receipt, + signer_registry=active_signer_registry, + object_schema_version="openadapt.qualification-evidence-decision-receipt/v1", + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=verification_time, + ) + trust.validate_release_evidence_chain( + release, + summary=summary, + manifest=manifest, + receipt=receipt, + qualification_admission=admission, + ) + return summary, manifest, receipt, admission + + +def resolve_workflow_receipt( + admission: dict[str, object], + *, + active_signer_registry: dict[str, object], + verification_time: datetime, +) -> dict[str, object]: + receipt = reference_pair( + admission["decision_receipt_reference"], + admission["decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + prefix="workflow qualification decision receipt", + ) + receipt_signer_registry = historical_signer_registry( + admission["decision_receipt_reference"], receipt + ) + trust.verify_embedded_signature( + receipt, + signer_registry=receipt_signer_registry, + object_schema_version="openadapt.qualification-evidence-decision-receipt/v1", + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=trust.require_timestamp(receipt["issued_at"], "receipt issued_at"), + ) + trust.verify_embedded_signature( + receipt, + signer_registry=active_signer_registry, + object_schema_version="openadapt.qualification-evidence-decision-receipt/v1", + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=verification_time, + ) + trust._validate_receipt_admission_binding(receipt, admission) + return receipt + + +def refuse_revoked( + revocation: dict[str, object], + *, + subject_kind: str, + subject_id: str, +) -> None: + if any( + item["subject_kind"] == subject_kind and item["subject_id"] == subject_id + for item in revocation["revocations"] + ): + raise trust.TrustError(f"{subject_kind} is revoked") + + +def revocation_ancestry( + feed: dict[str, object], + current_state: dict[str, object], + *, + active_signer_registry: dict[str, object], + verification_time: datetime, +) -> set[str]: + """Resolve the complete signed revocation chain from the current registry.""" + + commit = feed["registry_source_commit"] + document = json.loads(git_file(commit, "evidence-registry.json")) + entries = evidence.validate_registry(document) + regular_by_identity = { + entry["semantic_identity_sha256"]: (index, entry) + for index, entry in enumerate(entries) + if entry["kind"] == "qualification-revocation-state-receipt" + } + seen: set[str] = set() + state = current_state + while True: + identity = state["revocation_state_sha256"] + if identity in seen: + raise trust.TrustError("revocation state history contains a cycle") + seen.add(identity) + previous_identity = state["previous_revocation_state_sha256"] + if previous_identity is None: + if state["revision"] != 1: + raise trust.TrustError("initial revocation state revision is not one") + return seen + match = regular_by_identity.get(previous_identity) + if match is None: + raise trust.TrustError("revocation state history is incomplete") + index, regular_entry = match + if index + 1 >= len(entries): + raise trust.TrustError("revocation state bundle is missing") + bundle_entry = entries[index + 1] + reference_common = { + "schema_version": evidence.REFERENCE_SCHEMA, + "repository": evidence.REPOSITORY, + "repository_id": evidence.REPOSITORY_ID, + "repository_owner_id": evidence.REPOSITORY_OWNER_ID, + "registry_source_commit": commit, + "registry_revision": document["revision"], + "registry_head_sha256": document["registry_head_sha256"], + } + regular_reference = {**reference_common, **regular_entry} + bundle_reference = {**reference_common, **bundle_entry} + previous_state = reference_pair( + regular_reference, + bundle_reference, + kind="qualification-revocation-state-receipt", + prefix=f"revocation state revision {state['revision'] - 1}", + ) + if ( + previous_state["revocation_state_sha256"] != previous_identity + or previous_state["revision"] != state["revision"] - 1 + ): + raise trust.TrustError("revocation state history skips or forks") + previous_revocations = { + (item["subject_kind"], item["subject_id"], item["revoked_at"], item["reason_code"]) + for item in previous_state["revocations"] + } + current_revocations = { + (item["subject_kind"], item["subject_id"], item["revoked_at"], item["reason_code"]) + for item in state["revocations"] + } + if not previous_revocations <= current_revocations: + raise trust.TrustError("revocation state removed an earlier revocation") + historical_registry = historical_signer_registry( + regular_reference, previous_state + ) + trust.verify_embedded_signature( + previous_state, + signer_registry=historical_registry, + object_schema_version=( + "openadapt.qualification-revocation-state-receipt/v1" + ), + signature_domain=trust.REVOCATION_STATE_SIGNATURE_DOMAIN, + usage="qualification-revocation-state-receipt", + now=trust.require_timestamp( + previous_state["observed_at"], "revocation observed_at" + ), + ) + trust.verify_embedded_signature( + previous_state, + signer_registry=active_signer_registry, + object_schema_version=( + "openadapt.qualification-revocation-state-receipt/v1" + ), + signature_domain=trust.REVOCATION_STATE_SIGNATURE_DOMAIN, + usage="qualification-revocation-state-receipt", + now=verification_time, + ) + state = previous_state + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--update-json", required=True) @@ -41,9 +452,22 @@ def main(argv: list[str] | None = None) -> int: feed_raw = git("show", f"{new_commit}:production-lifecycle-feed.json") if "sha256:" + hashlib.sha256(feed_raw).hexdigest() != update["feed_sha256"]: raise trust.TrustError("feed bytes differ from update digest") - feed = trust.validate_feed( - json.loads(feed_raw), now=datetime.now(timezone.utc) - ) + verification_time = datetime.now(timezone.utc) + feed = trust.validate_feed(json.loads(feed_raw), now=verification_time) + if feed_raw != evidence.canonical(feed) + b"\n": + raise trust.TrustError("feed is not canonical JSON followed by one LF") + expected_old = update["expected_old_commit"] + if expected_old is None: + if feed["feed_revision"] != 1: + raise trust.TrustError("initial lifecycle feed revision must be one") + else: + previous_raw = git("show", f"{expected_old}:production-lifecycle-feed.json") + previous = json.loads(previous_raw) + if previous_raw != evidence.canonical(previous) + b"\n": + raise trust.TrustError("previous feed bytes are not canonical") + trust.validate_feed_transition(previous, feed) + if feed["registry_source_commit"] != args.trusted_main: + raise trust.TrustError("feed registry commit is not current protected main") if feed["expires_at"] != update["expires_at"] or feed["registry_head_sha256"] != update["registry_head_sha256"]: raise trust.TrustError("feed state differs from update payload") checkpoint_digests = { @@ -52,6 +476,193 @@ def main(argv: list[str] | None = None) -> int: } if update["checkpoint_sha256"] not in checkpoint_digests: raise trust.TrustError("update checkpoint is not selected by the feed") + active_signer_registry = signer_registry(feed) + checkpoints = [ + registered_pair( + pair, + kind="production-lifecycle-checkpoint", + prefix=f"lifecycle checkpoint {index}", + ) + for index, pair in enumerate(feed["checkpoints"]) + ] + decision_series: dict[tuple[str, int], str] = {} + for checkpoint in checkpoints: + current, releases, workflows, authority, revocation = ( + checkpoint_children(checkpoint) + ) + registry_identity = evidence.signer_registry_identity_digest( + active_signer_registry + ) + signer_pointer = evidence._validate_signer_pointer( + checkpoint["signer_registry"] + ) + assert signer_pointer is not None + if ( + signer_pointer["registry_identity_sha256"] != registry_identity + or authority["signer_registry_identity_sha256"] + != registry_identity + or authority["signer_registry_sha256"] + != signer_pointer["object_sha256"] + or authority["signer_registry_revision"] + != active_signer_registry["revision"] + or revocation["signer_registry_sha256"] != registry_identity + or revocation["authority_state_sha256"] + != authority["authority_state_sha256"] + ): + raise trust.TrustError( + "checkpoint authority, revocation, or signer registry binding differs" + ) + trust.verify_embedded_signature( + authority, + signer_registry=active_signer_registry, + object_schema_version=( + "openadapt.qualification-authority-state-receipt/v2" + ), + signature_domain=trust.AUTHORITY_STATE_SIGNATURE_DOMAIN, + usage="qualification-authority-state-receipt", + now=verification_time, + ) + trust.verify_embedded_signature( + revocation, + signer_registry=active_signer_registry, + object_schema_version=( + "openadapt.qualification-revocation-state-receipt/v1" + ), + signature_domain=trust.REVOCATION_STATE_SIGNATURE_DOMAIN, + usage="qualification-revocation-state-receipt", + now=verification_time, + ) + for signer in active_signer_registry["signers"]: + if signer["status"] == "active": + refuse_revoked( + revocation, + subject_kind="qualification-signer-key", + subject_id=signer["public_key_sha256"], + ) + + summaries: list[dict[str, object]] = [] + manifests: list[dict[str, object]] = [] + receipts: list[dict[str, object]] = [] + for release, pair_value in zip( + releases, checkpoint["release_admissions"], strict=True + ): + summary, manifest, receipt, admission = resolve_release_chain( + release, + active_signer_registry=active_signer_registry, + verification_time=verification_time, + ) + summaries.append(summary) + manifests.append(manifest) + receipts.append(receipt) + for item, item_reference in ( + (release, pair_value["admission_reference"]), + (admission, summary["qualification_admission_reference"]), + ( + receipt, + summary[ + "qualification_evidence_decision_receipt_reference" + ], + ), + ): + refuse_revoked( + revocation, + subject_kind=item_reference["kind"], + subject_id=item_reference["semantic_identity_sha256"], + ) + if ( + release["authority_state_sha256"] + != authority["authority_state_sha256"] + or release["revocation_state_sha256"] + != revocation["revocation_state_sha256"] + or release["signer_registry_sha256"] != registry_identity + ): + raise trust.TrustError( + "release chain current authority state differs" + ) + + for admission, pair_value in zip( + workflows, checkpoint["workflow_admissions"], strict=True + ): + receipt = resolve_workflow_receipt( + admission, + active_signer_registry=active_signer_registry, + verification_time=verification_time, + ) + receipts.append(receipt) + for item_reference in ( + pair_value["admission_reference"], + admission["decision_receipt_reference"], + ): + refuse_revoked( + revocation, + subject_kind=item_reference["kind"], + subject_id=item_reference["semantic_identity_sha256"], + ) + if ( + admission["revocation_state_sha256"] + != revocation["revocation_state_sha256"] + or admission["signer_registry_sha256"] != registry_identity + ): + raise trust.TrustError( + "workflow admission current trust state differs" + ) + + for receipt in receipts: + series = ( + receipt["decision_identity_sha256"], + receipt["decision_revision"], + ) + object_sha = "sha256:" + hashlib.sha256( + evidence.canonical(receipt) + b"\n" + ).hexdigest() + previous_sha = decision_series.setdefault(series, object_sha) + if previous_sha != object_sha: + raise trust.TrustError( + "one qualification decision series has conflicting receipts" + ) + if ( + receipt["revocation_state_sha256"] + != revocation["revocation_state_sha256"] + or receipt["signer_registry_sha256"] != registry_identity + ): + raise trust.TrustError( + "qualification decision receipt current trust state differs" + ) + + for default_target, release in zip( + current["targets"], releases, strict=True + ): + if ( + default_target["target"] != release["target"] + or default_target["release_sha256"] != release["release_sha256"] + or default_target["artifact_inventory_sha256"] + != release["artifact_inventory_sha256"] + or default_target["qualification_release_sha256"] + != checkpoint["release_admissions"][ + trust.TARGETS.index(release["target"]) + ]["admission_reference"]["object_sha256"] + ): + raise trust.TrustError( + "current default differs from its release admission" + ) + trust.validate_checkpoint_expiry_containment( + checkpoint, + signer_registry=active_signer_registry, + current_default=current, + release_admissions=releases, + workflow_admissions=workflows, + authority_state=authority, + revocation_state=revocation, + acceptance_summaries=summaries, + acceptance_manifests=manifests, + decision_receipts=receipts, + ) + trust.validate_feed_expiry_containment( + feed, + checkpoints=checkpoints, + signer_registry=active_signer_registry, + now=verification_time, + ) print("production lifecycle feed update is valid") except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: print(f"REFUSED: {exc}", file=sys.stderr) diff --git a/scripts/verify_production_release_admission.py b/scripts/verify_production_release_admission.py index 630f60e..ecc143a 100644 --- a/scripts/verify_production_release_admission.py +++ b/scripts/verify_production_release_admission.py @@ -4,9 +4,12 @@ from __future__ import annotations import argparse +import base64 +import binascii import hashlib import json import os +import shutil import subprocess import sys import tempfile @@ -48,6 +51,8 @@ def verify_bytes(raw: bytes, reference: dict[str, Any], label: str) -> Any: except json.JSONDecodeError as exc: raise trust.TrustError(f"{label} is not JSON") from exc identity_value = reference["subject_sha256"] if reference["kind"].endswith("-sigstore-bundle") else value + if not reference["kind"].endswith("-sigstore-bundle") and raw != evidence.canonical(value) + b"\n": + raise trust.TrustError(f"{label} is not canonical JSON followed by one LF") expected_identity = evidence.semantic_identity_digest( kind=reference["kind"], object_schema_version=reference["object_schema_version"], @@ -61,7 +66,13 @@ def verify_bytes(raw: bytes, reference: dict[str, Any], label: str) -> Any: def fetch_pair( regular_reference: dict[str, Any], bundle_reference: dict[str, Any] -) -> tuple[bytes, bytes, dict[str, Any]]: +) -> tuple[ + bytes, + bytes, + dict[str, Any], + dict[str, Any], + dict[str, Any], +]: regular, bundle = trust.validate_reference_pair( regular_reference, bundle_reference, @@ -84,26 +95,92 @@ def fetch_pair( evidence.require_registered(entries, reference=bundle, label="bundle object") except evidence.EvidenceRegistryError as exc: raise trust.TrustError(str(exc)) from exc - signer_pointer = registry_value["signer_registry"] - if signer_pointer is None: + current_pointer = registry_value["signer_registry"] + if current_pointer is None: raise trust.TrustError("the referenced registry has no signer registry") - signer_raw = fetch(raw_url(commit, signer_pointer["object_path"])) - if "sha256:" + hashlib.sha256(signer_raw).hexdigest() != signer_pointer["object_sha256"]: - raise trust.TrustError("signer registry bytes differ") - try: - signer_registry = evidence.validate_signer_registry(json.loads(signer_raw)) - except (json.JSONDecodeError, evidence.EvidenceRegistryError) as exc: - raise trust.TrustError("signer registry is invalid") from exc - if evidence.signer_registry_identity_digest(signer_registry) != signer_pointer["registry_identity_sha256"]: - raise trust.TrustError("signer registry identity differs") - now = datetime.now(timezone.utc) - if not evidence._timestamp(signer_registry["generated_at"], "generated_at") <= now < evidence._timestamp(signer_registry["expires_at"], "expires_at"): - raise trust.TrustError("signer registry is not active") regular_raw = fetch(raw_url(commit, regular["object_path"])) bundle_raw = fetch(raw_url(commit, bundle["object_path"])) value = verify_bytes(regular_raw, regular, "regular object") verify_bytes(bundle_raw, bundle, "bundle object") - return regular_raw, bundle_raw, value + + def load_signer(pointer: dict[str, Any], *, require_active: bool) -> dict[str, Any]: + signer_raw = fetch(raw_url(commit, pointer["object_path"])) + if ( + "sha256:" + hashlib.sha256(signer_raw).hexdigest() + != pointer["object_sha256"] + or signer_raw[-1:] != b"\n" + ): + raise trust.TrustError("signer registry bytes differ") + try: + signer_value = evidence.validate_signer_registry(json.loads(signer_raw)) + except (json.JSONDecodeError, evidence.EvidenceRegistryError) as exc: + raise trust.TrustError("signer registry is invalid") from exc + if ( + evidence.canonical(signer_value) + b"\n" != signer_raw + or evidence.signer_registry_identity_digest(signer_value) + != pointer["registry_identity_sha256"] + or signer_value["revision"] != pointer["registry_revision"] + ): + raise trust.TrustError("signer registry identity differs") + if require_active: + now = datetime.now(timezone.utc) + if not ( + evidence._timestamp(signer_value["generated_at"], "generated_at") + <= now + < evidence._timestamp(signer_value["expires_at"], "expires_at") + ): + raise trust.TrustError("current signer registry is not active") + return signer_value + + current_signer_registry = load_signer(current_pointer, require_active=True) + bound_identity = value.get("signer_registry_sha256") + if bound_identity is None: + bound_pointer = current_pointer + else: + matches = [ + item + for item in registry_value["signer_registry_history"] + if item["registry_identity_sha256"] == bound_identity + ] + if len(matches) != 1: + raise trust.TrustError( + "object signer registry is not in append-only pointer history" + ) + bound_pointer = matches[0] + bound_signer_registry = ( + current_signer_registry + if bound_pointer == current_pointer + else load_signer(bound_pointer, require_active=False) + ) + return ( + regular_raw, + bundle_raw, + value, + bound_signer_registry, + current_signer_registry, + ) + + +def resolve_pair( + regular_reference: dict[str, Any], + bundle_reference: dict[str, Any], + *, + kind: str, + policy: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + if regular_reference.get("kind") != kind: + raise trust.TrustError(f"the referenced object is not {kind}") + regular_raw, bundle_raw, value, bound_signer_registry, current_signer_registry = fetch_pair( + regular_reference, bundle_reference + ) + verify_sigstore( + regular_raw, + bundle_raw, + kind=kind, + object_value=value, + policy=policy, + ) + return value, bound_signer_registry, current_signer_registry def derive_bundle_reference(regular_reference: dict[str, Any]) -> dict[str, Any]: @@ -131,9 +208,33 @@ def derive_bundle_reference(regular_reference: dict[str, Any]) -> dict[str, Any] def verify_sigstore( - regular_raw: bytes, bundle_raw: bytes, *, kind: str, policy: dict[str, Any] + regular_raw: bytes, + bundle_raw: bytes, + *, + kind: str, + object_value: dict[str, Any], + policy: dict[str, Any], ) -> None: sigstore = policy["sigstore"] + identities = { + item["kind"]: item for item in sigstore["certificate_identities"] + } + identity = identities.get(kind) + if identity is None: + raise trust.TrustError(f"no keyless identity is registered for {kind}") + profile = identity.get("bundle_profile") + if profile == "sigstore-message-signature": + verify_message_signature( + regular_raw, + bundle_raw, + kind=kind, + object_value=object_value, + identity=identity, + policy=policy, + ) + return + if profile != "github-attestation": + raise trust.TrustError(f"{kind} has an unsupported Sigstore profile") version = subprocess.run( ["gh", "--version"], check=True, capture_output=True, text=True ).stdout.splitlines()[0] @@ -141,12 +242,6 @@ def verify_sigstore( raise trust.TrustError( f"GitHub CLI version differs; required {sigstore['version']}; got {version}" ) - identities = { - item["kind"]: item for item in sigstore["certificate_identities"] - } - identity = identities.get(kind) - if identity is None: - raise trust.TrustError(f"no keyless identity is registered for {kind}") with tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) subject = directory / "subject.json" @@ -166,11 +261,185 @@ def verify_sigstore( "Sigstore bundle verification failed: " + result.stderr.strip() ) try: - statement = json.loads(result.stdout) - except json.JSONDecodeError as exc: + results = json.loads(result.stdout) + if not isinstance(results, list) or len(results) != 1: + raise trust.TrustError( + "Sigstore verifier must return exactly one verified statement" + ) + verification = results[0]["verificationResult"] + statement = verification["statement"] + certificate = verification["signature"]["certificate"] + subjects = statement["subject"] + predicate_type = statement["predicateType"] + dependencies = statement["predicate"]["buildDefinition"][ + "resolvedDependencies" + ] + except (json.JSONDecodeError, KeyError, TypeError) as exc: raise trust.TrustError("Sigstore verifier output is not JSON") from exc - if not statement: - raise trust.TrustError("Sigstore verifier returned no verified statement") + if ( + predicate_type != "https://slsa.dev/provenance/v1" + or not isinstance(subjects, list) + or len(subjects) != 1 + or subjects[0].get("digest") + != {"sha256": hashlib.sha256(regular_raw).hexdigest()} + ): + raise trust.TrustError("Sigstore statement subject or predicate differs") + issuer = object_value.get("issuer") + if not isinstance(issuer, dict): + raise trust.TrustError("signed object has no closed issuer identity") + source_commit = issuer.get("source_commit") + source_ref = issuer.get("ref") + if ( + certificate.get("githubWorkflowSHA") != source_commit + or certificate.get("sourceRepositoryDigest") != source_commit + or certificate.get("githubWorkflowRef") != source_ref + or certificate.get("githubWorkflowRepository") + != identity["issuer_repository"] + ): + raise trust.TrustError("Sigstore certificate source identity differs") + expected_dependency = { + "uri": ( + f"git+https://github.com/{identity['issuer_repository']}@{source_ref}" + ), + "digest": {"gitCommit": source_commit}, + } + if dependencies != [expected_dependency]: + raise trust.TrustError("Sigstore resolved source dependency differs") + + +def _canonical_base64(value: Any, *, label: str) -> bytes: + if not isinstance(value, str): + raise trust.TrustError(f"{label} is not base64") + try: + raw = base64.b64decode(value, validate=True) + except (ValueError, binascii.Error) as exc: + raise trust.TrustError(f"{label} is not canonical base64") from exc + if base64.b64encode(raw).decode("ascii") != value: + raise trust.TrustError(f"{label} is not canonical base64") + return raw + + +def verify_message_signature( + regular_raw: bytes, + bundle_raw: bytes, + *, + kind: str, + object_value: dict[str, Any], + identity: dict[str, Any], + policy: dict[str, Any], +) -> None: + """Verify the exact raw v0.3 messageSignature profile.""" + + try: + bundle = json.loads(bundle_raw) + if set(bundle) != {"mediaType", "verificationMaterial", "messageSignature"}: + raise trust.TrustError("messageSignature bundle top-level fields differ") + if bundle["mediaType"] != evidence.BUNDLE_MEDIA_TYPE: + raise trust.TrustError("messageSignature bundle media type differs") + message = bundle["messageSignature"] + if not isinstance(message, dict) or set(message) != { + "messageDigest", "signature" + }: + raise trust.TrustError("messageSignature fields differ") + digest = message["messageDigest"] + if not isinstance(digest, dict) or set(digest) != {"algorithm", "digest"}: + raise trust.TrustError("messageSignature digest fields differ") + if digest["algorithm"] != "SHA2_256": + raise trust.TrustError("messageSignature digest algorithm differs") + if _canonical_base64(digest["digest"], label="messageSignature digest") != ( + hashlib.sha256(regular_raw).digest() + ): + raise trust.TrustError("messageSignature digest differs from the object") + signature = _canonical_base64( + message["signature"], label="messageSignature signature" + ) + if not signature: + raise trust.TrustError("messageSignature signature is empty") + material = bundle["verificationMaterial"] + if not isinstance(material, dict) or "certificate" not in material: + raise trust.TrustError("messageSignature certificate is missing") + tlog_entries = material.get("tlogEntries") + if not isinstance(tlog_entries, list) or not tlog_entries: + raise trust.TrustError("messageSignature transparency log proof is missing") + certificate = material["certificate"] + if not isinstance(certificate, dict) or set(certificate) != {"rawBytes"}: + raise trust.TrustError("messageSignature certificate fields differ") + if not _canonical_base64( + certificate["rawBytes"], label="messageSignature certificate" + ): + raise trust.TrustError("messageSignature certificate is empty") + except json.JSONDecodeError as exc: + raise trust.TrustError("messageSignature bundle is not JSON") from exc + + message_policy = policy["message_signature"] + cosign_path = shutil.which("cosign") + if cosign_path is None: + raise trust.TrustError("the pinned Cosign verifier is not installed") + cosign_raw = Path(cosign_path).read_bytes() + if ( + len(cosign_raw) != message_policy["size_bytes"] + or "sha256:" + hashlib.sha256(cosign_raw).hexdigest() + != message_policy["sha256"] + ): + raise trust.TrustError("Cosign binary bytes differ from policy") + trusted_root_path = os.environ.get("OPENADAPT_SIGSTORE_TRUSTED_ROOT") + if not trusted_root_path: + raise trust.TrustError("the pinned Sigstore trusted root is not configured") + trusted_root = Path(trusted_root_path) + trusted_root_raw = trusted_root.read_bytes() + root_policy = message_policy["trusted_root"] + if ( + len(trusted_root_raw) != root_policy["size_bytes"] + or "sha256:" + hashlib.sha256(trusted_root_raw).hexdigest() + != root_policy["sha256"] + ): + raise trust.TrustError("Sigstore trusted root bytes differ from policy") + issuer = object_value.get("issuer") + if not isinstance(issuer, dict): + raise trust.TrustError("messageSignature object has no signed issuer") + if ( + issuer.get("repository") != identity["issuer_repository"] + or issuer.get("ref") != "refs/heads/main" + or not isinstance(issuer.get("source_commit"), str) + or trust.HEX40.fullmatch(issuer["source_commit"]) is None + ): + raise trust.TrustError("messageSignature signed issuer differs") + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + subject_path = directory / "subject.json" + bundle_path = directory / "bundle.json" + subject_path.write_bytes(regular_raw) + bundle_path.write_bytes(bundle_raw) + result = subprocess.run( + [ + cosign_path, + "verify-blob", + "--allow-certificate-chain", + "--bundle", + str(bundle_path), + "--trusted-root", + str(trusted_root), + "--certificate-identity", + identity["certificate_identity"], + "--certificate-oidc-issuer", + policy["sigstore"]["cert_oidc_issuer"], + "--certificate-github-workflow-repository", + identity["issuer_repository"], + "--certificate-github-workflow-ref", + issuer["ref"], + "--certificate-github-workflow-sha", + issuer["source_commit"], + "--certificate-github-workflow-trigger", + "workflow_dispatch", + str(subject_path), + ], + capture_output=True, + text=True, + ) + if result.returncode: + raise trust.TrustError( + "messageSignature verification failed: " + result.stderr.strip() + ) def main(argv: list[str] | None = None) -> int: @@ -197,15 +466,88 @@ def main(argv: list[str] | None = None) -> int: inventory = trust.validate_artifact_inventory( load_json_argument(args.artifact_inventory) ) - regular_raw, bundle_raw, admission_value = fetch_pair( + ( + regular_raw, + bundle_raw, + admission_value, + release_signer_registry, + current_signer_registry, + ) = fetch_pair( reference, bundle_reference ) policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) verify_sigstore( - regular_raw, bundle_raw, kind="qualification-release", policy=policy + regular_raw, + bundle_raw, + kind="qualification-release", + object_value=admission_value, + policy=policy, + ) + now = datetime.now(timezone.utc) + admission = trust.validate_release(admission_value) + if ( + admission["signer_registry_sha256"] + != evidence.signer_registry_identity_digest(release_signer_registry) + ): + raise trust.TrustError("release signer registry identity differs") + summary, _, _ = resolve_pair( + admission["production_acceptance_summary_reference"], + admission["production_acceptance_summary_bundle_reference"], + kind="production-acceptance-summary", + policy=policy, + ) + manifest, _, _ = resolve_pair( + summary["production_acceptance_manifest_reference"], + summary["production_acceptance_manifest_bundle_reference"], + kind="production-acceptance-manifest", + policy=policy, + ) + receipt, receipt_signer_registry, receipt_current_signer_registry = resolve_pair( + summary["qualification_evidence_decision_receipt_reference"], + summary["qualification_evidence_decision_receipt_bundle_reference"], + kind="qualification-evidence-decision-receipt", + policy=policy, + ) + qualification_admission, _, _ = resolve_pair( + summary["qualification_admission_reference"], + summary["qualification_admission_bundle_reference"], + kind="qualification-admission", + policy=policy, + ) + if ( + receipt["signer_registry_sha256"] + != evidence.signer_registry_identity_digest(receipt_signer_registry) + ): + raise trust.TrustError( + "decision receipt signer registry identity differs" + ) + trust.verify_embedded_signature( + receipt, + signer_registry=receipt_signer_registry, + object_schema_version=( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=trust.require_timestamp(receipt["issued_at"], "receipt issued_at"), + ) + trust.verify_embedded_signature( + receipt, + signer_registry=receipt_current_signer_registry, + object_schema_version=( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=now, ) - admission = trust.validate_release( - admission_value, now=datetime.now(timezone.utc) + admission = trust.validate_release_evidence_chain( + admission, + summary=summary, + manifest=manifest, + receipt=receipt, + qualification_admission=qualification_admission, + now=now, ) release = admission["release"] expected = { diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index 657cde6..fdf41a8 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -54,6 +54,7 @@ def document(entries: list[dict] | None = None, **overrides) -> dict: "previous_registry_head_sha256": None, "registry_head_sha256": sha("0"), "signer_registry": None, + "signer_registry_history": [], "entries": values, } value.update(overrides) @@ -77,6 +78,21 @@ def reference(value: dict, **overrides) -> dict: class EvidenceRegistryTests(unittest.TestCase): + def test_kind_map_has_17_regular_and_17_bundle_kinds(self) -> None: + self.assertEqual(len(registry.REGULAR_KIND_CONTRACTS), 17) + self.assertEqual(len(registry.OBJECT_KIND_CONTRACTS), 34) + self.assertEqual( + registry.REGULAR_KIND_CONTRACTS["support-release-admission"], + ( + "openadapt.support-release-admission/v1", + "application/vnd.openadapt.support-release-admission+json;version=1", + ), + ) + self.assertNotIn( + "openadapt-tray", + {"agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt"}, + ) + def test_repository_registry_is_valid_v2(self) -> None: value = json.loads((ROOT / "evidence-registry.json").read_text()) self.assertEqual(registry.validate_registry(value, root=ROOT), []) @@ -134,8 +150,17 @@ def test_bundle_must_immediately_follow_and_bind_regular_object(self) -> None: "registry_identity_sha256": sha("1"), "registry_revision": 1, } - valid = document([regular, bundle], signer_registry=signer) + valid = document( + [regular, bundle], + signer_registry=signer, + signer_registry_history=[signer], + ) registry.validate_registry(valid) + trailing = document( + [regular], signer_registry=signer, signer_registry_history=[signer] + ) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "every regular"): + registry.validate_registry(trailing) invalid = copy.deepcopy(valid) invalid["entries"].reverse() invalid["registry_head_sha256"] = registry.registry_head_digest(invalid) @@ -160,6 +185,23 @@ def test_registry_readback_binds_exact_bytes_and_size(self) -> None: path = root / regular["object_path"] path.parent.mkdir(parents=True) path.write_bytes(raw) + bundle_raw = b'{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}\n' + bundle_sha = "sha256:" + hashlib.sha256(bundle_raw).hexdigest() + bundle = entry( + "production-acceptance-manifest-sigstore-bundle", + object_sha256=bundle_sha, + size_bytes=len(bundle_raw), + subject_sha256=regular["object_sha256"], + semantic_identity_sha256=registry.semantic_identity_digest( + kind="production-acceptance-manifest-sigstore-bundle", + object_schema_version=registry.BUNDLE_MEDIA_TYPE, + object_value=regular["object_sha256"], + object_sha256=bundle_sha, + ), + ) + bundle_path = root / bundle["object_path"] + bundle_path.parent.mkdir(parents=True) + bundle_path.write_bytes(bundle_raw) signer = { "schema_version": registry.SIGNER_POINTER_SCHEMA, "object_path": ( @@ -171,7 +213,11 @@ def test_registry_readback_binds_exact_bytes_and_size(self) -> None: "registry_identity_sha256": sha("1"), "registry_revision": 1, } - value = document([regular], signer_registry=signer) + value = document( + [regular, bundle], + signer_registry=signer, + signer_registry_history=[signer], + ) with self.assertRaisesRegex(registry.EvidenceRegistryError, "signer registry"): registry.validate_registry(value, root=root) path.write_bytes(raw + b" ") @@ -181,6 +227,7 @@ def test_registry_readback_binds_exact_bytes_and_size(self) -> None: def test_signer_registry_key_id_binds_canonical_key(self) -> None: key = bytes(range(32)) public_key = registry.base64.urlsafe_b64encode(key).decode().rstrip("=") + spki = bytes.fromhex("302a300506032b6570032100") + key value = { "schema_version": registry.SIGNER_REGISTRY_SCHEMA, "revision": 1, @@ -191,6 +238,16 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: "algorithm": "ed25519", "key_id": "qa-ed25519-" + hashlib.sha256(key).hexdigest()[:16], "public_key": public_key, + "public_key_spki_der_base64": registry.base64.b64encode(spki).decode(), + "public_key_sha256": ( + "sha256:" + hashlib.sha256(spki).hexdigest() + ), + "statement_schema_versions": [ + "openadapt.qualification-evidence-signing-statement/v1" + ], + "allowed_usages": [ + "qualification-evidence-decision-receipt" + ], "allowed_workflows": [ "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/OpenAdaptAI/openadapt-internal/" ".github/workflows/issue-private-qualification-evidence-decision.yml" @@ -219,6 +276,60 @@ def test_append_only_revision_binds_previous_head(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "previous head"): registry.validate_append_only_history(previous, current) + def test_decision_revision_semantic_identity_cannot_conflict(self) -> None: + receipt_identity = { + "decision_identity_sha256": sha("1"), + "decision_revision": 7, + } + semantic = registry.semantic_identity_digest( + kind="qualification-evidence-decision-receipt", + object_schema_version=( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + object_value=receipt_identity, + object_sha256=sha("2"), + ) + first = entry( + "qualification-evidence-decision-receipt", + object_sha256=sha("2"), + semantic_identity_sha256=semantic, + ) + first_bundle = entry( + "qualification-evidence-decision-receipt-sigstore-bundle", + object_sha256=sha("3"), + subject_sha256=first["object_sha256"], + ) + conflicting = entry( + "qualification-evidence-decision-receipt", + object_sha256=sha("4"), + semantic_identity_sha256=semantic, + ) + conflicting_bundle = entry( + "qualification-evidence-decision-receipt-sigstore-bundle", + object_sha256=sha("5"), + subject_sha256=conflicting["object_sha256"], + ) + signer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/ff/" + + "f" * 64 + + ".qualification-signer-registry.json" + ), + "object_sha256": sha("f"), + "registry_identity_sha256": sha("6"), + "registry_revision": 1, + } + value = document( + [first, first_bundle, conflicting, conflicting_bundle], + signer_registry=signer, + signer_registry_history=[signer], + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "semantic identity" + ): + registry.validate_registry(value) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_production_trust.py b/tests/test_production_trust.py index 505dddc..97d94fb 100644 --- a/tests/test_production_trust.py +++ b/tests/test_production_trust.py @@ -8,6 +8,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) @@ -98,7 +99,7 @@ def rulesets() -> list[dict]: return [ { **common, - "name": "Production release tags: creation authority", + "name": "OpenAdapt policy: release tag creation", "role": "creation_authority", "ruleset_id": "10", "bypass_actors": [ @@ -112,7 +113,7 @@ def rulesets() -> list[dict]: }, { **common, - "name": "Production release tags: immutable", + "name": "OpenAdapt policy: immutable release tags", "role": "immutability", "ruleset_id": "11", "bypass_actors": [], @@ -129,14 +130,40 @@ def rulesets() -> list[dict]: def release_admission() -> dict: - artifact = { - "name": "openadapt_capture-1.0.0-py3-none-any.whl", - "kind": "python-wheel", - "sha256": sha("1"), - "size_bytes": 4, - "media_type": "application/zip", - "publish_destinations": ["github-release", "pypi"], - } + artifacts = [ + { + "name": "openadapt-capture-extension-1.0.0.zip", + "kind": "chrome-extension-zip", + "sha256": sha("1"), + "size_bytes": 4, + "media_type": "application/zip", + "publish_destinations": ["github-release"], + }, + { + "name": "openadapt_capture-1.0.0.tar.gz", + "kind": "python-sdist", + "sha256": sha("2"), + "size_bytes": 4, + "media_type": "application/gzip", + "publish_destinations": ["github-release", "pypi"], + }, + { + "name": "openadapt_capture-1.0.0-py3-none-any.whl", + "kind": "python-wheel", + "sha256": sha("3"), + "size_bytes": 4, + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + }, + { + "name": "openadapt-capture-1.0.0.spdx.json", + "kind": "spdx-sbom", + "sha256": sha("4"), + "size_bytes": 4, + "media_type": "application/spdx+json", + "publish_destinations": ["github-release"], + }, + ] normalized_rulesets = rulesets() staging = { "schema_version": "openadapt.production-release-staging-evidence/v1", @@ -153,7 +180,7 @@ def release_admission() -> dict: "release_author_login": "openadapt-release[bot]", "assets": [ { - "asset_id": "30", + "asset_id": str(30 + index), "name": artifact["name"], "sha256": artifact["sha256"], "size_bytes": artifact["size_bytes"], @@ -163,12 +190,22 @@ def release_admission() -> dict: "uploader_id": "321543906", "uploader_login": "openadapt-release[bot]", } + for index, artifact in enumerate(sorted(artifacts, key=lambda item: item["name"])) ], - "immutable_releases_enabled": True, + "immutable_releases": {"enabled": True, "enforced_by_owner": False}, + "immutable_releases_sha256": trust.digest_bytes( + trust.IMMUTABLE_RELEASES_DOMAIN, + {"enabled": True, "enforced_by_owner": False}, + ), "tag_rulesets": normalized_rulesets, "tag_rulesets_sha256": trust.digest_bytes( trust.TAG_RULESETS_DOMAIN, normalized_rulesets ), + "tag_ref_state": {"ref": "refs/tags/v1.0.0", "exists": False}, + "tag_ref_state_sha256": trust.digest_bytes( + trust.TAG_REF_STATE_DOMAIN, + {"ref": "refs/tags/v1.0.0", "exists": False}, + ), "observed_at": "2026-08-27T12:00:00Z", } release = { @@ -181,7 +218,7 @@ def release_admission() -> dict: "tag": "v1.0.0", "deployment_id": None, "deployment_sha256": None, - "artifacts": [artifact], + "artifacts": artifacts, } summary, summary_bundle = pair("production-acceptance-summary", "2", "3") value = { @@ -210,7 +247,7 @@ def release_admission() -> dict: { "target": "capture", "claim_scope": "production_capture", - "artifacts": [artifact], + "artifacts": artifacts, }, ), "publication_staging": staging, @@ -242,7 +279,434 @@ def release_admission() -> dict: return value +def cloud_handoff() -> dict: + authorization, authorization_bundle = pair( + "production-cloud-deploy-authorization", "a", "b" + ) + source_commit = "d" * 40 + source_commitment = "sha256:" + hashlib.sha256( + trust.CLOUD_SOURCE_COMMIT_DOMAIN + source_commit.encode("ascii") + ).hexdigest() + request = { + "schema_version": "openadapt.production-secret-source-proof-request/v2", + "authorization_sha256": "1" * 64, + "cloud_source_commit": source_commit, + "expected_live_attestation_sha256": "2" * 64, + "expires_at": "2026-08-27T12:05:00Z", + "issued_at": "2026-08-27T12:00:00Z", + "nonce": "A" * 43, + "production_origin": "https://app.openadapt.ai", + "profile_commit": "e" * 40, + "profile_environment": "production-cloud-deploy", + "profile_repository": "OpenAdaptAI/.github", + "profile_workflow_ref": ( + "OpenAdaptAI/.github/.github/workflows/" + "production-cloud-deploy.yml@refs/heads/main" + ), + "run_attempt": "1", + "run_id": "100", + "site_id": "ccf1a48a-d934-48ea-a9a8-36cbddf27cb5", + "source_names": list(trust.CLOUD_LIVE_SOURCE_NAMES), + } + request_sha_raw = hashlib.sha256( + trust.CLOUD_SOURCE_PROOF_REQUEST_DOMAIN + trust.canonical(request) + ).hexdigest() + request_sha = "sha256:" + request_sha_raw + public_values = {trust.CLOUD_PUBLIC_SOURCE_NAME: "answer_v1"} + response = { + "schema_version": "openadapt.production-secret-source-proof-response/v2", + "request_sha256": request_sha_raw, + "authorization_sha256": request["authorization_sha256"], + "live_attestation_sha256": "4" * 64, + "live_deployment": { + "build_id": sha("5"), + "source_commit": source_commit, + }, + "oidc_identity_sha256": "6" * 64, + "production_origin": request["production_origin"], + "proofs": { + name: hashlib.sha256(name.encode("ascii")).hexdigest() + for name in trust.CLOUD_SECRET_SOURCE_NAMES + }, + "public_values": public_values, + "site_id": request["site_id"], + } + provider_idempotency_key = "cloud-deploy:" + hashlib.sha256( + trust.CLOUD_PROVIDER_IDEMPOTENCY_DOMAIN + + trust.canonical( + { + "authorization_sha256": "sha256:" + request["authorization_sha256"], + "source_proof_request_sha256": request_sha, + "profile_run_id": request["run_id"], + "profile_run_attempt": request["run_attempt"], + } + ) + ).hexdigest() + value = { + "schema_version": "openadapt.production-cloud-deployment-result/v1", + "handoff_id_sha256": sha("0"), + "authorization_reference": authorization, + "authorization_bundle_reference": authorization_bundle, + "authorization_sha256": "sha256:" + request["authorization_sha256"], + "cloud_source_commitment_sha256": source_commitment, + "profile_commit": request["profile_commit"], + "profile_run_id": request["run_id"], + "profile_run_attempt": request["run_attempt"], + "source_proof_request": request, + "source_proof_request_sha256": request_sha, + "source_proof_response": response, + "source_proof_response_sha256": "sha256:" + + hashlib.sha256(trust.canonical(response)).hexdigest(), + "reviewed_public_values_sha256": trust.digest_bytes( + trust.CLOUD_PUBLIC_VALUES_DOMAIN, public_values + ), + "expected_live_attestation_sha256": "sha256:" + + request["expected_live_attestation_sha256"], + "live_attestation_sha256": "sha256:" + + response["live_attestation_sha256"], + "provider_idempotency_key": provider_idempotency_key, + "audience": "openadapt-private-cloud-production-deploy", + "signer_registry_sha256": sha("8"), + "revocation_state_sha256": sha("9"), + "verdict": "accepted", + "issued_at": request["issued_at"], + "not_before": request["issued_at"], + "expires_at": request["expires_at"], + "issuer": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/production-cloud-deploy.yml", + "ref": "refs/heads/main", + "source_commit": "f" * 40, + "environment": "production-cloud-deploy", + }, + } + projection = dict(value) + projection.pop("handoff_id_sha256") + value["handoff_id_sha256"] = trust.digest_bytes( + trust.CLOUD_HANDOFF_DOMAIN, projection + ) + return value + + +def rotation_fixture() -> tuple[dict, dict, dict, dict]: + signer_registry = { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": 4, + "generated_at": "2026-08-27T11:00:00Z", + "expires_at": "2026-08-27T14:00:00Z", + "signers": [], + } + signer_raw = registry.canonical(signer_registry) + b"\n" + signer_sha = "sha256:" + hashlib.sha256(signer_raw).hexdigest() + signer_hex = signer_sha.removeprefix("sha256:") + pointer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/" + f"{signer_hex[:2]}/{signer_hex}.qualification-signer-registry.json" + ), + "object_sha256": signer_sha, + "registry_identity_sha256": registry.signer_registry_identity_digest( + signer_registry + ), + "registry_revision": 4, + } + common = { + "registry_source_commit": "a" * 40, + "registry_revision": 9, + "registry_head_sha256": sha("2"), + "signer_registry": pointer, + } + older = { + **common, + "checkpoint_id_sha256": sha("3"), + "checkpoint_revision": 8, + "previous_checkpoint_sha256": sha("4"), + "generated_at": "2026-08-27T11:15:00Z", + "not_before": "2026-08-27T11:30:00Z", + "expires_at": "2026-08-27T12:30:00Z", + "signature": "older-wrapper", + } + older_sha = "sha256:" + hashlib.sha256( + trust.canonical(older) + b"\n" + ).hexdigest() + newer = { + **common, + "checkpoint_id_sha256": sha("5"), + "checkpoint_revision": 9, + "previous_checkpoint_sha256": older_sha, + "generated_at": "2026-08-27T11:45:00Z", + "not_before": "2026-08-27T12:30:00Z", + "expires_at": "2026-08-27T13:30:00Z", + "signature": "newer-wrapper", + } + + def checkpoint_reference(checkpoint: dict) -> dict: + return { + "object_sha256": "sha256:" + hashlib.sha256( + trust.canonical(checkpoint) + b"\n" + ).hexdigest(), + "semantic_identity_sha256": checkpoint["checkpoint_id_sha256"], + "registry_source_commit": checkpoint["registry_source_commit"], + "registry_revision": checkpoint["registry_revision"], + "registry_head_sha256": checkpoint["registry_head_sha256"], + } + + feed = { + **common, + "feed_revision": 12, + "generated_at": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-27T13:00:00Z", + "checkpoints": [ + { + "checkpoint_reference": checkpoint_reference(newer), + "checkpoint_bundle_reference": {}, + }, + { + "checkpoint_reference": checkpoint_reference(older), + "checkpoint_bundle_reference": {}, + }, + ], + } + return feed, newer, older, signer_registry + + class ProductionTrustTests(unittest.TestCase): + def test_feed_expiry_boundary_is_exclusive(self) -> None: + checkpoint_reference, checkpoint_bundle_reference = pair( + "production-lifecycle-checkpoint", "1", "2" + ) + feed = { + "schema_version": "openadapt.production-lifecycle-feed/v1", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "ref": "refs/heads/production-lifecycle-feed", + "feed_revision": 1, + "generated_at": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-27T13:00:00Z", + "registry_source_commit": "a" * 40, + "registry_revision": 3, + "registry_head_sha256": sha("8"), + "signer_registry": {}, + "checkpoints": [ + { + "checkpoint_reference": checkpoint_reference, + "checkpoint_bundle_reference": checkpoint_bundle_reference, + } + ], + } + trust.validate_feed( + feed, + now=trust.require_timestamp("2026-08-27T12:59:59Z", "test time"), + ) + with self.assertRaisesRegex(trust.TrustError, "not current"): + trust.validate_feed( + feed, + now=trust.require_timestamp( + "2026-08-27T13:00:00Z", "test time" + ), + ) + + def test_future_checkpoint_rotation_has_one_exact_boundary(self) -> None: + feed, newer, older, signer_registry = rotation_fixture() + with ( + mock.patch.object(trust, "validate_feed", return_value=feed), + mock.patch.object(trust, "validate_checkpoint", side_effect=lambda item, **_: item), + mock.patch.object( + registry, "validate_signer_registry", return_value=signer_registry + ), + ): + trust.validate_feed_expiry_containment( + feed, + checkpoints=[newer, older], + signer_registry=signer_registry, + now=trust.require_timestamp( + "2026-08-27T12:29:59Z", "test time" + ), + ) + trust.validate_feed_expiry_containment( + feed, + checkpoints=[newer, older], + signer_registry=signer_registry, + now=trust.require_timestamp( + "2026-08-27T12:30:00Z", "test time" + ), + ) + + def test_checkpoint_rotation_refuses_overlap_gap_fork_and_reorder(self) -> None: + for defect in ("overlap", "gap", "fork", "skip", "reorder"): + with self.subTest(defect=defect): + feed, newer, older, signer_registry = rotation_fixture() + checkpoints = [newer, older] + if defect == "overlap": + older["expires_at"] = "2026-08-27T12:30:01Z" + elif defect == "gap": + older["expires_at"] = "2026-08-27T12:29:59Z" + elif defect == "fork": + newer["previous_checkpoint_sha256"] = sha("6") + elif defect == "skip": + newer["checkpoint_revision"] = 10 + else: + checkpoints.reverse() + feed["checkpoints"].reverse() + for pair_value, checkpoint in zip( + feed["checkpoints"], checkpoints, strict=True + ): + pair_value["checkpoint_reference"]["object_sha256"] = ( + "sha256:" + hashlib.sha256( + trust.canonical(checkpoint) + b"\n" + ).hexdigest() + ) + with ( + mock.patch.object(trust, "validate_feed", return_value=feed), + mock.patch.object( + trust, + "validate_checkpoint", + side_effect=lambda item, **_: item, + ), + mock.patch.object( + registry, + "validate_signer_registry", + return_value=signer_registry, + ), + self.assertRaises(trust.TrustError), + ): + trust.validate_feed_expiry_containment( + feed, + checkpoints=checkpoints, + signer_registry=signer_registry, + ) + + def test_checkpoint_reference_binds_the_exact_signed_wrapper(self) -> None: + feed, newer, older, signer_registry = rotation_fixture() + newer["signature"] = "alternate-wrapper" + with ( + mock.patch.object(trust, "validate_feed", return_value=feed), + mock.patch.object(trust, "validate_checkpoint", side_effect=lambda item, **_: item), + mock.patch.object( + registry, "validate_signer_registry", return_value=signer_registry + ), + self.assertRaisesRegex(trust.TrustError, "bytes differ"), + ): + trust.validate_feed_expiry_containment( + feed, + checkpoints=[newer, older], + signer_registry=signer_registry, + ) + + def test_feed_transition_refuses_rollback_or_skipped_history(self) -> None: + previous, newer, older, _ = rotation_fixture() + previous["checkpoints"] = [previous["checkpoints"][1]] + previous["feed_revision"] = 11 + previous["generated_at"] = "2026-08-27T11:50:00Z" + current, _, _, _ = rotation_fixture() + with mock.patch.object(trust, "validate_feed", side_effect=lambda item: item): + trust.validate_feed_transition(previous, current) + rollback = copy.deepcopy(current) + rollback["checkpoints"] = [rollback["checkpoints"][1]] + rollback["feed_revision"] = 13 + rollback["generated_at"] = "2026-08-27T12:10:00Z" + with self.assertRaisesRegex(trust.TrustError, "skips or forks"): + trust.validate_feed_transition(current, rollback) + skipped_revision = copy.deepcopy(current) + skipped_revision["feed_revision"] = 13 + with self.assertRaisesRegex(trust.TrustError, "revision"): + trust.validate_feed_transition(previous, skipped_revision) + + def test_feed_expiry_cannot_outlive_checkpoint_or_signer_registry(self) -> None: + signer_registry = { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": 4, + "generated_at": "2026-08-27T11:00:00Z", + "expires_at": "2026-08-27T13:00:00Z", + "signers": [], + } + signer_raw = registry.canonical(signer_registry) + b"\n" + signer_sha = "sha256:" + hashlib.sha256(signer_raw).hexdigest() + signer_hex = signer_sha.removeprefix("sha256:") + pointer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/" + f"{signer_hex[:2]}/{signer_hex}.qualification-signer-registry.json" + ), + "object_sha256": signer_sha, + "registry_identity_sha256": registry.signer_registry_identity_digest( + signer_registry + ), + "registry_revision": 4, + } + checkpoint = { + "checkpoint_id_sha256": sha("1"), + "checkpoint_revision": 8, + "previous_checkpoint_sha256": None, + "registry_source_commit": "a" * 40, + "registry_revision": 9, + "registry_head_sha256": sha("2"), + "signer_registry": pointer, + "generated_at": "2026-08-27T11:15:00Z", + "not_before": "2026-08-27T11:30:00Z", + "expires_at": "2026-08-27T12:30:00Z", + } + checkpoint_sha = "sha256:" + hashlib.sha256( + trust.canonical(checkpoint) + b"\n" + ).hexdigest() + feed = { + "generated_at": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-27T12:30:00Z", + "registry_source_commit": checkpoint["registry_source_commit"], + "registry_revision": checkpoint["registry_revision"], + "registry_head_sha256": checkpoint["registry_head_sha256"], + "signer_registry": pointer, + "checkpoints": [ + { + "checkpoint_reference": { + "object_sha256": checkpoint_sha, + "registry_source_commit": checkpoint[ + "registry_source_commit" + ], + "registry_revision": checkpoint["registry_revision"], + "registry_head_sha256": checkpoint[ + "registry_head_sha256" + ], + }, + "checkpoint_bundle_reference": {}, + } + ], + } + with ( + mock.patch.object(trust, "validate_feed", return_value=feed), + mock.patch.object( + trust, "validate_checkpoint", return_value=checkpoint + ), + mock.patch.object( + registry, "validate_signer_registry", return_value=signer_registry + ), + ): + trust.validate_feed_expiry_containment( + feed, checkpoints=[checkpoint], signer_registry=signer_registry + ) + feed["expires_at"] = "2026-08-27T12:30:01Z" + with self.assertRaisesRegex(trust.TrustError, "checkpoint"): + trust.validate_feed_expiry_containment( + feed, + checkpoints=[checkpoint], + signer_registry=signer_registry, + ) + + def test_cloud_handoff_binds_remote_safe_request_and_response(self) -> None: + value = cloud_handoff() + trust.validate_cloud_deployment_handoff(value) + invalid = copy.deepcopy(value) + invalid["source_proof_response"]["proofs"][ + "BUSINESS_DECISION_ANSWER_HMAC_KEY" + ] = "a" * 64 + with self.assertRaisesRegex(trust.TrustError, "response digest"): + trust.validate_cloud_deployment_handoff(invalid) + def test_campaign_requires_reconciliation_without_replay(self) -> None: value = campaign_summary() trust.validate_campaign_summary(value) @@ -269,9 +733,12 @@ def test_local_artifact_inventory_refuses_extra_file(self) -> None: artifacts = value["release"]["artifacts"] with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - path = root / artifacts[0]["name"] - path.write_bytes(b"test") - artifacts[0]["sha256"] = "sha256:" + hashlib.sha256(b"test").hexdigest() + for artifact in artifacts: + path = root / artifact["name"] + path.write_bytes(b"test") + artifact["sha256"] = ( + "sha256:" + hashlib.sha256(b"test").hexdigest() + ) trust.verify_local_artifacts(root, artifacts) (root / "SHA256SUMS").write_text("not admitted") with self.assertRaisesRegex(trust.TrustError, "extra"): diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index f67a4c1..82f86f8 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -13,6 +13,114 @@ EXPECTED_TOP_LEVEL_FIELDS = { + "support-release-admission.schema.json": { + "schema_version", + "admission_id_sha256", + "lifecycle_state", + "support_target", + "verdict", + "claim_scope", + "release_identity", + "release", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging", + "publication_staging_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "support_policy_sha256", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "production-release-authority-policy.schema.json": { + "$schema", + "schema_version", + "release_app", + "repository_selection", + "permissions", + "repositories", + "tag_rulesets", + "support_release_authorities", + }, + "production-publication-recovery-authorization.schema.json": { + "schema_version", + "authorization_id_sha256", + "qualification_release_reference", + "qualification_release_object_sha256", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging_sha256", + "repository", + "repository_id", + "target", + "tag", + "tag_ref", + "tag_object_id", + "target_commit", + "draft_release_id", + "requested_effects", + "run_id", + "run_attempt", + "dispatcher_actor_id", + "environment", + "release_app", + "idempotency_key", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "production-cloud-deploy-authorization.schema.json": { + "schema_version", + "authorization_sha256", + "deployment_intent_sha256", + "profile_repository", + "profile_repository_id", + "profile_commit", + "profile_workflow", + "profile_run_id", + "profile_run_attempt", + "cloud_source_commitment_sha256", + "expected_live_attestation_sha256", + "provider_idempotency_key", + "audience", + "signer_registry_sha256", + "revocation_state_sha256", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "production-cloud-deployment-result.schema.json": { + "schema_version", + "handoff_id_sha256", + "authorization_reference", + "authorization_bundle_reference", + "authorization_sha256", + "cloud_source_commitment_sha256", + "profile_commit", + "profile_run_id", + "profile_run_attempt", + "source_proof_request", + "source_proof_request_sha256", + "source_proof_response", + "source_proof_response_sha256", + "reviewed_public_values_sha256", + "expected_live_attestation_sha256", + "live_attestation_sha256", + "provider_idempotency_key", + "audience", + "signer_registry_sha256", + "revocation_state_sha256", + "verdict", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, "production-lifecycle-evidence-manifest.schema.json": { "schema_version", "target", @@ -38,6 +146,7 @@ "issued_at", "not_before", "expires_at", + "issuer", }, "production-current-default.schema.json": { "schema_version", @@ -75,6 +184,8 @@ "reference_schema_version", "transport", "signer_registry_schema_version", + "embedded_signatures", + "message_signature", "sigstore", }, "production-lifecycle-checkpoint.schema.json": { @@ -132,6 +243,7 @@ "issued_at", "not_before", "expires_at", + "issuer", }, "production-lifecycle-feed.schema.json": { "schema_version", @@ -221,6 +333,8 @@ }, "qualification-evidence-decision-receipt.schema.json": { "schema_version", + "decision_identity_sha256", + "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", "organization_id_sha256", @@ -236,7 +350,7 @@ "identity_contract_sha256", "effect_contract_sha256", "policy_contract_sha256", - "evidence_authority_sha256", + "evidence_authority_contract_sha256", "campaign_permit_sha256", "signer_registry_sha256", "revocation_state_sha256", @@ -248,8 +362,10 @@ "expires_at", "issuer_key_id", "algorithm", + "signing_statement", "signature", "campaign_artifact_sha256", + "issuer", }, "qualification-release.schema.json": { "schema_version", @@ -347,6 +463,26 @@ def test_release_artifacts_bind_publish_destinations(self) -> None: self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["minItems"], 2) self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["maxItems"], 2) + def test_verification_policy_freezes_both_sigstore_profiles(self) -> None: + policy = json.loads( + (ROOT / "production-evidence-policy.json").read_text(encoding="utf-8") + ) + identities = policy["sigstore"]["certificate_identities"] + self.assertEqual( + [item["kind"] for item in identities], + sorted(item["kind"] for item in identities), + ) + profiles = {item["kind"]: item["bundle_profile"] for item in identities} + self.assertEqual( + profiles["qualification-evidence-decision-receipt"], + "sigstore-message-signature", + ) + self.assertEqual(profiles["qualification-release"], "github-attestation") + message = policy["message_signature"] + self.assertEqual(message["version"], "3.1.3") + self.assertEqual(message["runner"], "ubuntu-24.04") + self.assertEqual(message["architecture"], "linux-amd64") + if __name__ == "__main__": unittest.main() From 7f8c68bd08b2ff8178e95f5f140206ec404ac3ea Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:37:23 -0400 Subject: [PATCH 04/18] fix: close current-state recovery contracts --- ...ication-recovery-authorization.schema.json | 36 +++- scripts/production_trust.py | 182 ++++++++++++++++-- scripts/stage_production_evidence.py | 26 ++- tests/test_evidence_registry.py | 79 ++++++++ tests/test_public_trust_schemas.py | 2 +- 5 files changed, 294 insertions(+), 31 deletions(-) diff --git a/schemas/production-publication-recovery-authorization.schema.json b/schemas/production-publication-recovery-authorization.schema.json index 841e72b..10bac15 100644 --- a/schemas/production-publication-recovery-authorization.schema.json +++ b/schemas/production-publication-recovery-authorization.schema.json @@ -9,7 +9,7 @@ "qualification_release_reference", "qualification_release_object_sha256", "release_sha256", "artifact_inventory_sha256", "publication_staging_sha256", "repository", "repository_id", "target", "tag", "tag_ref", - "tag_object_id", "target_commit", "draft_release_id", "requested_effects", + "tag_object_id", "target_commit", "draft_release_id", "requested_effect", "run_id", "run_attempt", "dispatcher_actor_id", "environment", "release_app", "idempotency_key", "issued_at", "not_before", "expires_at", "issuer" @@ -30,17 +30,18 @@ "tag_object_id": {"$ref": "#/$defs/commit"}, "target_commit": {"$ref": "#/$defs/commit"}, "draft_release_id": {"$ref": "#/$defs/decimal_id"}, - "requested_effects": { - "type": "array", - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "items": {"enum": ["github-release", "mcp-registry", "pypi"]} + "requested_effect": { + "enum": [ + "stage-draft-assets", + "publish-pypi", + "publish-github-release", + "publish-mcp-registry" + ] }, "run_id": {"$ref": "#/$defs/decimal_id"}, "run_attempt": {"const": "1"}, "dispatcher_actor_id": {"const": "774615"}, - "environment": {"const": "release-identity"}, + "environment": {"enum": ["release-identity", "pypi", "mcp-registry"]}, "release_app": {"$ref": "#/$defs/release_app"}, "idempotency_key": {"type": "string", "pattern": "^publication-recovery:[0-9a-f]{64}$"}, "issued_at": {"$ref": "#/$defs/timestamp"}, @@ -48,6 +49,25 @@ "expires_at": {"$ref": "#/$defs/timestamp"}, "issuer": {"$ref": "#/$defs/issuer"} }, + "allOf": [ + { + "if": {"properties": {"requested_effect": {"enum": ["stage-draft-assets", "publish-github-release"]}}}, + "then": {"properties": {"environment": {"const": "release-identity"}}} + }, + { + "if": {"properties": {"requested_effect": {"const": "publish-pypi"}}}, + "then": {"properties": {"environment": {"const": "pypi"}}} + }, + { + "if": {"properties": {"requested_effect": {"const": "publish-mcp-registry"}}}, + "then": { + "properties": { + "environment": {"const": "mcp-registry"}, + "target": {"const": "agent"} + } + } + } + ], "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, diff --git a/scripts/production_trust.py b/scripts/production_trust.py index ecbb4f7..e9c7443 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -318,6 +318,15 @@ def validate_window( raise TrustError("object is not active at the requested time") +def validate_staging_observation_age( + staging: Mapping[str, Any], admission: Mapping[str, Any] +) -> None: + observed = require_timestamp(staging["observed_at"], "staging observed_at") + issued = require_timestamp(admission["issued_at"], "admission issued_at") + if not observed <= issued <= observed + timedelta(hours=1): + raise TrustError("publication staging observation is stale or postdates admission") + + def validate_reference_pair( regular: Any, bundle: Any, *, kind: str ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -751,6 +760,7 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An } or not isinstance(issuer["source_commit"], str) or HEX40.fullmatch(issuer["source_commit"]) is None: raise TrustError("release issuer identity differs") validate_window(release_admission, maximum=timedelta(days=30), now=now) + validate_staging_observation_age(staging, release_admission) projection = dict(release_admission) admission_id = projection.pop("admission_id_sha256") if admission_id != digest_bytes(RELEASE_ADMISSION_DOMAIN, projection): @@ -924,6 +934,7 @@ def validate_support_release( ): raise TrustError("Support release issuer differs") validate_window(admission, maximum=timedelta(days=7), now=now) + validate_staging_observation_age(staging, admission) projection = dict(admission) identity_digest = projection.pop("admission_id_sha256") if identity_digest != digest_bytes(SUPPORT_RELEASE_ADMISSION_DOMAIN, projection): @@ -940,7 +951,7 @@ def validate_publication_recovery_authorization( "release_sha256", "artifact_inventory_sha256", "publication_staging_sha256", "repository", "repository_id", "target", "tag", "tag_ref", "tag_object_id", "target_commit", "draft_release_id", - "requested_effects", "run_id", "run_attempt", "dispatcher_actor_id", + "requested_effect", "run_id", "run_attempt", "dispatcher_actor_id", "environment", "release_app", "idempotency_key", "issued_at", "not_before", "expires_at", "issuer", } @@ -952,7 +963,6 @@ def validate_publication_recovery_authorization( != "openadapt.production-publication-recovery-authorization/v1" or authorization["run_attempt"] != "1" or authorization["dispatcher_actor_id"] != "774615" - or authorization["environment"] != "release-identity" ): raise TrustError("publication recovery authorization identity differs") target = authorization["target"] @@ -996,18 +1006,19 @@ def validate_publication_recovery_authorization( raise TrustError("publication recovery immutable tag binding differs") require_decimal_id(authorization["draft_release_id"], "draft release id") require_decimal_id(authorization["run_id"], "recovery run id") - effects = authorization["requested_effects"] + effect = authorization["requested_effect"] + effect_environments = { + "stage-draft-assets": "release-identity", + "publish-pypi": "pypi", + "publish-github-release": "release-identity", + "publish-mcp-registry": "mcp-registry", + } if ( - not isinstance(effects, list) - or not effects - or effects != sorted(set(effects)) - or any( - effect not in {"github-release", "mcp-registry", "pypi"} - for effect in effects - ) - or ("mcp-registry" in effects and target != "agent") + effect not in effect_environments + or authorization["environment"] != effect_environments[effect] + or (effect == "publish-mcp-registry" and target != "agent") ): - raise TrustError("publication recovery effect set differs") + raise TrustError("publication recovery effect or environment differs") release_app = closed( authorization["release_app"], {"app_id", "installation_id", "bot_user_id", "bot_login"}, @@ -1021,15 +1032,27 @@ def validate_publication_recovery_authorization( }: raise TrustError("publication recovery release App differs") idempotency_projection = { - "qualification_release_object_sha256": authorization[ - "qualification_release_object_sha256" - ], - "repository": authorization["repository"], - "tag_ref": authorization["tag_ref"], - "tag_object_id": authorization["tag_object_id"], - "run_id": authorization["run_id"], - "run_attempt": authorization["run_attempt"], - "requested_effects": effects, + field: authorization[field] + for field in ( + "qualification_release_object_sha256", + "release_sha256", + "artifact_inventory_sha256", + "publication_staging_sha256", + "repository", + "repository_id", + "target", + "tag", + "tag_ref", + "tag_object_id", + "target_commit", + "draft_release_id", + "requested_effect", + "run_id", + "run_attempt", + "dispatcher_actor_id", + "environment", + "release_app", + ) } expected_idempotency = "publication-recovery:" + hashlib.sha256( PUBLICATION_RECOVERY_IDEMPOTENCY_DOMAIN @@ -1067,6 +1090,33 @@ def validate_publication_recovery_authorization( return authorization +def validate_publication_recovery_replay( + previous_value: Any, current_value: Any +) -> dict[str, Any]: + """Accept one byte-identical replay and refuse a changed one-use claim.""" + + previous = validate_publication_recovery_authorization(previous_value) + current = validate_publication_recovery_authorization(current_value) + if canonical(previous) == canonical(current): + return current + previous_consumption = ( + previous["repository_id"], + previous["tag_ref"], + previous["requested_effect"], + ) + current_consumption = ( + current["repository_id"], + current["tag_ref"], + current["requested_effect"], + ) + if ( + previous["idempotency_key"] == current["idempotency_key"] + or previous_consumption == current_consumption + ): + raise TrustError("publication recovery one-use claim conflicts") + return current + + def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, Any]: fields = { "schema_version", "decision_identity_sha256", "decision_revision", @@ -1471,6 +1521,96 @@ def validate_revocation_state( return state +def validate_admission_current_state( + admission_value: Any, + *, + admission_reference: Any, + authority_state: Any, + revocation_state: Any, + signer_registry: Any, + now: datetime | None = None, +) -> dict[str, Any]: + """Bind one release/workflow admission to the exact active trust state.""" + + verification_time = now or datetime.now(timezone.utc) + schema = admission_value.get("schema_version") if isinstance(admission_value, dict) else None + if schema == "openadapt.qualification-release/v1": + admission = validate_release(admission_value, now=verification_time) + expected_kind = "qualification-release" + elif schema == "openadapt.support-release-admission/v1": + admission = validate_support_release(admission_value, now=verification_time) + expected_kind = "support-release-admission" + elif schema == "openadapt.qualification-admission/v3": + admission = validate_qualification_admission( + admission_value, now=verification_time + ) + expected_kind = "qualification-admission" + else: + raise TrustError("admission schema is not current-state aware") + reference = evidence.validate_reference(admission_reference) + if ( + reference["kind"] != expected_kind + or reference["subject_sha256"] is not None + or reference["object_sha256"] + != "sha256:" + hashlib.sha256(canonical(admission) + b"\n").hexdigest() + ): + raise TrustError("admission reference differs from the signed object") + try: + registry = evidence.validate_signer_registry(signer_registry) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + authority = validate_authority_state(authority_state, now=verification_time) + revocation = validate_revocation_state(revocation_state, now=verification_time) + registry_identity = evidence.signer_registry_identity_digest(registry) + registry_raw_sha = "sha256:" + hashlib.sha256( + evidence.canonical(registry) + b"\n" + ).hexdigest() + if ( + authority["signer_registry_sha256"] != registry_raw_sha + or authority["signer_registry_identity_sha256"] != registry_identity + or authority["signer_registry_revision"] != registry["revision"] + or revocation["signer_registry_sha256"] != registry_identity + or revocation["authority_state_sha256"] + != authority["authority_state_sha256"] + or admission["signer_registry_sha256"] != registry_identity + or admission["revocation_state_sha256"] + != revocation["revocation_state_sha256"] + ): + raise TrustError("admission current trust state differs") + if "authority_state_sha256" in admission and ( + admission["authority_state_sha256"] != authority["authority_state_sha256"] + ): + raise TrustError("admission current authority state differs") + verify_embedded_signature( + authority, + signer_registry=registry, + object_schema_version="openadapt.qualification-authority-state-receipt/v2", + signature_domain=AUTHORITY_STATE_SIGNATURE_DOMAIN, + usage="qualification-authority-state-receipt", + now=verification_time, + ) + verify_embedded_signature( + revocation, + signer_registry=registry, + object_schema_version="openadapt.qualification-revocation-state-receipt/v1", + signature_domain=REVOCATION_STATE_SIGNATURE_DOMAIN, + usage="qualification-revocation-state-receipt", + now=verification_time, + ) + revoked = { + (item["subject_kind"], item["subject_id"]) + for item in revocation["revocations"] + } + if (reference["kind"], reference["semantic_identity_sha256"]) in revoked: + raise TrustError("admission is revoked") + for signer in registry["signers"]: + if signer["status"] == "active" and ( + "qualification-signer-key", signer["public_key_sha256"] + ) in revoked: + raise TrustError("an active signer key is revoked") + return admission + + def validate_local_identity_opening(value: Any) -> dict[str, Any]: opening = closed( value, diff --git a/scripts/stage_production_evidence.py b/scripts/stage_production_evidence.py index a564aeb..9be3ba4 100644 --- a/scripts/stage_production_evidence.py +++ b/scripts/stage_production_evidence.py @@ -53,6 +53,10 @@ def entry_for(raw: bytes, *, kind: str, subject: str | None) -> dict: def install_signer_registry(document: dict, source: Path, root: Path) -> None: raw = source.read_bytes() value = registry.validate_signer_registry(json.loads(raw)) + if raw != registry.canonical(value) + b"\n": + raise registry.EvidenceRegistryError( + "signer registry bytes must be canonical JSON followed by one LF" + ) object_sha = "sha256:" + hashlib.sha256(raw).hexdigest() digest_hex = object_sha.removeprefix("sha256:") relative = ( @@ -64,13 +68,33 @@ def install_signer_registry(document: dict, source: Path, root: Path) -> None: if target.exists() and target.read_bytes() != raw: raise registry.EvidenceRegistryError("signer registry content address collides") target.write_bytes(raw) - document["signer_registry"] = { + pointer = { "schema_version": registry.SIGNER_POINTER_SCHEMA, "object_path": relative, "object_sha256": object_sha, "registry_identity_sha256": registry.signer_registry_identity_digest(value), "registry_revision": value["revision"], } + current = document["signer_registry"] + history = document["signer_registry_history"] + if current == pointer: + return + if current is None: + if history or pointer["registry_revision"] != 1: + raise registry.EvidenceRegistryError( + "the first signer registry must be revision 1" + ) + else: + if not history or history[-1] != current: + raise registry.EvidenceRegistryError( + "signer registry history does not end at the current pointer" + ) + if pointer["registry_revision"] != current["registry_revision"] + 1: + raise registry.EvidenceRegistryError( + "signer registry revision must increase by exactly one" + ) + history.append(pointer) + document["signer_registry"] = pointer def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index fdf41a8..0851a37 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(ROOT / "scripts")) import validate_evidence_registry as registry # noqa: E402 +import stage_production_evidence as stage # noqa: E402 def sha(character: str) -> str: @@ -264,6 +265,84 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "bind"): registry.validate_signer_registry(value) + def test_signer_registry_install_is_canonical_consecutive_and_append_only(self) -> None: + key = bytes(range(32)) + spki = bytes.fromhex("302a300506032b6570032100") + key + + def signer_registry(revision: int) -> dict: + return { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": revision, + "generated_at": f"2026-08-{26 + revision:02d}T00:00:00Z", + "expires_at": f"2026-09-{2 + revision:02d}T00:00:00Z", + "signers": [ + { + "algorithm": "ed25519", + "key_id": ( + "qa-ed25519-" + hashlib.sha256(key).hexdigest()[:16] + ), + "public_key": ( + registry.base64.urlsafe_b64encode(key) + .decode() + .rstrip("=") + ), + "public_key_spki_der_base64": ( + registry.base64.b64encode(spki).decode() + ), + "public_key_sha256": ( + "sha256:" + hashlib.sha256(spki).hexdigest() + ), + "statement_schema_versions": [ + "openadapt.qualification-evidence-signing-statement/v1" + ], + "allowed_usages": [ + "qualification-evidence-decision-receipt" + ], + "allowed_workflows": [ + "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/OpenAdaptAI/openadapt-internal/" + ".github/workflows/" + "issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ], + "allowed_ref_prefixes": ["refs/heads/main"], + "status": "active", + "revoked_at": None, + } + ], + } + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + value = document() + first = signer_registry(1) + first_path = root / "signers-v1.json" + first_path.write_bytes(registry.canonical(first) + b"\n") + stage.install_signer_registry(value, first_path, root) + self.assertEqual(value["signer_registry_history"], [value["signer_registry"]]) + stage.install_signer_registry(value, first_path, root) + self.assertEqual(len(value["signer_registry_history"]), 1) + + third = signer_registry(3) + third_path = root / "signers-v3.json" + third_path.write_bytes(registry.canonical(third) + b"\n") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "exactly one"): + stage.install_signer_registry(value, third_path, root) + + second = signer_registry(2) + second_path = root / "signers-v2.json" + second_path.write_bytes(registry.canonical(second) + b"\n") + stage.install_signer_registry(value, second_path, root) + self.assertEqual( + [item["registry_revision"] for item in value["signer_registry_history"]], + [1, 2], + ) + self.assertEqual(value["signer_registry"], value["signer_registry_history"][-1]) + + noncanonical = root / "noncanonical.json" + noncanonical.write_text(json.dumps(signer_registry(3), indent=2) + "\n") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "canonical"): + stage.install_signer_registry(value, noncanonical, root) + def test_append_only_revision_binds_previous_head(self) -> None: previous = document() current = document( diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 82f86f8..87efc08 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -61,7 +61,7 @@ "tag_object_id", "target_commit", "draft_release_id", - "requested_effects", + "requested_effect", "run_id", "run_attempt", "dispatcher_actor_id", From 63d18af62fb4f96775cbba88df8b4604aa656c36 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:43:21 -0400 Subject: [PATCH 05/18] feat: bind support and trial authorities --- ...cation-trial-receipt-authority-policy.json | 1 + ...ication-recovery-authorization.schema.json | 4 +- ...tion-evidence-decision-receipt.schema.json | 2 + ...trial-receipt-authority-policy.schema.json | 93 +++++++++++++++++++ schemas/support-release-policy.schema.json | 87 +++++++++++++++++ scripts/production_trust.py | 75 ++++++++++++++- support-release-policy.json | 1 + tests/test_public_trust_schemas.py | 50 ++++++++++ 8 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 qualification-trial-receipt-authority-policy.json create mode 100644 schemas/qualification-trial-receipt-authority-policy.schema.json create mode 100644 schemas/support-release-policy.schema.json create mode 100644 support-release-policy.json diff --git a/qualification-trial-receipt-authority-policy.json b/qualification-trial-receipt-authority-policy.json new file mode 100644 index 0000000..e1644cf --- /dev/null +++ b/qualification-trial-receipt-authority-policy.json @@ -0,0 +1 @@ +{"$schema":"schemas/qualification-trial-receipt-authority-policy.schema.json","schema_version":"openadapt.qualification-trial-receipt-authority-policy/v1","repository":"OpenAdaptAI/openadapt-evals","repository_id":"1135998197","repository_owner_id":"132681217","workflow":".github/workflows/issue-qualification-trial-receipts.yml","ref":"refs/heads/main","trigger":"workflow_dispatch","input":{"name":"campaign_request_handle","schema_version":"openadapt.opaque-one-use-campaign-request-handle/v1","pattern":"^qcrh_[A-Za-z0-9_-]{43}$","atomically_consumed":true},"signing":{"algorithm":"ed25519","signer_registry_usage":"qualification-trial-receipt","distinct_key_per_receipt_type":true,"outer_sigstore_bundle":false},"authorities":[{"receipt_type":"runner","environment":"qualification-trial-runner","runner_labels":["self-hosted","evidence-authority","qualification-trial-runner"]},{"receipt_type":"observer","environment":"qualification-trial-observer","runner_labels":["self-hosted","evidence-authority","qualification-trial-observer"]},{"receipt_type":"delivery","environment":"qualification-trial-delivery","runner_labels":["self-hosted","evidence-authority","qualification-trial-delivery"]},{"receipt_type":"decision","environment":"qualification-trial-decision","runner_labels":["self-hosted","evidence-authority","qualification-trial-decision"]},{"receipt_type":"policy","environment":"qualification-trial-policy","runner_labels":["self-hosted","evidence-authority","qualification-trial-policy"]},{"receipt_type":"repair","environment":"qualification-trial-repair","runner_labels":["self-hosted","evidence-authority","qualification-trial-repair"]},{"receipt_type":"cleanup","environment":"qualification-trial-cleanup","runner_labels":["self-hosted","evidence-authority","qualification-trial-cleanup"]},{"receipt_type":"cleanup_absence","environment":"qualification-trial-cleanup-absence","runner_labels":["self-hosted","evidence-authority","qualification-trial-cleanup-absence"]}]} diff --git a/schemas/production-publication-recovery-authorization.schema.json b/schemas/production-publication-recovery-authorization.schema.json index 10bac15..78c64d0 100644 --- a/schemas/production-publication-recovery-authorization.schema.json +++ b/schemas/production-publication-recovery-authorization.schema.json @@ -25,8 +25,8 @@ "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, "repository_id": {"$ref": "#/$defs/decimal_id"}, "target": {"enum": ["agent", "capture", "desktop", "flow", "openadapt"]}, - "tag": {"type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:[A-Za-z0-9._-]+)?$"}, - "tag_ref": {"type": "string", "pattern": "^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+(?:[A-Za-z0-9._-]+)?$"}, + "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, + "tag_ref": {"type": "string", "pattern": "^refs/tags/v(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})\\.(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$"}, "tag_object_id": {"$ref": "#/$defs/commit"}, "target_commit": {"$ref": "#/$defs/commit"}, "draft_release_id": {"$ref": "#/$defs/decimal_id"}, diff --git a/schemas/qualification-evidence-decision-receipt.schema.json b/schemas/qualification-evidence-decision-receipt.schema.json index fa869bf..5c0dd16 100644 --- a/schemas/qualification-evidence-decision-receipt.schema.json +++ b/schemas/qualification-evidence-decision-receipt.schema.json @@ -10,6 +10,7 @@ "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", + "evidence_manifest_readback_sha256", "organization_id_sha256", "workflow_id_sha256", "workflow_version_id_sha256", @@ -46,6 +47,7 @@ "decision_revision": {"type": "integer", "minimum": 1}, "decision_commitment_sha256": {"$ref": "#/$defs/digest"}, "evidence_manifest_sha256": {"$ref": "#/$defs/digest"}, + "evidence_manifest_readback_sha256": {"$ref": "#/$defs/digest"}, "organization_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_id_sha256": {"$ref": "#/$defs/digest"}, "workflow_version_id_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/qualification-trial-receipt-authority-policy.schema.json b/schemas/qualification-trial-receipt-authority-policy.schema.json new file mode 100644 index 0000000..721774e --- /dev/null +++ b/schemas/qualification-trial-receipt-authority-policy.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-trial-receipt-authority-policy.schema.json", + "title": "OpenAdapt qualification trial receipt authority policy v1", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", "schema_version", "repository", "repository_id", + "repository_owner_id", "workflow", "ref", "trigger", "input", "signing", + "authorities" + ], + "properties": { + "$schema": {"const": "schemas/qualification-trial-receipt-authority-policy.schema.json"}, + "schema_version": {"const": "openadapt.qualification-trial-receipt-authority-policy/v1"}, + "repository": {"const": "OpenAdaptAI/openadapt-evals"}, + "repository_id": {"const": "1135998197"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-qualification-trial-receipts.yml"}, + "ref": {"const": "refs/heads/main"}, + "trigger": {"const": "workflow_dispatch"}, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["name", "schema_version", "pattern", "atomically_consumed"], + "properties": { + "name": {"const": "campaign_request_handle"}, + "schema_version": {"const": "openadapt.opaque-one-use-campaign-request-handle/v1"}, + "pattern": {"const": "^qcrh_[A-Za-z0-9_-]{43}$"}, + "atomically_consumed": {"const": true} + } + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", "signer_registry_usage", "distinct_key_per_receipt_type", + "outer_sigstore_bundle" + ], + "properties": { + "algorithm": {"const": "ed25519"}, + "signer_registry_usage": {"const": "qualification-trial-receipt"}, + "distinct_key_per_receipt_type": {"const": true}, + "outer_sigstore_bundle": {"const": false} + } + }, + "authorities": { + "type": "array", + "minItems": 8, + "maxItems": 8, + "prefixItems": [ + {"$ref": "#/$defs/runner"}, + {"$ref": "#/$defs/observer"}, + {"$ref": "#/$defs/delivery"}, + {"$ref": "#/$defs/decision"}, + {"$ref": "#/$defs/policy"}, + {"$ref": "#/$defs/repair"}, + {"$ref": "#/$defs/cleanup"}, + {"$ref": "#/$defs/cleanup_absence"} + ], + "items": false + } + }, + "$defs": { + "base": { + "type": "object", + "additionalProperties": false, + "required": ["receipt_type", "environment", "runner_labels"], + "properties": { + "receipt_type": {"enum": ["runner", "observer", "delivery", "decision", "policy", "repair", "cleanup", "cleanup_absence"]}, + "environment": {"type": "string", "pattern": "^qualification-trial-[a-z-]+$"}, + "runner_labels": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + {"const": "self-hosted"}, + {"const": "evidence-authority"}, + {"type": "string", "pattern": "^qualification-trial-[a-z-]+$"} + ], + "items": false + } + } + }, + "runner": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "runner"}, "environment": {"const": "qualification-trial-runner"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-runner"]}}}]}, + "observer": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "observer"}, "environment": {"const": "qualification-trial-observer"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-observer"]}}}]}, + "delivery": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "delivery"}, "environment": {"const": "qualification-trial-delivery"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-delivery"]}}}]}, + "decision": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "decision"}, "environment": {"const": "qualification-trial-decision"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-decision"]}}}]}, + "policy": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "policy"}, "environment": {"const": "qualification-trial-policy"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-policy"]}}}]}, + "repair": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "repair"}, "environment": {"const": "qualification-trial-repair"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-repair"]}}}]}, + "cleanup": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "cleanup"}, "environment": {"const": "qualification-trial-cleanup"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-cleanup"]}}}]}, + "cleanup_absence": {"allOf": [{"$ref": "#/$defs/base"}, {"properties": {"receipt_type": {"const": "cleanup_absence"}, "environment": {"const": "qualification-trial-cleanup-absence"}, "runner_labels": {"const": ["self-hosted", "evidence-authority", "qualification-trial-cleanup-absence"]}}}]} + } +} diff --git a/schemas/support-release-policy.schema.json b/schemas/support-release-policy.schema.json new file mode 100644 index 0000000..2c8098b --- /dev/null +++ b/schemas/support-release-policy.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "support-release-policy.schema.json", + "title": "OpenAdapt Support release policy v1", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", "schema_version", "lifecycle_state", "production_projection", + "maximum_admission_days", "release_authority", "targets" + ], + "properties": { + "$schema": {"const": "schemas/support-release-policy.schema.json"}, + "schema_version": {"const": "openadapt.support-release-policy/v1"}, + "lifecycle_state": {"const": "Support"}, + "production_projection": {"const": false}, + "maximum_admission_days": {"const": 7}, + "release_authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "app_id", "app_slug", "installation_id", "bot_user_id", "bot_login", + "admission_environment", "effect_environment", + "tag_creation_ruleset", "tag_immutability_ruleset" + ], + "properties": { + "app_id": {"const": "4730708"}, + "app_slug": {"const": "openadapt-release"}, + "installation_id": {"const": "156835568"}, + "bot_user_id": {"const": "321543906"}, + "bot_login": {"const": "openadapt-release[bot]"}, + "admission_environment": {"const": "support-release-admission"}, + "effect_environment": {"const": "release-identity"}, + "tag_creation_ruleset": {"const": "OpenAdapt policy: release tag creation"}, + "tag_immutability_ruleset": {"const": "OpenAdapt policy: immutable release tags"} + } + }, + "targets": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", "repository", "repository_id", "claim_scope", "release_kind", + "tag_pattern", "artifacts" + ], + "properties": { + "id": {"const": "openadapt-tray"}, + "repository": {"const": "OpenAdaptAI/openadapt-tray"}, + "repository_id": {"const": "1136122737"}, + "claim_scope": {"const": "support_openadapt_tray"}, + "release_kind": {"const": "package"}, + "tag_pattern": {"const": "refs/tags/v*"}, + "artifacts": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "media_type", "publish_destinations"], + "properties": { + "kind": {"const": "python-sdist"}, + "media_type": {"const": "application/gzip"}, + "publish_destinations": {"const": ["github-release", "pypi"]} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "media_type", "publish_destinations"], + "properties": { + "kind": {"const": "python-wheel"}, + "media_type": {"const": "application/zip"}, + "publish_destinations": {"const": ["github-release", "pypi"]} + } + } + ], + "items": false + } + } + } + } + } +} diff --git a/scripts/production_trust.py b/scripts/production_trust.py index e9c7443..64a1427 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -15,6 +15,9 @@ import validate_evidence_registry as evidence +ROOT = Path(__file__).resolve().parents[1] +SUPPORT_POLICY_PATH = ROOT / "support-release-policy.json" + DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") RAW_DIGEST = re.compile(r"^[0-9a-f]{64}$") HEX40 = re.compile(r"^[0-9a-f]{40}$") @@ -27,6 +30,10 @@ r"^(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\." r"(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" ) +RELEASE_TAG = re.compile( + r"^v(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\." + r"(0|[1-9][0-9]{0,9})(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$" +) TARGETS = ("agent", "capture", "cloud", "desktop", "docs", "flow", "openadapt") TARGET_CONTRACTS = { @@ -216,6 +223,7 @@ b"OpenAdapt Support release artifact inventory v1\0" ) SUPPORT_RELEASE_ADMISSION_DOMAIN = b"OpenAdapt Support release admission v1\0" +SUPPORT_POLICY_DOMAIN = b"OpenAdapt Support release policy v1\0" CLOUD_AUTHORIZATION_DOMAIN = b"OpenAdapt production Cloud deploy authorization v1\0" CLOUD_HANDOFF_DOMAIN = b"OpenAdapt production Cloud deployment handoff v1\0" CLOUD_SOURCE_PROOF_REQUEST_DOMAIN = ( @@ -811,6 +819,54 @@ def validate_support_release( for field in fields: if field.endswith("_sha256"): require_digest(admission[field], field) + try: + support_policy = json.loads(SUPPORT_POLICY_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise TrustError("Support release policy is unavailable") from exc + expected_support_policy = { + "$schema": "schemas/support-release-policy.schema.json", + "schema_version": "openadapt.support-release-policy/v1", + "lifecycle_state": "Support", + "production_projection": False, + "maximum_admission_days": 7, + "release_authority": { + "app_id": "4730708", + "app_slug": "openadapt-release", + "installation_id": "156835568", + "bot_user_id": "321543906", + "bot_login": "openadapt-release[bot]", + "admission_environment": "support-release-admission", + "effect_environment": "release-identity", + "tag_creation_ruleset": "OpenAdapt policy: release tag creation", + "tag_immutability_ruleset": "OpenAdapt policy: immutable release tags", + }, + "targets": [ + { + "id": "openadapt-tray", + "repository": "OpenAdaptAI/openadapt-tray", + "repository_id": "1136122737", + "claim_scope": "support_openadapt_tray", + "release_kind": "package", + "tag_pattern": "refs/tags/v*", + "artifacts": [ + { + "kind": "python-sdist", + "media_type": "application/gzip", + "publish_destinations": ["github-release", "pypi"], + }, + { + "kind": "python-wheel", + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + }, + ], + } + ], + } + if support_policy != expected_support_policy or admission[ + "support_policy_sha256" + ] != digest_bytes(SUPPORT_POLICY_DOMAIN, support_policy): + raise TrustError("Support release policy binding differs") identity = closed( admission["release_identity"], {"schema_version", "channel", "sequence", "previous_admission_sha256"}, @@ -996,7 +1052,7 @@ def validate_publication_recovery_authorization( raise TrustError("publication recovery release reference differs") if ( not isinstance(authorization["tag"], str) - or not authorization["tag"].startswith("v") + or RELEASE_TAG.fullmatch(authorization["tag"]) is None or authorization["tag_ref"] != f"refs/tags/{authorization['tag']}" or not isinstance(authorization["tag_object_id"], str) or HEX40.fullmatch(authorization["tag_object_id"]) is None @@ -1121,6 +1177,7 @@ def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, An fields = { "schema_version", "decision_identity_sha256", "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", + "evidence_manifest_readback_sha256", "campaign_artifact_sha256", "organization_id_sha256", "workflow_id_sha256", "workflow_version_id_sha256", "bundle_version", "bundle_sha256", "admitted_runtime_sha256", "application_contract_sha256", @@ -1144,7 +1201,14 @@ def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, An } for field in digest_fields: require_digest(receipt[field], field) - if len({receipt["decision_commitment_sha256"], receipt["evidence_manifest_sha256"], receipt["campaign_artifact_sha256"]}) != 3: + if len( + { + receipt["decision_commitment_sha256"], + receipt["evidence_manifest_sha256"], + receipt["evidence_manifest_readback_sha256"], + receipt["campaign_artifact_sha256"], + } + ) != 4: raise TrustError("decision, final manifest, and campaign commitments must be distinct") require_positive_int(receipt["decision_revision"], "decision revision") if ( @@ -1553,6 +1617,13 @@ def validate_admission_current_state( or reference["subject_sha256"] is not None or reference["object_sha256"] != "sha256:" + hashlib.sha256(canonical(admission) + b"\n").hexdigest() + or reference["semantic_identity_sha256"] + != evidence.semantic_identity_digest( + kind=expected_kind, + object_schema_version=reference["object_schema_version"], + object_value=admission, + object_sha256=reference["object_sha256"], + ) ): raise TrustError("admission reference differs from the signed object") try: diff --git a/support-release-policy.json b/support-release-policy.json new file mode 100644 index 0000000..2e0d206 --- /dev/null +++ b/support-release-policy.json @@ -0,0 +1 @@ +{"$schema":"schemas/support-release-policy.schema.json","schema_version":"openadapt.support-release-policy/v1","lifecycle_state":"Support","production_projection":false,"maximum_admission_days":7,"release_authority":{"app_id":"4730708","app_slug":"openadapt-release","installation_id":"156835568","bot_user_id":"321543906","bot_login":"openadapt-release[bot]","admission_environment":"support-release-admission","effect_environment":"release-identity","tag_creation_ruleset":"OpenAdapt policy: release tag creation","tag_immutability_ruleset":"OpenAdapt policy: immutable release tags"},"targets":[{"id":"openadapt-tray","repository":"OpenAdaptAI/openadapt-tray","repository_id":"1136122737","claim_scope":"support_openadapt_tray","release_kind":"package","tag_pattern":"refs/tags/v*","artifacts":[{"kind":"python-sdist","media_type":"application/gzip","publish_destinations":["github-release","pypi"]},{"kind":"python-wheel","media_type":"application/zip","publish_destinations":["github-release","pypi"]}]}]} diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 87efc08..62c870d 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -13,6 +13,28 @@ EXPECTED_TOP_LEVEL_FIELDS = { + "support-release-policy.schema.json": { + "$schema", + "schema_version", + "lifecycle_state", + "production_projection", + "maximum_admission_days", + "release_authority", + "targets", + }, + "qualification-trial-receipt-authority-policy.schema.json": { + "$schema", + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "workflow", + "ref", + "trigger", + "input", + "signing", + "authorities", + }, "support-release-admission.schema.json": { "schema_version", "admission_id_sha256", @@ -337,6 +359,7 @@ "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", + "evidence_manifest_readback_sha256", "organization_id_sha256", "workflow_id_sha256", "workflow_version_id_sha256", @@ -483,6 +506,33 @@ def test_verification_policy_freezes_both_sigstore_profiles(self) -> None: self.assertEqual(message["runner"], "ubuntu-24.04") self.assertEqual(message["architecture"], "linux-amd64") + def test_support_policy_cannot_enter_the_production_projection(self) -> None: + policy = json.loads((ROOT / "support-release-policy.json").read_text()) + self.assertEqual(policy["lifecycle_state"], "Support") + self.assertIs(policy["production_projection"], False) + self.assertEqual([item["id"] for item in policy["targets"]], ["openadapt-tray"]) + production_targets = { + "openadapt", "flow", "cloud", "desktop", "capture", "agent", "docs" + } + self.assertTrue(production_targets.isdisjoint({"openadapt-tray"})) + + def test_trial_receipt_authorities_are_fixed_per_receipt_type(self) -> None: + policy = json.loads( + (ROOT / "qualification-trial-receipt-authority-policy.json").read_text() + ) + self.assertEqual(policy["repository"], "OpenAdaptAI/openadapt-evals") + self.assertEqual(policy["workflow"], ".github/workflows/issue-qualification-trial-receipts.yml") + self.assertEqual(policy["trigger"], "workflow_dispatch") + self.assertEqual(len(policy["authorities"]), 8) + self.assertEqual( + len({item["environment"] for item in policy["authorities"]}), 8 + ) + for item in policy["authorities"]: + self.assertEqual( + item["runner_labels"], + ["self-hosted", "evidence-authority", item["environment"]], + ) + if __name__ == "__main__": unittest.main() From e5fc894ce910acbb78f69c0f0370dfb3dd76c64c Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:46:44 -0400 Subject: [PATCH 06/18] test: cover support release and recovery trust --- ..._support_release_and_recovery_contracts.py | 1063 +++++++++++++++++ 1 file changed, 1063 insertions(+) create mode 100644 tests/test_support_release_and_recovery_contracts.py diff --git a/tests/test_support_release_and_recovery_contracts.py b/tests/test_support_release_and_recovery_contracts.py new file mode 100644 index 0000000..e52af88 --- /dev/null +++ b/tests/test_support_release_and_recovery_contracts.py @@ -0,0 +1,1063 @@ +"""Focused fail-closed tests for Support releases and publication recovery.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import production_trust as trust # noqa: E402 +import validate_evidence_registry as registry # noqa: E402 + + +NOW = datetime(2026, 8, 27, 12, 4, 59, tzinfo=timezone.utc) + + +def sha(label: str) -> str: + return "sha256:" + hashlib.sha256(label.encode("utf-8")).hexdigest() + + +def evidence_entry( + kind: str, + *, + label: str, + subject_sha256: str | None = None, +) -> dict: + schema, media = registry.OBJECT_KIND_CONTRACTS[kind] + object_sha256 = sha(f"object:{label}") + digest_hex = object_sha256.removeprefix("sha256:") + entry = { + "kind": kind, + "object_schema_version": schema, + "object_path": ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ), + "object_sha256": object_sha256, + "size_bytes": 100, + "object_media_type": media, + "semantic_identity_sha256": sha(f"identity:{label}"), + "subject_sha256": subject_sha256, + } + entry["registry_entry_sha256"] = registry.entry_digest(entry) + return entry + + +def evidence_reference( + kind: str = "qualification-release", + *, + object_sha256: str | None = None, + object_value: object | None = None, +) -> dict: + entry = evidence_entry(kind, label=f"reference:{kind}") + if object_sha256 is not None: + entry["object_sha256"] = object_sha256 + digest_hex = object_sha256.removeprefix("sha256:") + entry["object_path"] = ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ) + if object_value is not None: + entry["semantic_identity_sha256"] = registry.semantic_identity_digest( + kind=kind, + object_schema_version=entry["object_schema_version"], + object_value=object_value, + object_sha256=object_sha256, + ) + entry["registry_entry_sha256"] = registry.entry_digest(entry) + return { + "schema_version": registry.REFERENCE_SCHEMA, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "registry_source_commit": "a" * 40, + "registry_revision": 3, + "registry_head_sha256": sha("registry-head"), + **entry, + } + + +def tag_rulesets( + repository: str = "OpenAdaptAI/openadapt-tray", + repository_id: str = "1136122737", +) -> list[dict]: + common = { + "schema_version": "openadapt.production-release-tag-ruleset/v1", + "repository": repository, + "repository_id": repository_id, + "target": "tag", + "enforcement": "active", + "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + } + return [ + { + **common, + "role": "creation_authority", + "ruleset_id": "10", + "name": "OpenAdapt policy: release tag creation", + "bypass_actors": [ + { + "actor_id": "4730708", + "actor_type": "Integration", + "bypass_mode": "always", + } + ], + "rules": [{"type": "creation"}], + }, + { + **common, + "role": "immutability", + "ruleset_id": "11", + "name": "OpenAdapt policy: immutable release tags", + "bypass_actors": [], + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "update", + "parameters": {"update_allows_fetch_and_merge": False}, + }, + ], + }, + ] + + +def support_artifacts() -> list[dict]: + return [ + { + "name": "openadapt_tray-1.0.0.tar.gz", + "kind": "python-sdist", + "sha256": sha("tray-sdist"), + "size_bytes": 101, + "media_type": "application/gzip", + "publish_destinations": ["github-release", "pypi"], + }, + { + "name": "openadapt_tray-1.0.0-py3-none-any.whl", + "kind": "python-wheel", + "sha256": sha("tray-wheel"), + "size_bytes": 102, + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + }, + ] + + +def refresh_staging(staging: dict) -> None: + staging["immutable_releases_sha256"] = trust.digest_bytes( + trust.IMMUTABLE_RELEASES_DOMAIN, staging["immutable_releases"] + ) + staging["tag_rulesets_sha256"] = trust.digest_bytes( + trust.TAG_RULESETS_DOMAIN, staging["tag_rulesets"] + ) + staging["tag_ref_state_sha256"] = trust.digest_bytes( + trust.TAG_REF_STATE_DOMAIN, staging["tag_ref_state"] + ) + + +def refresh_support_admission(admission: dict) -> None: + release = admission["release"] + admission["release_sha256"] = trust.digest_bytes( + trust.SUPPORT_RELEASE_DOMAIN, + { + "support_target": admission["support_target"], + "claim_scope": admission["claim_scope"], + "release": release, + }, + ) + admission["artifact_inventory_sha256"] = trust.digest_bytes( + trust.SUPPORT_ARTIFACT_INVENTORY_DOMAIN, + { + "support_target": admission["support_target"], + "claim_scope": admission["claim_scope"], + "artifacts": release["artifacts"], + }, + ) + refresh_staging(admission["publication_staging"]) + admission["publication_staging_sha256"] = trust.staging_digest( + admission["publication_staging"] + ) + projection = dict(admission) + projection.pop("admission_id_sha256", None) + admission["admission_id_sha256"] = trust.digest_bytes( + trust.SUPPORT_RELEASE_ADMISSION_DOMAIN, projection + ) + + +def support_admission() -> dict: + artifacts = support_artifacts() + staged_assets = [ + { + "asset_id": str(30 + index), + **artifact, + "uploader_id": "321543906", + "uploader_login": "openadapt-release[bot]", + } + for index, artifact in enumerate( + sorted(artifacts, key=lambda item: item["name"]) + ) + ] + staging = { + "schema_version": "openadapt.production-release-staging-evidence/v1", + "repository": "OpenAdaptAI/openadapt-tray", + "repository_id": "1136122737", + "draft_release_id": "20", + "tag": "v1.0.0", + "target_commitish": "b" * 40, + "draft": True, + "prerelease": False, + "release_app_id": "4730708", + "release_app_installation_id": "156835568", + "release_app_bot_user_id": "321543906", + "release_author_login": "openadapt-release[bot]", + "assets": staged_assets, + "immutable_releases": {"enabled": True, "enforced_by_owner": False}, + "immutable_releases_sha256": sha("placeholder"), + "tag_rulesets": tag_rulesets(), + "tag_rulesets_sha256": sha("placeholder"), + "tag_ref_state": {"ref": "refs/tags/v1.0.0", "exists": False}, + "tag_ref_state_sha256": sha("placeholder"), + "observed_at": "2026-08-27T12:00:00Z", + } + release = { + "schema_version": "openadapt.production-release-candidate/v1", + "kind": "package", + "source_repository": "OpenAdaptAI/openadapt-tray", + "source_repository_id": "1136122737", + "source_commit": "b" * 40, + "version": "1.0.0", + "tag": "v1.0.0", + "deployment_id": None, + "deployment_sha256": None, + "artifacts": artifacts, + } + admission = { + "schema_version": "openadapt.support-release-admission/v1", + "admission_id_sha256": sha("placeholder"), + "lifecycle_state": "Support", + "support_target": "openadapt-tray", + "verdict": "accepted", + "claim_scope": "support_openadapt_tray", + "release_identity": { + "schema_version": "openadapt.monotonic-support-release/v1", + "channel": "support", + "sequence": 1, + "previous_admission_sha256": None, + }, + "release": release, + "release_sha256": sha("placeholder"), + "artifact_inventory_sha256": sha("placeholder"), + "publication_staging": staging, + "publication_staging_sha256": sha("placeholder"), + "authority_state_sha256": sha("authority-current"), + "revocation_state_sha256": sha("revocation-current"), + "signer_registry_sha256": sha("signer-current"), + "support_policy_sha256": trust.digest_bytes( + trust.SUPPORT_POLICY_DOMAIN, + json.loads((ROOT / "support-release-policy.json").read_text()), + ), + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-09-03T12:00:00Z", + "issuer": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-support-release-admission.yml", + "ref": "refs/heads/main", + "source_commit": "c" * 40, + "environment": "support-release-admission", + }, + } + refresh_support_admission(admission) + return admission + + +def refresh_recovery_authorization(authorization: dict) -> None: + projection = { + "qualification_release_object_sha256": authorization[ + "qualification_release_object_sha256" + ], + "repository": authorization["repository"], + "repository_id": authorization["repository_id"], + "target": authorization["target"], + "tag": authorization["tag"], + "tag_ref": authorization["tag_ref"], + "tag_object_id": authorization["tag_object_id"], + "target_commit": authorization["target_commit"], + "draft_release_id": authorization["draft_release_id"], + "release_sha256": authorization["release_sha256"], + "artifact_inventory_sha256": authorization["artifact_inventory_sha256"], + "publication_staging_sha256": authorization["publication_staging_sha256"], + "run_id": authorization["run_id"], + "run_attempt": authorization["run_attempt"], + "dispatcher_actor_id": authorization["dispatcher_actor_id"], + "requested_effect": authorization["requested_effect"], + "environment": authorization["environment"], + "release_app": authorization["release_app"], + } + authorization["idempotency_key"] = "publication-recovery:" + hashlib.sha256( + trust.PUBLICATION_RECOVERY_IDEMPOTENCY_DOMAIN + trust.canonical(projection) + ).hexdigest() + identity_projection = dict(authorization) + identity_projection.pop("authorization_id_sha256", None) + authorization["authorization_id_sha256"] = trust.digest_bytes( + trust.PUBLICATION_RECOVERY_AUTHORIZATION_DOMAIN, identity_projection + ) + + +def recovery_authorization() -> dict: + release_reference = evidence_reference() + authorization = { + "schema_version": ( + "openadapt.production-publication-recovery-authorization/v1" + ), + "authorization_id_sha256": sha("placeholder"), + "qualification_release_reference": release_reference, + "qualification_release_object_sha256": release_reference["object_sha256"], + "release_sha256": sha("capture-release"), + "artifact_inventory_sha256": sha("capture-inventory"), + "publication_staging_sha256": sha("capture-staging"), + "repository": "OpenAdaptAI/openadapt-capture", + "repository_id": "1115283835", + "target": "capture", + "tag": "v1.0.0", + "tag_ref": "refs/tags/v1.0.0", + "tag_object_id": "d" * 40, + "target_commit": "b" * 40, + "draft_release_id": "20", + "requested_effect": "publish-github-release", + "run_id": "100", + "run_attempt": "1", + "dispatcher_actor_id": "774615", + "environment": "release-identity", + "release_app": { + "app_id": "4730708", + "installation_id": "156835568", + "bot_user_id": "321543906", + "bot_login": "openadapt-release[bot]", + }, + "idempotency_key": "publication-recovery:" + "0" * 64, + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-27T12:05:00Z", + "issuer": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ( + ".github/workflows/issue-production-publication-recovery.yml" + ), + "ref": "refs/heads/main", + "source_commit": "c" * 40, + "environment": "release-identity", + }, + } + refresh_recovery_authorization(authorization) + return authorization + + +class SupportReleaseAdmissionTests(unittest.TestCase): + def test_exact_tray_wheel_and_sdist_app_staged_draft_is_valid(self) -> None: + value = support_admission() + self.assertEqual(trust.validate_support_release(value, now=NOW), value) + self.assertEqual( + [item["kind"] for item in value["release"]["artifacts"]], + ["python-sdist", "python-wheel"], + ) + self.assertTrue(value["publication_staging"]["draft"]) + self.assertEqual( + { + item["uploader_login"] + for item in value["publication_staging"]["assets"] + }, + {"openadapt-release[bot]"}, + ) + + def test_support_is_not_an_eighth_production_target(self) -> None: + self.assertNotIn("openadapt-tray", trust.TARGETS) + value = support_admission() + value.pop("lifecycle_state") + value.pop("support_target") + value["schema_version"] = "openadapt.qualification-release/v1" + value["target"] = "openadapt-tray" + value["production_acceptance_summary_reference"] = evidence_reference( + "production-acceptance-summary" + ) + summary_bundle = evidence_reference( + "production-acceptance-summary-sigstore-bundle" + ) + summary_bundle["subject_sha256"] = value[ + "production_acceptance_summary_reference" + ]["object_sha256"] + projection = { + field: summary_bundle[field] for field in registry.ENTRY_FIELDS + } + summary_bundle["registry_entry_sha256"] = registry.entry_digest(projection) + value["production_acceptance_summary_bundle_reference"] = summary_bundle + value["publication_policy_sha256"] = value.pop("support_policy_sha256") + with self.assertRaisesRegex(trust.TrustError, "target is invalid"): + trust.validate_release(value) + + def test_missing_extra_and_cross_profile_artifacts_are_refused(self) -> None: + cases: list[tuple[str, object]] = [ + ( + "missing wheel", + lambda value: value["release"]["artifacts"].pop(), + ), + ( + "extra wheel", + lambda value: value["release"]["artifacts"].append( + { + "name": "extra.whl", + "kind": "python-wheel", + "sha256": sha("extra"), + "size_bytes": 1, + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + } + ), + ), + ( + "wheel media", + lambda value: value["release"]["artifacts"][1].__setitem__( + "media_type", "application/gzip" + ), + ), + ( + "sdist destination", + lambda value: value["release"]["artifacts"][0].__setitem__( + "publish_destinations", ["github-release"] + ), + ), + ( + "foreign kind", + lambda value: value["release"]["artifacts"][0].__setitem__( + "kind", "spdx-sbom" + ), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = support_admission() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_support_release(value) + + def test_staged_inventory_must_exactly_match_admitted_inventory(self) -> None: + value = support_admission() + value["publication_staging"]["assets"][0]["sha256"] = sha( + "other-staged-bytes" + ) + refresh_support_admission(value) + with self.assertRaisesRegex(trust.TrustError, "staged assets differ"): + trust.validate_support_release(value) + + def test_wrong_app_ruleset_tag_and_staging_state_are_refused(self) -> None: + cases: list[tuple[str, object]] = [ + ( + "release App", + lambda value: value["publication_staging"].__setitem__( + "release_app_id", "1" + ), + ), + ( + "asset uploader", + lambda value: value["publication_staging"]["assets"][0].__setitem__( + "uploader_id", "1" + ), + ), + ( + "creation ruleset", + lambda value: value["publication_staging"]["tag_rulesets"][0][ + "bypass_actors" + ][0].__setitem__("actor_id", "1"), + ), + ( + "immutability ruleset", + lambda value: value["publication_staging"]["tag_rulesets"][1][ + "rules" + ].pop(), + ), + ( + "existing tag", + lambda value: value["publication_staging"]["tag_ref_state"].__setitem__( + "exists", True + ), + ), + ( + "immutable releases disabled", + lambda value: value["publication_staging"][ + "immutable_releases" + ].__setitem__("enabled", False), + ), + ( + "not a draft", + lambda value: value["publication_staging"].__setitem__( + "draft", False + ), + ), + ( + "prerelease", + lambda value: value["publication_staging"].__setitem__( + "prerelease", True + ), + ), + ( + "wrong repository", + lambda value: value["publication_staging"].__setitem__( + "repository", "OpenAdaptAI/openadapt-flow" + ), + ), + ( + "wrong target commit", + lambda value: value["publication_staging"].__setitem__( + "target_commitish", "d" * 40 + ), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = support_admission() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_support_release(value) + + def test_window_is_half_open_and_limited_to_seven_days(self) -> None: + value = support_admission() + trust.validate_support_release( + value, + now=datetime(2026, 8, 27, 12, 0, 0, tzinfo=timezone.utc), + ) + with self.assertRaisesRegex(trust.TrustError, "not active"): + trust.validate_support_release( + value, + now=datetime(2026, 9, 3, 12, 0, 0, tzinfo=timezone.utc), + ) + value["expires_at"] = "2026-09-03T12:00:01Z" + refresh_support_admission(value) + with self.assertRaisesRegex(trust.TrustError, "validity window"): + trust.validate_support_release(value) + + def test_signer_and_revocation_digests_are_admission_identity_bound(self) -> None: + for field in ( + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + ): + with self.subTest(field=field): + value = support_admission() + value[field] = sha(f"changed:{field}") + with self.assertRaisesRegex(trust.TrustError, "admission id"): + trust.validate_support_release(value) + + def test_support_release_requires_exact_current_trust_state(self) -> None: + value = support_admission() + signer_registry = { + "schema_version": "test-signer-registry", + "revision": 7, + "signers": [ + { + "status": "active", + "public_key_sha256": sha("current-signer-key"), + } + ], + } + signer_identity = registry.signer_registry_identity_digest(signer_registry) + signer_raw_sha = "sha256:" + hashlib.sha256( + registry.canonical(signer_registry) + b"\n" + ).hexdigest() + authority = { + "authority_state_sha256": sha("current-authority"), + "signer_registry_sha256": signer_raw_sha, + "signer_registry_identity_sha256": signer_identity, + "signer_registry_revision": 7, + } + revocation = { + "revocation_state_sha256": sha("current-revocation"), + "authority_state_sha256": authority["authority_state_sha256"], + "signer_registry_sha256": signer_identity, + "revocations": [], + } + value["authority_state_sha256"] = authority["authority_state_sha256"] + value["revocation_state_sha256"] = revocation[ + "revocation_state_sha256" + ] + value["signer_registry_sha256"] = signer_identity + refresh_support_admission(value) + object_sha256 = "sha256:" + hashlib.sha256( + trust.canonical(value) + b"\n" + ).hexdigest() + admission_reference = evidence_reference( + "support-release-admission", + object_sha256=object_sha256, + object_value=value, + ) + patches = ( + mock.patch.object( + registry, "validate_signer_registry", return_value=signer_registry + ), + mock.patch.object( + trust, "validate_authority_state", return_value=authority + ), + mock.patch.object( + trust, "validate_revocation_state", return_value=revocation + ), + mock.patch.object(trust, "verify_embedded_signature"), + ) + with patches[0], patches[1], patches[2], patches[3]: + self.assertEqual( + trust.validate_admission_current_state( + value, + admission_reference=admission_reference, + authority_state=authority, + revocation_state=revocation, + signer_registry=signer_registry, + now=NOW, + ), + value, + ) + for field in ( + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + ): + with self.subTest(field=field): + changed = copy.deepcopy(value) + changed[field] = sha(f"not-current:{field}") + refresh_support_admission(changed) + changed_sha = "sha256:" + hashlib.sha256( + trust.canonical(changed) + b"\n" + ).hexdigest() + changed_reference = evidence_reference( + "support-release-admission", + object_sha256=changed_sha, + object_value=changed, + ) + with self.assertRaisesRegex(trust.TrustError, "current"): + trust.validate_admission_current_state( + changed, + admission_reference=changed_reference, + authority_state=authority, + revocation_state=revocation, + signer_registry=signer_registry, + now=NOW, + ) + + changed_identity_reference = copy.deepcopy(admission_reference) + changed_identity_reference["semantic_identity_sha256"] = sha( + "unbound-admission-identity" + ) + projection = { + field: changed_identity_reference[field] + for field in registry.ENTRY_FIELDS + } + changed_identity_reference["registry_entry_sha256"] = ( + registry.entry_digest(projection) + ) + with self.assertRaisesRegex( + trust.TrustError, "semantic identity|reference differs" + ): + trust.validate_admission_current_state( + value, + admission_reference=changed_identity_reference, + authority_state=authority, + revocation_state=revocation, + signer_registry=signer_registry, + now=NOW, + ) + + revocation["revocations"] = [ + { + "subject_kind": "support-release-admission", + "subject_id": admission_reference[ + "semantic_identity_sha256" + ], + } + ] + with self.assertRaisesRegex(trust.TrustError, "admission is revoked"): + trust.validate_admission_current_state( + value, + admission_reference=admission_reference, + authority_state=authority, + revocation_state=revocation, + signer_registry=signer_registry, + now=NOW, + ) + revocation["revocations"] = [ + { + "subject_kind": "qualification-signer-key", + "subject_id": signer_registry["signers"][0][ + "public_key_sha256" + ], + } + ] + with self.assertRaisesRegex(trust.TrustError, "signer key is revoked"): + trust.validate_admission_current_state( + value, + admission_reference=admission_reference, + authority_state=authority, + revocation_state=revocation, + signer_registry=signer_registry, + now=NOW, + ) + + def test_support_release_refuses_stale_staging_observation(self) -> None: + boundary = support_admission() + boundary["publication_staging"]["observed_at"] = "2026-08-27T11:00:00Z" + refresh_support_admission(boundary) + trust.validate_support_release(boundary, now=NOW) + + for observed_at in ( + "2026-08-27T10:59:59Z", + "2026-08-27T12:00:01Z", + ): + with self.subTest(observed_at=observed_at): + value = support_admission() + value["publication_staging"]["observed_at"] = observed_at + refresh_support_admission(value) + with self.assertRaisesRegex( + trust.TrustError, "staging observation.*stale" + ): + trust.validate_support_release(value, now=NOW) + + +class PublicationRecoveryAuthorizationTests(unittest.TestCase): + def test_exact_five_minute_single_attempt_authorization_is_valid(self) -> None: + value = recovery_authorization() + self.assertEqual( + trust.validate_publication_recovery_authorization(value, now=NOW), + value, + ) + + def test_five_minute_window_is_half_open_and_cannot_be_longer(self) -> None: + value = recovery_authorization() + with self.assertRaisesRegex(trust.TrustError, "not active"): + trust.validate_publication_recovery_authorization( + value, + now=datetime(2026, 8, 27, 12, 5, 0, tzinfo=timezone.utc), + ) + value["expires_at"] = "2026-08-27T12:05:01Z" + refresh_recovery_authorization(value) + with self.assertRaisesRegex(trust.TrustError, "validity window"): + trust.validate_publication_recovery_authorization(value) + + def test_attempt_dispatcher_environment_and_issuer_are_exact(self) -> None: + cases: list[tuple[str, object]] = [ + ("attempt", lambda value: value.__setitem__("run_attempt", "2")), + ( + "dispatcher", + lambda value: value.__setitem__("dispatcher_actor_id", "1"), + ), + ( + "environment", + lambda value: value.__setitem__("environment", "pypi"), + ), + ( + "issuer workflow", + lambda value: value["issuer"].__setitem__( + "workflow", ".github/workflows/release.yml" + ), + ), + ( + "issuer environment", + lambda value: value["issuer"].__setitem__("environment", "pypi"), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = recovery_authorization() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_publication_recovery_authorization(value) + + def test_release_app_identity_is_exact(self) -> None: + for field in ("app_id", "installation_id", "bot_user_id", "bot_login"): + with self.subTest(field=field): + value = recovery_authorization() + value["release_app"][field] = "1" + with self.assertRaisesRegex(trust.TrustError, "release App"): + trust.validate_publication_recovery_authorization(value) + + def test_repository_target_and_release_reference_profiles_are_exact(self) -> None: + cases: list[tuple[str, object]] = [ + ( + "repository", + lambda value: value.__setitem__( + "repository", "OpenAdaptAI/openadapt-flow" + ), + ), + ( + "repository id", + lambda value: value.__setitem__("repository_id", "1"), + ), + ("deployment target", lambda value: value.__setitem__("target", "cloud")), + ( + "reference kind", + lambda value: value.__setitem__( + "qualification_release_reference", + evidence_reference("support-release-admission"), + ), + ), + ( + "reference object", + lambda value: value.__setitem__( + "qualification_release_object_sha256", sha("other-release") + ), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = recovery_authorization() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_publication_recovery_authorization(value) + + def test_tag_and_staging_bindings_are_closed(self) -> None: + cases: list[tuple[str, object]] = [ + ("tag", lambda value: value.__setitem__("tag", "1.0.0")), + ( + "tag ref", + lambda value: value.__setitem__("tag_ref", "refs/tags/v2.0.0"), + ), + ( + "tag object", + lambda value: value.__setitem__("tag_object_id", "main"), + ), + ( + "target commit", + lambda value: value.__setitem__("target_commit", "main"), + ), + ( + "draft release", + lambda value: value.__setitem__("draft_release_id", "0"), + ), + ( + "staging digest", + lambda value: value.__setitem__( + "publication_staging_sha256", "not-a-digest" + ), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = recovery_authorization() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_publication_recovery_authorization(value) + + malformed = recovery_authorization() + malformed["tag"] = "vbad/path" + malformed["tag_ref"] = "refs/tags/vbad/path" + refresh_recovery_authorization(malformed) + with self.assertRaisesRegex(trust.TrustError, "tag"): + trust.validate_publication_recovery_authorization(malformed) + + def test_effect_environment_and_idempotency_key_are_exact(self) -> None: + cases: list[tuple[str, object]] = [ + ("empty", lambda value: value.__setitem__("requested_effect", "")), + ( + "unknown", + lambda value: value.__setitem__("requested_effect", "docker"), + ), + ( + "PyPI environment", + lambda value: ( + value.__setitem__("requested_effect", "publish-pypi"), + value.__setitem__("environment", "release-identity"), + ), + ), + ( + "MCP environment", + lambda value: ( + value.__setitem__("target", "agent"), + value.__setitem__("repository", "OpenAdaptAI/openadapt-agent"), + value.__setitem__("repository_id", "1136136670"), + value.__setitem__("requested_effect", "publish-mcp-registry"), + value.__setitem__("environment", "release-identity"), + ), + ), + ( + "idempotency mismatch", + lambda value: value.__setitem__( + "idempotency_key", "publication-recovery:" + "0" * 64 + ), + ), + ] + for label, mutate in cases: + with self.subTest(label=label): + value = recovery_authorization() + mutate(value) + with self.assertRaises(trust.TrustError): + trust.validate_publication_recovery_authorization(value) + + def test_each_single_effect_has_one_exact_environment(self) -> None: + profiles = { + "stage-draft-assets": "release-identity", + "publish-pypi": "pypi", + "publish-github-release": "release-identity", + } + for effect, environment in profiles.items(): + with self.subTest(effect=effect): + value = recovery_authorization() + value["requested_effect"] = effect + value["environment"] = environment + refresh_recovery_authorization(value) + trust.validate_publication_recovery_authorization(value, now=NOW) + + agent = recovery_authorization() + agent["target"] = "agent" + agent["repository"] = "OpenAdaptAI/openadapt-agent" + agent["repository_id"] = "1136136670" + agent["requested_effect"] = "publish-mcp-registry" + agent["environment"] = "mcp-registry" + refresh_recovery_authorization(agent) + trust.validate_publication_recovery_authorization(agent, now=NOW) + + def test_replay_key_binds_every_effect_critical_recovery_field(self) -> None: + first = recovery_authorization() + mutations: list[tuple[str, object]] = [ + ("target commit", lambda value: value.__setitem__("target_commit", "e" * 40)), + ("draft", lambda value: value.__setitem__("draft_release_id", "21")), + ( + "release", + lambda value: value.__setitem__( + "release_sha256", sha("different-release") + ), + ), + ( + "inventory", + lambda value: value.__setitem__( + "artifact_inventory_sha256", sha("different-inventory") + ), + ), + ( + "staging", + lambda value: value.__setitem__( + "publication_staging_sha256", sha("different-staging") + ), + ), + ("effect", lambda value: value.__setitem__("requested_effect", "stage-draft-assets")), + ("App", lambda value: value["release_app"].__setitem__("installation_id", "1")), + ] + for label, mutate in mutations: + with self.subTest(label=label): + second = copy.deepcopy(first) + mutate(second) + refresh_recovery_authorization(second) + self.assertNotEqual(first["idempotency_key"], second["idempotency_key"]) + + def test_replay_accepts_exact_bytes_and_refuses_one_use_conflict(self) -> None: + first = recovery_authorization() + self.assertEqual( + trust.validate_publication_recovery_replay(first, copy.deepcopy(first)), + first, + ) + conflict = copy.deepcopy(first) + conflict["draft_release_id"] = "21" + refresh_recovery_authorization(conflict) + with self.assertRaisesRegex(trust.TrustError, "one-use claim conflicts"): + trust.validate_publication_recovery_replay(first, conflict) + + next_tag = copy.deepcopy(first) + next_tag["tag"] = "v1.0.1" + next_tag["tag_ref"] = "refs/tags/v1.0.1" + next_tag["tag_object_id"] = "e" * 40 + next_tag["target_commit"] = "e" * 40 + next_tag["draft_release_id"] = "21" + next_tag["run_id"] = "101" + refresh_recovery_authorization(next_tag) + self.assertEqual( + trust.validate_publication_recovery_replay(first, next_tag), next_tag + ) + + +class EvidenceKindProfileTests(unittest.TestCase): + def registry_document(self, entries: list[dict]) -> dict: + signer = { + "schema_version": registry.SIGNER_POINTER_SCHEMA, + "object_path": ( + "production-evidence/signer-registries/sha256/" + + sha("signer-object").removeprefix("sha256:")[:2] + + "/" + + sha("signer-object").removeprefix("sha256:") + + ".qualification-signer-registry.json" + ), + "object_sha256": sha("signer-object"), + "registry_identity_sha256": sha("signer-identity"), + "registry_revision": 1, + } + value = { + "$schema": "schemas/evidence-registry.schema.json", + "schema_version": registry.REGISTRY_SCHEMA, + "repository": registry.REPOSITORY, + "repository_id": registry.REPOSITORY_ID, + "repository_owner_id": registry.REPOSITORY_OWNER_ID, + "revision": 1, + "previous_registry_head_sha256": None, + "registry_head_sha256": sha("placeholder"), + "signer_registry": signer, + "signer_registry_history": [signer], + "entries": entries, + } + value["registry_head_sha256"] = registry.registry_head_digest(value) + return value + + def test_all_17_regular_kinds_have_adjacent_bound_bundle_profiles(self) -> None: + entries: list[dict] = [] + for kind in registry.REGULAR_KIND_CONTRACTS: + regular = evidence_entry(kind, label=f"regular:{kind}") + bundle = evidence_entry( + f"{kind}-sigstore-bundle", + label=f"bundle:{kind}", + subject_sha256=regular["object_sha256"], + ) + entries.extend((regular, bundle)) + self.assertEqual(len(entries), 34) + self.assertEqual(registry.validate_registry(self.registry_document(entries)), entries) + + def test_profile_and_adjacency_substitution_are_refused_for_every_kind(self) -> None: + kinds = list(registry.REGULAR_KIND_CONTRACTS) + for index, kind in enumerate(kinds): + other_kind = kinds[(index + 1) % len(kinds)] + with self.subTest(kind=kind, substitution="regular profile"): + regular = evidence_entry(kind, label=f"regular:{kind}") + regular["object_schema_version"], regular["object_media_type"] = ( + registry.REGULAR_KIND_CONTRACTS[other_kind] + ) + regular["registry_entry_sha256"] = registry.entry_digest(regular) + bundle = evidence_entry( + f"{kind}-sigstore-bundle", + label=f"bundle:{kind}", + subject_sha256=regular["object_sha256"], + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "schema or media type" + ): + registry.validate_registry(self.registry_document([regular, bundle])) + with self.subTest(kind=kind, substitution="bundle adjacency"): + regular = evidence_entry(kind, label=f"regular:{kind}") + substituted_bundle = evidence_entry( + f"{other_kind}-sigstore-bundle", + label=f"substitute:{kind}", + subject_sha256=regular["object_sha256"], + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "immediately follow" + ): + registry.validate_registry( + self.registry_document([regular, substituted_bundle]) + ) + + +if __name__ == "__main__": + unittest.main() From 75e14b5e7ae1d96d85779c4e83737bb1b3239f1f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:53:45 -0400 Subject: [PATCH 07/18] feat: define worker admission authority --- ...ication-recovery-authorization.schema.json | 2 +- ...rker-admission-verifier-result.schema.json | 57 ++++++++ ...qualification-worker-admission.schema.json | 57 ++++++++ .../qualification-worker-dispatch.schema.json | 58 ++++++++ ...cation-worker-terminal-receipt.schema.json | 110 +++++++++++++++ scripts/production_trust.py | 17 ++- tests/test_public_trust_schemas.py | 127 ++++++++++++++++++ ..._support_release_and_recovery_contracts.py | 18 ++- 8 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 schemas/qualification-worker-admission-verifier-result.schema.json create mode 100644 schemas/qualification-worker-admission.schema.json create mode 100644 schemas/qualification-worker-dispatch.schema.json create mode 100644 schemas/qualification-worker-terminal-receipt.schema.json diff --git a/schemas/production-publication-recovery-authorization.schema.json b/schemas/production-publication-recovery-authorization.schema.json index 78c64d0..d1ff89f 100644 --- a/schemas/production-publication-recovery-authorization.schema.json +++ b/schemas/production-publication-recovery-authorization.schema.json @@ -106,7 +106,7 @@ "workflow": {"const": ".github/workflows/issue-production-publication-recovery.yml"}, "ref": {"const": "refs/heads/main"}, "source_commit": {"$ref": "#/$defs/commit"}, - "environment": {"const": "release-identity"} + "environment": {"enum": ["mcp-registry", "pypi", "release-identity"]} } } } diff --git a/schemas/qualification-worker-admission-verifier-result.schema.json b/schemas/qualification-worker-admission-verifier-result.schema.json new file mode 100644 index 0000000..9bd3d16 --- /dev/null +++ b/schemas/qualification-worker-admission-verifier-result.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-admission-verifier-result.schema.json", + "title": "OpenAdapt qualification worker admission verifier result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "verdict", "admission_object_sha256", + "provider_identity_sha256", "worker_identity_sha256", + "live_provider_observation_sha256", "admitted_runtime_sha256", + "worker_image_sha256", "baseline_sha256", "host_identity_sha256", + "tls_identity_sha256", "egress_policy_sha256", "campaign_permit_sha256", + "capability_handle_sha256", "authority_state_sha256", + "revocation_state_sha256", "signer_registry_sha256", "not_before", + "expires_at", "verified_at", "verifier" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-worker-admission-verifier-result/v1"}, + "verdict": {"const": "accepted"}, + "admission_object_sha256": {"$ref": "#/$defs/digest"}, + "provider_identity_sha256": {"$ref": "#/$defs/digest"}, + "worker_identity_sha256": {"$ref": "#/$defs/digest"}, + "live_provider_observation_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, + "worker_image_sha256": {"$ref": "#/$defs/digest"}, + "baseline_sha256": {"$ref": "#/$defs/digest"}, + "host_identity_sha256": {"$ref": "#/$defs/digest"}, + "tls_identity_sha256": {"$ref": "#/$defs/digest"}, + "egress_policy_sha256": {"$ref": "#/$defs/digest"}, + "campaign_permit_sha256": {"$ref": "#/$defs/digest"}, + "capability_handle_sha256": {"$ref": "#/$defs/digest"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "verified_at": {"$ref": "#/$defs/timestamp"}, + "verifier": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "source_commit", "runner_environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/verify-qualification-worker-admission.yml"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "runner_environment": {"const": "github-hosted"} + } + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"} + } +} diff --git a/schemas/qualification-worker-admission.schema.json b/schemas/qualification-worker-admission.schema.json new file mode 100644 index 0000000..0180b31 --- /dev/null +++ b/schemas/qualification-worker-admission.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-admission.schema.json", + "title": "OpenAdapt qualification worker admission v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "admission_id_sha256", "provider_identity_sha256", + "worker_identity_sha256", "live_provider_observation_sha256", + "admitted_runtime_sha256", "worker_image_sha256", "baseline_sha256", + "host_identity_sha256", "tls_identity_sha256", "egress_policy_sha256", + "campaign_permit_sha256", "capability_handle_sha256", + "authority_state_sha256", "revocation_state_sha256", + "signer_registry_sha256", "issued_at", "not_before", "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-worker-admission/v1"}, + "admission_id_sha256": {"$ref": "#/$defs/digest"}, + "provider_identity_sha256": {"$ref": "#/$defs/digest"}, + "worker_identity_sha256": {"$ref": "#/$defs/digest"}, + "live_provider_observation_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, + "worker_image_sha256": {"$ref": "#/$defs/digest"}, + "baseline_sha256": {"$ref": "#/$defs/digest"}, + "host_identity_sha256": {"$ref": "#/$defs/digest"}, + "tls_identity_sha256": {"$ref": "#/$defs/digest"}, + "egress_policy_sha256": {"$ref": "#/$defs/digest"}, + "campaign_permit_sha256": {"$ref": "#/$defs/digest"}, + "capability_handle_sha256": {"$ref": "#/$defs/digest"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-qualification-worker-admission.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "qualification-worker-admission"} + } + } + } +} diff --git a/schemas/qualification-worker-dispatch.schema.json b/schemas/qualification-worker-dispatch.schema.json new file mode 100644 index 0000000..8f2104a --- /dev/null +++ b/schemas/qualification-worker-dispatch.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-dispatch.schema.json", + "title": "OpenAdapt qualification worker dispatch v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "dispatch_id_sha256", "worker_admission_sha256", + "provider_identity_sha256", "worker_identity_sha256", + "live_provider_observation_sha256", "admitted_runtime_sha256", "run_id", + "run_attempt", "start_id_sha256", "task_id_sha256", + "task_condition_sha256", "campaign_artifact_sha256", "process_lease_sha256", + "capability_handle_sha256", "idempotency_key", "issued_at", "not_before", + "expires_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-worker-dispatch/v1"}, + "dispatch_id_sha256": {"$ref": "#/$defs/digest"}, + "worker_admission_sha256": {"$ref": "#/$defs/digest"}, + "provider_identity_sha256": {"$ref": "#/$defs/digest"}, + "worker_identity_sha256": {"$ref": "#/$defs/digest"}, + "live_provider_observation_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, + "run_id": {"$ref": "#/$defs/decimal_id"}, + "run_attempt": {"const": "1"}, + "start_id_sha256": {"$ref": "#/$defs/digest"}, + "task_id_sha256": {"$ref": "#/$defs/digest"}, + "task_condition_sha256": {"$ref": "#/$defs/digest"}, + "campaign_artifact_sha256": {"$ref": "#/$defs/digest"}, + "process_lease_sha256": {"$ref": "#/$defs/digest"}, + "capability_handle_sha256": {"$ref": "#/$defs/digest"}, + "idempotency_key": {"type": "string", "pattern": "^qualification-worker-dispatch:[0-9a-f]{64}$"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-qualification-worker-dispatch.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "qualification-worker-dispatch"} + } + } + } +} diff --git a/schemas/qualification-worker-terminal-receipt.schema.json b/schemas/qualification-worker-terminal-receipt.schema.json new file mode 100644 index 0000000..a9aad3b --- /dev/null +++ b/schemas/qualification-worker-terminal-receipt.schema.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-terminal-receipt.schema.json", + "title": "OpenAdapt qualification worker terminal receipt v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "receipt_id_sha256", "worker_admission_sha256", + "dispatch_id_sha256", "provider_identity_sha256", "worker_identity_sha256", + "run_id", "run_attempt", "start_id_sha256", "task_id_sha256", + "task_condition_sha256", "capability_handle_sha256", "process", + "oracle_sha256", "result_sha256", "log_sha256", "burned_identities_sha256", + "burn_ledger_revision", "burn_receipt_sha256", "burned_at", + "ledger_readback_sha256", "effect_started", "delivery_state", + "terminal_state", "exit_code", "uncertainty_sha256", "quarantine", + "completed_at", "issuer" + ], + "properties": { + "schema_version": {"const": "openadapt.qualification-worker-terminal-receipt/v1"}, + "receipt_id_sha256": {"$ref": "#/$defs/digest"}, + "worker_admission_sha256": {"$ref": "#/$defs/digest"}, + "dispatch_id_sha256": {"$ref": "#/$defs/digest"}, + "provider_identity_sha256": {"$ref": "#/$defs/digest"}, + "worker_identity_sha256": {"$ref": "#/$defs/digest"}, + "run_id": {"$ref": "#/$defs/decimal_id"}, + "run_attempt": {"const": "1"}, + "start_id_sha256": {"$ref": "#/$defs/digest"}, + "task_id_sha256": {"$ref": "#/$defs/digest"}, + "task_condition_sha256": {"$ref": "#/$defs/digest"}, + "capability_handle_sha256": {"$ref": "#/$defs/digest"}, + "process": { + "type": "object", + "additionalProperties": false, + "required": ["pid", "process_group_id", "launched_at", "executable_sha256", "process_start_identity_sha256"], + "properties": { + "pid": {"type": "integer", "minimum": 1}, + "process_group_id": {"type": "integer", "minimum": 1}, + "launched_at": {"$ref": "#/$defs/timestamp"}, + "executable_sha256": {"$ref": "#/$defs/digest"}, + "process_start_identity_sha256": {"$ref": "#/$defs/digest"} + } + }, + "oracle_sha256": {"$ref": "#/$defs/digest"}, + "result_sha256": {"$ref": "#/$defs/digest"}, + "log_sha256": {"$ref": "#/$defs/digest"}, + "burned_identities_sha256": {"$ref": "#/$defs/digest"}, + "burn_ledger_revision": {"type": "integer", "minimum": 1}, + "burn_receipt_sha256": {"$ref": "#/$defs/digest"}, + "burned_at": {"$ref": "#/$defs/timestamp"}, + "ledger_readback_sha256": {"$ref": "#/$defs/digest"}, + "effect_started": {"type": "boolean"}, + "delivery_state": {"enum": ["not_started", "verified", "uncertain"]}, + "terminal_state": {"enum": ["VERIFIED", "SAFE_HALT", "RECONCILIATION_REQUIRED", "QUARANTINED"]}, + "exit_code": {"type": ["integer", "null"]}, + "uncertainty_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "quarantine": { + "type": "object", + "additionalProperties": false, + "required": ["active", "reason_code", "evidence_sha256"], + "properties": { + "active": {"type": "boolean"}, + "reason_code": {"type": ["string", "null"], "pattern": "^[A-Z][A-Z0-9_]{2,63}$"}, + "evidence_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]} + } + }, + "completed_at": {"$ref": "#/$defs/timestamp"}, + "issuer": {"$ref": "#/$defs/issuer"} + }, + "allOf": [ + { + "if": {"properties": {"delivery_state": {"const": "uncertain"}}}, + "then": { + "properties": { + "effect_started": {"const": true}, + "terminal_state": {"enum": ["RECONCILIATION_REQUIRED", "QUARANTINED"]}, + "uncertainty_sha256": {"$ref": "#/$defs/digest"}, + "quarantine": {"properties": {"active": {"const": true}}} + } + } + }, + { + "if": {"properties": {"terminal_state": {"const": "VERIFIED"}}}, + "then": {"properties": {"delivery_state": {"const": "verified"}, "uncertainty_sha256": {"type": "null"}}} + }, + { + "if": {"properties": {"terminal_state": {"const": "SAFE_HALT"}}}, + "then": {"properties": {"effect_started": {"const": false}, "delivery_state": {"const": "not_started"}, "uncertainty_sha256": {"type": "null"}}} + } + ], + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "decimal_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/issue-qualification-worker-terminal-receipt.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "qualification-worker-terminal-receipt"} + } + } + } +} diff --git a/scripts/production_trust.py b/scripts/production_trust.py index 64a1427..d0ec2d5 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -1131,7 +1131,7 @@ def validate_publication_recovery_authorization( or issuer["workflow"] != ".github/workflows/issue-production-publication-recovery.yml" or issuer["ref"] != "refs/heads/main" - or issuer["environment"] != "release-identity" + or issuer["environment"] != authorization["environment"] or not isinstance(issuer["source_commit"], str) or HEX40.fullmatch(issuer["source_commit"]) is None ): @@ -1147,14 +1147,19 @@ def validate_publication_recovery_authorization( def validate_publication_recovery_replay( - previous_value: Any, current_value: Any + previous_value: Any, + current_value: Any, + *, + now: datetime | None = None, ) -> dict[str, Any]: - """Accept one byte-identical replay and refuse a changed one-use claim.""" + """Refuse expired effects and any changed one-use recovery claim.""" previous = validate_publication_recovery_authorization(previous_value) current = validate_publication_recovery_authorization(current_value) if canonical(previous) == canonical(current): - return current + return validate_publication_recovery_authorization( + current, now=now or datetime.now(timezone.utc) + ) previous_consumption = ( previous["repository_id"], previous["tag_ref"], @@ -1170,7 +1175,9 @@ def validate_publication_recovery_replay( or previous_consumption == current_consumption ): raise TrustError("publication recovery one-use claim conflicts") - return current + return validate_publication_recovery_authorization( + current, now=now or datetime.now(timezone.utc) + ) def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, Any]: diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 62c870d..83a8d70 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -13,6 +13,104 @@ EXPECTED_TOP_LEVEL_FIELDS = { + "qualification-worker-admission.schema.json": { + "schema_version", + "admission_id_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", + "worker_image_sha256", + "baseline_sha256", + "host_identity_sha256", + "tls_identity_sha256", + "egress_policy_sha256", + "campaign_permit_sha256", + "capability_handle_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "qualification-worker-admission-verifier-result.schema.json": { + "schema_version", + "verdict", + "admission_object_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", + "worker_image_sha256", + "baseline_sha256", + "host_identity_sha256", + "tls_identity_sha256", + "egress_policy_sha256", + "campaign_permit_sha256", + "capability_handle_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "signer_registry_sha256", + "not_before", + "expires_at", + "verified_at", + "verifier", + }, + "qualification-worker-dispatch.schema.json": { + "schema_version", + "dispatch_id_sha256", + "worker_admission_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", + "run_id", + "run_attempt", + "start_id_sha256", + "task_id_sha256", + "task_condition_sha256", + "campaign_artifact_sha256", + "process_lease_sha256", + "capability_handle_sha256", + "idempotency_key", + "issued_at", + "not_before", + "expires_at", + "issuer", + }, + "qualification-worker-terminal-receipt.schema.json": { + "schema_version", + "receipt_id_sha256", + "worker_admission_sha256", + "dispatch_id_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "run_id", + "run_attempt", + "start_id_sha256", + "task_id_sha256", + "task_condition_sha256", + "capability_handle_sha256", + "process", + "oracle_sha256", + "result_sha256", + "log_sha256", + "burned_identities_sha256", + "burn_ledger_revision", + "burn_receipt_sha256", + "burned_at", + "ledger_readback_sha256", + "effect_started", + "delivery_state", + "terminal_state", + "exit_code", + "uncertainty_sha256", + "quarantine", + "completed_at", + "issuer", + }, "support-release-policy.schema.json": { "$schema", "schema_version", @@ -533,6 +631,35 @@ def test_trial_receipt_authorities_are_fixed_per_receipt_type(self) -> None: ["self-hosted", "evidence-authority", item["environment"]], ) + def test_worker_contracts_pin_central_authority_and_terminal_uncertainty(self) -> None: + admission = json.loads( + (SCHEMA_ROOT / "qualification-worker-admission.schema.json").read_text() + ) + dispatch = json.loads( + (SCHEMA_ROOT / "qualification-worker-dispatch.schema.json").read_text() + ) + terminal = json.loads( + (SCHEMA_ROOT / "qualification-worker-terminal-receipt.schema.json").read_text() + ) + self.assertEqual( + admission["$defs"]["issuer"]["properties"]["repository"]["const"], + "OpenAdaptAI/.github", + ) + self.assertEqual(dispatch["properties"]["run_attempt"]["const"], "1") + self.assertEqual( + dispatch["$defs"]["issuer"]["properties"]["workflow"]["const"], + ".github/workflows/issue-qualification-worker-dispatch.yml", + ) + uncertain = terminal["allOf"][0]["then"]["properties"] + self.assertIs(uncertain["effect_started"]["const"], True) + self.assertEqual( + uncertain["terminal_state"]["enum"], + ["RECONCILIATION_REQUIRED", "QUARANTINED"], + ) + self.assertIs( + uncertain["quarantine"]["properties"]["active"]["const"], True + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_support_release_and_recovery_contracts.py b/tests/test_support_release_and_recovery_contracts.py index e52af88..b80a727 100644 --- a/tests/test_support_release_and_recovery_contracts.py +++ b/tests/test_support_release_and_recovery_contracts.py @@ -911,6 +911,7 @@ def test_each_single_effect_has_one_exact_environment(self) -> None: value = recovery_authorization() value["requested_effect"] = effect value["environment"] = environment + value["issuer"]["environment"] = environment refresh_recovery_authorization(value) trust.validate_publication_recovery_authorization(value, now=NOW) @@ -920,6 +921,7 @@ def test_each_single_effect_has_one_exact_environment(self) -> None: agent["repository_id"] = "1136136670" agent["requested_effect"] = "publish-mcp-registry" agent["environment"] = "mcp-registry" + agent["issuer"]["environment"] = "mcp-registry" refresh_recovery_authorization(agent) trust.validate_publication_recovery_authorization(agent, now=NOW) @@ -959,14 +961,16 @@ def test_replay_key_binds_every_effect_critical_recovery_field(self) -> None: def test_replay_accepts_exact_bytes_and_refuses_one_use_conflict(self) -> None: first = recovery_authorization() self.assertEqual( - trust.validate_publication_recovery_replay(first, copy.deepcopy(first)), + trust.validate_publication_recovery_replay( + first, copy.deepcopy(first), now=NOW + ), first, ) conflict = copy.deepcopy(first) conflict["draft_release_id"] = "21" refresh_recovery_authorization(conflict) with self.assertRaisesRegex(trust.TrustError, "one-use claim conflicts"): - trust.validate_publication_recovery_replay(first, conflict) + trust.validate_publication_recovery_replay(first, conflict, now=NOW) next_tag = copy.deepcopy(first) next_tag["tag"] = "v1.0.1" @@ -977,9 +981,17 @@ def test_replay_accepts_exact_bytes_and_refuses_one_use_conflict(self) -> None: next_tag["run_id"] = "101" refresh_recovery_authorization(next_tag) self.assertEqual( - trust.validate_publication_recovery_replay(first, next_tag), next_tag + trust.validate_publication_recovery_replay(first, next_tag, now=NOW), + next_tag, ) + with self.assertRaisesRegex(trust.TrustError, "not active"): + trust.validate_publication_recovery_replay( + first, + copy.deepcopy(first), + now=datetime(2026, 8, 27, 12, 5, 0, tzinfo=timezone.utc), + ) + class EvidenceKindProfileTests(unittest.TestCase): def registry_document(self, entries: list[dict]) -> dict: From 826757f7a5239c30ec2e647f03201171943cfab3 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:03:59 -0400 Subject: [PATCH 08/18] feat: define KMS public trust profile --- ...ction-public-trust-dsse-bundle.schema.json | 49 ++ ...public-trust-signing-statement.schema.json | 78 +++ scripts/public_trust_kms.py | 463 ++++++++++++++++++ tests/test_public_trust_kms.py | 316 ++++++++++++ tests/test_public_trust_schemas.py | 25 + 5 files changed, 931 insertions(+) create mode 100644 schemas/production-public-trust-dsse-bundle.schema.json create mode 100644 schemas/production-public-trust-signing-statement.schema.json create mode 100644 scripts/public_trust_kms.py create mode 100644 tests/test_public_trust_kms.py diff --git a/schemas/production-public-trust-dsse-bundle.schema.json b/schemas/production-public-trust-dsse-bundle.schema.json new file mode 100644 index 0000000..f1fdd6e --- /dev/null +++ b/schemas/production-public-trust-dsse-bundle.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-public-trust-dsse-bundle.schema.json", + "title": "OpenAdapt production public-trust raw Sigstore bundle profile", + "type": "object", + "additionalProperties": false, + "required": ["mediaType", "verificationMaterial", "dsseEnvelope"], + "properties": { + "mediaType": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "verificationMaterial": { + "type": "object", + "additionalProperties": false, + "required": ["publicKey"], + "properties": { + "publicKey": { + "type": "object", + "additionalProperties": false, + "required": ["hint"], + "properties": { + "hint": {"type": "string", "pattern": "^oa-public-trust-p256-[0-9a-f]{16}$"} + } + } + } + }, + "dsseEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["payload", "payloadType", "signatures"], + "properties": { + "payload": {"type": "string", "pattern": "^[A-Za-z0-9+/]+={0,2}$"}, + "payloadType": {"const": "application/vnd.openadapt.production-public-trust-signing-statement+json;version=1"}, + "signatures": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["keyid", "sig"], + "properties": { + "keyid": {"type": "string", "pattern": "^oa-public-trust-p256-[0-9a-f]{16}$"}, + "sig": {"type": "string", "pattern": "^[A-Za-z0-9+/]+={0,2}$"} + } + } + } + } + } + } +} diff --git a/schemas/production-public-trust-signing-statement.schema.json b/schemas/production-public-trust-signing-statement.schema.json new file mode 100644 index 0000000..a461249 --- /dev/null +++ b/schemas/production-public-trust-signing-statement.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "production-public-trust-signing-statement.schema.json", + "title": "OpenAdapt production public-trust signing statement v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "object_kind", "object_schema_version", "object_media_type", + "object_sha256", "object_size_bytes", "semantic_identity_sha256", "source_issuer", + "signer_registry_sha256", "authority_state_sha256", "revocation_state_sha256", + "issued_at", "not_before", "expires_at", "request_id_sha256", + "signing_authority", "key_id", "signature_profile" + ], + "properties": { + "schema_version": {"const": "openadapt.production-public-trust-signing-statement/v1"}, + "object_kind": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,95}$"}, + "object_schema_version": {"type": "string", "minLength": 1, "maxLength": 160}, + "object_media_type": {"type": "string", "minLength": 1, "maxLength": 200}, + "object_sha256": {"$ref": "#/$defs/digest"}, + "object_size_bytes": {"type": "integer", "minimum": 1}, + "semantic_identity_sha256": {"$ref": "#/$defs/digest"}, + "source_issuer": {"$ref": "#/$defs/issuer"}, + "signer_registry_sha256": {"$ref": "#/$defs/digest"}, + "authority_state_sha256": {"$ref": "#/$defs/digest"}, + "revocation_state_sha256": {"$ref": "#/$defs/digest"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "not_before": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "request_id_sha256": {"$ref": "#/$defs/digest"}, + "signing_authority": {"$ref": "#/$defs/signing_authority"}, + "key_id": {"type": "string", "pattern": "^oa-public-trust-p256-[0-9a-f]{16}$"}, + "signature_profile": {"const": "aws-kms-p256-dsse-v1"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "issuer": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "repository_id", "repository_owner_id", "workflow", "ref", "source_commit", "environment"], + "properties": { + "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "repository_id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"type": "string", "pattern": "^\\.github/workflows/[A-Za-z0-9._-]+\\.yml$"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"type": "string", "pattern": "^[a-z][a-z0-9-]{2,63}$"} + } + }, + "signing_authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", "repository_id", "repository_owner_id", "workflow", "ref", + "source_commit", "environment", "aws_account_id", "aws_region", "kms_key_arn", + "kms_key_spec", "kms_signing_algorithm", "kms_message_type", "role_arn" + ], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "workflow": {"const": ".github/workflows/sign-production-evidence.yml"}, + "ref": {"const": "refs/heads/main"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"type": "string", "pattern": "^[a-z][a-z0-9-]{2,63}$"}, + "aws_account_id": {"const": "992382684924"}, + "aws_region": {"const": "us-east-1"}, + "kms_key_arn": {"type": "string", "pattern": "^arn:aws:kms:us-east-1:992382684924:key/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"}, + "kms_key_spec": {"const": "ECC_NIST_P256"}, + "kms_signing_algorithm": {"const": "ECDSA_SHA_256"}, + "kms_message_type": {"const": "DIGEST"}, + "role_arn": {"const": "arn:aws:iam::992382684924:role/openadapt-public-trust-signer"} + } + } + } +} diff --git a/scripts/public_trust_kms.py b/scripts/public_trust_kms.py new file mode 100644 index 0000000..efdd9c8 --- /dev/null +++ b/scripts/public_trust_kms.py @@ -0,0 +1,463 @@ +"""Build and verify the offline OpenAdapt public-trust KMS DSSE profile.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import re +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature + +STATEMENT_SCHEMA = "openadapt.production-public-trust-signing-statement/v1" +STATEMENT_MEDIA_TYPE = ( + "application/vnd.openadapt.production-public-trust-signing-statement+json;version=1" +) +BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json" +SIGNATURE_PROFILE = "aws-kms-p256-dsse-v1" +KMS_ACCOUNT_ID = "992382684924" +KMS_REGION = "us-east-1" +KMS_ALGORITHM = "ECDSA_SHA_256" +KMS_MESSAGE_TYPE = "DIGEST" +KMS_KEY_SPEC = "ECC_NIST_P256" +KMS_ROLE_ARN = ( + "arn:aws:iam::992382684924:role/openadapt-public-trust-signer" +) + +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +KIND = re.compile(r"^[a-z][a-z0-9-]{1,95}$") +TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +KMS_KEY_ARN = re.compile( + r"^arn:aws:kms:us-east-1:992382684924:key/" + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) +KEY_ID = re.compile(r"^oa-public-trust-p256-[0-9a-f]{16}$") +P256_ORDER = int( + "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16 +) + +STATEMENT_FIELDS = { + "schema_version", + "object_kind", + "object_schema_version", + "object_media_type", + "object_sha256", + "object_size_bytes", + "semantic_identity_sha256", + "source_issuer", + "signer_registry_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "issued_at", + "not_before", + "expires_at", + "request_id_sha256", + "signing_authority", + "key_id", + "signature_profile", +} +ISSUER_FIELDS = { + "repository", + "repository_id", + "repository_owner_id", + "workflow", + "ref", + "source_commit", + "environment", +} +AUTHORITY_FIELDS = { + "repository", + "repository_id", + "repository_owner_id", + "workflow", + "ref", + "source_commit", + "environment", + "aws_account_id", + "aws_region", + "kms_key_arn", + "kms_key_spec", + "kms_signing_algorithm", + "kms_message_type", + "role_arn", +} +PUBLIC_SIGNER_FIELDS = { + "algorithm", + "key_id", + "key_origin", + "kms_key_arn", + "public_key", + "public_key_spki_der_base64", + "public_key_sha256", + "signature_encoding", + "statement_schema_versions", + "allowed_usages", + "allowed_kinds", + "allowed_workflows", + "allowed_ref_prefixes", + "allowed_environments", + "status", + "revoked_at", +} + + +class PublicTrustKmsError(ValueError): + """The public-trust signing statement, signer, or bundle is invalid.""" + + +def canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def canonical_lf(value: Any) -> bytes: + return canonical(value) + b"\n" + + +def _closed(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise PublicTrustKmsError( + f"{label} must contain exactly {sorted(fields)}; got {actual}" + ) + return value + + +def _digest(value: Any, label: str) -> str: + if not isinstance(value, str) or SHA256.fullmatch(value) is None: + raise PublicTrustKmsError(f"{label} must be a lowercase sha256 digest") + return value + + +def _timestamp(value: Any, label: str) -> datetime: + if not isinstance(value, str) or TIMESTAMP.fullmatch(value) is None: + raise PublicTrustKmsError(f"{label} must be an exact UTC timestamp") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError as exc: + raise PublicTrustKmsError(f"{label} is not a calendar timestamp") from exc + + +def _canonical_base64(value: Any, label: str) -> bytes: + if not isinstance(value, str): + raise PublicTrustKmsError(f"{label} must be canonical padded base64") + try: + decoded = base64.b64decode(value, validate=True) + except (ValueError, binascii.Error) as exc: + raise PublicTrustKmsError(f"{label} is not valid base64") from exc + if base64.b64encode(decoded).decode("ascii") != value: + raise PublicTrustKmsError(f"{label} must be canonical padded base64") + return decoded + + +def _canonical_base64url(value: Any, label: str) -> bytes: + if not isinstance(value, str) or "=" in value: + raise PublicTrustKmsError(f"{label} must be canonical unpadded base64url") + try: + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + except (ValueError, binascii.Error) as exc: + raise PublicTrustKmsError(f"{label} is not valid base64url") from exc + if base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") != value: + raise PublicTrustKmsError(f"{label} must be canonical unpadded base64url") + return decoded + + +def public_key_id(public_key: ec.EllipticCurvePublicKey) -> str: + spki = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return "oa-public-trust-p256-" + hashlib.sha256(spki).hexdigest()[:16] + + +def validate_public_signer(value: Any) -> dict[str, Any]: + signer = _closed(value, PUBLIC_SIGNER_FIELDS, "public-trust signer") + if signer["algorithm"] != "ecdsa-p256-sha256": + raise PublicTrustKmsError("public-trust signer algorithm is not supported") + if signer["key_origin"] != "aws-kms": + raise PublicTrustKmsError("public-trust signer must use AWS KMS") + if ( + not isinstance(signer["kms_key_arn"], str) + or KMS_KEY_ARN.fullmatch(signer["kms_key_arn"]) is None + ): + raise PublicTrustKmsError("public-trust signer KMS key ARN is invalid") + key_bytes = _canonical_base64url(signer["public_key"], "public key") + if len(key_bytes) != 65 or key_bytes[:1] != b"\x04": + raise PublicTrustKmsError("public key must be an uncompressed P-256 point") + try: + public_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), key_bytes + ) + except ValueError as exc: + raise PublicTrustKmsError("public key is not a valid P-256 point") from exc + spki = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + if _canonical_base64( + signer["public_key_spki_der_base64"], "public key SPKI" + ) != spki: + raise PublicTrustKmsError("public key SPKI does not bind the public key") + if signer["public_key_sha256"] != "sha256:" + hashlib.sha256(spki).hexdigest(): + raise PublicTrustKmsError("public key fingerprint does not bind the public key") + expected_key_id = public_key_id(public_key) + if signer["key_id"] != expected_key_id: + raise PublicTrustKmsError("public-trust key id does not bind the public key") + if signer["signature_encoding"] != "asn1-der-low-s-base64-rfc4648-padded": + raise PublicTrustKmsError("public-trust signature encoding is not supported") + if signer["statement_schema_versions"] != [STATEMENT_SCHEMA]: + raise PublicTrustKmsError("public-trust statement schema is not supported") + if signer["allowed_usages"] != ["production-public-evidence"]: + raise PublicTrustKmsError("public-trust signer usage is not supported") + for field in ("allowed_kinds", "allowed_workflows", "allowed_ref_prefixes", "allowed_environments"): + values = signer[field] + if ( + not isinstance(values, list) + or not values + or values != sorted(set(values)) + or not all(isinstance(item, str) and item for item in values) + ): + raise PublicTrustKmsError(f"public-trust signer {field} must be sorted and unique") + if signer["status"] not in {"active", "revoked"}: + raise PublicTrustKmsError("public-trust signer status is invalid") + if signer["status"] == "active" and signer["revoked_at"] is not None: + raise PublicTrustKmsError("an active public-trust signer cannot be revoked") + if signer["status"] == "revoked": + _timestamp(signer["revoked_at"], "public-trust signer revoked_at") + return signer + + +def validate_signing_statement( + value: Any, + *, + signer: Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + statement = _closed(value, STATEMENT_FIELDS, "public-trust signing statement") + if statement["schema_version"] != STATEMENT_SCHEMA: + raise PublicTrustKmsError("public-trust signing statement schema is not supported") + if not isinstance(statement["object_kind"], str) or KIND.fullmatch(statement["object_kind"]) is None: + raise PublicTrustKmsError("public-trust object kind is invalid") + for field in ("object_schema_version", "object_media_type"): + if not isinstance(statement[field], str) or not statement[field]: + raise PublicTrustKmsError(f"public-trust {field} is invalid") + for field in ( + "object_sha256", + "semantic_identity_sha256", + "signer_registry_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "request_id_sha256", + ): + _digest(statement[field], field) + if ( + not isinstance(statement["object_size_bytes"], int) + or isinstance(statement["object_size_bytes"], bool) + or statement["object_size_bytes"] < 1 + ): + raise PublicTrustKmsError("public-trust object size must be positive") + issuer = _closed(statement["source_issuer"], ISSUER_FIELDS, "source issuer") + for field in ("repository", "repository_id", "repository_owner_id", "workflow", "ref", "environment"): + if not isinstance(issuer[field], str) or not issuer[field]: + raise PublicTrustKmsError(f"source issuer {field} is invalid") + if not isinstance(issuer["source_commit"], str) or HEX40.fullmatch(issuer["source_commit"]) is None: + raise PublicTrustKmsError("source issuer commit is invalid") + authority = _closed( + statement["signing_authority"], AUTHORITY_FIELDS, "signing authority" + ) + expected_authority = { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/sign-production-evidence.yml", + "ref": "refs/heads/main", + "aws_account_id": KMS_ACCOUNT_ID, + "aws_region": KMS_REGION, + "kms_key_spec": KMS_KEY_SPEC, + "kms_signing_algorithm": KMS_ALGORITHM, + "kms_message_type": KMS_MESSAGE_TYPE, + "role_arn": KMS_ROLE_ARN, + } + for field, expected in expected_authority.items(): + if authority[field] != expected: + raise PublicTrustKmsError(f"signing authority {field} is invalid") + if not isinstance(authority["source_commit"], str) or HEX40.fullmatch(authority["source_commit"]) is None: + raise PublicTrustKmsError("signing authority commit is invalid") + if not isinstance(authority["environment"], str) or not authority["environment"]: + raise PublicTrustKmsError("signing authority environment is invalid") + if not isinstance(authority["kms_key_arn"], str) or KMS_KEY_ARN.fullmatch(authority["kms_key_arn"]) is None: + raise PublicTrustKmsError("signing authority KMS key ARN is invalid") + if not isinstance(statement["key_id"], str) or KEY_ID.fullmatch(statement["key_id"]) is None: + raise PublicTrustKmsError("public-trust key id is invalid") + if statement["signature_profile"] != SIGNATURE_PROFILE: + raise PublicTrustKmsError("public-trust signature profile is invalid") + issued_at = _timestamp(statement["issued_at"], "statement issued_at") + not_before = _timestamp(statement["not_before"], "statement not_before") + expires_at = _timestamp(statement["expires_at"], "statement expires_at") + if not not_before <= issued_at < expires_at: + raise PublicTrustKmsError("public-trust statement validity is invalid") + if now is not None and not not_before <= now < expires_at: + raise PublicTrustKmsError("public-trust signing statement is not active") + if signer is not None: + validated_signer = validate_public_signer(signer) + if validated_signer["status"] != "active": + raise PublicTrustKmsError("public-trust signer is not active") + if statement["key_id"] != validated_signer["key_id"]: + raise PublicTrustKmsError("statement key id does not match the signer") + if authority["kms_key_arn"] != validated_signer["kms_key_arn"]: + raise PublicTrustKmsError("statement KMS key does not match the signer") + if statement["object_kind"] not in validated_signer["allowed_kinds"]: + raise PublicTrustKmsError("signer is not authorized for this object kind") + workflow_identity = ( + "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/OpenAdaptAI/.github/" + + authority["workflow"] + + "@" + + authority["ref"] + ) + if workflow_identity not in validated_signer["allowed_workflows"]: + raise PublicTrustKmsError("signer is not authorized for this workflow") + if not any(authority["ref"].startswith(prefix) for prefix in validated_signer["allowed_ref_prefixes"]): + raise PublicTrustKmsError("signer is not authorized for this ref") + if authority["environment"] not in validated_signer["allowed_environments"]: + raise PublicTrustKmsError("signer is not authorized for this environment") + return statement + + +def dsse_pae(payload_type: str, payload: bytes) -> bytes: + if not isinstance(payload_type, str) or not payload_type: + raise PublicTrustKmsError("DSSE payload type is invalid") + payload_type_bytes = payload_type.encode("utf-8") + return ( + b"DSSEv1 " + + str(len(payload_type_bytes)).encode("ascii") + + b" " + + payload_type_bytes + + b" " + + str(len(payload)).encode("ascii") + + b" " + + payload + ) + + +def kms_message_digest(statement: Mapping[str, Any]) -> bytes: + payload = canonical_lf(statement) + return hashlib.sha256(dsse_pae(STATEMENT_MEDIA_TYPE, payload)).digest() + + +def verify_bundle( + bundle_value: Any, + *, + expected_statement: Mapping[str, Any], + signer: Mapping[str, Any], + now: datetime | None = None, +) -> dict[str, Any]: + validated_signer = validate_public_signer(signer) + statement = validate_signing_statement( + expected_statement, signer=validated_signer, now=now + ) + bundle = _closed( + bundle_value, + {"mediaType", "verificationMaterial", "dsseEnvelope"}, + "public-trust DSSE bundle", + ) + if bundle["mediaType"] != BUNDLE_MEDIA_TYPE: + raise PublicTrustKmsError("public-trust bundle media type is invalid") + verification_material = _closed( + bundle["verificationMaterial"], {"publicKey"}, "verification material" + ) + public_key_hint = _closed( + verification_material["publicKey"], {"hint"}, "public key hint" + ) + if public_key_hint["hint"] != validated_signer["key_id"]: + raise PublicTrustKmsError("public key hint does not match the signer") + envelope = _closed( + bundle["dsseEnvelope"], + {"payload", "payloadType", "signatures"}, + "DSSE envelope", + ) + if envelope["payloadType"] != STATEMENT_MEDIA_TYPE: + raise PublicTrustKmsError("DSSE payload type is invalid") + payload = _canonical_base64(envelope["payload"], "DSSE payload") + expected_payload = canonical_lf(statement) + if payload != expected_payload: + raise PublicTrustKmsError("DSSE payload does not match the signing statement") + signatures = envelope["signatures"] + if not isinstance(signatures, list) or len(signatures) != 1: + raise PublicTrustKmsError("DSSE envelope must contain exactly one signature") + signature_entry = _closed( + signatures[0], {"keyid", "sig"}, "DSSE signature" + ) + if signature_entry["keyid"] != validated_signer["key_id"]: + raise PublicTrustKmsError("DSSE signature key id does not match the signer") + signature = _canonical_base64(signature_entry["sig"], "DSSE signature") + try: + _, signature_s = decode_dss_signature(signature) + except ValueError as exc: + raise PublicTrustKmsError("DSSE signature is not canonical ASN.1 DER") from exc + if signature_s > P256_ORDER // 2: + raise PublicTrustKmsError("DSSE signature must use canonical low-S form") + public_key_bytes = _canonical_base64url( + validated_signer["public_key"], "public key" + ) + public_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), public_key_bytes + ) + pae = dsse_pae(STATEMENT_MEDIA_TYPE, payload) + try: + public_key.verify(signature, pae, ec.ECDSA(hashes.SHA256())) + except InvalidSignature as exc: + raise PublicTrustKmsError("DSSE signature verification failed") from exc + return bundle + + +def validate_statement_object_binding( + statement_value: Any, + *, + object_raw: bytes, + object_value: Mapping[str, Any], + object_kind: str, + object_schema_version: str, + object_media_type: str, + semantic_identity_sha256: str, +) -> dict[str, Any]: + statement = validate_signing_statement(statement_value) + if object_raw != canonical_lf(object_value): + raise PublicTrustKmsError("public-trust object must be canonical JSON plus LF") + bindings = { + "object_kind": object_kind, + "object_schema_version": object_schema_version, + "object_media_type": object_media_type, + "object_sha256": "sha256:" + hashlib.sha256(object_raw).hexdigest(), + "object_size_bytes": len(object_raw), + "semantic_identity_sha256": semantic_identity_sha256, + } + for field, expected in bindings.items(): + if statement[field] != expected: + raise PublicTrustKmsError(f"public-trust statement {field} binding differs") + source_issuer = object_value.get("issuer") + if not isinstance(source_issuer, dict): + raise PublicTrustKmsError("public-trust object has no signed issuer") + projected_issuer = {field: source_issuer[field] for field in ISSUER_FIELDS if field in source_issuer} + if set(projected_issuer) != ISSUER_FIELDS or statement["source_issuer"] != projected_issuer: + raise PublicTrustKmsError("public-trust statement source issuer binding differs") + for field in ( + "signer_registry_sha256", + "authority_state_sha256", + "revocation_state_sha256", + ): + if statement[field] != object_value.get(field): + raise PublicTrustKmsError(f"public-trust statement {field} binding differs") + return statement diff --git a/tests/test_public_trust_kms.py b/tests/test_public_trust_kms.py new file mode 100644 index 0000000..e9f6975 --- /dev/null +++ b/tests/test_public_trust_kms.py @@ -0,0 +1,316 @@ +"""Tests for the offline AWS KMS P-256 public-trust DSSE profile.""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + encode_dss_signature, +) + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import public_trust_kms as kms + +NOW = datetime(2026, 8, 27, 12, 0, 30, tzinfo=timezone.utc) +KMS_ARN = ( + "arn:aws:kms:us-east-1:992382684924:key/" + "12345678-1234-4abc-8def-1234567890ab" +) + + +def sha(label: str) -> str: + return "sha256:" + hashlib.sha256(label.encode("utf-8")).hexdigest() + + +def private_key() -> ec.EllipticCurvePrivateKey: + return ec.derive_private_key( + int("2f29c1f30c9db50a15e08ddd4f5f49d1f5c621d40ad72835d11065448148d8c7", 16), + ec.SECP256R1(), + ) + + +def signer(*, status: str = "active") -> dict: + public_key = private_key().public_key() + point = public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + spki = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return { + "algorithm": "ecdsa-p256-sha256", + "key_id": kms.public_key_id(public_key), + "key_origin": "aws-kms", + "kms_key_arn": KMS_ARN, + "public_key": base64.urlsafe_b64encode(point).decode().rstrip("="), + "public_key_spki_der_base64": base64.b64encode(spki).decode(), + "public_key_sha256": "sha256:" + hashlib.sha256(spki).hexdigest(), + "signature_encoding": "asn1-der-low-s-base64-rfc4648-padded", + "statement_schema_versions": [kms.STATEMENT_SCHEMA], + "allowed_usages": ["production-public-evidence"], + "allowed_kinds": ["qualification-release"], + "allowed_workflows": [ + ( + "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/OpenAdaptAI/.github/" + ".github/workflows/sign-production-evidence.yml@refs/heads/main" + ) + ], + "allowed_ref_prefixes": ["refs/heads/main"], + "allowed_environments": ["public-trust-signing"], + "status": status, + "revoked_at": "2026-08-27T11:59:00Z" if status == "revoked" else None, + } + + +def source_issuer() -> dict: + return { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-production-release-admission.yml", + "ref": "refs/heads/main", + "source_commit": "a" * 40, + "environment": "production-release-admission", + } + + +def object_value() -> dict: + return { + "schema_version": "test/v1", + "authority_state_sha256": sha("authority"), + "revocation_state_sha256": sha("revocation"), + "signer_registry_sha256": sha("registry"), + "issuer": source_issuer(), + } + + +def statement() -> dict: + value = object_value() + raw = kms.canonical_lf(value) + active_signer = signer() + return { + "schema_version": kms.STATEMENT_SCHEMA, + "object_kind": "qualification-release", + "object_schema_version": "openadapt.qualification-release/v1", + "object_media_type": "application/vnd.openadapt.qualification-release+json;version=1", + "object_sha256": "sha256:" + hashlib.sha256(raw).hexdigest(), + "object_size_bytes": len(raw), + "semantic_identity_sha256": sha("release-identity"), + "source_issuer": source_issuer(), + "signer_registry_sha256": sha("registry"), + "authority_state_sha256": sha("authority"), + "revocation_state_sha256": sha("revocation"), + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-27T12:05:00Z", + "request_id_sha256": sha("request"), + "signing_authority": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/sign-production-evidence.yml", + "ref": "refs/heads/main", + "source_commit": "b" * 40, + "environment": "public-trust-signing", + "aws_account_id": "992382684924", + "aws_region": "us-east-1", + "kms_key_arn": KMS_ARN, + "kms_key_spec": "ECC_NIST_P256", + "kms_signing_algorithm": "ECDSA_SHA_256", + "kms_message_type": "DIGEST", + "role_arn": "arn:aws:iam::992382684924:role/openadapt-public-trust-signer", + }, + "key_id": active_signer["key_id"], + "signature_profile": "aws-kms-p256-dsse-v1", + } + + +def bundle(value: dict | None = None) -> dict: + signed_statement = value or statement() + payload = kms.canonical_lf(signed_statement) + signature = private_key().sign( + kms.dsse_pae(kms.STATEMENT_MEDIA_TYPE, payload), ec.ECDSA(hashes.SHA256()) + ) + signature_r, signature_s = decode_dss_signature(signature) + if signature_s > kms.P256_ORDER // 2: + signature_s = kms.P256_ORDER - signature_s + signature = encode_dss_signature(signature_r, signature_s) + key_id = signer()["key_id"] + return { + "mediaType": kms.BUNDLE_MEDIA_TYPE, + "verificationMaterial": {"publicKey": {"hint": key_id}}, + "dsseEnvelope": { + "payload": base64.b64encode(payload).decode(), + "payloadType": kms.STATEMENT_MEDIA_TYPE, + "signatures": [ + {"keyid": key_id, "sig": base64.b64encode(signature).decode()} + ], + }, + } + + +class PublicTrustKmsTests(unittest.TestCase): + def test_fixed_key_and_dsse_pae_digest_vector(self) -> None: + active_signer = signer() + self.assertEqual( + active_signer["key_id"], "oa-public-trust-p256-93dbc7899ed2892d" + ) + self.assertEqual( + active_signer["public_key_sha256"], + "sha256:93dbc7899ed2892d0215567d8f8f7a2b00d44246d8c6a9f8cb40c4355c1234d3", + ) + self.assertEqual( + kms.kms_message_digest(statement()).hex(), + "272b5a49125aac7591703c97fc50bc42cc31c678f762e557830279ba09772aa1", + ) + + def test_exact_statement_signer_and_bundle_verify_offline(self) -> None: + active_signer = signer() + signed_statement = statement() + self.assertEqual(kms.validate_public_signer(active_signer), active_signer) + self.assertEqual( + kms.validate_signing_statement( + signed_statement, signer=active_signer, now=NOW + ), + signed_statement, + ) + signed_bundle = bundle(signed_statement) + self.assertEqual( + kms.verify_bundle( + signed_bundle, + expected_statement=signed_statement, + signer=active_signer, + now=NOW, + ), + signed_bundle, + ) + + def test_statement_binds_exact_canonical_object_and_signed_issuer(self) -> None: + value = object_value() + raw = kms.canonical_lf(value) + self.assertEqual( + kms.validate_statement_object_binding( + statement(), + object_raw=raw, + object_value=value, + object_kind="qualification-release", + object_schema_version="openadapt.qualification-release/v1", + object_media_type="application/vnd.openadapt.qualification-release+json;version=1", + semantic_identity_sha256=sha("release-identity"), + ), + statement(), + ) + with self.assertRaisesRegex(kms.PublicTrustKmsError, "canonical JSON"): + kms.validate_statement_object_binding( + statement(), + object_raw=json.dumps(value, indent=2).encode() + b"\n", + object_value=value, + object_kind="qualification-release", + object_schema_version="openadapt.qualification-release/v1", + object_media_type="application/vnd.openadapt.qualification-release+json;version=1", + semantic_identity_sha256=sha("release-identity"), + ) + + def test_kms_digest_is_sha256_of_exact_dsse_pae(self) -> None: + value = statement() + expected = hashlib.sha256( + kms.dsse_pae(kms.STATEMENT_MEDIA_TYPE, kms.canonical_lf(value)) + ).digest() + self.assertEqual(kms.kms_message_digest(value), expected) + self.assertEqual(len(expected), 32) + + def test_tamper_profile_workflow_expiry_revocation_and_alias_fail(self) -> None: + active_signer = signer() + signed_statement = statement() + signed_bundle = bundle(signed_statement) + + tampered = copy.deepcopy(signed_bundle) + tampered["dsseEnvelope"]["payloadType"] = "application/json" + with self.assertRaisesRegex(kms.PublicTrustKmsError, "payload type"): + kms.verify_bundle( + tampered, + expected_statement=signed_statement, + signer=active_signer, + ) + + wrong_profile = copy.deepcopy(signed_statement) + wrong_profile["signature_profile"] = "github-attestation" + with self.assertRaisesRegex(kms.PublicTrustKmsError, "profile"): + kms.validate_signing_statement(wrong_profile) + + wrong_workflow = copy.deepcopy(signed_statement) + wrong_workflow["signing_authority"]["workflow"] = ".github/workflows/release.yml" + with self.assertRaisesRegex(kms.PublicTrustKmsError, "workflow"): + kms.validate_signing_statement(wrong_workflow) + + with self.assertRaisesRegex(kms.PublicTrustKmsError, "not active"): + kms.verify_bundle( + signed_bundle, + expected_statement=signed_statement, + signer=active_signer, + now=datetime(2026, 8, 27, 12, 5, 0, tzinfo=timezone.utc), + ) + + with self.assertRaisesRegex(kms.PublicTrustKmsError, "not active"): + kms.verify_bundle( + signed_bundle, + expected_statement=signed_statement, + signer=signer(status="revoked"), + now=NOW, + ) + + alias_signer = signer() + alias_signer["kms_key_arn"] = ( + "arn:aws:kms:us-east-1:992382684924:alias/openadapt-public-trust" + ) + with self.assertRaisesRegex(kms.PublicTrustKmsError, "ARN"): + kms.validate_public_signer(alias_signer) + + def test_bundle_rejects_profile_substitution_and_extra_fields(self) -> None: + active_signer = signer() + signed_statement = statement() + for label, mutate in ( + ( + "message signature", + lambda value: value.__setitem__("messageSignature", {}), + ), + ( + "keyless material", + lambda value: value["verificationMaterial"].__setitem__( + "x509CertificateChain", {} + ), + ), + ( + "second signature", + lambda value: value["dsseEnvelope"]["signatures"].append( + copy.deepcopy(value["dsseEnvelope"]["signatures"][0]) + ), + ), + ): + with self.subTest(label=label): + changed = bundle(signed_statement) + mutate(changed) + with self.assertRaises(kms.PublicTrustKmsError): + kms.verify_bundle( + changed, + expected_statement=signed_statement, + signer=active_signer, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 83a8d70..e935304 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -13,6 +13,31 @@ EXPECTED_TOP_LEVEL_FIELDS = { + "production-public-trust-signing-statement.schema.json": { + "schema_version", + "object_kind", + "object_schema_version", + "object_media_type", + "object_sha256", + "object_size_bytes", + "semantic_identity_sha256", + "source_issuer", + "signer_registry_sha256", + "authority_state_sha256", + "revocation_state_sha256", + "issued_at", + "not_before", + "expires_at", + "request_id_sha256", + "signing_authority", + "key_id", + "signature_profile", + }, + "production-public-trust-dsse-bundle.schema.json": { + "mediaType", + "verificationMaterial", + "dsseEnvelope", + }, "qualification-worker-admission.schema.json": { "schema_version", "admission_id_sha256", From e3783d8301a7986abe9ad7d5fa6facec8d60ebba Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:16:19 -0400 Subject: [PATCH 09/18] fix: bind worker terminal process generation --- ...cation-worker-terminal-receipt.schema.json | 72 ++- scripts/qualification_worker_contract.py | 428 ++++++++++++++++++ tests/test_public_trust_schemas.py | 29 +- tests/test_qualification_worker_contract.py | 192 ++++++++ 4 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 scripts/qualification_worker_contract.py create mode 100644 tests/test_qualification_worker_contract.py diff --git a/schemas/qualification-worker-terminal-receipt.schema.json b/schemas/qualification-worker-terminal-receipt.schema.json index a9aad3b..194d69d 100644 --- a/schemas/qualification-worker-terminal-receipt.schema.json +++ b/schemas/qualification-worker-terminal-receipt.schema.json @@ -7,8 +7,10 @@ "required": [ "schema_version", "receipt_id_sha256", "worker_admission_sha256", "dispatch_id_sha256", "provider_identity_sha256", "worker_identity_sha256", + "live_provider_observation_sha256", "admitted_runtime_sha256", "run_id", "run_attempt", "start_id_sha256", "task_id_sha256", - "task_condition_sha256", "capability_handle_sha256", "process", + "task_condition_sha256", "capability_handle_sha256", "launch_attempt", + "launch_attempt_sha256", "process", "oracle_sha256", "result_sha256", "log_sha256", "burned_identities_sha256", "burn_ledger_revision", "burn_receipt_sha256", "burned_at", "ledger_readback_sha256", "effect_started", "delivery_state", @@ -22,24 +24,51 @@ "dispatch_id_sha256": {"$ref": "#/$defs/digest"}, "provider_identity_sha256": {"$ref": "#/$defs/digest"}, "worker_identity_sha256": {"$ref": "#/$defs/digest"}, + "live_provider_observation_sha256": {"$ref": "#/$defs/digest"}, + "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, "run_id": {"$ref": "#/$defs/decimal_id"}, "run_attempt": {"const": "1"}, "start_id_sha256": {"$ref": "#/$defs/digest"}, "task_id_sha256": {"$ref": "#/$defs/digest"}, "task_condition_sha256": {"$ref": "#/$defs/digest"}, "capability_handle_sha256": {"$ref": "#/$defs/digest"}, - "process": { + "launch_attempt": { "type": "object", "additionalProperties": false, - "required": ["pid", "process_group_id", "launched_at", "executable_sha256", "process_start_identity_sha256"], + "required": ["attempted_at", "host_identity_sha256", "executable_sha256", "capability_handle_sha256", "evidence_sha256", "failure_classification"], "properties": { - "pid": {"type": "integer", "minimum": 1}, - "process_group_id": {"type": "integer", "minimum": 1}, - "launched_at": {"$ref": "#/$defs/timestamp"}, + "attempted_at": {"$ref": "#/$defs/timestamp"}, + "host_identity_sha256": {"$ref": "#/$defs/digest"}, "executable_sha256": {"$ref": "#/$defs/digest"}, - "process_start_identity_sha256": {"$ref": "#/$defs/digest"} + "capability_handle_sha256": {"$ref": "#/$defs/digest"}, + "evidence_sha256": {"$ref": "#/$defs/digest"}, + "failure_classification": { + "oneOf": [ + {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED", "PROCESS_IDENTITY_UNAVAILABLE"]}, + {"type": "null"} + ] + } } }, + "launch_attempt_sha256": {"$ref": "#/$defs/digest"}, + "process": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["pid", "process_group_id", "process_start_ticks", "launched_at", "executable_sha256", "process_start_identity_sha256"], + "properties": { + "pid": {"type": "integer", "minimum": 1}, + "process_group_id": {"type": "integer", "minimum": 1}, + "process_start_ticks": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "launched_at": {"$ref": "#/$defs/timestamp"}, + "executable_sha256": {"$ref": "#/$defs/digest"}, + "process_start_identity_sha256": {"$ref": "#/$defs/digest"} + } + }, + {"type": "null"} + ] + }, "oracle_sha256": {"$ref": "#/$defs/digest"}, "result_sha256": {"$ref": "#/$defs/digest"}, "log_sha256": {"$ref": "#/$defs/digest"}, @@ -50,7 +79,7 @@ "ledger_readback_sha256": {"$ref": "#/$defs/digest"}, "effect_started": {"type": "boolean"}, "delivery_state": {"enum": ["not_started", "verified", "uncertain"]}, - "terminal_state": {"enum": ["VERIFIED", "SAFE_HALT", "RECONCILIATION_REQUIRED", "QUARANTINED"]}, + "terminal_state": {"enum": ["VERIFIED", "SAFE_HALT", "RECONCILIATION_REQUIRED", "QUARANTINED", "PRELAUNCH_QUARANTINED"]}, "exit_code": {"type": ["integer", "null"]}, "uncertainty_sha256": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, "quarantine": { @@ -67,6 +96,33 @@ "issuer": {"$ref": "#/$defs/issuer"} }, "allOf": [ + { + "if": {"properties": {"process": {"type": "null"}}}, + "then": { + "properties": { + "effect_started": {"const": false}, + "delivery_state": {"const": "not_started"}, + "terminal_state": {"const": "PRELAUNCH_QUARANTINED"}, + "uncertainty_sha256": {"type": "null"}, + "launch_attempt": { + "properties": { + "failure_classification": {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED", "PROCESS_IDENTITY_UNAVAILABLE"]} + } + }, + "quarantine": { + "properties": { + "active": {"const": true}, + "reason_code": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$"}, + "evidence_sha256": {"$ref": "#/$defs/digest"} + } + } + } + } + }, + { + "if": {"properties": {"terminal_state": {"not": {"const": "PRELAUNCH_QUARANTINED"}}}}, + "then": {"properties": {"process": {"type": "object"}}} + }, { "if": {"properties": {"delivery_state": {"const": "uncertain"}}}, "then": { diff --git a/scripts/qualification_worker_contract.py b/scripts/qualification_worker_contract.py new file mode 100644 index 0000000..4bdb5c2 --- /dev/null +++ b/scripts/qualification_worker_contract.py @@ -0,0 +1,428 @@ +"""Validate the remote-safe qualification worker terminal contract.""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from typing import Any + +PROCESS_START_DOMAIN = b"OpenAdapt qualification worker process start v1\0" +LAUNCH_ATTEMPT_DOMAIN = b"OpenAdapt qualification worker launch attempt v1\0" +TERMINAL_RECEIPT_DOMAIN = b"OpenAdapt qualification worker terminal receipt v1\0" +TERMINAL_SCHEMA = "openadapt.qualification-worker-terminal-receipt/v1" +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +DECIMAL = re.compile(r"^[1-9][0-9]*$") +TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +TERMINAL_FIELDS = { + "schema_version", + "receipt_id_sha256", + "worker_admission_sha256", + "dispatch_id_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", + "run_id", + "run_attempt", + "start_id_sha256", + "task_id_sha256", + "task_condition_sha256", + "capability_handle_sha256", + "launch_attempt", + "launch_attempt_sha256", + "process", + "oracle_sha256", + "result_sha256", + "log_sha256", + "burned_identities_sha256", + "burn_ledger_revision", + "burn_receipt_sha256", + "burned_at", + "ledger_readback_sha256", + "effect_started", + "delivery_state", + "terminal_state", + "exit_code", + "uncertainty_sha256", + "quarantine", + "completed_at", + "issuer", +} +PROCESS_FIELDS = { + "pid", + "process_group_id", + "process_start_ticks", + "launched_at", + "executable_sha256", + "process_start_identity_sha256", +} +LAUNCH_ATTEMPT_FIELDS = { + "attempted_at", + "host_identity_sha256", + "executable_sha256", + "capability_handle_sha256", + "evidence_sha256", + "failure_classification", +} +ISSUER_FIELDS = { + "repository", + "repository_id", + "repository_owner_id", + "workflow", + "ref", + "source_commit", + "environment", +} + + +class QualificationWorkerContractError(ValueError): + """The worker terminal receipt or replay is invalid.""" + + +def canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def digest(domain: bytes, value: Any) -> str: + return "sha256:" + hashlib.sha256(domain + canonical(value)).hexdigest() + + +def _closed(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise QualificationWorkerContractError( + f"{label} must contain exactly {sorted(fields)}; got {actual}" + ) + return value + + +def _digest(value: Any, label: str) -> str: + if not isinstance(value, str) or DIGEST.fullmatch(value) is None: + raise QualificationWorkerContractError( + f"{label} must be a lowercase sha256 digest" + ) + return value + + +def _timestamp(value: Any, label: str) -> datetime: + if not isinstance(value, str) or TIMESTAMP.fullmatch(value) is None: + raise QualificationWorkerContractError(f"{label} is not an exact UTC time") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError as exc: + raise QualificationWorkerContractError( + f"{label} is not a calendar time" + ) from exc + + +def process_start_projection(receipt: dict[str, Any]) -> dict[str, Any]: + process = receipt["process"] + return { + "provider_identity_sha256": receipt["provider_identity_sha256"], + "worker_identity_sha256": receipt["worker_identity_sha256"], + "live_provider_observation_sha256": receipt[ + "live_provider_observation_sha256" + ], + "admitted_runtime_sha256": receipt["admitted_runtime_sha256"], + "run_id": receipt["run_id"], + "run_attempt": receipt["run_attempt"], + "start_id_sha256": receipt["start_id_sha256"], + "dispatch_id_sha256": receipt["dispatch_id_sha256"], + "pid": process["pid"], + "process_group_id": process["process_group_id"], + "process_start_ticks": process["process_start_ticks"], + "launched_at": process["launched_at"], + "executable_sha256": process["executable_sha256"], + } + + +def launch_attempt_projection(receipt: dict[str, Any]) -> dict[str, Any]: + return { + "worker_admission_sha256": receipt["worker_admission_sha256"], + "dispatch_id_sha256": receipt["dispatch_id_sha256"], + "provider_identity_sha256": receipt["provider_identity_sha256"], + "worker_identity_sha256": receipt["worker_identity_sha256"], + "live_provider_observation_sha256": receipt[ + "live_provider_observation_sha256" + ], + "admitted_runtime_sha256": receipt["admitted_runtime_sha256"], + "run_id": receipt["run_id"], + "run_attempt": receipt["run_attempt"], + "start_id_sha256": receipt["start_id_sha256"], + "capability_handle_sha256": receipt["capability_handle_sha256"], + "launch_attempt": receipt["launch_attempt"], + } + + +def terminal_receipt_projection(receipt: dict[str, Any]) -> dict[str, Any]: + projection = dict(receipt) + projection.pop("receipt_id_sha256", None) + return projection + + +def validate_terminal_receipt(value: Any) -> dict[str, Any]: + receipt = _closed(value, TERMINAL_FIELDS, "worker terminal receipt") + if receipt["schema_version"] != TERMINAL_SCHEMA: + raise QualificationWorkerContractError("terminal receipt schema is invalid") + if receipt["run_attempt"] != "1" or not isinstance(receipt["run_id"], str) or DECIMAL.fullmatch(receipt["run_id"]) is None: + raise QualificationWorkerContractError("terminal receipt run identity is invalid") + for field in ( + "receipt_id_sha256", + "worker_admission_sha256", + "dispatch_id_sha256", + "provider_identity_sha256", + "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", + "start_id_sha256", + "task_id_sha256", + "task_condition_sha256", + "capability_handle_sha256", + "launch_attempt_sha256", + "oracle_sha256", + "result_sha256", + "log_sha256", + "burned_identities_sha256", + "burn_receipt_sha256", + "ledger_readback_sha256", + ): + _digest(receipt[field], field) + launch_attempt = _closed( + receipt["launch_attempt"], LAUNCH_ATTEMPT_FIELDS, "worker launch attempt" + ) + attempted_at = _timestamp(launch_attempt["attempted_at"], "launch attempted_at") + for field in ( + "host_identity_sha256", + "executable_sha256", + "capability_handle_sha256", + "evidence_sha256", + ): + _digest(launch_attempt[field], f"launch attempt {field}") + if launch_attempt["capability_handle_sha256"] != receipt["capability_handle_sha256"]: + raise QualificationWorkerContractError( + "launch attempt capability differs from the terminal receipt" + ) + if receipt["launch_attempt_sha256"] != digest( + LAUNCH_ATTEMPT_DOMAIN, launch_attempt_projection(receipt) + ): + raise QualificationWorkerContractError("worker launch attempt identity differs") + process_value = receipt["process"] + process: dict[str, Any] | None + if process_value is None: + process = None + else: + process = _closed(process_value, PROCESS_FIELDS, "worker process") + for field in ("pid", "process_group_id"): + if ( + not isinstance(process[field], int) + or isinstance(process[field], bool) + or process[field] < 1 + ): + raise QualificationWorkerContractError( + f"worker process {field} is invalid" + ) + if ( + not isinstance(process["process_start_ticks"], str) + or DECIMAL.fullmatch(process["process_start_ticks"]) is None + ): + raise QualificationWorkerContractError( + "worker process start ticks are invalid" + ) + _timestamp(process["launched_at"], "worker process launched_at") + _digest(process["executable_sha256"], "worker process executable") + _digest(process["process_start_identity_sha256"], "worker process identity") + if process["process_start_identity_sha256"] != digest( + PROCESS_START_DOMAIN, process_start_projection(receipt) + ): + raise QualificationWorkerContractError( + "worker process start identity differs" + ) + if not isinstance(receipt["burn_ledger_revision"], int) or isinstance(receipt["burn_ledger_revision"], bool) or receipt["burn_ledger_revision"] < 1: + raise QualificationWorkerContractError("burn ledger revision is invalid") + burned_at = _timestamp(receipt["burned_at"], "burned_at") + completed_at = _timestamp(receipt["completed_at"], "completed_at") + if process is not None: + launched_at = _timestamp(process["launched_at"], "launched_at") + if ( + launch_attempt["failure_classification"] is not None + or launch_attempt["executable_sha256"] != process["executable_sha256"] + or not attempted_at <= launched_at + ): + raise QualificationWorkerContractError( + "completed process differs from its launch attempt" + ) + earliest = launched_at + else: + earliest = attempted_at + if not earliest <= burned_at <= completed_at: + raise QualificationWorkerContractError("terminal receipt time order is invalid") + if not isinstance(receipt["effect_started"], bool): + raise QualificationWorkerContractError("effect_started must be boolean") + if receipt["delivery_state"] not in {"not_started", "verified", "uncertain"}: + raise QualificationWorkerContractError("delivery state is invalid") + if receipt["terminal_state"] not in { + "VERIFIED", + "SAFE_HALT", + "RECONCILIATION_REQUIRED", + "QUARANTINED", + "PRELAUNCH_QUARANTINED", + }: + raise QualificationWorkerContractError("terminal state is invalid") + quarantine = _closed( + receipt["quarantine"], + {"active", "reason_code", "evidence_sha256"}, + "worker quarantine", + ) + if not isinstance(quarantine["active"], bool): + raise QualificationWorkerContractError("quarantine active must be boolean") + if process is None: + if ( + receipt["effect_started"] + or receipt["delivery_state"] != "not_started" + or receipt["terminal_state"] != "PRELAUNCH_QUARANTINED" + or receipt["uncertainty_sha256"] is not None + or not quarantine["active"] + or not isinstance(quarantine["reason_code"], str) + or launch_attempt["failure_classification"] + not in { + "PROCESS_START_REFUSED", + "PROCESS_START_FAILED", + "PROCESS_IDENTITY_UNAVAILABLE", + } + or quarantine["reason_code"] + != launch_attempt["failure_classification"] + ): + raise QualificationWorkerContractError( + "a prelaunch terminal receipt must quarantine without an effect" + ) + _digest(quarantine["evidence_sha256"], "prelaunch quarantine evidence") + if receipt["delivery_state"] == "uncertain": + _digest(receipt["uncertainty_sha256"], "uncertainty") + if ( + not receipt["effect_started"] + or receipt["terminal_state"] + not in {"RECONCILIATION_REQUIRED", "QUARANTINED"} + or not quarantine["active"] + ): + raise QualificationWorkerContractError( + "uncertain delivery must quarantine and require reconciliation" + ) + if receipt["terminal_state"] == "VERIFIED" and ( + receipt["delivery_state"] != "verified" + or receipt["uncertainty_sha256"] is not None + ): + raise QualificationWorkerContractError("VERIFIED terminal receipt is invalid") + if receipt["terminal_state"] == "SAFE_HALT" and ( + receipt["effect_started"] + or receipt["delivery_state"] != "not_started" + or receipt["uncertainty_sha256"] is not None + ): + raise QualificationWorkerContractError("SAFE_HALT terminal receipt is invalid") + issuer = _closed(receipt["issuer"], ISSUER_FIELDS, "terminal issuer") + expected_issuer = { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-qualification-worker-terminal-receipt.yml", + "ref": "refs/heads/main", + "environment": "qualification-worker-terminal-receipt", + } + for field, expected in expected_issuer.items(): + if issuer[field] != expected: + raise QualificationWorkerContractError("terminal issuer identity differs") + if not isinstance(issuer["source_commit"], str) or re.fullmatch(r"[0-9a-f]{40}", issuer["source_commit"]) is None: + raise QualificationWorkerContractError("terminal issuer commit is invalid") + if receipt["receipt_id_sha256"] != digest( + TERMINAL_RECEIPT_DOMAIN, terminal_receipt_projection(receipt) + ): + raise QualificationWorkerContractError("terminal receipt identity differs") + return receipt + + +def validate_terminal_replay(previous_value: Any, current_value: Any) -> dict[str, Any]: + previous = validate_terminal_receipt(previous_value) + current = validate_terminal_receipt(current_value) + if canonical(previous) == canonical(current): + return current + previous_dispatch = ( + previous["worker_admission_sha256"], + previous["dispatch_id_sha256"], + previous["run_id"], + previous["start_id_sha256"], + ) + current_dispatch = ( + current["worker_admission_sha256"], + current["dispatch_id_sha256"], + current["run_id"], + current["start_id_sha256"], + ) + if previous_dispatch == current_dispatch: + raise QualificationWorkerContractError( + "one worker dispatch has conflicting terminal receipts" + ) + return current + + +def validate_terminal_bindings( + receipt_value: Any, + *, + admission: Any, + dispatch: Any, +) -> dict[str, Any]: + receipt = validate_terminal_receipt(receipt_value) + if not isinstance(admission, dict) or not isinstance(dispatch, dict): + raise QualificationWorkerContractError( + "worker admission and dispatch must be objects" + ) + admission_bindings = { + "worker_admission_sha256": admission.get("admission_id_sha256"), + "provider_identity_sha256": admission.get("provider_identity_sha256"), + "worker_identity_sha256": admission.get("worker_identity_sha256"), + "live_provider_observation_sha256": admission.get( + "live_provider_observation_sha256" + ), + "admitted_runtime_sha256": admission.get("admitted_runtime_sha256"), + "capability_handle_sha256": admission.get("capability_handle_sha256"), + "launch_attempt_host_identity_sha256": admission.get( + "host_identity_sha256" + ), + } + dispatch_bindings = { + "worker_admission_sha256": dispatch.get("worker_admission_sha256"), + "dispatch_id_sha256": dispatch.get("dispatch_id_sha256"), + "provider_identity_sha256": dispatch.get("provider_identity_sha256"), + "worker_identity_sha256": dispatch.get("worker_identity_sha256"), + "live_provider_observation_sha256": dispatch.get( + "live_provider_observation_sha256" + ), + "admitted_runtime_sha256": dispatch.get("admitted_runtime_sha256"), + "run_id": dispatch.get("run_id"), + "run_attempt": dispatch.get("run_attempt"), + "start_id_sha256": dispatch.get("start_id_sha256"), + "task_id_sha256": dispatch.get("task_id_sha256"), + "task_condition_sha256": dispatch.get("task_condition_sha256"), + "capability_handle_sha256": dispatch.get("capability_handle_sha256"), + } + for source, bindings in ( + ("admission", admission_bindings), + ("dispatch", dispatch_bindings), + ): + for field, expected in bindings.items(): + actual = ( + receipt["launch_attempt"]["host_identity_sha256"] + if field == "launch_attempt_host_identity_sha256" + else receipt[field] + ) + if actual != expected: + raise QualificationWorkerContractError( + f"terminal receipt {field} differs from the {source}" + ) + return receipt diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index e935304..47de57c 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -45,6 +45,8 @@ "worker_identity_sha256", "live_provider_observation_sha256", "admitted_runtime_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", "worker_image_sha256", "baseline_sha256", "host_identity_sha256", @@ -112,12 +114,16 @@ "dispatch_id_sha256", "provider_identity_sha256", "worker_identity_sha256", + "live_provider_observation_sha256", + "admitted_runtime_sha256", "run_id", "run_attempt", "start_id_sha256", "task_id_sha256", "task_condition_sha256", "capability_handle_sha256", + "launch_attempt", + "launch_attempt_sha256", "process", "oracle_sha256", "result_sha256", @@ -675,7 +681,28 @@ def test_worker_contracts_pin_central_authority_and_terminal_uncertainty(self) - dispatch["$defs"]["issuer"]["properties"]["workflow"]["const"], ".github/workflows/issue-qualification-worker-dispatch.yml", ) - uncertain = terminal["allOf"][0]["then"]["properties"] + process_schema = terminal["properties"]["process"]["oneOf"][0] + self.assertEqual( + set(process_schema["required"]), + { + "pid", + "process_group_id", + "process_start_ticks", + "launched_at", + "executable_sha256", + "process_start_identity_sha256", + }, + ) + prelaunch = terminal["allOf"][0]["then"]["properties"] + self.assertIs(prelaunch["effect_started"]["const"], False) + self.assertEqual(prelaunch["delivery_state"]["const"], "not_started") + self.assertEqual( + prelaunch["terminal_state"]["const"], "PRELAUNCH_QUARANTINED" + ) + self.assertIs( + prelaunch["quarantine"]["properties"]["active"]["const"], True + ) + uncertain = terminal["allOf"][2]["then"]["properties"] self.assertIs(uncertain["effect_started"]["const"], True) self.assertEqual( uncertain["terminal_state"]["enum"], diff --git a/tests/test_qualification_worker_contract.py b/tests/test_qualification_worker_contract.py new file mode 100644 index 0000000..ecd35b8 --- /dev/null +++ b/tests/test_qualification_worker_contract.py @@ -0,0 +1,192 @@ +"""Tests for the qualification worker terminal authority contract.""" + +from __future__ import annotations + +import copy +import hashlib +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import qualification_worker_contract as worker + + +def sha(label: str) -> str: + return "sha256:" + hashlib.sha256(label.encode()).hexdigest() + + +def admission() -> dict: + return { + "admission_id_sha256": sha("admission"), + "provider_identity_sha256": sha("provider"), + "worker_identity_sha256": sha("worker"), + "live_provider_observation_sha256": sha("observation"), + "admitted_runtime_sha256": sha("runtime"), + "host_identity_sha256": sha("host"), + "capability_handle_sha256": sha("capability"), + } + + +def dispatch() -> dict: + return { + "dispatch_id_sha256": sha("dispatch"), + "worker_admission_sha256": sha("admission"), + "provider_identity_sha256": sha("provider"), + "worker_identity_sha256": sha("worker"), + "live_provider_observation_sha256": sha("observation"), + "admitted_runtime_sha256": sha("runtime"), + "run_id": "100", + "run_attempt": "1", + "start_id_sha256": sha("start"), + "task_id_sha256": sha("task"), + "task_condition_sha256": sha("condition"), + "capability_handle_sha256": sha("capability"), + } + + +def terminal(*, process: bool = True) -> dict: + value = { + "schema_version": worker.TERMINAL_SCHEMA, + "receipt_id_sha256": sha("pending"), + "worker_admission_sha256": sha("admission"), + "dispatch_id_sha256": sha("dispatch"), + "provider_identity_sha256": sha("provider"), + "worker_identity_sha256": sha("worker"), + "live_provider_observation_sha256": sha("observation"), + "admitted_runtime_sha256": sha("runtime"), + "run_id": "100", + "run_attempt": "1", + "start_id_sha256": sha("start"), + "task_id_sha256": sha("task"), + "task_condition_sha256": sha("condition"), + "capability_handle_sha256": sha("capability"), + "launch_attempt": { + "attempted_at": "2026-08-27T11:59:59Z", + "host_identity_sha256": sha("host"), + "executable_sha256": sha("executable"), + "capability_handle_sha256": sha("capability"), + "evidence_sha256": sha("launch-evidence"), + "failure_classification": "PROCESS_START_FAILED" if not process else None, + }, + "launch_attempt_sha256": sha("pending-launch"), + "process": None, + "oracle_sha256": sha("oracle"), + "result_sha256": sha("result"), + "log_sha256": sha("log"), + "burned_identities_sha256": sha("burned"), + "burn_ledger_revision": 7, + "burn_receipt_sha256": sha("burn-receipt"), + "burned_at": "2026-08-27T12:01:00Z", + "ledger_readback_sha256": sha("readback"), + "effect_started": False, + "delivery_state": "not_started", + "terminal_state": "SAFE_HALT" if process else "PRELAUNCH_QUARANTINED", + "exit_code": None, + "uncertainty_sha256": None, + "quarantine": { + "active": True, + "reason_code": "PROCESS_START_FAILED" if not process else "SAFE_HALT", + "evidence_sha256": sha("quarantine"), + }, + "completed_at": "2026-08-27T12:01:01Z", + "issuer": { + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "workflow": ".github/workflows/issue-qualification-worker-terminal-receipt.yml", + "ref": "refs/heads/main", + "source_commit": "a" * 40, + "environment": "qualification-worker-terminal-receipt", + }, + } + if process: + value["process"] = { + "pid": 4242, + "process_group_id": 4242, + "process_start_ticks": "987654321", + "launched_at": "2026-08-27T12:00:00Z", + "executable_sha256": sha("executable"), + "process_start_identity_sha256": sha("pending-process"), + } + value["process"]["process_start_identity_sha256"] = worker.digest( + worker.PROCESS_START_DOMAIN, worker.process_start_projection(value) + ) + value["launch_attempt_sha256"] = worker.digest( + worker.LAUNCH_ATTEMPT_DOMAIN, worker.launch_attempt_projection(value) + ) + value["receipt_id_sha256"] = worker.digest( + worker.TERMINAL_RECEIPT_DOMAIN, worker.terminal_receipt_projection(value) + ) + return value + + +def refresh(value: dict) -> None: + if value["process"] is not None: + value["process"]["process_start_identity_sha256"] = worker.digest( + worker.PROCESS_START_DOMAIN, worker.process_start_projection(value) + ) + value["launch_attempt_sha256"] = worker.digest( + worker.LAUNCH_ATTEMPT_DOMAIN, worker.launch_attempt_projection(value) + ) + value["receipt_id_sha256"] = worker.digest( + worker.TERMINAL_RECEIPT_DOMAIN, worker.terminal_receipt_projection(value) + ) + + +class QualificationWorkerContractTests(unittest.TestCase): + def test_terminal_binds_admission_dispatch_and_kernel_process_generation(self) -> None: + value = terminal() + self.assertEqual( + worker.validate_terminal_bindings( + value, admission=admission(), dispatch=dispatch() + ), + value, + ) + self.assertEqual(value["process"]["process_start_ticks"], "987654321") + + def test_pid_reuse_or_process_start_tick_tamper_is_refused(self) -> None: + original = terminal() + tampered = copy.deepcopy(original) + tampered["process"]["process_start_ticks"] = "987654322" + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "process start identity" + ): + worker.validate_terminal_receipt(tampered) + + conflicting = copy.deepcopy(original) + conflicting["process"]["process_start_ticks"] = "987654322" + refresh(conflicting) + worker.validate_terminal_receipt(conflicting) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "conflicting terminal" + ): + worker.validate_terminal_replay(original, conflicting) + + def test_prelaunch_failure_is_a_bound_quarantined_terminal_receipt(self) -> None: + value = terminal(process=False) + self.assertEqual(worker.validate_terminal_receipt(value), value) + changed = copy.deepcopy(value) + changed["quarantine"]["active"] = False + refresh(changed) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "prelaunch terminal" + ): + worker.validate_terminal_receipt(changed) + + def test_cross_contract_identity_drift_is_refused(self) -> None: + changed_dispatch = dispatch() + changed_dispatch["live_provider_observation_sha256"] = sha("other") + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, + "live_provider_observation_sha256 differs", + ): + worker.validate_terminal_bindings( + terminal(), admission=admission(), dispatch=changed_dispatch + ) + + +if __name__ == "__main__": + unittest.main() From d95fb52bd4c6fe677390e16f3ee40441eff684e6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:20:52 -0400 Subject: [PATCH 10/18] fix: bind worker terminal launch identity --- ...cation-worker-terminal-receipt.schema.json | 19 ++++- scripts/qualification_worker_contract.py | 40 +++++----- tests/test_public_trust_schemas.py | 10 ++- tests/test_qualification_worker_contract.py | 74 ++++++++++++++++++- 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/schemas/qualification-worker-terminal-receipt.schema.json b/schemas/qualification-worker-terminal-receipt.schema.json index 194d69d..79553a6 100644 --- a/schemas/qualification-worker-terminal-receipt.schema.json +++ b/schemas/qualification-worker-terminal-receipt.schema.json @@ -35,16 +35,17 @@ "launch_attempt": { "type": "object", "additionalProperties": false, - "required": ["attempted_at", "host_identity_sha256", "executable_sha256", "capability_handle_sha256", "evidence_sha256", "failure_classification"], + "required": ["attempted_at", "host_identity_sha256", "executable_sha256", "capability_handle_sha256", "evidence_sha256", "child_created", "failure_classification"], "properties": { "attempted_at": {"$ref": "#/$defs/timestamp"}, "host_identity_sha256": {"$ref": "#/$defs/digest"}, "executable_sha256": {"$ref": "#/$defs/digest"}, "capability_handle_sha256": {"$ref": "#/$defs/digest"}, "evidence_sha256": {"$ref": "#/$defs/digest"}, + "child_created": {"type": "boolean"}, "failure_classification": { "oneOf": [ - {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED", "PROCESS_IDENTITY_UNAVAILABLE"]}, + {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED"]}, {"type": "null"} ] } @@ -106,7 +107,8 @@ "uncertainty_sha256": {"type": "null"}, "launch_attempt": { "properties": { - "failure_classification": {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED", "PROCESS_IDENTITY_UNAVAILABLE"]} + "child_created": {"const": false}, + "failure_classification": {"enum": ["PROCESS_START_REFUSED", "PROCESS_START_FAILED"]} } }, "quarantine": { @@ -119,9 +121,18 @@ } } }, + { + "if": {"properties": {"terminal_state": {"const": "PRELAUNCH_QUARANTINED"}}}, + "then": {"properties": {"process": {"type": "null"}}} + }, { "if": {"properties": {"terminal_state": {"not": {"const": "PRELAUNCH_QUARANTINED"}}}}, - "then": {"properties": {"process": {"type": "object"}}} + "then": { + "properties": { + "process": {"type": "object"}, + "launch_attempt": {"properties": {"child_created": {"const": true}}} + } + } }, { "if": {"properties": {"delivery_state": {"const": "uncertain"}}}, diff --git a/scripts/qualification_worker_contract.py b/scripts/qualification_worker_contract.py index 4bdb5c2..d7fb51f 100644 --- a/scripts/qualification_worker_contract.py +++ b/scripts/qualification_worker_contract.py @@ -66,6 +66,7 @@ "executable_sha256", "capability_handle_sha256", "evidence_sha256", + "child_created", "failure_classification", } ISSUER_FIELDS = { @@ -206,6 +207,10 @@ def validate_terminal_receipt(value: Any) -> dict[str, Any]: "evidence_sha256", ): _digest(launch_attempt[field], f"launch attempt {field}") + if not isinstance(launch_attempt["child_created"], bool): + raise QualificationWorkerContractError( + "launch attempt child_created must be boolean" + ) if launch_attempt["capability_handle_sha256"] != receipt["capability_handle_sha256"]: raise QualificationWorkerContractError( "launch attempt capability differs from the terminal receipt" @@ -253,17 +258,22 @@ def validate_terminal_receipt(value: Any) -> dict[str, Any]: launched_at = _timestamp(process["launched_at"], "launched_at") if ( launch_attempt["failure_classification"] is not None + or not launch_attempt["child_created"] or launch_attempt["executable_sha256"] != process["executable_sha256"] or not attempted_at <= launched_at ): raise QualificationWorkerContractError( "completed process differs from its launch attempt" ) - earliest = launched_at + if not attempted_at <= burned_at <= launched_at <= completed_at: + raise QualificationWorkerContractError( + "terminal receipt time order is invalid" + ) else: - earliest = attempted_at - if not earliest <= burned_at <= completed_at: - raise QualificationWorkerContractError("terminal receipt time order is invalid") + if not attempted_at <= burned_at <= completed_at: + raise QualificationWorkerContractError( + "terminal receipt time order is invalid" + ) if not isinstance(receipt["effect_started"], bool): raise QualificationWorkerContractError("effect_started must be boolean") if receipt["delivery_state"] not in {"not_started", "verified", "uncertain"}: @@ -295,15 +305,21 @@ def validate_terminal_receipt(value: Any) -> dict[str, Any]: not in { "PROCESS_START_REFUSED", "PROCESS_START_FAILED", - "PROCESS_IDENTITY_UNAVAILABLE", } + or launch_attempt["child_created"] or quarantine["reason_code"] != launch_attempt["failure_classification"] + or quarantine["evidence_sha256"] + != launch_attempt["evidence_sha256"] ): raise QualificationWorkerContractError( "a prelaunch terminal receipt must quarantine without an effect" ) _digest(quarantine["evidence_sha256"], "prelaunch quarantine evidence") + elif receipt["terminal_state"] == "PRELAUNCH_QUARANTINED": + raise QualificationWorkerContractError( + "PRELAUNCH_QUARANTINED cannot contain a process identity" + ) if receipt["delivery_state"] == "uncertain": _digest(receipt["uncertainty_sha256"], "uncertainty") if ( @@ -352,19 +368,7 @@ def validate_terminal_replay(previous_value: Any, current_value: Any) -> dict[st current = validate_terminal_receipt(current_value) if canonical(previous) == canonical(current): return current - previous_dispatch = ( - previous["worker_admission_sha256"], - previous["dispatch_id_sha256"], - previous["run_id"], - previous["start_id_sha256"], - ) - current_dispatch = ( - current["worker_admission_sha256"], - current["dispatch_id_sha256"], - current["run_id"], - current["start_id_sha256"], - ) - if previous_dispatch == current_dispatch: + if previous["dispatch_id_sha256"] == current["dispatch_id_sha256"]: raise QualificationWorkerContractError( "one worker dispatch has conflicting terminal receipts" ) diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 47de57c..de3d856 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any - ROOT = Path(__file__).resolve().parents[1] SCHEMA_ROOT = ROOT / "schemas" @@ -45,8 +44,6 @@ "worker_identity_sha256", "live_provider_observation_sha256", "admitted_runtime_sha256", - "live_provider_observation_sha256", - "admitted_runtime_sha256", "worker_image_sha256", "baseline_sha256", "host_identity_sha256", @@ -702,7 +699,12 @@ def test_worker_contracts_pin_central_authority_and_terminal_uncertainty(self) - self.assertIs( prelaunch["quarantine"]["properties"]["active"]["const"], True ) - uncertain = terminal["allOf"][2]["then"]["properties"] + uncertain = next( + rule["then"]["properties"] + for rule in terminal["allOf"] + if rule["if"]["properties"].get("delivery_state") + == {"const": "uncertain"} + ) self.assertIs(uncertain["effect_started"]["const"], True) self.assertEqual( uncertain["terminal_state"]["enum"], diff --git a/tests/test_qualification_worker_contract.py b/tests/test_qualification_worker_contract.py index ecd35b8..7264976 100644 --- a/tests/test_qualification_worker_contract.py +++ b/tests/test_qualification_worker_contract.py @@ -69,6 +69,7 @@ def terminal(*, process: bool = True) -> dict: "executable_sha256": sha("executable"), "capability_handle_sha256": sha("capability"), "evidence_sha256": sha("launch-evidence"), + "child_created": process, "failure_classification": "PROCESS_START_FAILED" if not process else None, }, "launch_attempt_sha256": sha("pending-launch"), @@ -89,7 +90,7 @@ def terminal(*, process: bool = True) -> dict: "quarantine": { "active": True, "reason_code": "PROCESS_START_FAILED" if not process else "SAFE_HALT", - "evidence_sha256": sha("quarantine"), + "evidence_sha256": sha("launch-evidence") if not process else sha("quarantine"), }, "completed_at": "2026-08-27T12:01:01Z", "issuer": { @@ -107,7 +108,7 @@ def terminal(*, process: bool = True) -> dict: "pid": 4242, "process_group_id": 4242, "process_start_ticks": "987654321", - "launched_at": "2026-08-27T12:00:00Z", + "launched_at": "2026-08-27T12:01:00Z", "executable_sha256": sha("executable"), "process_start_identity_sha256": sha("pending-process"), } @@ -165,6 +166,45 @@ def test_pid_reuse_or_process_start_tick_tamper_is_refused(self) -> None: ): worker.validate_terminal_replay(original, conflicting) + def test_capability_burn_must_precede_process_launch(self) -> None: + launched_before_burn = terminal() + launched_before_burn["process"]["launched_at"] = "2026-08-27T11:59:59Z" + refresh(launched_before_burn) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "time order" + ): + worker.validate_terminal_receipt(launched_before_burn) + + launch_attempt_after_burn = terminal() + launch_attempt_after_burn["launch_attempt"]["attempted_at"] = ( + "2026-08-27T12:00:01Z" + ) + launch_attempt_after_burn["process"]["launched_at"] = ( + "2026-08-27T12:00:02Z" + ) + refresh(launch_attempt_after_burn) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "time order" + ): + worker.validate_terminal_receipt(launch_attempt_after_burn) + + def test_dispatch_id_uniquely_selects_one_terminal_receipt(self) -> None: + previous = terminal() + for field, changed_value in ( + ("worker_admission_sha256", sha("other-admission")), + ("run_id", "101"), + ("start_id_sha256", sha("other-start")), + ): + current = copy.deepcopy(previous) + current[field] = changed_value + refresh(current) + worker.validate_terminal_receipt(current) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, + "one worker dispatch has conflicting terminal receipts", + ): + worker.validate_terminal_replay(previous, current) + def test_prelaunch_failure_is_a_bound_quarantined_terminal_receipt(self) -> None: value = terminal(process=False) self.assertEqual(worker.validate_terminal_receipt(value), value) @@ -176,6 +216,36 @@ def test_prelaunch_failure_is_a_bound_quarantined_terminal_receipt(self) -> None ): worker.validate_terminal_receipt(changed) + def test_prelaunch_refuses_post_pid_classification_and_split_evidence(self) -> None: + identity_unavailable = terminal(process=False) + identity_unavailable["launch_attempt"]["failure_classification"] = ( + "PROCESS_IDENTITY_UNAVAILABLE" + ) + identity_unavailable["quarantine"]["reason_code"] = ( + "PROCESS_IDENTITY_UNAVAILABLE" + ) + refresh(identity_unavailable) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "prelaunch terminal" + ): + worker.validate_terminal_receipt(identity_unavailable) + + process_exists = terminal(process=True) + process_exists["terminal_state"] = "PRELAUNCH_QUARANTINED" + refresh(process_exists) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "cannot contain a process" + ): + worker.validate_terminal_receipt(process_exists) + + split_evidence = terminal(process=False) + split_evidence["quarantine"]["evidence_sha256"] = sha("other-evidence") + refresh(split_evidence) + with self.assertRaisesRegex( + worker.QualificationWorkerContractError, "prelaunch terminal" + ): + worker.validate_terminal_receipt(split_evidence) + def test_cross_contract_identity_drift_is_refused(self) -> None: changed_dispatch = dispatch() changed_dispatch["live_provider_observation_sha256"] = sha("other") From 63ab50f469f6a32b9b865ba7a608ad5d84c82037 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:27:14 -0400 Subject: [PATCH 11/18] feat: bind public trust KMS registry --- .../qualification-signer-registry.schema.json | 64 ++++++ scripts/production_trust.py | 56 ++++- scripts/public_trust_kms.py | 86 +++++++- scripts/validate_evidence_registry.py | 23 ++- scripts/validate_production_lifecycle_ref.py | 10 +- .../verify_production_release_admission.py | 13 +- tests/test_evidence_registry.py | 75 ++++++- tests/test_production_trust.py | 192 +++++++++++++++++- tests/test_public_trust_kms.py | 6 + 9 files changed, 492 insertions(+), 33 deletions(-) diff --git a/schemas/qualification-signer-registry.schema.json b/schemas/qualification-signer-registry.schema.json index 64fd7a0..dc681fe 100644 --- a/schemas/qualification-signer-registry.schema.json +++ b/schemas/qualification-signer-registry.schema.json @@ -24,6 +24,12 @@ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" }, "signer": { + "oneOf": [ + {"$ref": "#/$defs/ed25519_signer"}, + {"$ref": "#/$defs/public_trust_p256_signer"} + ] + }, + "ed25519_signer": { "type": "object", "additionalProperties": false, "required": [ @@ -84,6 +90,64 @@ "oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}] } } + }, + "public_trust_p256_signer": { + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", "key_id", "key_origin", "kms_key_arn", "public_key", + "public_key_spki_der_base64", "public_key_sha256", "signature_encoding", + "statement_schema_versions", "allowed_usages", "allowed_kinds", + "allowed_workflows", "allowed_ref_prefixes", "allowed_environments", + "status", "revoked_at" + ], + "properties": { + "algorithm": {"const": "ecdsa-p256-sha256"}, + "key_id": {"type": "string", "pattern": "^oa-public-trust-p256-[0-9a-f]{16}$"}, + "key_origin": {"const": "aws-kms"}, + "kms_key_arn": {"type": "string", "pattern": "^arn:aws:kms:us-east-1:992382684924:key/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"}, + "public_key": {"type": "string", "pattern": "^[A-Za-z0-9_-]{87}$"}, + "public_key_spki_der_base64": {"type": "string", "pattern": "^[A-Za-z0-9+/]{122}==$"}, + "public_key_sha256": {"$ref": "#/$defs/digest"}, + "signature_encoding": {"const": "asn1-der-low-s-base64-rfc4648-padded"}, + "statement_schema_versions": {"const": ["openadapt.production-public-trust-signing-statement/v1"]}, + "allowed_usages": {"const": ["production-public-evidence"]}, + "allowed_kinds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "production-acceptance-manifest", + "production-acceptance-summary", + "production-cloud-deploy-authorization", + "production-cloud-deployment-result", + "production-current-default", + "production-deployment-observation", + "production-lifecycle-checkpoint", + "qualification-admission", + "qualification-authority-state-receipt", + "qualification-campaign-permit", + "qualification-campaign-permit-policy", + "qualification-campaign-permit-receipt", + "qualification-campaign-permit-request", + "qualification-evidence-decision-receipt", + "qualification-release", + "qualification-revocation-state-receipt", + "support-release-admission" + ] + } + }, + "allowed_workflows": { + "const": ["/OpenAdaptAI/.github/.github/workflows/sign-production-evidence.yml@refs/heads/main"] + }, + "allowed_ref_prefixes": {"const": ["refs/heads/main"]}, + "allowed_environments": {"const": ["public-trust-signing"]}, + "status": {"enum": ["active", "revoked"]}, + "revoked_at": { + "oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}] + } + } } } } diff --git a/scripts/production_trust.py b/scripts/production_trust.py index d0ec2d5..67d3205 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -1180,7 +1180,9 @@ def validate_publication_recovery_replay( ) -def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, Any]: +def _validate_receipt_structure( + value: Any, *, now: datetime | None = None +) -> dict[str, Any]: fields = { "schema_version", "decision_identity_sha256", "decision_revision", "decision_commitment_sha256", "evidence_manifest_sha256", @@ -1284,6 +1286,36 @@ def validate_receipt(value: Any, *, now: datetime | None = None) -> dict[str, An return receipt +def validate_receipt( + value: Any, + *, + signer_registry: Any, + now: datetime | None = None, +) -> dict[str, Any]: + """Validate the receipt bytes and its active registered Ed25519 signature.""" + + receipt = _validate_receipt_structure(value, now=now) + try: + registry = evidence.validate_signer_registry(signer_registry) + except evidence.EvidenceRegistryError as exc: + raise TrustError(str(exc)) from exc + if receipt["signer_registry_sha256"] != evidence.signer_registry_identity_digest( + registry + ): + raise TrustError("decision receipt signer registry identity differs") + verify_embedded_signature( + receipt, + signer_registry=registry, + object_schema_version=( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + signature_domain=DECISION_RECEIPT_SIGNATURE_DOMAIN, + usage="qualification-evidence-decision-receipt", + now=now, + ) + return receipt + + def signing_statement( value: Mapping[str, Any], *, @@ -1404,6 +1436,7 @@ def verify_embedded_signature( ], capture_output=True, text=True, + check=False, ) if result.returncode: raise TrustError("embedded Ed25519 signing statement verification failed") @@ -1827,9 +1860,12 @@ def _validate_receipt_admission_binding( receipt_value: Any, admission_value: Any, *, + signer_registry: Any, now: datetime | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: - receipt = validate_receipt(receipt_value, now=now) + receipt = validate_receipt( + receipt_value, signer_registry=signer_registry, now=now + ) admission = validate_qualification_admission(admission_value, now=now) validate_reference_pair( admission["decision_receipt_reference"], @@ -1890,6 +1926,7 @@ def validate_acceptance_manifest( *, receipt: Any, qualification_admission: Any, + receipt_signer_registry: Any, now: datetime | None = None, ) -> dict[str, Any]: fields = { @@ -1996,7 +2033,10 @@ def validate_acceptance_manifest( kind="qualification-admission", ) bound_receipt, bound_admission = _validate_receipt_admission_binding( - receipt, qualification_admission, now=now + receipt, + qualification_admission, + signer_registry=receipt_signer_registry, + now=now, ) if ( manifest["qualification_evidence_decision_receipt_reference"] @@ -2052,6 +2092,7 @@ def validate_acceptance_summary( manifest: Any, receipt: Any, qualification_admission: Any, + receipt_signer_registry: Any, now: datetime | None = None, ) -> dict[str, Any]: fields = { @@ -2102,6 +2143,7 @@ def validate_acceptance_summary( manifest, receipt=receipt, qualification_admission=qualification_admission, + receipt_signer_registry=receipt_signer_registry, now=now, ) for field in ( @@ -2142,6 +2184,7 @@ def validate_release_evidence_chain( manifest: Any, receipt: Any, qualification_admission: Any, + receipt_signer_registry: Any, now: datetime | None = None, ) -> dict[str, Any]: release = validate_release(release_value, now=now) @@ -2150,6 +2193,7 @@ def validate_release_evidence_chain( manifest=manifest, receipt=receipt, qualification_admission=qualification_admission, + receipt_signer_registry=receipt_signer_registry, now=now, ) bound_manifest = dict(manifest) @@ -2670,9 +2714,9 @@ def validate_checkpoint_expiry_containment( ] authority = validate_authority_state(authority_state) revocation = validate_revocation_state(revocation_state) - summaries = [validate_acceptance_summary(item) for item in acceptance_summaries] - manifests = [validate_acceptance_manifest(item) for item in acceptance_manifests] - receipts = [validate_evidence_decision_receipt(item) for item in decision_receipts] + summaries = list(acceptance_summaries) + manifests = list(acceptance_manifests) + receipts = [_validate_receipt_structure(item) for item in decision_receipts] if len(releases) != len(checkpoint["release_admissions"]): raise TrustError("checkpoint release object count differs") if len(workflows) != len(checkpoint["workflow_admissions"]): diff --git a/scripts/public_trust_kms.py b/scripts/public_trust_kms.py index efdd9c8..b2eb7bd 100644 --- a/scripts/public_trust_kms.py +++ b/scripts/public_trust_kms.py @@ -109,6 +109,29 @@ "status", "revoked_at", } +PUBLIC_EVIDENCE_KINDS = { + "production-acceptance-manifest", + "production-acceptance-summary", + "production-cloud-deploy-authorization", + "production-cloud-deployment-result", + "production-current-default", + "production-deployment-observation", + "production-lifecycle-checkpoint", + "qualification-admission", + "qualification-authority-state-receipt", + "qualification-campaign-permit", + "qualification-campaign-permit-policy", + "qualification-campaign-permit-receipt", + "qualification-campaign-permit-request", + "qualification-evidence-decision-receipt", + "qualification-release", + "qualification-revocation-state-receipt", + "support-release-admission", +} +PUBLIC_SIGNING_WORKFLOW = ( + "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/OpenAdaptAI/.github/" + ".github/workflows/sign-production-evidence.yml@refs/heads/main" +) class PublicTrustKmsError(ValueError): @@ -222,7 +245,12 @@ def validate_public_signer(value: Any) -> dict[str, Any]: raise PublicTrustKmsError("public-trust statement schema is not supported") if signer["allowed_usages"] != ["production-public-evidence"]: raise PublicTrustKmsError("public-trust signer usage is not supported") - for field in ("allowed_kinds", "allowed_workflows", "allowed_ref_prefixes", "allowed_environments"): + for field in ( + "allowed_kinds", + "allowed_workflows", + "allowed_ref_prefixes", + "allowed_environments", + ): values = signer[field] if ( not isinstance(values, list) @@ -230,7 +258,17 @@ def validate_public_signer(value: Any) -> dict[str, Any]: or values != sorted(set(values)) or not all(isinstance(item, str) and item for item in values) ): - raise PublicTrustKmsError(f"public-trust signer {field} must be sorted and unique") + raise PublicTrustKmsError( + f"public-trust signer {field} must be sorted and unique" + ) + if any(kind not in PUBLIC_EVIDENCE_KINDS for kind in signer["allowed_kinds"]): + raise PublicTrustKmsError("public-trust signer object-kind allowlist is invalid") + if signer["allowed_workflows"] != [PUBLIC_SIGNING_WORKFLOW]: + raise PublicTrustKmsError("public-trust signer workflow allowlist is invalid") + if signer["allowed_ref_prefixes"] != ["refs/heads/main"]: + raise PublicTrustKmsError("public-trust signer ref allowlist is invalid") + if signer["allowed_environments"] != ["public-trust-signing"]: + raise PublicTrustKmsError("public-trust signer environment allowlist is invalid") if signer["status"] not in {"active", "revoked"}: raise PublicTrustKmsError("public-trust signer status is invalid") if signer["status"] == "active" and signer["revoked_at"] is not None: @@ -423,6 +461,31 @@ def verify_bundle( return bundle +def statement_from_bundle(bundle_value: Any) -> dict[str, Any]: + bundle = _closed( + bundle_value, + {"mediaType", "verificationMaterial", "dsseEnvelope"}, + "public-trust DSSE bundle", + ) + envelope = _closed( + bundle["dsseEnvelope"], + {"payload", "payloadType", "signatures"}, + "DSSE envelope", + ) + if envelope["payloadType"] != STATEMENT_MEDIA_TYPE: + raise PublicTrustKmsError("DSSE payload type is invalid") + payload = _canonical_base64(envelope["payload"], "DSSE payload") + if payload[-1:] != b"\n": + raise PublicTrustKmsError("DSSE payload must end with exactly one LF") + try: + value = json.loads(payload) + except json.JSONDecodeError as exc: + raise PublicTrustKmsError("DSSE payload is not JSON") from exc + if payload != canonical_lf(value): + raise PublicTrustKmsError("DSSE payload is not canonical JSON plus LF") + return validate_signing_statement(value) + + def validate_statement_object_binding( statement_value: Any, *, @@ -432,6 +495,9 @@ def validate_statement_object_binding( object_schema_version: str, object_media_type: str, semantic_identity_sha256: str, + expected_signer_registry_sha256: str, + expected_authority_state_sha256: str, + expected_revocation_state_sha256: str, ) -> dict[str, Any]: statement = validate_signing_statement(statement_value) if object_raw != canonical_lf(object_value): @@ -453,11 +519,15 @@ def validate_statement_object_binding( projected_issuer = {field: source_issuer[field] for field in ISSUER_FIELDS if field in source_issuer} if set(projected_issuer) != ISSUER_FIELDS or statement["source_issuer"] != projected_issuer: raise PublicTrustKmsError("public-trust statement source issuer binding differs") - for field in ( - "signer_registry_sha256", - "authority_state_sha256", - "revocation_state_sha256", - ): - if statement[field] != object_value.get(field): + expected_trust_state = { + "signer_registry_sha256": expected_signer_registry_sha256, + "authority_state_sha256": expected_authority_state_sha256, + "revocation_state_sha256": expected_revocation_state_sha256, + } + for field, expected in expected_trust_state.items(): + _digest(expected, f"expected {field}") + if statement[field] != expected: raise PublicTrustKmsError(f"public-trust statement {field} binding differs") + if field in object_value and object_value[field] != expected: + raise PublicTrustKmsError(f"public-trust object {field} binding differs") return statement diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index f1d5d48..baab6aa 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -12,6 +12,7 @@ import base64 import binascii import hashlib +import itertools import json import re import sys @@ -20,6 +21,8 @@ from pathlib import Path, PurePosixPath from typing import Any +from public_trust_kms import PublicTrustKmsError, validate_public_signer + ROOT = Path(__file__).resolve().parents[1] REGISTRY_PATH = ROOT / "evidence-registry.json" @@ -281,6 +284,24 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: seen_ids: set[str] = set() for index, signer_value in enumerate(signers): label = f"signer registry signer {index}" + if not isinstance(signer_value, dict): + raise EvidenceRegistryError(f"{label} must be an object") + if signer_value.get("algorithm") == "ecdsa-p256-sha256": + try: + signer = validate_public_signer(signer_value) + except PublicTrustKmsError as exc: + raise EvidenceRegistryError(f"{label}: {exc}") from exc + key_id = signer["key_id"] + if key_id in seen_ids: + raise EvidenceRegistryError("signer registry key ids must be unique") + seen_ids.add(key_id) + if signer["status"] == "revoked": + revoked_at = _timestamp(signer["revoked_at"], f"{label} revoked_at") + if not generated_at <= revoked_at <= expires_at: + raise EvidenceRegistryError( + f"{label} revocation time is outside the registry lifetime" + ) + continue signer = _closed( signer_value, { @@ -565,7 +586,7 @@ def validate_registry(value: Any, *, root: Path | None = None) -> list[dict[str, revisions = [item["registry_revision"] for item in typed_history] if revisions != sorted(set(revisions)) or any( current != previous + 1 - for previous, current in zip(revisions, revisions[1:]) + for previous, current in itertools.pairwise(revisions) ): raise EvidenceRegistryError( "signer registry history must use unique consecutive revisions" diff --git a/scripts/validate_production_lifecycle_ref.py b/scripts/validate_production_lifecycle_ref.py index fce6f1d..916a06e 100644 --- a/scripts/validate_production_lifecycle_ref.py +++ b/scripts/validate_production_lifecycle_ref.py @@ -15,7 +15,6 @@ import validate_evidence_registry as evidence from verify_production_release_admission import verify_bytes, verify_sigstore - ROOT = Path(__file__).resolve().parents[1] POLICY = json.loads( (ROOT / "production-evidence-policy.json").read_text(encoding="utf-8") @@ -282,6 +281,8 @@ def resolve_release_chain( manifest=manifest, receipt=receipt, qualification_admission=admission, + receipt_signer_registry=active_signer_registry, + now=verification_time, ) return summary, manifest, receipt, admission @@ -317,7 +318,12 @@ def resolve_workflow_receipt( usage="qualification-evidence-decision-receipt", now=verification_time, ) - trust._validate_receipt_admission_binding(receipt, admission) + trust._validate_receipt_admission_binding( + receipt, + admission, + signer_registry=active_signer_registry, + now=verification_time, + ) return receipt diff --git a/scripts/verify_production_release_admission.py b/scripts/verify_production_release_admission.py index ecc143a..b2aed4c 100644 --- a/scripts/verify_production_release_admission.py +++ b/scripts/verify_production_release_admission.py @@ -255,7 +255,9 @@ def verify_sigstore( sigstore["cert_oidc_issuer"], "--deny-self-hosted-runners", "--no-public-good", "--format", "json", ] - result = subprocess.run(command, capture_output=True, text=True) + result = subprocess.run( + command, capture_output=True, text=True, check=False + ) if result.returncode: raise trust.TrustError( "Sigstore bundle verification failed: " + result.stderr.strip() @@ -435,6 +437,7 @@ def verify_message_signature( ], capture_output=True, text=True, + check=False, ) if result.returncode: raise trust.TrustError( @@ -471,7 +474,7 @@ def main(argv: list[str] | None = None) -> int: bundle_raw, admission_value, release_signer_registry, - current_signer_registry, + _current_signer_registry, ) = fetch_pair( reference, bundle_reference ) @@ -547,6 +550,7 @@ def main(argv: list[str] | None = None) -> int: manifest=manifest, receipt=receipt, qualification_admission=qualification_admission, + receipt_signer_registry=receipt_current_signer_registry, now=now, ) release = admission["release"] @@ -591,8 +595,9 @@ def main(argv: list[str] | None = None) -> int: } if args.github_output: with Path(args.github_output).open("a", encoding="utf-8") as handle: - for key, value in outputs.items(): - handle.write(f"{key}={value}\n") + handle.writelines( + f"{key}={value}\n" for key, value in outputs.items() + ) print(json.dumps(outputs, sort_keys=True)) except ( OSError, diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index 0851a37..302c056 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import copy import hashlib import json @@ -10,11 +11,15 @@ import unittest from pathlib import Path +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -import validate_evidence_registry as registry # noqa: E402 -import stage_production_evidence as stage # noqa: E402 +import public_trust_kms as kms +import stage_production_evidence as stage +import validate_evidence_registry as registry def sha(character: str) -> str: @@ -250,9 +255,12 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: "qualification-evidence-decision-receipt" ], "allowed_workflows": [ - "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/OpenAdaptAI/openadapt-internal/" - ".github/workflows/issue-private-qualification-evidence-decision.yml" - "@refs/heads/main" + ( + "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/OpenAdaptAI/openadapt-internal/" + ".github/workflows/" + "issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ) ], "allowed_ref_prefixes": ["refs/heads/main"], "status": "active", @@ -265,6 +273,53 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "bind"): registry.validate_signer_registry(value) + def test_signer_registry_accepts_only_exact_aws_kms_p256_profile(self) -> None: + public_key = ec.derive_private_key(7, ec.SECP256R1()).public_key() + point = public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + spki = public_key.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + signer = { + "algorithm": "ecdsa-p256-sha256", + "key_id": kms.public_key_id(public_key), + "key_origin": "aws-kms", + "kms_key_arn": ( + "arn:aws:kms:us-east-1:992382684924:key/" + "12345678-1234-4abc-8def-1234567890ab" + ), + "public_key": base64.urlsafe_b64encode(point).decode().rstrip("="), + "public_key_spki_der_base64": base64.b64encode(spki).decode(), + "public_key_sha256": "sha256:" + hashlib.sha256(spki).hexdigest(), + "signature_encoding": "asn1-der-low-s-base64-rfc4648-padded", + "statement_schema_versions": [kms.STATEMENT_SCHEMA], + "allowed_usages": ["production-public-evidence"], + "allowed_kinds": ["qualification-release"], + "allowed_workflows": [kms.PUBLIC_SIGNING_WORKFLOW], + "allowed_ref_prefixes": ["refs/heads/main"], + "allowed_environments": ["public-trust-signing"], + "status": "active", + "revoked_at": None, + } + value = { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": 1, + "generated_at": "2026-08-27T00:00:00Z", + "expires_at": "2026-09-03T00:00:00Z", + "signers": [signer], + } + self.assertEqual(registry.validate_signer_registry(value), value) + + alias = copy.deepcopy(value) + alias["signers"][0]["kms_key_arn"] = ( + "arn:aws:kms:us-east-1:992382684924:alias/openadapt-public-trust" + ) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "ARN"): + registry.validate_signer_registry(alias) + def test_signer_registry_install_is_canonical_consecutive_and_append_only(self) -> None: key = bytes(range(32)) spki = bytes.fromhex("302a300506032b6570032100") + key @@ -299,10 +354,12 @@ def signer_registry(revision: int) -> dict: "qualification-evidence-decision-receipt" ], "allowed_workflows": [ - "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/OpenAdaptAI/openadapt-internal/" - ".github/workflows/" - "issue-private-qualification-evidence-decision.yml" - "@refs/heads/main" + ( + "/OpenAdaptAI/" + "openadapt-internal/.github/workflows/" + "issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ) ], "allowed_ref_prefixes": ["refs/heads/main"], "status": "active", diff --git a/tests/test_production_trust.py b/tests/test_production_trust.py index 97d94fb..871ef19 100644 --- a/tests/test_production_trust.py +++ b/tests/test_production_trust.py @@ -2,19 +2,24 @@ from __future__ import annotations +import base64 import copy import hashlib import sys import tempfile import unittest +from datetime import datetime, timezone from pathlib import Path from unittest import mock +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -import production_trust as trust # noqa: E402 -import validate_evidence_registry as registry # noqa: E402 +import production_trust as trust +import validate_evidence_registry as registry def sha(character: str) -> str: @@ -87,6 +92,124 @@ def campaign_summary() -> dict: return result +RECEIPT_NOW = datetime(2026, 8, 27, 12, 30, tzinfo=timezone.utc) + + +def _resign_receipt(value: dict, private_key: Ed25519PrivateKey) -> None: + value["signing_statement"] = trust.signing_statement( + value, + object_schema_version=( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + signature_domain=trust.DECISION_RECEIPT_SIGNATURE_DOMAIN, + ) + value["signature"] = base64.b64encode( + private_key.sign(trust.canonical(value["signing_statement"]) + b"\n") + ).decode("ascii") + + +def decision_receipt_fixture( + private_key: Ed25519PrivateKey | None = None, +) -> tuple[dict, dict, Ed25519PrivateKey]: + key = private_key or Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33))) + public_key = key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + spki = bytes.fromhex("302a300506032b6570032100") + public_key + key_id = "qa-ed25519-" + hashlib.sha256(public_key).hexdigest()[:16] + signer = { + "algorithm": "ed25519", + "key_id": key_id, + "public_key": base64.urlsafe_b64encode(public_key).decode().rstrip("="), + "public_key_spki_der_base64": base64.b64encode(spki).decode(), + "public_key_sha256": "sha256:" + hashlib.sha256(spki).hexdigest(), + "statement_schema_versions": [ + "openadapt.qualification-evidence-signing-statement/v1" + ], + "allowed_usages": ["qualification-evidence-decision-receipt"], + "allowed_workflows": [ + ( + "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/OpenAdaptAI/openadapt-internal/" + ".github/workflows/" + "issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ) + ], + "allowed_ref_prefixes": ["refs/heads/main"], + "status": "active", + "revoked_at": None, + } + signer_registry = { + "schema_version": "openadapt.qualification-signer-registry/v2", + "revision": 1, + "generated_at": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-29T12:00:00Z", + "signers": [signer], + } + receipt = { + "schema_version": ( + "openadapt.qualification-evidence-decision-receipt/v1" + ), + "decision_identity_sha256": sha("a"), + "decision_revision": 1, + "decision_commitment_sha256": sha("b"), + "evidence_manifest_sha256": sha("c"), + "evidence_manifest_readback_sha256": sha("d"), + "campaign_artifact_sha256": sha("e"), + "organization_id_sha256": sha("f"), + "workflow_id_sha256": sha("1"), + "workflow_version_id_sha256": sha("2"), + "bundle_version": "3.0.0-rc.1", + "bundle_sha256": sha("3"), + "admitted_runtime_sha256": sha("4"), + "application_contract_sha256": sha("5"), + "environment_contract_sha256": sha("6"), + "input_contract_sha256": sha("7"), + "action_contract_sha256": sha("8"), + "identity_contract_sha256": sha("9"), + "effect_contract_sha256": sha("a"), + "policy_contract_sha256": sha("b"), + "evidence_authority_contract_sha256": sha("c"), + "campaign_permit_sha256": sha("d"), + "signer_registry_sha256": registry.signer_registry_identity_digest( + signer_registry + ), + "revocation_state_sha256": sha("e"), + "entity_class": "record", + "campaign_summary": { + "schema_version": ( + "openadapt.qualification-evidence-decision-campaign-summary/v1" + ), + "minimum_trials_per_task_condition": 3, + "task_count": 1, + "classes": campaign_summary(), + }, + "verdict": "ADMIT", + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-28T12:00:00Z", + "issuer_key_id": key_id, + "algorithm": "ed25519", + "signing_statement": None, + "signature": "", + "issuer": { + "repository": "OpenAdaptAI/openadapt-internal", + "repository_id": "1170060695", + "repository_owner_id": "132681217", + "workflow": ( + ".github/workflows/" + "issue-private-qualification-evidence-decision.yml" + ), + "ref": "refs/heads/main", + "source_commit": "a" * 40, + "environment": "private-qualification-evidence-decision", + }, + } + _resign_receipt(receipt, key) + return receipt, signer_registry, key + + def rulesets() -> list[dict]: common = { "schema_version": "openadapt.production-release-tag-ruleset/v1", @@ -474,6 +597,69 @@ def checkpoint_reference(checkpoint: dict) -> dict: class ProductionTrustTests(unittest.TestCase): + def test_decision_receipt_requires_active_registered_signature(self) -> None: + receipt, signer_registry, key = decision_receipt_fixture() + self.assertEqual( + trust.validate_receipt( + receipt, signer_registry=signer_registry, now=RECEIPT_NOW + ), + receipt, + ) + + zero_signature = copy.deepcopy(receipt) + zero_signature["signature"] = base64.b64encode(bytes(64)).decode("ascii") + with self.assertRaisesRegex(trust.TrustError, "verification failed"): + trust.validate_receipt( + zero_signature, + signer_registry=signer_registry, + now=RECEIPT_NOW, + ) + + changed_payload = copy.deepcopy(receipt) + changed_payload["bundle_sha256"] = sha("f") + with self.assertRaisesRegex(trust.TrustError, "signing statement differs"): + trust.validate_receipt( + changed_payload, + signer_registry=signer_registry, + now=RECEIPT_NOW, + ) + + _, wrong_registry, _ = decision_receipt_fixture( + Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65))) + ) + wrong_key = copy.deepcopy(receipt) + wrong_key["issuer_key_id"] = wrong_registry["signers"][0]["key_id"] + wrong_key["signer_registry_sha256"] = ( + registry.signer_registry_identity_digest(wrong_registry) + ) + _resign_receipt(wrong_key, key) + with self.assertRaisesRegex(trust.TrustError, "verification failed"): + trust.validate_receipt( + wrong_key, signer_registry=wrong_registry, now=RECEIPT_NOW + ) + + with self.assertRaisesRegex(trust.TrustError, "not active"): + trust.validate_receipt( + receipt, + signer_registry=signer_registry, + now=datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc), + ) + + revoked_registry = copy.deepcopy(signer_registry) + revoked_registry["signers"][0]["status"] = "revoked" + revoked_registry["signers"][0]["revoked_at"] = ( + "2026-08-27T12:15:00Z" + ) + revoked = copy.deepcopy(receipt) + revoked["signer_registry_sha256"] = ( + registry.signer_registry_identity_digest(revoked_registry) + ) + _resign_receipt(revoked, key) + with self.assertRaisesRegex(trust.TrustError, "not an active registered"): + trust.validate_receipt( + revoked, signer_registry=revoked_registry, now=RECEIPT_NOW + ) + def test_feed_expiry_boundary_is_exclusive(self) -> None: checkpoint_reference, checkpoint_bundle_reference = pair( "production-lifecycle-checkpoint", "1", "2" @@ -598,7 +784,7 @@ def test_checkpoint_reference_binds_the_exact_signed_wrapper(self) -> None: ) def test_feed_transition_refuses_rollback_or_skipped_history(self) -> None: - previous, newer, older, _ = rotation_fixture() + previous, _newer, _older, _ = rotation_fixture() previous["checkpoints"] = [previous["checkpoints"][1]] previous["feed_revision"] = 11 previous["generated_at"] = "2026-08-27T11:50:00Z" diff --git a/tests/test_public_trust_kms.py b/tests/test_public_trust_kms.py index e9f6975..b39ec80 100644 --- a/tests/test_public_trust_kms.py +++ b/tests/test_public_trust_kms.py @@ -211,6 +211,9 @@ def test_statement_binds_exact_canonical_object_and_signed_issuer(self) -> None: object_schema_version="openadapt.qualification-release/v1", object_media_type="application/vnd.openadapt.qualification-release+json;version=1", semantic_identity_sha256=sha("release-identity"), + expected_signer_registry_sha256=sha("registry"), + expected_authority_state_sha256=sha("authority"), + expected_revocation_state_sha256=sha("revocation"), ), statement(), ) @@ -223,6 +226,9 @@ def test_statement_binds_exact_canonical_object_and_signed_issuer(self) -> None: object_schema_version="openadapt.qualification-release/v1", object_media_type="application/vnd.openadapt.qualification-release+json;version=1", semantic_identity_sha256=sha("release-identity"), + expected_signer_registry_sha256=sha("registry"), + expected_authority_state_sha256=sha("authority"), + expected_revocation_state_sha256=sha("revocation"), ) def test_kms_digest_is_sha256_of_exact_dsse_pae(self) -> None: From 8368fb69904db294dbe3268bdaafbd0137b32260 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:32:14 -0400 Subject: [PATCH 12/18] feat: bind qualification worker task identities --- ...fication-worker-task-condition.schema.json | 29 +++ ...ification-worker-task-selector.schema.json | 29 +++ scripts/qualification_worker_task_contract.py | 168 ++++++++++++ ...test_qualification_worker_task_contract.py | 241 ++++++++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 schemas/qualification-worker-task-condition.schema.json create mode 100644 schemas/qualification-worker-task-selector.schema.json create mode 100644 scripts/qualification_worker_task_contract.py create mode 100644 tests/test_qualification_worker_task_contract.py diff --git a/schemas/qualification-worker-task-condition.schema.json b/schemas/qualification-worker-task-condition.schema.json new file mode 100644 index 0000000..d21f900 --- /dev/null +++ b/schemas/qualification-worker-task-condition.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-task-condition.schema.json", + "title": "OpenAdapt qualification worker task condition v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "task_id_sha256", + "condition_source_sha256", + "condition_ordinal", + "task_condition_sha256" + ], + "properties": { + "schema_version": { + "const": "openadapt.qualification-worker-task-condition/v1" + }, + "task_id_sha256": {"$ref": "#/$defs/digest"}, + "condition_source_sha256": {"$ref": "#/$defs/digest"}, + "condition_ordinal": {"type": "integer", "minimum": 1}, + "task_condition_sha256": {"$ref": "#/$defs/digest"} + }, + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/schemas/qualification-worker-task-selector.schema.json b/schemas/qualification-worker-task-selector.schema.json new file mode 100644 index 0000000..bd72a3a --- /dev/null +++ b/schemas/qualification-worker-task-selector.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qualification-worker-task-selector.schema.json", + "title": "OpenAdapt qualification worker task selector v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "campaign_artifact_sha256", + "task_source_sha256", + "task_ordinal", + "task_id_sha256" + ], + "properties": { + "schema_version": { + "const": "openadapt.qualification-worker-task-selector/v1" + }, + "campaign_artifact_sha256": {"$ref": "#/$defs/digest"}, + "task_source_sha256": {"$ref": "#/$defs/digest"}, + "task_ordinal": {"type": "integer", "minimum": 1}, + "task_id_sha256": {"$ref": "#/$defs/digest"} + }, + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/scripts/qualification_worker_task_contract.py b/scripts/qualification_worker_task_contract.py new file mode 100644 index 0000000..ba05955 --- /dev/null +++ b/scripts/qualification_worker_task_contract.py @@ -0,0 +1,168 @@ +"""Validate remote-safe qualification worker task identities.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + +TASK_SELECTOR_DOMAIN = b"OpenAdapt qualification worker task selector v1\0" +TASK_CONDITION_DOMAIN = b"OpenAdapt qualification worker task condition v1\0" +TASK_SELECTOR_SCHEMA = "openadapt.qualification-worker-task-selector/v1" +TASK_CONDITION_SCHEMA = "openadapt.qualification-worker-task-condition/v1" +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +TASK_SELECTOR_FIELDS = { + "schema_version", + "campaign_artifact_sha256", + "task_source_sha256", + "task_ordinal", + "task_id_sha256", +} +TASK_CONDITION_FIELDS = { + "schema_version", + "task_id_sha256", + "condition_source_sha256", + "condition_ordinal", + "task_condition_sha256", +} + + +class QualificationWorkerTaskContractError(ValueError): + """A remote-safe task selector or condition identity is invalid.""" + + +def canonical(value: Any) -> bytes: + """Return the canonical compact sorted UTF-8 JSON representation.""" + + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def digest(domain: bytes, value: Any) -> str: + """Return a prefixed lowercase SHA-256 digest for a domain and object.""" + + return "sha256:" + hashlib.sha256(domain + canonical(value)).hexdigest() + + +def _closed(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise QualificationWorkerTaskContractError( + f"{label} must contain exactly {sorted(fields)}; got {actual}" + ) + return value + + +def _digest(value: Any, label: str) -> str: + if not isinstance(value, str) or DIGEST.fullmatch(value) is None: + raise QualificationWorkerTaskContractError( + f"{label} must be a lowercase sha256 digest" + ) + return value + + +def _positive_ordinal(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise QualificationWorkerTaskContractError( + f"{label} must be a positive integer" + ) + return value + + +def task_selector_projection(selector: dict[str, Any]) -> dict[str, Any]: + """Return the exact projection covered by the task identity digest.""" + + return { + "schema_version": selector["schema_version"], + "campaign_artifact_sha256": selector["campaign_artifact_sha256"], + "task_source_sha256": selector["task_source_sha256"], + "task_ordinal": selector["task_ordinal"], + } + + +def task_condition_projection(condition: dict[str, Any]) -> dict[str, Any]: + """Return the exact projection covered by the task-condition digest.""" + + return { + "schema_version": condition["schema_version"], + "task_id_sha256": condition["task_id_sha256"], + "condition_source_sha256": condition["condition_source_sha256"], + "condition_ordinal": condition["condition_ordinal"], + } + + +def validate_task_selector(value: Any) -> dict[str, Any]: + """Validate one closed remote-safe task selector and its identity.""" + + selector = _closed(value, TASK_SELECTOR_FIELDS, "worker task selector") + if selector["schema_version"] != TASK_SELECTOR_SCHEMA: + raise QualificationWorkerTaskContractError( + "worker task selector schema is invalid" + ) + _digest(selector["campaign_artifact_sha256"], "campaign artifact identity") + _digest(selector["task_source_sha256"], "task source identity") + _positive_ordinal(selector["task_ordinal"], "task ordinal") + _digest(selector["task_id_sha256"], "task identity") + if selector["task_id_sha256"] != digest( + TASK_SELECTOR_DOMAIN, task_selector_projection(selector) + ): + raise QualificationWorkerTaskContractError("worker task identity differs") + return selector + + +def validate_task_condition(value: Any) -> dict[str, Any]: + """Validate one closed remote-safe task condition and its identity.""" + + condition = _closed(value, TASK_CONDITION_FIELDS, "worker task condition") + if condition["schema_version"] != TASK_CONDITION_SCHEMA: + raise QualificationWorkerTaskContractError( + "worker task condition schema is invalid" + ) + _digest(condition["task_id_sha256"], "task identity") + _digest(condition["condition_source_sha256"], "condition source identity") + _positive_ordinal(condition["condition_ordinal"], "condition ordinal") + _digest(condition["task_condition_sha256"], "task condition identity") + if condition["task_condition_sha256"] != digest( + TASK_CONDITION_DOMAIN, task_condition_projection(condition) + ): + raise QualificationWorkerTaskContractError( + "worker task condition identity differs" + ) + return condition + + +def validate_task_condition_binding( + selector_value: Any, condition_value: Any +) -> tuple[dict[str, Any], dict[str, Any]]: + """Require one condition to select the exact validated task identity.""" + + selector = validate_task_selector(selector_value) + condition = validate_task_condition(condition_value) + if condition["task_id_sha256"] != selector["task_id_sha256"]: + raise QualificationWorkerTaskContractError( + "worker task condition selects a different task identity" + ) + return selector, condition + + +def validate_task_contract( + selector_value: Any, + condition_value: Any, + *, + campaign_artifact_sha256: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Bind a task and condition to the expected campaign artifact.""" + + expected_campaign = _digest( + campaign_artifact_sha256, "expected campaign artifact identity" + ) + selector, condition = validate_task_condition_binding( + selector_value, condition_value + ) + if selector["campaign_artifact_sha256"] != expected_campaign: + raise QualificationWorkerTaskContractError( + "worker task selector binds a different campaign artifact" + ) + return selector, condition diff --git a/tests/test_qualification_worker_task_contract.py b/tests/test_qualification_worker_task_contract.py new file mode 100644 index 0000000..0b2f583 --- /dev/null +++ b/tests/test_qualification_worker_task_contract.py @@ -0,0 +1,241 @@ +"""Tests for qualification worker task and condition identities.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import sys +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import qualification_worker_task_contract as task_contract + + +def sha(label: str) -> str: + return "sha256:" + hashlib.sha256(label.encode()).hexdigest() + + +def selector() -> dict: + value = { + "schema_version": task_contract.TASK_SELECTOR_SCHEMA, + "campaign_artifact_sha256": sha("campaign"), + "task_source_sha256": sha("private-task-source"), + "task_ordinal": 7, + "task_id_sha256": sha("pending-task"), + } + value["task_id_sha256"] = task_contract.digest( + task_contract.TASK_SELECTOR_DOMAIN, + task_contract.task_selector_projection(value), + ) + return value + + +def condition(task: dict | None = None) -> dict: + selected_task = selector() if task is None else task + value = { + "schema_version": task_contract.TASK_CONDITION_SCHEMA, + "task_id_sha256": selected_task["task_id_sha256"], + "condition_source_sha256": sha("private-condition-source"), + "condition_ordinal": 3, + "task_condition_sha256": sha("pending-condition"), + } + value["task_condition_sha256"] = task_contract.digest( + task_contract.TASK_CONDITION_DOMAIN, + task_contract.task_condition_projection(value), + ) + return value + + +def schema(name: str) -> dict: + return json.loads((ROOT / "schemas" / name).read_text()) + + +class QualificationWorkerTaskContractTests(unittest.TestCase): + def test_domains_projections_and_digests_are_frozen(self) -> None: + selected_task = selector() + selected_condition = condition(selected_task) + self.assertEqual( + task_contract.TASK_SELECTOR_DOMAIN, + b"OpenAdapt qualification worker task selector v1\0", + ) + self.assertEqual( + task_contract.TASK_CONDITION_DOMAIN, + b"OpenAdapt qualification worker task condition v1\0", + ) + self.assertEqual( + task_contract.canonical( + task_contract.task_selector_projection(selected_task) + ), + ( + b'{"campaign_artifact_sha256":"sha256:3dc260b2472062d9c57b' + b'd930b02f23831d917b8f3e3234b6d63964a53c31d3aa","schema_version"' + b':"openadapt.qualification-worker-task-selector/v1","task_ordinal"' + b':7,"task_source_sha256":"sha256:8057301060c8a6a36e2b425e4d26' + b'eb10a6d110cdaffe5c80ebe1c515286c3e17"}' + ), + ) + self.assertEqual( + selected_task["task_id_sha256"], + "sha256:3e688825cd4e6ce312d15f47ddb1d3fa57441e3b0c3a5ca9ccc2f1f285a52cc7", + ) + self.assertEqual( + task_contract.canonical( + task_contract.task_condition_projection(selected_condition) + ), + ( + b'{"condition_ordinal":3,"condition_source_sha256":"sha256:' + b'b9079b50782122c328dae360cb2d9766601668d1e1842bc4f73d0a0404b' + b'f38df","schema_version":"openadapt.qualification-worker-task-condition' + b'/v1","task_id_sha256":"sha256:3e688825cd4e6ce312d15f47ddb1d3' + b'fa57441e3b0c3a5ca9ccc2f1f285a52cc7"}' + ), + ) + self.assertEqual( + selected_condition["task_condition_sha256"], + "sha256:e09987d522fd290c2d07994428027bebd5deabcf2156258dd9633a465095b16f", + ) + + def test_closed_objects_validate_and_cross_bind(self) -> None: + selected_task = selector() + selected_condition = condition(selected_task) + self.assertEqual( + task_contract.validate_task_contract( + selected_task, + selected_condition, + campaign_artifact_sha256=sha("campaign"), + ), + (selected_task, selected_condition), + ) + Draft202012Validator( + schema("qualification-worker-task-selector.schema.json") + ).validate(selected_task) + Draft202012Validator( + schema("qualification-worker-task-condition.schema.json") + ).validate(selected_condition) + + def test_task_selector_tamper_is_refused(self) -> None: + for field, replacement in ( + ("campaign_artifact_sha256", sha("other-campaign")), + ("task_source_sha256", sha("other-task-source")), + ("task_ordinal", 8), + ): + changed = copy.deepcopy(selector()) + changed[field] = replacement + with self.subTest(field=field), self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "task identity differs", + ): + task_contract.validate_task_selector(changed) + + def test_task_condition_tamper_is_refused(self) -> None: + for field, replacement in ( + ("task_id_sha256", sha("other-task")), + ("condition_source_sha256", sha("other-condition-source")), + ("condition_ordinal", 4), + ): + changed = copy.deepcopy(condition()) + changed[field] = replacement + with self.subTest(field=field), self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "task condition identity differs", + ): + task_contract.validate_task_condition(changed) + + def test_recomputed_cross_contract_substitution_is_refused(self) -> None: + selected_task = selector() + different_task = copy.deepcopy(selected_task) + different_task["task_ordinal"] = 8 + different_task["task_id_sha256"] = task_contract.digest( + task_contract.TASK_SELECTOR_DOMAIN, + task_contract.task_selector_projection(different_task), + ) + substituted = condition(different_task) + task_contract.validate_task_condition(substituted) + with self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "selects a different task identity", + ): + task_contract.validate_task_condition_binding( + selected_task, substituted + ) + + different_campaign = copy.deepcopy(selected_task) + different_campaign["campaign_artifact_sha256"] = sha("other-campaign") + different_campaign["task_id_sha256"] = task_contract.digest( + task_contract.TASK_SELECTOR_DOMAIN, + task_contract.task_selector_projection(different_campaign), + ) + different_campaign_condition = condition(different_campaign) + with self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "different campaign artifact", + ): + task_contract.validate_task_contract( + different_campaign, + different_campaign_condition, + campaign_artifact_sha256=sha("campaign"), + ) + + def test_invalid_digest_ordinal_and_shape_are_refused(self) -> None: + for invalid in (0, -1, True, "1"): + changed = selector() + changed["task_ordinal"] = invalid + with self.subTest(invalid=invalid), self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "positive integer", + ): + task_contract.validate_task_selector(changed) + + changed_condition = condition() + changed_condition["condition_ordinal"] = invalid + with self.subTest(condition_invalid=invalid), self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "positive integer", + ): + task_contract.validate_task_condition(changed_condition) + + invalid_digest = selector() + invalid_digest["task_source_sha256"] = "sha256:" + "A" * 64 + with self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "lowercase sha256 digest", + ): + task_contract.validate_task_selector(invalid_digest) + + extra = condition() + extra["private_condition"] = "must-not-egress" + with self.assertRaisesRegex( + task_contract.QualificationWorkerTaskContractError, + "must contain exactly", + ): + task_contract.validate_task_condition(extra) + + def test_json_schemas_refuse_extra_fields_and_nonpositive_ordinals(self) -> None: + selector_validator = Draft202012Validator( + schema("qualification-worker-task-selector.schema.json") + ) + condition_validator = Draft202012Validator( + schema("qualification-worker-task-condition.schema.json") + ) + + changed_selector = selector() + changed_selector["task_ordinal"] = 0 + self.assertTrue(list(selector_validator.iter_errors(changed_selector))) + + changed_condition = condition() + changed_condition["condition_ordinal"] = True + self.assertTrue(list(condition_validator.iter_errors(changed_condition))) + + changed_condition = condition() + changed_condition["raw_condition"] = {"secret": "must-not-egress"} + self.assertTrue(list(condition_validator.iter_errors(changed_condition))) + + +if __name__ == "__main__": + unittest.main() From ad93d800cbbe42f61135d4c04b0d2eb6eebeafa1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:32:26 -0400 Subject: [PATCH 13/18] feat: add inactive KMS public trust policy --- production-evidence-policy.json | 39 +++++++++++ ...n-evidence-verification-policy.schema.json | 66 ++++++++++++++++++- tests/test_public_trust_schemas.py | 15 ++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/production-evidence-policy.json b/production-evidence-policy.json index 064d657..6de9428 100644 --- a/production-evidence-policy.json +++ b/production-evidence-policy.json @@ -87,5 +87,44 @@ {"kind":"qualification-revocation-state-receipt","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/openadapt-ops","certificate_identity":"/OpenAdaptAI/openadapt-ops/.github/workflows/qualification-revocation-state.yml@refs/heads/main"}, {"kind":"support-release-admission","bundle_profile":"github-attestation","issuer_repository":"OpenAdaptAI/.github","certificate_identity":"/OpenAdaptAI/.github/.github/workflows/issue-support-release-admission.yml@refs/heads/main"} ] + }, + "public_trust_dsse": { + "profile": "aws-kms-p256-dsse-v1", + "bundle_media_type": "application/vnd.dev.sigstore.bundle.v0.3+json", + "statement_schema_version": "openadapt.production-public-trust-signing-statement/v1", + "statement_media_type": "application/vnd.openadapt.production-public-trust-signing-statement+json;version=1", + "signature_encoding": "asn1-der-low-s-base64-rfc4648-padded", + "key_origin": "aws-kms", + "aws_account_id": "992382684924", + "aws_region": "us-east-1", + "kms_key_spec": "ECC_NIST_P256", + "kms_signing_algorithm": "ECDSA_SHA_256", + "kms_message_type": "DIGEST", + "role_arn": "arn:aws:iam::992382684924:role/openadapt-public-trust-signer", + "signing_workflow": "/OpenAdaptAI/.github/.github/workflows/sign-production-evidence.yml@refs/heads/main", + "signing_environment": "public-trust-signing", + "key_source": "active-qualification-signer-registry-v2", + "offline_verification_required": true, + "alias_allowed": false, + "activation_state": "inactive-until-all-consumers-pin-exact-verifier", + "allowed_kinds": [ + "production-acceptance-manifest", + "production-acceptance-summary", + "production-cloud-deploy-authorization", + "production-cloud-deployment-result", + "production-current-default", + "production-deployment-observation", + "production-lifecycle-checkpoint", + "qualification-admission", + "qualification-authority-state-receipt", + "qualification-campaign-permit", + "qualification-campaign-permit-policy", + "qualification-campaign-permit-receipt", + "qualification-campaign-permit-request", + "qualification-evidence-decision-receipt", + "qualification-release", + "qualification-revocation-state-receipt", + "support-release-admission" + ] } } diff --git a/schemas/production-evidence-verification-policy.schema.json b/schemas/production-evidence-verification-policy.schema.json index 0a5c867..7259d7e 100644 --- a/schemas/production-evidence-verification-policy.schema.json +++ b/schemas/production-evidence-verification-policy.schema.json @@ -14,7 +14,8 @@ "signer_registry_schema_version", "embedded_signatures", "message_signature", - "sigstore" + "sigstore", + "public_trust_dsse" ], "properties": { "schema_version": {"const": "openadapt.production-evidence-verification-policy/v1"}, @@ -32,7 +33,8 @@ "items": {"$ref": "#/$defs/embedded_signature"} }, "message_signature": {"$ref": "#/$defs/message_signature"}, - "sigstore": {"$ref": "#/$defs/sigstore"} + "sigstore": {"$ref": "#/$defs/sigstore"}, + "public_trust_dsse": {"$ref": "#/$defs/public_trust_dsse"} }, "$defs": { "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, @@ -125,6 +127,66 @@ "signing_config": {"$ref": "#/$defs/pinned_document"} } }, + "public_trust_dsse": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", "bundle_media_type", "statement_schema_version", + "statement_media_type", "signature_encoding", "key_origin", + "aws_account_id", "aws_region", "kms_key_spec", + "kms_signing_algorithm", "kms_message_type", "role_arn", + "signing_workflow", "signing_environment", "key_source", + "offline_verification_required", "alias_allowed", "activation_state", + "allowed_kinds" + ], + "properties": { + "profile": {"const": "aws-kms-p256-dsse-v1"}, + "bundle_media_type": {"const": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "statement_schema_version": {"const": "openadapt.production-public-trust-signing-statement/v1"}, + "statement_media_type": {"const": "application/vnd.openadapt.production-public-trust-signing-statement+json;version=1"}, + "signature_encoding": {"const": "asn1-der-low-s-base64-rfc4648-padded"}, + "key_origin": {"const": "aws-kms"}, + "aws_account_id": {"const": "992382684924"}, + "aws_region": {"const": "us-east-1"}, + "kms_key_spec": {"const": "ECC_NIST_P256"}, + "kms_signing_algorithm": {"const": "ECDSA_SHA_256"}, + "kms_message_type": {"const": "DIGEST"}, + "role_arn": {"const": "arn:aws:iam::992382684924:role/openadapt-public-trust-signer"}, + "signing_workflow": {"const": "/OpenAdaptAI/.github/.github/workflows/sign-production-evidence.yml@refs/heads/main"}, + "signing_environment": {"const": "public-trust-signing"}, + "key_source": {"const": "active-qualification-signer-registry-v2"}, + "offline_verification_required": {"const": true}, + "alias_allowed": {"const": false}, + "activation_state": {"const": "inactive-until-all-consumers-pin-exact-verifier"}, + "allowed_kinds": { + "type": "array", + "minItems": 17, + "maxItems": 17, + "uniqueItems": true, + "items": { + "enum": [ + "production-acceptance-manifest", + "production-acceptance-summary", + "production-cloud-deploy-authorization", + "production-cloud-deployment-result", + "production-current-default", + "production-deployment-observation", + "production-lifecycle-checkpoint", + "qualification-admission", + "qualification-authority-state-receipt", + "qualification-campaign-permit", + "qualification-campaign-permit-policy", + "qualification-campaign-permit-receipt", + "qualification-campaign-permit-request", + "qualification-evidence-decision-receipt", + "qualification-release", + "qualification-revocation-state-receipt", + "support-release-admission" + ] + } + } + } + }, "sigstore": { "type": "object", "additionalProperties": false, diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index de3d856..9e4fb8a 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -335,6 +335,7 @@ "embedded_signatures", "message_signature", "sigstore", + "public_trust_dsse", }, "production-lifecycle-checkpoint.schema.json": { "schema_version", @@ -612,7 +613,9 @@ def test_release_artifacts_bind_publish_destinations(self) -> None: self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["minItems"], 2) self.assertEqual(schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"]["maxItems"], 2) - def test_verification_policy_freezes_both_sigstore_profiles(self) -> None: + def test_verification_policy_keeps_sigstore_and_freezes_future_kms_profile( + self, + ) -> None: policy = json.loads( (ROOT / "production-evidence-policy.json").read_text(encoding="utf-8") ) @@ -631,6 +634,16 @@ def test_verification_policy_freezes_both_sigstore_profiles(self) -> None: self.assertEqual(message["version"], "3.1.3") self.assertEqual(message["runner"], "ubuntu-24.04") self.assertEqual(message["architecture"], "linux-amd64") + kms = policy["public_trust_dsse"] + self.assertEqual(kms["profile"], "aws-kms-p256-dsse-v1") + self.assertEqual(kms["aws_account_id"], "992382684924") + self.assertEqual(kms["kms_key_spec"], "ECC_NIST_P256") + self.assertEqual(kms["kms_signing_algorithm"], "ECDSA_SHA_256") + self.assertEqual(kms["kms_message_type"], "DIGEST") + self.assertIs(kms["offline_verification_required"], True) + self.assertIs(kms["alias_allowed"], False) + self.assertEqual(kms["allowed_kinds"], sorted(kms["allowed_kinds"])) + self.assertEqual(len(kms["allowed_kinds"]), 17) def test_support_policy_cannot_enter_the_production_projection(self) -> None: policy = json.loads((ROOT / "support-release-policy.json").read_text()) From d4e4236fe4a871a87ef7dc28e72783a7ad7e4ac0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:45:31 -0400 Subject: [PATCH 14/18] fix: bind offline trust resolution --- .../production-current-default.schema.json | 9 +- ...roduction-lifecycle-checkpoint.schema.json | 7 +- scripts/production_trust.py | 14 +- scripts/public_trust_resolver.py | 147 ++++++++++++++ tests/test_governance_workflows.py | 73 +++++-- tests/test_production_lifecycle.py | 130 +++++++++++-- tests/test_public_trust_kms.py | 183 +++++++++++++++++- tests/test_public_trust_schemas.py | 35 ++++ 8 files changed, 552 insertions(+), 46 deletions(-) create mode 100644 scripts/public_trust_resolver.py diff --git a/schemas/production-current-default.schema.json b/schemas/production-current-default.schema.json index e0e73fe..67a37e6 100644 --- a/schemas/production-current-default.schema.json +++ b/schemas/production-current-default.schema.json @@ -58,13 +58,18 @@ "issuer": { "type": "object", "additionalProperties": false, - "required": ["repository", "repository_id", "workflow", "ref", "source_commit"], + "required": [ + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment" + ], "properties": { "repository": {"const": "OpenAdaptAI/openadapt-ops"}, "repository_id": {"const": "1172011294"}, + "repository_owner_id": {"const": "132681217"}, "workflow": {"const": ".github/workflows/production-current-default.yml"}, "ref": {"const": "refs/heads/main"}, - "source_commit": {"$ref": "#/$defs/commit"} + "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-current-default"} } } } diff --git a/schemas/production-lifecycle-checkpoint.schema.json b/schemas/production-lifecycle-checkpoint.schema.json index 8da0e9e..22990ee 100644 --- a/schemas/production-lifecycle-checkpoint.schema.json +++ b/schemas/production-lifecycle-checkpoint.schema.json @@ -245,15 +245,18 @@ "type": "object", "additionalProperties": false, "required": [ - "repository", "repository_id", "workflow", "ref", "source_commit", - "policy_repository", "policy_repository_id", "policy_source_commit", "policy_path" + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", "policy_repository", + "policy_repository_id", "policy_source_commit", "policy_path" ], "properties": { "repository": {"const": "OpenAdaptAI/openadapt-ops"}, "repository_id": {"const": "1172011294"}, + "repository_owner_id": {"const": "132681217"}, "workflow": {"const": ".github/workflows/production-lifecycle-checkpoint.yml"}, "ref": {"const": "refs/heads/main"}, "source_commit": {"$ref": "#/$defs/commit"}, + "environment": {"const": "production-lifecycle-checkpoint"}, "policy_repository": {"const": "OpenAdaptAI/.github"}, "policy_repository_id": {"const": "858454062"}, "policy_source_commit": {"$ref": "#/$defs/commit"}, diff --git a/scripts/production_trust.py b/scripts/production_trust.py index 67d3205..9104eb1 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -2529,15 +2529,20 @@ def validate_current_default(value: Any, *, now: datetime | None = None) -> dict require_decimal_id(target["deployment_id"], "deployment id") issuer = closed( current["issuer"], - {"repository", "repository_id", "workflow", "ref", "source_commit"}, + { + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", + }, "current default issuer", ) if ( issuer["repository"] != "OpenAdaptAI/openadapt-ops" or issuer["repository_id"] != "1172011294" + or issuer["repository_owner_id"] != "132681217" or issuer["workflow"] != ".github/workflows/production-current-default.yml" or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "production-current-default" or not isinstance(issuer["source_commit"], str) or HEX40.fullmatch(issuer["source_commit"]) is None ): @@ -2645,8 +2650,9 @@ def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, issuer = closed( checkpoint["issuer"], { - "repository", "repository_id", "workflow", "ref", "source_commit", - "policy_repository", "policy_repository_id", "policy_source_commit", "policy_path", + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", "policy_repository", + "policy_repository_id", "policy_source_commit", "policy_path", }, "checkpoint issuer", ) @@ -2656,9 +2662,11 @@ def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, if ( issuer["repository"] != "OpenAdaptAI/openadapt-ops" or issuer["repository_id"] != "1172011294" + or issuer["repository_owner_id"] != "132681217" or issuer["workflow"] != ".github/workflows/production-lifecycle-checkpoint.yml" or issuer["ref"] != "refs/heads/main" + or issuer["environment"] != "production-lifecycle-checkpoint" or issuer["policy_repository"] != "OpenAdaptAI/.github" or issuer["policy_repository_id"] != "858454062" or issuer["policy_source_commit"] != checkpoint["registry_source_commit"] diff --git a/scripts/public_trust_resolver.py b/scripts/public_trust_resolver.py new file mode 100644 index 0000000..dfbf589 --- /dev/null +++ b/scripts/public_trust_resolver.py @@ -0,0 +1,147 @@ +"""Resolve and verify one content-addressed KMS public-trust object pair.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +import public_trust_kms as kms +import validate_evidence_registry as evidence + + +class PublicTrustResolutionError(ValueError): + """The reference pair, raw bytes, current state, or signature is invalid.""" + + +def _parse_canonical(raw: bytes, label: str) -> dict[str, Any]: + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise PublicTrustResolutionError(f"{label} is not JSON") from exc + if not isinstance(value, dict) or raw != evidence.canonical(value) + b"\n": + raise PublicTrustResolutionError( + f"{label} must be canonical JSON followed by one LF" + ) + return value + + +def _verify_raw_reference( + raw: bytes, reference_value: Any, *, label: str +) -> tuple[dict[str, Any], dict[str, Any]]: + try: + reference = evidence.validate_reference(reference_value) + except evidence.EvidenceRegistryError as exc: + raise PublicTrustResolutionError(f"{label} reference is invalid: {exc}") from exc + expected = "sha256:" + hashlib.sha256(raw).hexdigest() + if expected != reference["object_sha256"] or len(raw) != reference["size_bytes"]: + raise PublicTrustResolutionError(f"{label} bytes or size differ from the reference") + return reference, _parse_canonical(raw, label) + + +def verify_registered_public_trust_pair( + *, + object_raw: bytes, + object_reference: Any, + bundle_raw: bytes, + bundle_reference: Any, + signer_registry_raw: bytes, + expected_signer_registry_sha256: str, + expected_authority_state_sha256: str, + expected_revocation_state_sha256: str, + now: datetime | None = None, +) -> dict[str, Any]: + """Verify raw referenced object bytes and the adjacent offline KMS DSSE bundle.""" + + object_ref, object_value = _verify_raw_reference( + object_raw, object_reference, label="public-trust object" + ) + bundle_ref, bundle_value = _verify_raw_reference( + bundle_raw, bundle_reference, label="public-trust bundle" + ) + if ( + bundle_ref["kind"] != object_ref["kind"] + "-sigstore-bundle" + or bundle_ref["subject_sha256"] != object_ref["object_sha256"] + or object_ref["subject_sha256"] is not None + or any( + object_ref[field] != bundle_ref[field] + for field in ( + "repository", "repository_id", "repository_owner_id", + "registry_source_commit", "registry_revision", + "registry_head_sha256", + ) + ) + ): + raise PublicTrustResolutionError("public-trust reference pair is not adjacent and bound") + expected_object_identity = evidence.semantic_identity_digest( + kind=object_ref["kind"], + object_schema_version=object_ref["object_schema_version"], + object_value=object_value, + object_sha256=object_ref["object_sha256"], + ) + expected_bundle_identity = evidence.semantic_identity_digest( + kind=bundle_ref["kind"], + object_schema_version=bundle_ref["object_schema_version"], + object_value=bundle_ref["subject_sha256"], + object_sha256=bundle_ref["object_sha256"], + ) + if ( + object_ref["semantic_identity_sha256"] != expected_object_identity + or bundle_ref["semantic_identity_sha256"] != expected_bundle_identity + ): + raise PublicTrustResolutionError("public-trust semantic identity differs") + registry_value = _parse_canonical(signer_registry_raw, "signer registry") + try: + registry = evidence.validate_signer_registry(registry_value) + except evidence.EvidenceRegistryError as exc: + raise PublicTrustResolutionError(f"signer registry is invalid: {exc}") from exc + registry_identity = evidence.signer_registry_identity_digest(registry) + if registry_identity != expected_signer_registry_sha256: + raise PublicTrustResolutionError("signer registry identity is not current") + instant = now or datetime.now(timezone.utc) + generated_at = evidence._timestamp(registry["generated_at"], "registry generated_at") + expires_at = evidence._timestamp(registry["expires_at"], "registry expires_at") + if not generated_at <= instant < expires_at: + raise PublicTrustResolutionError("signer registry is not active") + try: + statement = kms.statement_from_bundle(bundle_value) + except kms.PublicTrustKmsError as exc: + raise PublicTrustResolutionError(f"public-trust bundle is invalid: {exc}") from exc + matches = [ + signer for signer in registry["signers"] + if signer.get("key_id") == statement["key_id"] + and signer.get("algorithm") == "ecdsa-p256-sha256" + ] + if len(matches) != 1: + raise PublicTrustResolutionError("public-trust bundle does not select one registry signer") + signer = matches[0] + try: + kms.validate_statement_object_binding( + statement, + object_raw=object_raw, + object_value=object_value, + object_kind=object_ref["kind"], + object_schema_version=object_ref["object_schema_version"], + object_media_type=object_ref["object_media_type"], + semantic_identity_sha256=object_ref["semantic_identity_sha256"], + expected_signer_registry_sha256=expected_signer_registry_sha256, + expected_authority_state_sha256=expected_authority_state_sha256, + expected_revocation_state_sha256=expected_revocation_state_sha256, + ) + kms.verify_bundle( + bundle_value, + expected_statement=statement, + signer=signer, + now=instant, + ) + except kms.PublicTrustKmsError as exc: + raise PublicTrustResolutionError(f"public-trust verification failed: {exc}") from exc + return { + "object": object_value, + "object_reference": object_ref, + "bundle_reference": bundle_ref, + "signing_statement": statement, + "signer_registry_sha256": registry_identity, + "verified_at": instant.strftime("%Y-%m-%dT%H:%M:%SZ"), + } diff --git a/tests/test_governance_workflows.py b/tests/test_governance_workflows.py index 1841820..49c2d04 100644 --- a/tests/test_governance_workflows.py +++ b/tests/test_governance_workflows.py @@ -1,17 +1,19 @@ from __future__ import annotations import json +import hashlib import re import sys import tempfile +import types import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -import prepare_lifecycle_change as prepare -import validate_evidence_registry as registry +import prepare_lifecycle_change as prepare # noqa: E402 LIFECYCLE_WORKFLOWS = { "production-lifecycle-activation.yml": "production-lifecycle-activation", @@ -28,6 +30,45 @@ def _write_json(path: Path, value: object) -> None: path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") +def _legacy_entry_digest(entry: dict) -> str: + projection = { + field: entry[field] + for field in ( + "kind", "prior_entry_sha256", "recorded_at", "sequence", + "sha256", "size_bytes", "url", + ) + } + encoded = json.dumps( + projection, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + return "sha256:" + hashlib.sha256( + b"OpenAdapt production evidence registry entry v1\0" + encoded + ).hexdigest() + + +def _legacy_build_entry(**fields: object) -> dict: + entry = dict(fields) + entry["entry_sha256"] = _legacy_entry_digest(entry) + return entry + + +def _legacy_registry_adapter() -> types.SimpleNamespace: + def validate_registry(value: dict) -> list[dict]: + if value.get("schema_version") != "openadapt.production-evidence-registry/v1": + raise ValueError("legacy registry schema differs") + return value["entries"] + + def validate_append_only_history(previous: dict, current: dict) -> None: + if current["entries"][:-1] != previous["entries"]: + raise ValueError("legacy registry history changed") + + return types.SimpleNamespace( + build_entry=_legacy_build_entry, + validate_registry=validate_registry, + validate_append_only_history=validate_append_only_history, + ) + + class GovernanceWorkflowContractTests(unittest.TestCase): def test_profile_check_reports_on_every_pull_request(self) -> None: content = _read(".github/workflows/profile-consistency.yml") @@ -66,7 +107,8 @@ def test_dispatch_inventory_is_exact(self) -> None: discovered.add(path.name) self.assertEqual( discovered, - set(LIFECYCLE_WORKFLOWS) | {"profile-consistency.yml"}, + set(LIFECYCLE_WORKFLOWS) + | {"production-lifecycle-ref.yml", "profile-consistency.yml"}, ) def test_lifecycle_workflows_are_app_only_review_paths(self) -> None: @@ -166,7 +208,7 @@ def test_authority_state_appends_one_hash_chained_entry(self) -> None: "entries": [], } _write_json(root / "evidence-registry.json", empty) - entry = registry.build_entry( + entry = _legacy_build_entry( sequence=1, kind="evidence-summary", url="https://example.test/summary.json", @@ -179,16 +221,19 @@ def test_authority_state_appends_one_hash_chained_entry(self) -> None: prepare.AUTHORITY_DOMAIN, {"source_commit": self.SOURCE_COMMIT, "entry": entry}, ) - prepare.record_authority( - root=root, - source_commit=self.SOURCE_COMMIT, - kind=entry["kind"], - url=entry["url"], - sha256=entry["sha256"], - size_bytes=entry["size_bytes"], - recorded_at=entry["recorded_at"], - idempotency_key=key, - ) + with mock.patch.object( + prepare, "evidence_registry", _legacy_registry_adapter() + ): + prepare.record_authority( + root=root, + source_commit=self.SOURCE_COMMIT, + kind=entry["kind"], + url=entry["url"], + sha256=entry["sha256"], + size_bytes=entry["size_bytes"], + recorded_at=entry["recorded_at"], + idempotency_key=key, + ) current = json.loads((root / "evidence-registry.json").read_text()) self.assertEqual(current["entries"], [entry]) self.assertEqual(current["head_entry_sha256"], entry["entry_sha256"]) diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index 90ab5dc..9f68dd1 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -6,15 +6,16 @@ import hashlib import json import sys +import types import unittest from datetime import datetime, timezone from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) import validate_production_lifecycle as lifecycle # noqa: E402 -import validate_evidence_registry as evidence_registry # noqa: E402 NOW = datetime(2026, 8, 18, 12, 0, 0, tzinfo=timezone.utc) SOURCE_COMMIT = "1" * 40 @@ -25,12 +26,93 @@ def digest_bytes(value: bytes) -> str: return "sha256:" + hashlib.sha256(value).hexdigest() -def load_policy() -> dict: - return json.loads( - (ROOT / "production-lifecycle-policy.json").read_text(encoding="utf-8") +class LegacyRegistryError(ValueError): + pass + + +def legacy_entry_digest(entry: dict) -> str: + projection = { + field: entry[field] + for field in ( + "kind", "prior_entry_sha256", "recorded_at", "sequence", + "sha256", "size_bytes", "url", + ) + } + return digest_bytes( + b"OpenAdapt production evidence registry entry v1\0" + + json.dumps( + projection, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() ) +def legacy_build_entry(**fields: object) -> dict: + entry = dict(fields) + entry["entry_sha256"] = legacy_entry_digest(entry) + return entry + + +def legacy_registry_adapter() -> types.SimpleNamespace: + def validate_registry(value: object) -> list[dict]: + if not isinstance(value, dict) or set(value) != { + "$schema", "schema_version", "head_entry_sha256", "entries" + }: + raise LegacyRegistryError("evidence registry object is not closed") + if value["schema_version"] != "openadapt.production-evidence-registry/v1": + raise LegacyRegistryError("evidence registry schema is not supported") + entries = value["entries"] + if not isinstance(entries, list): + raise LegacyRegistryError("evidence registry entries must be a list") + prior = None + for index, entry in enumerate(entries): + if ( + entry["sequence"] != index + 1 + or entry["prior_entry_sha256"] != prior + or entry["entry_sha256"] != legacy_entry_digest(entry) + ): + raise LegacyRegistryError("evidence registry chain is invalid") + prior = entry["entry_sha256"] + if value["head_entry_sha256"] != prior: + raise LegacyRegistryError("evidence registry head digest is stale") + return entries + + def require_registered( + entries: list[dict], *, url: str, sha256: str, kind: str, label: str + ) -> None: + matches = [ + entry for entry in entries + if entry["url"] == url and entry["sha256"] == sha256 + ] + if len(matches) != 1: + raise LegacyRegistryError( + f"{label} is not registered in the central evidence registry" + ) + if matches[0]["kind"] != kind: + raise LegacyRegistryError(f"{label} is registered with the wrong kind") + + return types.SimpleNamespace( + EvidenceRegistryError=LegacyRegistryError, + validate_registry=validate_registry, + require_registered=require_registered, + ) + + +def load_policy() -> dict: + # The first v2 migration PR retains the v1 validator for recovery only. + # Keep these tests on its own closed contract instead of feeding it v2 bytes. + return { + "$schema": "schemas/production-lifecycle-policy.schema.json", + "schema_version": lifecycle.POLICY_SCHEMA, + "revision": 1, + "maximum_admission_days": 30, + "summary_authority": copy.deepcopy(lifecycle.EXPECTED_AUTHORITY), + "targets": [ + {"id": target, **copy.deepcopy(contract)} + for target, contract in lifecycle.EXPECTED_TARGETS.items() + ], + } + + def flow_release() -> dict: artifacts = [ { @@ -407,6 +489,7 @@ def build_registry(admissions: dict, remote: dict[str, bytes]) -> dict | None: """Build a valid central evidence registry for the given admissions.""" entries = [] + seen_references: set[tuple[str, str, str]] = set() prior: str | None = None sequence = 0 for admission in admissions.get("admissions", []): @@ -415,8 +498,12 @@ def build_registry(admissions: dict, remote: dict[str, bytes]) -> dict | None: ("evidence-summary", "summary_url", "summary_sha256"), ("attestation-bundle", "attestation_bundle_url", "attestation_bundle_sha256"), ): + identity = (kind, reference[url_key], reference[digest_key]) + if identity in seen_references: + continue + seen_references.add(identity) sequence += 1 - entry = evidence_registry.build_entry( + entry = legacy_build_entry( sequence=sequence, kind=kind, url=reference[url_key], @@ -433,7 +520,7 @@ def build_registry(admissions: dict, remote: dict[str, bytes]) -> dict | None: return None return { "$schema": "schemas/evidence-registry.schema.json", - "schema_version": evidence_registry.REGISTRY_SCHEMA, + "schema_version": "openadapt.production-evidence-registry/v1", "head_entry_sha256": prior, "entries": entries, } @@ -477,19 +564,24 @@ def verify( if group != "production" and subject in subjects: subjects.remove(subject) - result = lifecycle.validate( - load_policy(), - admissions, - repository_lifecycle, - surface_lifecycle, - policy_sha256=POLICY_DIGEST, - now=NOW, - fetch=fetch, - verify_attestation=verify, - registry_value=( - registry if registry is not None else build_registry(admissions, remote or {}) - ), - ) + with mock.patch.object( + lifecycle, "evidence_registry", legacy_registry_adapter() + ): + result = lifecycle.validate( + load_policy(), + admissions, + repository_lifecycle, + surface_lifecycle, + policy_sha256=POLICY_DIGEST, + now=NOW, + fetch=fetch, + verify_attestation=verify, + registry_value=( + registry + if registry is not None + else build_registry(admissions, remote or {}) + ), + ) return result diff --git a/tests/test_public_trust_kms.py b/tests/test_public_trust_kms.py index b39ec80..f8e5323 100644 --- a/tests/test_public_trust_kms.py +++ b/tests/test_public_trust_kms.py @@ -21,7 +21,9 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -import public_trust_kms as kms +import public_trust_kms as kms # noqa: E402 +import public_trust_resolver as resolver # noqa: E402 +import validate_evidence_registry as evidence # noqa: E402 NOW = datetime(2026, 8, 27, 12, 0, 30, tzinfo=timezone.utc) KMS_ARN = ( @@ -88,18 +90,18 @@ def source_issuer() -> dict: } -def object_value() -> dict: +def object_value(registry_sha256: str = sha("registry")) -> dict: return { "schema_version": "test/v1", "authority_state_sha256": sha("authority"), "revocation_state_sha256": sha("revocation"), - "signer_registry_sha256": sha("registry"), + "signer_registry_sha256": registry_sha256, "issuer": source_issuer(), } -def statement() -> dict: - value = object_value() +def statement(registry_sha256: str = sha("registry")) -> dict: + value = object_value(registry_sha256) raw = kms.canonical_lf(value) active_signer = signer() return { @@ -111,7 +113,7 @@ def statement() -> dict: "object_size_bytes": len(raw), "semantic_identity_sha256": sha("release-identity"), "source_issuer": source_issuer(), - "signer_registry_sha256": sha("registry"), + "signer_registry_sha256": registry_sha256, "authority_state_sha256": sha("authority"), "revocation_state_sha256": sha("revocation"), "issued_at": "2026-08-27T12:00:00Z", @@ -163,6 +165,47 @@ def bundle(value: dict | None = None) -> dict: } +def reference(kind: str, raw: bytes, *, semantic: str, subject: str | None) -> dict: + object_sha256 = "sha256:" + hashlib.sha256(raw).hexdigest() + digest_hex = object_sha256.removeprefix("sha256:") + schema, media = evidence.OBJECT_KIND_CONTRACTS[kind] + entry = { + "kind": kind, + "object_schema_version": schema, + "object_path": ( + f"production-evidence/objects/sha256/{digest_hex[:2]}/" + f"{digest_hex}.{kind}.json" + ), + "object_sha256": object_sha256, + "size_bytes": len(raw), + "object_media_type": media, + "semantic_identity_sha256": semantic, + "subject_sha256": subject, + } + entry["registry_entry_sha256"] = evidence.entry_digest(entry) + return { + "schema_version": evidence.REFERENCE_SCHEMA, + "repository": evidence.REPOSITORY, + "repository_id": evidence.REPOSITORY_ID, + "repository_owner_id": evidence.REPOSITORY_OWNER_ID, + "registry_source_commit": "c" * 40, + "registry_revision": 5, + "registry_head_sha256": sha("registry-head"), + **entry, + } + + +def registry_raw() -> bytes: + value = { + "schema_version": evidence.SIGNER_REGISTRY_SCHEMA, + "revision": 5, + "generated_at": "2026-08-27T11:00:00Z", + "expires_at": "2026-08-27T13:00:00Z", + "signers": [signer()], + } + return evidence.canonical(value) + b"\n" + + class PublicTrustKmsTests(unittest.TestCase): def test_fixed_key_and_dsse_pae_digest_vector(self) -> None: active_signer = signer() @@ -317,6 +360,134 @@ def test_bundle_rejects_profile_substitution_and_extra_fields(self) -> None: signer=active_signer, ) + def test_registered_pair_resolves_raw_bytes_and_current_state_offline(self) -> None: + signer_registry_raw = registry_raw() + registry_identity = evidence.signer_registry_identity_digest( + json.loads(signer_registry_raw) + ) + signed_object = object_value(registry_identity) + object_raw = kms.canonical_lf(signed_object) + semantic = evidence.semantic_identity_digest( + kind="qualification-release", + object_schema_version="openadapt.qualification-release/v1", + object_value=signed_object, + object_sha256="sha256:" + hashlib.sha256(object_raw).hexdigest(), + ) + signed_statement = statement(registry_identity) + signed_statement["object_sha256"] = "sha256:" + hashlib.sha256(object_raw).hexdigest() + signed_statement["object_size_bytes"] = len(object_raw) + signed_statement["semantic_identity_sha256"] = semantic + signed_bundle = bundle(signed_statement) + bundle_raw = kms.canonical_lf(signed_bundle) + object_ref = reference( + "qualification-release", + object_raw, + semantic=semantic, + subject=None, + ) + bundle_sha = "sha256:" + hashlib.sha256(bundle_raw).hexdigest() + bundle_ref = reference( + "qualification-release-sigstore-bundle", + bundle_raw, + semantic=evidence.semantic_identity_digest( + kind="qualification-release-sigstore-bundle", + object_schema_version="sigstore.bundle/v0.3", + object_value=object_ref["object_sha256"], + object_sha256=bundle_sha, + ), + subject=object_ref["object_sha256"], + ) + result = resolver.verify_registered_public_trust_pair( + object_raw=object_raw, + object_reference=object_ref, + bundle_raw=bundle_raw, + bundle_reference=bundle_ref, + signer_registry_raw=signer_registry_raw, + expected_signer_registry_sha256=registry_identity, + expected_authority_state_sha256=sha("authority"), + expected_revocation_state_sha256=sha("revocation"), + now=NOW, + ) + self.assertEqual(result["object"], signed_object) + self.assertEqual(result["signing_statement"], signed_statement) + + def test_registered_pair_refuses_raw_tamper_wrong_state_and_wrong_key(self) -> None: + signer_registry_raw = registry_raw() + registry_identity = evidence.signer_registry_identity_digest( + json.loads(signer_registry_raw) + ) + signed_object = object_value(registry_identity) + object_raw = kms.canonical_lf(signed_object) + semantic = evidence.semantic_identity_digest( + kind="qualification-release", + object_schema_version="openadapt.qualification-release/v1", + object_value=signed_object, + object_sha256="sha256:" + hashlib.sha256(object_raw).hexdigest(), + ) + signed_statement = statement(registry_identity) + signed_statement["object_sha256"] = "sha256:" + hashlib.sha256(object_raw).hexdigest() + signed_statement["object_size_bytes"] = len(object_raw) + signed_statement["semantic_identity_sha256"] = semantic + signed_bundle = bundle(signed_statement) + bundle_raw = kms.canonical_lf(signed_bundle) + object_ref = reference( + "qualification-release", + object_raw, + semantic=semantic, + subject=None, + ) + bundle_sha = "sha256:" + hashlib.sha256(bundle_raw).hexdigest() + bundle_ref = reference( + "qualification-release-sigstore-bundle", + bundle_raw, + semantic=evidence.semantic_identity_digest( + kind="qualification-release-sigstore-bundle", + object_schema_version="sigstore.bundle/v0.3", + object_value=object_ref["object_sha256"], + object_sha256=bundle_sha, + ), + subject=object_ref["object_sha256"], + ) + base = { + "object_raw": object_raw, + "object_reference": object_ref, + "bundle_raw": bundle_raw, + "bundle_reference": bundle_ref, + "signer_registry_raw": signer_registry_raw, + "expected_signer_registry_sha256": registry_identity, + "expected_authority_state_sha256": sha("authority"), + "expected_revocation_state_sha256": sha("revocation"), + "now": NOW, + } + for label, mutate, message in ( + ( + "object bytes", + lambda value: value.__setitem__("object_raw", object_raw + b" "), + "bytes or size", + ), + ( + "authority state", + lambda value: value.__setitem__( + "expected_authority_state_sha256", sha("other-authority") + ), + "authority_state_sha256", + ), + ( + "registry identity", + lambda value: value.__setitem__( + "expected_signer_registry_sha256", sha("other-registry") + ), + "not current", + ), + ): + with self.subTest(label=label): + changed = copy.deepcopy(base) + mutate(changed) + with self.assertRaisesRegex( + resolver.PublicTrustResolutionError, message + ): + resolver.verify_registered_public_trust_pair(**changed) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 9e4fb8a..14447a1 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -589,6 +589,41 @@ def test_v2_reference_has_no_url_transport_field(self) -> None: "openadapt.production-evidence-object-reference/v2", ) + def test_current_default_and_checkpoint_bind_complete_signed_issuers(self) -> None: + current = json.loads( + (SCHEMA_ROOT / "production-current-default.schema.json").read_text() + ) + checkpoint = json.loads( + (SCHEMA_ROOT / "production-lifecycle-checkpoint.schema.json").read_text() + ) + common = { + "repository", "repository_id", "repository_owner_id", "workflow", + "ref", "source_commit", "environment", + } + current_issuer = current["$defs"]["issuer"] + checkpoint_issuer = checkpoint["$defs"]["issuer"] + self.assertEqual(set(current_issuer["required"]), common) + self.assertEqual( + current_issuer["properties"]["repository_owner_id"]["const"], + "132681217", + ) + self.assertEqual( + current_issuer["properties"]["environment"]["const"], + "production-current-default", + ) + self.assertEqual( + set(checkpoint_issuer["required"]), + common + | { + "policy_repository", "policy_repository_id", + "policy_source_commit", "policy_path", + }, + ) + self.assertEqual( + checkpoint_issuer["properties"]["environment"]["const"], + "production-lifecycle-checkpoint", + ) + def test_release_artifacts_bind_publish_destinations(self) -> None: schema = json.loads( (SCHEMA_ROOT / "qualification-release.schema.json").read_text( From 727fb20b022ebd9d0284af7927e49ad4a1fa8a3c Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:50:21 -0400 Subject: [PATCH 15/18] fix: isolate private recovery signer --- .../qualification-signer-registry.schema.json | 1 + scripts/validate_evidence_registry.py | 37 ++++++ tests/test_evidence_registry.py | 107 +++++++++++++++++- 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/schemas/qualification-signer-registry.schema.json b/schemas/qualification-signer-registry.schema.json index dc681fe..ec31819 100644 --- a/schemas/qualification-signer-registry.schema.json +++ b/schemas/qualification-signer-registry.schema.json @@ -60,6 +60,7 @@ "private-qualification-evidence-decision-final-manifest", "private-qualification-evidence-decision-request", "private-qualification-evidence-decision-storage-seal", + "private-qualification-evidence-recovery-receipt", "private-qualification-evidence-manifest", "qualification-authority-state-receipt", "qualification-campaign-permit", diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index baab6aa..c7750b5 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -44,10 +44,12 @@ "private-qualification-evidence-decision", "private-qualification-evidence-decision-storage-seal", "private-qualification-evidence-decision-final-manifest", + "private-qualification-evidence-recovery-receipt", "qualification-evidence-decision-receipt", "qualification-authority-state-receipt", "qualification-revocation-state-receipt", } +RECOVERY_SIGNER_USAGE = "private-qualification-evidence-recovery-receipt" DECISION_RECEIPT_IDENTITY_DOMAIN = ( b"OpenAdapt qualification decision receipt series identity v1\0" ) @@ -374,6 +376,10 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: or any(item not in SIGNER_USAGES for item in usages) ): raise EvidenceRegistryError(f"{label} usage allowlist is invalid") + if RECOVERY_SIGNER_USAGE in usages and usages != [RECOVERY_SIGNER_USAGE]: + raise EvidenceRegistryError( + f"{label} recovery usage must use a distinct signer" + ) workflows = signer["allowed_workflows"] if ( not isinstance(workflows, list) @@ -412,9 +418,40 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: ) else: raise EvidenceRegistryError(f"{label} status is invalid") + recovery_signers = [ + signer + for signer in signers + if RECOVERY_SIGNER_USAGE in signer.get("allowed_usages", []) + ] + if recovery_signers and sum( + signer["status"] == "active" for signer in recovery_signers + ) != 1: + raise EvidenceRegistryError( + "signer registry must identify exactly one active recovery signer" + ) return registry +def require_active_signer_for_usage( + value: Any, usage: str +) -> dict[str, Any]: + """Select the one active signer for a closed private signing usage.""" + + if usage not in SIGNER_USAGES: + raise EvidenceRegistryError("requested signer usage is invalid") + registry = validate_signer_registry(value) + matches = [ + signer + for signer in registry["signers"] + if signer["status"] == "active" and usage in signer["allowed_usages"] + ] + if len(matches) != 1: + raise EvidenceRegistryError( + f"signer registry must identify exactly one active signer for {usage}" + ) + return matches[0] + + def _object_path(value: Any, kind: str, object_sha256: str) -> str: if not isinstance(value, str) or not value or len(value) > 512: raise EvidenceRegistryError("object path is invalid") diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index 302c056..cbe768b 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -17,9 +17,9 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -import public_trust_kms as kms -import stage_production_evidence as stage -import validate_evidence_registry as registry +import public_trust_kms as kms # noqa: E402 +import stage_production_evidence as stage # noqa: E402 +import validate_evidence_registry as registry # noqa: E402 def sha(character: str) -> str: @@ -83,6 +83,47 @@ def reference(value: dict, **overrides) -> dict: return result +def ed25519_signer( + key_byte: int, + usage: str, + *, + status: str = "active", +) -> dict: + key = bytes([key_byte]) * 32 + spki = bytes.fromhex("302a300506032b6570032100") + key + return { + "algorithm": "ed25519", + "key_id": "qa-ed25519-" + hashlib.sha256(key).hexdigest()[:16], + "public_key": base64.urlsafe_b64encode(key).decode().rstrip("="), + "public_key_spki_der_base64": base64.b64encode(spki).decode(), + "public_key_sha256": "sha256:" + hashlib.sha256(spki).hexdigest(), + "statement_schema_versions": [ + "openadapt.qualification-evidence-signing-statement/v1" + ], + "allowed_usages": [usage], + "allowed_workflows": [ + ( + "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/OpenAdaptAI/openadapt-internal/" + ".github/workflows/issue-private-qualification-evidence-decision.yml" + "@refs/heads/main" + ) + ], + "allowed_ref_prefixes": ["refs/heads/main"], + "status": status, + "revoked_at": "2026-08-27T01:00:00Z" if status == "revoked" else None, + } + + +def signer_registry(*signers: dict) -> dict: + return { + "schema_version": registry.SIGNER_REGISTRY_SCHEMA, + "revision": 1, + "generated_at": "2026-08-27T00:00:00Z", + "expires_at": "2026-09-03T00:00:00Z", + "signers": list(signers), + } + + class EvidenceRegistryTests(unittest.TestCase): def test_kind_map_has_17_regular_and_17_bundle_kinds(self) -> None: self.assertEqual(len(registry.REGULAR_KIND_CONTRACTS), 17) @@ -273,6 +314,66 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "bind"): registry.validate_signer_registry(value) + def test_recovery_receipt_uses_one_distinct_active_signer(self) -> None: + schema = json.loads( + (ROOT / "schemas/qualification-signer-registry.schema.json").read_text() + ) + self.assertIn( + registry.RECOVERY_SIGNER_USAGE, + schema["$defs"]["ed25519_signer"]["properties"]["allowed_usages"] + ["items"]["enum"], + ) + recovery = ed25519_signer( + 17, registry.RECOVERY_SIGNER_USAGE + ) + storage = ed25519_signer( + 18, "private-qualification-evidence-decision-storage-seal" + ) + value = signer_registry(recovery, storage) + self.assertEqual(registry.validate_signer_registry(value), value) + self.assertEqual( + registry.require_active_signer_for_usage( + value, registry.RECOVERY_SIGNER_USAGE + )["key_id"], + recovery["key_id"], + ) + self.assertNotEqual(recovery["public_key_sha256"], storage["public_key_sha256"]) + + def test_recovery_signer_mismatch_revocation_and_reuse_fail_closed(self) -> None: + mismatch = signer_registry( + ed25519_signer(19, "qualification-evidence-decision-receipt") + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "exactly one active signer" + ): + registry.require_active_signer_for_usage( + mismatch, registry.RECOVERY_SIGNER_USAGE + ) + + revoked = signer_registry( + ed25519_signer( + 20, registry.RECOVERY_SIGNER_USAGE, status="revoked" + ) + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "exactly one active recovery signer" + ): + registry.validate_signer_registry(revoked) + + reused = signer_registry( + ed25519_signer(21, registry.RECOVERY_SIGNER_USAGE) + ) + reused["signers"][0]["allowed_usages"] = sorted( + [ + registry.RECOVERY_SIGNER_USAGE, + "private-qualification-evidence-decision-storage-seal", + ] + ) + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "distinct signer" + ): + registry.validate_signer_registry(reused) + def test_signer_registry_accepts_only_exact_aws_kms_p256_profile(self) -> None: public_key = ec.derive_private_key(7, ec.SECP256R1()).public_key() point = public_key.public_bytes( From 19d7db6b03444a807f6494ae4c0e4375adc7ca91 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:52:49 -0400 Subject: [PATCH 16/18] fix: validate empty registry migration --- scripts/validate_evidence_registry.py | 26 ++++++++++++++++++++++++++ tests/test_evidence_registry.py | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index c7750b5..3f18776 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -775,6 +775,32 @@ def require_registered( def validate_append_only_history(previous_value: object, current_value: object) -> None: + if isinstance(previous_value, dict) and previous_value.get("schema_version") == ( + "openadapt.production-evidence-registry/v1" + ): + expected_empty_v1 = { + "$schema": "schemas/evidence-registry.schema.json", + "schema_version": "openadapt.production-evidence-registry/v1", + "head_entry_sha256": None, + "entries": [], + } + if previous_value != expected_empty_v1: + raise EvidenceRegistryError( + "only the exact empty v1 registry can migrate to v2" + ) + current = validate_registry(current_value) + assert isinstance(current_value, dict) + if ( + current_value["revision"] != 1 + or current_value["previous_registry_head_sha256"] is not None + or current_value["signer_registry"] is not None + or current_value["signer_registry_history"] != [] + or current != [] + ): + raise EvidenceRegistryError( + "the v1-to-v2 migration must create the exact empty v2 genesis" + ) + return previous = validate_registry(previous_value) current = validate_registry(current_value) previous_doc = previous_value diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index cbe768b..ea7030c 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -513,6 +513,25 @@ def test_append_only_revision_binds_previous_head(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "previous head"): registry.validate_append_only_history(previous, current) + def test_only_exact_empty_v1_registry_can_create_the_empty_v2_genesis(self) -> None: + previous = { + "$schema": "schemas/evidence-registry.schema.json", + "schema_version": "openadapt.production-evidence-registry/v1", + "head_entry_sha256": None, + "entries": [], + } + current = document() + registry.validate_append_only_history(previous, current) + + changed_previous = copy.deepcopy(previous) + changed_previous["head_entry_sha256"] = sha("1") + with self.assertRaisesRegex(registry.EvidenceRegistryError, "exact empty"): + registry.validate_append_only_history(changed_previous, current) + + changed_current = document(revision=2) + with self.assertRaisesRegex(registry.EvidenceRegistryError, "empty v2 genesis"): + registry.validate_append_only_history(previous, changed_current) + def test_decision_revision_semantic_identity_cannot_conflict(self) -> None: receipt_identity = { "decision_identity_sha256": sha("1"), From 575c7f639c2bedc8e8498e785207b731757b6db2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:57:25 -0400 Subject: [PATCH 17/18] fix: isolate retained lifecycle history check --- .github/workflows/profile-consistency.yml | 4 ++- scripts/validate_production_lifecycle.py | 40 +++++++++++++++++++++++ tests/test_governance_workflows.py | 9 +++++ tests/test_production_lifecycle.py | 18 ++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/workflows/profile-consistency.yml b/.github/workflows/profile-consistency.yml index bdf6ebb..a81bfdb 100644 --- a/.github/workflows/profile-consistency.yml +++ b/.github/workflows/profile-consistency.yml @@ -66,7 +66,9 @@ jobs: else printf '{"admissions":[]}\n' > "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" fi - python3 scripts/validate_production_lifecycle.py --previous-admissions "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" + python3 scripts/validate_production_lifecycle.py \ + --history-only \ + --previous-admissions "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" - name: Validate product truth and links run: python3 scripts/check_profile.py diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py index 85b96bf..702557e 100755 --- a/scripts/validate_production_lifecycle.py +++ b/scripts/validate_production_lifecycle.py @@ -1656,6 +1656,21 @@ def validate_files(root: Path = ROOT, *, now: datetime | None = None) -> dict[st ) +def validate_history_document(value: object, label: str) -> None: + """Validate one retained v1 admission-ledger document without its policy.""" + + document = _closed( + value, + {"$schema", "schema_version", "policy_sha256", "admissions"}, + label, + ) + if document["schema_version"] != ADMISSIONS_SCHEMA: + raise LifecycleError(f"{label} schema is not supported") + _digest(document["policy_sha256"], f"{label} policy digest") + if not isinstance(document["admissions"], list): + raise LifecycleError(f"{label} admissions must be a list") + + def validate_append_only_history(previous_value: object, current_value: object) -> None: """Reject release-ledger rollback while allowing one-way current revocation.""" @@ -1700,8 +1715,33 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--previous-admissions", type=Path) + parser.add_argument( + "--history-only", + action="store_true", + help=( + "validate only the retained v1 admission-ledger append-only relation; " + "requires --previous-admissions" + ), + ) args = parser.parse_args() + if args.history_only and args.previous_admissions is None: + parser.error("--history-only requires --previous-admissions") try: + if args.history_only: + previous = _load_json( + args.previous_admissions, "previous Production lifecycle admissions" + ) + current = _load_json( + args.root / ADMISSIONS_PATH.name, + "current Production lifecycle admissions", + ) + validate_history_document( + previous, "previous Production admission history" + ) + validate_history_document(current, "current Production admission history") + validate_append_only_history(previous, current) + print("Validated retained v1 Production admission history.") + return 0 active = validate_files(args.root) if args.previous_admissions is not None: previous = _load_json( diff --git a/tests/test_governance_workflows.py b/tests/test_governance_workflows.py index 49c2d04..c6fd4fb 100644 --- a/tests/test_governance_workflows.py +++ b/tests/test_governance_workflows.py @@ -80,6 +80,15 @@ def test_profile_check_reports_on_every_pull_request(self) -> None: self.assertIn("group: ${{ github.workflow }}-${{ github.event_name }}", content) self.assertIn("cancel-in-progress: false", content) + def test_v1_release_history_gate_does_not_parse_the_v2_policy_as_v1(self) -> None: + content = _read(".github/workflows/profile-consistency.yml") + rollback_step = content.split( + " - name: Refuse Production release-ledger rollback\n", 1 + )[1].split(" - name:", 1)[0] + self.assertIn("--history-only", rollback_step) + self.assertIn("--previous-admissions", rollback_step) + self.assertNotIn("previous-production-lifecycle-policy", rollback_step) + def test_profile_dispatch_refuses_the_lifecycle_app_in_every_job(self) -> None: content = _read(".github/workflows/profile-consistency.yml") self.assertIn(" reject-lifecycle-app:\n permissions: {}", content) diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index 9f68dd1..a67282b 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -1015,6 +1015,24 @@ def test_history_gate_refuses_revocation_rollback(self) -> None: with self.assertRaisesRegex(lifecycle.LifecycleError, "only append"): lifecycle.validate_append_only_history(previous, current) + def test_history_gate_refuses_nonclosed_legacy_document(self) -> None: + previous, _summary, _remote = build_case() + current = copy.deepcopy(previous) + current["unexpected"] = True + with self.assertRaisesRegex(lifecycle.LifecycleError, "must contain exactly"): + lifecycle.validate_history_document( + current, "current Production admission history" + ) + + def test_history_gate_refuses_non_v1_document(self) -> None: + previous, _summary, _remote = build_case() + current = copy.deepcopy(previous) + current["schema_version"] = "openadapt.production-lifecycle-admissions/v2" + with self.assertRaisesRegex(lifecycle.LifecycleError, "not supported"): + lifecycle.validate_history_document( + current, "current Production admission history" + ) + def test_pinned_target_map_refuses_subject_substitution(self) -> None: policy = load_policy() next(item for item in policy["targets"] if item["id"] == "flow")[ From f6efe8b2fae02b5b19668ba3fa688079eb08a4bc Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 00:00:58 -0400 Subject: [PATCH 18/18] fix: validate the v2 lifecycle policy without dropping the trust root validate_production_lifecycle.py still enforced the v1 policy contract while production-lifecycle-policy.json and its schema moved to v2, so check_profile.py refused the repository. Two v1 anchors had to go together: the pinned POLICY_SCHEMA, and a closed key set naming maximum_admission_days and summary_authority. Neither key exists in v2. summary_authority was not renamed. v2 moved the certificate identity that signs Production evidence into production-evidence-policy.json, keyed by evidence kind, for v2 objects. The ledger this module reads is the retained v1 ledger, so its records stay verifiable only against the authority, target map, policy revision and policy digest that issued them. Those move from policy data to pinned module constants, and _validate_summary_authority keeps every check the v1 policy path ran, now applied to the pinned trust root. Deleting the field without that replacement would have left the validator reporting success while nothing checked who signed the evidence. maximum_admission_days split in two. production_trust.validate_release enforces 30 days on openadapt.qualification-release/v1 and validate_qualification_admission enforces 7 days on openadapt.qualification-admission/v3. The retained ledger holds release admissions on the production channel, so maximum_release_admission_days governs its expiry. Both maximums keep the 1-to-30 bound and must now equal the window the trust core actually applies, so neither number can drift unnoticed. The v2 target contract is cross-checked against production_trust.TARGET_CONTRACTS for claim scope, source repository and repository id, and a target may not require an artifact kind that contract does not define. The tests now read the published policy instead of a fixture mirroring the validator's own constants, which is why 135 tests passed while check_profile failed. New tests cover the published document, both admission windows, and the certificate-identity binding: a refactor that empties the authority or removes the verification path fails rather than passing quietly. Co-Authored-By: Claude Opus 5 --- scripts/validate_production_lifecycle.py | 402 +++++++++++++++------- tests/test_production_lifecycle.py | 409 ++++++++++++++++++++--- 2 files changed, 639 insertions(+), 172 deletions(-) diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py index 702557e..14061e0 100755 --- a/scripts/validate_production_lifecycle.py +++ b/scripts/validate_production_lifecycle.py @@ -11,11 +11,22 @@ No network request occurs while the admissions list is empty. A Production admission fails closed unless the referenced summary, attestation bundle, and evidence manifest can all be fetched, hashed, and verified. + +The lifecycle policy is a v2 document. It declares the schema versions and the +two admission windows that the signed checkpoint chain enforces, and it names +the protected feed ref that carries live Production state. It does not carry a +summary authority. For v2 objects the certificate identity that signs +Production acceptance evidence lives in production-evidence-policy.json, keyed +by evidence kind. The admission ledger this module reads is the retained v1 +ledger, so its records keep the exact trust root, target map and policy +revision they were issued under; those are pinned here as module constants +rather than read from the v2 policy file. """ from __future__ import annotations import argparse +import copy import hashlib import json import re @@ -29,6 +40,7 @@ from typing import Any from urllib.parse import quote, urlsplit +import production_trust import validate_evidence_registry as evidence_registry ROOT = Path(__file__).resolve().parents[1] @@ -36,10 +48,37 @@ ADMISSIONS_PATH = ROOT / "production-lifecycle-admissions.json" LIFECYCLE_PATH = ROOT / "repository-lifecycle.yml" -POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v1" +POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v2" +POLICY_DOCUMENT_SCHEMA = "schemas/production-lifecycle-policy.schema.json" +POLICY_REVISION_MINIMUM = 2 ADMISSIONS_SCHEMA = "openadapt.production-lifecycle-admissions/v1" SUMMARY_SCHEMA = "openadapt.production-lifecycle-evidence-summary/v1" RELEASE_IDENTITY_SCHEMA = "openadapt.monotonic-production-release/v1" +OBJECT_REFERENCE_SCHEMA = "openadapt.production-evidence-object-reference/v2" +RELEASE_ADMISSION_SCHEMA = "openadapt.qualification-release/v1" +WORKFLOW_ADMISSION_SCHEMA = "openadapt.qualification-admission/v3" +LIFECYCLE_CHECKPOINT_SCHEMA = "openadapt.production-lifecycle-checkpoint/v1" +LIFECYCLE_FEED_SCHEMA = "openadapt.production-lifecycle-feed/v1" +LIFECYCLE_FEED_REF = "refs/heads/production-lifecycle-feed" +# production_trust.validate_release enforces a 30 day window on every +# openadapt.qualification-release/v1 object, and +# production_trust.validate_qualification_admission enforces a 7 day window on +# every openadapt.qualification-admission/v3 object. The policy declares both +# numbers; this module refuses a policy that declares a different one. +RELEASE_ADMISSION_MAXIMUM_DAYS = 30 +WORKFLOW_ADMISSION_MAXIMUM_DAYS = 7 +# The retained v1 admission ledger holds release admissions on the production +# channel only, so the release admission window governs its expiry, and every +# retained record was issued under policy revision 1. Its records and their +# acceptance summaries carry the digest of the v1 policy document that issued +# them, which is not the digest of the live v2 policy document. +RETAINED_POLICY_REVISION = 1 +RETAINED_POLICY_SHA256 = ( + "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11" +) +TARGET_RELEASE_KINDS = frozenset({"package", "deployment", "hybrid"}) +CLAIM_SCOPE = re.compile(r"^production_[a-z]+$") +DECIMAL_ID = re.compile(r"^[1-9][0-9]*$") SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") HEX40 = re.compile(r"^[0-9a-f]{40}$") SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") @@ -340,36 +379,34 @@ def load_lifecycle( return _parse_group(text, "lifecycle"), _parse_group(text, "public_surfaces") -def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: - policy = _closed( - value, - { - "$schema", - "schema_version", - "revision", - "maximum_admission_days", - "summary_authority", - "targets", - }, - "production lifecycle policy", - ) - if policy["schema_version"] != POLICY_SCHEMA: - raise LifecycleError("production lifecycle policy schema is not supported") - if ( - not isinstance(policy["revision"], int) - or isinstance(policy["revision"], bool) - or policy["revision"] < 1 - ): - raise LifecycleError("production lifecycle policy revision must be positive") - maximum_days = policy["maximum_admission_days"] +def _admission_days(value: object, label: str, enforced: int) -> int: + """Validate one declared admission window against the enforced window.""" + if ( - not isinstance(maximum_days, int) - or isinstance(maximum_days, bool) - or not 1 <= maximum_days <= 30 + not isinstance(value, int) + or isinstance(value, bool) + or not 1 <= value <= 30 ): - raise LifecycleError("maximum_admission_days must be between 1 and 30") + raise LifecycleError(f"{label} must be between 1 and 30") + if value != enforced: + raise LifecycleError( + f"{label} differs from the window the Production trust core enforces" + ) + return value + + +def _validate_summary_authority(value: object) -> dict[str, Any]: + """Validate the attestation authority that verifies acceptance evidence. + + v1 carried this object in the policy document and checked it there. v2 + removed it: for v2 objects production-evidence-policy.json holds the + certificate identity per evidence kind, and the retained v1 ledger keeps the + authority that issued its records. The checks are unchanged; only their + subject moved from policy data to the pinned trust root. + """ + authority = _closed( - policy["summary_authority"], + value, { "repository", "workflow", @@ -405,112 +442,211 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, dict[str, "openadapt.production-acceptance/v1" ): raise LifecycleError("evidence manifest schema is not supported") + if authority["release_identity_schema_version"] != RELEASE_IDENTITY_SCHEMA: + raise LifecycleError("release identity schema is not supported") + if authority["production_channel"] != "production": + raise LifecycleError("summary authority channel is not production") + _digest(authority["acceptance_policy_sha256"], "acceptance policy digest") if authority["signer_provenance_digest_domain"] != ( "OpenAdapt production certificate signer provenance v1\0" ): raise LifecycleError("signer provenance digest domain is not supported") if authority != EXPECTED_AUTHORITY: raise LifecycleError("summary authority differs from the pinned trust root") + return authority - targets_value = policy["targets"] - if not isinstance(targets_value, list) or not targets_value: - raise LifecycleError("production lifecycle policy must declare targets") - targets: dict[str, dict[str, Any]] = {} - memberships: set[tuple[str, str]] = set() - for index, item in enumerate(targets_value): - target = _closed( - item, - { - "id", - "display_name", - "lifecycle_scope", - "lifecycle_subject", - "source_repository", - "release_kind", - "required_claim_scope", - "required_artifact_kinds", - "package_index_project", - "artifact_authority_by_kind", - }, - f"target {index}", + +def _retained_admission_contract(maximum_admission_days: int) -> dict[str, Any]: + """Pin the contract that the retained v1 admission ledger was issued under. + + v2 keeps live Production state in the signed checkpoint feed. The v1 ledger + stays readable so a recovery can replay it, and a v1 record is only + verifiable against the attestation authority, target map and policy revision + that issued it. The v2 policy carries none of those, so they are pinned + here. The expiry window follows the release admission maximum, because + every record in this ledger is a release admission on the production + channel. + """ + + return { + "revision": RETAINED_POLICY_REVISION, + "policy_sha256": RETAINED_POLICY_SHA256, + "maximum_admission_days": maximum_admission_days, + "summary_authority": _validate_summary_authority( + copy.deepcopy(EXPECTED_AUTHORITY) + ), + "targets": { + target_id: {"id": target_id, **copy.deepcopy(contract)} + for target_id, contract in EXPECTED_TARGETS.items() + }, + } + + +def _validate_policy_target( + item: object, index: int, seen: Mapping[str, Any] +) -> tuple[str, dict[str, Any]]: + target = _closed( + item, + { + "id", + "display_name", + "source_repository", + "source_repository_id", + "release_kind", + "claim_scope", + "required_artifact_kinds", + "package_index_project", + }, + f"target {index}", + ) + target_id = _nonempty(target["id"], f"target {index} id") + if TARGET_ID.fullmatch(target_id) is None or target_id in seen: + raise LifecycleError(f"target id is invalid or duplicate: {target_id!r}") + contract = production_trust.TARGET_CONTRACTS.get(target_id) + if contract is None: + raise LifecycleError( + f"target {target_id} has no Production trust contract" ) - target_id = _nonempty(target["id"], f"target {index} id") - if TARGET_ID.fullmatch(target_id) is None or target_id in targets: - raise LifecycleError(f"target id is invalid or duplicate: {target_id!r}") - scope = target["lifecycle_scope"] - if scope not in {"repository", "public_surface"}: - raise LifecycleError(f"target {target_id} lifecycle_scope is invalid") - subject = _nonempty(target["lifecycle_subject"], f"target {target_id} subject") - membership = (scope, subject) - if membership in memberships: - raise LifecycleError(f"lifecycle subject is duplicate: {membership}") - memberships.add(membership) - _nonempty(target["display_name"], f"target {target_id} display name") - source_repository = _nonempty( - target["source_repository"], f"target {target_id} source repository" + _nonempty(target["display_name"], f"target {target_id} display name") + source_repository = _nonempty( + target["source_repository"], f"target {target_id} source repository" + ) + if not source_repository.startswith("OpenAdaptAI/"): + raise LifecycleError( + f"target {target_id} source repository is not first-party" ) - if not source_repository.startswith("OpenAdaptAI/"): - raise LifecycleError( - f"target {target_id} source repository is not first-party" - ) - release_kind = target["release_kind"] - if release_kind not in { - "public_package", - "private_deployment", - "public_deployment", - }: - raise LifecycleError(f"target {target_id} release kind is invalid") - claim_scope = _nonempty( - target["required_claim_scope"], - f"target {target_id} required claim scope", + repository_id = target["source_repository_id"] + if ( + not isinstance(repository_id, str) + or DECIMAL_ID.fullmatch(repository_id) is None + ): + raise LifecycleError( + f"target {target_id} source repository id is invalid" ) - if re.fullmatch(r"^[a-z][a-z0-9_]{2,127}$", claim_scope) is None: - raise LifecycleError(f"target {target_id} required claim scope is invalid") - kinds = target["required_artifact_kinds"] - if not isinstance(kinds, list) or not all( - isinstance(kind, str) and kind for kind in kinds - ): - raise LifecycleError( - f"target {target_id} required artifact kinds are invalid" - ) - if kinds != sorted(set(kinds)): - raise LifecycleError( - f"target {target_id} required artifact kinds must be unique and sorted" - ) - if release_kind == "private_deployment" and kinds: - raise LifecycleError( - f"private target {target_id} cannot require public artifacts" - ) - if release_kind != "private_deployment" and not kinds: - raise LifecycleError(f"public target {target_id} must require artifacts") - package_project = target["package_index_project"] - if package_project is not None and ( - not isinstance(package_project, str) - or re.fullmatch(r"^[a-z0-9][a-z0-9._-]+$", package_project) is None - ): - raise LifecycleError(f"target {target_id} package project is invalid") - authorities = target["artifact_authority_by_kind"] - if not isinstance(authorities, dict) or set(authorities) != set(kinds): - raise LifecycleError( - f"target {target_id} artifact authority map differs from artifact kinds" - ) - if not all( - value in {"pypi", "github_release", "managed_evidence"} - for value in authorities.values() - ): - raise LifecycleError(f"target {target_id} artifact authority is invalid") - if ("pypi" in authorities.values()) != (package_project is not None): + if target["release_kind"] not in TARGET_RELEASE_KINDS: + raise LifecycleError(f"target {target_id} release kind is invalid") + claim_scope = _nonempty( + target["claim_scope"], f"target {target_id} claim scope" + ) + if CLAIM_SCOPE.fullmatch(claim_scope) is None: + raise LifecycleError(f"target {target_id} claim scope is invalid") + kinds = target["required_artifact_kinds"] + if ( + not isinstance(kinds, list) + or not kinds + or not all(isinstance(kind, str) and kind for kind in kinds) + ): + raise LifecycleError( + f"target {target_id} required artifact kinds are invalid" + ) + if kinds != sorted(set(kinds)): + raise LifecycleError( + f"target {target_id} required artifact kinds must be unique and sorted" + ) + package_project = target["package_index_project"] + if package_project is not None and ( + not isinstance(package_project, str) + or re.fullmatch(r"^[a-z0-9][a-z0-9._-]+$", package_project) is None + ): + raise LifecycleError(f"target {target_id} package project is invalid") + if ( + claim_scope != contract["claim_scope"] + or source_repository != contract["repository"] + or repository_id != contract["repository_id"] + ): + raise LifecycleError( + f"target {target_id} differs from the Production trust contract" + ) + undefined = sorted(set(kinds) - set(contract["artifacts"])) + if undefined: + raise LifecycleError( + f"target {target_id} requires artifact kinds the Production trust " + f"contract does not define: {undefined}" + ) + return target_id, target + + +def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: + """Validate the v2 policy and pin the retained v1 admission contract. + + The v2 policy states which schema versions the signed checkpoint chain + accepts and how long each admission kind may live. Every target it declares + must agree with the Production trust contract that + production_trust.validate_release applies to the matching + openadapt.qualification-release/v1 object. + """ + + policy = _closed( + value, + { + "$schema", + "schema_version", + "revision", + "maximum_release_admission_days", + "maximum_workflow_admission_days", + "object_reference_schema_version", + "release_admission_schema_version", + "workflow_admission_schema_version", + "lifecycle_checkpoint_schema_version", + "lifecycle_feed_schema_version", + "lifecycle_feed_ref", + "targets", + }, + "production lifecycle policy", + ) + if policy["schema_version"] != POLICY_SCHEMA: + raise LifecycleError("production lifecycle policy schema is not supported") + if policy["$schema"] != POLICY_DOCUMENT_SCHEMA: + raise LifecycleError("production lifecycle policy document schema differs") + if ( + not isinstance(policy["revision"], int) + or isinstance(policy["revision"], bool) + or policy["revision"] < POLICY_REVISION_MINIMUM + ): + raise LifecycleError( + "production lifecycle policy revision must be at least " + f"{POLICY_REVISION_MINIMUM}" + ) + release_days = _admission_days( + policy["maximum_release_admission_days"], + "maximum_release_admission_days", + RELEASE_ADMISSION_MAXIMUM_DAYS, + ) + workflow_days = _admission_days( + policy["maximum_workflow_admission_days"], + "maximum_workflow_admission_days", + WORKFLOW_ADMISSION_MAXIMUM_DAYS, + ) + if workflow_days > release_days: + raise LifecycleError( + "maximum_workflow_admission_days cannot exceed " + "maximum_release_admission_days" + ) + for key, expected in ( + ("object_reference_schema_version", OBJECT_REFERENCE_SCHEMA), + ("release_admission_schema_version", RELEASE_ADMISSION_SCHEMA), + ("workflow_admission_schema_version", WORKFLOW_ADMISSION_SCHEMA), + ("lifecycle_checkpoint_schema_version", LIFECYCLE_CHECKPOINT_SCHEMA), + ("lifecycle_feed_schema_version", LIFECYCLE_FEED_SCHEMA), + ("lifecycle_feed_ref", LIFECYCLE_FEED_REF), + ): + if policy[key] != expected: raise LifecycleError( - f"target {target_id} package project does not match PyPI authority" + f"production lifecycle policy {key} is not supported" ) + + targets_value = policy["targets"] + if not isinstance(targets_value, list) or not targets_value: + raise LifecycleError("production lifecycle policy must declare targets") + targets: dict[str, dict[str, Any]] = {} + for index, item in enumerate(targets_value): + target_id, target = _validate_policy_target(item, index, targets) targets[target_id] = target - actual_targets = { - target_id: {key: value for key, value in target.items() if key != "id"} - for target_id, target in targets.items() - } - if actual_targets != EXPECTED_TARGETS: - raise LifecycleError("production target map differs from the pinned target map") - return policy, targets + if set(targets) != set(EXPECTED_TARGETS): + raise LifecycleError( + "production target map differs from the pinned target map" + ) + return policy, _retained_admission_contract(release_days) def _clean_https_url(value: object, label: str) -> str: @@ -1433,7 +1569,17 @@ def validate( """Validate the complete lifecycle state and return target to admission IDs.""" now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) - policy, targets = _validate_policy(policy_value) + policy, retained = _validate_policy(policy_value) + targets = retained["targets"] + if retained["revision"] >= policy["revision"]: + raise LifecycleError( + "the retained admission revision must precede the live policy revision" + ) + _digest(policy_sha256, "production lifecycle policy digest") + if policy_sha256 == retained["policy_sha256"]: + raise LifecycleError( + "the live policy document cannot be the retained policy revision" + ) admissions_doc = _closed( admissions_value, {"$schema", "schema_version", "policy_sha256", "admissions"}, @@ -1441,7 +1587,7 @@ def validate( ) if admissions_doc["schema_version"] != ADMISSIONS_SCHEMA: raise LifecycleError("production lifecycle admissions schema is not supported") - if admissions_doc["policy_sha256"] != policy_sha256: + if admissions_doc["policy_sha256"] != retained["policy_sha256"]: raise LifecycleError("production lifecycle admissions policy digest differs") admissions_value_list = admissions_doc["admissions"] if not isinstance(admissions_value_list, list): @@ -1494,7 +1640,7 @@ def validate( raise LifecycleError( f"production admission target is not eligible: {target_id!r}" ) - if admission["policy_revision"] != policy["revision"]: + if admission["policy_revision"] != retained["revision"]: raise LifecycleError(f"admission {target_id} policy revision differs") reference = admission["acceptance_evidence"] if not isinstance(reference, dict): @@ -1533,7 +1679,7 @@ def validate( ) _validate_release_identity( admission["release_identity"], - policy["summary_authority"], + retained["summary_authority"], f"admission {target_id} release identity", ) issued_at = _timestamp( @@ -1545,7 +1691,7 @@ def validate( if issued_at > now: raise LifecycleError(f"admission {target_id} is not valid yet") if expires_at <= issued_at or expires_at > issued_at + timedelta( - days=policy["maximum_admission_days"] + days=retained["maximum_admission_days"] ): raise LifecycleError( f"admission {target_id} validity window is outside policy" @@ -1598,8 +1744,8 @@ def validate( _validate_remote_summary( latest, latest_release, - policy["summary_authority"], - policy_sha256, + retained["summary_authority"], + retained["policy_sha256"], now, fetch=fetch, verify_attestation=verify_attestation, diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index a67282b..d5aa0bf 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -4,7 +4,9 @@ import copy import hashlib +import inspect import json +import subprocess import sys import types import unittest @@ -15,11 +17,18 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) +import production_trust as trust # noqa: E402 import validate_production_lifecycle as lifecycle # noqa: E402 NOW = datetime(2026, 8, 18, 12, 0, 0, tzinfo=timezone.utc) SOURCE_COMMIT = "1" * 40 +# The digest of the live v2 policy document. The value is opaque to these +# tests; the validator only requires that it is a digest and that it is not the +# retained v1 policy revision. POLICY_DIGEST = "sha256:" + "a" * 64 +# The digest of the v1 policy document that issued the retained admission +# ledger. Every retained record and every acceptance summary carries it. +LEDGER_DIGEST = lifecycle.RETAINED_POLICY_SHA256 def digest_bytes(value: bytes) -> str: @@ -98,19 +107,22 @@ def require_registered( def load_policy() -> dict: - # The first v2 migration PR retains the v1 validator for recovery only. - # Keep these tests on its own closed contract instead of feeding it v2 bytes. - return { - "$schema": "schemas/production-lifecycle-policy.schema.json", - "schema_version": lifecycle.POLICY_SCHEMA, - "revision": 1, - "maximum_admission_days": 30, - "summary_authority": copy.deepcopy(lifecycle.EXPECTED_AUTHORITY), - "targets": [ - {"id": target, **copy.deepcopy(contract)} - for target, contract in lifecycle.EXPECTED_TARGETS.items() - ], - } + """Return the live v2 policy document the repository actually publishes. + + The validator reads this file in production, so the tests read it too. A + fixture that mirrors the validator's own constants cannot catch the case + where the data and the validator disagree. + """ + + return json.loads( + (ROOT / "production-lifecycle-policy.json").read_text(encoding="utf-8") + ) + + +def retained_target(target: str) -> dict: + """Return the retained v1 contract a retained admission is verified against.""" + + return copy.deepcopy(lifecycle.EXPECTED_TARGETS[target]) def flow_release() -> dict: @@ -215,13 +227,8 @@ def build_case( expires_at: str = "2026-08-20T11:00:00Z", ) -> tuple[dict, dict, dict[str, bytes]]: release = copy.deepcopy(release or flow_release()) - policy = load_policy() - claim_scope = next( - item["required_claim_scope"] - for item in policy["targets"] - if item["id"] == target - ) - acceptance_policy_digest = policy["summary_authority"]["acceptance_policy_sha256"] + claim_scope = retained_target(target)["required_claim_scope"] + acceptance_policy_digest = lifecycle.EXPECTED_AUTHORITY["acceptance_policy_sha256"] release_digest = lifecycle._target_release_digest(target, claim_scope, release) artifact_digest = lifecycle._artifact_inventory_digest( target, claim_scope, release.get("artifacts", []) @@ -240,7 +247,7 @@ def build_case( "claim_scope": claim_scope, "verdict": "accepted", "acceptance_policy_sha256": acceptance_policy_digest, - "lifecycle_policy_sha256": POLICY_DIGEST, + "lifecycle_policy_sha256": LEDGER_DIGEST, "target_release_sha256": release_digest, "target_artifact_inventory_sha256": artifact_digest, "evidence_identity_sha256": evidence_identity, @@ -324,7 +331,7 @@ def build_case( "verdict": "accepted", "claim_scope": claim_scope, "acceptance_policy_sha256": acceptance_policy_digest, - "lifecycle_policy_sha256": POLICY_DIGEST, + "lifecycle_policy_sha256": LEDGER_DIGEST, "release_identity": release_identity, "release_sha256": release_digest, "artifact_inventory_sha256": artifact_digest, @@ -386,7 +393,7 @@ def build_case( admissions = { "$schema": "schemas/production-lifecycle-admissions.schema.json", "schema_version": lifecycle.ADMISSIONS_SCHEMA, - "policy_sha256": POLICY_DIGEST, + "policy_sha256": LEDGER_DIGEST, "admissions": [admission], } remote = { @@ -398,11 +405,7 @@ def build_case( item for item in release.get("artifacts", []) if item["authority"] == "pypi" ] if pypi_artifacts: - project = next( - item["package_index_project"] - for item in policy["targets"] - if item["id"] == target - ) + project = retained_target(target)["package_index_project"] metadata_url = f"https://pypi.org/pypi/{project}/{release['version']}/json" remote[metadata_url] = json.dumps( { @@ -444,7 +447,7 @@ def empty_admissions() -> dict: return { "$schema": "schemas/production-lifecycle-admissions.schema.json", "schema_version": lifecycle.ADMISSIONS_SCHEMA, - "policy_sha256": POLICY_DIGEST, + "policy_sha256": LEDGER_DIGEST, "admissions": [], } @@ -533,6 +536,7 @@ def validate_case( surfaces: list[str] | None = None, attestation_valid: bool = True, registry: dict | None = None, + authority_sink: list[dict] | None = None, ) -> dict[str, str]: attestation_calls: list[tuple[bytes, bytes]] = [] @@ -544,8 +548,10 @@ def fetch(url: str) -> bytes: raise OSError(f"synthetic URL is absent: {url}") from exc def verify( - summary: bytes, bundle: bytes, _authority: dict, _source_commit: str + summary: bytes, bundle: bytes, authority: dict, _source_commit: str ) -> None: + if authority_sink is not None: + authority_sink.append(copy.deepcopy(authority)) if not attestation_valid: raise lifecycle.LifecycleError("synthetic attestation failure") attestation_calls.append((summary, bundle)) @@ -891,13 +897,14 @@ def test_target_claim_scope_mismatch_is_refused(self) -> None: ): validate_case(admissions, remote, repositories=["openadapt-flow"]) - def test_signer_provenance_domain_mismatch_is_refused(self) -> None: + def test_policy_cannot_carry_a_substitute_summary_authority(self) -> None: policy = load_policy() + policy["summary_authority"] = copy.deepcopy(lifecycle.EXPECTED_AUTHORITY) policy["summary_authority"]["signer_provenance_digest_domain"] = ( "OpenAdapt production certificate signer provenance v2\0" ) with self.assertRaisesRegex( - lifecycle.LifecycleError, "signer provenance digest domain" + lifecycle.LifecycleError, "must contain exactly" ): lifecycle.validate( policy, @@ -1033,11 +1040,27 @@ def test_history_gate_refuses_non_v1_document(self) -> None: current, "current Production admission history" ) - def test_pinned_target_map_refuses_subject_substitution(self) -> None: + def test_policy_target_cannot_substitute_its_source_repository(self) -> None: policy = load_policy() next(item for item in policy["targets"] if item["id"] == "flow")[ - "lifecycle_subject" - ] = "openadapt-evals" + "source_repository" + ] = "OpenAdaptAI/openadapt-evals" + with self.assertRaisesRegex( + lifecycle.LifecycleError, "differs from the Production trust contract" + ): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + def test_policy_target_cannot_drop_a_target(self) -> None: + policy = load_policy() + policy["targets"] = [ + item for item in policy["targets"] if item["id"] != "flow" + ] with self.assertRaisesRegex(lifecycle.LifecycleError, "pinned target map"): lifecycle.validate( policy, @@ -1047,16 +1070,14 @@ def test_pinned_target_map_refuses_subject_substitution(self) -> None: now=NOW, ) - def test_pinned_attestation_authority_refuses_substitution(self) -> None: + def test_policy_target_cannot_require_an_undefined_artifact_kind(self) -> None: policy = load_policy() - authority = policy["summary_authority"] - authority["repository"] = "OpenAdaptAI/attacker" - authority["certificate_identity"] = ( - "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/OpenAdaptAI/attacker/" - + authority["workflow"] - + "@refs/heads/main" - ) - with self.assertRaisesRegex(lifecycle.LifecycleError, "pinned trust root"): + next(item for item in policy["targets"] if item["id"] == "flow")[ + "required_artifact_kinds" + ] = ["python-sdist", "python-wheel", "unregistered-kind"] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "does not define" + ): lifecycle.validate( policy, empty_admissions(), @@ -1070,5 +1091,305 @@ def test_schema_files_are_valid_json(self) -> None: self.assertIsInstance(json.loads(path.read_text(encoding="utf-8")), dict) +class PublishedPolicyTests(unittest.TestCase): + """Bind the validator to the policy document the repository publishes.""" + + def test_published_policy_is_accepted(self) -> None: + self.assertEqual(lifecycle.validate_files(ROOT, now=NOW), {}) + + def test_check_profile_accepts_the_published_repository(self) -> None: + completed = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "check_profile.py")], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_published_policy_declares_the_v2_contract(self) -> None: + policy = load_policy() + self.assertEqual( + policy["schema_version"], "openadapt.production-lifecycle-policy/v2" + ) + self.assertEqual( + lifecycle.POLICY_SCHEMA, "openadapt.production-lifecycle-policy/v2" + ) + self.assertGreaterEqual(policy["revision"], 2) + self.assertNotIn("summary_authority", policy) + self.assertNotIn("maximum_admission_days", policy) + self.assertEqual( + policy["lifecycle_feed_ref"], "refs/heads/production-lifecycle-feed" + ) + + def test_published_policy_matches_its_own_schema_key_set(self) -> None: + schema = json.loads( + ( + ROOT / "schemas" / "production-lifecycle-policy.schema.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(set(load_policy()), set(schema["required"])) + + def test_retained_ledger_carries_the_v1_policy_digest(self) -> None: + ledger = json.loads( + (ROOT / "production-lifecycle-admissions.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(ledger["policy_sha256"], lifecycle.RETAINED_POLICY_SHA256) + self.assertNotEqual( + lifecycle.RETAINED_POLICY_SHA256, + "sha256:" + + hashlib.sha256( + (ROOT / "production-lifecycle-policy.json").read_bytes() + ).hexdigest(), + ) + + +class AdmissionWindowTests(unittest.TestCase): + """The two v2 maximums must be the windows the trust core enforces.""" + + def test_release_maximum_governs_the_retained_expiry(self) -> None: + policy = load_policy() + self.assertEqual(policy["maximum_release_admission_days"], 30) + self.assertEqual(lifecycle.RELEASE_ADMISSION_MAXIMUM_DAYS, 30) + admissions, _summary, remote = build_case( + issued_at="2026-08-18T11:00:00Z", + expires_at="2026-09-17T11:00:00Z", + ) + self.assertEqual( + validate_case(admissions, remote), {"flow": "production:flow:1"} + ) + admissions, _summary, remote = build_case( + issued_at="2026-08-18T11:00:00Z", + expires_at="2026-09-17T11:00:01Z", + ) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "validity window is outside policy" + ): + validate_case(admissions, remote) + + def test_policy_maximums_stay_within_the_historical_bound(self) -> None: + policy = load_policy() + for key in ( + "maximum_release_admission_days", + "maximum_workflow_admission_days", + ): + with self.subTest(key=key): + self.assertIsInstance(policy[key], int) + self.assertGreaterEqual(policy[key], 1) + self.assertLessEqual(policy[key], 30) + self.assertLessEqual( + policy["maximum_workflow_admission_days"], + policy["maximum_release_admission_days"], + ) + + def test_declared_maximums_match_the_enforced_windows(self) -> None: + # production_trust is the v2 trust core. validate_release applies the + # release window to openadapt.qualification-release/v1 and + # validate_qualification_admission applies the workflow window to + # openadapt.qualification-admission/v3. A drift between the declared + # policy numbers and those call sites must fail here. + policy = load_policy() + for function, days in ( + (trust.validate_release, policy["maximum_release_admission_days"]), + ( + trust.validate_qualification_admission, + policy["maximum_workflow_admission_days"], + ), + ): + with self.subTest(function=function.__name__): + source = inspect.getsource(function) + self.assertIn("validate_window(", source) + self.assertIn(f"maximum=timedelta(days={days})", source) + + def test_policy_that_declares_another_maximum_is_refused(self) -> None: + for key in ( + "maximum_release_admission_days", + "maximum_workflow_admission_days", + ): + with self.subTest(key=key): + policy = load_policy() + policy[key] = 3 + with self.assertRaisesRegex( + lifecycle.LifecycleError, "the Production trust core enforces" + ): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + def test_policy_maximum_outside_the_historical_bound_is_refused(self) -> None: + policy = load_policy() + policy["maximum_release_admission_days"] = 31 + with self.assertRaisesRegex( + lifecycle.LifecycleError, "must be between 1 and 30" + ): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + +class CertificateIdentityBindingTests(unittest.TestCase): + """The v2 policy dropped the summary authority. The binding must remain. + + Removing summary_authority from the validator without a replacement would + leave it reporting success while nothing checks who signed the acceptance + evidence. These tests fail if that happens. + """ + + def active_authority(self) -> dict: + admissions, _summary, remote = build_case() + captured: list[dict] = [] + self.assertEqual( + validate_case(admissions, remote, authority_sink=captured), + {"flow": "production:flow:1"}, + ) + self.assertEqual(len(captured), 1) + return captured[0] + + def test_attestation_verifier_receives_the_exact_certificate_identity( + self, + ) -> None: + authority = self.active_authority() + # Literal values, not lifecycle.EXPECTED_AUTHORITY: a refactor that + # empties or rewrites the pinned trust root must fail here too. + self.assertEqual( + authority["certificate_identity"], + "/OpenAdaptAI/openadapt-evals/.github/workflows/" + "production-lifecycle-evidence.yml@refs/heads/main", + ) + self.assertEqual( + authority["oidc_issuer"], "https://token.actions.githubusercontent.com" + ) + self.assertEqual(authority["repository"], "OpenAdaptAI/openadapt-evals") + self.assertEqual(authority["source_ref"], "refs/heads/main") + + def test_certificate_identity_is_derived_from_repository_and_workflow( + self, + ) -> None: + authority = self.active_authority() + self.assertEqual( + authority["certificate_identity"], + "/" + + authority["repository"] + + "/" + + authority["workflow"] + + "@" + + authority["source_ref"], + ) + + def test_published_policy_no_longer_carries_the_authority(self) -> None: + policy = load_policy() + self.assertNotIn("summary_authority", json.dumps(policy)) + # The authority is absent from the policy and present at the verifier. + self.assertTrue(self.active_authority()["certificate_identity"]) + + def test_refused_attestation_removes_derived_production(self) -> None: + admissions, _summary, remote = build_case() + with self.assertRaisesRegex(lifecycle.LifecycleError, "attestation"): + validate_case(admissions, remote, attestation_valid=False) + + def test_verifier_command_pins_the_identity_flags(self) -> None: + recorded: list[list[str]] = [] + + class Completed: + returncode = 0 + stdout = "not json" + stderr = "" + + def fake_run(command, **_kwargs): + recorded.append(list(command)) + return Completed() + + with mock.patch.object(lifecycle.subprocess, "run", fake_run): + with self.assertRaises(lifecycle.LifecycleError): + lifecycle._verify_attestation( + b"{}", b"{}", lifecycle.EXPECTED_AUTHORITY, "1" * 40 + ) + self.assertEqual(len(recorded), 1) + command = recorded[0] + self.assertIn("--cert-identity", command) + self.assertEqual( + command[command.index("--cert-identity") + 1], + "/OpenAdaptAI/openadapt-evals/.github/workflows/" + "production-lifecycle-evidence.yml@refs/heads/main", + ) + self.assertIn("--cert-oidc-issuer", command) + self.assertEqual( + command[command.index("--cert-oidc-issuer") + 1], + "https://token.actions.githubusercontent.com", + ) + self.assertIn("--deny-self-hosted-runners", command) + + def test_pinned_authority_checks_still_bite(self) -> None: + # v1 checked the authority where the policy declared it. v2 removed the + # declaration, so the same checks now run against the pinned trust root. + # Each mutation must still be refused. + cases = ( + ("oidc_issuer", "https://token.actions.example.com", "OIDC issuer"), + ("source_ref", "refs/heads/release", "refs/heads/main"), + ( + "certificate_identity", + "/OpenAdaptAI/attacker/w.yml@refs/heads/main", + "certificate identity is not exact", + ), + ( + "summary_schema_version", + "openadapt.production-lifecycle-evidence-summary/v2", + "summary authority schema", + ), + ( + "evidence_manifest_schema_version", + "openadapt.production-acceptance/v2", + "evidence manifest schema", + ), + ( + "release_identity_schema_version", + "openadapt.monotonic-production-release/v2", + "release identity schema", + ), + ("production_channel", "staging", "channel is not production"), + ( + "signer_provenance_digest_domain", + "OpenAdapt production certificate signer provenance v2\0", + "signer provenance digest domain", + ), + ) + for key, value, message in cases: + with self.subTest(key=key): + authority = copy.deepcopy(lifecycle.EXPECTED_AUTHORITY) + authority[key] = value + with self.assertRaisesRegex(lifecycle.LifecycleError, message): + lifecycle._validate_summary_authority(authority) + + def test_pinned_authority_cannot_gain_or_lose_a_field(self) -> None: + authority = copy.deepcopy(lifecycle.EXPECTED_AUTHORITY) + authority["extra"] = "value" + with self.assertRaisesRegex( + lifecycle.LifecycleError, "must contain exactly" + ): + lifecycle._validate_summary_authority(authority) + authority = copy.deepcopy(lifecycle.EXPECTED_AUTHORITY) + del authority["certificate_identity"] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "must contain exactly" + ): + lifecycle._validate_summary_authority(authority) + + def test_published_authority_passes_every_pinned_check(self) -> None: + self.assertEqual( + lifecycle._validate_summary_authority( + copy.deepcopy(lifecycle.EXPECTED_AUTHORITY) + ), + lifecycle.EXPECTED_AUTHORITY, + ) + + if __name__ == "__main__": unittest.main()