diff --git a/.claude/commands/goal.md b/.claude/commands/goal.md new file mode 100644 index 000000000..73fcac858 --- /dev/null +++ b/.claude/commands/goal.md @@ -0,0 +1,29 @@ +--- +description: Execute the next step of the Notary retirement and Evidence onboarding plan +argument-hint: [DoD item id, e.g. B5, or a workstream letter; empty picks the next item] +--- + +Work the tracked plan at `plans/notary-retirement-and-evidence-onboarding.md`. + +1. Read the plan in full: decisions, constraints, DoD checklist, dependency + order, status log. The decisions are settled; do not relitigate them. +2. Select work. If `$ARGUMENTS` names a DoD item or workstream, target it. + Otherwise pick the first unchecked item whose dependencies are satisfied, + preferring workstream B (onboarding is the standing priority). +3. Announce the selected item and its intended shape in one short message, + then execute. Only stop for input when the item is security-sensitive, + requires a decision the plan reserves for Jeremi (A1's Mint-for-Relay + branch, G4 re-approval), or turns out to conflict with a constraint. +4. For code: TDD, failing test first. The frozen Evidence V1 rules in + `AGENTS.md` and `products/evidence/AGENTS.md` apply; composition work + must not touch Evidence production code. +5. Verify with the gates listed in the plan's Verification section for every + area touched. All must pass; paste the evidence in the report. +6. Update the plan file in the same commit as the work: tick the DoD + checkbox, append a dated status log line (absolute dates). +7. Commit with `git commit -s` and a conventional prefix. Stage only files + belonging to this item; never sweep unrelated worktree changes. +8. Report: what changed, verification evidence, and the next unblocked item. + +Never: modify frozen Evidence V1 contracts, hand-edit generated artifacts, +scrub Notary from history pages, or log secrets. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3a329f85c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.cargo-home +target +scratch +docs/site/node_modules diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a38b94bf0..b66e1a7d9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,21 +30,6 @@ updates: - minor - patch - # The conformance helper lock is a pip-compile requirements file. Its direct - # input remains byte-bound to the reviewed upstream suite requirements. - - package-ecosystem: pip - directory: "/release/conformance/openid" - schedule: - interval: weekly - day: wednesday - time: "08:30" - timezone: Etc/UTC - open-pull-requests-limit: 1 - groups: - conformance-python: - patterns: - - "*" - # Pinned GitHub Actions used by the workflows. - package-ecosystem: github-actions directory: "/" @@ -64,9 +49,7 @@ updates: - package-ecosystem: docker directories: - "/release/docker" - - "/release/conformance/openid" - "/crates/registry-relay" - - "/products/notary" schedule: interval: weekly day: wednesday @@ -77,18 +60,3 @@ updates: docker-images: patterns: - "*" - - # Docker Compose image pins use a distinct Dependabot ecosystem from - # Dockerfiles. Both override filenames match Dependabot's Compose matcher. - - package-ecosystem: docker-compose - directory: "/release/conformance/openid" - schedule: - interval: weekly - day: wednesday - time: "11:30" - timezone: Etc/UTC - open-pull-requests-limit: 1 - groups: - docker-compose-images: - patterns: - - "*" diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 2f8767da9..683bf0062 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -15,17 +15,14 @@ "platform": ( "registry-platform-audit", "registry-platform-authcommon", - "registry-platform-cache", "registry-platform-canonical-json", "registry-platform-config", "registry-platform-crypto", "registry-platform-httpsec", "registry-platform-httputil", - "registry-platform-oid4vci", "registry-platform-oidc", "registry-platform-ops", "registry-platform-pdp", - "registry-platform-replay", "registry-platform-sdjwt", "registry-platform-testing", ), @@ -33,15 +30,9 @@ "registry-manifest-cli", "registry-manifest-core", ), - "notary": ( - "registry-notary", - "registry-notary-client", - "registry-notary-core", - "registry-notary-server", - "registry-notary-worker-harness", - "xtask", - ), "relay": ("registry-relay",), + "evidence": ("registry-evidence", "registry-evidencectl"), + "mint": ("registry-mint",), "developer-tools": ( "registry-config-report", "registry-language-server", @@ -49,15 +40,41 @@ "registryctl": ("registryctl",), } -NOTARY_PACKAGES = frozenset(SHARDS["notary"]) +EVIDENCE_PACKAGES = frozenset(SHARDS["evidence"]) PLATFORM_PACKAGES = frozenset(SHARDS["platform"]) MANIFEST_PACKAGES = frozenset(SHARDS["manifest"]) TUTORIAL_PACKAGES = frozenset( package - for shard in ("platform", "manifest", "notary", "relay", "registryctl") + for shard in ("platform", "manifest", "relay", "registryctl") for package in SHARDS[shard] ) | {"registry-config-report"} +# Every input the Evidence tutorial gate replays or is built from. The tutorial +# pages and helper scripts here must stay in step with the gate's own registry +# and the helpers it invokes, which test_ci_changes.py enforces: a tutorial or +# helper CI does not watch is one that rots silently. +EVIDENCE_TUTORIAL_INPUTS = frozenset( + { + "Cargo.lock", + "Cargo.toml", + "docs/site/package-lock.json", + "docs/site/package.json", + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/scripts/evidence-tutorial-fence.sh", + "docs/site/scripts/registryctl-tutorial.mjs", + "docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + "docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx", + "docs/site/src/content/docs/tutorials/return-a-governed-value.mdx", + "docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx", + } +) + +# The gate also builds and runs `mint`, because one tutorial serves assertions +# to a caller holding a real Mint-issued token. +EVIDENCE_TUTORIAL_PACKAGES = EVIDENCE_PACKAGES | frozenset(SHARDS["mint"]) + ROOT_RUST_INPUTS = { "Cargo.lock", "Cargo.toml", @@ -307,8 +324,8 @@ def classify( if package is not None: seeds.add(package) continue - if path.startswith("products/notary/"): - seeds.update(NOTARY_PACKAGES) + if path.startswith("products/evidence/"): + seeds.update(EVIDENCE_PACKAGES) elif path.startswith("products/manifest/"): seeds.update(MANIFEST_PACKAGES) elif path.startswith("products/platform/"): @@ -387,7 +404,6 @@ def classify( "crates/registry-relay/openapi/*", "crates/registry-relay/src/api/openapi.rs", "crates/registryctl/assets/project-starters/*", - "crates/registry-notary-server/src/standalone/activation.rs", "crates/registry-platform-ops/src/lib.rs", "crates/registry-relay/src/consultation/*", "crates/registryctl/schemas/project-reports/*", @@ -396,8 +412,6 @@ def classify( "crates/registryctl/tests/fixtures/project-reports/*", "docs/site/*", "products/manifest/docs/*", - "products/notary/docs/*", - "products/notary/openapi/*", *AUTHORING_REFERENCE_PATTERNS, ) or path @@ -472,18 +486,13 @@ def classify( "docs/site/src/content/docs/tutorials/use-your-spreadsheet.mdx", "docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx", "docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx", - "release/docker/Dockerfile.registry-notary", "release/docker/Dockerfile.registry-relay", } - or path.startswith("products/notary/") for path in paths ) tutorial_source_under_test = any( matches( path, - "crates/registry-notary/src/*", - "crates/registry-notary-core/src/*", - "crates/registry-notary-server/src/*", "crates/registryctl/src/templates/*", ) or path @@ -503,6 +512,12 @@ def classify( or bool(affected & TUTORIAL_PACKAGES) ) + evidence_tutorial = ( + complete + or any(path in EVIDENCE_TUTORIAL_INPUTS for path in paths) + or bool(affected & EVIDENCE_TUTORIAL_PACKAGES) + ) + matrix = [] for shard_name, shard_packages in SHARDS.items(): selected = sorted(affected.intersection(shard_packages)) @@ -521,8 +536,8 @@ def classify( "rust_packages": sorted(affected), "platform": platform, "platform_hygiene": platform_hygiene, - "notary_contracts": bool(affected & NOTARY_PACKAGES), "relay_contracts": "registry-relay" in affected, + "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "project_authoring": "registryctl" in affected, "release_tool": release_tool, "release_source_proof": release_source_proof, @@ -530,6 +545,7 @@ def classify( "docs_archives": docs_archives, "editors": editors, "registryctl_tutorial": registryctl_tutorial, + "evidence_tutorial": evidence_tutorial, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 764f44cb3..af939aa72 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -7,13 +7,13 @@ import re import subprocess import tempfile -import tomllib import unittest from pathlib import Path from ci_changes import ( AUTHORING_REFERENCE_CONTRACT_SOURCES, AUTHORING_REFERENCE_INPUTS, + EVIDENCE_TUTORIAL_INPUTS, RELEASE_SECURITY_WORKFLOWS, SHARDS, Workspace, @@ -24,6 +24,49 @@ from run_cargo_packages import command_args, package_args +class CiRetirementTest(unittest.TestCase): + def test_current_ci_surfaces_do_not_reference_retired_notary(self) -> None: + current_ci_surfaces = ( + Path(".github/dependabot.yml"), + Path(".github/scripts/ci_changes.py"), + Path(".github/workflows/ci.yml"), + Path(".github/workflows/nightly-rust-coverage.yml"), + Path(".github/workflows/nightly-security.yml"), + ) + for path in current_ci_surfaces: + with self.subTest(path=path): + self.assertNotRegex(path.read_text(encoding="utf-8"), r"(?i)notary") + + self.assertFalse( + Path(".github/workflows/notary-postgres-conformance.yml").exists() + ) + + +class PlatformRetirementTest(unittest.TestCase): + def test_orphan_platform_crates_and_oid4vci_fuzz_surface_are_absent(self) -> None: + retired_crates = ( + "registry-platform-cache", + "registry-platform-oid4vci", + "registry-platform-replay", + "registry-platform-sts", + ) + for crate in retired_crates: + with self.subTest(crate=crate): + self.assertNotIn(crate, SHARDS["platform"]) + self.assertFalse(Path("crates", crate).exists()) + + self.assertIn("registry-platform-pdp", SHARDS["platform"]) + self.assertIn("registry-platform-testing", SHARDS["platform"]) + self.assertFalse( + Path( + "products/platform/fuzz/fuzz_targets/oid4vci_request_and_proof.rs" + ).exists() + ) + self.assertFalse( + Path("products/platform/fuzz/corpus/oid4vci_request_and_proof").exists() + ) + + class CiChangesTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -58,6 +101,81 @@ def test_example_pr_runs_only_affected_rust_shards(self) -> None: self.assertTrue(outputs["registryctl_tutorial"]) self.assertFalse(outputs["platform"]) + def test_evidence_tutorial_inputs_cover_every_registered_tutorial(self) -> None: + # The gate's registry is the source of truth for which tutorials exist. + # A tutorial missing here would not trigger the job that replays it, so + # it could break without any pull request noticing. + gate = ( + Path(__file__).resolve().parents[2] + / "docs/site/scripts/check-evidence-tutorials.sh" + ) + registry = re.search( + r"^EVIDENCE_TUTORIALS=\((.*?)^\)", gate.read_text(), re.DOTALL | re.MULTILINE + ) + if registry is None: + self.fail("the gate must declare EVIDENCE_TUTORIALS") + slugs = registry.group(1).split() + self.assertTrue(slugs, "the gate must register at least one tutorial") + for slug in slugs: + with self.subTest(slug=slug): + self.assertIn( + f"docs/site/src/content/docs/tutorials/{slug}.mdx", + EVIDENCE_TUTORIAL_INPUTS, + ) + + def test_evidence_tutorial_inputs_cover_every_helper_the_gate_invokes(self) -> None: + # Same reasoning as the tutorial registry above, one layer down. The gate + # delegates to sibling scripts, and a change to one of those changes what + # every tutorial replay does. A helper missing here routes the change + # past the job that would have caught it. + gate = ( + Path(__file__).resolve().parents[2] + / "docs/site/scripts/check-evidence-tutorials.sh" + ) + helpers = set( + re.findall(r"scripts/([A-Za-z0-9._-]+\.(?:sh|mjs))", gate.read_text()) + ) + self.assertTrue(helpers, "the gate must invoke at least one helper") + for helper in sorted(helpers): + with self.subTest(helper=helper): + self.assertIn(f"docs/site/scripts/{helper}", EVIDENCE_TUTORIAL_INPUTS) + + def test_evidence_tutorial_routing(self) -> None: + infrastructure = ( + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + "docs/site/package.json", + ) + for path in infrastructure: + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["evidence_tutorial"] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidence/src/runtime.rs",))[ + "evidence_tutorial" + ] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidencectl/src/scaffold.rs",))[ + "evidence_tutorial" + ] + ) + # The gate runs `mint` too, so a Mint change that breaks the served + # tutorial has to reach the job that replays it. + self.assertTrue( + classify(self.workspace, ("crates/registry-mint/src/lib.rs",))[ + "evidence_tutorial" + ] + ) + self.assertFalse( + classify( + self.workspace, + ("docs/site/src/content/docs/tutorials/author-registry-project.mdx",), + )["evidence_tutorial"] + ) + def test_reverse_dependencies_are_included(self) -> None: outputs = classify( self.workspace, @@ -65,7 +183,6 @@ def test_reverse_dependencies_are_included(self) -> None: ) self.assertIn("registry-platform-crypto", outputs["rust_packages"]) self.assertIn("registry-relay", outputs["rust_packages"]) - self.assertIn("registry-notary", outputs["rust_packages"]) self.assertTrue(outputs["registryctl_tutorial"]) def test_ci_workflow_change_runs_the_complete_matrix(self) -> None: @@ -85,6 +202,44 @@ def test_docs_only_change_skips_rust(self) -> None: self.assertTrue(outputs["docs"]) self.assertFalse(outputs["docs_archives"]) + def test_evidence_code_and_product_contracts_select_its_shards_and_drift_gate(self) -> None: + for path in ( + "crates/registry-evidence/src/source.rs", + "products/evidence/contracts/source-contract.yaml", + "products/evidence/reference/request-adapter/ADAPTER-API.md", + "products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml", + ): + with self.subTest(path=path): + outputs = classify(self.workspace, (path,)) + self.assertTrue(outputs["evidence_contracts"]) + self.assertIn("registry-evidence", outputs["rust_packages"]) + # registry-mint dev-depends on registry-evidence so its + # compatibility test proves Evidence accepts a minted token. + # Changing Evidence must therefore run the mint shard too. + self.assertEqual( + {entry["name"] for entry in outputs["rust_matrix"]["include"]}, + {"evidence", "mint"}, + ) + + def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + self.assertIn("\n evidence-contracts:\n", workflow) + self.assertIn("products/evidence/scripts/check-contracts.sh", workflow) + self.assertIn( + "products/evidence/scripts/check-source-neutrality.sh", workflow + ) + self.assertIn("\n relay-contracts:\n", workflow) + self.assertIn("name: Relay OpenAPI contract", workflow) + self.assertNotIn("\n notary-contracts:\n", workflow) + self.assertNotIn("notary_contracts", workflow) + + rust_result = workflow.split("\n rust-result:\n", 1)[1].split( + "\n project-authoring-determinism:\n", 1 + )[0] + self.assertIn("\n - evidence-contracts\n", rust_result) + self.assertIn("\n - relay-contracts\n", rust_result) + self.assertNotIn("\n - notary-contracts\n", rust_result) + def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None: current_content = classify( self.workspace, @@ -177,11 +332,9 @@ def test_authoring_reference_source_contract_has_independent_ci_coverage(self) - "crates/registryctl/schemas/project-authoring/fixture.schema.json", "crates/registryctl/schemas/project-authoring/entity.schema.json", "schemas/registry-relay.config.schema.json", - "schemas/registry-notary.config.schema.json", "crates/registryctl/schemas/project-authoring/parity-coverage.json", "crates/registryctl/schemas/project-authoring/documentation-intent.json", "crates/registry-relay/config/documentation-intent.json", - "crates/registry-notary-core/config/documentation-intent.json", ), ) validate_authoring_reference_routing( @@ -248,7 +401,6 @@ def test_public_project_authoring_modules_run_docs(self) -> None: def test_diagnostic_reference_inputs_run_docs(self) -> None: inputs = ( - "crates/registry-notary-server/src/standalone/activation.rs", "crates/registry-platform-ops/src/lib.rs", "crates/registry-relay/src/consultation/**", "crates/registry-relay/src/process_startup.rs", @@ -290,14 +442,6 @@ def test_first_country_docs_and_journey_routing_matrix(self) -> None: "registryctl_tutorial": True, }, ), - ( - "crates/registryctl/src/templates/notary_addon/registry-stack.yaml", - { - "docs": True, - "project_authoring": True, - "registryctl_tutorial": True, - }, - ), ( "crates/registryctl/schemas/project-reports/registry.project.explanation.v1.schema.json", { @@ -370,38 +514,6 @@ def test_first_country_docs_and_journey_routing_matrix(self) -> None: "registryctl_tutorial": True, }, ), - ( - "crates/registry-notary-server/src/standalone/activation.rs", - { - "docs": True, - "notary_contracts": True, - "registryctl_tutorial": True, - }, - ), - ( - "crates/registry-notary/src/config_loader.rs", - { - "docs": False, - "notary_contracts": True, - "registryctl_tutorial": True, - }, - ), - ( - "crates/registry-notary-core/src/config/root.rs", - { - "docs": True, - "notary_contracts": True, - "registryctl_tutorial": True, - }, - ), - ( - "crates/registry-notary-server/src/runtime/evaluation.rs", - { - "docs": False, - "notary_contracts": True, - "registryctl_tutorial": True, - }, - ), ( "crates/registry-platform-ops/src/lib.rs", { @@ -491,14 +603,6 @@ def test_tutorial_package_dependencies_route_the_source_journey(self) -> None: "README.md", {"docs": False, "rust": False, "registryctl_tutorial": False}, ), - ( - "crates/registry-notary-client/src/lib.rs", - { - "docs": False, - "notary_contracts": True, - "registryctl_tutorial": True, - }, - ), ) for path, expected in cases: @@ -578,30 +682,6 @@ def test_unrelated_workflow_does_not_select_release_checks(self) -> None: self.assertFalse(outputs["release_tool"]) self.assertFalse(outputs["release_source_proof"]) - def test_nightly_notary_fuzz_inventory_matches_declared_targets(self) -> None: - workflow = Path(".github/workflows/nightly-security.yml").read_text( - encoding="utf-8" - ) - target_block = re.search( - r"name: Run notary fuzz smoke.*?for target in \\\n" - r"(?P.*?)\n\s*do", - workflow, - flags=re.DOTALL, - ) - self.assertIsNotNone(target_block) - configured = re.findall( - r"^\s+([a-z][a-z0-9_]*)", - target_block["targets"], - re.MULTILINE, - ) - - manifest = tomllib.loads( - Path("products/notary/fuzz/Cargo.toml").read_text(encoding="utf-8") - ) - declared = [target["name"] for target in manifest["bin"]] - self.assertCountEqual(configured, declared) - - class RunCargoPackagesTest(unittest.TestCase): def test_builds_a_direct_cargo_argument_vector(self) -> None: packages = package_args('["registry-relay","registryctl"]') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee59cb8d..e3d56b884 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: rust_packages: ${{ steps.filter.outputs.rust_packages }} platform: ${{ steps.filter.outputs.platform }} platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }} - notary_contracts: ${{ steps.filter.outputs.notary_contracts }} relay_contracts: ${{ steps.filter.outputs.relay_contracts }} + evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} project_authoring: ${{ steps.filter.outputs.project_authoring }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} @@ -52,6 +52,7 @@ jobs: docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} + evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -116,20 +117,17 @@ jobs: set -euo pipefail "${RUNNER_TEMP}/bin/actionlint" - - name: Check advisory checker copies - run: python3 release/scripts/check_advisory_checker_copies.py - - name: Test advisory baseline checkers - run: | - python3 -m unittest products/notary/tests/advisory_baseline_check_test.py - python3 -m unittest crates/registry-relay/tests/advisory_baseline_check_test.py - - - name: Test advisory checker copy guard - run: python3 -m unittest release/scripts/test_check_advisory_checker_copies.py + run: python3 -m unittest crates/registry-relay/tests/advisory_baseline_check_test.py - name: Check Debian 13 image contract run: python3 release/scripts/check-debian13-images.py + - name: Test Mint demonstration support scripts + run: | + python3 -m unittest discover \ + --start-directory crates/registry-mint/demo/support + secrets: name: Secret scan runs-on: ubuntu-24.04 @@ -273,7 +271,6 @@ jobs: matrix: target: - authcommon_parsers - - oid4vci_request_and_proof - sdjwt_holder_proof - sdjwt_issuance steps: @@ -437,17 +434,16 @@ jobs: df -h / du -sh target 2>/dev/null || true - notary-contracts: - name: Notary API contracts + evidence-contracts: + name: Evidence contracts and source neutrality needs: changes - if: needs.changes.outputs.notary_contracts == 'true' + if: needs.changes.outputs.evidence_contracts == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: - fetch-depth: 0 persist-credentials: false submodules: false @@ -458,37 +454,11 @@ jobs: cache-targets: false save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Install just - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 - with: - tool: just@1.51.0 + - name: Reproduce Evidence generated contracts + run: products/evidence/scripts/check-contracts.sh - - name: Install pinned oasdiff - shell: bash - run: | - set -euo pipefail - mkdir -p "${RUNNER_TEMP}/bin" - curl --fail --silent --show-error --location \ - --output "${RUNNER_TEMP}/oasdiff.tar.gz" \ - "https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" - echo "${OASDIFF_LINUX_X64_SHA256} ${RUNNER_TEMP}/oasdiff.tar.gz" | sha256sum -c - - tar -xzf "${RUNNER_TEMP}/oasdiff.tar.gz" -C "${RUNNER_TEMP}/bin" oasdiff - chmod +x "${RUNNER_TEMP}/bin/oasdiff" - echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" - - - name: Notary OpenAPI baseline - working-directory: products/notary - run: just openapi-check - - - name: Notary OpenAPI contract - working-directory: products/notary - env: - OPENAPI_CONTRACT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} - run: just openapi-contract - - - name: Notary exposure check - working-directory: products/notary - run: just exposure-check + - name: Enforce Evidence source-product neutrality + run: products/evidence/scripts/check-source-neutrality.sh relay-contracts: name: Relay API contracts @@ -552,7 +522,7 @@ jobs: - rust-policy - rust-quality - rust-tests - - notary-contracts + - evidence-contracts - relay-contracts runs-on: ubuntu-24.04 env: @@ -711,15 +681,9 @@ jobs: - name: Test OpenID conformance runner run: python3 -m unittest release/scripts/test_openid_conformance_runner.py - - name: Test external integration evidence runner - run: python3 -m unittest release/scripts/test_integration_e2_runner.py - - name: Test conformance candidate binding run: python3 -m unittest release/scripts/test_conformance_candidate.py - - name: Validate external integration evidence packet - run: python3 release/scripts/integration-e2-runner.py validate - - name: Test Relay OIDC smoke run: python3 -m unittest release/scripts/test_relay_oidc_smoke.py @@ -957,6 +921,82 @@ jobs: target/registryctl-tutorial-source \ 2>/dev/null || true + evidence-tutorials: + name: Evidence tutorials from source + needs: changes + if: needs.changes.outputs.evidence_tutorial == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + submodules: false + + - name: Assert amd64 runner + shell: bash + run: | + set -euo pipefail + [[ "$(uname -m)" == "x86_64" ]] + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: docs/site/package-lock.json + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Test the tutorial gate helpers + working-directory: docs/site + run: npm run test:tutorial:evidence + + - name: Check tutorial command drift + working-directory: docs/site + run: npm run check:tutorial:evidence:dry-run + + - name: Build the Evidence toolset under test + shell: bash + run: | + set -euo pipefail + CARGO_TARGET_DIR="target/evidence-tutorial-source" \ + cargo build --locked --profile ci \ + -p registry-evidence -p registry-evidencectl -p registry-mint + + - name: Test the exact local Evidence lifecycle + shell: bash + env: + EVIDENCE_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/evidence + MINT_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/mint + run: | + set -euo pipefail + CARGO_TARGET_DIR="target/evidence-tutorial-source" \ + cargo test --locked --profile ci \ + -p registry-evidencectl --test dev_lifecycle -- \ + --ignored --test-threads=1 + + - name: Execute the Evidence tutorials in a clean container + shell: bash + run: | + set -euo pipefail + # The image is the repository's pinned release-builder digest, used + # here only as a clean Debian userland: the gate exercises a shell + # and coreutils, and the toolset binaries are mounted in prebuilt. + docker run --rm \ + --mount "type=bind,src=${PWD},dst=/work,readonly" \ + --env EVIDENCE_BIN=/work/target/evidence-tutorial-source/ci/evidence \ + --env EVIDENCECTL_BIN=/work/target/evidence-tutorial-source/ci/evidencectl \ + --env MINT_BIN=/work/target/evidence-tutorial-source/ci/mint \ + rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ + bash /work/docs/site/scripts/check-evidence-tutorials.sh + docs: name: Docs checks needs: changes @@ -1130,6 +1170,7 @@ jobs: - release-tool - release-source-proof - registryctl-tutorials + - evidence-tutorials - docs - editor-extensions runs-on: ubuntu-24.04 diff --git a/.github/workflows/nightly-rust-coverage.yml b/.github/workflows/nightly-rust-coverage.yml index 711846f9f..b1317291e 100644 --- a/.github/workflows/nightly-rust-coverage.yml +++ b/.github/workflows/nightly-rust-coverage.yml @@ -57,7 +57,6 @@ jobs: flags = { "platform": "platform", "manifest": "manifest-unit", - "notary": "notary-unit", "relay": "relay-unit", "developer-tools": "developer-tools", "registryctl": "registryctl-unit", @@ -72,19 +71,6 @@ jobs: } for name, packages in SHARDS.items() ] - matrix.append( - { - "name": "notary-cel", - "packages": "registry-notary registry-notary-server", - "all_features": "false", - "features": ( - "registry-notary/registry-notary-cel," - "registry-notary-server/cel-worker-fixture" - ), - "flag": "notary-cel", - } - ) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: output.write(f"matrix={json.dumps({'include': matrix})}\n") PY @@ -292,12 +278,3 @@ jobs: run: | df -h / du -sh "${CARGO_TARGET_DIR}" 2>/dev/null || true - - notary-postgres: - name: Notary PostgreSQL coverage - permissions: - contents: read - id-token: write - uses: ./.github/workflows/notary-postgres-conformance.yml - with: - strict_coverage: true diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml index 65165d86a..491ed049f 100644 --- a/.github/workflows/nightly-security.yml +++ b/.github/workflows/nightly-security.yml @@ -56,19 +56,22 @@ jobs: Cargo.toml|Cargo.lock|deny.toml|rust-toolchain*) return 0 ;; - crates/registry-relay/*|crates/registry-notary*/*) + crates/registry-relay/*) return 0 ;; crates/registry-platform-authcommon/*|crates/registry-platform-crypto/*) return 0 ;; - crates/registry-platform-cache/*|crates/registry-platform-oid4vci/*|crates/registry-platform-replay/*|crates/registry-platform-sdjwt/*) + crates/registry-platform-sdjwt/*) return 0 ;; crates/registry-manifest-core/*|crates/registry-manifest-cli/*) return 0 ;; - products/notary/*|products/platform/*|products/manifest/*|release/docker/*) + products/platform/*|products/manifest/*|release/docker/*) + return 0 + ;; + docker/*|.dockerignore) return 0 ;; *) @@ -118,9 +121,7 @@ jobs: run: | python3 -m unittest \ crates/registry-relay/tests/security_assurance_check_test.py \ - crates/registry-relay/tests/advisory_baseline_check_test.py \ - products/notary/tests/security_assurance_check_test.py \ - products/notary/tests/advisory_baseline_check_test.py + crates/registry-relay/tests/advisory_baseline_check_test.py - name: Relay exposure and container checks working-directory: crates/registry-relay @@ -129,14 +130,7 @@ jobs: python3 scripts/check_security_assurance.py dockerfile-secrets python3 scripts/check_security_assurance.py openapi-strategy - - name: Notary container and OpenAPI checks - working-directory: products/notary - run: | - python3 scripts/check_security_assurance.py manifest - python3 scripts/check_security_assurance.py dockerfile-secrets - python3 scripts/check_security_assurance.py openapi-baseline - - - name: Release image Dockerfile checks + - name: Release and adopter image Dockerfile checks run: | python3 - <<'PY' import re @@ -147,7 +141,11 @@ jobs: r"\b(?:COPY|ADD)\b(?=.*(?:\.env|\.pem|\.key|\.p12|jwk|secret|credential))|\"d\"\s*:", re.IGNORECASE, ) + # The adopter image is scanned beside the release ones: it is built from + # the same tree by anyone following the local-run docs, so baking key or + # credential material into it would leak just as widely. paths = sorted(Path("release/docker").glob("Dockerfile.registry-*")) + paths += sorted(Path("docker").glob("Dockerfile")) if not paths: print("security assurance check failed: no release Dockerfiles found", file=sys.stderr) sys.exit(1) @@ -161,55 +159,6 @@ jobs: sys.exit(1) PY - notary-fuzz: - name: Notary fuzz smoke - needs: changes - if: needs.changes.outputs.run == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - persist-credentials: false - submodules: false - - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly - with: - toolchain: nightly - - - name: Cache Cargo registry and build artifacts - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Install cargo-fuzz - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 - with: - tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} - - - name: Run notary fuzz smoke - working-directory: products/notary - run: | - set -euo pipefail - for target in \ - core_request_bodies - do - mkdir -p "fuzz/artifacts/${target}" - cargo +nightly fuzz run --fuzz-dir fuzz --target x86_64-unknown-linux-gnu "${target}" -- \ - -max_total_time=60 \ - -rss_limit_mb=1024 \ - -artifact_prefix="fuzz/artifacts/${target}/" \ - -print_final_stats=1 - done - - - name: Upload notary fuzz artifacts - if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: nightly-notary-fuzz-artifacts - path: products/notary/fuzz/artifacts - if-no-files-found: ignore - platform-fuzz: name: Platform fuzz smoke needs: changes @@ -242,7 +191,6 @@ jobs: set -euo pipefail for target in \ authcommon_parsers \ - oid4vci_request_and_proof \ sdjwt_holder_proof \ sdjwt_issuance do @@ -317,7 +265,6 @@ jobs: needs: - changes - assurance - - notary-fuzz - platform-fuzz - manifest-fuzz if: needs.changes.outputs.run == 'true' diff --git a/.github/workflows/notary-postgres-conformance.yml b/.github/workflows/notary-postgres-conformance.yml deleted file mode 100644 index a256e1cd9..000000000 --- a/.github/workflows/notary-postgres-conformance.yml +++ /dev/null @@ -1,241 +0,0 @@ -name: Notary PostgreSQL conformance - -on: - workflow_call: - inputs: - strict_coverage: - description: Run coverage and fail the caller when the Codecov upload fails. - required: false - type: boolean - default: false - pull_request: - branches: - - main - - release/1.0 - paths: - - .github/workflows/notary-postgres-conformance.yml - - Cargo.lock - - Cargo.toml - - rust-toolchain.toml - - crates/registry-notary/** - - crates/registry-notary-core/** - - crates/registry-notary-server/** - - crates/registry-notary-worker-harness/** - - crates/registry-platform-audit/** - - crates/registry-platform-authcommon/** - - crates/registry-platform-cache/** - - crates/registry-platform-crypto/** - - crates/registry-platform-ops/** - - crates/registry-platform-replay/** - - products/notary/scripts/postgresql-conformance.sh - merge_group: - types: - - checks_requested - push: - branches: - - main - paths: - - .github/workflows/notary-postgres-conformance.yml - - Cargo.lock - - Cargo.toml - - rust-toolchain.toml - - crates/registry-notary/** - - crates/registry-notary-core/** - - crates/registry-notary-server/** - - crates/registry-notary-worker-harness/** - - crates/registry-platform-audit/** - - crates/registry-platform-authcommon/** - - crates/registry-platform-cache/** - - crates/registry-platform-crypto/** - - crates/registry-platform-ops/** - - crates/registry-platform-replay/** - - products/notary/scripts/postgresql-conformance.sh - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_LLVM_COV_VERSION: "0.8.7" - CARGO_TERM_COLOR: always - CARGO_TARGET_DIR: target/notary-postgres-conformance - -jobs: - state-plane: - name: Notary state plane (PostgreSQL ${{ matrix.postgresql }}) - if: github.event_name == 'pull_request' || github.event_name == 'merge_group' - runs-on: ubuntu-24.04 - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - postgresql: "16" - source_image: postgres:16.13-alpine - target_image: postgres:16.14-alpine - - postgresql: "17" - source_image: postgres:17.9-alpine - target_image: postgres:17.10-alpine - - postgresql: "18" - source_image: postgres:18.3-alpine - target_image: postgres:18.4-alpine - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - persist-credentials: false - submodules: false - - - name: Cache Cargo registry and build artifacts - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: notary-postgresql-pr-${{ matrix.postgresql }} - - - name: Build Registry Notary with CEL support - run: cargo build --locked -p registry-notary --features registry-notary-cel - - - name: Precompile PostgreSQL conformance tests before service startup - run: cargo test --locked -p registry-notary-server --lib --no-run - - - name: Run Notary PostgreSQL conformance - env: - NOTARY_BIN: target/notary-postgres-conformance/debug/registry-notary - NOTARY_POSTGRES_SOURCE_IMAGE: ${{ matrix.source_image }} - NOTARY_POSTGRES_TARGET_IMAGE: ${{ matrix.target_image }} - run: | - set -euo pipefail - diagnostics="${RUNNER_TEMP}/notary-postgresql-${{ matrix.postgresql }}-docker-events.log" - docker events --format '{{json .}}' >"${diagnostics}" & - events_pid=$! - trap 'kill "${events_pid}" 2>/dev/null || true; wait "${events_pid}" 2>/dev/null || true' EXIT - products/notary/scripts/postgresql-conformance.sh "${{ matrix.postgresql }}" - - - name: Report PostgreSQL Docker diagnostics - if: failure() - run: | - set -euo pipefail - diagnostics="${RUNNER_TEMP}/notary-postgresql-${{ matrix.postgresql }}-docker-events.log" - if [[ -s "${diagnostics}" ]]; then - sed -n '1,400p' "${diagnostics}" - else - echo "No Docker events were captured." - fi - docker ps --all --no-trunc - docker version - - state-plane-coverage: - name: Notary state plane (PostgreSQL ${{ matrix.postgresql }}) - if: >- - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - inputs.strict_coverage - runs-on: ubuntu-24.04 - timeout-minutes: 30 - permissions: - contents: read - id-token: write - strategy: - fail-fast: false - matrix: - include: - - postgresql: "16" - source_image: postgres:16.13-alpine - target_image: postgres:16.14-alpine - - postgresql: "17" - source_image: postgres:17.9-alpine - target_image: postgres:17.10-alpine - - postgresql: "18" - source_image: postgres:18.3-alpine - target_image: postgres:18.4-alpine - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - persist-credentials: false - submodules: false - - - name: Cache Cargo registry and build artifacts - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - key: notary-postgresql-coverage-${{ matrix.postgresql }} - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Install pinned coverage tool - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 - with: - tool: cargo-llvm-cov@${{ env.CARGO_LLVM_COV_VERSION }} - - - name: Build Registry Notary with CEL support - run: | - set -euo pipefail - cargo llvm-cov clean --workspace - eval "$(cargo llvm-cov show-env --sh)" - cargo build --locked -p registry-notary --features registry-notary-cel - - - name: Precompile PostgreSQL conformance tests before service startup - run: | - set -euo pipefail - eval "$(cargo llvm-cov show-env --sh)" - cargo test --locked -p registry-notary-server --lib --no-run - - - name: Run Notary PostgreSQL conformance - env: - NOTARY_BIN: target/notary-postgres-conformance/debug/registry-notary - NOTARY_POSTGRES_SOURCE_IMAGE: ${{ matrix.source_image }} - NOTARY_POSTGRES_TARGET_IMAGE: ${{ matrix.target_image }} - run: | - set -euo pipefail - diagnostics="${RUNNER_TEMP}/notary-postgresql-${{ matrix.postgresql }}-docker-events.log" - docker events --format '{{json .}}' >"${diagnostics}" & - events_pid=$! - trap 'kill "${events_pid}" 2>/dev/null || true; wait "${events_pid}" 2>/dev/null || true' EXIT - eval "$(cargo llvm-cov show-env --sh)" - products/notary/scripts/postgresql-conformance.sh "${{ matrix.postgresql }}" - - - name: Report Notary PostgreSQL coverage - run: | - set -euo pipefail - eval "$(cargo llvm-cov show-env --sh)" - coverage_dir="target/notary-postgres-conformance/coverage" - mkdir -p "${coverage_dir}" - cargo llvm-cov report --locked \ - -p registry-notary \ - -p registry-notary-server - cargo llvm-cov report --locked \ - -p registry-notary \ - -p registry-notary-server \ - --lcov \ - --output-path "${coverage_dir}/notary-postgresql-${{ matrix.postgresql }}.lcov" - - - name: Upload Notary PostgreSQL coverage - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: notary-postgresql-${{ matrix.postgresql }}-coverage - path: target/notary-postgres-conformance/coverage/ - if-no-files-found: error - - - name: Upload Notary PostgreSQL coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - disable_search: true - fail_ci_if_error: ${{ inputs.strict_coverage }} - files: target/notary-postgres-conformance/coverage/notary-postgresql-${{ matrix.postgresql }}.lcov - flags: notary-postgres - name: notary-postgresql-${{ matrix.postgresql }} - use_oidc: true - version: v11.3.1 - - - name: Report PostgreSQL Docker diagnostics - if: failure() - run: | - set -euo pipefail - diagnostics="${RUNNER_TEMP}/notary-postgresql-${{ matrix.postgresql }}-docker-events.log" - if [[ -s "${diagnostics}" ]]; then - sed -n '1,400p' "${diagnostics}" - else - echo "No Docker events were captured." - fi - docker ps --all --no-trunc - docker version diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index b3cc70b9e..ef41ddb81 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -56,7 +56,6 @@ jobs: printf 'canary docs\n' > canary/bundle-root/registry-docs-${tag}.tar.gz printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - notary_ref="ghcr.io/registrystack/registry-notary-candidate@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" relay_ref="ghcr.io/registrystack/registry-relay-candidate@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" postgresql_ref="$(tr -d '\n' < release/registryctl-postgresql-image.ref)" printf '%s\n' "${postgresql_ref}" \ @@ -84,7 +83,6 @@ jobs: } }' > "${evidence_root}/grype/${name}.grype.json" } - write_image_reports registry-notary "${notary_ref}" write_image_reports registry-relay "${relay_ref}" write_image_reports postgresql "${postgresql_ref}" postgresql_digest="${postgresql_ref##*@}" @@ -106,15 +104,12 @@ jobs: schema_version:"registry-stack.advisory-verdict.v2", verdict:"passed", subjects:[ - "registry-notary-image", "registry-relay-image", "postgresql-runtime" ] }' > "${evidence_root}/advisory-verdict.json" cp "${evidence_root}/grype/registry-relay.grype.json" \ canary/bundle-root/security/registry-relay.grype.json - cp "${evidence_root}/grype/registry-notary.grype.json" \ - canary/bundle-root/security/registry-notary.grype.json cp "${evidence_root}/advisory-verdict.json" \ canary/bundle-root/security/advisory-verdict.json evidence_name="registry-stack-${tag}-security-evidence.tar.gz" @@ -127,7 +122,6 @@ jobs: sbom_sha="$(sha256sum canary/bundle-root/registry-stack-${tag}.sbom.spdx.json | awk '{print $1}')" evidence_sha="$(sha256sum "canary/bundle-root/${evidence_name}" | awk '{print $1}')" relay_scan_sha="$(sha256sum canary/bundle-root/security/registry-relay.grype.json | awk '{print $1}')" - notary_scan_sha="$(sha256sum canary/bundle-root/security/registry-notary.grype.json | awk '{print $1}')" advisory_sha="$(sha256sum canary/bundle-root/security/advisory-verdict.json | awk '{print $1}')" payload_size="$(stat -c %s canary/bundle-root/registryctl-${tag}-linux-amd64)" docs_size="$(stat -c %s canary/bundle-root/registry-docs-${tag}.tar.gz)" @@ -159,7 +153,6 @@ jobs: --arg evidence_sha "${evidence_sha}" \ --argjson evidence_size "${evidence_size}" \ --arg relay_scan_sha "${relay_scan_sha}" \ - --arg notary_scan_sha "${notary_scan_sha}" \ --arg advisory_sha "${advisory_sha}" \ --arg bundle_sha "${bundle_sha}" \ --argjson bundle_size "${bundle_size}" \ @@ -209,14 +202,6 @@ jobs: } ], images: [ - { - name: "registry-notary", - candidate_ref: - "ghcr.io/registrystack/registry-notary-candidate@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - digest: - "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - final_ref: ("ghcr.io/registrystack/registry-notary:" + $tag) - }, { name: "registry-relay", candidate_ref: @@ -235,12 +220,6 @@ jobs: sha256: $sbom_sha }, scans: [ - { - image: "registry-notary", - name: "security/registry-notary.grype.json", - sha256: $notary_scan_sha, - status: "passed" - }, { image: "registry-relay", name: "security/registry-relay.grype.json", @@ -305,7 +284,6 @@ jobs: python3 release/scripts/release_candidate.py verify-canary \ --metadata canary/trusted-canary-run.json \ --workflow-revision "${workflow_revision}" - python3 products/notary/tests/advisory_baseline_check_test.py python3 crates/registry-relay/tests/advisory_baseline_check_test.py jq -n \ --arg tag "${tag}" \ diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index cee2b431e..04eb23950 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -131,7 +131,7 @@ jobs: echo "GitHub Release destination is not absent" >&2 exit 1 fi - for package in registry-notary registry-relay; do + for package in registry-relay; do versions="${RUNNER_TEMP}/${package}-versions.json" gh api --paginate --slurp \ "/orgs/${IMAGE_NAMESPACE}/packages/container/${package}/versions?per_page=100" \ @@ -187,8 +187,6 @@ jobs: REGISTRY_RELEASE_SOURCE_MODE=monorepo \ release/scripts/check-release-source-model.sh release/scripts/check-debian13-images.py - python3 -m unittest \ - release/scripts/test_check_advisory_checker_copies.py build-canonical: name: Build Linux payload, private images, and docs once @@ -240,7 +238,7 @@ jobs: run: | set -euo pipefail mkdir -p dist/images - for name in registry-notary registry-relay; do + for name in registry-relay; do package="${name}-candidate" candidate="${REGISTRY}/${IMAGE_NAMESPACE}/${package}:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" metadata="dist/images/${name}.metadata.json" @@ -372,7 +370,8 @@ jobs: rustup toolchain install 1.95.0 \ --profile minimal --target "${{ matrix.target }}" cargo build --release --locked \ - -p registryctl --target "${{ matrix.target }}" + -p registryctl -p registry-evidence -p registry-evidencectl \ + -p registry-mint --target "${{ matrix.target }}" mkdir -p platform asset="registryctl-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/registryctl" "platform/${asset}" @@ -380,6 +379,12 @@ jobs: release/scripts/registry-release verify-registryctl-binary-version \ "platform/${asset}" \ --version "${{ needs.validate.outputs.version }}" + for evidence_binary in evidence evidencectl mint; do + asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" + cp "target/${{ matrix.target }}/release/${evidence_binary}" \ + "platform/${asset}" + chmod 0755 "platform/${asset}" + done mkdir -p candidate-platform mv platform candidate-platform/platform @@ -470,7 +475,7 @@ jobs: run: | set -euo pipefail canonical="inputs/candidate-canonical-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - for name in registry-notary registry-relay; do + for name in registry-relay; do expected_label_args=() if [[ "${name}" == registry-relay ]]; then expected_label_args+=( @@ -496,7 +501,7 @@ jobs: printf '%s' "${GH_TOKEN}" \ | oras login "${REGISTRY}" \ --username "${GITHUB_ACTOR}" --password-stdin - for name in registry-notary registry-relay; do + for name in registry-relay; do package="${name}-candidate" test "$( gh api "/orgs/${IMAGE_NAMESPACE}/packages/container/${package}" \ @@ -589,7 +594,7 @@ jobs: exit 1 fi } - for name in registry-notary registry-relay; do + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" test "$(crane digest "${candidate_ref}")" = "${digest}" @@ -639,10 +644,6 @@ jobs: echo "Exported PostgreSQL rootfs contains a forbidden special file" >&2 exit 1 fi - python3 products/notary/scripts/check_advisory_baselines.py \ - grype candidate/security/grype/registry-notary.grype.json \ - --syft-report candidate/security/syft/registry-notary.syft.json \ - --subject registry-notary-image python3 crates/registry-relay/scripts/check_advisory_baselines.py \ grype candidate/security/grype/registry-relay.grype.json \ --syft-report candidate/security/syft/registry-relay.syft.json \ @@ -657,7 +658,6 @@ jobs: schema_version: "registry-stack.advisory-verdict.v2", verdict: "passed", subjects: [ - "registry-notary-image", "registry-relay-image", "postgresql-runtime" ] @@ -680,7 +680,22 @@ jobs: installer="registryctl-${{ needs.validate.outputs.tag }}-install.sh" cp crates/registryctl/install.sh "candidate/bundle-root/${installer}" chmod 0755 "candidate/bundle-root/${installer}" - for name in registry-notary registry-relay; do + evidencectl_installer="evidencectl-${{ needs.validate.outputs.tag }}-install.sh" + awk -v version="${{ needs.validate.outputs.tag }}" ' + $0 == "default_version=\"\"" { + print "default_version=\"" version "\"" + rendered = 1 + next + } + { print } + END { if (!rendered) exit 1 } + ' crates/registry-evidencectl/install.sh \ + > "candidate/bundle-root/${evidencectl_installer}" + chmod 0755 "candidate/bundle-root/${evidencectl_installer}" + cp "candidate/bundle-root/${evidencectl_installer}" \ + candidate/bundle-root/evidencectl-install.sh + chmod 0755 candidate/bundle-root/evidencectl-install.sh + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" printf '%s\n' "${REGISTRY}/${IMAGE_NAMESPACE}/${name}@${digest}" \ @@ -689,7 +704,6 @@ jobs: release/scripts/registry-release render-registryctl-image-lock \ "${{ needs.validate.outputs.manifest }}" \ --relay-digest "${RUNNER_TEMP}/registry-relay.release.digest" \ - --notary-digest "${RUNNER_TEMP}/registry-notary.release.digest" \ --postgresql-ref-file release/registryctl-postgresql-image.ref \ --tag-target "${{ needs.validate.outputs.source_sha }}" \ --source-sha "${{ needs.validate.outputs.source_sha }}" \ @@ -702,7 +716,7 @@ jobs: "candidate/bundle-root/registry-stack-${{ needs.validate.outputs.tag }}-security-evidence.tar.gz" \ -C candidate/security \ images image-sbom syft grype advisory-verdict.json - for name in registry-notary registry-relay; do + for name in registry-relay; do cp "candidate/security/grype/${name}.grype.json" \ candidate/bundle-root/ done @@ -727,8 +741,6 @@ jobs: "candidate/bundle-root/registryctl-${{ needs.validate.outputs.tag }}-image-lock.json" \ --relay-image-index \ "${RUNNER_TEMP}/registry-relay.index.json" \ - --notary-image-index \ - "${RUNNER_TEMP}/registry-notary.index.json" \ --postgresql-image-index \ "${RUNNER_TEMP}/postgresql.index.json" \ --output "${RUNNER_TEMP}/registry-release-lock.payload.json" @@ -773,7 +785,7 @@ jobs: done | jq -s . )" images="$( - for name in registry-notary registry-relay; do + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" jq -n \ @@ -785,7 +797,7 @@ jobs: done | jq -s . )" scans="$( - for name in registry-notary registry-relay; do + for name in registry-relay; do file="candidate/bundle-root/${name}.grype.json" jq -n \ --arg image "${name}" \ diff --git a/.github/workflows/release-repeatability.yml b/.github/workflows/release-repeatability.yml index 3d0d90c3d..cab59437f 100644 --- a/.github/workflows/release-repeatability.yml +++ b/.github/workflows/release-repeatability.yml @@ -128,13 +128,39 @@ jobs: run: | set -euo pipefail mkdir -p proof - expected=( - "registry-manifest-${TAG}-linux-amd64" - "registry-notary-${TAG}-linux-amd64" - "registry-notary-cel-worker-${TAG}-linux-amd64" - "registry-relay-${TAG}-linux-amd64" - "registry-relay-rhai-worker-${TAG}-linux-amd64" - "registryctl-${TAG}-linux-amd64" + mapfile -t expected < <( + python3 - "source" "${TAG}" <<'PY' + import glob + import sys + + import yaml + + root, tag = sys.argv[1:] + version = tag.removeprefix("v") + matches = [] + for path in glob.glob(f"{root}/release/manifests/registry-stack-*.yaml"): + with open(path, encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if str(data.get("stack", {}).get("version", "")) == version: + matches.append(data) + if len(matches) != 1: + raise SystemExit(f"expected one release manifest for {version}") + artifacts = matches[0].get("artifacts", {}) + binary_names = { + "evidence": "evidence", + "evidencectl": "evidencectl", + "mint": "mint", + "registry-manifest-cli": "registry-manifest", + "registry-notary": "registry-notary", + "registry-notary-cel-worker": "registry-notary-cel-worker", + "registry-relay": "registry-relay", + "registry-relay-rhai-worker": "registry-relay-rhai-worker", + "registryctl": "registryctl", + } + for artifact, asset in sorted(binary_names.items()): + if artifact in artifacts: + print(f"{asset}-{tag}-linux-amd64") + PY ) for asset in "${expected[@]}"; do test -s "published/${asset}" @@ -177,7 +203,11 @@ jobs: set -euo pipefail image_lock="published/registryctl-${TAG}-image-lock.json" mkdir -p proof/image-layouts - for name in registry-notary registry-relay; do + mapfile -t product_images < <( + jq -er '.images | keys[] | select(startswith("registry-"))' \ + "${image_lock}" + ) + for name in "${product_images[@]}"; do published_ref="$(jq -er --arg name "${name}" '.images[$name]' "${image_lock}")" published_layout="proof/image-layouts/${name}-published" rebuilt_layout="${GITHUB_WORKSPACE}/proof/image-layouts/${name}-rebuilt" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f6944b45..66cfa473a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -795,7 +795,7 @@ jobs: PY )" mkdir -p "${RUNNER_TEMP}/release-image-indexes" - for name in registry-notary registry-relay; do + for name in registry-relay; do index_digest="$(jq -er --arg name "${name}" \ '.images[] | select(.name == $name) | .digest | select(test("^sha256:[0-9a-f]{64}$"))' "${candidate}")" @@ -817,7 +817,6 @@ jobs: release/scripts/registry-release render-registryctl-image-lock \ "${release_manifest}" \ --relay-digest "${RUNNER_TEMP}/registry-relay.release.digest" \ - --notary-digest "${RUNNER_TEMP}/registry-notary.release.digest" \ --postgresql-ref-file release/registryctl-postgresql-image.ref \ --tag-target "${{ needs.verify.outputs.source_sha }}" \ --source-sha "${{ needs.verify.outputs.workflow_revision }}" \ diff --git a/.gitignore b/.gitignore index 73396c81e..69648d06c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,15 @@ __pycache__/ .DS_Store -# Local agent tooling (private skills are symlinked here; never commit) -/.claude/ +# Local agent tooling (private skills are symlinked here; never commit). +# Only the shared project commands in /.claude/commands/ are tracked. +/.claude/* +!/.claude/commands/ /.playwright-mcp/ /worktrees/ /*.png + +# Evidence local operator material. Never commit credentials or curl artifacts. +/products/evidence/.env +/products/evidence/.first-curl/ +/products/evidence/.sd-jwt-vc-demo/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 31c6096ba..97cb27724 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -16,30 +16,17 @@ paths = ["^editors/vscode/\\.vscode-test/"] [[allowlists]] description = "Allow synthetic JWT fixtures used only by the platform fuzz harnesses." paths = [ - "^products/platform/fuzz/corpus/oid4vci_request_and_proof/credential_request\\.json$", - "^products/platform/fuzz/corpus/oid4vci_request_and_proof/valid-proof-jwt$", "^products/platform/fuzz/corpus/sdjwt_holder_proof/holder_proof\\.jwt$", "^products/platform/fuzz/corpus/sdjwt_holder_proof/valid-holder-proof-jwt$", ] -[[allowlists]] -description = "Allow fixed identifiers and JWTs in the public Notary OpenAPI example." -regexTarget = "match" -regexes = [ - '''credential:01HX7Y5F2WAJ7ZP0Q4M5K9E8NC''', - '''eyJhbGciOiJFZERTQSIsInR5cCI6ImRjK3NkLWp3dCIsImtpZCI6ImRpZDp3ZWI6YWdyaWN1bHR1cmUuZGVtby5leGFtcGxlLmdvdi''', -] - [[allowlists]] description = "Allow fixed non-secret values used by tests and configuration examples." regexTarget = "line" regexes = [ - '''oid4vci_preauth_enabled:\s*self\.oid4vci\.enabled''', - '''std::env::set_var\(SECRET_ENV,\s*"0123456789abcdef0123456789abcdef"\)''', '''let key = Pkcs1RsaPrivateKey''', '''chain_key_epoch_id:\s*[a-z0-9-]+-chain-1''', '''"dci_crvs_api":\s*"5e31d1e381d4bd8c7c74112d714fd49d263c6df7"''', - '''"key_path":\s*"oid4vci\.(?:enabled|nonce\.ttl_seconds|proof\.max_age_seconds)"''', '''"AKIAABCDEFGHIJKLMNOP"''', '''"eyJhbGciOiJSUzI1NiJ9\.eyJzdWIiOiJzdWJqZWN0In0\.abcdefghijklmnop"''', ] @@ -49,9 +36,3 @@ description = "Allow synthetic OpenCRVS response JWTs used by registryctl projec paths = [ "^crates/registryctl/tests/fixtures/project-authoring/opencrvs(?:-country-variant)?/integrations/birth-record/fixtures/bodies/(?:ambiguous|match|no-match)\\.json$", ] - -[[allowlists]] -description = "Allow synthetic Notary holder-proof fixtures generated from repository test keys." -paths = [ - "^products/notary/tests/fixtures/sd_jwt_vc/holder-proof-(?:eddsa|es256-unsupported|mismatch)\\.(?:jwt|sd-jwt)$", -] diff --git a/AGENTS.md b/AGENTS.md index 0c669b6b8..aedfc5ed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,31 +3,103 @@ This is the Registry Stack monorepo: registry-facing services over data institutions already hold. Pre-1.0; APIs and deployment contracts may change. -Two runtime patterns anchor everything: +Two independent runtime patterns are relevant: - **Registry Relay** exposes protected, scoped, read-only HTTP APIs over existing sources. -- **Registry Notary** certifies evidence: claim evaluation, credential - issuance, disclosure policy, audit provenance. +- **Evidence** returns signed, minimum-disclosure assertions from fixed + requests to authoritative sources. + +The two patterns compose without merging their product boundaries: Evidence +may use a Relay-protected API as a fixed HTTP source. Registry Manifest describes sources portably; Relay is its consumer in code -(Notary does not depend on the manifest crates). `registry-platform-*` crates -are shared primitives. `registryctl` is adopter tooling. +and `registry-platform-*` crates are shared primitives. `registryctl` is Relay +adopter tooling; `registry-evidencectl` is Evidence adopter tooling. + +Registry Mint is a supporting service, not a third pattern: it issues the +access tokens a resource server such as Evidence verifies, for deployments with +no identity provider. The dependency runs one way only. Mint's tests drive +Evidence's authenticator; Evidence does not depend on Mint. ## Repository map | Area | Owns | |---|---| | `crates/registry-relay` | Protected read APIs (Relay) | -| `crates/registry-notary*` | Evidence gateway: server, core, client, source adapters, worker harness (Notary) | +| `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | +| `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | +| `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | -| `crates/registry-platform-*` | Shared primitives: audit, authcommon, cache, config, crypto, httpsec, httputil, oid4vci, oidc, ops, pdp, replay, sdjwt, sts, testing | -| `crates/registryctl` | Adopter tooling | +| `crates/registry-platform-*` | Shared primitives used by the maintained runtimes and tooling | +| `crates/registryctl` | Relay adopter tooling | | `products/` | Product-owned specs, examples, fixtures, docs (not crates) | | `docs/site/` | Public docs site (Astro). Has its own `AGENTS.md`; read it before touching this subtree | | `release/` | Release manifests, schemas, notes, validation and conformance tooling, and the release source-model proof | | `external/` | Notes on inputs that intentionally stay out of this tree (e.g. Crosswalk stays a pinned git dependency) | +## Evidence product boundary + +Evidence is its own minimum-disclosure assertion product, not a Relay mode. +Evidence may consume a Relay-protected API through its ordinary fixed HTTP +source contract, but it does not inherit Relay's authorization or policy +model. Evidence serializes the same stateless assertion as a signed flattened +JWS or, under its own frozen profile, as an SD-JWT VC response. The latter is a +second encoding of one response, never a credential lifecycle. + +The runtime implementation is one `registry-evidence` crate and one `evidence` +binary. It may reuse narrowly applicable `registry-platform-*` +primitives such as audit, crypto, OIDC, HTTP security, SD-JWT serialization, +and testing. + +`registry-evidencectl` (`evidencectl`) is adopter tooling beside the runtime, +like `registryctl` is for the rest of the stack. It sits outside the frozen +Version 1 runtime contract: it generates key material, starts incomplete +OpenAPI authoring workspaces, and drives fixture runs for complete deployment +projects, but it shells out to the `evidence` binary for every Evidence semantic +decision and never re-implements evaluation, signing, or verification. Its +source is covered by the same source-product and domain neutrality checks as +the runtime. + +Evidence configuration and scripts are trusted, startup-only deployment +artifacts. Rust owns authentication, authorization, fixed source execution, +bounded script execution, output validation, evidence construction, signing, +and audit. Rhai owns bounded request preparation, source extraction, and +requirement-specific derivation, using only deterministic, bounded, +domain-neutral primitives supplied by Rust. +Adult status, residence region, professional licence status, and legal-parent +relationship are coequal full-path Evidence acceptance definitions. None may +become a Rust domain type, built-in operation, special route, or implementation +phase. + +Evidence implementation changes require its approved Version 1 contracts, +schedule, and Definition of Done to remain aligned in tracked product material. +Do not call the product implemented when only one assertion case or a subset of +that DoD passes. Stop before the approved concept's non-goals and future +profiles. + +Evidence source compatibility is proven with sanitized local mocks in ordinary +tests. Public demo checks are opt-in, ignored, read-only local tests after the +mock suite passes. Credentials, tokens, live responses, demo-subject +identifiers, and human login or two-factor details must not be committed, +logged, placed in snapshots, or passed on command lines. +The provider-published shared credentials for the public DHIS2 demo at +`https://play.im.dhis2.org/stable-2-43-1/` are a documentation-only exception: +public tutorials may state them and place them in ignored, owner-only local +files. +The provider-published human login credentials and synthetic Josh Hoeger record +identifiers for the public OpenCRVS Farajaland integration demo are a second +documentation-only exception. Public tutorials may state them so readers can +inspect the same synthetic record, use its stated selectors in tutorial +commands, and create their own Record Search client. +The exceptions do not cover OAuth client credentials created by a reader, +tokens, live responses, real identifiers, other demo-subject identifiers in +tracked files, logs, or snapshots. + +DHIS2 and OpenCRVS names and behavior are test-only. Evidence production code, +dependencies, Cargo features, public configuration schemas, routes, and CLI +options must remain source-product neutral. + The adopter demo is maintained separately in [`registrystack/solmara-lab`](https://github.com/registrystack/solmara-lab). @@ -48,16 +120,21 @@ Root CI's `rust` job runs `cargo fmt --check`, `cargo check --locked --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --locked --workspace`, the full `cargo deny check` (advisories included; unresolvable RUSTSEC advisories carry scoped ignores in -`deny.toml` with review triggers), and the OpenAPI drift checks for both -products (`just openapi-check` from `products/notary`, `just openapi-contract` -from `crates/registry-relay`). cargo-deny needs v0.19+ to parse this -`deny.toml`; CI pins 0.19.8. +`deny.toml` with review triggers), and the Relay OpenAPI drift check +(`just openapi-contract` from `crates/registry-relay`). cargo-deny needs v0.19+ +to parse this `deny.toml`; CI pins 0.19.8. + +Evidence-specific contracts and source neutrality: + +```bash +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` Release source checks: ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py release/scripts/registry-release validate release/manifests/.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -68,7 +145,7 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. ## Rules that bite - Every commit needs a DCO sign-off: `git commit -s`. -- Commit subjects: imperative mood; `fix(notary):` / `feat(relay):` style +- Commit subjects: imperative mood; `feat(relay):` and `feat(evidence):` style prefixes are the norm for product-scoped changes. - History may be rewritten during review (session commits get squashed). In durable docs, cite only commits reachable from pushed `main`, and prefer @@ -76,7 +153,7 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. - Major functionality and bug fixes require automated tests with the change. - Keep a change scoped to one owning area (`crates/`, `products/`, `docs/site/`, `release/`). -- Changes to authentication, authorization, credential issuance, signing, +- Changes to authentication, authorization, assertion evaluation or signing, audit integrity, release provenance, deployment defaults, or data minimization are security-sensitive and need explicit review notes. - Generated outputs (OpenAPI under `docs/site/openapi/`, `docs/site` @@ -84,7 +161,7 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. generator commands, never hand-edited, and must be bit-for-bit repeatable. If you change an HTTP endpoint, regenerating and committing the OpenAPI documents is part of the change, not a follow-up. -- Suspected vulnerabilities (credential disclosure, auth bypass, audit +- Suspected vulnerabilities (minimum-disclosure failure, auth bypass, audit redaction failure, connector data leakage, signing key handling) go through `SECURITY.md`, never public issues or PRs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3e9a5a77..0e10d131d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,10 +55,10 @@ Every pull request should make the review path clear: check was skipped and why. - Include tests for major functionality and bug fixes. If a test cannot be added, explain why in the pull request. -- Treat changes to authentication, authorization, credential issuance, signing, - audit integrity, release provenance, deployment defaults, or data minimization - as security-sensitive. These changes need explicit maintainer review notes, - even when the maintainer is also the author. +- Treat changes to authentication, authorization, assertion evaluation or + signing, audit integrity, release provenance, deployment defaults, or data + minimization as security-sensitive. These changes need explicit maintainer + review notes, even when the maintainer is also the author. ## Dependency Changes @@ -129,7 +129,6 @@ cargo check --locked --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings cargo test --locked --workspace cargo deny check -(cd products/notary && just openapi-check) (cd crates/registry-relay && just openapi-contract) ``` @@ -144,8 +143,7 @@ These checks require Python 3.11 or later. ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-17.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-27.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -183,6 +181,7 @@ same source can reproduce it exactly. ## Security Reports -Do not open public issues or pull requests for suspected credential disclosure, -auth bypass, audit redaction failure, source connector data leakage, signing key -handling bugs, or other vulnerabilities. Follow [SECURITY.md](SECURITY.md). +Do not open public issues or pull requests for suspected minimum-disclosure +failure, auth bypass, audit redaction failure, source connector data leakage, +signing key handling bugs, or other vulnerabilities. Follow +[SECURITY.md](SECURITY.md). diff --git a/Cargo.lock b/Cargo.lock index 9ce5ee5cf..f712f3345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1155,6 +1155,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1306,6 +1315,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crosswalk-cel" version = "0.2.0" @@ -1437,28 +1473,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "cryptoki" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" -dependencies = [ - "bitflags 2.13.0", - "cryptoki-sys", - "libloading", - "log", - "secrecy", -] - -[[package]] -name = "cryptoki-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121" -dependencies = [ - "libloading", -] - [[package]] name = "csv" version = "1.4.0" @@ -2251,9 +2265,6 @@ name = "deadpool-runtime" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" -dependencies = [ - "tokio", -] [[package]] name = "debug_unsafe" @@ -2325,6 +2336,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -2366,6 +2399,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2821,6 +2863,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -3607,6 +3658,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "insta" version = "1.48.0" @@ -3962,16 +4027,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "liblzma" version = "0.4.6" @@ -4025,6 +4080,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -4153,6 +4214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -5345,207 +5407,153 @@ dependencies = [ ] [[package]] -name = "registry-language-server" -version = "0.16.3" -dependencies = [ - "anyhow", - "serde_json", - "tempfile", - "tokio", - "tower-lsp-server", - "tree-sitter", - "tree-sitter-yaml", -] - -[[package]] -name = "registry-manifest-cli" -version = "0.16.3" -dependencies = [ - "registry-manifest-core", - "serde", - "serde_json", - "serde_yaml_ng", - "unsafe-libyaml", -] - -[[package]] -name = "registry-manifest-core" -version = "0.16.3" -dependencies = [ - "oxiri", - "oxjsonld", - "registry-platform-canonical-json", - "serde", - "serde_json", - "serde_path_to_error", - "serde_yaml_ng", - "sha2 0.11.0", - "thiserror 2.0.18", -] - -[[package]] -name = "registry-notary" +name = "registry-evidence" version = "0.16.3" dependencies = [ + "assert-json-diff", + "async-trait", "axum", "axum-test", "base64", + "bytes", "chrono", + "chrono-tz 0.10.4", "clap", + "criterion", "ed25519-dalek", - "getrandom 0.4.3", - "hyper", - "hyper-util", + "fs2", + "http", "jsonschema 0.18.3", - "registry-config-report", - "registry-notary-core", - "registry-notary-server", + "jsonwebtoken", + "rand_core 0.6.4", + "rcgen", "registry-platform-audit", - "registry-platform-authcommon", - "registry-platform-config", "registry-platform-crypto", - "registry-platform-ops", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "registry-platform-sdjwt", "reqwest 0.12.28", + "rhai", + "rustix", + "schemars 1.2.1", + "serde", "serde_json", "serde_norway", "sha2 0.11.0", "tempfile", + "thiserror 2.0.18", "time", "tokio", + "tokio-rustls", "tower", "tower-http 0.7.0", "tracing", "tracing-subscriber", "ulid", + "url", + "utoipa", "wiremock", + "zeroize", ] [[package]] -name = "registry-notary-client" +name = "registry-evidencectl" version = "0.16.3" dependencies = [ - "async-trait", - "axum", - "axum-test", + "anyhow", "base64", - "flate2", - "registry-notary-core", - "registry-notary-server", + "chrono", + "clap", + "ed25519-dalek", + "getrandom 0.4.3", + "inquire", "registry-platform-crypto", - "registry-platform-httputil", - "registry-platform-oid4vci", - "registry-platform-sdjwt", - "reqwest 0.12.28", - "secrecy", + "rhai", + "rustix", "serde", "serde_json", "serde_norway", - "sha2 0.11.0", + "signal-hook", + "tempfile", + "ureq", + "url", + "zeroize", +] + +[[package]] +name = "registry-language-server" +version = "0.16.3" +dependencies = [ + "anyhow", + "serde_json", "tempfile", - "thiserror 2.0.18", - "time", "tokio", - "tracing", + "tower-lsp-server", + "tree-sitter", + "tree-sitter-yaml", ] [[package]] -name = "registry-notary-core" +name = "registry-manifest-cli" version = "0.16.3" dependencies = [ - "base64", - "humantime-serde", - "ipnet", - "registry-platform-authcommon", - "registry-platform-config", - "registry-platform-crypto", - "registry-platform-httputil", - "registry-platform-oid4vci", - "registry-platform-ops", - "registry-platform-sdjwt", - "schemars 1.2.1", + "registry-manifest-core", "serde", "serde_json", - "serde_norway", + "serde_yaml_ng", + "unsafe-libyaml", +] + +[[package]] +name = "registry-manifest-core" +version = "0.16.3" +dependencies = [ + "oxiri", + "oxjsonld", + "registry-platform-canonical-json", + "serde", + "serde_json", + "serde_path_to_error", + "serde_yaml_ng", "sha2 0.11.0", - "tempfile", "thiserror 2.0.18", - "time", - "tokio", - "ulid", - "url", - "utoipa", ] [[package]] -name = "registry-notary-server" +name = "registry-mint" version = "0.16.3" dependencies = [ "async-trait", - "aws-lc-rs", "axum", "axum-test", "base64", - "chrono", - "criterion", - "crosswalk-core", - "cryptoki", - "deadpool", - "getrandom 0.4.3", - "hex", - "hmac 0.13.0", - "jsonschema 0.18.3", + "clap", + "ed25519-dalek", + "http", "jsonwebtoken", - "native-tls", - "postgres-native-tls", - "registry-notary-client", - "registry-notary-core", - "registry-notary-worker-harness", + "registry-evidence", "registry-platform-audit", - "registry-platform-authcommon", - "registry-platform-cache", - "registry-platform-config", + "registry-platform-canonical-json", "registry-platform-crypto", - "registry-platform-httpsec", - "registry-platform-httputil", - "registry-platform-oid4vci", "registry-platform-oidc", - "registry-platform-ops", - "registry-platform-pdp", - "registry-platform-replay", - "registry-platform-sdjwt", - "registry-platform-testing", "reqwest 0.12.28", "rustix", "serde", "serde_json", "serde_norway", - "sha2 0.11.0", - "subtle", "tempfile", "thiserror 2.0.18", "time", "tokio", - "tokio-postgres", "tower-http 0.7.0", "tracing", + "tracing-subscriber", "ulid", - "utoipa", - "wiremock", + "url", "zeroize", ] -[[package]] -name = "registry-notary-worker-harness" -version = "0.16.3" -dependencies = [ - "libc", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "registry-platform-audit" version = "0.16.3" @@ -5553,6 +5561,7 @@ dependencies = [ "async-trait", "hmac 0.13.0", "registry-platform-canonical-json", + "rustix", "serde", "serde_json", "sha2 0.11.0", @@ -5581,17 +5590,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "registry-platform-cache" -version = "0.16.3" -dependencies = [ - "async-trait", - "sha2 0.11.0", - "thiserror 2.0.18", - "time", - "tokio", -] - [[package]] name = "registry-platform-canonical-json" version = "0.16.3" @@ -5682,21 +5680,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "registry-platform-oid4vci" -version = "0.16.3" -dependencies = [ - "base64", - "registry-platform-crypto", - "registry-platform-replay", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "time", - "tokio", -] - [[package]] name = "registry-platform-oidc" version = "0.16.3" @@ -5737,18 +5720,6 @@ dependencies = [ "serde", ] -[[package]] -name = "registry-platform-replay" -version = "0.16.3" -dependencies = [ - "async-trait", - "getrandom 0.4.3", - "registry-platform-cache", - "thiserror 2.0.18", - "time", - "tokio", -] - [[package]] name = "registry-platform-sdjwt" version = "0.16.3" @@ -5779,14 +5750,11 @@ dependencies = [ "registry-platform-crypto", "registry-platform-httpsec", "registry-platform-httputil", - "registry-platform-oid4vci", "registry-platform-oidc", - "registry-platform-replay", "reqwest 0.12.28", "serde_json", "tempfile", "thiserror 2.0.18", - "time", "tokio", "tower", "wiremock", @@ -5834,8 +5802,6 @@ dependencies = [ "rcgen", "registry-config-report", "registry-manifest-core", - "registry-notary-core", - "registry-notary-server", "registry-platform-audit", "registry-platform-authcommon", "registry-platform-config", @@ -5895,8 +5861,6 @@ dependencies = [ "regex", "registry-config-report", "registry-language-server", - "registry-notary-core", - "registry-notary-server", "registry-platform-authcommon", "registry-platform-config", "registry-platform-crypto", @@ -6287,6 +6251,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive", @@ -6326,15 +6291,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -6629,6 +6585,27 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -8346,17 +8323,6 @@ dependencies = [ "der", ] -[[package]] -name = "xtask" -version = "0.1.0" -dependencies = [ - "base64", - "registry-platform-crypto", - "registry-platform-sdjwt", - "serde_json", - "tokio", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 57a857047..e4ab5d2e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,40 +1,29 @@ [workspace] members = [ "crates/registry-config-report", + "crates/registry-evidence", + "crates/registry-evidencectl", "crates/registry-platform-audit", "crates/registry-platform-authcommon", - "crates/registry-platform-cache", "crates/registry-platform-canonical-json", "crates/registry-platform-config", "crates/registry-platform-crypto", "crates/registry-platform-httpsec", "crates/registry-platform-httputil", - "crates/registry-platform-oid4vci", "crates/registry-platform-oidc", "crates/registry-platform-ops", "crates/registry-platform-pdp", - "crates/registry-platform-replay", "crates/registry-platform-sdjwt", "crates/registry-platform-testing", "crates/registry-manifest-core", "crates/registry-manifest-cli", - "crates/registry-notary-core", - "crates/registry-notary-client", - "crates/registry-notary-server", - "crates/registry-notary", - "crates/registry-notary-worker-harness", + "crates/registry-mint", "crates/registry-relay", "crates/registry-language-server", "crates/registryctl", - "products/notary/xtask", ] exclude = [ - # Parked until Assisted Access or the delegation profile work supplies a - # release-surface consumer (#298). Keep the source in git, but out of - # workspace CI. - "crates/registry-platform-sts", "products/platform/fuzz", - "products/notary/fuzz", "products/manifest/fuzz", ] resolver = "2" @@ -52,26 +41,21 @@ unsafe_code = "forbid" [workspace.dependencies] registry-config-report = { path = "crates/registry-config-report", version = "0.16.3" } +registry-evidence = { path = "crates/registry-evidence", version = "0.16.3" } registry-language-server = { path = "crates/registry-language-server", version = "0.16.3" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.16.3" } -registry-notary-client = { path = "crates/registry-notary-client", version = "0.16.3" } -registry-notary-core = { path = "crates/registry-notary-core", version = "0.16.3" } +registry-mint = { path = "crates/registry-mint", version = "0.16.3" } registry-relay = { path = "crates/registry-relay", version = "0.16.3" } -registry-notary-server = { path = "crates/registry-notary-server", version = "0.16.3", default-features = false } -registry-notary-worker-harness = { path = "crates/registry-notary-worker-harness", version = "0.16.3" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.16.3" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.16.3" } -registry-platform-cache = { path = "crates/registry-platform-cache", version = "0.16.3" } registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.16.3" } registry-platform-config = { path = "crates/registry-platform-config", version = "0.16.3" } registry-platform-crypto = { path = "crates/registry-platform-crypto", version = "0.16.3" } registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.16.3" } registry-platform-httputil = { path = "crates/registry-platform-httputil", version = "0.16.3" } -registry-platform-oid4vci = { path = "crates/registry-platform-oid4vci", version = "0.16.3" } registry-platform-oidc = { path = "crates/registry-platform-oidc", version = "0.16.3" } registry-platform-ops = { path = "crates/registry-platform-ops", version = "0.16.3" } registry-platform-pdp = { path = "crates/registry-platform-pdp", version = "0.16.3" } -registry-platform-replay = { path = "crates/registry-platform-replay", version = "0.16.3" } registry-platform-sdjwt = { path = "crates/registry-platform-sdjwt", version = "0.16.3" } registry-platform-testing = { path = "crates/registry-platform-testing", version = "0.16.3" } @@ -90,13 +74,12 @@ bytes = { version = "1" } calamine = { version = "0.36" } cel = { version = "0.13" } chrono = { version = "0.4" } +chrono-tz = { version = "0.10.4" } clap = { version = "4", features = ["derive", "env"] } crc32fast = { version = "1.4" } criterion = { version = "0.8", features = ["html_reports", "async_tokio"] } -cryptoki = { version = "0.12" } csv = { version = "1" } datafusion = { version = "53.1" } -deadpool = { version = "0.12.3", default-features = false, features = ["managed", "rt_tokio_1"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } fs2 = { version = "0.4" } futures = { version = "0.3" } @@ -112,6 +95,7 @@ http = { version = "1" } humantime-serde = { version = "1" } hyper = { version = "1", features = ["http1", "http2", "server"] } hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } +inquire = { version = "0.9.4" } insta = { version = "1", features = ["json"] } ipnet = { version = "2" } jsonschema = { version = "0.18", features = ["draft202012"] } @@ -125,11 +109,10 @@ proptest = { version = "1" } rand_core = { version = "0.6", features = ["std"] } rcgen = { version = "0.13", default-features = false, features = ["ring", "zeroize"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } -rhai = { version = "1.25.1", features = ["sync", "serde"] } +rhai = { version = "=1.25.1", features = ["sync", "serde"] } rustix = { version = "1", features = ["fs", "process"] } rustls-native-certs = { version = "0.8.4" } ryu-js = { version = "=1.0.3" } -secrecy = { version = "0.10" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } schemars = { version = "=1.2.1" } @@ -138,6 +121,7 @@ serde_path_to_error = { version = "0.1" } serde-saphyr = { version = "0.0.26" } serde_yaml_ng = { version = "0.10.0" } sha2 = { version = "0.11" } +signal-hook = { version = "0.3" } subtle = { version = "2" } tempfile = { version = "3" } thiserror = { version = "2" } diff --git a/README.md b/README.md index 6d9d00460..58ba2b99e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) Registry Stack helps institutions build registry-facing services over data they -already hold: protected read APIs, governed evidence responses, credentials, and +already hold: protected read APIs, signed minimum-disclosure assertions, and audit records, without turning the registry into a shared database. This repository is the monorepo source of truth for Registry Stack product code, @@ -25,8 +25,8 @@ release manifests, and docs. |---|---| | Understand the product | [registrystack.org](https://registrystack.org/) | | Read the technical docs | [docs.registrystack.org](https://docs.registrystack.org/) | +| Get a first minimum-disclosure assertion | [Evidence quickstart](https://docs.registrystack.org/dev/start/evidence-quickstart/) | | Build and run the maintained HTTP project | [Registry Stack 1.0 first run](https://docs.registrystack.org/dev/tutorials/author-registry-project/) | -| Move a pre-1.0 project | [Pre-1.0 cutover](https://docs.registrystack.org/dev/start/pre-1.0-cutover/) | | Install VS Code or Zed integration | [Editor integrations](editors/README.md) | | Work on the monorepo | See [Development](#development) | | Review the public roadmap | [ROADMAP.md](ROADMAP.md) | @@ -34,39 +34,45 @@ release manifests, and docs. ## What It Includes -Registry Stack is organized around two runtime patterns: +Registry Stack contains two independent runtime patterns: - **Protected Registry APIs:** scoped, read-only HTTP APIs over existing files, extracts, databases, or legacy registry systems. Registry Relay implements this surface. -- **Evidence Gateway:** governed evidence responses, claim evaluation, - credential issuance, disclosure policy, and audit provenance. Registry Notary - implements claim evaluation and credential issuance; governed Registry Relay - routes use the same Policy Decision Point pattern for protected reads. +- **Evidence:** a small service that returns signed, minimum-disclosure + assertions from fixed authoritative-source requests. Its first version + excludes credential lifecycles, documents, federation, and a general policy + engine. -The stack also includes Registry Manifest for portable metadata, Registry -Platform shared primitives, `registryctl` adopter tooling, and release tooling -for validating the public source model. +Evidence can use a Relay-protected API as one of its fixed sources. The stack +also includes Registry Mint for short-lived access tokens, Registry Manifest +for portable metadata, Registry Platform shared primitives, `registryctl` and +`evidencectl` adopter tooling, and release tooling for validating the public +source model. ```mermaid flowchart LR source["Existing registry source
file, extract, database, platform"] manifest["Registry Manifest
describe"] relay["Registry Relay
expose protected reads"] - notary["Registry Notary
certify evidence"] - caller["Approved service, verifier, or wallet"] + evidence["Evidence
minimum-disclosure assertions"] + mint["Registry Mint
issue short-lived tokens"] + caller["Approved service or verifier"] source --> relay manifest --> relay relay --> caller - relay --> notary - notary --> caller + source -. fixed request .-> evidence + relay -. protected fixed request .-> evidence + evidence -. signed assertion .-> caller + mint -. access token .-> caller ``` ## Repository Layout -- `crates/`: Rust crates and runnable binaries for Platform, Manifest, Notary, - Relay, `registryctl`, and shared release tooling. +- `crates/`: Rust crates and runnable binaries for Platform, Manifest, Relay, + Evidence, Mint, `registryctl`, and `evidencectl`. Evidence lives in one + `crates/registry-evidence` crate with one `evidence` binary. - `products/`: product-owned docs, examples, Docker inputs, specs, security material, scripts, performance harnesses, and fixtures that are not normal workspace crates. @@ -98,8 +104,7 @@ Release source checks: ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-6.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-27.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -158,8 +163,8 @@ described in [CONTRIBUTING.md](CONTRIBUTING.md#issue-labels). ## Security Report vulnerabilities privately. See [SECURITY.md](SECURITY.md) before opening -a public issue for suspected credential disclosure, auth bypass, audit redaction -failure, source connector data leakage, or signing key handling bugs. +a public issue for suspected minimum-disclosure failure, auth bypass, audit +redaction failure, source connector data leakage, or signing key handling bugs. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 03a435898..9f32bcaf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Public issues remain the source of truth for work selection: ## October 2026 To March 2027 -- Harden Registry Relay and Registry Notary deployment profiles for pilot +- Harden Registry Relay and Evidence deployment profiles for pilot operators, especially configuration diagnostics, audit posture, and verification commands. - Expand coverage for governed registry reads, evidence issuance, sidecar diff --git a/codecov.yml b/codecov.yml index fab47399a..6d9f6ab15 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,9 +8,9 @@ coverage: flags: - platform informational: true - notary-postgres: + relay-postgres: flags: - - notary-postgres + - relay-postgres informational: true patch: default: off @@ -18,9 +18,9 @@ coverage: flags: - platform informational: true - notary-postgres: + relay-postgres: flags: - - notary-postgres + - relay-postgres informational: true flags: @@ -28,24 +28,10 @@ flags: paths: - "crates/registry-platform-*/**" carryforward: true - notary-postgres: - paths: - - "crates/registry-notary/" - - "crates/registry-notary-server/" - carryforward: true manifest-unit: paths: - "crates/registry-manifest-*/**" carryforward: true - notary-unit: - paths: - - "crates/registry-notary*/**" - - "products/notary/xtask/**" - carryforward: true - notary-cel: - paths: - - "crates/registry-notary*/**" - carryforward: true relay-unit: paths: - "crates/registry-relay/**" diff --git a/crates/registry-config-report/fixtures/diagnostics/registry-notary.error.json b/crates/registry-config-report/fixtures/diagnostics/registry-notary.error.json deleted file mode 100644 index 09e8a570e..000000000 --- a/crates/registry-config-report/fixtures/diagnostics/registry-notary.error.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "schema_version": "registry.config.diagnostic_report.v1", - "product": "registry-notary", - "config_schema_version": "registry.notary.config.v1", - "source": { - "kind": "local_file", - "path": "notary/config.yaml" - }, - "status": "error", - "summary": { - "error_count": 2, - "warning_count": 0 - }, - "diagnostics": [ - { - "code": "notary.config.claim_source_missing", - "severity": "error", - "path": "/claim_sources/0", - "message": "Claim source local-relay must declare a source kind.", - "documentation_key": "notary.config.claim_sources" - }, - { - "code": "notary.config.signer_required", - "severity": "error", - "path": "/issuer/signer", - "message": "Issuer signer configuration is required." - } - ], - "required_env": [ - { - "name": "REGISTRY_NOTARY_SIGNING_KEY", - "classification": "secret", - "status": "not_checked" - } - ], - "context_constraints": [], - "audit_shipping": { - "sink_type": "file", - "shipping_target_configured": false, - "shipping_target": "none", - "shipping_health": null, - "shipping_observed_at": null - }, - "generated_at": "2026-06-20T00:00:00Z" -} diff --git a/crates/registry-config-report/fixtures/diagnostics/registry-notary.ok.json b/crates/registry-config-report/fixtures/diagnostics/registry-notary.ok.json deleted file mode 100644 index a7293ebb8..000000000 --- a/crates/registry-config-report/fixtures/diagnostics/registry-notary.ok.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schema_version": "registry.config.diagnostic_report.v1", - "product": "registry-notary", - "config_schema_version": "registry.notary.config.v1", - "source": { - "kind": "generated_file", - "path": "notary/config.yaml" - }, - "status": "ok", - "summary": { - "error_count": 0, - "warning_count": 0 - }, - "diagnostics": [], - "required_env": [ - { - "name": "REGISTRY_NOTARY_SIGNING_KEY", - "classification": "secret", - "status": "present" - }, - { - "name": "REGISTRY_NOTARY_ISSUER_DID", - "classification": "public", - "status": "present" - } - ], - "context_constraints": [], - "audit_shipping": { - "sink_type": "file", - "shipping_target_configured": true, - "shipping_target": "declared_external", - "shipping_health": "unverified", - "shipping_observed_at": "2026-06-19T23:59:00Z" - }, - "hashes": { - "internal_config_hash": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "posture_safe_config_hash": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - }, - "generated_at": "2026-06-20T00:00:00Z" -} diff --git a/crates/registry-config-report/src/lib.rs b/crates/registry-config-report/src/lib.rs index fef01a4fd..c2a3133e4 100644 --- a/crates/registry-config-report/src/lib.rs +++ b/crates/registry-config-report/src/lib.rs @@ -27,12 +27,6 @@ pub const RELAY_DIAGNOSTIC_OK_FIXTURE_V1: &str = pub const RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1: &str = include_str!("../fixtures/diagnostics/registry-relay.error.json"); -pub const NOTARY_DIAGNOSTIC_OK_FIXTURE_V1: &str = - include_str!("../fixtures/diagnostics/registry-notary.ok.json"); - -pub const NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1: &str = - include_str!("../fixtures/diagnostics/registry-notary.error.json"); - pub const CONFIG_EXPLANATION_FIXTURE_V1: &str = include_str!("../fixtures/explanations/registry-relay.explanation.json"); diff --git a/crates/registry-config-report/tests/report_contract.rs b/crates/registry-config-report/tests/report_contract.rs index 8eb198910..4779ecc5d 100644 --- a/crates/registry-config-report/tests/report_contract.rs +++ b/crates/registry-config-report/tests/report_contract.rs @@ -2,7 +2,6 @@ use registry_config_report::{ redact_config_value, ConfigDiagnosticReport, ConfigExplanation, ConfigExplanationDocument, ConfigHashes, ConfigValueClassification, RedactedConfig, RegistryctlValidationReport, RequiredEnvStatus, RequiredEnvVar, CONFIG_EXPLANATION_FIXTURE_V1, CONFIG_EXPLANATION_SCHEMA_V1, - NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1, NOTARY_DIAGNOSTIC_OK_FIXTURE_V1, PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, REDACTED_VALUE, REDACTION_INPUT_FIXTURE_V1, REGISTRYCTL_VALIDATION_FIXTURE_V1, REGISTRYCTL_VALIDATION_REPORT_SCHEMA_V1, REGISTRYCTL_VALIDATION_REPORT_SCHEMA_VERSION_V1, RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1, @@ -128,8 +127,6 @@ fn product_diagnostic_schema_validates_canonical_product_fixtures() { for fixture in [ RELAY_DIAGNOSTIC_OK_FIXTURE_V1, RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1, - NOTARY_DIAGNOSTIC_OK_FIXTURE_V1, - NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1, ] { assert_valid(PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, &parse(fixture)); } @@ -154,17 +151,21 @@ fn product_diagnostic_schema_rejects_wrong_schema_unknown_status_and_bad_hash() #[test] fn product_diagnostic_schema_accepts_optional_declared_audit_shipping() { - // Canonical fixtures carry the declared audit shipping state the products' - // doctor reports emit; the strict schema accepts it. - let report = parse(NOTARY_DIAGNOSTIC_OK_FIXTURE_V1); - assert_eq!(report["audit_shipping"]["sink_type"], "file"); + // The canonical fixture carries the declared audit shipping state the + // products' doctor reports emit; the strict schema accepts it. + let report = parse(RELAY_DIAGNOSTIC_OK_FIXTURE_V1); + assert_eq!(report["audit_shipping"]["sink_type"], "stdout"); assert_eq!(report["audit_shipping"]["shipping_target_configured"], true); - assert_eq!( - report["audit_shipping"]["shipping_target"], - "declared_external" - ); + assert_eq!(report["audit_shipping"]["shipping_target"], "stdout"); assert_valid(PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, &report); + // A local sink that declares off-host shipping is the other accepted + // declared state. + let mut declared_external = report.clone(); + declared_external["audit_shipping"]["sink_type"] = json!("file"); + declared_external["audit_shipping"]["shipping_target"] = json!("declared_external"); + assert_valid(PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, &declared_external); + // The section is optional: a report may omit it (e.g. when config is // unavailable) and still validate. let mut without = report.clone(); @@ -197,9 +198,7 @@ fn product_diagnostic_schema_accepts_observed_audit_shipping_fields() { // validate under the strict schema. for fixture in [ RELAY_DIAGNOSTIC_OK_FIXTURE_V1, - NOTARY_DIAGNOSTIC_OK_FIXTURE_V1, RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1, - NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1, ] { let report = parse(fixture); assert!(report["audit_shipping"].get("shipping_health").is_some()); @@ -210,7 +209,7 @@ fn product_diagnostic_schema_accepts_observed_audit_shipping_fields() { } // An observed health plus timestamp validates. - let mut with_health = parse(NOTARY_DIAGNOSTIC_OK_FIXTURE_V1); + let mut with_health = parse(RELAY_DIAGNOSTIC_OK_FIXTURE_V1); with_health["audit_shipping"]["shipping_health"] = json!("stale"); with_health["audit_shipping"]["shipping_observed_at"] = json!("2026-06-19T23:00:00Z"); assert_valid(PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, &with_health); @@ -231,7 +230,7 @@ fn product_diagnostic_schema_accepts_observed_audit_shipping_fields() { // Even alongside the new observed fields, unknown fields inside // audit_shipping still fail. - let mut unknown = parse(NOTARY_DIAGNOSTIC_OK_FIXTURE_V1); + let mut unknown = parse(RELAY_DIAGNOSTIC_OK_FIXTURE_V1); unknown["audit_shipping"]["backlog_depth"] = json!(3); assert_invalid(PRODUCT_DIAGNOSTIC_REPORT_SCHEMA_V1, &unknown); } @@ -288,8 +287,6 @@ fn registryctl_schema_rejects_lossy_embedded_product_report() { fn serde_types_round_trip_canonical_fixtures() { round_trip::(RELAY_DIAGNOSTIC_OK_FIXTURE_V1); round_trip::(RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1); - round_trip::(NOTARY_DIAGNOSTIC_OK_FIXTURE_V1); - round_trip::(NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1); let _: ConfigExplanation = decode(CONFIG_EXPLANATION_FIXTURE_V1); let _: ConfigExplanationDocument = decode(CONFIG_EXPLANATION_FIXTURE_V1); round_trip::(REGISTRYCTL_VALIDATION_FIXTURE_V1); @@ -387,8 +384,6 @@ fn diagnostic_report_round_trip_preserves_audit_shipping_section() { for fixture in [ RELAY_DIAGNOSTIC_OK_FIXTURE_V1, RELAY_DIAGNOSTIC_ERROR_FIXTURE_V1, - NOTARY_DIAGNOSTIC_OK_FIXTURE_V1, - NOTARY_DIAGNOSTIC_ERROR_FIXTURE_V1, ] { let original = parse(fixture); let decoded: ConfigDiagnosticReport = decode(fixture); diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml new file mode 100644 index 000000000..cf748e311 --- /dev/null +++ b/crates/registry-evidence/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "registry-evidence" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Minimum-disclosure signed assertion evidence service." +repository.workspace = true +publish = false + +[[bin]] +name = "evidence" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +bytes.workspace = true +chrono = { workspace = true, features = ["serde"] } +chrono-tz.workspace = true +clap.workspace = true +ed25519-dalek.workspace = true +fs2.workspace = true +http.workspace = true +jsonschema.workspace = true +jsonwebtoken.workspace = true +registry-platform-audit.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-httputil.workspace = true +registry-platform-oidc.workspace = true +registry-platform-sdjwt.workspace = true +reqwest.workspace = true +rand_core.workspace = true +rhai.workspace = true +rustix.workspace = true +schemars = { workspace = true, features = ["chrono04"] } +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +ulid.workspace = true +url.workspace = true +utoipa.workspace = true +zeroize.workspace = true + +[dev-dependencies] +assert-json-diff.workspace = true +axum-test.workspace = true +criterion.workspace = true +rcgen.workspace = true +tempfile.workspace = true +tokio-rustls.workspace = true +wiremock.workspace = true + +[[bench]] +name = "audit_bench" +harness = false diff --git a/crates/registry-evidence/README.md b/crates/registry-evidence/README.md new file mode 100644 index 000000000..2616cc318 --- /dev/null +++ b/crates/registry-evidence/README.md @@ -0,0 +1,53 @@ +# registry-evidence + +`registry-evidence` is the single-crate Evidence Version 1 runtime. It loads +one immutable operator-controlled bundle, evaluates fixed requirements through +bounded Rhai extraction and derivation, and returns minimum-disclosure +assertion evidence. + +The `evidence` binary takes a runtime file and one subcommand: + +```text +evidence --runtime check +evidence --runtime evaluate --fixture +evidence --runtime serve +evidence verify --jws --jwks --policy [--at ] +``` + +`check` validates and compiles the complete immutable bundle. `evaluate` runs +one bundle-owned fixture without source or credential access. `verify` +re-verifies a stored signed response offline against a pinned trusted JWKS +file and a complete relying-procedure policy document, reporting cryptographic +authenticity separately from current validity; it needs no runtime file and +never touches the network. `serve` starts the native HTTP service: + +```text +POST /v1/evidence +GET /v1/evidence-definitions +GET /health +GET /openapi.json +GET /ready +GET /.well-known/evidence/jwks.json +``` + +`GET /openapi.json` returns the generated public contract as +`application/openapi+json`. It is unauthenticated and byte-identical to the +released artifact under `products/evidence/generated/`, so it describes no +deployment, definition, or authority. + +`POST /v1/evidence` requires a `requestNonce`: the canonical unpadded base64url +encoding of exactly 32 random bytes, freshly generated per request. The runtime +echoes it into the Evidence payload and covers it by the signature. It is never +stored, never uniqueness-checked, and never reaches authorization, rate limits, +Rhai, source requests, logs, metrics, traces, or audit. + +Signed flattened JWS (`application/jose+json`) is the mandatory default and the +only later-verifiable format. The exact +`application/vnd.registrystack.evidence-unsigned+json` selects a self-identifying +unsigned envelope, and only when both the bundle and the one complete matched +grant permit that format. Signing failure never falls back to unsigned output. + +The normative product contracts and verification commands live under +`products/evidence/`. Evidence is independent from Registry Notary and has no +credential, replay, policy-engine, document, federation, worker, or OOTS +subsystem. diff --git a/crates/registry-evidence/benches/audit_bench.rs b/crates/registry-evidence/benches/audit_bench.rs new file mode 100644 index 000000000..03120a5e0 --- /dev/null +++ b/crates/registry-evidence/benches/audit_bench.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Microbenchmarks for the durable Evidence audit chain. +//! +//! These measure the real filesystem path through the shared +//! `DurableSegmentedAuditLog`, because the cost that decides service throughput +//! is the durable `fsync` that covers each append, not the chain hashing. +//! +//! Covers: +//! - one sequential append, the latency floor a request pays per audit record; +//! - concurrent appends, which show whether added concurrency raises append +//! throughput or merely queues behind the same serialized `fsync`; +//! - event construction and serialization alone, for scale against the I/O. +//! +//! The `record_bytes` line printed on startup reports the on-disk size of one +//! representative record, which is what sizes the audit file against its +//! configured ceiling. + +use std::{hint::black_box, sync::Arc}; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use registry_evidence::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind, EvidenceAuditEvent, + EvidenceAuditLog, ResponseProtection, +}; +use registry_evidence::config::AssuranceProfile; +use tokio::{runtime::Runtime, task::JoinSet}; + +/// Far above anything an individual benchmark run appends, so the file-size +/// ceiling never interferes with the measurement. +const BENCH_MAXIMUM_FILE_BYTES: u64 = 64 * 1024 * 1024 * 1024; +const BENCH_SECRET: [u8; 64] = [0x5a; 64]; +const CONCURRENCY_LEVELS: [usize; 4] = [1, 8, 32, 128]; + +/// Build a pseudonym of the shape the runtime actually writes, so records are +/// representative in size rather than artificially short. +fn pseudonym(seed: u8) -> String { + let digest: String = (0u8..32) + .map(|byte| format!("{:02x}", byte ^ seed)) + .collect(); + format!("hmac-sha256:v1:{digest}") +} + +fn sample_event() -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + AssuranceProfile::EvidenceGrade, + "evidence.request.evaluate".to_string(), + AuditPhase::AccessAttempt, + "adult-status".to_string(), + "2026-08-01T00:00:00Z/1".to_string(), + "age-verification".to_string(), + pseudonym(0x11), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: Some(pseudonym(0x22)), + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "national-identifier".to_string(), + selector_bundle_pseudonym: Some(pseudonym(0x33)), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 12, + ) +} + +fn runtime() -> Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime") +} + +/// Initialize a durable log over a fresh temporary file. The `TempDir` is +/// returned because dropping it would delete the file out from under the sink. +fn durable_log(runtime: &Runtime) -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("audit.jsonl"); + let log = runtime.block_on(async { + EvidenceAuditLog::initialize(path, BENCH_MAXIMUM_FILE_BYTES, BENCH_SECRET.to_vec(), 1) + .await + .expect("initialize audit log") + }); + (directory, Arc::new(log)) +} + +/// Report the on-disk size of a single record. This is the number that decides +/// how quickly a deployment reaches its configured audit file ceiling. +fn report_record_bytes(runtime: &Runtime) { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("audit.jsonl"); + runtime.block_on(async { + let log = + EvidenceAuditLog::initialize(&path, BENCH_MAXIMUM_FILE_BYTES, BENCH_SECRET.to_vec(), 1) + .await + .expect("initialize audit log"); + log.append(sample_event()).await.expect("append"); + }); + let bytes = std::fs::metadata(&path).expect("metadata").len(); + eprintln!("audit/record_bytes: {bytes}"); +} + +/// One append at a time: the per-record cost a request pays, dominated by the +/// `fsync` in the sink write path. +fn benchmark_sequential_append(c: &mut Criterion) { + let runtime = runtime(); + report_record_bytes(&runtime); + let (_directory, log) = durable_log(&runtime); + + let mut group = c.benchmark_group("audit/durable_append"); + group.throughput(Throughput::Elements(1)); + group.bench_function("sequential", |b| { + b.to_async(&runtime) + .iter(|| async { log.append(black_box(sample_event())).await.expect("append") }); + }); + group.finish(); +} + +/// Many appends in flight at once. Because the sink holds its state mutex +/// across the blocking write and `fsync`, throughput here is expected to stay +/// flat as concurrency rises: the extra callers queue rather than batch. +fn benchmark_concurrent_append(c: &mut Criterion) { + let runtime = runtime(); + let (_directory, log) = durable_log(&runtime); + + let mut group = c.benchmark_group("audit/durable_append_concurrent"); + for concurrency in CONCURRENCY_LEVELS { + group.throughput(Throughput::Elements(concurrency as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(concurrency), + &concurrency, + |b, &concurrency| { + b.to_async(&runtime).iter(|| { + let log = Arc::clone(&log); + async move { + let mut appends = JoinSet::new(); + for _ in 0..concurrency { + let log = Arc::clone(&log); + appends.spawn(async move { + log.append(sample_event()).await.expect("append") + }); + } + while let Some(result) = appends.join_next().await { + black_box(result.expect("join")); + } + } + }); + }, + ); + } + group.finish(); +} + +/// Event construction and JSON serialization with no I/O, for scale against +/// the durable append measurements. +fn benchmark_event_serialization(c: &mut Criterion) { + let mut group = c.benchmark_group("audit/event"); + group.bench_function("construct", |b| b.iter(|| black_box(sample_event()))); + let event = sample_event(); + group.bench_function("serialize", |b| { + b.iter(|| serde_json::to_value(black_box(&event)).expect("serialize")); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(50); + targets = + benchmark_sequential_append, + benchmark_concurrent_append, + benchmark_event_serialization +} +criterion_main!(benches); diff --git a/crates/registry-evidence/examples/evidence-contracts.rs b/crates/registry-evidence/examples/evidence-contracts.rs new file mode 100644 index 000000000..77c05ac05 --- /dev/null +++ b/crates/registry-evidence/examples/evidence-contracts.rs @@ -0,0 +1,28 @@ +//! Generate deterministic Evidence Version 1 public contract artifacts. + +use std::{env, path::PathBuf, process::ExitCode}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("evidence contract generation failed: {message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--output")) { + return Err("usage: evidence-contracts --output ".to_string()); + } + let output = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "usage: evidence-contracts --output ".to_string())?; + if arguments.next().is_some() { + return Err("usage: evidence-contracts --output ".to_string()); + } + registry_evidence::contracts::write_documents(&output).map_err(|error| error.to_string()) +} diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs new file mode 100644 index 000000000..4fdcae9c8 --- /dev/null +++ b/crates/registry-evidence/src/audit.rs @@ -0,0 +1,2289 @@ +//! Fail-closed native Evidence audit with a durable keyed JSONL chain. + +use std::{ + collections::{BTreeMap, BTreeSet}, + io::{Error as IoError, ErrorKind}, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[cfg(test)] +use std::io::{Seek, SeekFrom, Write}; +#[cfg(test)] +use std::sync::atomic::Ordering; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +pub use registry_platform_audit::segmented_audit_paths as audit_segment_paths; +use registry_platform_audit::{ + verify_segmented_audit_chain, visit_stopped_segmented_audit_chain, AuditChainHasher, + AuditEnvelope, AuditError, AuditHashSecret, AuditKeyHasher, DurableSegmentedAuditLog, +}; +use registry_platform_crypto::canonicalize_json; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::config::AssuranceProfile; + +const AUDIT_SCHEMA: &str = "registry.evidence.audit/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditPhase { + AccessAttempt, + DisclosureRelease, + Denial, + TransientFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditDecision { + Authorized, + Released, + NoMatch, + Ambiguous, + FactMissing, + DependencyFailure, + EvaluationFailure, + SigningFailure, +} + +/// Closed non-secret response-protection mode resolved with authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ResponseProtection { + Signed, + Unsigned, + SdJwtVc, +} + +impl ResponseProtection { + /// Report whether release under this mode is cryptographically protected + /// and therefore records the signing key identifier. + pub fn is_signed(self) -> bool { + matches!(self, Self::Signed | Self::SdJwtVc) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityKind { + Statutory, + Organizational, + Consent, + Delegated, + ExplicitRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditAuthority { + pub kind: AuthorityKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_pseudonym: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditSubject { + pub role: String, + pub selector_profile: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selector_bundle_pseudonym: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceAuditEvent { + pub schema: String, + pub assurance_profile: AssuranceProfile, + pub event_id: String, + pub occurred_at: String, + pub operation: String, + pub phase: AuditPhase, + pub requirement: String, + pub bundle_revision: String, + pub purpose: String, + pub requester_pseudonym: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor_pseudonym: Option, + pub authority: AuditAuthority, + pub subjects: Vec, + pub response_protection: ResponseProtection, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter_id: Option, + pub decision: AuditDecision, + #[serde(skip_serializing_if = "Option::is_none")] + pub disclosed_concepts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_key_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safe_error_category: Option, + pub duration_milliseconds: u64, +} + +impl EvidenceAuditEvent { + #[allow(clippy::too_many_arguments)] + pub fn new( + assurance_profile: AssuranceProfile, + operation: String, + phase: AuditPhase, + requirement: String, + bundle_revision: String, + purpose: String, + requester_pseudonym: String, + authority: AuditAuthority, + subjects: Vec, + response_protection: ResponseProtection, + decision: AuditDecision, + duration_milliseconds: u64, + ) -> Self { + Self { + schema: AUDIT_SCHEMA.to_owned(), + assurance_profile, + event_id: format!("urn:ulid:{}", ulid::Ulid::new()), + occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + operation, + phase, + requirement, + bundle_revision, + purpose, + requester_pseudonym, + actor_pseudonym: None, + authority, + subjects, + response_protection, + source_id: None, + adapter_id: None, + decision, + disclosed_concepts: None, + evidence_id: None, + signing_key_id: None, + safe_error_category: None, + duration_milliseconds, + } + } + + pub fn validate_phase_fields(&self) -> Result<(), EvidenceAuditError> { + let any_release_field = self.disclosed_concepts.is_some() || self.evidence_id.is_some(); + let all_release_fields = self.disclosed_concepts.is_some() && self.evidence_id.is_some(); + if (self.phase == AuditPhase::DisclosureRelease && !all_release_fields) + || (self.phase != AuditPhase::DisclosureRelease && any_release_field) + { + return Err(EvidenceAuditError::InvalidEvent); + } + // A signing key identity exists exactly for cryptographically + // protected disclosure release. + let signing_key_required = + self.phase == AuditPhase::DisclosureRelease && self.response_protection.is_signed(); + if self.signing_key_id.is_some() != signing_key_required { + return Err(EvidenceAuditError::InvalidEvent); + } + let phase_decision_is_native = matches!( + (self.phase, self.decision), + (AuditPhase::AccessAttempt, AuditDecision::Authorized) + | (AuditPhase::DisclosureRelease, AuditDecision::Released) + | ( + AuditPhase::Denial, + AuditDecision::NoMatch | AuditDecision::Ambiguous | AuditDecision::FactMissing + ) + | ( + AuditPhase::TransientFailure, + AuditDecision::DependencyFailure + | AuditDecision::EvaluationFailure + | AuditDecision::SigningFailure + ) + ); + let concepts_are_valid = self.disclosed_concepts.as_ref().is_none_or(|concepts| { + concepts.len() <= 16 + && concepts.iter().all(|concept| valid_uri(concept)) + && concepts.iter().collect::>().len() == concepts.len() + }); + if self.schema != AUDIT_SCHEMA + || !phase_decision_is_native + || !valid_uri(&self.event_id) + || chrono::DateTime::parse_from_rfc3339(&self.occurred_at).is_err() + || !valid_uri(&self.requirement) + || !valid_revision(&self.bundle_revision) + || !valid_purpose(&self.purpose, 128) + || !valid_pseudonym(&self.requester_pseudonym) + || self + .actor_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) + || self + .authority + .grant_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) + || self.subjects.is_empty() + || self.subjects.len() > 8 + || !(16..=128).contains(&self.operation.len()) + || self.subjects.iter().any(|subject| { + !valid_local_name(&subject.role, 64) + || !valid_local_name(&subject.selector_profile, 128) + || subject + .selector_bundle_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) + }) + || self + .source_id + .as_ref() + .is_some_and(|value| !valid_local_name(value, 128)) + || self + .adapter_id + .as_ref() + .is_some_and(|value| !valid_local_name(value, 128)) + || !concepts_are_valid + || self + .evidence_id + .as_ref() + .is_some_and(|value| !valid_uri(value)) + || self.signing_key_id.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) + }) + || self + .safe_error_category + .as_ref() + .is_some_and(|value| !valid_local_name(value, 128)) + || self.duration_milliseconds > 86_400_000 + { + return Err(EvidenceAuditError::InvalidEvent); + } + Ok(()) + } +} + +fn valid_uri(value: &str) -> bool { + !value.is_empty() && value.len() <= 512 && url::Url::parse(value).is_ok() +} + +fn valid_revision(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) +} + +fn valid_purpose(value: &str, maximum: usize) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= maximum + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} + +fn valid_local_name(value: &str, maximum: usize) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= maximum + && bytes.all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn valid_pseudonym(value: &str) -> bool { + let Some(rest) = value.strip_prefix("hmac-sha256:v") else { + return false; + }; + let Some((version, digest)) = rest.split_once(':') else { + return false; + }; + !version.is_empty() + && !version.starts_with('0') + && version.bytes().all(|byte| byte.is_ascii_digit()) + && digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[derive(Debug, Error)] +pub enum EvidenceAuditError { + #[error("audit configuration is invalid")] + Configuration, + #[error("audit event is invalid")] + InvalidEvent, + #[error("audit initialization or write failed")] + Audit(#[from] AuditError), + /// A span of sealed history is absent. Reported separately from a hash + /// break so an operator can tell deliberate archival from tampering. + #[error("audit chain is missing sealed segment {sequence}")] + SegmentMissing { sequence: u64 }, +} + +/// The chain's on-disk footprint, sealed segments and the active segment +/// together. +/// +/// Rotation never deletes a sealed segment, so this only falls when an +/// operator archives one. That is why it is measured by walking the segment +/// directory rather than accumulated in a counter: a counter would keep +/// reporting bytes an operator had already reclaimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditStorageUsage { + pub segments: usize, + pub bytes: u64, +} + +pub struct EvidenceAuditLog { + sink: Arc, + key_hasher: AuditKeyHasher, + key_version: u32, +} + +impl std::fmt::Debug for EvidenceAuditLog { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceAuditLog") + .field("path", &self.sink.path()) + .field("key_version", &self.key_version) + .finish_non_exhaustive() + } +} + +impl EvidenceAuditLog { + pub async fn initialize( + path: impl Into, + maximum_file_bytes: u64, + master_secret: Vec, + key_version: u32, + ) -> Result { + if maximum_file_bytes == 0 || key_version == 0 { + return Err(EvidenceAuditError::Configuration); + } + let path = path.into(); + if !path.is_absolute() { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path must be absolute", + )) + .into()); + } + if !path.parent().is_some_and(Path::is_dir) { + return Err(AuditError::Io(IoError::new( + ErrorKind::NotFound, + "audit parent directory is unavailable", + )) + .into()); + } + let secret = AuditHashSecret::new(master_secret)?; + let chain_hasher = AuditChainHasher::keyed(secret.clone()); + let key_hasher = AuditKeyHasher::Keyed(secret); + let sink = Arc::new( + DurableSegmentedAuditLog::initialize(path, maximum_file_bytes, chain_hasher).await?, + ); + Ok(Self { + sink, + key_hasher, + key_version, + }) + } + + pub fn pseudonym( + &self, + class: &str, + scope: &str, + protected_input: &[u8], + ) -> Result { + if protected_input.is_empty() { + return Err(EvidenceAuditError::InvalidEvent); + } + let transient = URL_SAFE_NO_PAD.encode(protected_input); + let digest = self + .key_hasher + .audit_reference_hash(class, scope, &transient) + .map_err(|_| EvidenceAuditError::InvalidEvent)?; + let digest = digest + .strip_prefix("hmac-sha256:") + .ok_or(EvidenceAuditError::InvalidEvent)?; + Ok(format!("hmac-sha256:v{}:{digest}", self.key_version)) + } + + /// Measure the chain's footprint for the capacity gauge. + /// + /// This walks the audit directory, so it runs on the blocking pool: the + /// number of sealed segments grows without bound and the caller is a + /// scrape handler on the async runtime. A segment that disappears midway + /// through the walk is skipped rather than failing the read, because an + /// operator archiving history concurrently is expected, not an error. + pub async fn storage_usage(&self) -> Result { + let path = self.sink.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + let segments = audit_segment_paths(&path)?; + let mut bytes = 0u64; + let mut counted = 0usize; + for segment in &segments { + match std::fs::symlink_metadata(segment) { + Ok(metadata) => { + counted += 1; + bytes = bytes.saturating_add(metadata.len()); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(AuditError::Io(error)), + } + } + Ok(AuditStorageUsage { + segments: counted, + bytes, + }) + }) + .await + .map_err(|error| AuditError::Io(IoError::other(error)))? + .map_err(EvidenceAuditError::from) + } + + pub async fn append( + &self, + event: EvidenceAuditEvent, + ) -> Result { + event.validate_phase_fields()?; + let record = serde_json::to_value(event).map_err(AuditError::Json)?; + self.sink + .append_record(record) + .await + .map_err(EvidenceAuditError::Audit) + } + + pub async fn ready(&self) -> bool { + self.sink.ready().await + } + + /// Durable writes performed so far, for proving that concurrent appends + /// share them rather than each paying an `fsync`. + #[cfg(test)] + pub(crate) fn durable_writes(&self) -> usize { + usize::try_from(self.sink.durable_writes()).unwrap_or(usize::MAX) + } + + #[cfg(test)] + fn startup_verifications(&self) -> u64 { + self.sink.startup_verifications() + } +} + +/// Result of an out-of-band verification pass over a whole audit chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditChainSummary { + /// Segments actually replayed. + pub segments: usize, + pub records: usize, + pub head: Option<[u8; 32]>, + /// Sequence of the oldest and newest sealed segments, absent when the chain + /// has never rotated. + pub first_sequence: Option, + pub last_sequence: Option, + /// Whether the active segment was replayed. False when a running writer + /// holds the chain, in which case only sealed history was proven. + pub active_verified: bool, +} + +pub const LOCAL_AUDIT_OPERATION_VIEW_SCHEMA_V1: &str = "registry.evidence.local-audit-operation/v1"; + +/// Minimized verified view of one native audit operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAuditOperationView { + schema: &'static str, + operation: String, + events: Vec, + #[serde(skip)] + assurance_profile: AssuranceProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalAuditOperationEvent { + occurred_at: String, + phase: AuditPhase, + decision: AuditDecision, + requirement: String, + purpose: String, + requester_pseudonym: String, + response_protection: ResponseProtection, + #[serde(skip_serializing_if = "Option::is_none")] + disclosed_concepts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + evidence_id: Option, +} + +#[derive(Clone, Copy)] +struct LocalAuditInspectionBounds { + maximum_segments: usize, + maximum_records: usize, + maximum_output_bytes: usize, +} + +impl LocalAuditInspectionBounds { + const DEFAULT: Self = Self { + maximum_segments: 1024, + maximum_records: 10_000, + maximum_output_bytes: 256 * 1024, + }; +} + +struct PendingLocalOperation { + event: EvidenceAuditEvent, + view: LocalAuditOperationEvent, +} + +#[derive(Default)] +struct LocalAuditCollector { + bounds: Option, + records: usize, + pending: BTreeMap, + completed: BTreeSet, + last_operation: Option, + last_completed: Option, +} + +impl LocalAuditCollector { + fn new(bounds: LocalAuditInspectionBounds) -> Self { + Self { + bounds: Some(bounds), + ..Self::default() + } + } + + fn collect(&mut self, envelope: AuditEnvelope) -> Result<(), AuditError> { + let bounds = self.bounds.ok_or_else(invalid_audit_data)?; + self.records = self.records.checked_add(1).ok_or_else(file_size_error)?; + if self.records > bounds.maximum_records { + return Err(file_size_error()); + } + let event: EvidenceAuditEvent = + serde_json::from_value(envelope.record).map_err(|_| invalid_audit_data())?; + event + .validate_phase_fields() + .map_err(|_| invalid_audit_data())?; + let operation = event.operation.clone(); + self.last_operation = Some(operation.clone()); + let view = LocalAuditOperationEvent::from(&event); + + if event.phase == AuditPhase::AccessAttempt { + if self.completed.contains(&operation) + || self + .pending + .insert(operation, PendingLocalOperation { event, view }) + .is_some() + { + return Err(invalid_audit_data()); + } + return Ok(()); + } + + let access = self + .pending + .remove(&operation) + .ok_or_else(invalid_audit_data)?; + if !coherent_operation_pair(&access.event, &event) + || !self.completed.insert(operation.clone()) + { + return Err(invalid_audit_data()); + } + self.last_completed = Some(LocalAuditOperationView { + schema: LOCAL_AUDIT_OPERATION_VIEW_SCHEMA_V1, + operation, + events: vec![access.view, view], + assurance_profile: access.event.assurance_profile, + }); + Ok(()) + } + + fn finish(mut self) -> Result { + let bounds = self.bounds.take().ok_or(EvidenceAuditError::InvalidEvent)?; + let last = self + .last_operation + .take() + .ok_or(EvidenceAuditError::InvalidEvent)?; + let view = if let Some(pending) = self.pending.remove(&last) { + LocalAuditOperationView { + schema: LOCAL_AUDIT_OPERATION_VIEW_SCHEMA_V1, + operation: last, + events: vec![pending.view], + assurance_profile: pending.event.assurance_profile, + } + } else { + self.last_completed + .take() + .filter(|completed| completed.operation == last) + .ok_or(EvidenceAuditError::InvalidEvent)? + }; + if view.events.first().is_none_or(|event| { + event.phase != AuditPhase::AccessAttempt || event.decision != AuditDecision::Authorized + }) || view.assurance_profile != AssuranceProfile::Local + { + return Err(EvidenceAuditError::InvalidEvent); + } + let serialized = serde_json::to_value(&view).map_err(AuditError::Json)?; + if canonicalize_json(&serialized) + .map_err(|_| invalid_audit_data())? + .len() + > bounds.maximum_output_bytes + { + return Err(EvidenceAuditError::Configuration); + } + Ok(view) + } +} + +impl From<&EvidenceAuditEvent> for LocalAuditOperationEvent { + fn from(event: &EvidenceAuditEvent) -> Self { + Self { + occurred_at: event.occurred_at.clone(), + phase: event.phase, + decision: event.decision, + requirement: event.requirement.clone(), + purpose: event.purpose.clone(), + requester_pseudonym: event.requester_pseudonym.clone(), + response_protection: event.response_protection, + disclosed_concepts: event.disclosed_concepts.clone(), + evidence_id: event.evidence_id.clone(), + } + } +} + +fn coherent_operation_pair(access: &EvidenceAuditEvent, terminal: &EvidenceAuditEvent) -> bool { + let occurred_in_order = chrono::DateTime::parse_from_rfc3339(&access.occurred_at) + .ok() + .zip(chrono::DateTime::parse_from_rfc3339(&terminal.occurred_at).ok()) + .is_some_and(|(access, terminal)| access <= terminal); + occurred_in_order + && access.operation == terminal.operation + && access.assurance_profile == terminal.assurance_profile + && access.requirement == terminal.requirement + && access.bundle_revision == terminal.bundle_revision + && access.purpose == terminal.purpose + && access.requester_pseudonym == terminal.requester_pseudonym + && access.actor_pseudonym == terminal.actor_pseudonym + && access.authority == terminal.authority + && access.subjects == terminal.subjects + && access.response_protection == terminal.response_protection + && access.source_id == terminal.source_id + && access.adapter_id == terminal.adapter_id +} + +/// Verify the whole stopped local chain and derive the last operation from the +/// exact verified envelopes in that one replay. +pub fn verified_last_local_audit_operation( + path: &Path, + master_secret: &AuditHashSecret, +) -> Result { + verified_last_local_audit_operation_with_bounds( + path, + master_secret, + LocalAuditInspectionBounds::DEFAULT, + ) +} + +fn verified_last_local_audit_operation_with_bounds( + path: &Path, + master_secret: &AuditHashSecret, + bounds: LocalAuditInspectionBounds, +) -> Result { + if bounds.maximum_segments == 0 + || bounds.maximum_records == 0 + || bounds.maximum_output_bytes == 0 + { + return Err(EvidenceAuditError::Configuration); + } + + let hasher = AuditChainHasher::keyed(master_secret.clone()); + let mut collector = LocalAuditCollector::new(bounds); + visit_stopped_segmented_audit_chain( + path, + &hasher, + bounds.maximum_segments, + bounds.maximum_records, + |envelope| collector.collect(envelope), + ) + .map_err(map_platform_audit_error)?; + collector.finish() +} + +/// Verify every retained segment, including the active segment when no writer is running. +pub fn verify_audit_chain( + path: &Path, + master_secret: &AuditHashSecret, +) -> Result { + let summary = + verify_segmented_audit_chain(path, &AuditChainHasher::keyed(master_secret.clone())) + .map_err(map_platform_audit_error)?; + Ok(AuditChainSummary { + segments: summary.segments, + records: summary.records, + head: summary.last_hash, + first_sequence: summary.first_sequence, + last_sequence: summary.last_sequence, + active_verified: summary.active_verified, + }) +} + +fn map_platform_audit_error(error: AuditError) -> EvidenceAuditError { + match error { + AuditError::SegmentMissing { sequence } => EvidenceAuditError::SegmentMissing { sequence }, + error => EvidenceAuditError::Audit(error), + } +} + +fn invalid_audit_data() -> AuditError { + AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit record is invalid", + )) +} + +fn file_size_error() -> AuditError { + AuditError::Io(IoError::other("audit file size bound exceeded")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + AssuranceProfile::EvidenceGrade, + "01K1EXAMPLE0000000000000000".to_string(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_string(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_string(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "person-v1".to_string(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 5, + ) + } + + #[test] + fn frozen_audit_fixture_matches_native_event_shape_and_phase_rules() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/audit-events.yaml" + )) + .expect("frozen audit fixture parses"); + assert_eq!( + fixture["fixture"], + serde_json::json!("registry.evidence.audit-events/v1") + ); + assert_eq!(fixture["synthetic_only"], serde_json::json!(true)); + + let access = EvidenceAuditEvent { + schema: AUDIT_SCHEMA.to_owned(), + assurance_profile: AssuranceProfile::EvidenceGrade, + event_id: "urn:example:fixture:audit:access-001".to_owned(), + occurred_at: "2026-08-02T00:00:00Z".to_owned(), + operation: "fixture-operation-00000001".to_owned(), + phase: AuditPhase::AccessAttempt, + requirement: "urn:example:fixture:requirement:property:v1".to_owned(), + bundle_revision: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_owned(), + purpose: "fixture-procedure".to_owned(), + requester_pseudonym: + "hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111" + .to_owned(), + actor_pseudonym: None, + authority: AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + subjects: vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "opaque-record-v1".to_owned(), + selector_bundle_pseudonym: Some( + "hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222" + .to_owned(), + ), + }], + response_protection: ResponseProtection::Signed, + source_id: Some("source-a".to_owned()), + adapter_id: Some("adapter-a".to_owned()), + decision: AuditDecision::Authorized, + disclosed_concepts: None, + evidence_id: None, + signing_key_id: None, + safe_error_category: None, + duration_milliseconds: 2, + }; + access + .validate_phase_fields() + .expect("fixture access event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&access).expect("access event serializes"), + fixture["access_attempt"] + ); + + let mut release = access.clone(); + release.event_id = "urn:example:fixture:audit:release-001".to_owned(); + release.occurred_at = "2026-08-02T00:00:01Z".to_owned(); + release.phase = AuditPhase::DisclosureRelease; + release.decision = AuditDecision::Released; + release.disclosed_concepts = Some(vec!["urn:example:fixture:concept:boolean-a".to_owned()]); + release.evidence_id = Some("urn:example:fixture:evidence:001".to_owned()); + release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + release.duration_milliseconds = 12; + release + .validate_phase_fields() + .expect("fixture release event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&release).expect("release event serializes"), + fixture["disclosure_release"] + ); + + let mut unsigned_release = release.clone(); + unsigned_release.event_id = "urn:example:fixture:audit:release-002".to_owned(); + unsigned_release.occurred_at = "2026-08-02T00:00:02Z".to_owned(); + unsigned_release.response_protection = ResponseProtection::Unsigned; + unsigned_release.signing_key_id = None; + unsigned_release + .validate_phase_fields() + .expect("fixture unsigned release event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&unsigned_release).expect("unsigned release event serializes"), + fixture["unsigned_disclosure_release"] + ); + unsigned_release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + assert!(matches!( + unsigned_release.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + + let mut signed_release_without_key = release.clone(); + signed_release_without_key.signing_key_id = None; + assert!(matches!( + signed_release_without_key.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + + let mut release_fields_on_access = access; + release_fields_on_access.disclosed_concepts = release.disclosed_concepts.clone(); + release_fields_on_access.evidence_id = release.evidence_id.clone(); + release_fields_on_access.signing_key_id = release.signing_key_id.clone(); + assert!(matches!( + release_fields_on_access.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + release.evidence_id = None; + assert!(matches!( + release.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + + assert_eq!( + fixture["order"], + serde_json::json!({ + "access_attempt_durable_before": ["credential-resolution", "source-access"], + "disclosure_release_durable_after": ["signing"], + "disclosure_release_durable_before": ["response-release"] + }) + ); + assert_eq!( + fixture["negative"], + serde_json::json!([ + "raw-principal", + "raw-actor-or-grant", + "raw-selector-value", + "separate-field-hash", + "plain-sha256-subject-hash", + "base64url-reencoded-audit-hmac", + "globally-stable-subject-pseudonym", + "source-or-supported-value", + "credential-token-or-private-key", + "candidate-count-score-hint-or-comparison", + "release-fields-on-access-event", + "missing-release-fields-on-release-event", + "signing-key-on-unsigned-release-event", + "missing-signing-key-on-signed-release-event", + "request-nonce-in-any-event" + ]) + ); + } + + #[tokio::test] + async fn audit_is_durable_keyed_and_redacted() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + assert_eq!(log.startup_verifications(), 1); + assert!(log.ready().await, "an empty verified chain is ready"); + log.append(event(&log)).await.expect("event appends"); + assert!(log.ready().await); + assert_eq!( + log.startup_verifications(), + 1, + "steady-state appends and readiness must not rescan the audit file" + ); + + let contents = std::fs::read_to_string(&path).expect("audit reads"); + assert!(!contents.contains("principal-canary")); + assert!(!contents.contains("selector-canary")); + assert!(contents.contains("hmac-sha256:v1:")); + assert!(!contents.contains("hmac-sha256:v1:hmac-sha256:")); + assert!(contents.ends_with('\n')); + + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("tamper audit file"); + assert!(!log.ready().await, "readiness detects chain tampering"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_appends_extend_one_keyed_chain_without_forking() { + // Every evaluation shares one `EvidenceAuditLog` through an `Arc`, so many + // requests can append at once. The keyed chain must serialize each event's + // prev-hash read with its durable write: if two appends observed the same + // tail hash in parallel they would fork the chain and surface as a + // `ChainForkDetected` error (a spurious 503) or a broken linkage. Drive a + // burst of concurrent appends across worker threads and prove each one + // succeeds and the resulting chain still verifies end to end. + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 256 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + const CONCURRENCY: usize = 16; + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let log = Arc::clone(&log); + handles.push(tokio::spawn(async move { + let event = event(log.as_ref()); + log.append(event).await + })); + } + for handle in handles { + handle + .await + .expect("append task joins") + .expect("a concurrent append never forks the keyed chain"); + } + + assert!( + log.ready().await, + "the chain verifies after concurrent appends" + ); + assert_eq!( + log.startup_verifications(), + 1, + "concurrent appends extend the chain incrementally without rescanning it" + ); + let lines = std::fs::read_to_string(&path) + .expect("audit reads") + .lines() + .count(); + assert_eq!( + lines, CONCURRENCY, + "every concurrent append is durably recorded exactly once" + ); + + // Release the single-writer sink lock before reopening: the sink holds an + // exclusive lock for one writer per file, so a fresh reader can only + // re-verify the chain once this handle is dropped. + drop(log); + + // A fresh reader re-verifies the whole keyed chain from disk, proving the + // prev-hash linkage stayed consistent under concurrent appends. + let reopened = EvidenceAuditLog::initialize( + &path, + 256 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a chain grown under concurrency verifies on restart"); + assert!( + reopened.ready().await, + "the reopened chain verifies end to end" + ); + } + + #[tokio::test] + async fn restart_verifies_a_nonempty_keyed_chain_before_accepting_appends() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let restarted = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a valid nonempty chain verifies on restart"); + assert!(restarted.ready().await); + assert_eq!(restarted.startup_verifications(), 1); + restarted + .append(event(&restarted)) + .await + .expect("verified restarted chain accepts an append"); + } + + #[tokio::test] + async fn restart_rejects_same_length_chain_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let mut external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for corruption"); + external + .seek(SeekFrom::Start(0)) + .and_then(|_| external.write_all(b"[")) + .and_then(|_| external.sync_all()) + .expect("same-length corruption persists"); + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a corrupted keyed chain" + ); + } + + #[tokio::test] + async fn restart_rejects_a_truncated_final_record() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let original_length = std::fs::metadata(&path) + .expect("audit metadata reads") + .len(); + assert!(original_length > 8, "fixture record has truncation room"); + let external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for truncation"); + external + .set_len(original_length - 8) + .and_then(|_| external.sync_all()) + .expect("truncation persists"); + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a truncated keyed chain" + ); + } + + #[tokio::test] + async fn restart_rejects_the_wrong_audit_key() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"fedcba9876543210fedcba9876543210".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a keyed chain under a different audit secret" + ); + } + + #[tokio::test] + async fn same_length_external_mutation_fails_readiness_and_future_appends() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + + std::thread::sleep(std::time::Duration::from_millis(2)); + let mut external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for mutation"); + external + .seek(SeekFrom::Start(0)) + .and_then(|_| external.write_all(b"[")) + .and_then(|_| external.sync_all()) + .expect("same-length mutation persists"); + + assert!(!log.ready().await); + assert!(log.append(event(&log)).await.is_err()); + assert_eq!(log.startup_verifications(), 1); + } + + #[tokio::test] + async fn invalid_release_shape_and_size_limit_fail_closed() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = + EvidenceAuditLog::initialize(&path, 1, b"0123456789abcdef0123456789abcdef".to_vec(), 1) + .await + .expect("audit initializes"); + assert!(log.append(event(&log)).await.is_err()); + + let mut invalid = event(&log); + invalid.phase = AuditPhase::DisclosureRelease; + assert!(matches!( + log.append(invalid).await, + Err(EvidenceAuditError::InvalidEvent) + )); + } + + #[tokio::test] + async fn second_writer_is_rejected() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let first = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("first initializes"); + let second = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await; + assert!(second.is_err()); + drop(first); + } + + #[cfg(unix)] + #[tokio::test] + async fn pathname_replacement_never_redirects_the_pinned_audit_writer() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let displaced = directory.path().join("displaced.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + std::fs::rename(&path, &displaced).expect("initialized file is displaced"); + std::fs::write(&path, b"replacement-canary\n").expect("replacement is created"); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("replacement mode is owner-only"); + + assert!(log.append(event(&log)).await.is_err()); + assert!(!log.ready().await); + assert_eq!( + std::fs::read_to_string(&path).expect("replacement reads"), + "replacement-canary\n" + ); + assert_eq!( + std::fs::read_to_string(&displaced).expect("pinned file reads"), + "" + ); + } + + fn audit_secret() -> AuditHashSecret { + AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) + .expect("audit secret builds") + } + + fn local_access(log: &EvidenceAuditLog, operation: &str) -> EvidenceAuditEvent { + let mut event = EvidenceAuditEvent::new( + AssuranceProfile::Local, + operation.to_owned(), + AuditPhase::AccessAttempt, + "urn:example:requirement:age-bracket:v1".to_owned(), + format!("sha256:{}", "a".repeat(64)), + "benefit:eligibility".to_owned(), + log.pseudonym( + "requester-v1", + "urn:example:trust", + b"raw-requester-token-canary", + ) + .expect("requester pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Delegated, + grant_pseudonym: Some( + log.pseudonym("grant-v1", "urn:example:trust", b"raw-grant-token-canary") + .expect("grant pseudonym builds"), + ), + }, + vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "person-v1".to_owned(), + selector_bundle_pseudonym: Some( + log.pseudonym( + "subject-v1", + "benefit:eligibility", + b"person-id-raw-selector-canary", + ) + .expect("subject pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 4, + ); + event.actor_pseudonym = Some( + log.pseudonym("actor-v1", "urn:example:trust", b"raw-actor-token-canary") + .expect("actor pseudonym builds"), + ); + event.source_id = Some("source-private-canary".to_owned()); + event.adapter_id = Some("adapter-private-canary".to_owned()); + event + } + + fn local_release(access: &EvidenceAuditEvent) -> EvidenceAuditEvent { + let mut release = access.clone(); + release.event_id = format!("urn:ulid:{}", ulid::Ulid::new()); + release.occurred_at = chrono::Utc::now() + .checked_add_signed(chrono::Duration::milliseconds(1)) + .expect("timestamp advances") + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + release.phase = AuditPhase::DisclosureRelease; + release.decision = AuditDecision::Released; + release.disclosed_concepts = Some(vec!["urn:example:concept:age-bracket".to_owned()]); + release.evidence_id = Some(format!("urn:example:evidence:{}", ulid::Ulid::new())); + release.signing_key_id = Some("local-signing-key-1".to_owned()); + release.duration_milliseconds = 19; + release + } + + async fn append_local_operation(log: &EvidenceAuditLog, operation: &str) { + let access = local_access(log, operation); + let release = local_release(&access); + log.append(access).await.expect("access event appends"); + log.append(release).await.expect("release event appends"); + } + + #[tokio::test] + async fn local_inspection_returns_one_closed_two_phase_view() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + let operation = "local-operation-0000000000000001"; + append_local_operation(&log, operation).await; + drop(log); + + let view = verified_last_local_audit_operation(&path, &audit_secret()) + .expect("stopped local chain verifies"); + let value = serde_json::to_value(view).expect("view serializes"); + assert_eq!( + value + .as_object() + .expect("view is an object") + .keys() + .collect::>(), + ["events", "operation", "schema"] + ); + assert_eq!( + value["schema"], + serde_json::json!(LOCAL_AUDIT_OPERATION_VIEW_SCHEMA_V1) + ); + assert_eq!(value["operation"], serde_json::json!(operation)); + let events = value["events"].as_array().expect("events are an array"); + assert_eq!(events.len(), 2); + assert_eq!( + events[0] + .as_object() + .expect("access is an object") + .keys() + .collect::>(), + [ + "decision", + "occurredAt", + "phase", + "purpose", + "requesterPseudonym", + "requirement", + "responseProtection", + ] + ); + assert_eq!(events[0]["phase"], serde_json::json!("access-attempt")); + assert_eq!(events[0]["decision"], serde_json::json!("authorized")); + assert_eq!( + events[1] + .as_object() + .expect("release is an object") + .keys() + .collect::>(), + [ + "decision", + "disclosedConcepts", + "evidenceId", + "occurredAt", + "phase", + "purpose", + "requesterPseudonym", + "requirement", + "responseProtection", + ] + ); + assert_eq!(events[1]["phase"], serde_json::json!("disclosure-release")); + assert_eq!(events[1]["decision"], serde_json::json!("released")); + + let rendered = serde_json::to_string(&value).expect("view renders"); + for forbidden in [ + "assuranceProfile", + "actorPseudonym", + "grantPseudonym", + "subjects", + "selectorProfile", + "selectorBundlePseudonym", + "sourceId", + "adapterId", + "durationMilliseconds", + "signingKeyId", + "bundleRevision", + "raw-requester-token-canary", + "raw-grant-token-canary", + "raw-actor-token-canary", + "person-id-raw-selector-canary", + "source-private-canary", + "adapter-private-canary", + ] { + assert!(!rendered.contains(forbidden), "view disclosed {forbidden}"); + } + } + + #[tokio::test] + async fn local_inspection_selects_the_last_verified_native_operation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + append_local_operation(&log, "local-operation-0000000000000001").await; + append_local_operation(&log, "local-operation-0000000000000002").await; + drop(log); + + let value = serde_json::to_value( + verified_last_local_audit_operation(&path, &audit_secret()) + .expect("stopped local chain verifies"), + ) + .expect("view serializes"); + assert_eq!( + value["operation"], + serde_json::json!("local-operation-0000000000000002") + ); + assert_eq!(value["events"].as_array().map(Vec::len), Some(2)); + } + + #[tokio::test] + async fn local_inspection_verifies_the_full_rotated_keyed_chain() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for index in 0..12 { + append_local_operation(&log, &format!("local-operation-{index:016}")).await; + } + drop(log); + assert!( + audit_segment_paths(&path) + .expect("segments enumerate") + .len() + > 2, + "fixture rotates through sealed history" + ); + + let value = serde_json::to_value( + verified_last_local_audit_operation(&path, &audit_secret()) + .expect("the full keyed chain verifies"), + ) + .expect("view serializes"); + assert_eq!( + value["operation"], + serde_json::json!("local-operation-0000000000000011") + ); + } + + #[tokio::test] + async fn local_inspection_rejects_tampering_wrong_secret_and_live_writer() { + let directory = tempfile::tempdir().expect("temporary directory"); + let live_path = directory.path().join("live.jsonl"); + let live = EvidenceAuditLog::initialize( + &live_path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + append_local_operation(&live, "local-operation-0000000000000001").await; + assert!( + verified_last_local_audit_operation(&live_path, &audit_secret()).is_err(), + "a live writer fails rather than yielding a partial view" + ); + drop(live); + + let wrong = AuditHashSecret::new(b"abcdef0123456789abcdef0123456789".to_vec()) + .expect("wrong secret builds"); + assert!( + verified_last_local_audit_operation(&live_path, &wrong).is_err(), + "a wrong secret yields no view" + ); + + rewrite_segment_line(&live_path, 0, corrupt_line); + assert!( + verified_last_local_audit_operation(&live_path, &audit_secret()).is_err(), + "tampered keyed history yields no view" + ); + } + + #[tokio::test] + async fn local_inspection_rejects_missing_history_and_active_segment() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for index in 0..12 { + append_local_operation(&log, &format!("local-operation-{index:016}")).await; + } + drop(log); + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!(segments.len() > 3, "fixture has a middle sealed segment"); + std::fs::remove_file(&segments[1]).expect("middle segment is removed"); + assert!(matches!( + verified_last_local_audit_operation(&path, &audit_secret()), + Err(EvidenceAuditError::SegmentMissing { sequence: 2 }) + )); + + let active_path = directory.path().join("missing-active.jsonl"); + let active = EvidenceAuditLog::initialize( + &active_path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + append_local_operation(&active, "local-operation-0000000000000001").await; + drop(active); + std::fs::remove_file(&active_path).expect("active segment is removed"); + assert!( + verified_last_local_audit_operation(&active_path, &audit_secret()).is_err(), + "an absent active segment yields no view" + ); + } + + #[tokio::test] + async fn local_inspection_rejects_a_keyed_but_invalid_native_event() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &directory.path().join("pseudonyms.jsonl"), + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("pseudonym helper initializes"); + let mut invalid = local_access(&log, "local-operation-0000000000000001"); + invalid.decision = AuditDecision::Released; + drop(log); + let hasher = AuditChainHasher::keyed(audit_secret()); + let envelope = AuditEnvelope::new_with_hasher( + serde_json::to_value(invalid).expect("invalid event serializes"), + None, + &hasher, + ) + .expect("invalid native event is still keyed"); + std::fs::write(&path, envelope.to_jsonl().expect("envelope renders")) + .expect("audit writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("audit mode is owner-only"); + } + + assert!( + verified_last_local_audit_operation(&path, &audit_secret()).is_err(), + "a valid chain hash cannot bless a non-native event" + ); + } + + #[tokio::test] + async fn local_inspection_fails_instead_of_truncating_at_any_bound() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for index in 0..4 { + append_local_operation(&log, &format!("local-operation-{index:016}")).await; + } + drop(log); + let defaults = LocalAuditInspectionBounds::DEFAULT; + for bounds in [ + LocalAuditInspectionBounds { + maximum_segments: 1, + ..defaults + }, + LocalAuditInspectionBounds { + maximum_records: 1, + ..defaults + }, + LocalAuditInspectionBounds { + maximum_output_bytes: 1, + ..defaults + }, + ] { + assert!( + verified_last_local_audit_operation_with_bounds(&path, &audit_secret(), bounds) + .is_err(), + "a bound failure yields no truncated view" + ); + } + } + + /// Change one byte of a record without changing its length, so the record + /// no longer matches the hash the chain recorded for it. + fn corrupt_line(line: &str) -> String { + let mut bytes = line.as_bytes().to_vec(); + for byte in bytes.iter_mut() { + if byte.is_ascii_lowercase() { + *byte = if *byte == b'z' { b'y' } else { *byte + 1 }; + break; + } + } + String::from_utf8(bytes).expect("a corrupted record stays UTF-8") + } + + fn rewrite_segment_line(path: &Path, index: usize, rewrite: impl Fn(&str) -> String) { + let contents = std::fs::read_to_string(path).expect("segment reads"); + let mut lines: Vec = contents.lines().map(str::to_owned).collect(); + lines[index] = rewrite(&lines[index]); + let mut rewritten = lines.join("\n"); + rewritten.push('\n'); + std::fs::write(path, rewritten).expect("segment rewrites"); + } + + /// Readiness reports on the chain, not on how busy the writer is. The + /// fingerprint it compares is the one the writer advances on every append, + /// so a probe that read it outside the writer's lock would see the + /// service's own traffic as external mutation and flap under load. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn readiness_holds_while_appends_are_in_flight() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut writers = tokio::task::JoinSet::new(); + for _ in 0..8 { + let log = Arc::clone(&log); + let stop = Arc::clone(&stop); + writers.spawn(async move { + while !stop.load(Ordering::Relaxed) { + log.append(event(&log)).await.expect("event appends"); + } + }); + } + + // Probe often enough to land inside a durable write rather than only in + // the gaps between them, which is the window the race lives in. + let mut probes = 0usize; + let mut unready = 0usize; + for _ in 0..200 { + if log.ready().await { + probes += 1; + } else { + unready += 1; + } + tokio::task::yield_now().await; + } + stop.store(true, Ordering::Relaxed); + while let Some(result) = writers.join_next().await { + result.expect("writer task joins"); + } + + assert_eq!( + unready, 0, + "readiness stayed true through {probes} probes but reported unready {unready} times while the service was writing its own audit records" + ); + } + + /// The point of group commit: appends that arrive while a durable write is + /// in flight join the next one instead of each paying their own `fsync`. + #[tokio::test] + async fn concurrent_appends_share_durable_writes() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + const APPENDS: usize = 64; + let mut appends = tokio::task::JoinSet::new(); + for _ in 0..APPENDS { + let log = Arc::clone(&log); + appends.spawn(async move { log.append(event(&log)).await.expect("event appends") }); + } + let mut hashes = Vec::new(); + while let Some(result) = appends.join_next().await { + hashes.push(result.expect("append task joins").record_hash); + } + hashes.sort_unstable(); + hashes.dedup(); + assert_eq!( + hashes.len(), + APPENDS, + "every concurrent append gets its own chain position" + ); + + let writes = log.durable_writes(); + assert!( + writes < APPENDS, + "concurrent appends must share durable writes, saw {writes} for {APPENDS} records" + ); + drop(log); + + let summary = verify_audit_chain(&path, &audit_secret()).expect("chain verifies"); + assert_eq!( + summary.records, APPENDS, + "batching must not drop or duplicate a record" + ); + assert!(summary.active_verified); + } + + /// A durable write that fails leaves the in-memory head ahead of the disk, + /// so the sink must refuse everything afterwards rather than chain onto a + /// record that was never written. + #[tokio::test] + async fn a_failed_durable_write_poisons_the_sink_instead_of_forking_the_chain() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + assert!(log.ready().await); + + // Truncating through a second handle leaves the writer's pinned handle + // valid but the file no longer the one it fingerprinted. + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .expect("external truncation opens"); + + let failed = log.append(event(&log)).await; + assert!( + failed.is_err(), + "an externally modified file fails the write" + ); + + let after = log.append(event(&log)).await; + assert!( + after.is_err(), + "the sink stays failed rather than continuing on a head the disk never received" + ); + assert!( + !log.ready().await, + "a poisoned sink never reports itself ready again" + ); + } + + /// Callers wait for a batch they did not write, so a batch that fails has + /// to hand every one of them the failure. Waiting on a durable write that + /// will never arrive would hang the request that asked for the audit + /// record, which is a worse outcome than refusing it. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_poisoned_sink_fails_concurrent_waiters_instead_of_hanging_them() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + log.append(event(&log)).await.expect("event appends"); + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .expect("external truncation opens"); + assert!( + log.append(event(&log)).await.is_err(), + "an externally modified file fails the write" + ); + + let mut waiters = tokio::task::JoinSet::new(); + for _ in 0..32 { + let log = Arc::clone(&log); + waiters.spawn(async move { log.append(event(&log)).await }); + } + let outcomes = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut outcomes = Vec::new(); + while let Some(result) = waiters.join_next().await { + outcomes.push(result.expect("append task joins")); + } + outcomes + }) + .await + .expect("a poisoned sink answers every waiter rather than hanging one"); + + assert_eq!(outcomes.len(), 32); + assert!( + outcomes.iter().all(Result::is_err), + "every waiter is told the chain stopped, none is handed a position that was never written" + ); + } + + #[tokio::test] + async fn storage_usage_counts_every_segment_and_grows_across_rotation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + let empty = log.storage_usage().await.expect("usage reads"); + assert_eq!( + empty.segments, 1, + "the active segment counts before any append" + ); + assert_eq!(empty.bytes, 0); + + log.append(event(&log)).await.expect("event appends"); + let single = log.storage_usage().await.expect("usage reads"); + assert_eq!(single.segments, 1); + assert!(single.bytes > 0, "an appended record occupies bytes"); + + const RECORDS: usize = 24; + for _ in 0..RECORDS { + log.append(event(&log)).await.expect("event appends"); + } + + let rolled = log.storage_usage().await.expect("usage reads"); + assert!( + rolled.segments > 1, + "a bound smaller than the appended volume must roll at least once" + ); + assert_eq!( + rolled.segments, + audit_segment_paths(&path) + .expect("segments enumerate") + .len(), + "usage counts sealed segments as well as the active one" + ); + assert!( + rolled.bytes > single.bytes, + "sealed history keeps counting toward the footprint after rotation" + ); + + // Retention is the operator's, so archiving a sealed segment must show + // up as a smaller footprint rather than being masked by a counter that + // only ever accumulates. + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let oldest = segments.first().expect("rotation sealed a segment"); + let archived = std::fs::metadata(oldest).expect("sealed metadata").len(); + std::fs::remove_file(oldest).expect("sealed segment archives away"); + + let pruned = log.storage_usage().await.expect("usage reads"); + assert_eq!(pruned.segments, rolled.segments - 1); + assert_eq!(pruned.bytes, rolled.bytes - archived); + } + + /// Append past the per-segment bound and prove the sealed segment and the + /// active segment are one chain, not two independent ones. + #[tokio::test] + async fn appends_rotate_into_sealed_segments_and_the_chain_spans_the_seam() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + const RECORDS: usize = 24; + for _ in 0..RECORDS { + log.append(event(&log)).await.expect("event appends"); + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() > 1, + "a bound smaller than the appended volume must roll at least once" + ); + assert_eq!( + segments.last().expect("an active segment exists"), + &path, + "the configured path stays the active segment" + ); + assert!( + log.ready().await, + "the chain stays ready across its own rotation" + ); + + // Against a live writer the verifier proves sealed history only, rather + // than racing an in-flight append and calling a partial line corruption. + let live = verify_audit_chain(&path, &audit_secret()) + .expect("sealed history verifies while the writer runs"); + assert!(!live.active_verified); + assert_eq!(live.segments, segments.len() - 1); + drop(log); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the chain verifies across every seam"); + assert!(summary.active_verified); + assert_eq!(summary.records, RECORDS, "no record is lost to rotation"); + assert_eq!(summary.segments, segments.len()); + assert_eq!(summary.first_sequence, Some(1)); + assert_eq!(summary.last_sequence, Some(segments.len() as u64 - 1)); + } + + /// Rotation must never be reachable ahead of the pinned-path check, or an + /// external rename would be laundered into a legitimate-looking seal. + #[tokio::test] + async fn pathname_replacement_is_rejected_even_when_the_append_would_rotate() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + // Fill the active segment so the next append is one that would rotate. + while std::fs::metadata(&path) + .expect("active segment reads") + .len() + == 0 + || audit_segment_paths(&path) + .expect("segments enumerate") + .len() + < 2 + { + log.append(event(&log)).await.expect("event appends"); + } + let sealed_before = audit_segment_paths(&path) + .expect("segments enumerate") + .len(); + + let displaced = directory.path().join("displaced.jsonl"); + std::fs::rename(&path, &displaced).expect("the active segment is renamed away"); + std::fs::write(&path, "").expect("a replacement is planted"); + + assert!( + log.append(event(&log)).await.is_err(), + "an append must not continue onto a replaced pathname, rotation or not" + ); + assert!(!log.ready().await); + assert_eq!( + audit_segment_paths(&path) + .expect("segments enumerate") + .len(), + sealed_before, + "a rejected append must not seal anything" + ); + } + + /// A gap in sealed history is reported as a missing segment, not as a hash + /// break, so an operator can tell archival from tampering. + #[tokio::test] + async fn an_archived_middle_segment_is_reported_as_missing_not_as_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(event(&log)).await.expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs a sealed segment that is neither first nor last" + ); + std::fs::remove_file(&segments[1]).expect("a middle segment is archived away"); + + assert!( + matches!( + verify_audit_chain(&path, &audit_secret()), + Err(EvidenceAuditError::SegmentMissing { sequence: 2 }) + ), + "a gap must name the absent sequence rather than look like tampering" + ); + } + + /// A restart after rotation must resume the sealed chain rather than + /// starting a second one. + #[tokio::test] + async fn a_restart_after_rotation_continues_from_the_sealed_tail() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + const BEFORE: usize = 24; + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..BEFORE { + log.append(event(&log)).await.expect("event appends"); + } + assert!( + audit_segment_paths(&path) + .expect("segments enumerate") + .len() + > 1 + ); + } + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a rotated chain verifies on restart"); + restarted + .append(event(&restarted)) + .await + .expect("a restarted rotated chain accepts an append"); + drop(restarted); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the chain verifies after a restart across a seam"); + assert_eq!(summary.records, BEFORE + 1); + } + + /// Crashing between the rename and the creation of the replacement leaves + /// no active segment. Restart must recover the chain head from the sealed + /// tail instead of silently beginning a new chain at genesis. + #[tokio::test] + async fn a_missing_active_segment_recovers_from_the_sealed_tail() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + const BEFORE: usize = 24; + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..BEFORE { + log.append(event(&log)).await.expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!(segments.len() > 1, "the fixture must have rolled"); + let sealed_records: usize = segments[..segments.len() - 1] + .iter() + .map(|segment| { + std::fs::read_to_string(segment) + .expect("sealed segment reads") + .lines() + .count() + }) + .sum(); + std::fs::remove_file(&path).expect("the active segment is lost to a crash"); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a missing active segment is recreated"); + restarted + .append(event(&restarted)) + .await + .expect("appends resume after the active segment is lost"); + drop(restarted); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the recovered chain still spans its seams"); + assert_eq!( + summary.records, + sealed_records + 1, + "the record written after recovery continues sealed history, and the \ + records lost with the active segment are not silently replaced" + ); + assert!( + summary.records < BEFORE + 1, + "the fixture must actually have lost the active segment's records" + ); + assert!( + summary.head.is_some(), + "the recovered chain continues rather than restarting at genesis" + ); + } + + /// The chain head is recovered from the last record of the newest sealed + /// segment, so corrupting that record is caught at startup. + #[tokio::test] + async fn a_corrupt_sealed_tail_is_rejected_at_startup() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(event(&log)).await.expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let newest_sealed = segments[segments.len() - 2].clone(); + let sealed_lines = std::fs::read_to_string(&newest_sealed) + .expect("sealed segment reads") + .lines() + .count(); + rewrite_segment_line(&newest_sealed, sealed_lines - 1, corrupt_line); + + assert!( + EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "a corrupt sealed tail must not be accepted as the chain head" + ); + } + + /// Boot-time verification deliberately covers only the active segment and + /// the sealed tail it chains to, so history is bounded rather than replayed + /// from genesis. This pins the accepted cost: corruption inside an already + /// sealed segment starts the service and is caught by the out-of-band + /// verifier instead. + #[tokio::test] + async fn sealed_segment_corruption_passes_startup_and_fails_the_verifier() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(event(&log)).await.expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let oldest_sealed = segments[0].clone(); + assert!( + std::fs::read_to_string(&oldest_sealed) + .expect("sealed segment reads") + .lines() + .count() + > 1, + "the corrupted record must not be the sealed tail" + ); + rewrite_segment_line(&oldest_sealed, 0, corrupt_line); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("startup does not replay sealed history"); + assert!(restarted.ready().await); + drop(restarted); + + assert!( + verify_audit_chain(&path, &audit_secret()).is_err(), + "the out-of-band verifier is what catches sealed-segment corruption" + ); + } +} diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs new file mode 100644 index 000000000..bb697db11 --- /dev/null +++ b/crates/registry-evidence/src/auth.rs @@ -0,0 +1,991 @@ +//! Strict OIDC access-token authentication and configured claim extraction. + +use std::{ + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::parse_json_strict; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{ + JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, VerifiedToken, +}; +use serde_json::{Map, Value}; +use thiserror::Error; + +use crate::config::{ + AccessTokenAlgorithm, AccessTokenType, AssuranceProfile, AuthenticationConfig, +}; + +const MAX_TOKEN_BYTES: usize = 128 * 1024; +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_CLAIMS_BYTES: usize = 64 * 1024; +const MAX_PRINCIPAL_BYTES: usize = 512; +const MAX_TAGS: usize = 32; + +/// How long an unreachable issuer key set stays quiet after it has been named +/// once. +/// +/// A deployment that cannot reach the key set rejects every request, so the +/// unthrottled report is one log line per request: the fault would bury the +/// traffic that revealed it, and a caller could provoke the writing. One line a +/// minute names it and keeps naming it while it lasts. +const KEY_SOURCE_REPORT_INTERVAL: Duration = Duration::from_secs(60); + +/// How long a failed readiness probe is trusted before another is attempted. +/// +/// Readiness is polled on a schedule the service does not choose. Without this, +/// a probe every second against an issuer that is down is an outbound request +/// every second, from every replica. A successful probe needs no equivalent: +/// the verifier's own cache answers it without a request. +const KEY_SOURCE_PROBE_INTERVAL: Duration = Duration::from_secs(15); + +/// Stands in for the key-set location in the log when there is none to name. +/// +/// Only an in-memory key set reaches this, which no deployment configures. +const STATIC_KEY_SOURCE: &str = ""; + +/// How many causes below the reported error are rendered. +/// +/// `reqwest` reports a transport failure as "error sending request" and keeps +/// the reason underneath it: the connection that was refused, the certificate +/// that did not verify. That reason is the whole diagnosis, and it is two or +/// three levels down. +const REPORTED_CAUSES: usize = 3; + +/// The bound on the rendered cause, which is remote text in part. +const MAX_CAUSE_BYTES: usize = 512; + +#[derive(Debug, Clone)] +pub struct AuthenticationClaimsConfig { + pub principal_claim: String, + pub requester_tags_claim: String, + pub evidence_audience_claim: String, + pub grant_id_claim: String, + pub grant_authority_claim: String, + pub actor_claim: Option, +} + +#[derive(Clone)] +pub struct Authenticator { + verifier: Arc, + claims: AuthenticationClaimsConfig, + key_source: Arc>, +} + +/// What is known about the issuer key set, and when it was last said aloud. +/// +/// Shared rather than copied when the authenticator is cloned, so the two +/// intervals bound the process and not each handle. +#[derive(Debug, Default)] +struct KeySourceState { + /// When the failure was last written to the log. + last_reported: Option, + /// When a readiness probe last failed, and so has not been retried since. + last_failed_probe: Option, +} + +impl std::fmt::Debug for Authenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Authenticator") + .field("claims", &self.claims) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub struct AuthenticatedContext { + principal: String, + actor: Option, + requester_tags: Vec, + evidence_audience: String, + grant_id: Option, + grant_authority: Option, + verified_claims: Value, +} + +impl AuthenticatedContext { + pub fn principal(&self) -> &str { + &self.principal + } + + pub fn actor(&self) -> Option<&str> { + self.actor.as_deref() + } + + pub fn requester_tags(&self) -> &[String] { + &self.requester_tags + } + + pub fn evidence_audience(&self) -> &str { + &self.evidence_audience + } + + pub fn grant_id(&self) -> Option<&str> { + self.grant_id.as_deref() + } + + pub fn grant_authority(&self) -> Option<&str> { + self.grant_authority.as_deref() + } + + pub fn claim_path(&self, path: &str) -> Option<&Value> { + resolve_claim_path(&self.verified_claims, path) + } + + /// Construct a context for the bundle-owned, offline fixture command. + /// + /// This is crate-private so no production caller can bypass token + /// verification. The public fixture harness still runs the normal + /// authorization and selector-resolution functions over this context. + pub(crate) fn offline_fixture_context( + requester_tags: Vec, + evidence_audience: &str, + grant_id: Option<&str>, + grant_authority: Option<&str>, + verified_claims: Value, + ) -> Self { + Self { + principal: "offline-fixture-principal".to_owned(), + actor: None, + requester_tags, + evidence_audience: evidence_audience.to_owned(), + grant_id: grant_id.map(ToOwned::to_owned), + grant_authority: grant_authority.map(ToOwned::to_owned), + verified_claims, + } + } + + #[cfg(test)] + pub(crate) fn test_context( + principal: &str, + requester_tags: Vec, + evidence_audience: &str, + grant_id: Option<&str>, + grant_authority: Option<&str>, + verified_claims: Value, + ) -> Self { + let mut context = Self::offline_fixture_context( + requester_tags, + evidence_audience, + grant_id, + grant_authority, + verified_claims, + ); + context.principal = principal.to_owned(); + context + } +} + +impl std::fmt::Debug for AuthenticatedContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AuthenticatedContext") + .field("principal", &"") + .field("actor", &self.actor.as_ref().map(|_| "")) + .field("requester_tags", &"") + .field("evidence_audience", &self.evidence_audience) + .field("grant_id", &self.grant_id.as_ref().map(|_| "")) + .field( + "grant_authority", + &self.grant_authority.as_ref().map(|_| ""), + ) + .field("verified_claims", &"") + .finish() + } +} + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("access token is malformed")] + Malformed, + #[error("access token verification failed")] + Verification, + #[error("required authenticated context is missing or invalid")] + Context, + #[error("access token is bound to a sender proof this profile cannot validate")] + SenderConstrained, +} + +/// RFC 7800 confirmation claim. Its presence means the authorization server +/// issued a token that is only valid when presented with a matching proof of +/// possession, such as DPoP or mutual TLS. +const CONFIRMATION_CLAIM: &str = "cnf"; + +impl Authenticator { + /// Build the one strict resource-server profile from the loaded bundle. + pub fn from_config(config: &AuthenticationConfig, assurance_profile: AssuranceProfile) -> Self { + let algorithms = config + .algorithms + .iter() + .map(|algorithm| match algorithm { + AccessTokenAlgorithm::EdDSA => jsonwebtoken::Algorithm::EdDSA, + AccessTokenAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, + AccessTokenAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, + }) + .collect(); + let token_types = config + .token_types + .iter() + .map(|token_type| match token_type { + AccessTokenType::AtJwt => "at+jwt".to_owned(), + AccessTokenType::ApplicationAtJwt => "application/at+jwt".to_owned(), + }) + .collect(); + let verifier_config = TokenVerifierConfig::access_token_profile( + config.issuer.clone(), + config.audiences.clone(), + algorithms, + token_types, + ); + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + config.jwks_uri.clone(), + JwksFetcherConfig::defaults(), + jwks_fetch_policy(config, assurance_profile), + )); + let verifier = Arc::new(TokenVerifier::new(verifier_config, fetcher)); + let claims = AuthenticationClaimsConfig { + principal_claim: config.principal_claim.clone(), + requester_tags_claim: config.requester_tags_claim.clone(), + evidence_audience_claim: config.evidence_audience_claim.clone(), + grant_id_claim: config.grant_id_claim.clone(), + grant_authority_claim: config.grant_authority_claim.clone(), + actor_claim: config.actor_claim.clone(), + }; + Self::new(verifier, claims) + } + + pub fn new(verifier: Arc, claims: AuthenticationClaimsConfig) -> Self { + Self { + verifier, + claims, + key_source: Arc::new(Mutex::new(KeySourceState::default())), + } + } + + pub async fn authenticate( + &self, + access_token: &str, + ) -> Result { + strict_jwt_preflight(access_token)?; + let verified = match self.verifier.verify(access_token).await { + Ok(verified) => verified, + Err(error) => { + if is_key_source_failure(&error) { + self.report_key_source_failure(&error); + } + return Err(AuthenticationError::Verification); + } + }; + self.extract_context(verified) + } + + /// Ask the issuer for its key set on a readiness check, and name what comes + /// back, without letting the answer decide readiness. + /// + /// Readiness answers whether this deployment should be sent traffic, and + /// the honest answer during an issuer outage is yes: the verifier keeps + /// serving a key set it cannot recheck for a bounded while, so requests + /// carrying tokens signed by keys already held still succeed. Withholding + /// readiness would take every replica out of rotation at once for a + /// dependency none of them owns, which is the shape of a cascading failure + /// rather than a diagnosis. So the probe reports and the report is the + /// point: an operator watching this deployment is told the issuer has gone + /// quiet while requests still work, and told again when the allowance is + /// what stands between them and rejecting everything. + /// + /// A failed probe is remembered for a short interval, so an orchestrator's + /// polling schedule cannot become this deployment's retry schedule against + /// an issuer that is down. + pub async fn probe_key_source(&self) { + if self.probe_is_suppressed(Instant::now()) { + return; + } + match self.verifier.key_source().ensure_key_set().await { + Ok(()) => { + self.lock_key_source().last_failed_probe = None; + self.report_key_source_outage().await; + } + Err(error) => { + self.report_key_source_failure(&error); + self.lock_key_source().last_failed_probe = Some(Instant::now()); + } + } + } + + /// Attempt the issuer's key set once at startup, and name it if it cannot + /// be had. + /// + /// A misspelled or unreachable `jwksUri` is otherwise discovered one + /// rejected request at a time, and the rejection an operator sees is the + /// same closed `401` a bad token gets. Startup is where an operator is + /// looking, so startup is where it should be said. + /// + /// It reports rather than refuses, for the same reason readiness does: + /// refusing would tie this deployment's start to the issuer's, so a restart + /// during an issuer outage could not come back, and an issuer that starts + /// alongside this service would race it. + pub async fn announce_key_source(&self) { + if let Err(error) = self.verifier.key_source().ensure_key_set().await { + self.report_key_source_failure(&error); + } + } + + /// Whether the last probe failed recently enough to stand for this one. + fn probe_is_suppressed(&self, now: Instant) -> bool { + self.lock_key_source() + .last_failed_probe + .is_some_and(|failed| now.duration_since(failed) < KEY_SOURCE_PROBE_INTERVAL) + } + + /// Name an unreachable issuer key set, at most once per interval. + /// + /// The caller learns nothing from this. Their rejection is the same closed + /// `401` whether the token was bad or this deployment could not check it, + /// which is the right answer to give a caller and the reason the operator + /// has nothing else to go on. The distinction exists only here. + fn report_key_source_failure(&self, error: &OidcError) { + if !self.claim_report_interval(Instant::now()) { + return; + } + tracing::warn!( + target: "registry_evidence::authentication", + jwks_uri = self + .verifier + .key_source() + .jwks_uri() + .unwrap_or(STATIC_KEY_SOURCE), + cause = describe_causes(error), + "the access-token issuer key set could not be retrieved; every request is rejected until it can be" + ); + } + + /// Name a key set that is being served without being confirmed, at most + /// once per interval. + /// + /// Nothing else would say so. The verifier keeps serving a key set it + /// cannot recheck, so requests succeed, readiness holds, and the only + /// thing that has changed is that the issuer has stopped answering. That + /// is worth an operator's attention before the allowance runs out and the + /// deployment starts rejecting everything. + async fn report_key_source_outage(&self) { + let Some(outage) = self.verifier.key_source().outage_duration().await else { + return; + }; + if !self.claim_report_interval(Instant::now()) { + return; + } + tracing::warn!( + target: "registry_evidence::authentication", + jwks_uri = self + .verifier + .key_source() + .jwks_uri() + .unwrap_or(STATIC_KEY_SOURCE), + outage_seconds = outage.as_secs(), + "the access-token issuer key set has not been retrievable; the key set already held is still being accepted, and requests will be rejected once its allowance runs out" + ); + } + + /// Take the reporting interval if it is free, so exactly one caller logs. + fn claim_report_interval(&self, now: Instant) -> bool { + let mut state = self.lock_key_source(); + if state + .last_reported + .is_some_and(|reported| now.duration_since(reported) < KEY_SOURCE_REPORT_INTERVAL) + { + return false; + } + state.last_reported = Some(now); + true + } + + /// Recover the guard even from a poisoned lock: the state behind it is two + /// timestamps, which a panicking holder cannot leave inconsistent, and + /// readiness must not panic because a report once did. + fn lock_key_source(&self) -> MutexGuard<'_, KeySourceState> { + self.key_source + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + fn extract_context( + &self, + verified: VerifiedToken, + ) -> Result { + let claims = + serde_json::to_value(verified.claims).map_err(|_| AuthenticationError::Context)?; + let claims_object = claims.as_object().ok_or(AuthenticationError::Context)?; + + // Version one validates no proof of possession. Treating a + // sender-constrained token as an ordinary bearer would silently discard + // the constraint the authorization server issued it under and make a + // stolen token replayable for its whole lifetime, so the profile denies + // rather than downgrades. + if claims_object.contains_key(CONFIRMATION_CLAIM) { + return Err(AuthenticationError::SenderConstrained); + } + + let principal = required_direct_string( + claims_object, + &self.claims.principal_claim, + MAX_PRINCIPAL_BYTES, + )?; + let evidence_audience = required_direct_string( + claims_object, + &self.claims.evidence_audience_claim, + MAX_PRINCIPAL_BYTES, + )?; + url::Url::parse(&evidence_audience).map_err(|_| AuthenticationError::Context)?; + let requester_tags = + required_string_array(claims_object, &self.claims.requester_tags_claim, MAX_TAGS)?; + let actor = self + .claims + .actor_claim + .as_deref() + .map(|claim| optional_direct_string(claims_object, claim, MAX_PRINCIPAL_BYTES)) + .transpose()? + .flatten(); + let grant_id = optional_direct_string( + claims_object, + &self.claims.grant_id_claim, + MAX_PRINCIPAL_BYTES, + )?; + let grant_authority = optional_direct_string( + claims_object, + &self.claims.grant_authority_claim, + MAX_PRINCIPAL_BYTES, + )?; + if grant_id.is_some() != grant_authority.is_some() { + return Err(AuthenticationError::Context); + } + + Ok(AuthenticatedContext { + principal, + actor, + requester_tags, + evidence_audience, + grant_id, + grant_authority, + verified_claims: claims, + }) + } +} + +fn jwks_fetch_policy( + config: &AuthenticationConfig, + assurance_profile: AssuranceProfile, +) -> FetchUrlPolicy { + if config.uses_local_mint_http(assurance_profile) { + return FetchUrlPolicy { + allowed_schemes: vec!["http".to_owned()], + allow_localhost: true, + allow_http_private_network: false, + deny_private_ranges: true, + deny_cloud_metadata: true, + }; + } + FetchUrlPolicy { + allowed_schemes: vec!["https".to_owned()], + allow_localhost: true, + allow_http_private_network: false, + deny_private_ranges: false, + deny_cloud_metadata: true, + } +} + +/// Whether a verification failure was this deployment's key source rather than +/// the caller's token. +/// +/// Every known failure is listed rather than folded into the wildcard, which +/// covers only variants added to the shared verifier after this was written. +/// Those default to the caller's side: a failure this code has never seen is +/// one it cannot honestly describe as an unreachable key set, and an operator +/// misdirected by a confident wrong message is worse off than one who reads +/// the same `authentication_failed` twice. +fn is_key_source_failure(error: &OidcError) -> bool { + match error { + OidcError::Transport(_) + | OidcError::BoundedRead(_) + | OidcError::FetchUrl(_) + | OidcError::HttpStatus(_) + | OidcError::InvalidUrl + | OidcError::Parse + | OidcError::InvalidJwk + | OidcError::EmptyKeySet + | OidcError::MissingIssuer => true, + OidcError::IssuerMismatch { .. } + | OidcError::MalformedToken + | OidcError::AlgorithmNotAllowed + | OidcError::TokenTypeNotAllowed + | OidcError::MissingKid + | OidcError::KidTooLong + | OidcError::UnknownKid + | OidcError::TokenExpired + | OidcError::TokenNotYetValid + | OidcError::AudienceMismatch + | OidcError::SignatureInvalid + | OidcError::InvalidToken + | OidcError::ClientNotAllowed => false, + _ => false, + } +} + +/// Render an error together with the causes beneath it, bounded. +/// +/// Separated from the logging call so what reaches the log can be asserted +/// directly. Every part of it is either this crate's own text or the transport +/// library's account of a connection to an address the bundle configured; +/// nothing the caller supplied passes through here. +fn describe_causes(error: &dyn std::error::Error) -> String { + let mut rendered = error.to_string(); + let mut cause = error.source(); + let mut remaining = REPORTED_CAUSES; + while let (Some(current), 1..) = (cause, remaining) { + rendered.push_str(": "); + rendered.push_str(¤t.to_string()); + cause = current.source(); + remaining -= 1; + } + if rendered.len() > MAX_CAUSE_BYTES { + let mut end = MAX_CAUSE_BYTES; + while !rendered.is_char_boundary(end) { + end -= 1; + } + rendered.truncate(end); + rendered.push_str("..."); + } + rendered +} + +pub fn strict_jwt_preflight(token: &str) -> Result<(), AuthenticationError> { + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return Err(AuthenticationError::Malformed); + } + let mut segments = token.split('.'); + let header = segments.next().ok_or(AuthenticationError::Malformed)?; + let claims = segments.next().ok_or(AuthenticationError::Malformed)?; + let signature = segments.next().ok_or(AuthenticationError::Malformed)?; + if segments.next().is_some() || header.is_empty() || claims.is_empty() || signature.is_empty() { + return Err(AuthenticationError::Malformed); + } + decode_strict_object(header, MAX_HEADER_BYTES)?; + decode_strict_object(claims, MAX_CLAIMS_BYTES)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| AuthenticationError::Malformed)?; + if signature.is_empty() || signature.len() > MAX_HEADER_BYTES { + return Err(AuthenticationError::Malformed); + } + Ok(()) +} + +fn decode_strict_object(segment: &str, maximum: usize) -> Result<(), AuthenticationError> { + let decoded = URL_SAFE_NO_PAD + .decode(segment) + .map_err(|_| AuthenticationError::Malformed)?; + if decoded.is_empty() || decoded.len() > maximum { + return Err(AuthenticationError::Malformed); + } + let value = parse_json_strict(&decoded).map_err(|_| AuthenticationError::Malformed)?; + if !value.is_object() { + return Err(AuthenticationError::Malformed); + } + Ok(()) +} + +fn required_direct_string( + claims: &Map, + name: &str, + maximum_bytes: usize, +) -> Result { + optional_direct_string(claims, name, maximum_bytes)?.ok_or(AuthenticationError::Context) +} + +fn optional_direct_string( + claims: &Map, + name: &str, + maximum_bytes: usize, +) -> Result, AuthenticationError> { + let Some(value) = claims.get(name) else { + return Ok(None); + }; + let value = value.as_str().ok_or(AuthenticationError::Context)?; + if value.is_empty() || value.len() > maximum_bytes { + return Err(AuthenticationError::Context); + } + Ok(Some(value.to_owned())) +} + +fn required_string_array( + claims: &Map, + name: &str, + maximum_items: usize, +) -> Result, AuthenticationError> { + let values = claims + .get(name) + .and_then(Value::as_array) + .ok_or(AuthenticationError::Context)?; + if values.is_empty() || values.len() > maximum_items { + return Err(AuthenticationError::Context); + } + let mut output = Vec::with_capacity(values.len()); + for value in values { + let value = value.as_str().ok_or(AuthenticationError::Context)?; + if value.is_empty() || value.len() > MAX_PRINCIPAL_BYTES { + return Err(AuthenticationError::Context); + } + output.push(value.to_owned()); + } + output.sort(); + output.dedup(); + Ok(output) +} + +fn resolve_claim_path<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> { + if path.is_empty() || path.len() > 512 { + return None; + } + let mut current = claims; + for segment in path.split('.') { + if !valid_claim_path_segment(segment) { + return None; + } + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +fn valid_claim_path_segment(segment: &str) -> bool { + let mut bytes = segment.bytes(); + matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes.all(|byte| { + byte.is_ascii_uppercase() + || byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn authentication_config() -> AuthenticationConfig { + crate::config::EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("acceptance configuration parses") + .authentication + } + + #[test] + fn jwks_fetch_policy_opens_http_only_for_exact_local_mint() { + let mut exact = authentication_config(); + exact.issuer = "http://127.0.0.1:8081".to_owned(); + exact.jwks_uri = "http://127.0.0.1:8081/.well-known/jwks.json".to_owned(); + let local = jwks_fetch_policy(&exact, AssuranceProfile::Local); + assert_eq!(local.allowed_schemes, ["http"]); + assert!(local.allow_localhost); + assert!(local.deny_private_ranges); + + for (profile, issuer, jwks_uri) in [ + ( + AssuranceProfile::Production, + "http://127.0.0.1:8081", + "http://127.0.0.1:8081/.well-known/jwks.json", + ), + ( + AssuranceProfile::EvidenceGrade, + "http://127.0.0.1:8081", + "http://127.0.0.1:8081/.well-known/jwks.json", + ), + ( + AssuranceProfile::Local, + "http://localhost:8081", + "http://localhost:8081/.well-known/jwks.json", + ), + ( + AssuranceProfile::Local, + "http://127.0.0.2:8081", + "http://127.0.0.2:8081/.well-known/jwks.json", + ), + ( + AssuranceProfile::Local, + "http://127.0.0.1:8081", + "http://127.0.0.1:8082/.well-known/jwks.json", + ), + ] { + let mut candidate = authentication_config(); + candidate.issuer = issuer.to_owned(); + candidate.jwks_uri = jwks_uri.to_owned(); + let policy = jwks_fetch_policy(&candidate, profile); + assert_eq!( + policy.allowed_schemes, + ["https"], + "{profile:?} opened HTTP for {jwks_uri}" + ); + } + } + + fn segment(input: &str) -> String { + URL_SAFE_NO_PAD.encode(input) + } + + #[test] + fn strict_preflight_accepts_three_strict_json_segments() { + let token = format!( + "{}.{}.{}", + segment(r#"{"alg":"EdDSA","kid":"key","typ":"at+jwt"}"#), + segment(r#"{"iss":"https://issuer.invalid","aud":"evidence","exp":2000000000}"#), + URL_SAFE_NO_PAD.encode([1_u8; 64]) + ); + strict_jwt_preflight(&token).expect("token structure is valid"); + } + + #[test] + fn strict_preflight_rejects_duplicate_members_and_bad_compact_shape() { + let duplicate_header = format!( + "{}.{}.{}", + segment(r#"{"alg":"EdDSA","alg":"RS256"}"#), + segment(r#"{"iss":"https://issuer.invalid"}"#), + URL_SAFE_NO_PAD.encode([1_u8; 64]) + ); + assert!(strict_jwt_preflight(&duplicate_header).is_err()); + for token in ["", "a.b", "a.b.c.d", "a..c", " a.b.c"] { + assert!(strict_jwt_preflight(token).is_err()); + } + } + + #[test] + fn claim_paths_are_exact_and_do_not_fallback() { + let claims = serde_json::json!({"grant": {"subject-id": "value"}, "sub": "principal"}); + assert_eq!( + resolve_claim_path(&claims, "grant.subject-id"), + Some(&Value::String("value".to_string())) + ); + assert!(resolve_claim_path(&claims, "grant.missing").is_none()); + assert!(resolve_claim_path(&claims, "grant..subject-id").is_none()); + } + + #[test] + fn key_source_failures_are_separated_from_token_failures() { + // The operator's half: nothing here is anything the caller did, and + // each one leaves the deployment unable to verify any token at all. + for error in [ + OidcError::HttpStatus(503), + OidcError::InvalidUrl, + OidcError::Parse, + OidcError::InvalidJwk, + OidcError::EmptyKeySet, + OidcError::MissingIssuer, + ] { + assert!( + is_key_source_failure(&error), + "{error} is a fault in this deployment's key source" + ); + } + + // The caller's half: reporting these would let a caller write to the + // operator log by presenting bad tokens, and none of them says + // anything about the deployment. + for error in [ + OidcError::MalformedToken, + OidcError::AlgorithmNotAllowed, + OidcError::TokenTypeNotAllowed, + OidcError::MissingKid, + OidcError::KidTooLong, + OidcError::UnknownKid, + OidcError::TokenExpired, + OidcError::TokenNotYetValid, + OidcError::AudienceMismatch, + OidcError::SignatureInvalid, + OidcError::InvalidToken, + OidcError::ClientNotAllowed, + OidcError::IssuerMismatch { + expected: "https://issuer.invalid".to_owned(), + actual: "https://other.invalid".to_owned(), + }, + ] { + assert!( + !is_key_source_failure(&error), + "{error} is a fault in the presented token" + ); + } + } + + #[derive(Debug)] + struct Layered { + message: &'static str, + below: Option>, + } + + impl std::fmt::Display for Layered { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message) + } + } + + impl std::error::Error for Layered { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.below + .as_deref() + .map(|below| below as &(dyn std::error::Error + 'static)) + } + } + + fn layered(messages: &[&'static str]) -> Layered { + let mut layers = messages.iter().rev(); + let mut error = Layered { + message: layers.next().expect("at least one layer"), + below: None, + }; + for message in layers { + error = Layered { + message, + below: Some(Box::new(error)), + }; + } + error + } + + #[test] + fn the_reported_cause_reaches_past_the_summary_to_the_reason() { + // The shape a refused TLS handshake arrives in: the top layer says + // only that a request failed, and the layer an operator needs is + // underneath it. + let described = describe_causes(&layered(&[ + "error sending request", + "connection error", + "invalid peer certificate: UnknownIssuer", + ])); + assert_eq!( + described, + "error sending request: connection error: invalid peer certificate: UnknownIssuer" + ); + } + + #[test] + fn the_reported_cause_is_bounded_in_depth_and_length() { + let deep = describe_causes(&layered(&["first", "second", "third", "fourth", "fifth"])); + assert_eq!(deep, "first: second: third: fourth"); + + let long = describe_causes(&layered(&["x".repeat(4096).leak()])); + assert!( + long.len() <= MAX_CAUSE_BYTES + 3, + "an unbounded remote message reached the log: {} bytes", + long.len() + ); + assert!(long.ends_with("..."), "truncation is not marked: {long}"); + } + + #[test] + fn a_failing_key_source_is_named_once_per_interval() { + let authenticator = Authenticator::new( + Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.invalid".to_owned(), + vec!["urn:example:audience".to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new( + "https://issuer.invalid/jwks".to_owned(), + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ); + + let now = Instant::now(); + assert!( + authenticator.claim_report_interval(now), + "the first failure is always named" + ); + assert!( + !authenticator.claim_report_interval(now + KEY_SOURCE_REPORT_INTERVAL / 2), + "a deployment rejecting every request must not log every request" + ); + assert!( + authenticator.claim_report_interval(now + KEY_SOURCE_REPORT_INTERVAL), + "a fault that lasts keeps being named" + ); + } + + #[test] + fn a_failed_readiness_probe_stands_in_for_the_next_one() { + let authenticator = Authenticator::new( + Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.invalid".to_owned(), + vec!["urn:example:audience".to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new( + "https://issuer.invalid/jwks".to_owned(), + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ); + + let now = Instant::now(); + assert!( + !authenticator.probe_is_suppressed(now), + "nothing is known yet, so the first probe must run" + ); + authenticator.lock_key_source().last_failed_probe = Some(now); + assert!( + authenticator.probe_is_suppressed(now + KEY_SOURCE_PROBE_INTERVAL / 2), + "an orchestrator's polling rate must not become the retry rate" + ); + assert!( + !authenticator.probe_is_suppressed(now + KEY_SOURCE_PROBE_INTERVAL), + "an issuer that comes back must be found" + ); + } + + #[test] + fn authenticated_context_debug_redacts_claim_material() { + let mut context = AuthenticatedContext::test_context( + "principal-canary", + vec!["tag-canary".to_string()], + "urn:example:audience", + Some("grant-canary"), + Some("authority-canary"), + serde_json::json!({"protected": "claim-canary"}), + ); + context.actor = Some("actor-canary".to_string()); + let debug = format!("{context:?}"); + for canary in [ + "principal-canary", + "actor-canary", + "tag-canary", + "grant-canary", + "authority-canary", + "claim-canary", + ] { + assert!(!debug.contains(canary)); + } + } +} diff --git a/crates/registry-evidence/src/binding.rs b/crates/registry-evidence/src/binding.rs new file mode 100644 index 000000000..abac5fdeb --- /dev/null +++ b/crates/registry-evidence/src/binding.rs @@ -0,0 +1,391 @@ +//! Audience-scoped subject and source-entity reference projection. + +use std::fmt; + +use registry_platform_crypto::hmac_sha256_base64url_no_pad; +use thiserror::Error; + +const SUBJECT_DOMAIN: &[u8] = b"registry-evidence/subject-binding/v1"; +const ENTITY_DOMAIN: &[u8] = b"registry-evidence/entity-reference/v1"; +const MIN_KEY_BYTES: usize = 32; +const MAX_COMPONENT_BYTES: usize = 8 * 1024; +const MAX_SELECTOR_FIELDS: usize = 16; +const MAX_ENTITY_SEED_BYTES: usize = 512; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SelectorScalar<'a> { + String(&'a str), + Date(&'a str), + Integer(i64), + Boolean(bool), + ControlledCode(&'a str), +} + +impl fmt::Debug for SelectorScalar<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let form = match self { + Self::String(_) => "string", + Self::Date(_) => "date", + Self::Integer(_) => "integer", + Self::Boolean(_) => "boolean", + Self::ControlledCode(_) => "controlled-code", + }; + formatter + .debug_struct("SelectorScalar") + .field("form", &form) + .field("value", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct SelectorField<'a> { + pub name: &'a str, + pub value: SelectorScalar<'a>, +} + +impl fmt::Debug for SelectorField<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SelectorField") + .field("name", &self.name) + .field("value", &self.value) + .finish() + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum BindingError { + #[error("binding key material is too short")] + WeakKey, + #[error("binding key version is invalid")] + KeyVersion, + #[error("binding input is empty or exceeds its bound")] + Component, + #[error("selector field set is invalid")] + Fields, + #[error("selector date is not canonical")] + Date, + #[error("entity-reference seed is invalid")] + EntitySeed, +} + +pub struct SubjectBindingInput<'a> { + pub trust_domain: &'a str, + pub audience: &'a str, + pub purpose: &'a str, + pub role: &'a str, + pub profile: &'a str, + pub fields: &'a [SelectorField<'a>], +} + +pub fn subject_binding( + key: &[u8], + key_version: u32, + input: SubjectBindingInput<'_>, +) -> Result { + validate_key(key, key_version)?; + if input.fields.is_empty() || input.fields.len() > MAX_SELECTOR_FIELDS { + return Err(BindingError::Fields); + } + + let mut canonical = Vec::new(); + push_bytes(&mut canonical, SUBJECT_DOMAIN)?; + push_u32(&mut canonical, key_version); + push_str(&mut canonical, input.trust_domain)?; + push_str(&mut canonical, input.audience)?; + push_str(&mut canonical, input.purpose)?; + push_str(&mut canonical, input.role)?; + push_str(&mut canonical, input.profile)?; + push_u32( + &mut canonical, + u32::try_from(input.fields.len()).map_err(|_| BindingError::Fields)?, + ); + + for field in input.fields { + push_str(&mut canonical, field.name)?; + match field.value { + SelectorScalar::String(value) => { + canonical.push(0x01); + push_str(&mut canonical, value)?; + } + SelectorScalar::Date(value) => { + if !is_canonical_date(value) { + return Err(BindingError::Date); + } + canonical.push(0x02); + push_str(&mut canonical, value)?; + } + SelectorScalar::Integer(value) => { + canonical.push(0x03); + push_str(&mut canonical, &value.to_string())?; + } + SelectorScalar::Boolean(value) => { + canonical.push(0x04); + push_bytes(&mut canonical, &[u8::from(value)])?; + } + SelectorScalar::ControlledCode(value) => { + canonical.push(0x05); + push_str(&mut canonical, value)?; + } + } + } + + Ok(format!( + "urn:evidence:subject:v{key_version}_{}", + hmac_sha256_base64url_no_pad(key, &canonical) + )) +} + +pub fn entity_reference( + key: &[u8], + key_version: u32, + concept_id: &str, + audience: &str, + seed: &[u8], +) -> Result { + validate_key(key, key_version)?; + if seed.is_empty() || seed.len() > MAX_ENTITY_SEED_BYTES { + return Err(BindingError::EntitySeed); + } + let mut canonical = Vec::new(); + push_bytes(&mut canonical, ENTITY_DOMAIN)?; + push_u32(&mut canonical, key_version); + push_str(&mut canonical, concept_id)?; + push_str(&mut canonical, audience)?; + push_bytes(&mut canonical, seed)?; + Ok(format!( + "urn:evidence:entity:v{key_version}_{}", + hmac_sha256_base64url_no_pad(key, &canonical) + )) +} + +fn validate_key(key: &[u8], key_version: u32) -> Result<(), BindingError> { + if key.len() < MIN_KEY_BYTES { + return Err(BindingError::WeakKey); + } + if key_version == 0 { + return Err(BindingError::KeyVersion); + } + Ok(()) +} + +fn push_str(output: &mut Vec, input: &str) -> Result<(), BindingError> { + push_bytes(output, input.as_bytes()) +} + +fn push_bytes(output: &mut Vec, input: &[u8]) -> Result<(), BindingError> { + if input.is_empty() || input.len() > MAX_COMPONENT_BYTES { + return Err(BindingError::Component); + } + let length = u32::try_from(input.len()).map_err(|_| BindingError::Component)?; + push_u32(output, length); + output.extend_from_slice(input); + Ok(()) +} + +fn push_u32(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn is_canonical_date(value: &str) -> bool { + if value.len() != 10 { + return false; + } + let bytes = value.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return false; + } + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map(|date| date.format("%Y-%m-%d").to_string() == value) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + + fn input<'a>(fields: &'a [SelectorField<'a>]) -> SubjectBindingInput<'a> { + SubjectBindingInput { + trust_domain: "urn:example:trust", + audience: "urn:example:relying-party", + purpose: "enrolment", + role: "subject", + profile: "person-v1", + fields, + } + } + + #[test] + fn subject_binding_is_stable_and_scoped() { + let fields = [ + SelectorField { + name: "name", + value: SelectorScalar::String("Synthetic Person"), + }, + SelectorField { + name: "birth_date", + value: SelectorScalar::Date("2000-02-29"), + }, + ]; + let first = subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"); + let second = subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"); + assert_eq!(first, second); + assert!(first.starts_with("urn:evidence:subject:v1_")); + assert_eq!(first.len(), "urn:evidence:subject:v1_".len() + 43); + + let mut other_audience = input(&fields); + other_audience.audience = "urn:example:other-party"; + assert_ne!( + first, + subject_binding(KEY, 1, other_audience).expect("binding succeeds") + ); + } + + #[test] + fn subject_binding_is_field_order_sensitive() { + let fields = [ + SelectorField { + name: "a", + value: SelectorScalar::Integer(1), + }, + SelectorField { + name: "b", + value: SelectorScalar::Boolean(true), + }, + ]; + let reversed = [fields[1], fields[0]]; + assert_ne!( + subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"), + subject_binding(KEY, 1, input(&reversed)).expect("binding succeeds") + ); + } + + #[test] + fn every_subject_binding_scope_component_is_cryptographically_bound() { + let fields = [SelectorField { + name: "coordinate", + value: SelectorScalar::String("same-bytes"), + }]; + let baseline = subject_binding(KEY, 1, input(&fields)).expect("baseline binding succeeds"); + + let mut changed = input(&fields); + changed.trust_domain = "urn:example:other-trust"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed trust binding succeeds") + ); + let mut changed = input(&fields); + changed.audience = "urn:example:other-audience"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed audience binding succeeds") + ); + let mut changed = input(&fields); + changed.purpose = "other-purpose"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed purpose binding succeeds") + ); + let mut changed = input(&fields); + changed.role = "other-role"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed role binding succeeds") + ); + let mut changed = input(&fields); + changed.profile = "other-profile"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed profile binding succeeds") + ); + assert_ne!( + baseline, + subject_binding(KEY, 2, input(&fields)).expect("changed key version succeeds") + ); + + let changed_name = [SelectorField { + name: "other_coordinate", + value: SelectorScalar::String("same-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_name)).expect("changed name binding succeeds") + ); + let changed_type = [SelectorField { + name: "coordinate", + value: SelectorScalar::ControlledCode("same-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_type)).expect("changed type binding succeeds") + ); + let changed_value = [SelectorField { + name: "coordinate", + value: SelectorScalar::String("other-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_value)).expect("changed value binding succeeds") + ); + } + + #[test] + fn entity_reference_is_audience_and_concept_scoped() { + let first = entity_reference( + KEY, + 2, + "urn:example:concept:person", + "urn:example:audience:a", + b"protected-source-id", + ) + .expect("reference succeeds"); + let second = entity_reference( + KEY, + 2, + "urn:example:concept:person", + "urn:example:audience:b", + b"protected-source-id", + ) + .expect("reference succeeds"); + assert_ne!(first, second); + assert!(first.starts_with("urn:evidence:entity:v2_")); + } + + #[test] + fn invalid_dates_and_weak_keys_are_rejected() { + let fields = [SelectorField { + name: "birth_date", + value: SelectorScalar::Date("2025-02-29"), + }]; + assert_eq!( + subject_binding(KEY, 1, input(&fields)), + Err(BindingError::Date) + ); + assert_eq!( + entity_reference(b"short", 1, "concept", "audience", b"seed"), + Err(BindingError::WeakKey) + ); + } + + #[test] + fn selector_binding_helper_debug_never_exposes_values() { + let fields = [ + SelectorField { + name: "alpha", + value: SelectorScalar::String("selector-debug-canary"), + }, + SelectorField { + name: "delta", + value: SelectorScalar::Integer(8_192_125), + }, + ]; + let diagnostic = format!("{fields:?}"); + assert!(!diagnostic.contains("selector-debug-canary")); + assert!(!diagnostic.contains("8192125")); + assert!(diagnostic.contains("alpha")); + assert!(diagnostic.contains("[REDACTED]")); + } +} diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs new file mode 100644 index 000000000..d5c185421 --- /dev/null +++ b/crates/registry-evidence/src/bundle.rs @@ -0,0 +1,2453 @@ +//! Immutable Evidence Version 1 deployment bundle loading. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{self, File, Metadata}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use jsonschema::{Draft, JSONSchema}; +use rhai::{Engine, AST}; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use serde_norway::Value as YamlValue; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use url::Url; + +use crate::config::{ + ArtifactPath, ConceptForm, EvidenceConfig, OrderedMap, RuntimeConfig, SchemaFault, + SelectorField, +}; + +pub const MAX_BUNDLE_FILES: usize = 1_024; +pub const MAX_BUNDLE_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_ARTIFACT_BYTES: u64 = 1024 * 1024; +pub const MAX_SCRIPT_BYTES: u64 = 64 * 1024; +pub const MAX_PUBLIC_JWK_BYTES: u64 = 64 * 1024; + +const CONFIG_FILE: &str = "evidence.yaml"; +const RUNTIME_FILE: &str = "runtime.yaml"; +const REVISION_DOMAIN: &[u8] = b"registry.evidence.bundle-revision/v1\0"; +const RUNTIME_REVISION_DOMAIN: &[u8] = b"registry.evidence.runtime-revision/v1\0"; +const MAX_CA_BUNDLE_BYTES: u64 = 1024 * 1024; +const ALLOWED_DIRECTORIES: [&str; 6] = [ + "adapters", + "derivations", + "schemas", + "codelists", + "fixtures", + "public-keys", +]; + +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum BundleError { + #[error("the Evidence deployment bundle is unavailable")] + Unavailable, + #[error("an Evidence deployment input is not immutable: {0}")] + NotImmutable(ArtifactFault), + #[error("the Evidence deployment bundle contains an unsupported filesystem entry")] + UnsupportedEntry, + #[error("the Evidence deployment bundle contains a prohibited path")] + InvalidPath, + #[error("the Evidence deployment bundle artifact closure is invalid: {0}")] + UnknownFile(ArtifactFault), + #[error("the Evidence deployment bundle exceeds a Version 1 size bound")] + TooLarge, + #[error("the Evidence deployment configuration is invalid: {0}")] + Config(ArtifactFault), + #[error("an Evidence bundle artifact is invalid: {0}")] + InvalidArtifact(ArtifactFault), + #[error("an Evidence Rhai script is invalid: {0}")] + InvalidScript(ArtifactFault), +} + +impl BundleError { + /// The value-free diagnostic, when this failure identifies one artifact. + /// + /// The remaining variants describe the deployment directory itself and are + /// already specific enough to act on without naming a file. + pub fn artifact_fault(&self) -> Option<&ArtifactFault> { + match self { + Self::Config(fault) + | Self::InvalidArtifact(fault) + | Self::InvalidScript(fault) + | Self::NotImmutable(fault) + | Self::UnknownFile(fault) => Some(fault), + _ => None, + } + } + + /// Name the artifact being loaded when the failure did not already know it. + /// + /// Loaders raise their causes where the cause is known and the artifact is + /// not, so the enclosing per-artifact loop binds the name on the way out. + fn in_artifact(self, artifact: &str) -> Self { + match self { + Self::Config(fault) => Self::Config(fault.bind(artifact)), + Self::InvalidArtifact(fault) => Self::InvalidArtifact(fault.bind(artifact)), + Self::InvalidScript(fault) => Self::InvalidScript(fault.bind(artifact)), + Self::NotImmutable(fault) => Self::NotImmutable(fault.bind(artifact)), + other => other, + } + } +} + +/// A value-free deployment diagnostic bound to one bundle-relative artifact. +/// +/// The artifact name comes from the reviewed bundle layout or from the +/// operator's runtime file name. It is never taken from document content, and +/// the fault it carries is value-free by construction. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ArtifactFault { + artifact: String, + fault: SchemaFault, +} + +impl ArtifactFault { + /// A diagnostic whose artifact is already known. + pub fn new(artifact: impl Into, fault: SchemaFault) -> Self { + Self { + artifact: artifact.into(), + fault, + } + } + + /// A cause raised before the artifact being loaded is in scope. + fn unbound(cause: &'static str) -> Self { + Self { + artifact: String::new(), + fault: SchemaFault::because(cause), + } + } + + /// The bundle-relative artifact, empty when no loader claimed the failure. + pub fn artifact(&self) -> &str { + &self.artifact + } + + pub fn fault(&self) -> &SchemaFault { + &self.fault + } + + fn bind(mut self, artifact: &str) -> Self { + if self.artifact.is_empty() { + self.artifact = artifact.to_owned(); + } + self + } +} + +impl fmt::Display for ArtifactFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.artifact.is_empty() { + return fmt::Display::fmt(&self.fault, formatter); + } + write!(formatter, "artifact {}: {}", self.artifact, self.fault) + } +} + +/// An artifact fault whose artifact the caller binds later. +fn invalid_artifact(cause: &'static str) -> BundleError { + BundleError::InvalidArtifact(ArtifactFault::unbound(cause)) +} + +/// An immutability fault whose artifact the caller binds when it knows one. +fn not_immutable(cause: &'static str) -> BundleError { + BundleError::NotImmutable(ArtifactFault::unbound(cause)) +} + +/// A script fault whose artifact the caller binds later. +fn invalid_script(cause: &'static str) -> BundleError { + BundleError::InvalidScript(ArtifactFault::unbound(cause)) +} + +/// A closure fault naming the artifact when the name is safe to print. +/// +/// Closure names come from the reviewed configuration or from the bundle +/// directory listing. A directory listing is operator input, so a name is +/// quoted only when it matches the reviewed artifact grammar; anything else is +/// reported without a name rather than echoed. +fn unknown_file(candidate: &str, cause: &'static str) -> BundleError { + if safe_artifact_name(candidate) { + BundleError::UnknownFile(ArtifactFault::new(candidate, SchemaFault::because(cause))) + } else { + BundleError::UnknownFile(ArtifactFault::unbound(cause)) + } +} + +/// The reviewed bundle-relative artifact grammar, as a printable-name test. +fn safe_artifact_name(candidate: &str) -> bool { + !candidate.is_empty() + && candidate.len() <= 128 + && candidate + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) +} + +#[derive(Debug, Clone)] +pub struct CompiledScript { + pub source: String, + pub ast: AST, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Codelist { + Codes { + id: String, + version: String, + codes: Vec, + }, + Mapping { + id: String, + version: String, + entries: BTreeMap, + allowed_outputs: Vec, + }, +} + +impl Codelist { + pub fn id(&self) -> &str { + match self { + Self::Codes { id, .. } | Self::Mapping { id, .. } => id, + } + } + + pub fn version(&self) -> &str { + match self { + Self::Codes { version, .. } | Self::Mapping { version, .. } => version, + } + } + + pub fn contains_output(&self, value: &str) -> bool { + match self { + Self::Codes { codes, .. } => codes.iter().any(|code| code == value), + Self::Mapping { + allowed_outputs, .. + } => allowed_outputs.iter().any(|code| code == value), + } + } +} + +/// One fully captured, validated bundle revision. +/// +/// Runtime consumers use these captured bytes and compiled artifacts. They do +/// not reopen the deployment directory, which prevents a later filesystem +/// change from partially replacing the revision used by a serving process. +#[derive(Debug, Clone)] +pub struct Bundle { + root: PathBuf, + pub config: EvidenceConfig, + revision: String, + files: BTreeMap>, + pub scripts: BTreeMap, + pub fact_schemas: BTreeMap, + pub codelists: BTreeMap, + pub fixtures: BTreeMap, + pub retired_public_jwks: BTreeMap, +} + +/// One captured operator runtime configuration and its bound trust anchors. +/// +/// Secret values and audit contents are deliberately not captured. The +/// runtime digest covers only the reviewed runtime YAML and private-CA bytes. +#[derive(Debug, Clone)] +pub struct RuntimeDocument { + path: PathBuf, + pub config: RuntimeConfig, + revision: String, + bytes: Vec, + pub ca_bundles: BTreeMap>, +} + +impl RuntimeDocument { + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.file_name().and_then(|name| name.to_str()) != Some(RUNTIME_FILE) { + return Err(BundleError::InvalidPath); + } + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(BundleError::InvalidPath); + } + let filesystem_read_only = filesystem_is_read_only(path)?; + let writable_runtime = "the runtime file is writable"; + validate_read_only(&metadata, filesystem_read_only, writable_runtime) + .map_err(|error| error.in_artifact(RUNTIME_FILE))?; + let bytes = read_stable_file( + path, + &metadata, + crate::config::MAX_CONFIG_BYTES as u64, + filesystem_read_only, + writable_runtime, + ) + .map_err(|error| error.in_artifact(RUNTIME_FILE))?; + let config = RuntimeConfig::parse_yaml(&bytes).map_err(|error| { + BundleError::Config(ArtifactFault::new(RUNTIME_FILE, error.fault())) + })?; + validate_secret_root(Path::new(&config.secret_providers.file.root))?; + + let mut ca_bundles = BTreeMap::new(); + for (profile, binding) in config.outbound_tls.trust_profiles.iter() { + let ca_path = Path::new(&binding.ca_bundle_file); + let ca_metadata = + fs::symlink_metadata(ca_path).map_err(|_| BundleError::Unavailable)?; + if ca_metadata.file_type().is_symlink() || !ca_metadata.is_file() { + return Err(BundleError::InvalidPath); + } + let ca_filesystem_read_only = filesystem_is_read_only(ca_path)?; + let writable_ca = "the TLS CA bundle the runtime file names is writable"; + validate_read_only(&ca_metadata, ca_filesystem_read_only, writable_ca)?; + let ca_bytes = read_stable_file( + ca_path, + &ca_metadata, + MAX_CA_BUNDLE_BYTES, + ca_filesystem_read_only, + writable_ca, + )?; + validate_ca_bundle(&ca_bytes).map_err(|error| error.in_artifact(RUNTIME_FILE))?; + ca_bundles.insert(profile.to_owned(), ca_bytes); + } + let revision = compute_runtime_revision(&bytes, &ca_bundles)?; + Ok(Self { + path: path.to_path_buf(), + config, + revision, + bytes, + ca_bundles, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// The two independently captured, closed startup inputs. +#[derive(Debug, Clone)] +pub struct DeploymentInputs { + pub bundle: Bundle, + pub runtime: RuntimeDocument, +} + +impl DeploymentInputs { + pub fn load(runtime_path: impl AsRef) -> Result { + let runtime = RuntimeDocument::load(runtime_path)?; + let bundle = Bundle::load(&runtime.config.bundle_directory)?; + validate_runtime_bindings(&bundle.config, &runtime.config)?; + Ok(Self { bundle, runtime }) + } +} + +pub type EvidenceBundle = Bundle; + +impl Bundle { + pub fn load(root: impl AsRef) -> Result { + let root = root.as_ref(); + let files = capture_bundle_files(root)?; + let config_bytes = files.get(CONFIG_FILE).ok_or(BundleError::Unavailable)?; + let config = EvidenceConfig::parse_yaml(config_bytes) + .map_err(|error| BundleError::Config(ArtifactFault::new(CONFIG_FILE, error.fault())))?; + validate_file_closure(&config, &files)?; + + let scripts = load_scripts(&config, &files)?; + let fact_schemas = load_fact_schemas(&config, &files)?; + let codelists = load_codelists(&config, &files)?; + validate_codelist_references(&config, &codelists)?; + let fixtures = load_fixtures(&config, &files)?; + let retired_public_jwks = load_retired_public_jwks(&config, &files)?; + let revision = compute_revision(&files)?; + + Ok(Self { + root: root.to_path_buf(), + config, + revision, + files, + scripts, + fact_schemas, + codelists, + fixtures, + retired_public_jwks, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn configuration_revision(&self) -> &str { + &self.revision + } + + pub fn revision(&self) -> &str { + self.configuration_revision() + } + + pub fn artifact(&self, path: &str) -> Option<&[u8]> { + self.files.get(path).map(Vec::as_slice) + } + + pub fn script(&self, path: &ArtifactPath) -> Option<&CompiledScript> { + self.scripts.get(path.as_str()) + } + + pub fn fact_schema(&self, path: &ArtifactPath) -> Option<&JsonValue> { + self.fact_schemas.get(path.as_str()) + } + + pub fn codelist(&self, path: &ArtifactPath) -> Option<&Codelist> { + self.codelists.get(path.as_str()) + } + + pub fn fixture(&self, path: &ArtifactPath) -> Option<&YamlValue> { + self.fixtures.get(path.as_str()) + } +} + +pub fn load_bundle(root: impl AsRef) -> Result { + Bundle::load(root) +} + +fn capture_bundle_files(root: &Path) -> Result>, BundleError> { + let root_metadata = fs::symlink_metadata(root).map_err(|_| BundleError::Unavailable)?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(BundleError::InvalidPath); + } + let filesystem_read_only = filesystem_is_read_only(root)?; + validate_read_only( + &root_metadata, + filesystem_read_only, + "the bundle directory is writable", + )?; + let canonical_root = fs::canonicalize(root).map_err(|_| BundleError::Unavailable)?; + let mut paths = Vec::new(); + collect_paths( + root, + root, + &canonical_root, + filesystem_read_only, + &mut paths, + )?; + paths.sort_by(|left, right| left.0.cmp(&right.0)); + if paths.len() > MAX_BUNDLE_FILES { + return Err(BundleError::TooLarge); + } + + let mut files = BTreeMap::new(); + let mut total = 0_u64; + for (relative, path, scanned_metadata) in paths { + let cap = file_size_cap(&relative); + let bytes = read_stable_file( + &path, + &scanned_metadata, + cap, + filesystem_read_only, + "the bundle artifact is writable", + ) + .map_err(|error| error.in_artifact(&relative))?; + total = total + .checked_add(u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)?) + .ok_or(BundleError::TooLarge)?; + if total > MAX_BUNDLE_BYTES { + return Err(BundleError::TooLarge); + } + files.insert(relative, bytes); + } + if !files.contains_key(CONFIG_FILE) { + return Err(BundleError::Unavailable); + } + Ok(files) +} + +fn collect_paths( + root: &Path, + directory: &Path, + canonical_root: &Path, + filesystem_read_only: bool, + files: &mut Vec<(String, PathBuf, Metadata)>, +) -> Result<(), BundleError> { + let mut entries = fs::read_dir(directory) + .map_err(|_| BundleError::Unavailable)? + .collect::, _>>() + .map_err(|_| BundleError::Unavailable)?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() { + return Err(BundleError::InvalidPath); + } + let relative_path = path + .strip_prefix(root) + .map_err(|_| BundleError::InvalidPath)?; + let relative = path_to_bundle_string(relative_path)?; + // Named after the relative path is known, so a writable artifact says + // which one it is. A path the bundle grammar refuses is refused as a + // path first; both fail closed, and neither reaches the caller unread. + validate_read_only( + &metadata, + filesystem_read_only, + if metadata.is_dir() { + "the bundle directory is writable" + } else { + "the bundle artifact is writable" + }, + ) + .map_err(|error| error.in_artifact(&relative))?; + let top = relative.split('/').next().ok_or(BundleError::InvalidPath)?; + if directory == root { + if metadata.is_dir() { + if !ALLOWED_DIRECTORIES.contains(&top) { + return Err(BundleError::InvalidPath); + } + } else if relative != CONFIG_FILE { + return Err(unknown_file( + &relative, + "bundle root contains a file other than the configuration", + )); + } + } else if !ALLOWED_DIRECTORIES.contains(&top) { + return Err(BundleError::InvalidPath); + } + + let canonical = fs::canonicalize(&path).map_err(|_| BundleError::Unavailable)?; + if !canonical.starts_with(canonical_root) { + return Err(BundleError::InvalidPath); + } + if metadata.is_dir() { + collect_paths(root, &path, canonical_root, filesystem_read_only, files)?; + } else if metadata.is_file() { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if metadata.nlink() != 1 { + return Err(BundleError::InvalidPath); + } + } + files.push((relative, path, metadata)); + } else { + return Err(BundleError::UnsupportedEntry); + } + } + Ok(()) +} + +fn path_to_bundle_string(path: &Path) -> Result { + let value = path.to_str().ok_or(BundleError::InvalidPath)?; + if value.is_empty() + || value.starts_with('/') + || value.contains('\\') + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(BundleError::InvalidPath); + } + Ok(value.to_owned()) +} + +/// Refuse a writable deployment input, naming which input it was. +/// +/// Every caller passes its own cause because the classes are not +/// interchangeable to whoever has to fix one: a writable bundle artifact is +/// re-frozen with the documented `chmod`, while a writable runtime file or CA +/// bundle sits outside the bundle entirely and re-freezing changes nothing. +#[cfg(unix)] +fn validate_read_only( + metadata: &Metadata, + filesystem_read_only: bool, + cause: &'static str, +) -> Result<(), BundleError> { + use std::os::unix::fs::PermissionsExt as _; + if !filesystem_read_only && metadata.permissions().mode() & 0o222 != 0 { + Err(not_immutable(cause)) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn validate_read_only( + metadata: &Metadata, + filesystem_read_only: bool, + cause: &'static str, +) -> Result<(), BundleError> { + if filesystem_read_only || metadata.permissions().readonly() { + Ok(()) + } else { + Err(not_immutable(cause)) + } +} + +#[cfg(unix)] +fn filesystem_is_read_only(path: &Path) -> Result { + let status = rustix::fs::statvfs(path).map_err(|_| BundleError::Unavailable)?; + Ok(status + .f_flag + .contains(rustix::fs::StatVfsMountFlags::RDONLY)) +} + +#[cfg(not(unix))] +fn filesystem_is_read_only(_path: &Path) -> Result { + Ok(false) +} + +fn file_size_cap(path: &str) -> u64 { + if path.starts_with("adapters/") || path.starts_with("derivations/") { + MAX_SCRIPT_BYTES + } else if path.starts_with("public-keys/") { + MAX_PUBLIC_JWK_BYTES + } else { + MAX_ARTIFACT_BYTES + } +} + +fn read_stable_file( + path: &Path, + scanned: &Metadata, + cap: u64, + filesystem_read_only: bool, + writable_cause: &'static str, +) -> Result, BundleError> { + if scanned.len() > cap { + return Err(BundleError::TooLarge); + } + let mut file = open_no_follow(path)?; + let opened = file.metadata().map_err(|_| BundleError::Unavailable)?; + validate_read_only(&opened, filesystem_read_only, writable_cause)?; + if !opened.is_file() || !same_file(scanned, &opened) || opened.len() > cap { + return Err(not_immutable( + "the file was replaced between the directory scan and opening it", + )); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(cap.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| BundleError::Unavailable)?; + if u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)? > cap { + return Err(BundleError::TooLarge); + } + let after = file.metadata().map_err(|_| BundleError::Unavailable)?; + if !same_file(&opened, &after) + || after.len() != u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)? + { + return Err(not_immutable("the file changed while it was being read")); + } + Ok(bytes) +} + +#[cfg(unix)] +fn open_no_follow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| BundleError::Unavailable)?; + Ok(File::from(descriptor)) +} + +#[cfg(not(unix))] +fn open_no_follow(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() { + return Err(BundleError::InvalidPath); + } + File::open(path).map_err(|_| BundleError::Unavailable) +} + +#[cfg(unix)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + left.dev() == right.dev() + && left.ino() == right.ino() + && left.len() == right.len() + && left.permissions().mode() == right.permissions().mode() +} + +#[cfg(not(unix))] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +fn validate_file_closure( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result<(), BundleError> { + let mut expected = BTreeSet::from([CONFIG_FILE.to_owned()]); + for (_, source) in config.sources.iter() { + expected.insert(source.request.prepare_script.as_str().to_owned()); + expected.insert(source.extract_script.as_str().to_owned()); + expected.insert(source.request.adapter_parameters_schema.as_str().to_owned()); + expected.insert(source.response_schema.as_str().to_owned()); + expected.insert(source.fact_schema.as_str().to_owned()); + } + for requirement in &config.requirements { + expected.insert(requirement.derivation.script.as_str().to_owned()); + if let Some(fixtures) = &requirement.fixtures { + expected.insert(fixtures.as_str().to_owned()); + } + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::ControlledCode + | ConceptForm::ControlledCategory + | ConceptForm::ControlledCodeList + ) { + expected.insert(concept_codelist_path(&concept.constraints)?.to_owned()); + } + } + } + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { codelist, .. } = field { + expected.insert(codelist.as_str().to_owned()); + } + } + } + for path in &config.signing.retired_public_jwk_files { + expected.insert(path.as_str().to_owned()); + } + expected.extend(reviewed_schema_paths(config, files)?); + expected.extend(reviewed_bucket_codelist_paths(config, files)?); + let present: BTreeSet<&str> = files.keys().map(String::as_str).collect(); + let referenced: BTreeSet<&str> = expected.iter().map(String::as_str).collect(); + if let Some(missing) = referenced.difference(&present).next() { + return Err(unknown_file( + missing, + "the configuration references an artifact the bundle does not contain", + )); + } + if let Some(unreferenced) = present.difference(&referenced).next() { + return Err(unknown_file( + unreferenced, + "the bundle contains an artifact the configuration does not reference", + )); + } + Ok(()) +} + +fn reviewed_bucket_codelist_paths( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let declarations = config + .requirements + .iter() + .flat_map(|requirement| &requirement.concepts) + .filter(|concept| { + matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) + }) + .map(|concept| { + Ok(( + concept_constraint_string(&concept.constraints, "bucketScheme")?, + concept_constraint_string(&concept.constraints, "schemeVersion")?, + )) + }) + .collect::, BundleError>>()?; + let mut paths = BTreeSet::new(); + for (identifier, version) in declarations { + let matches = files + .iter() + .filter(|(path, _)| path.starts_with("codelists/")) + .filter_map(|(path, bytes)| { + let document = std::str::from_utf8(bytes) + .ok() + .and_then(|text| serde_norway::from_str::(text).ok())?; + let mapping = document.as_mapping()?; + (mapping.get("id").and_then(YamlValue::as_str) == Some(identifier) + && mapping.get("version").and_then(YamlValue::as_str) == Some(version)) + .then(|| path.clone()) + }) + .collect::>(); + if matches.len() != 1 { + return Err(invalid_artifact( + "bucket scheme codelist is missing or ambiguous", + )); + } + paths.insert(matches[0].clone()); + } + Ok(paths) +} + +fn reviewed_schema_paths( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let identifiers = config + .requirements + .iter() + .flat_map(|requirement| &requirement.concepts) + .filter(|concept| concept.form == ConceptForm::ReviewedStructuredValue) + .map(|concept| concept_constraint_string(&concept.constraints, "schema")) + .collect::, _>>()?; + let mut paths = BTreeSet::new(); + for identifier in identifiers { + let matches = files + .iter() + .filter(|(path, _)| path.starts_with("schemas/")) + .filter_map(|(path, bytes)| { + let document = std::str::from_utf8(bytes) + .ok() + .and_then(|text| serde_norway::from_str::(text).ok())?; + (document.get("$id").and_then(JsonValue::as_str) == Some(identifier)) + .then(|| path.clone()) + }) + .collect::>(); + if matches.len() != 1 { + return Err(invalid_artifact( + "reviewed structured schema identifier is missing or ambiguous", + )); + } + paths.insert(matches[0].clone()); + } + Ok(paths) +} + +fn load_scripts( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut expected = BTreeMap::new(); + for (_, source) in config.sources.iter() { + insert_script_contract( + &mut expected, + source.request.prepare_script.as_str(), + ("prepare", 2), + )?; + insert_script_contract( + &mut expected, + source.extract_script.as_str(), + ("extract", 2), + )?; + } + for requirement in &config.requirements { + insert_script_contract( + &mut expected, + requirement.derivation.script.as_str(), + ("derive", 3), + )?; + } + let mut scripts = BTreeMap::new(); + for (path, (entrypoint, arity)) in expected { + let script = compile_script(path, entrypoint, arity, files) + .map_err(|error| error.in_artifact(path))?; + scripts.insert(path.to_owned(), script); + } + Ok(scripts) +} + +fn compile_script( + path: &str, + entrypoint: &'static str, + arity: usize, + files: &BTreeMap>, +) -> Result { + let bytes = files.get(path).ok_or(invalid_artifact("missing script"))?; + let source = std::str::from_utf8(bytes) + .map_err(|_| invalid_artifact("script is not UTF-8"))? + .to_owned(); + reject_prohibited_script_capabilities(&source)?; + let mut engine = Engine::new(); + engine.set_max_expr_depths(64, 64); + engine.set_max_call_levels(32); + engine.set_max_operations(100_000); + engine.set_max_array_size(256); + engine.set_max_map_size(256); + engine.set_max_string_size(16_384); + engine.set_max_modules(0); + let ast = engine + .compile(&source) + .map_err(|_| invalid_script("script does not compile"))?; + let entrypoint_functions = ast + .iter_functions() + .filter(|function| function.name == entrypoint) + .map(|function| function.params.len()) + .collect::>(); + if entrypoint_functions != [arity] { + return Err(invalid_script( + "script does not declare exactly one entrypoint with the required arity", + )); + } + Ok(CompiledScript { source, ast }) +} + +fn insert_script_contract<'a>( + scripts: &mut BTreeMap<&'a str, (&'static str, usize)>, + path: &'a str, + contract: (&'static str, usize), +) -> Result<(), BundleError> { + if scripts + .insert(path, contract) + .is_some_and(|existing| existing != contract) + { + return Err(invalid_artifact( + "one script path is assigned incompatible entry points", + )); + } + Ok(()) +} + +fn reject_prohibited_script_capabilities(source: &str) -> Result<(), BundleError> { + const PROHIBITED: [&str; 14] = [ + "import", + "eval", + "print", + "debug", + "get_env", + "environment", + "filesystem", + "network", + "process", + "random", + "Fn", + "call", + "curry", + "is_def_fn", + ]; + let mut identifier = String::new(); + let mut chars = source.chars().peekable(); + let mut quote = None; + while let Some(character) = chars.next() { + if let Some(delimiter) = quote { + if character == '\\' { + chars.next(); + } else if character == delimiter { + quote = None; + } + continue; + } + if matches!(character, '\'' | '"' | '`') { + quote = Some(character); + continue; + } + if character == '/' && chars.peek() == Some(&'/') { + chars.next(); + for next in chars.by_ref() { + if next == '\n' { + break; + } + } + continue; + } + if character.is_ascii_alphanumeric() || character == '_' { + identifier.push(character); + } else if !identifier.is_empty() { + if PROHIBITED.contains(&identifier.as_str()) { + return Err(invalid_script("script uses a prohibited capability")); + } + identifier.clear(); + } + } + if PROHIBITED.contains(&identifier.as_str()) { + return Err(invalid_script("script uses a prohibited capability")); + } + Ok(()) +} + +/// Which contract one bundle schema artifact carries. Configuration keeps the +/// three roles disjoint, so every path resolves to exactly one of them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SchemaRole { + /// Closed startup parameters; the empty parameter set is legitimate. + AdapterParameters, + /// Shape of one projected source response. Projection drops a missing + /// selected leaf, so a declared property may legitimately be absent. + Response, + /// Closed fact set handed to derivation, and the reviewed concept schemas held + /// to the same rule; every declared property is required. + Facts, +} + +fn load_fact_schemas( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let parameter_paths = config + .sources + .iter() + .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) + .collect::>(); + let response_paths = config + .sources + .iter() + .map(|(_, source)| source.response_schema.as_str()) + .collect::>(); + let mut paths = config + .sources + .iter() + .flat_map(|(_, source)| { + [ + source.fact_schema.as_str(), + source.response_schema.as_str(), + source.request.adapter_parameters_schema.as_str(), + ] + }) + .map(ToOwned::to_owned) + .collect::>(); + paths.extend(reviewed_schema_paths(config, files)?); + let mut schemas = BTreeMap::new(); + for path in paths { + let role = if parameter_paths.contains(path.as_str()) { + SchemaRole::AdapterParameters + } else if response_paths.contains(path.as_str()) { + SchemaRole::Response + } else { + SchemaRole::Facts + }; + let schema = + load_fact_schema(&path, role, files).map_err(|error| error.in_artifact(&path))?; + schemas.insert(path, schema); + } + for (_, source) in config.sources.iter() { + let schema_path = source.request.adapter_parameters_schema.as_str(); + validate_adapter_parameters(source, &schemas) + .map_err(|error| error.in_artifact(schema_path))?; + } + Ok(schemas) +} + +fn load_fact_schema( + path: &str, + role: SchemaRole, + files: &BTreeMap>, +) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("missing fact schema"))?; + let text = + std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fact schema is not UTF-8"))?; + let schema: JsonValue = serde_norway::from_str(text) + .map_err(|_| invalid_artifact("fact schema YAML is invalid"))?; + validate_closed_schema(&schema, role)?; + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .map_err(|_| invalid_artifact("fact schema is not valid JSON Schema"))?; + Ok(schema) +} + +fn validate_adapter_parameters( + source: &crate::config::SourceConfig, + schemas: &BTreeMap, +) -> Result<(), BundleError> { + let schema = schemas + .get(source.request.adapter_parameters_schema.as_str()) + .ok_or(invalid_artifact("missing adapter-parameter schema"))?; + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .map_err(|_| invalid_artifact("adapter-parameter schema is not valid JSON Schema"))?; + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| invalid_artifact("adapter parameters are not JSON-compatible"))?; + if !compiled.is_valid(¶meters) { + return Err(invalid_artifact( + "adapter parameters do not satisfy their closed schema", + )); + } + Ok(()) +} + +fn validate_closed_schema(schema: &JsonValue, role: SchemaRole) -> Result<(), BundleError> { + let allow_empty_root = role == SchemaRole::AdapterParameters; + let root = schema + .as_object() + .ok_or(invalid_artifact("fact schema must be an object"))?; + if root.get("type").and_then(JsonValue::as_str) != Some("object") + || root + .get("additionalProperties") + .and_then(JsonValue::as_bool) + != Some(false) + { + return Err(invalid_artifact("fact schema must close the root object")); + } + let properties = root + .get("properties") + .and_then(JsonValue::as_object) + .ok_or(invalid_artifact("fact schema must declare properties"))?; + if (!allow_empty_root && properties.is_empty()) || properties.len() > 64 { + return Err(invalid_artifact("fact schema property count is invalid")); + } + let required = root + .get("required") + .and_then(JsonValue::as_array) + .ok_or(invalid_artifact("fact schema must declare required fields"))?; + let required = required + .iter() + .map(JsonValue::as_str) + .collect::>>() + .ok_or(invalid_artifact("fact schema required fields are invalid"))?; + if required + .iter() + .any(|field| !properties.contains_key(*field)) + || (role != SchemaRole::Response + && properties + .keys() + .any(|property| !required.contains(property.as_str()))) + { + return Err(invalid_artifact( + "fact schema must require its exact closed field set", + )); + } + validate_schema_node(schema, role) +} + +/// Reads the one type a schema node declares, and returns `None` for a node that +/// declares a bounded const instead. +/// +/// A response schema may write that type as the pair `[T, "null"]`. Sources do +/// report an explicit null where they hold no value, and the projection carries +/// that null through verbatim, so a response shape has to be able to say so. The +/// pair is the only union the subset admits, and only in the response role: a +/// fact or an adapter parameter is never null. `null` reaches the script as the +/// same unit marker `is_missing` already reads, so one script test covers both an +/// absent leaf and an explicitly null one. +fn schema_node_type( + object: &JsonMap, + role: SchemaRole, +) -> Result, BundleError> { + match object.get("type") { + Some(JsonValue::String(name)) => Ok(Some(name.as_str())), + Some(JsonValue::Array(members)) => { + let [JsonValue::String(name), JsonValue::String(null_member)] = members.as_slice() + else { + return Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )); + }; + if role != SchemaRole::Response || null_member != "null" || name == "null" { + return Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )); + } + Ok(Some(name.as_str())) + } + Some(_) => Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )), + None => Ok(None), + } +} + +fn validate_schema_node(node: &JsonValue, role: SchemaRole) -> Result<(), BundleError> { + let object = node + .as_object() + .ok_or(invalid_artifact("every schema node must be a typed object"))?; + let Some(value_type) = schema_node_type(object, role)? else { + if object + .keys() + .all(|key| matches!(key.as_str(), "$schema" | "$id" | "const")) + && object.get("const").is_some_and(schema_const_is_bounded) + { + return Ok(()); + } + return Err(invalid_artifact( + "every schema node must declare one type or one bounded const", + )); + }; + let allowed = match value_type { + "object" => &[ + "$schema", + "$id", + "type", + "additionalProperties", + "required", + "properties", + ][..], + "array" => &[ + "$schema", + "$id", + "type", + "minItems", + "maxItems", + "uniqueItems", + "items", + "const", + ][..], + "string" => &[ + "$schema", + "$id", + "type", + "minLength", + "maxLength", + "format", + "enum", + "const", + ][..], + "integer" => &[ + "$schema", "$id", "type", "minimum", "maximum", "enum", "const", + ][..], + "boolean" => &["$schema", "$id", "type", "enum", "const"][..], + _ => { + return Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )); + } + }; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(invalid_artifact( + "schema node uses a keyword outside the closed Version 1 subset", + )); + } + + match value_type { + "object" => { + if object + .get("additionalProperties") + .and_then(JsonValue::as_bool) + != Some(false) + { + return Err(invalid_artifact("nested schema objects must be closed")); + } + let properties = object + .get("properties") + .and_then(JsonValue::as_object) + .filter(|properties| !properties.is_empty() && properties.len() <= 64) + .ok_or(invalid_artifact( + "schema objects must declare bounded properties", + ))?; + let required = object + .get("required") + .and_then(JsonValue::as_array) + .and_then(|required| { + required + .iter() + .map(JsonValue::as_str) + .collect::>>() + }) + .ok_or(invalid_artifact( + "schema objects must declare required properties", + ))?; + if required + .iter() + .any(|field| !properties.contains_key(*field)) + || (role != SchemaRole::Response + && properties + .keys() + .any(|property| !required.contains(property.as_str()))) + { + return Err(invalid_artifact( + "schema objects must require their exact property set", + )); + } + for property in properties.values() { + validate_schema_node(property, role)?; + } + } + "array" => { + if object + .get("uniqueItems") + .is_some_and(|value| value.as_bool() != Some(true)) + { + return Err(invalid_artifact("schema array uniqueness flag is invalid")); + } + if let Some(value) = object.get("const") { + if !value.is_array() || !schema_const_is_bounded(value) { + return Err(invalid_artifact("schema array const is invalid")); + } + } + let maximum = object + .get("maxItems") + .and_then(JsonValue::as_u64) + .ok_or(invalid_artifact("schema arrays must be bounded"))?; + if maximum == 0 || maximum > 256 { + return Err(invalid_artifact("schema array bound is invalid")); + } + if object + .get("minItems") + .and_then(JsonValue::as_u64) + .is_some_and(|minimum| minimum > maximum) + { + return Err(invalid_artifact("schema array bounds are invalid")); + } + validate_schema_node( + object + .get("items") + .ok_or(invalid_artifact("schema arrays must close their item type"))?, + role, + )?; + } + "string" => { + if object + .get("format") + .and_then(JsonValue::as_str) + .is_some_and(|format| !matches!(format, "date" | "date-time")) + { + return Err(invalid_artifact( + "schema string format is outside the closed Version 1 subset", + )); + } + let bounded = object + .get("maxLength") + .and_then(JsonValue::as_u64) + .is_some_and(|maximum| maximum > 0 && maximum <= 65_536); + let formatted = matches!( + object.get("format").and_then(JsonValue::as_str), + Some("date" | "date-time") + ); + let enumerated = object + .get("enum") + .and_then(JsonValue::as_array) + .is_some_and(|values| { + !values.is_empty() + && values.len() <= 256 + && values.iter().all(|value| value.as_str().is_some()) + }); + let constant = object + .get("const") + .and_then(JsonValue::as_str) + .is_some_and(|value| value.len() <= 65_536); + if !bounded && !formatted && !enumerated && !constant { + return Err(invalid_artifact( + "schema strings must be bounded, formatted, or enumerated", + )); + } + } + "integer" => { + let bounded = object + .get("minimum") + .and_then(JsonValue::as_i64) + .zip(object.get("maximum").and_then(JsonValue::as_i64)); + let enumerated = object + .get("enum") + .and_then(JsonValue::as_array) + .is_some_and(|values| { + !values.is_empty() + && values.len() <= 256 + && values.iter().all(|value| value.as_i64().is_some()) + }); + let constant = object.get("const").and_then(JsonValue::as_i64).is_some(); + if bounded.is_none_or(|(minimum, maximum)| minimum > maximum) + && !enumerated + && !constant + { + return Err(invalid_artifact( + "schema integers need both a minimum and a maximum, or an enum, or a const", + )); + } + } + "boolean" => { + if object.get("const").is_some_and(|value| !value.is_boolean()) { + return Err(invalid_artifact("schema boolean const is invalid")); + } + if object.get("enum").is_some_and(|value| { + value.as_array().is_none_or(|values| { + values.is_empty() + || values.len() > 2 + || values.iter().any(|value| !value.is_boolean()) + }) + }) { + return Err(invalid_artifact("schema boolean enumeration is invalid")); + } + } + _ => unreachable!("type was closed above"), + } + Ok(()) +} + +fn schema_const_is_bounded(value: &JsonValue) -> bool { + match value { + JsonValue::Bool(_) => true, + JsonValue::Number(value) => value.as_i64().is_some(), + JsonValue::String(value) => value.len() <= 65_536, + JsonValue::Array(values) => { + values.len() <= 256 && values.iter().all(schema_const_is_bounded) + } + JsonValue::Object(values) => { + values.len() <= 256 + && values + .iter() + .all(|(name, value)| name.len() <= 1_024 && schema_const_is_bounded(value)) + } + JsonValue::Null => false, + } +} + +fn load_codelists( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut paths = BTreeSet::new(); + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { codelist, .. } = field { + paths.insert(codelist.as_str().to_owned()); + } + } + } + for requirement in &config.requirements { + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::ControlledCode + | ConceptForm::ControlledCategory + | ConceptForm::ControlledCodeList + ) { + paths.insert(concept_codelist_path(&concept.constraints)?.to_owned()); + } + } + } + paths.extend(reviewed_bucket_codelist_paths(config, files)?); + let mut codelists = BTreeMap::new(); + for path in paths { + let codelist = load_codelist(&path, files).map_err(|error| error.in_artifact(&path))?; + codelists.insert(path, codelist); + } + Ok(codelists) +} + +fn load_codelist(path: &str, files: &BTreeMap>) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("missing codelist"))?; + let text = std::str::from_utf8(bytes).map_err(|_| invalid_artifact("codelist is not UTF-8"))?; + let document: CodelistDocument = + serde_norway::from_str(text).map_err(|_| invalid_artifact("codelist YAML is invalid"))?; + document.validate() +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CodelistDocument { + Codes(CodeCodelistDocument), + Mapping(MappingCodelistDocument), +} + +impl CodelistDocument { + fn validate(self) -> Result { + match self { + Self::Codes(document) => document.validate(), + Self::Mapping(document) => document.validate(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CodeCodelistDocument { + id: String, + version: String, + codes: Vec, +} + +impl CodeCodelistDocument { + fn validate(self) -> Result { + validate_codelist_header(&self.id, &self.version)?; + validate_code_collection(&self.codes)?; + Ok(Codelist::Codes { + id: self.id, + version: self.version, + codes: self.codes, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingCodelistDocument { + id: String, + version: String, + entries: BTreeMap, + allowed_outputs: Vec, +} + +impl MappingCodelistDocument { + fn validate(self) -> Result { + validate_codelist_header(&self.id, &self.version)?; + if self.entries.is_empty() || self.entries.len() > 4_096 { + return Err(invalid_artifact("codelist entry count is invalid")); + } + validate_code_collection(&self.allowed_outputs)?; + for (input, output) in &self.entries { + validate_code(input)?; + validate_code(output)?; + if !self.allowed_outputs.contains(output) { + return Err(invalid_artifact("codelist mapping output is not allowed")); + } + } + Ok(Codelist::Mapping { + id: self.id, + version: self.version, + entries: self.entries, + allowed_outputs: self.allowed_outputs, + }) + } +} + +fn validate_codelist_header(id: &str, version: &str) -> Result<(), BundleError> { + if id.len() > 512 + || Url::parse(id).is_err() + || version.is_empty() + || version.len() > 128 + || version.contains('\0') + { + return Err(invalid_artifact("codelist identity is invalid")); + } + Ok(()) +} + +fn validate_code_collection(codes: &[String]) -> Result<(), BundleError> { + if codes.is_empty() || codes.len() > 4_096 { + return Err(invalid_artifact("codelist code count is invalid")); + } + let mut seen = BTreeSet::new(); + for code in codes { + validate_code(code)?; + if !seen.insert(code.as_str()) { + return Err(invalid_artifact("codelist code is duplicated")); + } + } + Ok(()) +} + +fn validate_code(code: &str) -> Result<(), BundleError> { + let bytes = code.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !bytes[0].is_ascii_alphanumeric() + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) + { + return Err(invalid_artifact("codelist code is invalid")); + } + Ok(()) +} + +fn validate_codelist_references( + config: &EvidenceConfig, + codelists: &BTreeMap, +) -> Result<(), BundleError> { + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { + codelist, + codelist_version, + .. + } = field + { + let loaded = codelists + .get(codelist.as_str()) + .ok_or(invalid_artifact("selector codelist is missing"))?; + if loaded.version() != codelist_version { + return Err(invalid_artifact("selector codelist version mismatch")); + } + } + } + } + for requirement in &config.requirements { + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) { + let identifier = concept_constraint_string(&concept.constraints, "bucketScheme")?; + let version = concept_constraint_string(&concept.constraints, "schemeVersion")?; + let matches = codelists + .values() + .filter(|codelist| codelist.id() == identifier && codelist.version() == version) + .count(); + if matches != 1 { + return Err(invalid_artifact("bucket scheme codelist identity mismatch")); + } + continue; + } + let version_key = match concept.form { + ConceptForm::ControlledCode | ConceptForm::ControlledCodeList => "codelistVersion", + ConceptForm::ControlledCategory => "schemeVersion", + _ => continue, + }; + let path = concept_codelist_path(&concept.constraints)?; + let version = concept_constraint_string(&concept.constraints, version_key)?; + let loaded = codelists + .get(path) + .ok_or(invalid_artifact("concept codelist is missing"))?; + if loaded.version() != version { + return Err(invalid_artifact("concept codelist version mismatch")); + } + } + } + Ok(()) +} + +fn load_fixtures( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut fixtures = BTreeMap::new(); + for requirement in &config.requirements { + let Some(fixture_path) = &requirement.fixtures else { + continue; + }; + let path = fixture_path.as_str(); + if fixtures.contains_key(path) { + continue; + } + let fixture = load_fixture(path, files).map_err(|error| error.in_artifact(path))?; + fixtures.insert(path.to_owned(), fixture); + } + Ok(fixtures) +} + +fn load_fixture(path: &str, files: &BTreeMap>) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("fixture file is missing"))?; + let text = + std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fixture file is not UTF-8"))?; + let fixture: YamlValue = + serde_norway::from_str(text).map_err(|_| invalid_artifact("fixture YAML is invalid"))?; + validate_fixture_coverage(&fixture)?; + Ok(fixture) +} + +fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { + let root = fixture + .as_mapping() + .ok_or(invalid_artifact("fixture root must be a mapping"))?; + if root.get("synthetic_only").and_then(YamlValue::as_bool) != Some(true) { + return Err(invalid_artifact("fixtures must be synthetic-only")); + } + let cases = root + .get("cases") + .and_then(YamlValue::as_sequence) + .ok_or(invalid_artifact("fixture cases are missing"))?; + if cases.is_empty() || cases.len() > 256 { + return Err(invalid_artifact("fixture case count is invalid")); + } + let mut ids = BTreeSet::new(); + let mut categories = FixtureCategories::default(); + for case in cases { + let id = case + .as_mapping() + .and_then(|mapping| mapping.get("id")) + .and_then(YamlValue::as_str) + .ok_or(invalid_artifact("fixture case id is missing"))?; + if id.is_empty() || id.len() > 128 || !ids.insert(id) { + return Err(invalid_artifact("fixture case id is invalid or duplicated")); + } + categories.observe(id); + } + if !categories.complete() { + return Err(invalid_artifact("fixture category coverage is incomplete")); + } + Ok(()) +} + +#[derive(Default)] +struct FixtureCategories { + positive: bool, + negative: bool, + boundary: bool, + missing: bool, + no_match: bool, + ambiguous: bool, + source_failure: bool, + anti_reconstruction: bool, +} + +impl FixtureCategories { + fn observe(&mut self, id: &str) { + self.positive |= id == "positive"; + self.negative |= id.starts_with("negative"); + self.boundary |= id.starts_with("boundary"); + self.missing |= id.starts_with("missing"); + self.no_match |= id == "no-match"; + self.ambiguous |= id.starts_with("ambiguous"); + self.source_failure |= id == "source-failure"; + self.anti_reconstruction |= id == "anti-reconstruction"; + } + + fn complete(&self) -> bool { + self.positive + && self.negative + && self.boundary + && self.missing + && self.no_match + && self.ambiguous + && self.source_failure + && self.anti_reconstruction + } +} + +fn load_retired_public_jwks( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut keys = BTreeMap::new(); + for path in &config.signing.retired_public_jwk_files { + let path = path.as_str(); + let load = || -> Result<(String, JsonMap), BundleError> { + let bytes = files + .get(path) + .ok_or(invalid_artifact("retired public JWK is missing"))?; + let object = parse_strict_json_object(bytes)?; + let kid = validate_public_jwk(&object, &config.signing.active_key_id)?; + Ok((kid, object)) + }; + let (kid, object) = load().map_err(|error| error.in_artifact(path))?; + if keys.insert(kid, JsonValue::Object(object)).is_some() { + return Err(invalid_artifact("retired public JWK kid is duplicated").in_artifact(path)); + } + } + Ok(keys) +} + +fn parse_strict_json_object(bytes: &[u8]) -> Result, BundleError> { + struct StrictObject(JsonMap); + impl<'de> Deserialize<'de> for StrictObject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ObjectVisitor; + impl<'de> Visitor<'de> for ObjectVisitor { + type Value = StrictObject; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object with unique members") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut object = JsonMap::new(); + while let Some((key, value)) = map.next_entry::()? { + if object.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate JSON member")); + } + } + Ok(StrictObject(object)) + } + } + deserializer.deserialize_map(ObjectVisitor) + } + } + + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let object = StrictObject::deserialize(&mut deserializer) + .map_err(|_| invalid_artifact("public JWK JSON is invalid"))?; + deserializer + .end() + .map_err(|_| invalid_artifact("public JWK has trailing data"))?; + Ok(object.0) +} + +fn validate_public_jwk( + object: &JsonMap, + active_key_id: &str, +) -> Result { + const ALLOWED: [&str; 7] = ["kty", "crv", "x", "kid", "alg", "use", "key_ops"]; + if object.keys().any(|key| !ALLOWED.contains(&key.as_str())) + || object.get("kty").and_then(JsonValue::as_str) != Some("OKP") + || object.get("crv").and_then(JsonValue::as_str) != Some("Ed25519") + || object.get("alg").and_then(JsonValue::as_str) != Some("EdDSA") + || object + .get("use") + .is_some_and(|value| value.as_str() != Some("sig")) + { + return Err(invalid_artifact( + "retired JWK is not an allowed public EdDSA key", + )); + } + let kid = object + .get("kid") + .and_then(JsonValue::as_str) + .filter(|kid| { + !kid.is_empty() + && kid.len() <= 256 + && !kid.chars().any(char::is_control) + && *kid != active_key_id + }) + .ok_or(invalid_artifact("retired JWK kid is invalid"))?; + let x = object + .get("x") + .and_then(JsonValue::as_str) + .ok_or(invalid_artifact("retired JWK public coordinate is missing"))?; + let decoded = URL_SAFE_NO_PAD + .decode(x) + .map_err(|_| invalid_artifact("retired JWK public coordinate is invalid"))?; + if decoded.len() != 32 { + return Err(invalid_artifact( + "retired JWK public coordinate has the wrong size", + )); + } + if let Some(operations) = object.get("key_ops") { + let operations = operations + .as_array() + .ok_or(invalid_artifact("retired JWK key_ops is invalid"))?; + if operations.len() != 1 || operations[0].as_str() != Some("verify") { + return Err(invalid_artifact("retired JWK key_ops is not verify-only")); + } + } + Ok(kid.to_owned()) +} + +fn concept_codelist_path(constraints: &OrderedMap) -> Result<&str, BundleError> { + concept_constraint_string(constraints, "codelist") +} + +fn concept_constraint_string<'a>( + constraints: &'a OrderedMap, + key: &str, +) -> Result<&'a str, BundleError> { + constraints + .get(key) + .and_then(YamlValue::as_str) + .ok_or(invalid_artifact("concept codelist constraint is invalid")) +} + +fn validate_runtime_bindings( + bundle: &EvidenceConfig, + runtime: &RuntimeConfig, +) -> Result<(), BundleError> { + let required = bundle + .sources + .iter() + .filter_map(|(_, source)| source.tls_trust_profile.as_deref()) + .collect::>(); + let configured = runtime + .outbound_tls + .trust_profiles + .keys() + .collect::>(); + if required != configured { + return Err(invalid_artifact( + "runtime TLS trust profiles must exactly bind bundle source profiles", + )); + } + Ok(()) +} + +/// The secret root is the one immutability check whose subject is outside the +/// bundle, so its cause says so. Re-freezing the bundle does not touch it, and +/// an operator told only that a deployment input is not immutable audits the +/// bundle first and finds nothing wrong with it. +fn validate_secret_root(path: &Path) -> Result<(), BundleError> { + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(BundleError::InvalidPath); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(not_immutable( + "the secret root directory the runtime file names is reachable by group or other", + )); + } + } + #[cfg(not(unix))] + if !metadata.permissions().readonly() { + return Err(not_immutable( + "the secret root directory the runtime file names is writable", + )); + } + Ok(()) +} + +fn validate_ca_bundle(bytes: &[u8]) -> Result<(), BundleError> { + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid_artifact("TLS CA bundle is not UTF-8 PEM"))?; + let mut in_certificate = false; + let mut encoded = String::new(); + let mut certificates = 0_usize; + for line in text.lines() { + match line { + "-----BEGIN CERTIFICATE-----" if !in_certificate => { + in_certificate = true; + encoded.clear(); + } + "-----END CERTIFICATE-----" if in_certificate => { + let der = base64::engine::general_purpose::STANDARD + .decode(encoded.as_bytes()) + .map_err(|_| invalid_artifact("TLS CA bundle PEM is invalid"))?; + if der.len() < 4 || der.first() != Some(&0x30) { + return Err(invalid_artifact("TLS CA bundle certificate is invalid")); + } + certificates = certificates.checked_add(1).ok_or(BundleError::TooLarge)?; + if certificates > 64 { + return Err(BundleError::TooLarge); + } + in_certificate = false; + } + "" if !in_certificate => {} + _ if in_certificate + && !line.is_empty() + && line.len() <= 76 + && line.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') + }) => + { + encoded.push_str(line); + } + _ => { + return Err(invalid_artifact( + "TLS CA bundle contains non-certificate PEM data", + )); + } + } + } + if in_certificate || certificates == 0 { + return Err(invalid_artifact( + "TLS CA bundle contains no complete certificate", + )); + } + Ok(()) +} + +fn compute_runtime_revision( + runtime_bytes: &[u8], + ca_bundles: &BTreeMap>, +) -> Result { + let mut files = BTreeMap::from([("runtime.yaml".to_owned(), runtime_bytes.to_vec())]); + for (profile, bytes) in ca_bundles { + files.insert(format!("trust-profile/{profile}.pem"), bytes.clone()); + } + compute_named_revision(RUNTIME_REVISION_DOMAIN, &files) +} + +fn compute_revision(files: &BTreeMap>) -> Result { + compute_named_revision(REVISION_DOMAIN, files) +} + +fn compute_named_revision( + domain: &[u8], + files: &BTreeMap>, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update( + u64::try_from(files.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + for (path, bytes) in files { + let path_bytes = path.as_bytes(); + hasher.update( + u64::try_from(path_bytes.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + hasher.update(path_bytes); + hasher.update( + u64::try_from(bytes.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + hasher.update(bytes); + } + let digest = hasher.finalize(); + let mut revision = String::with_capacity("sha256:".len() + 64); + revision.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut revision, "{byte:02x}").map_err(|_| BundleError::TooLarge)?; + } + Ok(revision) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AssuranceProfile; + use crate::kernel::OfflineKernel; + + #[cfg(unix)] + fn copy_acceptance_bundle(case: &str, destination: &Path) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(case); + copy_tree(&source, destination); + } + + #[cfg(unix)] + fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create destination"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source file type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy fixture artifact"); + } + } + } + + #[cfg(unix)] + fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + let metadata = fs::symlink_metadata(path).expect("tree metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read tree") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set directory mode"); + } else if metadata.is_file() { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set file mode"); + } + } + + #[test] + fn revision_binds_paths_and_exact_bytes_deterministically() { + let first = BTreeMap::from([ + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ("schemas/facts.yaml".to_owned(), b"type: object\n".to_vec()), + ]); + let same = BTreeMap::from([ + ("schemas/facts.yaml".to_owned(), b"type: object\n".to_vec()), + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ]); + assert_eq!(compute_revision(&first), compute_revision(&same)); + + let renamed = BTreeMap::from([ + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ("schemas/other.yaml".to_owned(), b"type: object\n".to_vec()), + ]); + assert_ne!(compute_revision(&first), compute_revision(&renamed)); + } + + #[test] + fn fixture_coverage_is_case_neutral_but_complete() { + let fixture: YamlValue = serde_norway::from_str( + "synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: no-match}\n - {id: ambiguous}\n - {id: source-failure}\n - {id: anti-reconstruction}\n", + ) + .expect("fixture parses"); + assert!(validate_fixture_coverage(&fixture).is_ok()); + } + + #[cfg(unix)] + #[test] + fn local_bundle_may_omit_fixtures_but_strict_bundles_remain_complete() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + let config_path = directory.path().join(CONFIG_FILE); + let strict = fs::read_to_string(&config_path).expect("configuration reads"); + let local = strict + .replace( + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + ) + .lines() + .filter(|line| !line.trim_start().starts_with("fixtures:")) + .collect::>() + .join("\n"); + fs::write(&config_path, local).expect("local configuration writes"); + fs::remove_file(directory.path().join("fixtures/cases.yaml")) + .expect("unreferenced fixture is removed"); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Bundle::load(directory.path()).expect("local bundle loads without fixtures"); + assert_eq!(bundle.config.assurance_profile, AssuranceProfile::Local); + assert!(bundle.fixtures.is_empty()); + + set_tree_mode(directory.path(), 0o755, 0o644); + for profile in ["production", "evidence-grade"] { + let candidate = fs::read_to_string(&config_path) + .expect("local configuration reads") + .replace( + "assuranceProfile: local", + &format!("assuranceProfile: {profile}"), + ); + fs::write(&config_path, candidate).expect("strict configuration writes"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!( + Bundle::load(directory.path()).is_err(), + "{profile} bundle loaded without fixtures" + ); + set_tree_mode(directory.path(), 0o755, 0o644); + let reset = fs::read_to_string(&config_path) + .expect("strict configuration reads") + .replace( + &format!("assuranceProfile: {profile}"), + "assuranceProfile: local", + ); + fs::write(&config_path, reset).expect("local configuration restores"); + } + } + + #[cfg(unix)] + #[test] + fn strict_assurance_rejects_partial_fixture_suites() { + for profile in ["production", "evidence-grade"] { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + + let config_path = directory.path().join(CONFIG_FILE); + let configuration = fs::read_to_string(&config_path) + .expect("configuration reads") + .replace( + "assuranceProfile: evidence-grade", + &format!("assuranceProfile: {profile}"), + ); + fs::write(&config_path, configuration).expect("configuration writes"); + + let fixtures_path = directory.path().join("fixtures/cases.yaml"); + let fixtures = fs::read_to_string(&fixtures_path) + .expect("fixtures read") + .lines() + .filter(|line| !line.contains("id: anti-reconstruction")) + .collect::>() + .join("\n"); + fs::write(&fixtures_path, fixtures).expect("partial fixtures write"); + set_tree_mode(directory.path(), 0o555, 0o444); + + let error = Bundle::load(directory.path()).expect_err(&format!( + "{profile} bundle loaded with incomplete fixture coverage" + )); + assert!( + error + .to_string() + .contains("fixture category coverage is incomplete"), + "{profile} failed for an unexpected reason: {error}" + ); + } + } + + #[test] + fn strict_public_jwk_rejects_private_material_and_duplicate_members() { + let private = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","d":"secret"}"#; + let object = parse_strict_json_object(private).expect("JSON parses"); + assert!(validate_public_jwk(&object, "active").is_err()); + + let duplicate = br#"{"kty":"OKP","kty":"OKP"}"#; + assert!(parse_strict_json_object(duplicate).is_err()); + + let control_kid = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old\u000aidentifier","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let object = parse_strict_json_object(control_kid).expect("JSON parses"); + assert!(validate_public_jwk(&object, "active").is_err()); + } + + #[cfg(unix)] + #[test] + fn all_coequal_immutable_acceptance_bundles_load_through_one_kernel() { + for case in [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ] { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle(case, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Bundle::load(directory.path()).expect("bundle loads"); + assert!(bundle.configuration_revision().starts_with("sha256:")); + assert_eq!(bundle.configuration_revision().len(), 71); + assert_eq!(bundle.scripts.len(), 3); + assert_eq!(bundle.fact_schemas.len(), 3); + assert_eq!(bundle.fixtures.len(), 1); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[test] + fn combined_acceptance_bundle_loads_as_one_atomic_revision() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("all-definitions", directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Bundle::load(directory.path()).expect("combined bundle loads"); + assert_eq!(bundle.config.requirements.len(), 4); + assert_eq!(bundle.config.sources.len(), 4); + assert_eq!(bundle.scripts.len(), 12); + assert_eq!(bundle.fact_schemas.len(), 12); + assert_eq!(bundle.fixtures.len(), 4); + assert_eq!(bundle.codelists.len(), 3); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn deployment_reference_projects_are_complete_compilable_bundles() { + let projects_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects"); + for project in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { + let project_root = projects_root.join(project); + RuntimeConfig::parse_yaml( + &fs::read(project_root.join("runtime.yaml")).expect("read reference runtime"), + ) + .expect("reference runtime is closed and valid"); + + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_tree(&project_root.join("bundle"), directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = std::sync::Arc::new( + Bundle::load(directory.path()).expect("reference bundle loads atomically"), + ); + OfflineKernel::compile(bundle).expect("reference scripts compile through the ABI"); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[test] + fn reviewed_structured_schema_uri_resolves_exactly_one_bundle_artifact() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + let config_path = directory.path().join("evidence.yaml"); + let config = fs::read_to_string(&config_path).expect("read configuration"); + let original = " concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}]\n"; + let replacement = " concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}, {id: urn:example:fixture:concept:structured, form: reviewed-structured-value, required: false, constraints: {schema: urn:example:fixture:schema:structured:v1, maximumSerializedBytes: 512}}]\n"; + let config = config.replacen(original, replacement, 1); + assert_ne!( + config, + fs::read_to_string(&config_path).expect("read configuration") + ); + fs::write(&config_path, config).expect("write configuration"); + let schema = concat!( + "$schema: https://json-schema.org/draft/2020-12/schema\n", + "$id: urn:example:fixture:schema:structured:v1\n", + "type: object\n", + "additionalProperties: false\n", + "required: [status]\n", + "properties:\n", + " status: {type: string, enum: [A]}\n" + ); + fs::write(directory.path().join("schemas/structured.yaml"), schema) + .expect("write reviewed schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + let bundle = Bundle::load(directory.path()).expect("reviewed schema resolves"); + assert!(bundle.fact_schemas.contains_key("schemas/structured.yaml")); + + let schema_path = directory.path().join("schemas/structured.yaml"); + for invalid_property in [ + "{}", + "{type: object, additionalProperties: true, required: [nested], properties: {nested: {type: string, maxLength: 8}}}", + "{type: object, additionalProperties: false, required: [nested], properties: {nested: {}}}", + "{type: array, maxItems: 2, items: {}}", + "{type: array, items: {type: string, maxLength: 8}}", + "{type: string, maxLength: 8, format: custom-id}", + ] { + set_tree_mode(directory.path(), 0o755, 0o644); + fs::write( + &schema_path, + format!( + "$schema: https://json-schema.org/draft/2020-12/schema\n$id: urn:example:fixture:schema:structured:v1\ntype: object\nadditionalProperties: false\nrequired: [status]\nproperties:\n status: {invalid_property}\n" + ), + ) + .expect("write invalid reviewed schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!( + matches!( + Bundle::load(directory.path()), + Err(BundleError::InvalidArtifact(_)) + ), + "open nested schema must fail: {invalid_property}" + ); + } + + set_tree_mode(directory.path(), 0o755, 0o644); + fs::write(&schema_path, schema).expect("restore reviewed schema"); + fs::write(directory.path().join("schemas/duplicate.yaml"), schema) + .expect("write duplicate schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + let ambiguous = Bundle::load(directory.path()).expect_err("duplicate schema is rejected"); + assert!(matches!(ambiguous, BundleError::InvalidArtifact(_))); + assert_eq!( + ambiguous.artifact_fault().map(ArtifactFault::fault), + Some(&SchemaFault::because( + "reviewed structured schema identifier is missing or ambiguous" + )) + ); + } + + /// The closed subset is learnable, but only if each rule states itself + /// whole. A lower bound alone is what JSON Schema habit supplies, and it is + /// refused, so the refusal has to say that an upper bound is the missing + /// half rather than leave the author to guess which of three admitted forms + /// was meant. + #[test] + fn an_integer_bounded_on_one_side_is_refused_by_the_whole_rule() { + let admitted = [ + "{type: integer, minimum: 0, maximum: 64}", + "{type: integer, enum: [1, 2]}", + "{type: integer, const: 1}", + ]; + for node in admitted { + let node: JsonValue = serde_norway::from_str(node).expect("admitted integer node"); + assert!( + validate_schema_node(&node, SchemaRole::Facts).is_ok(), + "the subset admits this integer: {node}" + ); + } + + for node in [ + "{type: integer, minimum: 0}", + "{type: integer, maximum: 64}", + "{type: integer}", + ] { + let node: JsonValue = serde_norway::from_str(node).expect("unbounded integer node"); + let refused = validate_schema_node(&node, SchemaRole::Facts) + .expect_err("an integer bounded on one side is refused"); + assert_eq!( + refused.artifact_fault().map(ArtifactFault::fault), + Some(&SchemaFault::because( + "schema integers need both a minimum and a maximum, or an enum, or a const" + )), + "the refusal must name the whole rule: {node}" + ); + } + } + + #[cfg(unix)] + #[test] + fn writable_bundle_and_unknown_files_fail_closed() { + let writable = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", writable.path()); + let writable_error = Bundle::load(writable.path()).expect_err("a writable bundle fails"); + let fault = writable_error + .artifact_fault() + .expect("the refusal names what is writable"); + assert_eq!(fault.artifact(), ""); + assert_eq!(fault.fault().cause(), "the bundle directory is writable"); + + let unknown = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", unknown.path()); + fs::write( + unknown.path().join("fixtures/unreferenced.yaml"), + b"synthetic_only: true\n", + ) + .expect("write unknown artifact"); + set_tree_mode(unknown.path(), 0o555, 0o444); + let unreferenced = Bundle::load(unknown.path()).expect_err("unknown artifact is rejected"); + assert!(matches!(unreferenced, BundleError::UnknownFile(_))); + let fault = unreferenced.artifact_fault().expect("closure names a file"); + assert_eq!(fault.artifact(), "fixtures/unreferenced.yaml"); + assert_eq!( + fault.fault().cause(), + "the bundle contains an artifact the configuration does not reference" + ); + set_tree_mode(unknown.path(), 0o755, 0o444); + + let missing = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", missing.path()); + fs::remove_file(missing.path().join("derivations/adult-status.rhai")) + .expect("remove referenced derivation"); + set_tree_mode(missing.path(), 0o555, 0o444); + let absent = Bundle::load(missing.path()).expect_err("missing artifact is rejected"); + let fault = absent.artifact_fault().expect("closure names a file"); + assert_eq!(fault.artifact(), "derivations/adult-status.rhai"); + assert_eq!( + fault.fault().cause(), + "the configuration references an artifact the bundle does not contain" + ); + set_tree_mode(missing.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn symlinked_artifact_fails_before_file_access() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + let outside = tempfile::NamedTempFile::new().expect("outside script"); + let adapter = directory.path().join("adapters/source-a.rhai"); + fs::remove_file(&adapter).expect("remove copied adapter"); + symlink(outside.path(), adapter).expect("create symlink"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!(matches!( + Bundle::load(directory.path()), + Err(BundleError::InvalidPath) + )); + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn runtime_and_ca_bytes_are_captured_under_an_independent_read_only_revision() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("temporary runtime root"); + let secret_root = directory.path().join("secrets"); + fs::create_dir(&secret_root).expect("create secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("lock secret root"); + let ca_path = directory.path().join("internal.pem"); + fs::write( + &ca_path, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write CA bundle"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)).expect("lock CA bundle"); + let runtime_path = directory.path().join("runtime.yaml"); + fs::write( + &runtime_path, + format!( + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n internal-pki: {{caBundleFile: {}}}\n", + secret_root.display(), + ca_path.display() + ), + ) + .expect("write runtime document"); + + let writable = RuntimeDocument::load(&runtime_path).expect_err("a writable runtime fails"); + let fault = writable + .artifact_fault() + .expect("the refusal names what is writable"); + assert_eq!(fault.artifact(), RUNTIME_FILE); + assert_eq!(fault.fault().cause(), "the runtime file is writable"); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("lock runtime document"); + let runtime = RuntimeDocument::load(&runtime_path).expect("runtime loads"); + assert!(runtime.revision().starts_with("sha256:")); + assert_eq!(runtime.revision().len(), 71); + assert_eq!(runtime.ca_bundles.len(), 1); + assert_eq!( + runtime.bytes(), + fs::read(&runtime_path).expect("read runtime") + ); + + // Everything the operator can re-freeze is already frozen here, so the + // refusal has to name the one input outside the bundle. Re-freezing the + // bundle in answer to it changes nothing, which is what makes an + // unnamed immutability failure cost a mode audit of the whole tree. + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o750)) + .expect("loosen secret root"); + let loose = RuntimeDocument::load(&runtime_path).expect_err("a group-readable root fails"); + let fault = loose + .artifact_fault() + .expect("the refusal names what is loose"); + assert_eq!( + fault.fault().cause(), + "the secret root directory the runtime file names is reachable by group or other" + ); + } +} diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs new file mode 100644 index 000000000..55a36afa6 --- /dev/null +++ b/crates/registry-evidence/src/config.rs @@ -0,0 +1,4771 @@ +//! Typed Evidence Version 1 deployment configuration. +//! +//! Configuration is trusted deployment data, but it is still parsed as a +//! closed contract. Secret-bearing fields contain only [`SecretRef`] values; +//! this module never resolves them. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::net::{IpAddr, Ipv6Addr}; +use std::path::{Component, Path}; +use std::str::FromStr; + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_norway::Value as YamlValue; +use thiserror::Error; +use url::{Host, Url}; + +pub const MAX_CONFIG_BYTES: usize = 1024 * 1024; +pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum ConfigError { + #[error("configuration YAML does not match the Evidence Version 1 schema: {0}")] + InvalidYaml(SchemaFault), + #[error("configuration exceeds the Evidence Version 1 size limit")] + TooLarge, + #[error("configuration violates the Evidence Version 1 contract: {0}")] + Invalid(&'static str), +} + +impl ConfigError { + /// The value-free diagnostic for this failure. + /// + /// Deployment tooling reports this instead of the error itself so that + /// every configuration failure carries the same safe shape. + pub fn fault(&self) -> SchemaFault { + match self { + Self::InvalidYaml(fault) => fault.clone(), + Self::TooLarge => SchemaFault::because("document exceeds the Version 1 size limit"), + Self::Invalid(cause) => SchemaFault::because(cause), + } + } +} + +/// A one-based text position inside a deployment artifact. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct TextLocation { + pub line: usize, + pub column: usize, +} + +impl fmt::Display for TextLocation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "line {} column {}", self.line, self.column) + } +} + +/// A value-free reason one deployment document was rejected. +/// +/// Only three things are kept: a schema path built from mapping keys and +/// sequence indices, a text location, and one static cause. The decoder's own +/// message is classified and then discarded, because it quotes scalars, and a +/// deployment scalar can be a selector value, a secret reference, or a source +/// identifier. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SchemaFault { + location: Option, + path: Option, + cause: &'static str, +} + +/// The longest schema path a diagnostic will carry. +const MAX_SCHEMA_PATH_BYTES: usize = 256; + +/// Decoder message prefixes mapped to their value-free cause. +/// +/// The prefixes are the fixed leading text of `serde` and `serde_norway` +/// messages. Everything after a prefix can quote document content and is +/// never read. +const DECODE_CAUSES: [(&str, &str); 13] = [ + ("unknown field", "unknown field"), + ("missing field", "required field is missing"), + ("duplicate field", "duplicate field"), + ("duplicate entry", "duplicate mapping key"), + ("invalid type", "field has the wrong type"), + ("invalid value", "field value is not accepted"), + ("invalid length", "field has the wrong length"), + ( + "unknown variant", + "field value is not one of the accepted variants", + ), + ( + "data did not match any variant", + "field value is not one of the accepted variants", + ), + ( + "EOF while parsing", + "document ends before a value is complete", + ), + ("recursion limit exceeded", "document nests too deeply"), + ( + "repetition limit exceeded", + "document repeats an alias too often", + ), + ( + "deserializing from YAML containing more than one document", + "document contains more than one YAML document", + ), +]; + +impl SchemaFault { + /// A fault that names only its cause. + pub fn because(cause: &'static str) -> Self { + Self { + location: None, + path: None, + cause, + } + } + + pub fn cause(&self) -> &'static str { + self.cause + } + + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + + pub fn location(&self) -> Option { + self.location + } + + /// Reduce a decoder error to a location, a safe schema path, and a cause. + fn from_yaml_error(error: &serde_norway::Error, fallback: &'static str) -> Self { + let rendered = error.to_string(); + let (path, message) = split_schema_path(&rendered); + Self { + location: error.location().map(|location| TextLocation { + line: location.line(), + column: location.column(), + }), + path, + cause: classify_decode_cause(message, fallback), + } + } +} + +impl fmt::Display for SchemaFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.cause)?; + if let Some(path) = &self.path { + write!(formatter, " at {path}")?; + } + if let Some(location) = self.location { + write!(formatter, " ({location})")?; + } + Ok(()) + } +} + +/// Split a rendered decoder error into its schema path and its message. +/// +/// `serde_norway` prefixes a message with the path to the offending value when +/// it knows one. The candidate prefix is accepted only when it matches the +/// path grammar, which no message text can satisfy, so a message that merely +/// contains a colon never becomes a path. +fn split_schema_path(rendered: &str) -> (Option, &str) { + match rendered.split_once(": ") { + Some((candidate, message)) if is_safe_schema_path(candidate) => { + (Some(candidate.to_owned()), message) + } + _ => (None, rendered), + } +} + +/// Accept only paths built from mapping keys and numeric sequence indices. +/// +/// Mapping keys are structural names in a reviewed bundle, never values, and +/// this grammar admits no whitespace, quoting, or punctuation that a quoted +/// scalar would carry. +fn is_safe_schema_path(candidate: &str) -> bool { + if candidate.is_empty() || candidate.len() > MAX_SCHEMA_PATH_BYTES { + return false; + } + let mut index_digits: Option = None; + for character in candidate.chars() { + match index_digits { + Some(digits) => match character { + '0'..='9' => index_digits = Some(digits + 1), + ']' if digits > 0 => index_digits = None, + _ => return false, + }, + None => match character { + '[' => index_digits = Some(0), + '.' | '-' | '_' | '?' => {} + _ if character.is_ascii_alphanumeric() => {} + _ => return false, + }, + } + } + index_digits.is_none() +} + +/// Map a decoder message to one static cause, reading only its fixed prefix. +fn classify_decode_cause(message: &str, fallback: &'static str) -> &'static str { + DECODE_CAUSES + .iter() + .find(|(prefix, _)| message.starts_with(prefix)) + .map_or(fallback, |(_, cause)| cause) +} + +/// Decode one YAML document into a closed typed schema. +/// +/// The bytes are parsed as untyped YAML first so that a syntax failure is +/// reported as a syntax failure rather than as a schema mismatch. +fn decode_yaml(text: &str) -> Result { + if let Err(error) = serde_norway::from_str::(text) { + return Err(ConfigError::InvalidYaml(SchemaFault::from_yaml_error( + &error, + "document is not well-formed YAML", + ))); + } + serde_norway::from_str(text).map_err(|error| { + ConfigError::InvalidYaml(SchemaFault::from_yaml_error( + &error, + "document does not match the closed schema", + )) + }) +} + +/// A mapping that rejects duplicate keys and preserves declaration order. +/// +/// Selector declaration order is part of canonical selector encoding, so a +/// sorted map is not sufficient for this contract. +#[derive(Clone, Eq, PartialEq)] +pub struct OrderedMap(Vec<(String, T)>); + +impl Default for OrderedMap { + fn default() -> Self { + Self(Vec::new()) + } +} + +impl OrderedMap { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, key: &str) -> Option<&T> { + self.0 + .iter() + .find_map(|(candidate, value)| (candidate == key).then_some(value)) + } + + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(key, value)| (key.as_str(), value)) + } + + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(key, _)| key.as_str()) + } +} + +impl fmt::Debug for OrderedMap { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_map() + .entries(self.0.iter().map(|(key, value)| (key, value))) + .finish() + } +} + +impl<'de, T> Deserialize<'de> for OrderedMap +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct OrderedMapVisitor(std::marker::PhantomData); + + impl<'de, T> Visitor<'de> for OrderedMapVisitor + where + T: Deserialize<'de>, + { + type Value = OrderedMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a mapping with unique string keys") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + let mut seen = BTreeSet::new(); + while let Some((key, value)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom("duplicate mapping key")); + } + entries.push((key, value)); + } + Ok(OrderedMap(entries)) + } + } + + deserializer.deserialize_map(OrderedMapVisitor(std::marker::PhantomData)) + } +} + +impl Serialize for OrderedMap { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (key, value) in &self.0 { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceConfig { + pub version: u8, + /// The governed assurance boundary for this immutable bundle. + pub assurance_profile: AssuranceProfile, + pub service: ServiceConfig, + pub issuer: IssuerConfig, + pub authentication: AuthenticationConfig, + pub audit: AuditConfig, + pub subject_binding: SubjectBindingConfig, + pub rate_limits: RateLimitConfig, + pub signing: SigningConfig, + /// Closed enabled response formats for the whole immutable bundle. Signed + /// flattened JWS is mandatory and the default; unsigned JSON must be + /// enabled here and permitted by the complete matched grant. + #[serde(default = "default_response_formats")] + pub response_formats: Vec, + pub selector_profiles: OrderedMap, + pub sources: OrderedMap, + pub authority_profiles: OrderedMap, + pub requirements: Vec, +} + +#[derive( + Debug, + Clone, + Copy, + Eq, + PartialEq, + Deserialize, + Serialize, + schemars::JsonSchema, + utoipa::ToSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum AssuranceProfile { + Local, + Production, + EvidenceGrade, +} + +impl AssuranceProfile { + /// Only the explicit local profile may be authored before fixture + /// coverage exists. Deployable profiles retain the complete fixture gate. + pub fn requires_fixtures(self) -> bool { + matches!(self, Self::Production | Self::EvidenceGrade) + } +} + +pub type SourceSelectorSet = Vec<(String, String)>; + +impl EvidenceConfig { + pub fn parse_yaml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| ConfigError::InvalidYaml(SchemaFault::because("document is not UTF-8")))?; + let config: Self = decode_yaml(text)?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return invalid("version must equal 1"); + } + validate_uri(&self.service.provider_id)?; + validate_uri(&self.service.trust_domain)?; + validate_uri(&self.issuer.id)?; + self.authentication.validate(self.assurance_profile)?; + self.audit.validate()?; + self.subject_binding.validate()?; + self.rate_limits.validate()?; + self.signing.validate()?; + validate_response_formats(&self.response_formats, "bundle response formats")?; + validate_named_map(&self.selector_profiles, 1, 128, |profile| { + profile.validate() + })?; + validate_named_map(&self.sources, 1, 128, |source| { + source.validate(self.assurance_profile) + })?; + validate_named_map(&self.authority_profiles, 1, 128, |profile| { + profile.validate() + })?; + validate_len(self.requirements.len(), 1, 128, "requirements")?; + + let mut requirement_ids = BTreeSet::new(); + let mut evidence_types = BTreeSet::new(); + let mut concept_ids = BTreeSet::new(); + let mut disclosure_families = BTreeSet::new(); + // One artifact carries one schema role for the whole bundle: reviewing + // it as a response contract must not silently also accept it as a fact + // or adapter-parameter contract somewhere else. + let response_schemas = self + .sources + .iter() + .map(|(_, source)| source.response_schema.as_str()) + .collect::>(); + let fact_schemas = self + .sources + .iter() + .map(|(_, source)| source.fact_schema.as_str()) + .collect::>(); + let parameter_schemas = self + .sources + .iter() + .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) + .collect::>(); + if !fact_schemas.is_disjoint(¶meter_schemas) + || !response_schemas.is_disjoint(&fact_schemas) + || !response_schemas.is_disjoint(¶meter_schemas) + { + return invalid("source schema roles must not overlap across sources"); + } + for requirement in &self.requirements { + requirement.validate()?; + if self.assurance_profile.requires_fixtures() && requirement.fixtures.is_none() { + return invalid("production and evidence-grade requirements must declare fixtures"); + } + if !requirement_ids.insert(requirement.id.as_str()) { + return invalid("requirement identifiers must be unique"); + } + if !evidence_types.insert(requirement.evidence_type.as_str()) { + return invalid("Evidence Type identifiers must be unique"); + } + for concept in &requirement.concepts { + if !concept_ids.insert(concept.id.as_str()) { + return invalid("concept identifiers must be unique"); + } + } + for family in &requirement.disclosure_guard.families { + if !disclosure_families.insert(family.as_str()) { + return invalid("enabled requirements share a disclosure family"); + } + } + } + + self.validate_cross_references() + } + + /// Return the complete selector tuple sets that an authorized request may + /// activate for one source. The configuration has already proven that + /// every grant is complete and references the named requirement source. + pub fn source_selector_sets(&self, source_id: &str) -> Vec { + let Some(source) = self.sources.get(source_id) else { + return Vec::new(); + }; + let requirement_sources = self + .requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement.source.as_str())) + .collect::>(); + let mut sets = BTreeSet::new(); + for (_, authority) in self.authority_profiles.iter() { + for grant in &authority.grants { + if requirement_sources.get(grant.requirement.as_str()) != Some(&source_id) { + continue; + } + let mut set = grant + .subjects + .iter() + .filter(|subject| { + source.request.selector_inputs.iter().any(|input| { + input.role == subject.role + && input.alternatives.iter().any(|alternative| { + alternative.profile == subject.selector_profile + }) + }) + }) + .map(|subject| (subject.role.clone(), subject.selector_profile.clone())) + .collect::(); + if set.is_empty() { + continue; + } + set.sort(); + sets.insert(set); + } + } + sets.into_iter().collect() + } + + fn validate_cross_references(&self) -> Result<(), ConfigError> { + for (_, source) in self.sources.iter() { + for input in &source.request.selector_inputs { + for alternative in &input.alternatives { + let profile = self.selector_profiles.get(&alternative.profile).ok_or( + ConfigError::Invalid( + "source selector input references an unknown selector profile", + ), + )?; + if alternative + .fields + .iter() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid( + "source selector input references an unknown selector field", + ); + } + } + } + for (_, binding) in source.request.path_bindings.iter() { + let profile = + self.selector_profiles + .get(&binding.profile) + .ok_or(ConfigError::Invalid( + "source path binding references an unknown selector profile", + ))?; + if !profile.fields.contains_key(&binding.field) { + return invalid("source path binding references an unknown selector field"); + } + if !source.request.selector_inputs.iter().any(|input| { + input.role == binding.role + && input.alternatives.iter().any(|alternative| { + alternative.profile == binding.profile + && alternative.fields.contains(&binding.field) + }) + }) { + return invalid("source path binding is not declared as a selector input"); + } + } + } + + for requirement in &self.requirements { + if !self.sources.contains_key(&requirement.source) { + return invalid("requirement references an unknown source"); + } + if requirement.validity_seconds > self.signing.maximum_assertion_validity_seconds { + return invalid("requirement validity exceeds signing maximum validity"); + } + for role in &requirement.subject_roles { + for profile_id in &role.selector_profiles { + self.selector_profiles + .get(profile_id) + .ok_or(ConfigError::Invalid( + "requirement references an unknown selector profile", + ))?; + } + } + validate_derivation_selector_inputs(requirement, &self.selector_profiles)?; + } + + let requirements = self + .requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement)) + .collect::>(); + let mut authorized_combinations = BTreeSet::new(); + let mut source_selector_sets: BTreeMap> = + BTreeMap::new(); + for (_, authority) in self.authority_profiles.iter() { + for grant in &authority.grants { + let requirement = + requirements + .get(grant.requirement.as_str()) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown requirement", + ))?; + if !requirement + .purposes + .iter() + .any(|purpose| purpose == &grant.purpose) + { + return invalid("authority grant references an unauthorized purpose"); + } + if grant.subjects.len() != requirement.subject_roles.len() { + return invalid("authority grant must bind the complete subject-role set"); + } + let source = self + .sources + .get(&requirement.source) + .ok_or(ConfigError::Invalid( + "requirement references an unknown source", + ))?; + let mut seen_roles = BTreeSet::new(); + let mut source_selector_set = Vec::with_capacity(grant.subjects.len()); + for subject in &grant.subjects { + if !seen_roles.insert(subject.role.as_str()) { + return invalid("authority grant subject roles must be unique"); + } + let role = requirement + .subject_roles + .iter() + .find(|role| role.role == subject.role) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown subject role", + ))?; + if !role + .selector_profiles + .iter() + .any(|profile| profile == &subject.selector_profile) + { + return invalid("authority grant selector profile is not allowed for role"); + } + let profile = self + .selector_profiles + .get(&subject.selector_profile) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown selector profile", + ))?; + subject.validate_value_claims(profile)?; + authorized_combinations.insert(( + grant.requirement.as_str(), + grant.purpose.as_str(), + subject.role.as_str(), + subject.selector_profile.as_str(), + )); + if source.request.selector_inputs.iter().any(|input| { + input.role == subject.role + && input + .alternatives + .iter() + .any(|alternative| alternative.profile == subject.selector_profile) + }) { + source_selector_set + .push((subject.role.clone(), subject.selector_profile.clone())); + } + } + if requirement + .subject_roles + .iter() + .any(|role| !seen_roles.contains(role.role.as_str())) + { + return invalid("authority grant omits a required subject role"); + } + if source_selector_set.is_empty() { + return invalid( + "authority path does not activate any declared source selector input", + ); + } + source_selector_set.sort(); + source_selector_sets + .entry(requirement.source.clone()) + .or_default() + .insert(source_selector_set); + } + } + + for requirement in &self.requirements { + for purpose in &requirement.purposes { + for role in &requirement.subject_roles { + for profile in &role.selector_profiles { + if !authorized_combinations.contains(&( + requirement.id.as_str(), + purpose.as_str(), + role.role.as_str(), + profile.as_str(), + )) { + return invalid( + "requirement role and selector profile lack an authority path", + ); + } + } + } + } + } + self.validate_source_selector_sets(&source_selector_sets)?; + Ok(()) + } + + fn validate_source_selector_sets( + &self, + allowed: &BTreeMap>, + ) -> Result<(), ConfigError> { + for (source_id, source) in self.sources.iter() { + let sets = allowed.get(source_id).ok_or(ConfigError::Invalid( + "configured source is unreachable from every authority grant", + ))?; + let reachable = sets + .iter() + .flatten() + .map(|(role, profile)| (role.as_str(), profile.as_str())) + .collect::>(); + if source.request.selector_inputs.iter().any(|input| { + input.alternatives.iter().any(|alternative| { + !reachable.contains(&(input.role.as_str(), alternative.profile.as_str())) + }) + }) { + return invalid( + "source selector input is unreachable from every complete authority path", + ); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceConfig { + pub provider_id: String, + pub trust_domain: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IssuerConfig { + pub id: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfig { + pub version: u8, + pub bundle_directory: String, + pub listener: ListenerConfig, + /// Optional operator-only metrics listener. Absent means the deployment + /// serves no metrics endpoint at all, which is the default posture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics_listener: Option, + pub secret_providers: RuntimeSecretProviders, + pub audit_storage: AuditStorageConfig, + pub outbound_tls: OutboundTlsConfig, +} + +impl RuntimeConfig { + pub fn parse_yaml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| ConfigError::InvalidYaml(SchemaFault::because("document is not UTF-8")))?; + let config: Self = decode_yaml(text)?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return invalid("runtime version must equal 1"); + } + validate_absolute_path(&self.bundle_directory)?; + self.listener.validate()?; + if let Some(metrics) = &self.metrics_listener { + metrics.validate(&self.listener)?; + } + self.secret_providers.validate()?; + self.audit_storage.validate()?; + self.outbound_tls.validate() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeSecretProviders { + pub file: FileSecretProvider, +} + +impl RuntimeSecretProviders { + fn validate(&self) -> Result<(), ConfigError> { + self.file.validate() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FileSecretProvider { + pub root: String, +} + +impl FileSecretProvider { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.root) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditStorageConfig { + pub path: String, + pub maximum_file_bytes: u64, +} + +impl AuditStorageConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.path)?; + validate_range( + self.maximum_file_bytes, + 1_048_576, + 1_099_511_627_776, + "audit maximumFileBytes", + ) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutboundTlsConfig { + pub system_roots: bool, + pub trust_profiles: OrderedMap, +} + +impl OutboundTlsConfig { + fn validate(&self) -> Result<(), ConfigError> { + if !self.system_roots { + return invalid("outbound TLS system roots must remain enabled"); + } + validate_named_map(&self.trust_profiles, 0, 64, TrustProfileBinding::validate) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TrustProfileBinding { + pub ca_bundle_file: String, +} + +impl TrustProfileBinding { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.ca_bundle_file) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + pub bind_host: String, + pub port: u16, + pub tls_termination: TlsTermination, + pub trust_proxy_identity_headers: bool, + pub maximum_request_bytes: u64, + pub maximum_concurrent_requests: u32, + pub request_timeout_milliseconds: u64, + pub shutdown_grace_milliseconds: u64, +} + +impl ListenerConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_private_bind_host(&self.bind_host)?; + validate_listener_port(self.port)?; + if self.trust_proxy_identity_headers { + return invalid("proxy identity headers must not be trusted"); + } + validate_range( + self.maximum_request_bytes, + 1_024, + 1_048_576, + "maximumRequestBytes", + )?; + validate_range( + u64::from(self.maximum_concurrent_requests), + 1, + 4_096, + "maximumConcurrentRequests", + )?; + validate_range( + self.request_timeout_milliseconds, + 1, + 30_000, + "requestTimeoutMilliseconds", + )?; + validate_range( + self.shutdown_grace_milliseconds, + 1, + 120_000, + "shutdownGraceMilliseconds", + ) + } +} + +/// Operator-only telemetry listener. +/// +/// It is a separate binding rather than a route on the evidence listener so +/// that reaching the counters requires reaching a different socket. It carries +/// no request limits of its own: it serves one static rendering of in-process +/// counters, reads no request body, and touches no source or signing material. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MetricsListenerConfig { + pub bind_host: String, + pub port: u16, +} + +impl MetricsListenerConfig { + fn validate(&self, evidence_listener: &ListenerConfig) -> Result<(), ConfigError> { + validate_private_bind_host(&self.bind_host)?; + validate_listener_port(self.port)?; + // Sharing the evidence binding would publish the counters on the + // listener the public contract describes, which is the separation this + // block exists to enforce. + if self.bind_host == evidence_listener.bind_host && self.port == evidence_listener.port { + return invalid("metricsListener must not share the evidence listener binding"); + } + Ok(()) + } +} + +/// Refuse port 0, which asks the kernel for an arbitrary ephemeral port rather +/// than naming one. +/// +/// Every listener here is an operator-network binding that something upstream +/// firewalls, health-checks, or terminates TLS for, and none of that can follow +/// a port that is chosen at bind time and changes on every restart. The +/// published runtime schema already states the bound, so this is the loader +/// agreeing with the contract an operator validated against. +fn validate_listener_port(port: u16) -> Result<(), ConfigError> { + validate_range(u64::from(port), 1, 65_535, "port") +} + +/// Accept only numeric loopback, RFC 1918 private IPv4, and RFC 4193 +/// unique-local IPv6 bindings. Every listener this service opens is an +/// operator-network listener; TLS and exposure are upstream concerns. +fn validate_private_bind_host(bind_host: &str) -> Result<(), ConfigError> { + if bind_host.len() < 2 || bind_host.len() > 64 { + return invalid("listener bindHost length is invalid"); + } + let ip: IpAddr = bind_host + .parse() + .map_err(|_| ConfigError::Invalid("listener bindHost must be a private numeric IP"))?; + let private = match ip { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(), + IpAddr::V6(ip) => ip.is_loopback() || is_unique_local(ip), + }; + if !private || ip.is_unspecified() || ip.is_multicast() { + return invalid("listener bindHost must be loopback or private"); + } + Ok(()) +} + +fn is_unique_local(ip: Ipv6Addr) -> bool { + ip.octets()[0] & 0xfe == 0xfc +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TlsTermination { + OperatorControlledUpstream, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthenticationConfig { + pub kind: AuthenticationKind, + pub issuer: String, + pub audiences: Vec, + pub token_types: Vec, + pub algorithms: Vec, + pub jwks_uri: String, + pub principal_claim: String, + pub requester_tags_claim: String, + pub evidence_audience_claim: String, + pub grant_id_claim: String, + pub grant_authority_claim: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_claim: Option, +} + +impl AuthenticationConfig { + fn validate(&self, assurance_profile: AssuranceProfile) -> Result<(), ConfigError> { + let issuer = Url::parse(&self.issuer) + .map_err(|_| ConfigError::Invalid("authentication issuer is invalid"))?; + let jwks_uri = Url::parse(&self.jwks_uri) + .map_err(|_| ConfigError::Invalid("authentication JWKS URI is invalid"))?; + if issuer.scheme() == "http" || jwks_uri.scheme() == "http" { + if assurance_profile != AssuranceProfile::Local { + return invalid("production and evidence-grade authentication requires HTTPS"); + } + let origin = validate_local_mint_origin(&self.issuer)?; + if self.jwks_uri != format!("{origin}{LOCAL_MINT_JWKS_PATH}") { + return invalid( + "local authentication JWKS URI must use the issuer origin and Mint JWKS path", + ); + } + } else { + validate_https_issuer(&self.issuer)?; + validate_https_url(&self.jwks_uri, false)?; + } + validate_unique_strings(&self.audiences, 1, 16, 1, 512, "authentication audiences")?; + validate_unique(&self.token_types, 1, 4, "authentication tokenTypes")?; + validate_unique(&self.algorithms, 1, 3, "authentication algorithms")?; + // Ordered principal first, because `sub` is legitimate for that claim + // alone and the shadowing check below reads the rest of the list. + let claims = [ + Some(&self.principal_claim), + Some(&self.requester_tags_claim), + Some(&self.evidence_audience_claim), + Some(&self.grant_id_claim), + Some(&self.grant_authority_claim), + self.actor_claim.as_ref(), + ] + .into_iter() + .flatten() + .collect::>(); + for claim in &claims { + validate_claim_name(claim)?; + } + // Two claims naming one member means the same value is read as two + // different things: requester tags read as a principal, or a grant id + // read as the authority that granted it. + if claims.iter().collect::>().len() != claims.len() { + return invalid("authority claim names must be distinct"); + } + // These are defined by the token itself, so reading authority out of one + // reads something the issuer wrote for another purpose. `aud` is the + // sharpest: Evidence validates it against its own configured audiences, + // so a grant authority read from `aud` is Evidence's own name. + // + // `sub` is the exception, and only for the principal. It carries the + // principal already, so naming it there reads the same value; naming it + // anywhere else reads the principal as something it is not. + if claims + .iter() + .any(|claim| REGISTERED_JWT_CLAIMS.contains(&claim.as_str())) + || claims.iter().skip(1).any(|claim| claim.as_str() == "sub") + { + return invalid("authority claim names must not shadow registered JWT claims"); + } + Ok(()) + } + + pub(crate) fn uses_local_mint_http(&self, assurance_profile: AssuranceProfile) -> bool { + assurance_profile == AssuranceProfile::Local + && validate_local_mint_origin(&self.issuer).is_ok() + && self.jwks_uri == format!("{}{}", self.issuer, LOCAL_MINT_JWKS_PATH) + } +} + +/// Registered JWT claims no authority claim may be read from. `sub` is handled +/// separately, because the principal claim may legitimately name it. +/// +/// Mint refuses to write these when it mints. Evidence refuses to read them, +/// which is the check that still applies when the issuer is not Mint. +const REGISTERED_JWT_CLAIMS: [&str; 7] = ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"]; + +const LOCAL_MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; + +fn validate_local_mint_origin(value: &str) -> Result<&str, ConfigError> { + let port = value + .strip_prefix("http://127.0.0.1:") + .filter(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .filter(|port| !port.starts_with('0')) + .and_then(|port| port.parse::().ok()) + .filter(|port| *port != 0) + .ok_or(ConfigError::Invalid( + "local authentication issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", + ))?; + if value != format!("http://127.0.0.1:{port}") { + return invalid( + "local authentication issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", + ); + } + Ok(value) +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthenticationKind { + OidcAccessToken, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum AccessTokenType { + #[serde(rename = "at+jwt")] + AtJwt, + #[serde(rename = "application/at+jwt")] + ApplicationAtJwt, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum AccessTokenAlgorithm { + EdDSA, + ES256, + RS256, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SecretProvider { + Environment, + File, +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct SecretRef(String); + +impl SecretRef { + pub fn parse(value: &str) -> Result { + if let Some(name) = value.strip_prefix("secret:file/") { + if valid_file_secret_name(name) { + return Ok(Self(value.to_owned())); + } + } + invalid("secret reference does not use an exact permitted grammar") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn provider(&self) -> SecretProvider { + SecretProvider::File + } +} + +impl fmt::Debug for SecretRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SecretRef").field(&self.0).finish() + } +} + +impl<'de> Deserialize<'de> for SecretRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +impl Serialize for SecretRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn valid_file_secret_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditConfig { + pub format: AuditFormat, + pub hash_secret_ref: SecretRef, + pub hash_key_version: u32, + pub fail_closed: bool, +} + +impl AuditConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.hash_key_version == 0 || !self.fail_closed { + return invalid("audit must be versioned and fail closed"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditFormat { + KeyedJsonl, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubjectBindingConfig { + pub secret_ref: SecretRef, + pub key_version: u32, +} + +impl SubjectBindingConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.key_version == 0 { + return invalid("subject binding keyVersion must be positive"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RateLimitConfig { + pub requests_per_principal_per_minute: u64, + pub burst_per_principal: u64, + pub failed_selector_attempts_per_principal_authority_per_minute: u64, +} + +impl RateLimitConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_range( + self.requests_per_principal_per_minute, + 1, + 1_000_000, + "request rate limit", + )?; + validate_range(self.burst_per_principal, 1, 100_000, "burst rate limit")?; + validate_range( + self.failed_selector_attempts_per_principal_authority_per_minute, + 1, + 100_000, + "failed-selector rate limit", + ) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SigningConfig { + pub format: SigningFormat, + pub algorithm: SigningAlgorithm, + pub active_key_id: String, + pub active_key_ref: SecretRef, + pub retired_public_jwk_files: Vec, + pub jwks_path: String, + pub maximum_assertion_validity_seconds: u64, + pub verifier_clock_skew_seconds: u64, +} + +impl SigningConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_string(&self.active_key_id, 1, 256, "active signing key id")?; + if self.active_key_id.chars().any(char::is_control) { + return invalid("active signing key id contains a control character"); + } + validate_unique( + &self.retired_public_jwk_files, + 0, + 32, + "retired public JWK paths", + )?; + if self.jwks_path != "/.well-known/evidence/jwks.json" { + return invalid("JWKS path is not the Version 1 discovery path"); + } + validate_range( + self.maximum_assertion_validity_seconds, + 1, + 31_536_000, + "maximum assertion validity", + )?; + validate_range( + self.verifier_clock_skew_seconds, + 0, + 600, + "verifier clock skew", + ) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SigningFormat { + FlattenedJwsJson, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum SigningAlgorithm { + EdDSA, +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct PublicJwkPath(String); + +impl PublicJwkPath { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PublicJwkPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PublicJwkPath") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for PublicJwkPath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + if is_public_jwk_path(&value) { + Ok(Self(value)) + } else { + Err(de::Error::custom("invalid public JWK path")) + } + } +} + +impl Serialize for PublicJwkPath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn is_public_jwk_path(value: &str) -> bool { + let Some(name) = value.strip_prefix("public-keys/") else { + return false; + }; + let Some(stem) = name.strip_suffix(".jwk.json") else { + return false; + }; + !stem.is_empty() + && stem + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectorProfile { + pub maximum_aggregate_bytes: u64, + pub fields: OrderedMap, +} + +impl SelectorProfile { + fn validate(&self) -> Result<(), ConfigError> { + validate_range( + self.maximum_aggregate_bytes, + 1, + 8_192, + "selector maximumAggregateBytes", + )?; + validate_len(self.fields.len(), 1, 16, "selector fields")?; + for (name, field) in self.fields.iter() { + if !valid_field_name(name) { + return invalid("selector field name is invalid"); + } + field.validate(self.maximum_aggregate_bytes)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SelectorField { + String { + #[serde(rename = "minimumBytes")] + minimum_bytes: u64, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, + Date, + Integer { + minimum: i64, + maximum: i64, + }, + Boolean, + ControlledCode { + codelist: ArtifactPath, + #[serde(rename = "codelistVersion")] + codelist_version: String, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, +} + +impl SelectorField { + fn validate(&self, aggregate_maximum: u64) -> Result<(), ConfigError> { + match self { + Self::String { + minimum_bytes, + maximum_bytes, + } => { + validate_range(*minimum_bytes, 1, 8_192, "selector string minimumBytes")?; + validate_range(*maximum_bytes, 1, 8_192, "selector string maximumBytes")?; + if minimum_bytes > maximum_bytes || maximum_bytes > &aggregate_maximum { + return invalid("selector string byte bounds are inconsistent"); + } + } + Self::Integer { minimum, maximum } => { + if minimum > maximum || *minimum < -MAX_SAFE_INTEGER || *maximum > MAX_SAFE_INTEGER + { + return invalid("selector integer bounds are inconsistent"); + } + } + Self::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + } => { + require_artifact_prefix(codelist, "codelists/")?; + validate_string(codelist_version, 1, 128, "selector codelist version")?; + validate_range(*maximum_bytes, 1, 8_192, "selector code maximumBytes")?; + if maximum_bytes > &aggregate_maximum { + return invalid("selector code exceeds aggregate byte bound"); + } + } + Self::Date | Self::Boolean => {} + } + Ok(()) + } +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct ArtifactPath(String); + +impl ArtifactPath { + pub fn parse(value: &str) -> Result { + if !valid_artifact_path(value) { + return invalid("artifact path is invalid"); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ArtifactPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ArtifactPath") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for ArtifactPath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +impl Serialize for ArtifactPath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn valid_artifact_path(value: &str) -> bool { + const ROOTS: [&str; 5] = [ + "adapters/", + "derivations/", + "schemas/", + "codelists/", + "fixtures/", + ]; + ROOTS.iter().any(|root| value.starts_with(root)) + && !value.starts_with('/') + && !value.contains('\\') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) + && Path::new(value) + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceConfig { + pub transport: SourceTransport, + pub base_url: String, + pub posture: AcquisitionPosture, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_trust_profile: Option, + pub authentication: SourceAuthentication, + pub request: FixedRequest, + /// Shape contract for the projected response, validated by Rust before + /// extraction runs, so the script maps a response it can rely on. + pub response_schema: ArtifactPath, + pub extract_script: ArtifactPath, + pub fact_schema: ArtifactPath, +} + +impl SourceConfig { + fn validate(&self, assurance_profile: AssuranceProfile) -> Result<(), ConfigError> { + validate_source_origin(&self.base_url)?; + if self + .tls_trust_profile + .as_deref() + .is_some_and(|profile| !valid_local_id(profile)) + { + return invalid("source TLS trust profile identifier is invalid"); + } + if matches!(self.authentication, SourceAuthentication::None {}) { + if assurance_profile != AssuranceProfile::Local { + return invalid( + "unauthenticated sources are permitted only by the local assurance profile", + ); + } + validate_local_unauthenticated_source_origin(&self.base_url)?; + if self.tls_trust_profile.is_some() { + return invalid( + "an unauthenticated local HTTP source cannot use a TLS trust profile", + ); + } + } + self.authentication.validate()?; + self.request.validate()?; + require_artifact_prefix(&self.extract_script, "adapters/")?; + if !self.extract_script.as_str().ends_with(".rhai") { + return invalid("source extraction script must be a Rhai file"); + } + let adapter_id = Path::new(self.extract_script.as_str()) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| valid_local_id(value)) + .ok_or(ConfigError::Invalid( + "source adapter name must be a local identifier", + ))?; + debug_assert!(!adapter_id.is_empty()); + require_artifact_prefix(&self.response_schema, "schemas/")?; + require_artifact_prefix(&self.fact_schema, "schemas/")?; + let roles = [ + self.response_schema.as_str(), + self.fact_schema.as_str(), + self.request.adapter_parameters_schema.as_str(), + ]; + let distinct = roles.iter().collect::>(); + if distinct.len() != roles.len() { + return invalid("source schema roles must be distinct artifacts"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SourceTransport { + HttpJson, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AcquisitionPosture { + SourceDerived, + FieldProjected, + RecordTransformed, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SourceAuthentication { + /// No outbound credential is sent. + /// + /// The containing bundle validator admits this only for the local + /// assurance profile and a canonical numeric-loopback HTTP origin with an + /// explicit non-zero port. It is not a production authentication mode. + None {}, + Basic { + #[serde(rename = "usernameRef")] + username_ref: SecretRef, + #[serde(rename = "passwordRef")] + password_ref: SecretRef, + }, + StaticBearer { + #[serde(rename = "tokenRef")] + token_ref: SecretRef, + }, + StaticApiKey { + #[serde(rename = "headerName")] + header_name: String, + #[serde(rename = "valueRef")] + value_ref: SecretRef, + }, + Oauth2ClientCredentials { + #[serde(rename = "tokenEndpoint")] + token_endpoint: String, + #[serde(rename = "clientIdRef")] + client_id_ref: SecretRef, + #[serde(rename = "clientSecretRef")] + client_secret_ref: SecretRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(rename = "credentialPlacement")] + credential_placement: CredentialPlacement, + #[serde(rename = "maximumCacheSeconds")] + maximum_cache_seconds: u64, + /// Lifetime assumed when the provider omits `expires_in`. + /// + /// RFC 6749 section 5.1 makes `expires_in` recommended rather than + /// required, so a compliant provider may return only `access_token` + /// and `token_type`. The operator states the lifetime here rather than + /// the runtime inferring one from the token, and the cache is still + /// clamped to `maximumCacheSeconds`. + #[serde( + rename = "assumedLifetimeSeconds", + default, + skip_serializing_if = "Option::is_none" + )] + assumed_lifetime_seconds: Option, + }, +} + +impl SourceAuthentication { + fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::None {} => Ok(()), + Self::Basic { + username_ref: _, + password_ref: _, + } + | Self::StaticBearer { token_ref: _ } => Ok(()), + Self::StaticApiKey { + header_name, + value_ref: _, + } => validate_configurable_header_name(header_name), + Self::Oauth2ClientCredentials { + token_endpoint, + scope, + maximum_cache_seconds, + assumed_lifetime_seconds, + .. + } => { + let token_endpoint = validate_source_url(token_endpoint, false)?; + if token_endpoint.query().is_some() { + return invalid("OAuth token endpoint must not contain a query"); + } + if let Some(scope) = scope { + validate_string(scope, 1, 512, "OAuth scope")?; + } + if let Some(assumed_lifetime_seconds) = assumed_lifetime_seconds { + validate_range( + *assumed_lifetime_seconds, + 1, + 86_400, + "OAuth assumed token lifetime", + )?; + } + validate_range( + *maximum_cache_seconds, + 0, + 86_400, + "OAuth maximum cache lifetime", + ) + } + } + } + + pub fn secret_refs(&self) -> Vec<&SecretRef> { + match self { + Self::None {} => Vec::new(), + Self::Basic { + username_ref, + password_ref, + } => vec![username_ref, password_ref], + Self::StaticBearer { token_ref } => vec![token_ref], + Self::StaticApiKey { value_ref, .. } => vec![value_ref], + Self::Oauth2ClientCredentials { + client_id_ref, + client_secret_ref, + .. + } => vec![client_id_ref, client_secret_ref], + } + } +} + +/// Where the token request carries the client credentials. +/// +/// RFC 6749 section 2.3.1 defines Basic authentication and the request-body +/// parameters and states that those parameters must not be placed in the +/// request URI, so Version 1 offers no query-string placement. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialPlacement { + BasicHeader, + FormBody, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixedRequest { + pub method: HttpMethod, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_template: Option, + #[serde(default, skip_serializing_if = "OrderedMap::is_empty")] + pub path_bindings: OrderedMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fixed_headers: Vec, + pub selector_inputs: Vec, + pub prepare_script: ArtifactPath, + pub adapter_parameters: OrderedMap, + pub adapter_parameters_schema: ArtifactPath, + pub preparation_limits: PreparationLimits, + pub projection: Vec, + pub redirects: RedirectPolicy, + pub timeout_milliseconds: u64, + pub maximum_response_bytes: u64, + pub concurrency_limit: u16, +} + +impl FixedRequest { + fn validate(&self) -> Result<(), ConfigError> { + match (&self.path, &self.path_template) { + (Some(path), None) => { + validate_normalized_request_path(path)?; + if !self.path_bindings.is_empty() { + return invalid("fixed source path must not define pathBindings"); + } + } + (None, Some(template)) => validate_path_template(template, &self.path_bindings)?, + _ => return invalid("source request must define exactly one of path or pathTemplate"), + } + validate_fixed_headers(&self.fixed_headers)?; + validate_selector_inputs(&self.selector_inputs)?; + require_artifact_prefix(&self.prepare_script, "adapters/")?; + if !self.prepare_script.as_str().ends_with(".rhai") { + return invalid("source preparation script must be a Rhai file"); + } + validate_len(self.adapter_parameters.len(), 0, 64, "adapter parameters")?; + for (name, value) in self.adapter_parameters.iter() { + if !valid_parameter_key(name) { + return invalid("adapter parameter name is invalid"); + } + value.validate(0)?; + } + require_artifact_prefix(&self.adapter_parameters_schema, "schemas/")?; + self.preparation_limits.validate()?; + if self.method == HttpMethod::GET + && self.preparation_limits.json_body != PreparationChannelPolicy::Forbidden + { + return invalid("GET source requests must forbid the JSON body channel"); + } + validate_projection(&self.projection)?; + validate_range(self.timeout_milliseconds, 1, 30_000, "source timeout")?; + validate_range( + self.maximum_response_bytes, + 1, + 1_048_576, + "source response size", + )?; + validate_range( + u64::from(self.concurrency_limit), + 1, + 256, + "source concurrency", + )?; + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum HttpMethod { + GET, + POST, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RedirectPolicy { + Deny, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FixedHeader { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectorInput { + pub role: String, + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SelectorInputAlternative { + pub profile: String, + pub fields: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PathBindingConfig { + pub role: String, + pub profile: String, + pub field: String, +} + +impl PathBindingConfig { + fn validate(&self) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) + || !valid_local_id(&self.profile) + || !valid_field_name(&self.field) + { + return invalid("source selector binding identifier is invalid"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum AdapterParameterValue { + Boolean(bool), + Integer(i64), + String(String), + Array(Vec), + Object(OrderedMap), +} + +impl AdapterParameterValue { + fn validate(&self, depth: usize) -> Result<(), ConfigError> { + if depth > 32 { + return invalid("adapter parameter nesting exceeds Version 1 bounds"); + } + match self { + Self::Boolean(_) | Self::Integer(_) => Ok(()), + Self::String(value) => validate_string(value, 0, 16_384, "adapter parameter string"), + Self::Array(values) => { + validate_len(values.len(), 0, 256, "adapter parameter array")?; + for value in values { + value.validate(depth + 1)?; + } + Ok(()) + } + Self::Object(values) => { + validate_len(values.len(), 0, 256, "adapter parameter object")?; + for (name, value) in values.iter() { + if !valid_parameter_key(name) { + return invalid("adapter parameter object key is invalid"); + } + value.validate(depth + 1)?; + } + Ok(()) + } + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PreparationLimits { + pub query: PreparationChannelPolicy, + pub json_body: PreparationChannelPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_pairs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_name_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_value_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_json_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_collection_items: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_string_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_normalized_bytes: Option, +} + +impl PreparationLimits { + fn validate(&self) -> Result<(), ConfigError> { + if self.query == PreparationChannelPolicy::Forbidden + && self.json_body == PreparationChannelPolicy::Forbidden + { + return invalid("at least one preparation output channel must be usable"); + } + validate_optional_range(self.maximum_query_pairs, 1, 64)?; + validate_optional_range(self.maximum_query_name_bytes, 1, 64)?; + validate_optional_range(self.maximum_query_value_bytes, 1, 4_096)?; + validate_optional_range(self.maximum_json_depth, 1, 32)?; + validate_optional_range(self.maximum_collection_items, 1, 256)?; + validate_optional_range(self.maximum_string_bytes, 1, 16_384)?; + validate_optional_range(self.maximum_normalized_bytes, 1, 65_536) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PreparationChannelPolicy { + Required, + Allowed, + Forbidden, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityProfile { + pub kind: AuthorityKind, + pub requester_tags: Vec, + pub grants: Vec, +} + +impl AuthorityProfile { + fn validate(&self) -> Result<(), ConfigError> { + validate_unique_strings(&self.requester_tags, 1, 32, 1, 128, "requester tags")?; + if self.requester_tags.iter().any(|tag| !valid_local_id(tag)) { + return invalid("requester tag is invalid"); + } + validate_len(self.grants.len(), 1, 128, "authority grants")?; + for grant in &self.grants { + grant.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityKind { + Statutory, + Organizational, + Consent, + Delegated, + ExplicitRequest, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityGrant { + pub requirement: String, + pub purpose: String, + pub audience_from: AudienceFrom, + /// Closed response formats this complete grant permits. Selection through + /// the API creates no permission; the bundle formats and this grant must + /// both allow the requested format. Formats are never unioned across + /// grants. + #[serde(default = "default_response_formats")] + pub response_formats: Vec, + pub subjects: Vec, +} + +impl AuthorityGrant { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.requirement)?; + validate_purpose(&self.purpose)?; + validate_response_formats(&self.response_formats, "authority grant response formats")?; + validate_len(self.subjects.len(), 1, 8, "authority grant subjects") + } +} + +/// Closed Version 1 response-format vocabulary. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ResponseFormat { + SignedJws, + UnsignedJson, + /// Audience-scoped SD-JWT VC serialization of the same assertion. + SdJwtVc, +} + +fn default_response_formats() -> Vec { + vec![ResponseFormat::SignedJws] +} + +fn validate_response_formats( + formats: &[ResponseFormat], + description: &'static str, +) -> Result<(), ConfigError> { + validate_len(formats.len(), 1, 3, description)?; + let mut seen = BTreeSet::new(); + for format in formats { + if !seen.insert(format_discriminant(*format)) { + return invalid("response formats must be unique"); + } + } + if !formats.contains(&ResponseFormat::SignedJws) { + return invalid("signed JWS must remain an enabled response format"); + } + Ok(()) +} + +fn format_discriminant(format: ResponseFormat) -> u8 { + match format { + ResponseFormat::SignedJws => 0, + ResponseFormat::UnsignedJson => 1, + ResponseFormat::SdJwtVc => 2, + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AudienceFrom { + AuthenticatedRequester, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantedSubject { + pub role: String, + pub selector_profile: String, + pub value_origin: ValueOrigin, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_claims: Option>, +} + +impl GrantedSubject { + fn validate_value_claims(&self, profile: &SelectorProfile) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) || !valid_local_id(&self.selector_profile) { + return invalid("authority subject identifier is invalid"); + } + match self.value_origin { + ValueOrigin::Request => { + if self.value_claims.is_some() { + return invalid("request-derived subject must not define valueClaims"); + } + } + ValueOrigin::AuthenticatedContext | ValueOrigin::AuthenticatedGrant => { + let claims = self.value_claims.as_ref().ok_or(ConfigError::Invalid( + "context-derived subject requires valueClaims", + ))?; + if claims.len() != profile.fields.len() + || profile + .fields + .keys() + .any(|field| !claims.contains_key(field)) + || claims + .keys() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid("valueClaims must exactly equal selector profile fields"); + } + let mut targets = BTreeSet::new(); + for (_, claim) in claims.iter() { + validate_claim_path(claim)?; + if !targets.insert(claim.as_str()) { + return invalid("valueClaims targets must be unique"); + } + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ValueOrigin { + AuthenticatedContext, + AuthenticatedGrant, + Request, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RequirementConfig { + pub id: String, + pub kind: RequirementKind, + pub source: String, + pub purposes: Vec, + pub subject_roles: Vec, + pub reference_frameworks: Vec, + pub evidence_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observation_timezone: Option, + pub validity_seconds: u64, + pub derivation: DerivationConfig, + pub concepts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixtures: Option, + pub disclosure_guard: DisclosureGuard, + pub existence_disclosure: ExistenceDisclosure, +} + +impl RequirementConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.id)?; + if !valid_local_id(&self.source) { + return invalid("requirement source identifier is invalid"); + } + validate_unique_strings(&self.purposes, 1, 32, 1, 128, "requirement purposes")?; + for purpose in &self.purposes { + validate_purpose(purpose)?; + } + validate_len(self.subject_roles.len(), 1, 8, "requirement subject roles")?; + let mut roles = BTreeSet::new(); + for role in &self.subject_roles { + role.validate()?; + if !roles.insert(role.role.as_str()) { + return invalid("requirement subject roles must be unique"); + } + } + validate_unique_strings( + &self.reference_frameworks, + 1, + 16, + 1, + 512, + "reference frameworks", + )?; + for reference in &self.reference_frameworks { + validate_uri(reference)?; + } + validate_uri(&self.evidence_type)?; + if let Some(timezone) = &self.observation_timezone { + validate_string(timezone, 1, 128, "observation timezone")?; + chrono_tz::Tz::from_str(timezone).map_err(|_| { + ConfigError::Invalid("observation timezone is not an IANA timezone") + })?; + } + validate_range(self.validity_seconds, 1, 31_536_000, "requirement validity")?; + self.derivation.validate()?; + validate_len(self.concepts.len(), 1, 16, "requirement concepts")?; + let mut concepts = BTreeSet::new(); + let mut sd_jwt_claims = BTreeSet::new(); + for concept in &self.concepts { + concept.validate()?; + if !concepts.insert(concept.id.as_str()) { + return invalid("requirement concepts must be unique"); + } + if let Some(projection) = &concept.sd_jwt_vc { + if !sd_jwt_claims.insert(projection.claim.as_str()) { + return invalid("requirement SD-JWT VC claim names must be unique"); + } + } + } + if let Some(fixtures) = &self.fixtures { + require_artifact_prefix(fixtures, "fixtures/")?; + } + self.disclosure_guard.validate() + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementKind { + Criterion, + InformationRequirement, + Constraint, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubjectRole { + pub role: String, + pub cardinality: SubjectCardinality, + pub selector_profiles: Vec, +} + +impl SubjectRole { + fn validate(&self) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) { + return invalid("subject role identifier is invalid"); + } + validate_unique_strings( + &self.selector_profiles, + 1, + 16, + 1, + 128, + "role selector profiles", + )?; + if self + .selector_profiles + .iter() + .any(|profile| !valid_local_id(profile)) + { + return invalid("role selector profile identifier is invalid"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SubjectCardinality { + One, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DerivationConfig { + pub script: ArtifactPath, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_inputs: Vec, + pub parameters: OrderedMap, +} + +impl DerivationConfig { + fn validate(&self) -> Result<(), ConfigError> { + require_artifact_prefix(&self.script, "derivations/")?; + if !self.script.as_str().ends_with(".rhai") { + return invalid("derivation script must be a Rhai file"); + } + validate_derivation_input_shape(&self.selector_inputs)?; + validate_len(self.parameters.len(), 0, 32, "derivation parameters")?; + for (name, value) in self.parameters.iter() { + if !valid_field_name(name) { + return invalid("derivation parameter name is invalid"); + } + value.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ParameterValue { + String(String), + Integer(i64), + Boolean(bool), + Decimal(DecimalValue), + BucketBoundaries(Vec), +} + +impl ParameterValue { + fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::String(value) => validate_string(value, 0, 1_024, "derivation string parameter"), + Self::Integer(value) => { + if value.unsigned_abs() > MAX_SAFE_INTEGER as u64 { + invalid("derivation integer parameter exceeds safe bounds") + } else { + Ok(()) + } + } + Self::Boolean(_) => Ok(()), + Self::Decimal(value) => value.validate(), + Self::BucketBoundaries(boundaries) => validate_bucket_boundaries(boundaries), + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DecimalValue { + #[serde(rename = "type")] + pub value_type: DecimalMarker, + pub value: String, +} + +impl DecimalValue { + fn validate(&self) -> Result<(), ConfigError> { + validate_decimal(&self.value) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DecimalMarker { + Decimal, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BucketBoundary { + pub minimum_inclusive: DecimalValue, + pub maximum_exclusive: DecimalValue, + pub code: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConceptConfig { + pub id: String, + pub form: ConceptForm, + pub required: bool, + #[serde(default)] + pub constraints: OrderedMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sd_jwt_vc: Option, +} + +impl ConceptConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.id)?; + validate_len(self.constraints.len(), 0, 32, "concept constraints")?; + validate_concept_constraints(self)?; + if let Some(projection) = &self.sd_jwt_vc { + if self.form != ConceptForm::ReviewedStructuredValue { + return invalid("SD-JWT VC field projection requires a reviewed structured value"); + } + projection.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SdJwtVcConceptProjection { + pub claim: String, + pub disclosure: SdJwtVcDisclosureMode, +} + +impl SdJwtVcConceptProjection { + fn validate(&self) -> Result<(), ConfigError> { + if !valid_sd_jwt_claim_name(&self.claim) { + return invalid("SD-JWT VC structured claim name is invalid"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SdJwtVcDisclosureMode { + TopLevel, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConceptForm { + Boolean, + ControlledCode, + ControlledCategory, + BoundedInteger, + BoundedDecimal, + DateBucket, + TimeBucket, + AudienceScopedEntityReference, + ControlledCodeList, + EntityReferenceList, + ReviewedStructuredValue, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DisclosureGuard { + pub families: Vec, +} + +impl DisclosureGuard { + fn validate(&self) -> Result<(), ConfigError> { + validate_unique_strings(&self.families, 1, 16, 1, 512, "disclosure families")?; + for family in &self.families { + validate_uri(family)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExistenceDisclosure { + CollapseUnresolved, +} + +fn validate_named_map( + map: &OrderedMap, + minimum: usize, + maximum: usize, + validate: impl Fn(&T) -> Result<(), ConfigError>, +) -> Result<(), ConfigError> { + validate_len(map.len(), minimum, maximum, "named configuration map")?; + for (name, value) in map.iter() { + if !valid_local_id(name) { + return invalid("local identifier is invalid"); + } + validate(value)?; + } + Ok(()) +} + +fn require_artifact_prefix(path: &ArtifactPath, prefix: &'static str) -> Result<(), ConfigError> { + if path.as_str().starts_with(prefix) { + Ok(()) + } else { + invalid("artifact path has the wrong bundle directory") + } +} + +fn validate_selector_inputs(inputs: &[SelectorInput]) -> Result<(), ConfigError> { + validate_len(inputs.len(), 1, 8, "source selector inputs")?; + validate_derivation_input_shape(inputs) +} + +fn validate_derivation_input_shape(inputs: &[SelectorInput]) -> Result<(), ConfigError> { + validate_len(inputs.len(), 0, 8, "selector inputs")?; + let mut roles = BTreeSet::new(); + for input in inputs { + if !valid_local_id(&input.role) || !roles.insert(input.role.as_str()) { + return invalid("selector-input roles must be valid and unique"); + } + validate_len( + input.alternatives.len(), + 1, + 16, + "selector-input alternatives", + )?; + let mut profiles = BTreeSet::new(); + for alternative in &input.alternatives { + if !valid_local_id(&alternative.profile) + || !profiles.insert(alternative.profile.as_str()) + { + return invalid("selector-input profiles must be valid and unique per role"); + } + validate_unique_strings(&alternative.fields, 1, 16, 1, 64, "selector-input fields")?; + if alternative + .fields + .iter() + .any(|field| !valid_field_name(field)) + { + return invalid("selector-input field name is invalid"); + } + } + } + Ok(()) +} + +fn validate_derivation_selector_inputs( + requirement: &RequirementConfig, + profiles: &OrderedMap, +) -> Result<(), ConfigError> { + for input in &requirement.derivation.selector_inputs { + let role = requirement + .subject_roles + .iter() + .find(|role| role.role == input.role) + .ok_or(ConfigError::Invalid( + "derivation selector input references an unknown requirement role", + ))?; + for alternative in &input.alternatives { + if !role.selector_profiles.contains(&alternative.profile) { + return invalid( + "derivation selector input profile is not allowed for the requirement role", + ); + } + let profile = profiles + .get(&alternative.profile) + .ok_or(ConfigError::Invalid( + "derivation selector input references an unknown selector profile", + ))?; + if alternative + .fields + .iter() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid("derivation selector input references an unknown selector field"); + } + } + } + Ok(()) +} + +fn validate_fixed_headers(headers: &[FixedHeader]) -> Result<(), ConfigError> { + validate_len(headers.len(), 0, 32, "fixed headers")?; + let mut names = BTreeSet::new(); + for header in headers { + validate_configurable_header_name(&header.name)?; + if !names.insert(header.name.to_ascii_lowercase()) { + return invalid("fixed header names must be unique ignoring ASCII case"); + } + validate_string(&header.value, 0, 4_096, "fixed header value")?; + if header.value.chars().any(char::is_control) { + return invalid("fixed header value contains a control character"); + } + } + Ok(()) +} + +fn validate_configurable_header_name(name: &str) -> Result<(), ConfigError> { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(is_http_token_byte) + || is_reserved_header_name(name) + { + return invalid("configured header name is prohibited"); + } + Ok(()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +/// The complete closed set of header names no bundle may configure. +/// +/// Authentication, host and routing, cookie, framing, hop-by-hop, forwarding, +/// proxy, and tracing headers are owned by Rust or by the operator's network +/// path. A bundle that could set them could redirect a source request, forge a +/// client identity, or smuggle a second request past the reviewed contract. +const RESERVED_HEADER_NAMES: [&str; 38] = [ + "authorization", + "proxy-authorization", + "www-authenticate", + "proxy-authenticate", + "host", + "cookie", + "set-cookie", + "content-length", + "content-type", + "transfer-encoding", + "expect", + "connection", + "keep-alive", + "te", + "trailer", + "upgrade", + "proxy-connection", + "forwarded", + "via", + "x-real-ip", + "x-client-ip", + "x-cluster-client-ip", + "true-client-ip", + "cf-connecting-ip", + "fastly-client-ip", + "x-appengine-user-ip", + "x-azure-clientip", + "traceparent", + "tracestate", + "baggage", + "b3", + "x-cloud-trace-context", + "x-request-id", + "x-correlation-id", + "x-amzn-trace-id", + "x-original-url", + "x-rewrite-url", + "x-original-method", +]; + +/// The complete closed set of reserved header-name prefix families. +/// +/// A prefix family is denied before any exact name so that a new vendor +/// forwarding or tracing member cannot be configured before this contract +/// learns its exact name. +const RESERVED_HEADER_PREFIXES: [&str; 7] = [ + "x-forwarded-", + "proxy-", + "sec-", + "x-b3-", + "x-envoy-", + "x-datadog-", + "x-http-method", +]; + +/// Representative reserved names, case variants, and prefix-family members. +/// +/// Both the startup configuration contract and the source plan compiler are +/// tested against this one list, which is how their shared classifier is +/// proven to be a single closed deny set rather than two drifting copies. +pub const RESERVED_HEADER_CONTRACT_CASES: [&str; 51] = [ + "Authorization", + "authorization", + "AUTHORIZATION", + "Proxy-Authorization", + "Proxy-Authenticate", + "WWW-Authenticate", + "Host", + "Cookie", + "Set-Cookie", + "Content-Length", + "Content-Type", + "Transfer-Encoding", + "Expect", + "Connection", + "Keep-Alive", + "TE", + "Trailer", + "Upgrade", + "Proxy-Connection", + "Forwarded", + "Via", + "X-Real-IP", + "X-Client-IP", + "x-client-ip", + "X-Cluster-Client-IP", + "True-Client-IP", + "true-client-ip", + "CF-Connecting-IP", + "cf-connecting-ip", + "Fastly-Client-IP", + "X-Appengine-User-IP", + "X-Azure-ClientIP", + "TraceParent", + "Tracestate", + "Baggage", + "b3", + "B3", + "X-Cloud-Trace-Context", + "X-Request-ID", + "X-Correlation-ID", + "X-Amzn-Trace-ID", + "X-Original-URL", + "X-Rewrite-URL", + "X-HTTP-Method-Override", + "X-Original-Method", + "X-Forwarded-For", + "X-Forwarded-Proto", + "X-B3-TraceId", + "X-Envoy-External-Address", + "X-Datadog-Trace-Id", + "Sec-Fetch-Mode", +]; + +/// The one closed reserved-header classifier. +/// +/// `name` may be in any ASCII case. Configuration validation rejects a +/// reserved name at startup and source plan compilation rejects it again +/// before any credential is resolved, so both call sites share this function +/// rather than duplicating the deny set. +pub(crate) fn is_reserved_header_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + RESERVED_HEADER_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + || RESERVED_HEADER_NAMES.contains(&name.as_str()) +} + +fn validate_path_template( + template: &str, + bindings: &OrderedMap, +) -> Result<(), ConfigError> { + validate_string(template, 2, 2_048, "source path template")?; + if !template.starts_with('/') + || template.starts_with("//") + || template.contains(['?', '#', '\\']) + || !template.is_ascii() + { + return invalid("source path template is invalid"); + } + let mut placeholders = BTreeSet::new(); + let mut normalized = String::new(); + for segment in template.split('/').skip(1) { + if segment.is_empty() || matches!(segment, "." | "..") { + return invalid("source path template contains an empty or dot segment"); + } + normalized.push('/'); + if let Some(name) = segment + .strip_prefix('{') + .and_then(|segment| segment.strip_suffix('}')) + { + if !valid_field_name(name) || !placeholders.insert(name) { + return invalid("source path-template placeholders must be valid and unique"); + } + normalized.push('x'); + } else { + if segment.contains(['{', '}']) { + return invalid("source path-template placeholder must occupy a complete segment"); + } + normalized.push_str(segment); + } + } + validate_normalized_request_path(&normalized)?; + if placeholders.is_empty() || placeholders != bindings.keys().collect::>() { + return invalid("pathBindings must exactly match path-template placeholders"); + } + for (_, binding) in bindings.iter() { + binding.validate()?; + } + Ok(()) +} + +fn validate_projection(projection: &[String]) -> Result<(), ConfigError> { + validate_unique_strings(projection, 1, 64, 2, 256, "source projection")?; + let paths = projection + .iter() + .map(|path| parse_projection_pointer(path)) + .collect::, _>>()?; + for (index, left) in paths.iter().enumerate() { + for right in paths.iter().skip(index + 1) { + if projection_paths_overlap(left, right) { + return invalid("source projection paths must not duplicate or overlap"); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum ProjectionSegment { + Wildcard, + Key(String), +} + +fn parse_projection_pointer(pointer: &str) -> Result, ConfigError> { + if !pointer.starts_with('/') + || pointer.starts_with("//") + || pointer.chars().any(char::is_control) + { + return invalid("source projection is not an extended JSON Pointer"); + } + pointer[1..] + .split('/') + .map(|raw| { + if raw.is_empty() { + return invalid("source projection contains an empty segment"); + } + if raw == "*" { + return Ok(ProjectionSegment::Wildcard); + } + let mut decoded = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(character) = chars.next() { + if character == '~' { + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return invalid("source projection contains an invalid escape"), + } + } else { + decoded.push(character); + } + } + Ok(ProjectionSegment::Key(decoded)) + }) + .collect() +} + +fn projection_paths_overlap(left: &[ProjectionSegment], right: &[ProjectionSegment]) -> bool { + let common = left.len().min(right.len()); + left.iter().zip(right).take(common).all(|(left, right)| { + left == right + || matches!(left, ProjectionSegment::Wildcard) + || matches!(right, ProjectionSegment::Wildcard) + }) +} + +fn validate_optional_range( + value: Option, + minimum: u64, + maximum: u64, +) -> Result<(), ConfigError> { + value.map_or(Ok(()), |value| { + validate_range(value, minimum, maximum, "optional bound") + }) +} + +fn validate_source_origin(value: &str) -> Result<(), ConfigError> { + let url = validate_source_url(value, true)?; + if url.path() != "/" || url.query().is_some() { + return invalid("source baseUrl must contain only scheme, host, and optional port"); + } + Ok(()) +} + +/// Validate the only credential-free source boundary. +/// +/// This is deliberately narrower than the numeric-loopback exception used by +/// authenticated deterministic source mocks. The unauthenticated local mode +/// requires one exact origin spelling and an explicit port so a tutorial +/// bundle cannot silently inherit a default port, path, alias, or userinfo. +pub(crate) fn validate_local_unauthenticated_source_origin(value: &str) -> Result<(), ConfigError> { + let url = validate_source_url(value, true)?; + let port = url.port_or_known_default().ok_or(ConfigError::Invalid( + "unauthenticated local source origin requires an explicit non-zero port", + ))?; + let canonical = match url.host() { + Some(Host::Ipv4(ip)) if ip.is_loopback() => format!("http://{ip}:{port}"), + Some(Host::Ipv6(ip)) if ip.is_loopback() => format!("http://[{ip}]:{port}"), + _ => { + return invalid( + "unauthenticated local source origin must use a numeric loopback HTTP host", + ) + } + }; + if url.scheme() != "http" || value != canonical { + return invalid( + "unauthenticated local source origin must be a canonical numeric loopback HTTP origin with an explicit non-zero port", + ); + } + Ok(()) +} + +fn validate_source_url(value: &str, origin_only: bool) -> Result { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("source URL is invalid"))?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return invalid("source URL contains prohibited authority or fragment data"); + } + if origin_only && url.query().is_some() { + return invalid("source origin must not contain a query"); + } + match url.scheme() { + "https" => {} + "http" => { + match url.host() { + Some(Host::Ipv4(ip)) if ip.is_loopback() => {} + Some(Host::Ipv6(ip)) if ip.is_loopback() => {} + _ => return invalid("insecure source URL must use a numeric loopback host"), + } + if !has_canonical_loopback_authority(value) { + return invalid("insecure source URL host syntax is ambiguous"); + } + } + _ => return invalid("source URL scheme is not permitted"), + } + Ok(url) +} + +fn has_canonical_loopback_authority(value: &str) -> bool { + let Some(rest) = value.strip_prefix("http://") else { + return false; + }; + let authority = rest.split('/').next().unwrap_or(rest); + if let Some(suffix) = authority.strip_prefix("[::1]") { + return suffix.is_empty() || valid_port_suffix(suffix); + } + let (host, port) = authority + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .map_or((authority, None), |(host, port)| (host, Some(port))); + if port.is_some_and(|port| !valid_port(port)) { + return false; + } + let octets = host.split('.').collect::>(); + octets.len() == 4 + && octets[0] == "127" + && octets.iter().all(|octet| { + !octet.is_empty() + && (octet == &"0" || !octet.starts_with('0')) + && octet.bytes().all(|byte| byte.is_ascii_digit()) + && octet.parse::().is_ok() + }) +} + +fn valid_port_suffix(value: &str) -> bool { + value.strip_prefix(':').is_some_and(valid_port) +} + +fn valid_port(value: &str) -> bool { + !value.starts_with('0') && value.parse::().is_ok_and(|port| port != 0) +} + +fn validate_https_url(value: &str, origin_only: bool) -> Result<(), ConfigError> { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("HTTPS URL is invalid"))?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || (origin_only && (url.path() != "/" || url.query().is_some())) + { + return invalid("HTTPS URL violates the strict origin contract"); + } + Ok(()) +} + +fn validate_https_issuer(value: &str) -> Result<(), ConfigError> { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("HTTPS issuer is invalid"))?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return invalid("HTTPS issuer violates the exact issuer contract"); + } + Ok(()) +} + +fn validate_normalized_request_path(value: &str) -> Result<(), ConfigError> { + if value.len() < 2 + || !value.starts_with('/') + || value.starts_with("//") + || value.contains(['?', '#', '\\']) + || !value.is_ascii() + { + return invalid("source request path is invalid"); + } + let mut index = 0; + let bytes = value.as_bytes(); + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + || bytes[index + 1].is_ascii_lowercase() + || bytes[index + 2].is_ascii_lowercase() + { + return invalid("source request path contains a non-canonical escape"); + } + let decoded = u8::from_str_radix(&value[index + 1..index + 3], 16) + .map_err(|_| ConfigError::Invalid("source request path escape is invalid"))?; + if decoded.is_ascii_alphanumeric() + || matches!(decoded, b'-' | b'.' | b'_' | b'~' | b'/' | b'\\') + { + return invalid("source request path contains an ambiguous escape"); + } + index += 3; + continue; + } + if !(byte.is_ascii_alphanumeric() + || matches!( + byte, + b'/' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + | b'-' + )) + { + return invalid("source request path contains a prohibited character"); + } + index += 1; + } + if value + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + return invalid("source request path contains a dot segment"); + } + Ok(()) +} + +fn validate_bucket_boundaries(boundaries: &[BucketBoundary]) -> Result<(), ConfigError> { + validate_len(boundaries.len(), 1, 64, "bucket boundaries")?; + let mut codes = BTreeSet::new(); + let mut previous_maximum: Option<&str> = None; + for boundary in boundaries { + boundary.minimum_inclusive.validate()?; + boundary.maximum_exclusive.validate()?; + if compare_decimal_text( + &boundary.minimum_inclusive.value, + &boundary.maximum_exclusive.value, + ) != std::cmp::Ordering::Less + { + return invalid("bucket interval must be non-empty"); + } + if previous_maximum.is_some_and(|previous| { + compare_decimal_text(previous, &boundary.minimum_inclusive.value) + != std::cmp::Ordering::Equal + }) { + return invalid("bucket intervals must be ordered and contiguous"); + } + if !valid_code(&boundary.code) || !codes.insert(boundary.code.as_str()) { + return invalid("bucket code is invalid or duplicated"); + } + previous_maximum = Some(&boundary.maximum_exclusive.value); + } + Ok(()) +} + +fn validate_concept_constraints(concept: &ConceptConfig) -> Result<(), ConfigError> { + let required: &[&str] = match concept.form { + ConceptForm::Boolean => &[], + ConceptForm::ControlledCode => &["codelist", "codelistVersion", "maximumBytes"], + ConceptForm::ControlledCategory => &[ + "categoryScheme", + "schemeVersion", + "maximumBytes", + "codelist", + ], + ConceptForm::BoundedInteger => &["minimum", "maximum"], + ConceptForm::BoundedDecimal => &["minimum", "maximum", "maximumScale"], + ConceptForm::DateBucket | ConceptForm::TimeBucket => &["bucketScheme", "schemeVersion"], + ConceptForm::AudienceScopedEntityReference => &["maximumBytes"], + ConceptForm::ControlledCodeList => &[ + "codelist", + "codelistVersion", + "minimumItems", + "maximumItems", + "unique", + ], + ConceptForm::EntityReferenceList => &["minimumItems", "maximumItems", "unique"], + ConceptForm::ReviewedStructuredValue => &["schema", "maximumSerializedBytes"], + }; + if concept.constraints.len() != required.len() + || required + .iter() + .any(|name| !concept.constraints.contains_key(name)) + { + return invalid("concept constraints do not exactly match the declared value form"); + } + + match concept.form { + ConceptForm::Boolean => {} + ConceptForm::ControlledCode => { + validate_codelist_constraints(&concept.constraints, "codelistVersion")?; + } + ConceptForm::ControlledCategory => { + validate_uri(yaml_string(&concept.constraints, "categoryScheme")?)?; + validate_string( + yaml_string(&concept.constraints, "schemeVersion")?, + 1, + 128, + "scheme version", + )?; + validate_codelist_path(yaml_string(&concept.constraints, "codelist")?)?; + validate_constraint_u64(&concept.constraints, "maximumBytes", 1, 8_192)?; + } + ConceptForm::BoundedInteger => { + let minimum = yaml_i64(&concept.constraints, "minimum")?; + let maximum = yaml_i64(&concept.constraints, "maximum")?; + if minimum > maximum || minimum < -MAX_SAFE_INTEGER || maximum > MAX_SAFE_INTEGER { + return invalid("bounded integer constraints are invalid"); + } + } + ConceptForm::BoundedDecimal => { + let minimum = yaml_string(&concept.constraints, "minimum")?; + let maximum = yaml_string(&concept.constraints, "maximum")?; + validate_decimal(minimum)?; + validate_decimal(maximum)?; + if compare_decimal_text(minimum, maximum) == std::cmp::Ordering::Greater { + return invalid("bounded decimal constraints are invalid"); + } + validate_constraint_u64(&concept.constraints, "maximumScale", 0, 9)?; + } + ConceptForm::DateBucket | ConceptForm::TimeBucket => { + validate_uri(yaml_string(&concept.constraints, "bucketScheme")?)?; + validate_string( + yaml_string(&concept.constraints, "schemeVersion")?, + 1, + 128, + "scheme version", + )?; + } + ConceptForm::AudienceScopedEntityReference => { + validate_constraint_u64(&concept.constraints, "maximumBytes", 1, 8_192)?; + } + ConceptForm::ControlledCodeList => { + validate_codelist_path(yaml_string(&concept.constraints, "codelist")?)?; + validate_string( + yaml_string(&concept.constraints, "codelistVersion")?, + 1, + 128, + "codelist version", + )?; + validate_collection_constraints(&concept.constraints)?; + } + ConceptForm::EntityReferenceList => validate_collection_constraints(&concept.constraints)?, + ConceptForm::ReviewedStructuredValue => { + validate_uri(yaml_string(&concept.constraints, "schema")?)?; + validate_constraint_u64(&concept.constraints, "maximumSerializedBytes", 1, 65_536)?; + } + } + Ok(()) +} + +fn validate_codelist_constraints( + constraints: &OrderedMap, + version_key: &str, +) -> Result<(), ConfigError> { + validate_codelist_path(yaml_string(constraints, "codelist")?)?; + validate_string( + yaml_string(constraints, version_key)?, + 1, + 128, + "codelist version", + )?; + validate_constraint_u64(constraints, "maximumBytes", 1, 8_192).map(|_| ()) +} + +fn validate_collection_constraints(constraints: &OrderedMap) -> Result<(), ConfigError> { + let minimum = validate_constraint_u64(constraints, "minimumItems", 1, 64)?; + let maximum = validate_constraint_u64(constraints, "maximumItems", 1, 64)?; + if minimum > maximum || !yaml_bool(constraints, "unique")? { + return invalid("collection constraints are invalid"); + } + Ok(()) +} + +fn validate_codelist_path(value: &str) -> Result<(), ConfigError> { + let path = ArtifactPath::parse(value)?; + require_artifact_prefix(&path, "codelists/") +} + +fn yaml_string<'a>(map: &'a OrderedMap, key: &str) -> Result<&'a str, ConfigError> { + map.get(key) + .and_then(YamlValue::as_str) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong scalar type", + )) +} + +fn yaml_i64(map: &OrderedMap, key: &str) -> Result { + map.get(key) + .and_then(YamlValue::as_i64) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong integer type", + )) +} + +fn yaml_bool(map: &OrderedMap, key: &str) -> Result { + map.get(key) + .and_then(YamlValue::as_bool) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong boolean type", + )) +} + +fn validate_constraint_u64( + map: &OrderedMap, + key: &str, + minimum: u64, + maximum: u64, +) -> Result { + let value = map + .get(key) + .and_then(YamlValue::as_u64) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong integer type", + ))?; + validate_range(value, minimum, maximum, "concept constraint")?; + Ok(value) +} + +fn validate_decimal(value: &str) -> Result<(), ConfigError> { + if value.is_empty() + || value.starts_with('+') + || value == "-0" + || value.starts_with("-0.") + || value.contains(['e', 'E']) + { + return invalid("decimal text is not canonical"); + } + let unsigned = value.strip_prefix('-').unwrap_or(value); + let mut parts = unsigned.split('.'); + let integer = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() + || integer.is_empty() + || !integer.bytes().all(|byte| byte.is_ascii_digit()) + || (integer.len() > 1 && integer.starts_with('0')) + || fraction.is_some_and(|fraction| { + fraction.is_empty() + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.ends_with('0') + }) + { + return invalid("decimal text is not canonical"); + } + let scale = fraction.map_or(0, str::len); + let precision = integer.len() + scale; + if precision > 28 || scale > 9 { + return invalid("decimal precision or scale exceeds Version 1 bounds"); + } + Ok(()) +} + +fn compare_decimal_text(left: &str, right: &str) -> std::cmp::Ordering { + fn parts(value: &str) -> (bool, &str, &str) { + let negative = value.starts_with('-'); + let unsigned = value.strip_prefix('-').unwrap_or(value); + let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "")); + (negative, integer, fraction) + } + let (left_negative, left_integer, left_fraction) = parts(left); + let (right_negative, right_integer, right_fraction) = parts(right); + if left_negative != right_negative { + return if left_negative { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + let magnitude = left_integer + .len() + .cmp(&right_integer.len()) + .then_with(|| left_integer.cmp(right_integer)) + .then_with(|| { + let width = left_fraction.len().max(right_fraction.len()); + left_fraction + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width) + .cmp( + right_fraction + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width), + ) + }); + if left_negative { + magnitude.reverse() + } else { + magnitude + } +} + +fn validate_uri(value: &str) -> Result<(), ConfigError> { + validate_string(value, 1, 512, "URI")?; + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("URI is invalid"))?; + if url.scheme().is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { + return invalid("URI is invalid"); + } + Ok(()) +} + +fn validate_absolute_path(value: &str) -> Result<(), ConfigError> { + let path = Path::new(value); + if value.len() > 512 + || !value.starts_with('/') + || value.starts_with("//") + || value.contains('\\') + || !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return invalid("absolute operator path is invalid"); + } + Ok(()) +} + +fn validate_claim_name(value: &str) -> Result<(), ConfigError> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + { + return invalid("claim name is invalid"); + } + Ok(()) +} + +fn validate_claim_path(value: &str) -> Result<(), ConfigError> { + if value.len() > 512 { + return invalid("claim path is too long"); + } + for segment in value.split('.') { + let bytes = segment.as_bytes(); + if bytes.is_empty() + || !matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return invalid("claim path is invalid"); + } + } + Ok(()) +} + +fn validate_purpose(value: &str) -> Result<(), ConfigError> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !matches!(bytes.first(), Some(b'a'..=b'z')) + || !bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + { + return invalid("purpose code is invalid"); + } + Ok(()) +} + +fn valid_local_id(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn valid_sd_jwt_claim_name(value: &str) -> bool { + const RESERVED: [&str; 24] = [ + "iss", + "sub", + "aud", + "iat", + "nbf", + "exp", + "vct", + "id", + "jti", + "_sd", + "_sd_alg", + "cnf", + "status", + "issuedBy", + "providedBy", + "supportsRequirement", + "purpose", + "audience", + "assuranceProfile", + "observedAt", + "configurationRevision", + "requestNonce", + "subjects", + "structuredValues", + ]; + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + && !RESERVED.contains(&value) +} + +fn valid_field_name(value: &str) -> bool { + value.len() <= 64 && valid_local_id(value) +} + +fn valid_parameter_key(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn valid_code(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn validate_len( + length: usize, + minimum: usize, + maximum: usize, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&length) { + Ok(()) + } else { + invalid("collection cardinality is outside Version 1 bounds") + } +} + +fn validate_range( + value: u64, + minimum: u64, + maximum: u64, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&value) { + Ok(()) + } else { + invalid("numeric value is outside Version 1 bounds") + } +} + +fn validate_string( + value: &str, + minimum: usize, + maximum: usize, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&value.len()) && !value.contains('\0') { + Ok(()) + } else { + invalid("string length is outside Version 1 bounds") + } +} + +fn validate_unique( + values: &[T], + minimum: usize, + maximum: usize, + field: &'static str, +) -> Result<(), ConfigError> { + validate_len(values.len(), minimum, maximum, field)?; + if values.iter().collect::>().len() != values.len() { + return invalid("collection values must be unique"); + } + Ok(()) +} + +fn validate_unique_strings( + values: &[String], + minimum_items: usize, + maximum_items: usize, + minimum_bytes: usize, + maximum_bytes: usize, + field: &'static str, +) -> Result<(), ConfigError> { + validate_len(values.len(), minimum_items, maximum_items, field)?; + let mut seen = BTreeSet::new(); + for value in values { + validate_string(value, minimum_bytes, maximum_bytes, field)?; + if !seen.insert(value.as_str()) { + return invalid("collection values must be unique"); + } + } + Ok(()) +} + +fn invalid(reason: &'static str) -> Result { + Err(ConfigError::Invalid(reason)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assurance_profile_is_explicit_and_strict_profiles_require_fixtures() { + let strict = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + + let omitted_profile = strict.replace("assuranceProfile: evidence-grade\n", ""); + assert_ne!(omitted_profile, strict, "profile mutation must apply"); + assert!(EvidenceConfig::parse_yaml(omitted_profile.as_bytes()).is_err()); + + let without_fixtures = strict + .lines() + .filter(|line| !line.trim_start().starts_with("fixtures:")) + .collect::>() + .join("\n"); + for profile in ["production", "evidence-grade"] { + let candidate = without_fixtures.replace("evidence-grade", profile); + assert!( + EvidenceConfig::parse_yaml(candidate.as_bytes()).is_err(), + "{profile} accepted a requirement without fixtures" + ); + } + + let local = without_fixtures.replace("evidence-grade", "local"); + let parsed = EvidenceConfig::parse_yaml(local.as_bytes()) + .expect("local authoring accepts an omitted fixture reference"); + assert_eq!(parsed.assurance_profile, AssuranceProfile::Local); + assert!(parsed.requirements[0].fixtures.is_none()); + } + + #[test] + fn only_local_assurance_accepts_the_exact_loopback_mint_identity() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("strict fixture validates"); + config.assurance_profile = AssuranceProfile::Local; + config.authentication.issuer = "http://127.0.0.1:8081".to_owned(); + config.authentication.jwks_uri = "http://127.0.0.1:8081/.well-known/jwks.json".to_owned(); + config + .validate() + .expect("local profile accepts the supervised Mint identity"); + + for invalid in [ + "http://localhost:8081", + "http://127.0.0.2:8081", + "http://127.0.0.1", + "http://127.0.0.1:0", + "http://127.0.0.1:08081", + "http://127.0.0.1:65536", + "http://user@127.0.0.1:8081", + "http://127.0.0.1:8081/", + ] { + let mut candidate = config.clone(); + candidate.authentication.issuer = invalid.to_owned(); + assert!( + candidate.validate().is_err(), + "local assurance accepted issuer {invalid}" + ); + } + for invalid in [ + "http://127.0.0.1:8081/.well-known/keys.json", + "http://127.0.0.1:8082/.well-known/jwks.json", + "http://localhost:8081/.well-known/jwks.json", + "https://127.0.0.1:8081/.well-known/jwks.json", + ] { + let mut candidate = config.clone(); + candidate.authentication.jwks_uri = invalid.to_owned(); + assert!( + candidate.validate().is_err(), + "local assurance accepted JWKS URI {invalid}" + ); + } + + for profile in [ + AssuranceProfile::Production, + AssuranceProfile::EvidenceGrade, + ] { + let mut candidate = config.clone(); + candidate.assurance_profile = profile; + assert!( + candidate.validate().is_err(), + "{profile:?} inherited the local HTTP exception" + ); + } + } + + /// Two authority claims naming one JWT member, or naming a member the token + /// already defines, is a configuration the verifier must refuse. + /// + /// Mint refuses the same shapes when it mints (`ClaimNames::validate`), but + /// Mint is one possible issuer. Evidence is documented against any OIDC + /// issuer, and no other issuer enforces Mint's rules, so the deployment with + /// no issuer-side check is exactly the one where this is the only check. + /// `grantAuthorityClaim: aud` would read Evidence's own audience as the + /// authority that granted the request. + #[test] + fn authority_claim_names_must_be_distinct_and_must_not_shadow_registered_claims() { + let config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("strict fixture validates"); + config + .validate() + .expect("the fixture claim names are sound"); + + let mut duplicate = config.clone(); + duplicate + .authentication + .grant_id_claim + .clone_from(&config.authentication.grant_authority_claim); + assert_eq!( + duplicate.validate(), + invalid("authority claim names must be distinct"), + "one member read as both the grant id and the granting authority" + ); + + let mut duplicate_actor = config.clone(); + duplicate_actor.authentication.actor_claim = + Some(config.authentication.requester_tags_claim.clone()); + assert_eq!( + duplicate_actor.validate(), + invalid("authority claim names must be distinct"), + "one member read as both the actor and the requester tags" + ); + + for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { + let mut candidate = config.clone(); + candidate.authentication.grant_authority_claim = reserved.to_owned(); + assert_eq!( + candidate.validate(), + invalid("authority claim names must not shadow registered JWT claims"), + "grant authority read from the registered claim {reserved}" + ); + } + + // `sub` carries the principal, so the principal claim may name it and + // the fixture does. Any other claim naming it would read the principal. + let mut principal_is_subject = config.clone(); + principal_is_subject.authentication.principal_claim = "sub".to_owned(); + principal_is_subject + .validate() + .expect("the principal may be read from sub"); + // Moved off `sub` first, so this proves the shadowing rule rather than + // colliding with the principal and tripping distinctness instead. + let mut authority_is_subject = config.clone(); + authority_is_subject.authentication.principal_claim = "evidence_principal".to_owned(); + authority_is_subject.authentication.grant_authority_claim = "sub".to_owned(); + assert_eq!( + authority_is_subject.validate(), + invalid("authority claim names must not shadow registered JWT claims"), + "the granting authority read from the principal member" + ); + + let mut distinct = config.clone(); + distinct.authentication.actor_claim = Some("evidence_actor".to_owned()); + distinct + .validate() + .expect("distinct, unreserved claim names load"); + } + + #[test] + fn unauthenticated_source_is_local_loopback_only_and_matches_the_bundle_schema() { + let mut local = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("strict fixture validates"); + local.assurance_profile = AssuranceProfile::Local; + local.sources.0[0].1.authentication = SourceAuthentication::None {}; + + for origin in [ + "http://127.0.0.1:80", + "http://127.0.0.1:18081", + "http://127.42.5.9:1", + "http://[::1]:65535", + ] { + let mut candidate = local.clone(); + candidate.sources.0[0].1.base_url = origin.to_owned(); + candidate + .validate() + .unwrap_or_else(|_| panic!("local assurance rejected {origin}")); + assert!(candidate.sources.0[0] + .1 + .authentication + .secret_refs() + .is_empty()); + } + + for origin in [ + "https://127.0.0.1:18081", + "http://localhost:18081", + "http://127.0.0.1", + "http://127.0.0.1:0", + "http://127.0.0.1:018081", + "http://127.0.0.1:65536", + "http://127.00.0.1:18081", + "http://127.0.0.1:18081/", + "http://127.0.0.1:18081/data", + "http://127.0.0.1:18081?query=true", + "http://127.0.0.1:18081#fragment", + "http://user@127.0.0.1:18081", + "http://192.168.1.2:18081", + ] { + let mut candidate = local.clone(); + candidate.sources.0[0].1.base_url = origin.to_owned(); + assert!( + candidate.validate().is_err(), + "local assurance accepted unauthenticated origin {origin}" + ); + } + + let mut with_tls_profile = local.clone(); + with_tls_profile.sources.0[0].1.base_url = "http://127.0.0.1:18081".to_owned(); + with_tls_profile.sources.0[0].1.tls_trust_profile = Some("unused-local-ca".to_owned()); + assert!(with_tls_profile.validate().is_err()); + + for profile in [ + AssuranceProfile::Production, + AssuranceProfile::EvidenceGrade, + ] { + let mut candidate = local.clone(); + candidate.assurance_profile = profile; + candidate.sources.0[0].1.base_url = "http://127.0.0.1:18081".to_owned(); + assert!( + candidate.validate().is_err(), + "{profile:?} accepted an unauthenticated source" + ); + } + + let validator = bundle_contract_validator(); + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["assuranceProfile"] = serde_json::json!("local"); + instance["sources"]["source-a"]["baseUrl"] = serde_json::json!("http://127.0.0.1:18081"); + instance["sources"]["source-a"]["authentication"] = serde_json::json!({"kind": "none"}); + assert!( + validator.is_valid(&instance), + "schema accepts the local form" + ); + instance["assuranceProfile"] = serde_json::json!("production"); + assert!( + !validator.is_valid(&instance), + "schema rejects the local exception in a deployable profile" + ); + + assert!( + serde_json::from_value::( + serde_json::json!({"kind": "none", "tokenRef": "secret:file/unexpected"}) + ) + .is_err(), + "the none variant is closed" + ); + } + + fn bundle_contract_validator() -> jsonschema::JSONSchema { + let schema: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/bundle.schema.yaml" + )) + .expect("bundle contract is YAML"); + let schema = serde_json::to_value(schema).expect("bundle contract converts to JSON"); + jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("bundle contract compiles") + } + + fn runtime_contract_validator() -> jsonschema::JSONSchema { + let schema: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/runtime.schema.yaml" + )) + .expect("runtime contract is YAML"); + let schema = serde_json::to_value(schema).expect("runtime contract converts to JSON"); + jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("runtime contract compiles") + } + + fn bundle_contract_instance(yaml: &[u8]) -> serde_json::Value { + let value: serde_norway::Value = + serde_norway::from_slice(yaml).expect("bundle instance is YAML"); + serde_json::to_value(value).expect("bundle instance converts to JSON") + } + + /// Canary scalars planted in a malformed document. + /// + /// A diagnostic that ever reproduces one of these has leaked a deployment + /// value, which is exactly what the safe-diagnostic contract forbids. + const CANARY_VALUES: [&str; 4] = [ + "s3cr3t-selector-value", + "urn:gov:example:canary:subject:9910", + "secret:file/canary-private-key", + "https://canary.internal.example", + ]; + + #[test] + fn decode_failures_report_a_safe_path_a_location_and_a_value_free_cause() { + let reference = include_str!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml"); + let unknown_nested = reference.replacen( + " timeoutMilliseconds: 3000", + " timeoutMilliseconds: 3000\n surprise: s3cr3t-selector-value", + 1, + ); + assert_ne!(unknown_nested, reference, "nested mutation applies"); + let cases: [(&str, String, &str, Option<&str>, bool); 6] = [ + ( + "malformed YAML", + format!("version: 1\nbroken: [{}\n", CANARY_VALUES[0]), + "document is not well-formed YAML", + None, + true, + ), + ( + "unknown top-level field", + format!("version: 1\nbogusField: {}\n", CANARY_VALUES[1]), + "unknown field", + None, + true, + ), + ( + "unknown nested field", + unknown_nested, + "unknown field", + Some("sources.registered-birth-date.request"), + true, + ), + ( + "wrong type", + format!("version: {}\n", CANARY_VALUES[2]), + "field has the wrong type", + Some("version"), + true, + ), + ( + "missing field", + "version: 1\n".to_owned(), + "required field is missing", + None, + true, + ), + ( + "more than one document", + format!("version: 1\n---\nversion: {}\n", CANARY_VALUES[3]), + "document contains more than one YAML document", + None, + false, + ), + ]; + for (label, document, expected_cause, expected_path, expects_location) in cases { + let error = EvidenceConfig::parse_yaml(document.as_bytes()) + .err() + .unwrap_or_else(|| panic!("{label} was accepted")); + let ConfigError::InvalidYaml(fault) = &error else { + panic!("{label} was not reported as a decode failure: {error}"); + }; + assert_eq!(fault.cause(), expected_cause, "{label} cause"); + assert_eq!(fault.path(), expected_path, "{label} path"); + assert_eq!( + fault.location().is_some(), + expects_location, + "{label} location presence" + ); + let rendered = error.to_string(); + for canary in CANARY_VALUES { + assert!( + !rendered.contains(canary), + "{label} diagnostic leaked a document value: {rendered}" + ); + } + } + } + + #[test] + fn schema_paths_are_accepted_only_when_they_carry_no_document_value() { + for safe in [ + "version", + "sources.registered-birth-date.request", + "sources.a.request.fixedHeaders[0].name", + "requirements[12].concepts[3].id", + "sources.a.?", + ] { + assert!(is_safe_schema_path(safe), "{safe} is a schema path"); + } + for unsafe_candidate in [ + "", + "invalid type", + "invalid value", + "sources.urn:gov:example", + "sources.\"quoted key\"", + "sources.a[]", + "sources.a[x]", + "sources.a[0", + "sources.a/b", + &"a".repeat(MAX_SCHEMA_PATH_BYTES + 1), + ] { + assert!( + !is_safe_schema_path(unsafe_candidate), + "{unsafe_candidate} is not a schema path" + ); + } + } + + #[test] + fn exact_secret_reference_grammars_are_closed() { + for valid in ["secret:file/a", "secret:file/source-token_v2.json"] { + assert!(SecretRef::parse(valid).is_ok(), "{valid}"); + } + for invalid in [ + "secret:env/", + "secret:env/SOURCE_2_PASSWORD", + "secret:env/lower", + "secret:env/A-B", + "secret:file/Upper", + "secret:file/../token", + "secret:file/.token", + "plain-value", + ] { + assert!(SecretRef::parse(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn decimal_comparison_is_exact() { + assert_eq!( + compare_decimal_text("-10.5", "-2"), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_decimal_text("1.2", "1.20"), + std::cmp::Ordering::Equal + ); + assert_eq!(compare_decimal_text("10", "2"), std::cmp::Ordering::Greater); + } + + #[test] + fn all_coequal_acceptance_definitions_use_the_same_typed_config() { + for yaml in [ + include_bytes!("../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/professional-licence/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml").as_slice(), + ] { + EvidenceConfig::parse_yaml(yaml).expect("acceptance definition must validate"); + } + } + + #[test] + fn requirement_validity_cannot_exceed_the_signing_maximum() { + // Startup validation is the enforcement point: runtime construction + // derives validUntil from the validated requirement validity, so no + // constructed assertion can exceed the bundle signing maximum and no + // redundant signing-time check exists. + let yaml = include_str!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + ); + assert!(EvidenceConfig::parse_yaml(yaml.as_bytes()).is_ok()); + let shrunk_maximum = yaml.replace( + "maximumAssertionValiditySeconds: 86400", + "maximumAssertionValiditySeconds: 3600", + ); + assert!(matches!( + EvidenceConfig::parse_yaml(shrunk_maximum.as_bytes()), + Err(ConfigError::Invalid( + "requirement validity exceeds signing maximum validity" + )) + )); + } + + #[test] + fn response_formats_are_closed_unique_and_keep_signed_mandatory() { + let yaml = include_str!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + ); + for (from, to) in [ + // The bundle cannot drop the mandatory signed format. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [unsigned-json]", + ), + // Formats must be unique. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, signed-jws]", + ), + // A grant cannot drop the mandatory signed format either. + ( + " responseFormats: [signed-jws, unsigned-json]\n subjects:\n - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}", + " responseFormats: [unsigned-json]\n subjects:\n - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}", + ), + // The vocabulary is closed. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, jws-detached]", + ), + ] { + let mutated = yaml.replace(from, to); + assert_ne!(mutated, yaml, "{to}"); + assert!( + EvidenceConfig::parse_yaml(mutated.as_bytes()).is_err(), + "{to}" + ); + } + } + + #[test] + fn bundle_contract_accepts_every_complete_version_one_bundle() { + let validator = bundle_contract_validator(); + for yaml in [ + include_bytes!("../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/professional-licence/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/conformance/selectors/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml").as_slice(), + ] { + assert!(validator.is_valid(&bundle_contract_instance(yaml))); + } + } + + #[test] + fn bundle_contract_closes_concept_constraints_by_form() { + let validator = bundle_contract_validator(); + let valid = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml" + )); + assert!(validator.is_valid(&valid)); + + let mut misspelled = valid.clone(); + let constraints = misspelled["requirements"][0]["concepts"][1]["constraints"] + .as_object_mut() + .expect("controlled-code constraints are an object"); + let version = constraints + .remove("codelistVersion") + .expect("canonical constraint exists"); + constraints.insert("codelist_version".to_owned(), version); + assert!(!validator.is_valid(&misspelled)); + + let mut unsupported = valid; + unsupported["requirements"][0]["concepts"][0]["constraints"]["maximumBytes"] = + serde_json::json!(32); + assert!(!validator.is_valid(&unsupported)); + + let mut structured_projection = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml" + )); + structured_projection["requirements"][0]["concepts"][10]["sdJwtVc"] = + serde_json::json!({"claim": "birthCertificate", "disclosure": "top-level"}); + assert!(validator.is_valid(&structured_projection)); + structured_projection["requirements"][0]["concepts"][10] + .as_object_mut() + .expect("concept is an object") + .remove("sdJwtVc"); + structured_projection["requirements"][0]["concepts"][0]["sdJwtVc"] = + serde_json::json!({"claim": "birthCertificate", "disclosure": "top-level"}); + assert!(!validator.is_valid(&structured_projection)); + } + + #[test] + fn structured_sd_jwt_claim_projection_is_generic_unique_and_non_reserved() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml" + )) + .expect("supported values fixture validates"); + let structured_index = config.requirements[0] + .concepts + .iter() + .position(|concept| concept.form == ConceptForm::ReviewedStructuredValue) + .expect("fixture has a structured concept"); + config.requirements[0].concepts[structured_index].sd_jwt_vc = + Some(SdJwtVcConceptProjection { + claim: "anyReviewedRecord".to_owned(), + disclosure: SdJwtVcDisclosureMode::TopLevel, + }); + config.validate().expect("generic claim name is accepted"); + + config.requirements[0].concepts[structured_index] + .sd_jwt_vc + .as_mut() + .expect("projection exists") + .claim = "iss".to_owned(); + assert!( + config.validate().is_err(), + "profile claim names are reserved" + ); + + config.requirements[0].concepts[structured_index] + .sd_jwt_vc + .as_mut() + .expect("projection exists") + .claim = "duplicateClaim".to_owned(); + let mut duplicate = config.requirements[0].concepts[structured_index].clone(); + duplicate.id = "urn:example:fixture:concept:another-structured-value".to_owned(); + config.requirements[0].concepts.push(duplicate); + assert!(matches!( + config.validate(), + Err(ConfigError::Invalid( + "requirement SD-JWT VC claim names must be unique" + )) + )); + + config.requirements[0].concepts.pop(); + let projection = config.requirements[0].concepts[structured_index] + .sd_jwt_vc + .take(); + config.requirements[0].concepts[0].sd_jwt_vc = projection; + assert!(matches!( + config.validate(), + Err(ConfigError::Invalid( + "SD-JWT VC field projection requires a reviewed structured value" + )) + )); + } + + #[test] + fn typed_config_rejects_noncanonical_constraint_names() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let misspelled = valid.replacen("codelistVersion", "codelist_version", 1); + assert_ne!(misspelled, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(misspelled.as_bytes()).is_err()); + } + + #[test] + fn source_schema_roles_are_mandatory_distinct_and_directory_scoped() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!(EvidenceConfig::parse_yaml(valid.as_bytes()).is_ok()); + + for (from, to) in [ + // The response contract is mandatory, so extraction never runs + // behind an undeclared response shape. + (" responseSchema: schemas/response.schema.yaml\n", ""), + // Every schema artifact is directory-scoped like the other roles. + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: adapters/response.schema.yaml", + ), + // One artifact cannot carry two schema roles inside one source. + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: schemas/facts.schema.yaml", + ), + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: schemas/adapter-parameters.schema.yaml", + ), + ] { + let mutated = valid.replace(from, to); + assert_ne!(mutated, valid, "fixture mutation must remain effective"); + assert!( + EvidenceConfig::parse_yaml(mutated.as_bytes()).is_err(), + "{to}" + ); + } + } + + #[test] + fn schema_roles_do_not_overlap_across_sources() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!(EvidenceConfig::parse_yaml(valid.as_bytes()).is_ok()); + + // One artifact validating a response for one source and facts for + // another would make a single review cover two different contracts. + let crossed = valid.replacen( + "responseSchema: schemas/adult-status-response.schema.yaml", + "responseSchema: schemas/residence-region-facts.schema.yaml", + 1, + ); + assert_ne!(crossed, valid, "fixture mutation must remain effective"); + assert!(matches!( + EvidenceConfig::parse_yaml(crossed.as_bytes()), + Err(ConfigError::Invalid( + "source schema roles must not overlap across sources" + )) + )); + } + + #[test] + fn yaml_names_and_secret_references_are_strict() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!( + EvidenceConfig::parse_yaml(valid.replace("providerId", "provider_id").as_bytes()) + .is_err() + ); + let unexpected = valid.replacen( + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance}", + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance, unexpected: true}", + 1, + ); + assert_ne!(unexpected, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(unexpected.as_bytes()).is_err()); + let literal_secret = valid.replacen( + "activeKeyRef: secret:file/signing-key", + "activeKeyRef: literal-private-key", + 1, + ); + assert_ne!( + literal_secret, valid, + "fixture mutation must remain effective" + ); + assert!(EvidenceConfig::parse_yaml(literal_secret.as_bytes(),).is_err()); + assert!(EvidenceConfig::parse_yaml( + valid + .replace( + "activeKeyId: fixture-key-2026-01", + "activeKeyId: \"fixture-key\\u000A2026-01\"", + ) + .as_bytes(), + ) + .is_err()); + } + + #[test] + fn context_and_grant_claim_maps_are_exact_and_non_aliasing() { + let profile: SelectorProfile = serde_norway::from_str( + "maximumAggregateBytes: 32\nfields:\n alpha: {type: string, minimumBytes: 1, maximumBytes: 16}\n beta: {type: boolean}\n", + ) + .expect("selector profile parses"); + let exact: GrantedSubject = serde_norway::from_str( + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-context\nvalueClaims: {alpha: claims.alpha, beta: claims.beta}\n", + ) + .expect("subject parses"); + assert!(exact.validate_value_claims(&profile).is_ok()); + + for invalid_subject in [ + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-context\nvalueClaims: {alpha: claims.alpha}\n", + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-grant\nvalueClaims: {alpha: claims.same, beta: claims.same}\n", + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: request\nvalueClaims: {alpha: claims.alpha, beta: claims.beta}\n", + ] { + let subject: GrantedSubject = + serde_norway::from_str(invalid_subject).expect("subject shape parses"); + assert!(subject.validate_value_claims(&profile).is_err()); + } + } + + #[test] + fn complete_authority_paths_cannot_be_unioned_across_partial_grants() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml" + )) + .expect("fixture validates"); + config.authority_profiles.0[0].1.grants[0].subjects.pop(); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "authority grant must bind the complete subject-role set" + )) + ); + } + + #[test] + fn active_source_role_sets_reject_unreachable_inputs_at_startup() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml" + )) + .expect("fixture validates"); + config.sources.0[0].1.request.selector_inputs[0] + .alternatives + .push(SelectorInputAlternative { + profile: "person-reference-v1".to_owned(), + fields: vec!["person_reference".to_owned()], + }); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "source selector input is unreachable from every complete authority path" + )) + ); + } + + #[test] + fn one_source_may_serve_mutually_exclusive_complete_role_sets() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("adult fixture validates"); + + let mut alternative = config.requirements[0].clone(); + alternative.id = "urn:example:fixture:requirement:adult-status-alternative:v1".to_owned(); + alternative.subject_roles[0].role = "alternate-subject".to_owned(); + alternative.evidence_type = + "urn:example:fixture:evidence-type:adult-status-alternative:v1".to_owned(); + alternative.derivation.script = + ArtifactPath::parse("derivations/adult-status-alternative.rhai") + .expect("alternative derivation path"); + alternative.concepts[0].id = + "urn:example:fixture:concept:adult-status-alternative".to_owned(); + alternative.disclosure_guard.families[0] = + "urn:example:fixture:disclosure-family:adult-status-alternative".to_owned(); + + let mut grant = config.authority_profiles.0[0].1.grants[0].clone(); + grant.requirement = alternative.id.clone(); + grant.subjects[0].role = "alternate-subject".to_owned(); + config.authority_profiles.0[0].1.grants.push(grant); + + let alternative_inputs = config.sources.0[0] + .1 + .request + .selector_inputs + .iter() + .cloned() + .map(|mut input| { + input.role = "alternate-subject".to_owned(); + input + }) + .collect::>(); + config.sources.0[0] + .1 + .request + .selector_inputs + .extend(alternative_inputs); + config.requirements.push(alternative); + + config + .validate() + .expect("mutually exclusive role sets may reuse fixed placements"); + assert_eq!( + config.source_selector_sets("source-a"), + vec![ + vec![( + "alternate-subject".to_owned(), + "person-demographics-v1".to_owned() + )], + vec![("subject".to_owned(), "person-demographics-v1".to_owned())] + ] + ); + } + + #[test] + fn one_trust_domain_and_native_token_identity_are_closed_configuration() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let invalid = valid.replacen( + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance}", + "service: {providerId: urn:example:fixture:provider:evidence, trustDomains: [urn:example:fixture:trust-domain:a, urn:example:fixture:trust-domain:b]}", + 1, + ); + assert_ne!(invalid, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(invalid.as_bytes()).is_err()); + } + + #[test] + fn source_urls_reject_insecure_aliases_and_ambiguous_numeric_hosts() { + for valid in [ + "https://source.invalid", + "http://127.0.0.1:18081", + "http://127.42.5.9", + "http://[::1]:18083", + ] { + assert!(validate_source_origin(valid).is_ok(), "{valid}"); + } + for invalid in [ + "http://localhost:18081", + "http://127.1:18081", + "http://127.00.0.1:18081", + "http://192.168.1.2", + "https://user@source.invalid", + "https://source.invalid/path", + "https://source.invalid#fragment", + ] { + assert!(validate_source_origin(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn source_adapter_name_is_audit_safe_and_oauth_endpoint_has_no_query() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + + let uppercase_adapter = valid.replace( + "extractScript: adapters/source-a.rhai", + "extractScript: adapters/Source-a.rhai", + ); + assert_eq!( + EvidenceConfig::parse_yaml(uppercase_adapter.as_bytes()), + Err(ConfigError::Invalid( + "source adapter name must be a local identifier" + )) + ); + + for query in [ + "?client_secret=plaintext", + "?client_id=duplicate", + "?fixed=true", + ] { + let mut oauth = + EvidenceConfig::parse_yaml(valid.as_bytes()).expect("fixture validates"); + oauth.sources.0[0].1.authentication = SourceAuthentication::Oauth2ClientCredentials { + token_endpoint: format!("https://source.invalid/token{query}"), + client_id_ref: SecretRef::parse("secret:file/oauth-client-id").expect("secret ref"), + client_secret_ref: SecretRef::parse("secret:file/oauth-client-secret") + .expect("secret ref"), + scope: None, + credential_placement: CredentialPlacement::FormBody, + maximum_cache_seconds: 60, + assumed_lifetime_seconds: None, + }; + assert_eq!( + oauth.validate(), + Err(ConfigError::Invalid( + "OAuth token endpoint must not contain a query" + )), + "{query}" + ); + } + } + + /// The assumed lifetime is a governed positive duration, so a zero or + /// oversized value is a configuration error rather than a silent clamp. + #[test] + fn oauth_assumed_token_lifetime_is_a_bounded_positive_duration() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + + for (assumed_lifetime_seconds, expected) in [ + ( + Some(0), + Err(ConfigError::Invalid( + "numeric value is outside Version 1 bounds", + )), + ), + (Some(1), Ok(())), + (Some(86_400), Ok(())), + ( + Some(86_401), + Err(ConfigError::Invalid( + "numeric value is outside Version 1 bounds", + )), + ), + (None, Ok(())), + ] { + let mut oauth = + EvidenceConfig::parse_yaml(valid.as_bytes()).expect("fixture validates"); + oauth.sources.0[0].1.authentication = SourceAuthentication::Oauth2ClientCredentials { + token_endpoint: "https://source.invalid/token".to_owned(), + client_id_ref: SecretRef::parse("secret:file/oauth-client-id").expect("secret ref"), + client_secret_ref: SecretRef::parse("secret:file/oauth-client-secret") + .expect("secret ref"), + scope: None, + credential_placement: CredentialPlacement::FormBody, + maximum_cache_seconds: 60, + assumed_lifetime_seconds, + }; + assert_eq!(oauth.validate(), expected, "{assumed_lifetime_seconds:?}"); + } + } + + /// Query-string placement puts the client id and secret in a URL that + /// authorization-server, proxy, and ingress logs capture, and RFC 6749 + /// section 2.3.1 requires those parameters to travel in the request body. + /// Version 1 accepts only the two placements the specification defines, + /// and the runtime and the published contract must agree on that. + #[test] + fn oauth_credential_placement_rejects_the_query_string_placement() { + let validator = bundle_contract_validator(); + for (placement, accepted) in [ + ("basic-header", true), + ("form-body", true), + ("query-string", false), + ] { + let authentication = serde_json::json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": "https://source.invalid/token", + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "credentialPlacement": placement, + "maximumCacheSeconds": 60, + }); + assert_eq!( + serde_json::from_value::(authentication.clone()).is_ok(), + accepted, + "{placement} runtime deserialization" + ); + + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["sources"]["source-a"]["authentication"] = authentication; + assert_eq!( + validator.is_valid(&instance), + accepted, + "{placement} bundle contract" + ); + } + } + + #[test] + fn get_sources_must_forbid_the_json_body_channel() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + config.sources.0[0].1.request.method = HttpMethod::GET; + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "GET source requests must forbid the JSON body channel" + )) + ); + + let validator = bundle_contract_validator(); + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["sources"]["source-a"]["request"]["method"] = serde_json::json!("GET"); + assert!(!validator.is_valid(&instance)); + } + + #[test] + fn a_shared_disclosure_family_rejects_the_complete_bundle() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + let mut duplicate = config.requirements[0].clone(); + duplicate.id = "urn:example:fixture:requirement:other:v1".to_owned(); + duplicate.evidence_type = "urn:example:fixture:evidence-type:other:v1".to_owned(); + duplicate.derivation.script = + ArtifactPath::parse("derivations/other.rhai").expect("artifact path"); + duplicate.concepts[0].id = "urn:example:fixture:concept:other".to_owned(); + config.requirements.push(duplicate); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "enabled requirements share a disclosure family" + )) + ); + } + + #[test] + fn runtime_document_is_closed_and_contains_no_governed_override_surface() { + let valid = br#" +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/secrets/registry-evidence} +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: + internal-pki: {caBundleFile: /etc/registry-evidence/ca/internal.pem} +"#; + RuntimeConfig::parse_yaml(valid).expect("closed runtime parses"); + let validator = runtime_contract_validator(); + assert!(validator.is_valid(&bundle_contract_instance(valid))); + for reference in [ + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml").as_slice(), + ] { + assert!(validator.is_valid(&bundle_contract_instance(reference))); + RuntimeConfig::parse_yaml(reference).expect("reference runtime matches Rust contract"); + } + for rejected_host in ["evidence.internal", "0.0.0.0", "8.8.8.8", "ff02::1"] { + let candidate = String::from_utf8(valid.to_vec()) + .expect("runtime fixture is UTF-8") + .replace("bindHost: 127.0.0.1", &format!("bindHost: {rejected_host}")); + assert!( + RuntimeConfig::parse_yaml(candidate.as_bytes()).is_err(), + "runtime accepted prohibited bindHost {rejected_host}" + ); + } + for governed_key in [ + "service", + "issuer", + "authentication", + "audit", + "subjectBinding", + "rateLimits", + "signing", + "selectorProfiles", + "sources", + "authorityProfiles", + "requirements", + ] { + let mut candidate = valid.to_vec(); + candidate.extend_from_slice(format!("{governed_key}: {{}}\n").as_bytes()); + let rejection = RuntimeConfig::parse_yaml(&candidate) + .expect_err("runtime accepted governed bundle key {governed_key}"); + let ConfigError::InvalidYaml(fault) = &rejection else { + panic!("runtime accepted governed bundle key {governed_key}: {rejection}"); + }; + assert_eq!( + fault.cause(), + "unknown field", + "governed bundle key {governed_key} was rejected for the wrong reason" + ); + assert!( + fault.location().is_some(), + "governed bundle key {governed_key} was rejected without a location" + ); + assert!( + !validator.is_valid(&bundle_contract_instance(&candidate)), + "runtime schema accepted governed bundle key {governed_key}" + ); + } + } + + /// The metrics listener is opt-in operator surface. It must be absent + /// unless an operator asks for it, must obey the same private-address rule + /// as the evidence listener, and must not be able to reuse the evidence + /// binding, which would publish counters on the contract listener. + #[test] + fn the_optional_metrics_listener_is_absent_by_default_and_stays_operator_private() { + let base = r#" +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/secrets/registry-evidence} +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {} +"#; + let validator = runtime_contract_validator(); + let default = RuntimeConfig::parse_yaml(base.as_bytes()).expect("closed runtime parses"); + assert!( + default.metrics_listener.is_none(), + "a deployment that asked for no metrics listener must not get one" + ); + + let configured = format!("{base}metricsListener:\n bindHost: 127.0.0.1\n port: 9090\n"); + assert!(validator.is_valid(&bundle_contract_instance(configured.as_bytes()))); + let parsed = + RuntimeConfig::parse_yaml(configured.as_bytes()).expect("metrics listener parses"); + let metrics = parsed + .metrics_listener + .expect("the configured metrics listener is retained"); + assert_eq!(metrics.bind_host, "127.0.0.1"); + assert_eq!(metrics.port, 9090); + + for rejected_host in ["evidence.internal", "0.0.0.0", "8.8.8.8", "ff02::1"] { + let candidate = + format!("{base}metricsListener:\n bindHost: {rejected_host}\n port: 9090\n"); + assert!( + RuntimeConfig::parse_yaml(candidate.as_bytes()).is_err(), + "metrics listener accepted prohibited bindHost {rejected_host}" + ); + } + + // Reusing the evidence binding would put the counters on the listener + // the public contract describes. + let shared = format!("{base}metricsListener:\n bindHost: 127.0.0.1\n port: 8080\n"); + assert!(matches!( + RuntimeConfig::parse_yaml(shared.as_bytes()), + Err(ConfigError::Invalid( + "metricsListener must not share the evidence listener binding" + )) + )); + + // The block is closed like every other level of the document. + let unknown = format!( + "{base}metricsListener:\n bindHost: 127.0.0.1\n port: 9090\n path: /telemetry\n" + ); + assert!(RuntimeConfig::parse_yaml(unknown.as_bytes()).is_err()); + assert!(!validator.is_valid(&bundle_contract_instance(unknown.as_bytes()))); + } + + /// Port 0 is not a port. The kernel picks an arbitrary one, so the socket an + /// operator firewalls, health-checks, and puts behind their TLS terminator + /// is not the socket the service opens, and it changes on every restart. + /// The published runtime schema already forbids it on both listeners; the + /// loader accepting it meant a deployment could pass the documented contract + /// check and still come up on an address nobody configured. On the metrics + /// listener it also defeats the binding-collision refusal, which compares + /// configured ports rather than bound ones. + #[test] + fn a_listener_port_of_zero_is_refused_on_both_listeners() { + let base = r#" +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/secrets/registry-evidence} +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {} +"#; + let validator = runtime_contract_validator(); + RuntimeConfig::parse_yaml(base.as_bytes()).expect("the configured ports load"); + + let ephemeral_evidence = base.replace("port: 8080", "port: 0"); + assert!( + RuntimeConfig::parse_yaml(ephemeral_evidence.as_bytes()).is_err(), + "the evidence listener accepted an ephemeral port" + ); + assert!( + !validator.is_valid(&bundle_contract_instance(ephemeral_evidence.as_bytes())), + "the published schema must already refuse this, so Rust is matching it" + ); + + let ephemeral_metrics = + format!("{base}metricsListener:\n bindHost: 127.0.0.1\n port: 0\n"); + assert!( + RuntimeConfig::parse_yaml(ephemeral_metrics.as_bytes()).is_err(), + "the metrics listener accepted an ephemeral port" + ); + assert!(!validator.is_valid(&bundle_contract_instance(ephemeral_metrics.as_bytes()))); + + // Both at zero would compare equal and trip the collision rule instead, + // so the port rule has to be the one that fires. + let both = format!( + "{}metricsListener:\n bindHost: 127.0.0.1\n port: 0\n", + base.replace("port: 8080", "port: 0") + ); + assert!(RuntimeConfig::parse_yaml(both.as_bytes()).is_err()); + } + + #[test] + fn path_templates_headers_and_projection_fail_closed() { + let bindings: OrderedMap = serde_norway::from_str( + "record_reference: {role: subject, profile: record-reference-v1, field: record_reference}\n", + ) + .expect("path binding parses"); + assert!(validate_path_template("/records/{record_reference}", &bindings).is_ok()); + for invalid_template in [ + "/records/{record_reference}/", + "/records/prefix-{record_reference}", + "/records/{missing}", + "/records/../{record_reference}", + "/records/{record_reference}/{record_reference}", + ] { + assert!(validate_path_template(invalid_template, &bindings).is_err()); + } + + assert!(validate_configurable_header_name("X-API-Version").is_ok()); + for forbidden in RESERVED_HEADER_CONTRACT_CASES { + assert!( + validate_configurable_header_name(forbidden).is_err(), + "{forbidden}" + ); + } + + assert!(validate_projection(&[ + "/total".to_owned(), + "/results/*/status".to_owned(), + "/declaration/mother.personReference".to_owned(), + ]) + .is_ok()); + for paths in [ + vec!["/results".to_owned(), "/results/*/status".to_owned()], + vec![ + "/results/*/status".to_owned(), + "/results/0/status".to_owned(), + ], + vec!["/bad/~2escape".to_owned()], + ] { + assert!(validate_projection(&paths).is_err()); + } + } + + #[test] + fn path_based_https_oidc_issuer_is_preserved_exactly() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + config.authentication.issuer = "https://identity.example.test/realms/registry".to_owned(); + assert!(config.validate().is_ok()); + config.authentication.issuer.push_str("?tenant=wrong"); + assert!(config.validate().is_err()); + } + + #[test] + fn file_secret_references_are_the_only_governed_secret_form() { + assert!(SecretRef::parse("secret:file/source-token").is_ok()); + assert!(SecretRef::parse("secret:env/SOURCE_TOKEN").is_err()); + assert!(SecretRef::parse("literal-token").is_err()); + } +} diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs new file mode 100644 index 000000000..8ac58518d --- /dev/null +++ b/crates/registry-evidence/src/contracts.rs @@ -0,0 +1,1517 @@ +//! Deterministic public-contract generation for Evidence Version 1. +//! +//! The generated files are release artifacts. This module is their only +//! source and deliberately has no dependency on a deployment bundle. + +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use jsonschema::{Draft, JSONSchema}; +use schemars::JsonSchema; +use serde_json::{json, Value}; +use thiserror::Error; + +use crate::model::{ + Evidence, EvidenceDefinitions, EvidenceRequest, FlattenedJws, JwksDocument, ProblemBody, + UnsignedEvidenceEnvelope, +}; + +pub const OPENAPI_FILE: &str = "registry-evidence.openapi.json"; +pub const REQUEST_SCHEMA_FILE: &str = "evidence-request-v1.schema.json"; +pub const EVIDENCE_SCHEMA_FILE: &str = "evidence-v1.schema.json"; +pub const DEFINITIONS_SCHEMA_FILE: &str = "evidence-definitions-v1.schema.json"; +pub const JWS_SCHEMA_FILE: &str = "flattened-jws-v1.schema.json"; +pub const UNSIGNED_ENVELOPE_SCHEMA_FILE: &str = "evidence-unsigned-envelope-v1.schema.json"; +pub const PROBLEM_SCHEMA_FILE: &str = "problem-v1.schema.json"; +pub const JWKS_SCHEMA_FILE: &str = "jwks-v1.schema.json"; + +const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; +const REQUEST_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/request-v1.json"; +const EVIDENCE_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json"; +const DEFINITIONS_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/definitions-v1.json"; +const JWS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/flattened-jws-v1.json"; +const UNSIGNED_ENVELOPE_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/unsigned-envelope-v1.json"; +const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; +const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; +const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; +/// Shape of the server-minted operation identifier, shared by the response +/// header and the problem member so the two cannot describe different values. +const OPERATION_PATTERN: &str = "^[0-9A-HJKMNP-TV-Z]{26}$"; +/// Unpadded base64url encoding of exactly one 32-byte Ed25519 public key. +const HOLDER_KEY_COORDINATE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; +const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ + ("malformed_request", 400, "Request is not valid"), + ("invalid_selector", 400, "Request is not valid"), + ("authentication_failed", 401, "Authentication failed"), + ("not_authorized", 403, "Request is not authorized"), + ( + "response_format_not_acceptable", + 406, + "Requested response format is not acceptable", + ), + ( + "evidence_not_available", + 422, + "Evidence could not be produced", + ), + ("rate_limited", 429, "Request rate exceeded"), + ( + "dependency_unavailable", + 503, + "Service temporarily unavailable", + ), + ( + "service_unavailable", + 503, + "Service temporarily unavailable", + ), +]; + +static SERVED_OPENAPI: OnceLock> = OnceLock::new(); +static REQUEST_VALIDATOR: OnceLock> = OnceLock::new(); +static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); +static DEFINITIONS_VALIDATOR: OnceLock> = + OnceLock::new(); + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("built-in public contract schema failed to initialize")] +pub(crate) struct ContractValidationError; + +#[derive(Debug, Error)] +pub enum ContractGenerationError { + #[error("generated contract serialization failed")] + Serialization(#[from] serde_json::Error), + #[error("generated contract output failed at {path}")] + Output { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("generated contract no longer matches the public Rust wire type {type_name}")] + ModelDrift { type_name: &'static str }, +} + +/// Generate every committed public contract, keyed by its stable filename. +pub fn documents() -> Result, ContractGenerationError> { + let request = request_schema(); + let evidence = evidence_schema(); + let definitions = definitions_schema(); + let jws = jws_schema(); + let unsigned = unsigned_envelope_schema(); + let problem = problem_schema(); + let jwks = jwks_schema(); + assert_model_shape::("EvidenceRequest", &request, true)?; + assert_model_shape::("Evidence", &evidence, true)?; + assert_model_shape::("EvidenceDefinitions", &definitions, true)?; + assert_model_shape::("FlattenedJws", &jws, false)?; + assert_model_shape::("UnsignedEvidenceEnvelope", &unsigned, false)?; + assert_model_shape::("ProblemBody", &problem, false)?; + assert_model_shape::("JwksDocument", &jwks, false)?; + let openapi = openapi_document( + &request, + &evidence, + &definitions, + &jws, + &unsigned, + &problem, + &jwks, + ); + + let values = [ + (REQUEST_SCHEMA_FILE, request), + (EVIDENCE_SCHEMA_FILE, evidence), + (DEFINITIONS_SCHEMA_FILE, definitions), + (JWS_SCHEMA_FILE, jws), + (UNSIGNED_ENVELOPE_SCHEMA_FILE, unsigned), + (PROBLEM_SCHEMA_FILE, problem), + (JWKS_SCHEMA_FILE, jwks), + (OPENAPI_FILE, openapi), + ]; + values + .into_iter() + .map(|(name, value)| Ok((name, pretty_json(&value)?))) + .collect() +} + +/// Write all generated contracts to an otherwise caller-owned directory. +pub fn write_documents(output: &Path) -> Result<(), ContractGenerationError> { + fs::create_dir_all(output).map_err(|source| ContractGenerationError::Output { + path: output.to_path_buf(), + source, + })?; + for (name, contents) in documents()? { + let path = output.join(name); + fs::write(&path, contents) + .map_err(|source| ContractGenerationError::Output { path, source })?; + } + Ok(()) +} + +/// The generated OpenAPI document the running service publishes. +/// +/// It is built once from the same generator as [`documents`], so the served +/// description is the committed release artifact and cannot drift from it. +pub(crate) fn served_openapi_document() -> Option<&'static str> { + SERVED_OPENAPI + .get_or_init(|| { + pretty_json(&openapi_document( + &request_schema(), + &evidence_schema(), + &definitions_schema(), + &jws_schema(), + &unsigned_envelope_schema(), + &problem_schema(), + &jwks_schema(), + )) + .ok() + }) + .as_deref() +} + +/// Validate an inbound public request against the exact generated Version 1 schema. +pub(crate) fn request_contract_accepts(value: &Value) -> Result { + contract_validator(&REQUEST_VALIDATOR, request_schema) + .map(|validator| validator.is_valid(value)) +} + +/// Validate a verified JWS payload against the exact generated Version 1 schema. +pub(crate) fn evidence_contract_accepts(value: &Value) -> Result { + contract_validator(&EVIDENCE_VALIDATOR, evidence_schema) + .map(|validator| validator.is_valid(value)) +} + +/// Validate an outbound discovery response against the exact generated +/// Version 1 schema. +pub(crate) fn definitions_contract_accepts(value: &Value) -> Result { + contract_validator(&DEFINITIONS_VALIDATOR, definitions_schema) + .map(|validator| validator.is_valid(value)) +} + +fn contract_validator( + cell: &'static OnceLock>, + schema: fn() -> Value, +) -> Result<&'static JSONSchema, ContractValidationError> { + match cell.get_or_init(|| { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema()) + .map_err(|_| ContractValidationError) + }) { + Ok(validator) => Ok(validator), + Err(error) => Err(*error), + } +} + +fn assert_model_shape( + type_name: &'static str, + contract: &Value, + compare_nested_properties: bool, +) -> Result<(), ContractGenerationError> { + let derived = serde_json::to_value(schemars::schema_for!(T))?; + let matches = if compare_nested_properties { + property_groups(&derived) == property_groups(contract) + } else { + root_properties(&derived) == root_properties(contract) + }; + if matches { + Ok(()) + } else { + Err(ContractGenerationError::ModelDrift { type_name }) + } +} + +fn root_properties(value: &Value) -> Vec { + value + .get("properties") + .and_then(Value::as_object) + .map(|properties| properties.keys().cloned().collect()) + .unwrap_or_default() +} + +fn property_groups(value: &Value) -> Vec> { + fn visit(value: &Value, groups: &mut Vec>) { + match value { + Value::Array(values) => { + for value in values { + visit(value, groups); + } + } + Value::Object(object) => { + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + groups.push(properties.keys().cloned().collect()); + } + for value in object.values() { + visit(value, groups); + } + } + _ => {} + } + } + + let mut groups = Vec::new(); + visit(value, &mut groups); + groups.sort(); + groups +} + +fn pretty_json(value: &Value) -> Result { + let mut rendered = serde_json::to_string_pretty(value)?; + rendered.push('\n'); + Ok(rendered) +} + +fn request_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": REQUEST_SCHEMA_ID, + "title": "Evidence request Version 1", + "type": "object", + "additionalProperties": false, + "required": ["requestNonce", "requirement", "purpose", "subjects"], + "properties": { + "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, + "requirement": {"type": "string", "format": "uri", "minLength": 1, "maxLength": 512}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, + "items": {"$ref": "#/$defs/subject"} + }, + "holderKey": {"$ref": "#/$defs/holder-key"} + }, + "$defs": { + "subject": { + "type": "object", "additionalProperties": false, + "required": ["role", "selector"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "selector": {"$ref": "#/$defs/selector"} + } + }, + "selector": { + "type": "object", "additionalProperties": false, + "required": ["profile"], + "properties": { + "profile": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,127}$"}, + "values": { + "type": "object", "minProperties": 1, "maxProperties": 16, + "propertyNames": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "additionalProperties": {"$ref": "#/$defs/scalar-selector-value"} + } + } + }, + "scalar-selector-value": { + "oneOf": [ + {"type": "string", "minLength": 1, "maxLength": 512}, + {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + {"type": "boolean"} + ] + }, + "holder-key": { + "type": "object", "additionalProperties": false, + "required": ["kty", "crv", "x"], + "properties": { + "kty": {"type": "string", "enum": ["OKP"]}, + "crv": {"type": "string", "enum": ["Ed25519"]}, + "x": {"type": "string", "pattern": HOLDER_KEY_COORDINATE_PATTERN}, + "alg": {"type": "string", "enum": ["EdDSA"]}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + }, + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it. holderKey is meaningful only for the SD-JWT VC response format, where it is echoed into the cnf claim; it never reaches authorization, selectors, Rhai, source requests, or audit, and a key carrying any private member is rejected before credential acquisition or source access." + }) +} + +fn definitions_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": DEFINITIONS_SCHEMA_ID, + "title": "Requester-scoped Evidence definitions Version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "assuranceProfile", "configurationRevision", "issuedBy", "providedBy", "definitions" + ], + "properties": { + "schema": {"const": "registry.evidence-definitions/v1"}, + "assuranceProfile": {"enum": ["local", "production", "evidence-grade"]}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "definitions": { + "type": "array", "maxItems": 16384, "uniqueItems": true, + "items": {"$ref": "#/$defs/definition"} + } + }, + "$defs": { + "definition": { + "type": "object", "additionalProperties": false, + "required": [ + "requirement", "kind", "evidenceType", "purpose", + "referenceFrameworks", "subjects", "concepts" + ], + "properties": { + "requirement": {"type": "string", "format": "uri", "maxLength": 512}, + "kind": {"enum": ["criterion", "information-requirement", "constraint"]}, + "evidenceType": {"type": "string", "format": "uri", "maxLength": 512}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "referenceFrameworks": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"type": "string", "format": "uri", "maxLength": 512} + }, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, + "items": {"$ref": "#/$defs/subject"} + }, + "concepts": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"$ref": "#/$defs/concept"} + } + } + }, + "subject": { + "type": "object", "additionalProperties": false, + "required": ["role", "cardinality", "selector"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "cardinality": {"const": "one"}, + "selector": {"$ref": "#/$defs/selector"} + } + }, + "selector": { + "type": "object", "additionalProperties": false, + "required": ["profile", "valueOrigin", "fields"], + "properties": { + "profile": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,127}$"}, + "valueOrigin": {"enum": ["request", "authenticated-context", "authenticated-grant"]}, + "fields": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"$ref": "#/$defs/selector-field"} + } + } + }, + "selector-field": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "minimumBytes", "maximumBytes"], + "properties": { + "type": {"const": "string"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "minimumBytes": {"type": "integer", "minimum": 1, "maximum": 8192}, + "maximumBytes": {"type": "integer", "minimum": 1, "maximum": 8192} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": {"const": "date"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "minimum", "maximum"], + "properties": { + "type": {"const": "integer"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "minimum": {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + "maximum": {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": {"const": "boolean"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "scheme", "version", "maximumBytes"], + "properties": { + "type": {"const": "controlled-code"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "scheme": {"type": "string", "format": "uri", "maxLength": 512}, + "version": {"type": "string", "minLength": 1, "maxLength": 128}, + "maximumBytes": {"type": "integer", "minimum": 1, "maximum": 8192} + } + } + ] + }, + "concept": { + "type": "object", "additionalProperties": false, + "required": ["id", "form"], + "properties": { + "id": {"type": "string", "format": "uri", "maxLength": 512}, + "form": {"enum": [ + "boolean", "controlled-code", "controlled-category", "bounded-integer", + "bounded-decimal", "date-bucket", "time-bucket", + "audience-scoped-entity-reference", "controlled-code-list", + "entity-reference-list", "reviewed-structured-value" + ]} + } + } + }, + "$comment": "The authenticated response contains only complete request shapes that match exactly one configured authority path. It never exposes source plans, scripts, credentials, requester tags, authority-profile identifiers, selector values, codelist values, or unrelated definitions." + }) +} + +fn evidence_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": EVIDENCE_SCHEMA_ID, + "title": "Evidence assertion payload Version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "assuranceProfile", "requestNonce", "id", "type", "supportsRequirement", "isConformantTo", + "issuedBy", "providedBy", "issuedAt", "observedAt", "validUntil", + "purpose", "audience", "configurationRevision", "subjects", "supportedValues" + ], + "properties": { + "schema": {"const": "registry.assertion-evidence/v1"}, + "assuranceProfile": {"enum": ["local", "production", "evidence-grade"]}, + "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, + "id": {"type": "string", "format": "uri", "maxLength": 512}, + "type": {"const": "Evidence"}, + "supportsRequirement": {"type": "string", "format": "uri", "maxLength": 512}, + "isConformantTo": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedAt": {"type": "string", "format": "date-time"}, + "observedAt": {"type": "string", "format": "date-time"}, + "validUntil": {"type": "string", "format": "date-time"}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "audience": {"type": "string", "format": "uri", "maxLength": 512}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, + "items": {"$ref": "#/$defs/subject-binding"} + }, + "supportedValues": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": {"$ref": "#/$defs/supported-value"} + } + }, + "$defs": { + "subject-binding": { + "type": "object", "additionalProperties": false, + "required": ["role", "binding"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "binding": {"type": "string", "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "supported-value": { + "type": "object", "additionalProperties": false, + "required": ["providesValueFor", "value"], + "properties": { + "providesValueFor": {"type": "string", "format": "uri", "maxLength": 512}, + "value": {"$ref": "#/$defs/value"} + } + }, + "value": { + "anyOf": [ + {"type": "boolean"}, + {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/bucket"}, + {"$ref": "#/$defs/entity-reference"}, + {"$ref": "#/$defs/structured"}, + { + "type": "array", "minItems": 1, "maxItems": 64, + "items": {"anyOf": [ + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/entity-reference"} + ]} + } + ] + }, + "bucket": { + "type": "object", "additionalProperties": false, + "required": ["form", "scheme", "bucket"], + "properties": { + "form": {"enum": ["date-bucket", "time-bucket"]}, + "scheme": {"type": "string", "format": "uri", "maxLength": 512}, + "bucket": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"} + } + }, + "entity-reference": { + "type": "object", "additionalProperties": false, + "required": ["form", "reference"], + "properties": { + "form": {"const": "audience-scoped-entity-reference"}, + "reference": {"type": "string", "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "structured": { + "type": "object", "additionalProperties": false, + "required": ["form", "schema", "fields"], + "properties": { + "form": {"const": "reviewed-structured-value"}, + "schema": {"type": "string", "format": "uri", "maxLength": 512}, + "fields": {"type": "object", "minProperties": 1, "maxProperties": 16} + } + } + }, + "$comment": "The selected concept declaration further closes value form, schema, codelist, precision, cardinality, structured fields, and uniqueness. Selector profiles and selector values never appear in Evidence." + }) +} + +fn jws_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": JWS_SCHEMA_ID, + "title": "Evidence flattened JWS response Version 1", + "type": "object", + "additionalProperties": false, + "required": ["protected", "payload", "signature"], + "properties": { + "protected": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$"}, + "payload": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$"}, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"} + }, + "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=EdDSA, kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes." + }) +} + +fn unsigned_envelope_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": UNSIGNED_ENVELOPE_SCHEMA_ID, + "title": "Evidence unsigned response envelope Version 1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "type", "integrityProtection", "warning", "evidence"], + "properties": { + "schema": {"const": "registry.unsigned-evidence-envelope/v1"}, + "type": {"const": "UnsignedEvidenceEnvelope"}, + "integrityProtection": {"const": "none"}, + "warning": {"const": "not-cryptographically-verifiable"}, + "evidence": {"$ref": EVIDENCE_SCHEMA_ID} + }, + "$comment": "Transport-authenticated convenience representation selected only by its exact vendor media type when the immutable bundle and the complete matched grant permit it. Once separated from its HTTPS exchange it provides no issuer-authenticity, integrity, non-repudiation, or later-verification property. The nested evidence is the same closed core object that would be JWS encoded. There is no protected, payload, or signature member, so the strict JWS verifier rejects this representation; tooling that parses it must return an explicitly unverified result." + }) +} + +fn problem_schema() -> Value { + let mut schema = json!({ + "$schema": SCHEMA_DIALECT, + "$id": PROBLEM_SCHEMA_ID, + "title": "Evidence public problem Version 1", + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "code", "operation"], + "properties": { + "type": { + "type": "string", + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request", + "https://registrystack.org/problems/evidence/invalid_selector", + "https://registrystack.org/problems/evidence/authentication_failed", + "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/response_format_not_acceptable", + "https://registrystack.org/problems/evidence/evidence_not_available", + "https://registrystack.org/problems/evidence/rate_limited", + "https://registrystack.org/problems/evidence/dependency_unavailable", + "https://registrystack.org/problems/evidence/service_unavailable" + ] + }, + "title": {"type": "string", "enum": [ + "Request is not valid", "Authentication failed", "Request is not authorized", + "Requested response format is not acceptable", + "Evidence could not be produced", "Request rate exceeded", "Service temporarily unavailable" + ]}, + "status": {"type": "integer", "enum": [400, 401, 403, 406, 422, 429, 503]}, + "code": {"type": "string", "enum": [ + "malformed_request", "invalid_selector", "authentication_failed", "not_authorized", + "response_format_not_acceptable", + "evidence_not_available", "rate_limited", "dependency_unavailable", "service_unavailable" + ]}, + "operation": {"type": "string", "pattern": OPERATION_PATTERN} + }, + "$comment": "Problem members are a closed safe shape. No request, authority, source, script, supported-value, subject-binding, candidate, or credential detail is returned." + }); + schema["oneOf"] = Value::Array( + PROBLEM_VARIANTS + .into_iter() + .map(|(code, status, title)| { + json!({"properties": { + "type": {"const": format!("https://registrystack.org/problems/evidence/{code}")}, + "title": {"const": title}, + "status": {"const": status}, + "code": {"const": code} + }}) + }) + .collect(), + ); + schema +} + +fn jwks_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": JWKS_SCHEMA_ID, + "title": "Evidence public JWKS Version 1", + "type": "object", + "additionalProperties": false, + "required": ["keys"], + "properties": { + "keys": { + "type": "array", "minItems": 1, "maxItems": 33, "uniqueItems": true, + "items": {"$ref": "#/$defs/ed25519-public-jwk"} + } + }, + "$defs": { + "ed25519-public-jwk": { + "type": "object", "additionalProperties": false, + "required": ["kty", "kid", "alg", "crv", "x"], + "properties": { + "kty": {"const": "OKP"}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$"}, + "alg": {"const": "EdDSA"}, + "crv": {"const": "Ed25519"}, + "x": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"} + } + } + }, + "$comment": "Only the active and configured retired public keys are published. Key ids are unique and limited to 256 UTF-8 bytes by the runtime; JSON Schema maxLength is an additional code-point bound. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location." + }) +} + +fn insert_schema_family( + components: &mut serde_json::Map, + root_name: &str, + schema: &Value, + definition_names: &[(&str, &str)], +) { + let mut root = schema.clone(); + let definitions = root + .as_object_mut() + .and_then(|object| object.remove("$defs")) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + rewrite_openapi_schema(&mut root, definition_names); + components.insert(root_name.to_string(), root); + + for (definition_name, component_name) in definition_names { + let mut definition = definitions + .get(*definition_name) + .unwrap_or_else(|| panic!("missing generated schema definition {definition_name}")) + .clone(); + rewrite_openapi_schema(&mut definition, definition_names); + components.insert((*component_name).to_string(), definition); + } +} + +fn rewrite_openapi_schema(value: &mut Value, definition_names: &[(&str, &str)]) { + match value { + Value::Array(values) => { + for value in values { + rewrite_openapi_schema(value, definition_names); + } + } + Value::Object(object) => { + object.remove("$schema"); + object.remove("$id"); + object.remove("$comment"); + + if let Some(reference) = object + .get("$ref") + .and_then(Value::as_str) + .map(str::to_string) + { + if let Some(definition_name) = reference.strip_prefix("#/$defs/") { + let component_name = definition_names + .iter() + .find_map(|(definition, component)| { + (*definition == definition_name).then_some(*component) + }) + .unwrap_or_else(|| { + panic!("unmapped generated schema definition {definition_name}") + }); + object.insert( + "$ref".to_string(), + Value::String(format!("#/components/schemas/{component_name}")), + ); + } + } + + if let Some(constant) = object.remove("const") { + let schema_type = match &constant { + Value::String(_) => Some("string"), + Value::Bool(_) => Some("boolean"), + Value::Number(number) if number.is_i64() || number.is_u64() => Some("integer"), + Value::Number(_) => Some("number"), + Value::Array(_) => Some("array"), + Value::Object(_) => Some("object"), + Value::Null => None, + }; + if let Some(schema_type) = schema_type { + object + .entry("type".to_string()) + .or_insert_with(|| Value::String(schema_type.to_string())); + } + object.insert("enum".to_string(), Value::Array(vec![constant])); + } + if !object.contains_key("type") { + let enum_type = object + .get("enum") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(|value| match value { + Value::String(_) => Some("string"), + Value::Bool(_) => Some("boolean"), + Value::Number(number) if number.is_i64() || number.is_u64() => { + Some("integer") + } + Value::Number(_) => Some("number"), + _ => None, + }); + if let Some(enum_type) = enum_type { + object.insert("type".to_string(), Value::String(enum_type.to_string())); + } + } + + for value in object.values_mut() { + rewrite_openapi_schema(value, definition_names); + } + } + _ => {} + } +} + +fn problem_content(codes: &[&str]) -> Value { + let variants = codes + .iter() + .map(|code| { + let (_, status, title) = PROBLEM_VARIANTS + .iter() + .find(|(variant, _, _)| variant == code) + .unwrap_or_else(|| panic!("unknown public problem code {code}")); + json!({ + "allOf": [ + {"$ref": "#/components/schemas/Problem"}, + { + "type": "object", + "properties": { + "type": {"type": "string", "enum": [format!("https://registrystack.org/problems/evidence/{code}")]}, + "title": {"type": "string", "enum": [title]}, + "status": {"type": "integer", "enum": [status]}, + "code": {"type": "string", "enum": [code]} + } + } + ] + }) + }) + .collect::>(); + let schema = if variants.len() == 1 { + variants.into_iter().next().expect("one problem variant") + } else { + json!({"oneOf": variants}) + }; + json!({"application/problem+json": {"schema": schema}}) +} + +fn response_headers(extra: Option<(&str, Value)>) -> Value { + let mut headers = serde_json::Map::new(); + headers.insert( + "Cache-Control".to_string(), + json!({ + "description": "Evidence responses are never cacheable.", + "schema": {"type": "string", "enum": ["no-store"]} + }), + ); + headers.insert( + "X-Request-Id".to_string(), + json!({ + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": {"type": "string", "pattern": OPERATION_PATTERN} + }), + ); + if let Some((name, header)) = extra { + headers.insert(name.to_string(), header); + } + Value::Object(headers) +} + +/// Headers for every `/v1/evidence` response, which varies on `Accept`. +fn evidence_response_headers(extra: Option<(&str, Value)>) -> Value { + let mut headers = response_headers(extra); + headers + .as_object_mut() + .expect("response headers are an object") + .insert( + "Vary".to_string(), + json!({ + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": {"type": "string", "enum": ["Accept"]} + }), + ); + headers +} + +#[allow(clippy::too_many_arguments)] +fn openapi_document( + request: &Value, + evidence: &Value, + definitions: &Value, + jws: &Value, + unsigned: &Value, + problem: &Value, + jwks: &Value, +) -> Value { + let mut schemas = serde_json::Map::new(); + insert_schema_family( + &mut schemas, + "EvidenceRequest", + request, + &[ + ("subject", "EvidenceRequestSubject"), + ("selector", "EvidenceRequestSelector"), + ("scalar-selector-value", "SelectorValue"), + ("holder-key", "HolderPublicKey"), + ], + ); + insert_schema_family( + &mut schemas, + "Evidence", + evidence, + &[ + ("subject-binding", "SubjectBinding"), + ("supported-value", "SupportedValue"), + ("value", "PublicValue"), + ("bucket", "BucketValue"), + ("entity-reference", "EntityReferenceValue"), + ("structured", "StructuredValue"), + ], + ); + insert_schema_family( + &mut schemas, + "EvidenceDefinitions", + definitions, + &[ + ("definition", "EvidenceDefinition"), + ("subject", "EvidenceDefinitionSubject"), + ("selector", "EvidenceDefinitionSelector"), + ("selector-field", "EvidenceSelectorField"), + ("concept", "EvidenceDefinitionConcept"), + ], + ); + insert_schema_family(&mut schemas, "FlattenedJws", jws, &[]); + insert_schema_family(&mut schemas, "UnsignedEvidenceEnvelope", unsigned, &[]); + if let Some(reference) = schemas + .get_mut("UnsignedEvidenceEnvelope") + .and_then(Value::as_object_mut) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("evidence")) + .and_then(Value::as_object_mut) + { + reference.insert( + "$ref".to_string(), + Value::String("#/components/schemas/Evidence".to_string()), + ); + } + if let Some(properties) = schemas + .get_mut("FlattenedJws") + .and_then(Value::as_object_mut) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + { + properties["protected"]["x-decoded-schema"] = + json!({"$ref": "#/components/schemas/EvidenceProtectedHeader"}); + properties["payload"]["x-decoded-schema"] = + json!({"$ref": "#/components/schemas/Evidence"}); + } + schemas.insert( + "EvidenceProtectedHeader".to_string(), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["alg", "kid", "typ", "cty"], + "properties": { + "alg": {"type": "string", "enum": ["EdDSA"]}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F]+$"}, + "typ": {"type": "string", "enum": ["evidence+jws"]}, + "cty": {"type": "string", "enum": ["application/evidence+json"]} + } + }), + ); + insert_schema_family(&mut schemas, "Problem", problem, &[]); + insert_schema_family( + &mut schemas, + "JwksDocument", + jwks, + &[("ed25519-public-jwk", "Ed25519PublicJwk")], + ); + schemas.insert( + "SdJwtVcCredential".to_string(), + json!({ + "type": "string", + "description": "Compact SD-JWT VC: the issuer-signed JWT, then the root-value and configured structured-field disclosures, then a trailing tilde marking an absent key-binding JWT. The issuer never appends a key-binding JWT.", + "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+(~[A-Za-z0-9_-]+)*~$" + }), + ); + schemas.insert( + "JwtVcIssuerMetadata".to_string(), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["issuer", "jwks"], + "properties": { + "issuer": {"type": "string", "maxLength": 512}, + "jwks": {"$ref": "#/components/schemas/JwksDocument"} + } + }), + ); + schemas.insert( + "HealthStatus".to_string(), + json!({ + "type": "object", "additionalProperties": false, + "required": ["status"], + "properties": {"status": {"type": "string", "enum": ["ok"]}} + }), + ); + schemas.insert( + "ReadyStatus".to_string(), + json!({ + "type": "object", "additionalProperties": false, + "required": ["status"], + "properties": {"status": {"type": "string", "enum": ["ready"]}} + }), + ); + + json!({ + "openapi": "3.1.0", + "info": { + "title": "Registry Evidence API", + "version": env!("CARGO_PKG_VERSION"), + "description": "Minimum-disclosure signed assertion service, Version 1." + }, + "paths": { + "/v1/evidence": { + "post": { + "operationId": "createEvidence", + "summary": "Produce evidence for one authorized fixed requirement", + "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only the exact application/dc+sd-jwt media type selects the SD-JWT VC serialization of the same assertion; each is released only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", + "security": [{"bearerAuth": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvidenceRequest"}}} + }, + "responses": { + "200": { + "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized SD-JWT VC serialization or self-identifying unsigned envelope", + "headers": evidence_response_headers(None), + "content": { + "application/jose+json": {"schema": {"$ref": "#/components/schemas/FlattenedJws"}}, + "application/dc+sd-jwt": {"schema": {"$ref": "#/components/schemas/SdJwtVcCredential"}}, + "application/vnd.registrystack.evidence-unsigned+json": {"schema": {"$ref": "#/components/schemas/UnsignedEvidenceEnvelope"}} + } + }, + "400": { + "description": "Malformed request or invalid selector", + "headers": evidence_response_headers(None), + "content": problem_content(&["malformed_request", "invalid_selector"]) + }, + "401": { + "description": "Authentication failed", + "headers": evidence_response_headers(Some(("WWW-Authenticate", json!({ + "schema": {"type": "string", "enum": ["Bearer"]} + })))), + "content": problem_content(&["authentication_failed"]) + }, + "403": { + "description": "Request is not authorized, including a recognized response format the bundle or matched grant does not permit", + "headers": evidence_response_headers(None), + "content": problem_content(&["not_authorized"]) + }, + "406": { + "description": "Media negotiation is outside the closed Accept matrix", + "headers": evidence_response_headers(None), + "content": problem_content(&["response_format_not_acceptable"]) + }, + "422": { + "description": "Evidence could not be produced", + "headers": evidence_response_headers(None), + "content": problem_content(&["evidence_not_available"]) + }, + "429": { + "description": "Request rate exceeded", + "headers": evidence_response_headers(Some(("Retry-After", json!({ + "schema": {"type": "string", "enum": ["1"]} + })))), + "content": problem_content(&["rate_limited"]) + }, + "503": { + "description": "Dependency or service temporarily unavailable", + "headers": evidence_response_headers(None), + "content": problem_content(&["dependency_unavailable", "service_unavailable"]) + }, + } + } + }, + "/v1/evidence-definitions": { + "get": { + "operationId": "listEvidenceDefinitions", + "summary": "List the complete Evidence request shapes available to the authenticated caller", + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "Requester-scoped Evidence definitions", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvidenceDefinitions"}}} + }, + "400": { + "description": "Malformed discovery request", + "headers": response_headers(None), + "content": problem_content(&["malformed_request"]) + }, + "401": { + "description": "Authentication failed", + "headers": response_headers(Some(("WWW-Authenticate", json!({ + "schema": {"type": "string", "enum": ["Bearer"]} + })))), + "content": problem_content(&["authentication_failed"]) + }, + "429": { + "description": "Request rate exceeded", + "headers": response_headers(Some(("Retry-After", json!({ + "schema": {"type": "string", "enum": ["1"]} + })))), + "content": problem_content(&["rate_limited"]) + }, + "503": { + "description": "Service temporarily unavailable", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, + "/health": { + "get": { + "operationId": "getHealth", + "summary": "Report process liveness without dependency access", + "responses": {"200": { + "description": "Process is live", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HealthStatus"}}} + }} + } + }, + "/ready": { + "get": { + "operationId": "getReadiness", + "summary": "Report fail-closed runtime readiness", + "responses": { + "200": { + "description": "Runtime is ready", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReadyStatus"}}} + }, + "503": { + "description": "Runtime is not ready", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "getOpenApi", + "summary": "Fetch this OpenAPI document", + "description": "The generated public contract for this service. This route is intentionally unauthenticated: the served bytes are the released generated artifact and describe no deployment, definition, or authority.", + "security": [], + "responses": { + "200": { + "description": "The generated Version 1 OpenAPI document", + "headers": response_headers(None), + "content": {"application/openapi+json": {"schema": {"type": "object"}}} + }, + "503": { + "description": "The document could not be produced", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, + "/.well-known/evidence/jwks.json": { + "get": { + "operationId": "getEvidenceJwks", + "summary": "Publish the active and retained public verification keys", + "responses": {"200": { + "description": "Evidence public verification keys", + "headers": response_headers(None), + "content": {"application/jwk-set+json": {"schema": {"$ref": "#/components/schemas/JwksDocument"}}} + }} + } + }, + "/.well-known/jwt-vc-issuer": { + "get": { + "operationId": "getJwtVcIssuerMetadata", + "summary": "Publish JWT VC Issuer Metadata for the SD-JWT VC response format", + "description": "Discovery is not a trust anchor. The document republishes the same public keys under the provider identity the assertion names, and resolution is meaningful only when that identity is the HTTPS origin of the deployment.", + "responses": {"200": { + "description": "Provider identity and public verification keys", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JwtVcIssuerMetadata"}}} + }} + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Exactly one Authorization header containing one Bearer token is required." + } + }, + "schemas": Value::Object(schemas) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_json_schema_is_valid_draft_2020_12() { + for schema in [ + request_schema(), + evidence_schema(), + definitions_schema(), + jws_schema(), + problem_schema(), + jwks_schema(), + ] { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("generated schema compiles"); + } + + // The unsigned envelope references the Evidence payload schema by its + // canonical identifier, so that document is registered for offline + // compilation. + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .with_document(EVIDENCE_SCHEMA_ID.to_string(), evidence_schema()) + .compile(&unsigned_envelope_schema()) + .expect("generated unsigned envelope schema compiles"); + } + + #[test] + fn openapi_document_is_valid_utoipa_model() { + let document = openapi_document( + &request_schema(), + &evidence_schema(), + &definitions_schema(), + &jws_schema(), + &unsigned_envelope_schema(), + &problem_schema(), + &jwks_schema(), + ); + for (name, schema) in document["components"]["schemas"] + .as_object() + .expect("component schemas are an object") + { + serde_json::from_value::>( + schema.clone(), + ) + .unwrap_or_else(|error| panic!("component schema {name} is invalid: {error}")); + } + serde_json::from_value::(document) + .expect("generated OpenAPI parses as OpenAPI 3.1"); + } + + #[test] + fn openapi_has_only_the_version_one_routes_and_exact_success_media() { + let document = openapi_document( + &request_schema(), + &evidence_schema(), + &definitions_schema(), + &jws_schema(), + &unsigned_envelope_schema(), + &problem_schema(), + &jwks_schema(), + ); + let paths = document["paths"].as_object().expect("paths is an object"); + assert_eq!( + paths.keys().map(String::as_str).collect::>(), + [ + "/.well-known/evidence/jwks.json", + "/.well-known/jwt-vc-issuer", + "/health", + "/openapi.json", + "/ready", + "/v1/evidence", + "/v1/evidence-definitions" + ] + ); + assert!( + document["paths"]["/openapi.json"]["get"]["responses"]["200"]["content"] + ["application/openapi+json"] + .is_object() + ); + assert_eq!( + document["paths"]["/openapi.json"]["get"]["security"], + json!([]) + ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/jose+json"] + .is_object() + ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/vnd.registrystack.evidence-unsigned+json"] + .is_object() + ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/dc+sd-jwt"] + .is_object() + ); + assert_eq!( + document["paths"]["/v1/evidence"]["post"]["responses"]["406"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["response_format_not_acceptable"]) + ); + for response in document["paths"]["/v1/evidence"]["post"]["responses"] + .as_object() + .expect("evidence responses are an object") + .values() + { + assert_eq!( + response["headers"]["Vary"]["schema"]["enum"], + json!(["Accept"]) + ); + } + assert!( + document["paths"]["/v1/evidence-definitions"]["get"]["responses"]["200"]["content"] + ["application/json"] + .is_object() + ); + assert!( + document["paths"]["/.well-known/evidence/jwks.json"]["get"]["responses"]["200"] + ["content"]["application/jwk-set+json"] + .is_object() + ); + assert!( + document["paths"]["/.well-known/jwt-vc-issuer"]["get"]["responses"]["200"]["content"] + ["application/json"] + .is_object() + ); + assert_eq!( + document["components"]["schemas"]["FlattenedJws"]["properties"]["payload"] + ["x-decoded-schema"]["$ref"], + json!("#/components/schemas/Evidence") + ); + assert_eq!( + document["components"]["schemas"]["EvidenceProtectedHeader"]["properties"]["alg"] + ["enum"], + json!(["EdDSA"]) + ); + + for path in paths.values() { + let operation = path + .as_object() + .and_then(|operations| operations.values().next()) + .expect("each Version 1 path has one operation"); + for response in operation["responses"] + .as_object() + .expect("responses is an object") + .values() + { + assert_eq!( + response["headers"]["Cache-Control"]["schema"]["enum"], + json!(["no-store"]) + ); + } + } + + assert_eq!( + document["paths"]["/v1/evidence"]["post"]["responses"]["401"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["authentication_failed"]) + ); + assert_eq!( + document["paths"]["/ready"]["get"]["responses"]["503"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["service_unavailable"]) + ); + } + + #[test] + fn the_served_openapi_document_is_the_generated_release_artifact() { + let generated = documents().expect("generated contracts build"); + assert_eq!( + served_openapi_document().expect("served OpenAPI document builds"), + generated[OPENAPI_FILE] + ); + } + + #[test] + fn jws_and_problem_schemas_are_closed() { + let jws = jws_schema(); + assert_eq!(jws["additionalProperties"], json!(false)); + assert!(jws["properties"].get("header").is_none()); + + let problem = problem_schema(); + assert_eq!(problem["additionalProperties"], json!(false)); + assert_eq!( + problem["properties"].as_object().map(|value| value.len()), + Some(5) + ); + } + + #[test] + fn schemas_accept_the_exact_public_wire_shapes() { + let cases = [ + ( + request_schema(), + json!({ + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-v1", "values": {"opaque": "value"}} + }] + }), + ), + ( + evidence_schema(), + json!({ + "schema": "registry.assertion-evidence/v1", + "assuranceProfile": "evidence-grade", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "id": "urn:ulid:01K1EXAMPLE0000000000000000", + "type": "Evidence", + "supportsRequirement": "urn:example:requirement:v1", + "isConformantTo": "urn:example:evidence-type:v1", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "casework", + "audience": "urn:example:audience", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "subjects": [{ + "role": "subject", + "binding": format!("urn:evidence:subject:v1_{}", "a".repeat(43)) + }], + "supportedValues": [{ + "providesValueFor": "urn:example:concept", + "value": true + }] + }), + ), + ( + definitions_schema(), + json!({ + "schema": "registry.evidence-definitions/v1", + "assuranceProfile": "evidence-grade", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "definitions": [{ + "requirement": "urn:example:requirement:v1", + "kind": "criterion", + "evidenceType": "urn:example:evidence-type:v1", + "purpose": "casework", + "referenceFrameworks": ["urn:example:framework:v1"], + "subjects": [{ + "role": "subject", + "cardinality": "one", + "selector": { + "profile": "person-v1", + "valueOrigin": "request", + "fields": [{ + "type": "string", + "name": "record_reference", + "minimumBytes": 1, + "maximumBytes": 96 + }] + } + }], + "concepts": [{"id": "urn:example:concept", "form": "boolean"}] + }] + }), + ), + ( + jws_schema(), + json!({ + "protected": "YWxn", + "payload": "ZXZpZGVuY2U", + "signature": "a".repeat(86) + }), + ), + ( + problem_schema(), + json!({ + "type": "https://registrystack.org/problems/evidence/evidence_not_available", + "title": "Evidence could not be produced", + "status": 422, + "code": "evidence_not_available", + "operation": "01ARZ3NDEKTSV4RRFFQ69G5FAV" + }), + ), + ( + jwks_schema(), + json!({"keys": [{ + "kty": "OKP", "kid": "evidence-key-1", "alg": "EdDSA", + "crv": "Ed25519", "x": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }]}), + ), + ]; + + for (schema, instance) in cases { + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("generated schema compiles"); + assert!(compiled.is_valid(&instance), "instance: {instance}"); + } + } + + #[test] + fn problem_schema_rejects_mismatched_code_status_and_title() { + let schema = problem_schema(); + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("problem schema compiles"); + let mismatched = json!({ + "type": "https://registrystack.org/problems/evidence/dependency_unavailable", + "title": "Request is not valid", + "status": 400, + "code": "dependency_unavailable", + "operation": "01ARZ3NDEKTSV4RRFFQ69G5FAV" + }); + assert!(!compiled.is_valid(&mismatched)); + } +} diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs new file mode 100644 index 000000000..bf15a3951 --- /dev/null +++ b/crates/registry-evidence/src/kernel.rs @@ -0,0 +1,2383 @@ +//! Generic offline Evidence evaluation and core-owned output projection. +//! +//! This module deliberately knows nothing about an acceptance case or source +//! product. It joins one captured bundle revision to the hardened Rhai runtime, +//! validates the complete declared Supported Value set, and constructs the +//! unsigned Evidence payload that the production release path later signs. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use chrono_tz::Tz; +use jsonschema::{Draft, JSONSchema}; +use serde_json::{Map as JsonMap, Value}; +use thiserror::Error; + +use crate::binding::entity_reference; +use crate::bundle::{Bundle, Codelist}; +use crate::config::{ + ConceptConfig, ConceptForm, PreparationChannelPolicy, PreparationLimits, RequirementConfig, +}; +use crate::model::{ + BucketForm, BucketValue, EntityReferenceForm, EntityReferenceValue, Evidence, + EvidenceObjectType, LookupResult, PublicValue, ScalarOrEntityReference, StructuredValue, + StructuredValueForm, SubjectBinding, SupportedValue, +}; +use crate::rhai_runtime::{ + CalendarDate, CodelistHandle, CompiledDerivation, CompiledExtraction, CompiledPreparation, + DerivedConceptValue, DerivedValue, EvaluationContext, LegalLocalTime, RequestPartRequirement, + RequestParts, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, RhaiRuntimeError, + UtcInstant, MAXIMUM_RESULT_BYTES, +}; +use crate::values::Decimal; + +const MAXIMUM_PUBLIC_STRING_BYTES: usize = 1_024; +const MAXIMUM_BUCKET_CODE_BYTES: usize = 128; +const MAXIMUM_EVIDENCE_IDENTIFIER_BYTES: usize = 512; +const DEFAULT_MAXIMUM_QUERY_PAIRS: usize = 64; +const DEFAULT_MAXIMUM_QUERY_NAME_BYTES: usize = 64; +const DEFAULT_MAXIMUM_QUERY_VALUE_BYTES: usize = 4_096; +const DEFAULT_MAXIMUM_JSON_DEPTH: usize = 32; +const DEFAULT_MAXIMUM_COLLECTION_ITEMS: usize = 256; +const DEFAULT_MAXIMUM_STRING_BYTES: usize = 16_384; +const DEFAULT_MAXIMUM_NORMALIZED_BYTES: usize = 65_536; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum KernelError { + #[error("the Evidence bundle cannot initialize the offline kernel")] + Bundle, + #[error("the requested Evidence requirement is unavailable")] + Requirement, + #[error("the Evidence extraction failed")] + Extraction, + /// A uniquely resolved record reached derivation with missing, mistyped, + /// or inconsistent inputs. Publicly this collapses with the unresolved + /// lookup classes so callers cannot learn that a record exists. + #[error("the Evidence derivation inputs are unresolved")] + DerivationInput, + #[error("the Evidence request preparation failed")] + Preparation, + #[error("the source response violates its fixed protocol contract")] + SourceProtocol, + #[error("the Evidence script failed")] + Script, + #[error("the derived Evidence values violate the requirement contract")] + Output, + #[error("the Evidence payload metadata is invalid")] + Evidence, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum KernelOutcome { + Match(ValidatedValues), + NoMatch, + Ambiguous, +} + +/// Values that have passed the complete requirement output gate. +/// +/// The inner vector is intentionally not publicly constructible or mutable, so +/// Evidence construction cannot be invoked with an unchecked `PublicValue`. +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedValues(Vec); + +impl ValidatedValues { + pub fn as_slice(&self) -> &[SupportedValue] { + &self.0 + } +} + +impl std::fmt::Debug for ValidatedValues { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ValidatedValues") + .field( + "concept_identifiers", + &self + .0 + .iter() + .map(|value| value.provides_value_for.as_str()) + .collect::>(), + ) + .finish_non_exhaustive() + } +} + +/// Runtime-owned inputs needed to project protected derivation values. +pub struct ValueProjection<'a> { + pub audience: &'a str, + pub binding_key: &'a [u8], + pub binding_key_version: u32, +} + +/// Core-owned envelope inputs supplied by the authenticated release pipeline. +pub struct EvidenceConstruction<'a> { + pub evidence_id: &'a str, + /// Exact caller nonce to echo. It is copied verbatim into the payload and + /// is not part of the subject binding, audit, or any diagnostic surface. + pub request_nonce: &'a str, + pub purpose: &'a str, + pub audience: &'a str, + pub issued_at: DateTime, + pub observed_at: DateTime, + pub subjects: Vec, +} + +fn compile_request_parts_limits( + configured: &PreparationLimits, +) -> Result { + fn requirement(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn bounded(value: Option, default: usize) -> Result { + value + .map(usize::try_from) + .transpose() + .map_err(|_| KernelError::Bundle) + .map(|value| value.unwrap_or(default)) + } + + RequestPartsLimits::new( + requirement(configured.query), + requirement(configured.json_body), + RequestPartsBounds { + maximum_query_pairs: bounded( + configured.maximum_query_pairs, + DEFAULT_MAXIMUM_QUERY_PAIRS, + )?, + maximum_query_name_bytes: bounded( + configured.maximum_query_name_bytes, + DEFAULT_MAXIMUM_QUERY_NAME_BYTES, + )?, + maximum_query_value_bytes: bounded( + configured.maximum_query_value_bytes, + DEFAULT_MAXIMUM_QUERY_VALUE_BYTES, + )?, + maximum_json_depth: bounded(configured.maximum_json_depth, DEFAULT_MAXIMUM_JSON_DEPTH)?, + maximum_collection_items: bounded( + configured.maximum_collection_items, + DEFAULT_MAXIMUM_COLLECTION_ITEMS, + )?, + maximum_string_bytes: bounded( + configured.maximum_string_bytes, + DEFAULT_MAXIMUM_STRING_BYTES, + )?, + maximum_normalized_bytes: bounded( + configured.maximum_normalized_bytes, + DEFAULT_MAXIMUM_NORMALIZED_BYTES, + )?, + }, + ) + .map_err(|_| KernelError::Bundle) +} + +/// A kernel compiled entirely from the bytes captured in one immutable bundle. +pub struct OfflineKernel { + bundle: Arc, + runtime: RhaiRuntime, + preparations: BTreeMap, + extractions: BTreeMap, + request_parts_limits: BTreeMap, + derivations: BTreeMap, + response_schemas: BTreeMap, + fact_schemas: BTreeMap, + reviewed_schemas: BTreeMap, + codelist_handles: BTreeMap>, +} + +impl std::fmt::Debug for OfflineKernel { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OfflineKernel") + .field("configuration_revision", &self.bundle.revision()) + .field("source_count", &self.extractions.len()) + .field("requirement_count", &self.derivations.len()) + .finish() + } +} + +impl OfflineKernel { + pub fn compile(bundle: Arc) -> Result { + let runtime = RhaiRuntime::new(); + let mut preparations = BTreeMap::new(); + let mut extractions = BTreeMap::new(); + let mut request_parts_limits = BTreeMap::new(); + let mut response_schemas = BTreeMap::new(); + let mut fact_schemas = BTreeMap::new(); + for (source_id, source) in bundle.config.sources.iter() { + let preparation = bundle + .script(&source.request.prepare_script) + .ok_or(KernelError::Bundle)?; + let compiled_preparation = runtime + .compile_preparation(&preparation.source) + .map_err(|_| KernelError::Bundle)?; + preparations.insert(source_id.to_owned(), compiled_preparation); + + let extraction = bundle + .script(&source.extract_script) + .ok_or(KernelError::Bundle)?; + let compiled_extraction = runtime + .compile_extraction(&extraction.source) + .map_err(|_| KernelError::Bundle)?; + extractions.insert(source_id.to_owned(), compiled_extraction); + request_parts_limits.insert( + source_id.to_owned(), + compile_request_parts_limits(&source.request.preparation_limits)?, + ); + + let response_schema = bundle + .fact_schema(&source.response_schema) + .ok_or(KernelError::Bundle)?; + response_schemas.insert(source_id.to_owned(), compile_schema(response_schema)?); + + let schema = bundle + .fact_schema(&source.fact_schema) + .ok_or(KernelError::Bundle)?; + let compiled_schema = compile_schema(schema)?; + fact_schemas.insert(source_id.to_owned(), compiled_schema); + } + + let mut derivations = BTreeMap::new(); + for requirement in &bundle.config.requirements { + let script = bundle + .script(&requirement.derivation.script) + .ok_or(KernelError::Bundle)?; + let compiled = runtime + .compile_derivation(&script.source) + .map_err(|_| KernelError::Bundle)?; + derivations.insert(requirement.id.clone(), compiled); + } + + let mut reviewed_schemas = BTreeMap::new(); + for schema in bundle.fact_schemas.values() { + if let Some(identifier) = schema.get("$id").and_then(Value::as_str) { + if reviewed_schemas + .insert(identifier.to_owned(), compile_schema(schema)?) + .is_some() + { + return Err(KernelError::Bundle); + } + } + } + + let codelist_handles = bundle + .config + .requirements + .iter() + .map(|requirement| { + build_codelist_handles(&bundle, requirement) + .map(|handles| (requirement.id.clone(), handles)) + }) + .collect::>()?; + Ok(Self { + bundle, + runtime, + preparations, + extractions, + request_parts_limits, + derivations, + response_schemas, + fact_schemas, + reviewed_schemas, + codelist_handles, + }) + } + + /// Run the reviewed preparation script after authorization and access audit. + pub fn prepare( + &self, + requirement_id: &str, + selectors: &Value, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let source = self + .bundle + .config + .sources + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let script = self + .preparations + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let limits = self + .request_parts_limits + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| KernelError::Bundle)?; + self.runtime + .prepare(script, selectors, ¶meters, limits) + .map_err(|_| KernelError::Preparation) + } + + pub fn bundle(&self) -> &Bundle { + &self.bundle + } + + pub fn requirement(&self, requirement_id: &str) -> Option<&RequirementConfig> { + self.bundle + .config + .requirements + .iter() + .find(|requirement| requirement.id == requirement_id) + } + + /// Run only the closed extraction ABI over one already-bounded JSON response. + pub fn extract( + &self, + requirement_id: &str, + source_response: &Value, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let script = self + .extractions + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let schema = self + .fact_schemas + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let source = self + .bundle + .config + .sources + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + // The declared response shape is checked in Rust before any script sees + // the response, so extraction maps a response it can rely on and a + // provider that breaks its protocol fails closed the same way whether + // or not the script happens to test for it. + let response_schema = self + .response_schemas + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + if let Err(errors) = response_schema.validate(source_response) { + report_response_shape_rejection( + &requirement.source, + source.response_schema.as_str(), + errors, + ); + return Err(KernelError::SourceProtocol); + } + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| KernelError::Bundle)?; + self.runtime + .extract(script, source_response, ¶meters, schema) + .map_err(|error| match error { + RhaiRuntimeError::ExtractionResult | RhaiRuntimeError::FactSchema => { + KernelError::Extraction + } + RhaiRuntimeError::Unavailable => KernelError::Extraction, + RhaiRuntimeError::SourceProtocol => KernelError::SourceProtocol, + RhaiRuntimeError::Compilation + | RhaiRuntimeError::EntryPoint + | RhaiRuntimeError::Invocation + | RhaiRuntimeError::InputBound + | RhaiRuntimeError::AdapterInput + | RhaiRuntimeError::PreparationResult + | RhaiRuntimeError::DerivationResult + | RhaiRuntimeError::DerivationInput + | RhaiRuntimeError::EvaluationContext + | RhaiRuntimeError::Codelist => KernelError::Script, + }) + } + + /// Derive and gate the exact Supported Value set for one unique match. + pub fn derive_and_validate( + &self, + requirement_id: &str, + facts: &BTreeMap, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + self.derive_and_validate_with_selectors( + requirement_id, + facts, + &Value::Object(JsonMap::new()), + observed_at, + projection, + ) + } + + /// Derive with the exact requirement-declared selector subset. + pub fn derive_and_validate_with_selectors( + &self, + requirement_id: &str, + facts: &BTreeMap, + selectors: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let script = self + .derivations + .get(requirement_id) + .ok_or(KernelError::Bundle)?; + let evaluation_context = self.evaluation_context(requirement, observed_at)?; + let derived = self + .runtime + .derive(script, facts, selectors, evaluation_context) + .map_err(|error| match error { + RhaiRuntimeError::Unavailable => KernelError::Extraction, + RhaiRuntimeError::DerivationInput => KernelError::DerivationInput, + RhaiRuntimeError::SourceProtocol => KernelError::Script, + _ => KernelError::Script, + })?; + self.validate_values(requirement_id, derived, projection) + } + + /// Run the complete offline lookup and derivation path. + pub fn evaluate( + &self, + requirement_id: &str, + source_response: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + self.evaluate_with_selectors( + requirement_id, + source_response, + &Value::Object(JsonMap::new()), + observed_at, + projection, + ) + } + + /// Run lookup and derivation with the exact selector subset declared for derivation. + pub fn evaluate_with_selectors( + &self, + requirement_id: &str, + source_response: &Value, + selectors: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + match self.extract(requirement_id, source_response)? { + LookupResult::Match(facts) => self + .derive_and_validate_with_selectors( + requirement_id, + &facts, + selectors, + observed_at, + projection, + ) + .map(KernelOutcome::Match), + LookupResult::NoMatch => Ok(KernelOutcome::NoMatch), + LookupResult::Ambiguous => Ok(KernelOutcome::Ambiguous), + } + } + + /// Apply the output gate to values produced by the hardened Rhai ABI. + pub fn validate_values( + &self, + requirement_id: &str, + derived: Vec, + projection: ValueProjection<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + gate_values( + requirement, + derived, + projection, + &self.bundle.codelists, + &self.reviewed_schemas, + ) + } + + /// Construct the exact unsigned payload after output validation. + pub fn construct_evidence( + &self, + requirement_id: &str, + values: ValidatedValues, + input: EvidenceConstruction<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + validate_evidence_inputs(requirement, values.as_slice(), &input)?; + let valid_until = input + .issued_at + .checked_add_signed(Duration::seconds( + i64::try_from(requirement.validity_seconds).map_err(|_| KernelError::Evidence)?, + )) + .ok_or(KernelError::Evidence) + .map(format_utc)?; + + Ok(Evidence { + schema: crate::EVIDENCE_SCHEMA_V1.to_owned(), + assurance_profile: self.bundle.config.assurance_profile, + request_nonce: input.request_nonce.to_owned(), + id: input.evidence_id.to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: requirement.id.clone(), + is_conformant_to: requirement.evidence_type.clone(), + issued_by: self.bundle.config.issuer.id.clone(), + provided_by: self.bundle.config.service.provider_id.clone(), + issued_at: format_utc(input.issued_at), + observed_at: format_utc(input.observed_at), + valid_until, + purpose: input.purpose.to_owned(), + audience: input.audience.to_owned(), + configuration_revision: self.bundle.revision().to_owned(), + subjects: input.subjects, + supported_values: values.0, + }) + } + + fn evaluation_context( + &self, + requirement: &RequirementConfig, + observed_at: DateTime, + ) -> Result { + let timezone = requirement + .observation_timezone + .as_deref() + .map(Tz::from_str) + .transpose() + .map_err(|_| KernelError::Bundle)? + .unwrap_or(Tz::UTC); + let local = observed_at.with_timezone(&timezone); + let parameters = serde_json::to_value(&requirement.derivation.parameters) + .map_err(|_| KernelError::Bundle)?; + EvaluationContext::new( + UtcInstant::parse(&format_utc(observed_at)).map_err(map_context_error)?, + CalendarDate::parse(&local.format("%Y-%m-%d").to_string()) + .map_err(map_context_error)?, + LegalLocalTime::parse(&local.format("%H:%M:%S%:z").to_string()) + .map_err(map_context_error)?, + ¶meters, + self.codelist_handles + .get(&requirement.id) + .cloned() + .ok_or(KernelError::Bundle)?, + ) + .map_err(map_context_error) + } +} + +fn map_context_error(_: RhaiRuntimeError) -> KernelError { + KernelError::Bundle +} + +/// How many response shape violations one rejection reports. +/// +/// A rejected response is one event, and the first few violations already say +/// which member disagrees with which rule. An unbounded list would let a source +/// decide how much an operator log holds. +const REPORTED_SHAPE_VIOLATIONS: usize = 5; + +/// Record which member of a projected response failed which schema rule. +/// +/// The two pointers are the whole diagnosis and neither is a value. Without +/// them a stale response schema and a source that changed its protocol are the +/// same `dependency_unavailable`, which sends an operator to the provider for a +/// defect that lives in the bundle. +/// +/// Nothing from the response body is recorded: the paths are members the +/// bundle's own projection selected, and the schema path is bundle text. The +/// library's own error message embeds the offending value, so it is deliberately +/// not used here. +fn report_response_shape_rejection<'a>( + source_id: &str, + schema_artifact: &str, + errors: impl Iterator>, +) { + let (violations, total) = describe_response_shape_rejection(errors); + tracing::warn!( + target: "registry_evidence::source", + source = source_id, + schema = schema_artifact, + violations = violations.join("; "), + total_violations = total, + "the projected source response does not match its declared response shape" + ); +} + +/// Reduce validation errors to bounded, value-free violation descriptions. +/// +/// Separated from the logging call so the property that matters can be asserted +/// directly: what is produced here is the only thing that reaches the log. +fn describe_response_shape_rejection<'a>( + errors: impl Iterator>, +) -> (Vec, usize) { + let mut violations = Vec::new(); + let mut total = 0usize; + for error in errors { + total += 1; + if violations.len() < REPORTED_SHAPE_VIOLATIONS { + violations.push(format!( + "{} violates {}", + display_pointer(&error.instance_path), + display_pointer(&error.schema_path) + )); + } + } + (violations, total) +} + +/// Render a JSON Pointer, naming the document root rather than printing nothing. +fn display_pointer(pointer: &jsonschema::paths::JSONPointer) -> String { + let rendered = pointer.to_string(); + if rendered.is_empty() { + "the response root".to_owned() + } else { + rendered + } +} + +fn compile_schema(schema: &Value) -> Result { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .map_err(|_| KernelError::Bundle) +} + +fn build_codelist_handles( + bundle: &Bundle, + requirement: &RequirementConfig, +) -> Result, KernelError> { + let mut paths = BTreeSet::new(); + for concept in &requirement.concepts { + if let Some(path) = concept + .constraints + .get("codelist") + .and_then(serde_norway::Value::as_str) + { + paths.insert(path); + } + if matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) { + let scheme = constraint_str(concept, "bucketScheme")?; + let version = constraint_str(concept, "schemeVersion")?; + let mut matches = bundle + .codelists + .iter() + .filter(|(_, codelist)| codelist.id() == scheme && codelist.version() == version); + let (path, _) = matches.next().ok_or(KernelError::Bundle)?; + if matches.next().is_some() { + return Err(KernelError::Bundle); + } + paths.insert(path); + } + } + + let mut handles = BTreeMap::new(); + for path in paths { + let codelist = bundle.codelists.get(path).ok_or(KernelError::Bundle)?; + let name = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or(KernelError::Bundle)?; + let entries = match codelist { + Codelist::Codes { codes, .. } => codes + .iter() + .map(|code| (code.clone(), code.clone())) + .collect(), + Codelist::Mapping { entries, .. } => entries.clone(), + }; + let handle = CodelistHandle::new(entries).map_err(|_| KernelError::Bundle)?; + if handles.insert(name.to_owned(), handle).is_some() { + return Err(KernelError::Bundle); + } + } + Ok(handles) +} + +fn gate_values( + requirement: &RequirementConfig, + derived: Vec, + projection: ValueProjection<'_>, + codelists: &BTreeMap, + reviewed_schemas: &BTreeMap, +) -> Result { + if derived.is_empty() || derived.len() > 16 { + return Err(KernelError::Output); + } + let derived_count = derived.len(); + let by_identifier = derived + .into_iter() + .map(|entry| (entry.concept_id, entry.value)) + .collect::>(); + if by_identifier.len() != derived_count + || by_identifier.len() > requirement.concepts.len() + || requirement + .concepts + .iter() + .any(|concept| concept.required && !by_identifier.contains_key(&concept.id)) + || by_identifier.keys().any(|identifier| { + !requirement + .concepts + .iter() + .any(|concept| concept.id == *identifier) + }) + { + return Err(KernelError::Output); + } + + let mut result = Vec::with_capacity(by_identifier.len()); + let mut total_bytes = 0usize; + for concept in &requirement.concepts { + let Some(value) = by_identifier.get(&concept.id) else { + continue; + }; + let public = validate_value(concept, value, &projection, codelists, reviewed_schemas)?; + total_bytes = total_bytes + .checked_add( + serde_json::to_vec(&public) + .map_err(|_| KernelError::Output)? + .len(), + ) + .ok_or(KernelError::Output)?; + if total_bytes > MAXIMUM_RESULT_BYTES { + return Err(KernelError::Output); + } + result.push(SupportedValue { + provides_value_for: concept.id.clone(), + value: public, + }); + } + Ok(ValidatedValues(result)) +} + +fn validate_value( + concept: &ConceptConfig, + value: &DerivedValue, + projection: &ValueProjection<'_>, + codelists: &BTreeMap, + reviewed_schemas: &BTreeMap, +) -> Result { + match concept.form { + ConceptForm::Boolean => match value { + DerivedValue::Json(Value::Bool(value)) => Ok(PublicValue::Boolean(*value)), + _ => Err(KernelError::Output), + }, + ConceptForm::ControlledCode | ConceptForm::ControlledCategory => { + let text = derived_string(value)?; + let maximum = constraint_usize(concept, "maximumBytes")?; + validate_public_string(text, maximum)?; + let codelist = declared_codelist(concept, codelists)?; + if concept.form == ConceptForm::ControlledCategory + && codelist.id() != constraint_str(concept, "categoryScheme")? + { + return Err(KernelError::Bundle); + } + if !codelist.contains_output(text) { + return Err(KernelError::Output); + } + Ok(PublicValue::String(text.to_owned())) + } + ConceptForm::BoundedInteger => { + let integer = match value { + DerivedValue::Json(Value::Number(number)) => { + number.as_i64().ok_or(KernelError::Output)? + } + _ => return Err(KernelError::Output), + }; + let minimum = constraint_i64(concept, "minimum")?; + let maximum = constraint_i64(concept, "maximum")?; + if !(minimum..=maximum).contains(&integer) { + return Err(KernelError::Output); + } + Ok(PublicValue::Integer(integer)) + } + ConceptForm::BoundedDecimal => { + let decimal = match value { + DerivedValue::Decimal(decimal) => decimal, + _ => return Err(KernelError::Output), + }; + let minimum = Decimal::parse(constraint_str(concept, "minimum")?) + .map_err(|_| KernelError::Bundle)?; + let maximum = Decimal::parse(constraint_str(concept, "maximum")?) + .map_err(|_| KernelError::Bundle)?; + let maximum_scale = constraint_u64(concept, "maximumScale")?; + if u64::from(decimal.scale()) > maximum_scale + || decimal.compare(&minimum).is_lt() + || decimal.compare(&maximum).is_gt() + { + return Err(KernelError::Output); + } + Ok(PublicValue::String(decimal.canonical().to_owned())) + } + ConceptForm::DateBucket | ConceptForm::TimeBucket => { + validate_bucket(concept, value, codelists) + } + ConceptForm::AudienceScopedEntityReference => { + let seed = match value { + DerivedValue::EntityReferenceSeed(seed) => seed, + _ => return Err(KernelError::Output), + }; + let reference = project_entity(concept, seed, projection)?; + Ok(PublicValue::EntityReference(EntityReferenceValue { + form: EntityReferenceForm::AudienceScopedEntityReference, + reference, + })) + } + ConceptForm::ControlledCodeList => { + let values = match value { + DerivedValue::Json(Value::Array(values)) => values, + _ => return Err(KernelError::Output), + }; + validate_cardinality(concept, values.len())?; + let codelist = declared_codelist(concept, codelists)?; + let mut unique = BTreeSet::new(); + let mut public = Vec::with_capacity(values.len()); + for item in values { + let text = item.as_str().ok_or(KernelError::Output)?; + validate_public_string(text, MAXIMUM_PUBLIC_STRING_BYTES)?; + if !codelist.contains_output(text) || !unique.insert(text) { + return Err(KernelError::Output); + } + public.push(ScalarOrEntityReference::String(text.to_owned())); + } + Ok(PublicValue::List(public)) + } + ConceptForm::EntityReferenceList => { + let seeds = match value { + DerivedValue::EntityReferenceSeedList(seeds) => seeds, + _ => return Err(KernelError::Output), + }; + validate_cardinality(concept, seeds.len())?; + let mut unique = BTreeSet::new(); + let mut public = Vec::with_capacity(seeds.len()); + for seed in seeds { + let reference = project_entity(concept, seed, projection)?; + if !unique.insert(reference.clone()) { + return Err(KernelError::Output); + } + public.push(ScalarOrEntityReference::EntityReference( + EntityReferenceValue { + form: EntityReferenceForm::AudienceScopedEntityReference, + reference, + }, + )); + } + Ok(PublicValue::List(public)) + } + ConceptForm::ReviewedStructuredValue => { + validate_structured(concept, value, reviewed_schemas) + } + } +} + +fn validate_bucket( + concept: &ConceptConfig, + value: &DerivedValue, + codelists: &BTreeMap, +) -> Result { + let object = match value { + DerivedValue::Json(Value::Object(object)) => object, + _ => return Err(KernelError::Output), + }; + if !has_exact_json_keys(object, &["form", "scheme", "bucket"]) { + return Err(KernelError::Output); + } + let expected_form = match concept.form { + ConceptForm::DateBucket => "date-bucket", + ConceptForm::TimeBucket => "time-bucket", + _ => return Err(KernelError::Bundle), + }; + let form = object["form"].as_str().ok_or(KernelError::Output)?; + let scheme = object["scheme"].as_str().ok_or(KernelError::Output)?; + let bucket = object["bucket"].as_str().ok_or(KernelError::Output)?; + if form != expected_form + || scheme != constraint_str(concept, "bucketScheme")? + || !valid_code(bucket) + || bucket.len() > MAXIMUM_BUCKET_CODE_BYTES + { + return Err(KernelError::Output); + } + let scheme_version = constraint_str(concept, "schemeVersion")?; + let codelist = codelists + .values() + .filter(|candidate| candidate.id() == scheme && candidate.version() == scheme_version) + .exactly_one() + .ok_or(KernelError::Bundle)?; + if !codelist.contains_output(bucket) { + return Err(KernelError::Output); + } + Ok(PublicValue::Bucket(BucketValue { + form: if concept.form == ConceptForm::DateBucket { + BucketForm::DateBucket + } else { + BucketForm::TimeBucket + }, + scheme: scheme.to_owned(), + bucket: bucket.to_owned(), + })) +} + +fn validate_structured( + concept: &ConceptConfig, + value: &DerivedValue, + schemas: &BTreeMap, +) -> Result { + let object = match value { + DerivedValue::Json(Value::Object(object)) => object, + _ => return Err(KernelError::Output), + }; + if !has_exact_json_keys(object, &["form", "schema", "fields"]) + || object["form"].as_str() != Some("reviewed-structured-value") + { + return Err(KernelError::Output); + } + let schema_id = object["schema"].as_str().ok_or(KernelError::Output)?; + if schema_id != constraint_str(concept, "schema")? { + return Err(KernelError::Output); + } + let fields = object["fields"] + .as_object() + .filter(|fields| !fields.is_empty() && fields.len() <= 16) + .ok_or(KernelError::Output)?; + let maximum = constraint_usize(concept, "maximumSerializedBytes")?; + if serde_json::to_vec(value_as_json(value)?) + .map_err(|_| KernelError::Output)? + .len() + > maximum + { + return Err(KernelError::Output); + } + let schema = schemas.get(schema_id).ok_or(KernelError::Bundle)?; + let fields_value = Value::Object(fields.clone()); + if !schema.is_valid(&fields_value) { + return Err(KernelError::Output); + } + Ok(PublicValue::Structured(StructuredValue { + form: StructuredValueForm::ReviewedStructuredValue, + schema: schema_id.to_owned(), + fields: fields.clone().into_iter().collect(), + })) +} + +fn project_entity( + concept: &ConceptConfig, + seed: &crate::values::EntityReferenceSeed, + projection: &ValueProjection<'_>, +) -> Result { + let reference = entity_reference( + projection.binding_key, + projection.binding_key_version, + &concept.id, + projection.audience, + seed.expose_for_projection(), + ) + .map_err(|_| KernelError::Output)?; + let maximum = if concept.constraints.contains_key("maximumBytes") { + constraint_usize(concept, "maximumBytes")? + } else { + MAXIMUM_PUBLIC_STRING_BYTES + }; + if reference.len() > maximum { + return Err(KernelError::Output); + } + Ok(reference) +} + +fn validate_evidence_inputs( + requirement: &RequirementConfig, + values: &[SupportedValue], + input: &EvidenceConstruction<'_>, +) -> Result<(), KernelError> { + if input.evidence_id.is_empty() + || input.evidence_id.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES + || url::Url::parse(input.evidence_id).is_err() + || !crate::model::request_nonce_is_canonical(input.request_nonce) + || input.audience.is_empty() + || input.audience.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES + || url::Url::parse(input.audience).is_err() + || !requirement + .purposes + .iter() + .any(|purpose| purpose == input.purpose) + || input.issued_at < input.observed_at + || input.subjects.len() != requirement.subject_roles.len() + || values.is_empty() + { + return Err(KernelError::Evidence); + } + let mut roles = BTreeSet::new(); + for (configured, subject) in requirement.subject_roles.iter().zip(&input.subjects) { + if configured.role != subject.role + || !roles.insert(subject.role.as_str()) + || !valid_opaque_binding(&subject.binding) + { + return Err(KernelError::Evidence); + } + } + let expected = requirement + .concepts + .iter() + .filter(|concept| concept.required) + .map(|concept| concept.id.as_str()) + .collect::>(); + let actual = values + .iter() + .map(|value| value.provides_value_for.as_str()) + .collect::>(); + if actual.len() != values.len() + || !expected.is_subset(&actual) + || actual.iter().any(|identifier| { + !requirement + .concepts + .iter() + .any(|concept| concept.id == *identifier) + }) + { + return Err(KernelError::Evidence); + } + Ok(()) +} + +fn format_utc(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn valid_opaque_binding(value: &str) -> bool { + let Some(rest) = value.strip_prefix("urn:evidence:subject:v") else { + return false; + }; + let Some((version, encoded)) = rest.split_once('_') else { + return false; + }; + !version.is_empty() + && !version.starts_with('0') + && version.bytes().all(|byte| byte.is_ascii_digit()) + && encoded.len() == 43 + && encoded + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn derived_string(value: &DerivedValue) -> Result<&str, KernelError> { + match value { + DerivedValue::Json(Value::String(value)) => Ok(value), + _ => Err(KernelError::Output), + } +} + +fn value_as_json(value: &DerivedValue) -> Result<&Value, KernelError> { + match value { + DerivedValue::Json(value) => Ok(value), + _ => Err(KernelError::Output), + } +} + +fn validate_public_string(value: &str, declared_maximum: usize) -> Result<(), KernelError> { + if value.is_empty() + || value.len() > declared_maximum + || value.len() > MAXIMUM_PUBLIC_STRING_BYTES + { + return Err(KernelError::Output); + } + Ok(()) +} + +fn validate_cardinality(concept: &ConceptConfig, length: usize) -> Result<(), KernelError> { + let minimum = constraint_usize(concept, "minimumItems")?; + let maximum = constraint_usize(concept, "maximumItems")?; + if length < minimum || length > maximum || !constraint_bool(concept, "unique")? { + return Err(KernelError::Output); + } + Ok(()) +} + +fn declared_codelist<'a>( + concept: &ConceptConfig, + codelists: &'a BTreeMap, +) -> Result<&'a Codelist, KernelError> { + let path = constraint_str(concept, "codelist")?; + let expected_version_key = if concept.form == ConceptForm::ControlledCategory { + "schemeVersion" + } else { + "codelistVersion" + }; + let expected_version = constraint_str(concept, expected_version_key)?; + let codelist = codelists.get(path).ok_or(KernelError::Bundle)?; + if codelist.version() != expected_version { + return Err(KernelError::Bundle); + } + Ok(codelist) +} + +fn constraint_str<'a>(concept: &'a ConceptConfig, name: &str) -> Result<&'a str, KernelError> { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_str) + .ok_or(KernelError::Bundle) +} + +fn constraint_i64(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_i64) + .ok_or(KernelError::Bundle) +} + +fn constraint_u64(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_u64) + .ok_or(KernelError::Bundle) +} + +fn constraint_usize(concept: &ConceptConfig, name: &str) -> Result { + usize::try_from(constraint_u64(concept, name)?).map_err(|_| KernelError::Bundle) +} + +fn constraint_bool(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_bool) + .ok_or(KernelError::Bundle) +} + +fn has_exact_json_keys(object: &JsonMap, keys: &[&str]) -> bool { + object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key)) +} + +fn valid_code(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +trait ExactlyOne: Iterator + Sized { + fn exactly_one(mut self) -> Option { + let item = self.next()?; + self.next().is_none().then_some(item) + } +} + +impl ExactlyOne for I {} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::Duration as StdDuration; + + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use serde_json::json; + use tempfile::TempDir; + + use crate::signing::{jwks_document, EvidenceSigner}; + use crate::source::project_fixture_response; + use crate::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; + + const KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + const AUDIENCE: &str = "urn:example:fixture:audience"; + const SUPPORTED_VALUE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"supported-values-fixture-key"}"#; + + fn projection() -> ValueProjection<'static> { + ValueProjection { + audience: AUDIENCE, + binding_key: KEY, + binding_key_version: 1, + } + } + + #[test] + fn all_four_acceptance_bundles_use_the_same_kernel() { + let cases = [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ]; + for case in cases { + let copied = immutable_fixture(case); + let bundle = Arc::new(Bundle::load(copied.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let fixture: Value = serde_norway::from_slice( + bundle + .artifact( + requirement + .fixtures + .as_ref() + .expect("acceptance fixture is declared") + .as_str(), + ) + .expect("fixture is captured"), + ) + .expect("fixture parses"); + let source_config = bundle + .config + .sources + .get(&requirement.source) + .expect("requirement source exists"); + for test_case in fixture["cases"].as_array().expect("cases is an array") { + if test_case.get("source_failure").is_some() + || test_case.get("injected_derivation").is_some() + || test_case.get("companion_bundle").is_some() + || test_case.get("subjects").is_some() + { + continue; + } + let Some(source) = test_case.get("source") else { + continue; + }; + let source = project_fixture_response(source_config, source) + .map_err(|_| KernelError::SourceProtocol); + let observed = observed_for_case(&fixture, test_case); + let selectors = test_case + .get("derivationSelectorInputs") + .or_else(|| { + fixture + .get("common") + .and_then(|common| common.get("derivationSelectorInputs")) + }) + .cloned() + .unwrap_or_else(|| Value::Object(JsonMap::new())); + let result = source.and_then(|source| { + kernel.evaluate_with_selectors( + &requirement.id, + &source, + &selectors, + observed, + projection(), + ) + }); + match test_case.get("expected_lookup").and_then(Value::as_str) { + Some("no_match") => assert!(matches!(result, Ok(KernelOutcome::NoMatch))), + Some("ambiguous") => assert!(matches!(result, Ok(KernelOutcome::Ambiguous))), + _ if test_case.get("expected_public_problem").is_some() => { + assert!(result.is_err(), "{case}: {}", test_case["id"]) + } + _ => { + let KernelOutcome::Match(values) = result.unwrap_or_else(|error| { + panic!("{case}: {} failed: {error:?}", test_case["id"]) + }) else { + panic!("{case}: expected match"); + }; + assert!(!values.as_slice().is_empty()); + if let Some(expected) = test_case.get("expected_value") { + assert_eq!( + serde_json::to_value(&values.as_slice()[0].value) + .expect("value serializes"), + *expected + ); + } + } + } + } + } + } + + #[test] + fn a_projected_response_outside_its_declared_shape_fails_before_extraction() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + + // The declared response shape is the extraction contract. Rust refuses + // a response the script would otherwise have to re-check by hand, and + // the refusal is the same closed source-protocol class the script + // would have raised. + for response in [ + json!({}), + json!({"total": "1"}), + json!({"total": -1}), + json!({"total": 1, "unexpected": true}), + json!({"total": 1, "date_of_birth": 19700101}), + json!({"total": 1, "date_of_birth": "1970-13-01"}), + ] { + assert_eq!( + kernel.extract(&requirement.id, &response), + Err(KernelError::SourceProtocol), + "{response}" + ); + } + + // An absent optional leaf is legitimate after projection, so it stays a + // script decision rather than a shape violation. + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 1})), + Err(KernelError::Extraction) + ); + assert_eq!( + kernel.extract( + &requirement.id, + &json!({"total": 1, "date_of_birth": "1970-01-01"}) + ), + Ok(LookupResult::Match(BTreeMap::from([( + "date_of_birth".to_owned(), + json!("1970-01-01") + )]))) + ); + } + + /// A shape rejection reaches the requester as `dependency_unavailable`, + /// which names the provider. When the real defect is a bundle schema that + /// no longer describes the source, the operator log is the only place that + /// can say so, and it can only say it by naming the member and the rule. + /// It must do that without recording any part of the response. + #[test] + fn a_response_shape_rejection_names_the_member_and_the_rule_but_no_value() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let requirement = &bundle.config.requirements[0]; + let source = bundle + .config + .sources + .get(&requirement.source) + .expect("the requirement names a configured source"); + let schema = bundle + .fact_schema(&source.response_schema) + .expect("the response schema is a bundle artifact"); + let compiled = compile_schema(schema).expect("the response schema compiles"); + + let canary = "0451-mrs-hunt-was-born-in-caracas"; + let response = json!({"total": 1, "date_of_birth": canary}); + let errors = compiled + .validate(&response) + .expect_err("a malformed date violates the shape"); + let (violations, total) = describe_response_shape_rejection(errors); + assert_eq!(total, 1); + assert_eq!( + violations, + vec!["/date_of_birth violates /properties/date_of_birth/format".to_owned()] + ); + + // The library's own message would carry the value here, which is the + // reason the description is built from the two pointers instead. + assert!( + !violations + .iter() + .any(|violation| violation.contains(canary)), + "no response value reaches the log: {violations:?}" + ); + + // A violation at the document root still names somewhere. + let not_an_object = json!([]); + let errors = compiled + .validate(¬_an_object) + .expect_err("an array is not the declared object"); + let (violations, _) = describe_response_shape_rejection(errors); + assert_eq!( + violations, + vec!["the response root violates /type".to_owned()] + ); + + // A source cannot decide how much the log holds. + let many = json!({"total": -1, "date_of_birth": "not-a-date", "extra": 1}); + let errors = compiled + .validate(&many) + .expect_err("several rules are violated at once"); + let (violations, total) = describe_response_shape_rejection(errors); + assert!(total >= 3, "the count is the whole number of violations"); + assert!(violations.len() <= REPORTED_SHAPE_VIOLATIONS); + } + + #[test] + fn extraction_failures_and_invalid_outputs_fail_closed() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 0})), + Ok(LookupResult::NoMatch) + ); + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 2})), + Ok(LookupResult::Ambiguous) + ); + assert!(kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!("true")), + }], + projection(), + ) + .is_err()); + let validated = kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ) + .expect("valid output"); + let debug = format!("{validated:?}"); + assert!(!debug.contains("true")); + assert!(kernel + .validate_values( + &requirement.id, + vec![ + DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(true)), + }, + DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(false)), + }, + ], + projection(), + ) + .is_err()); + assert!(kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:extra".to_owned(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ) + .is_err()); + } + + #[test] + fn required_unmapped_source_fact_is_unavailable_not_a_script_failure() { + let copied = immutable_fixture("residence-region"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let observed = "2026-08-02T00:00:00Z".parse().expect("time"); + let LookupResult::Match(facts) = kernel + .extract( + &requirement.id, + &json!({"total": 1, "official_residence_code": "R-999"}), + ) + .expect("source extracts") + else { + panic!("source must uniquely match"); + }; + let raw = kernel.runtime.derive( + kernel + .derivations + .get(&requirement.id) + .expect("derivation exists"), + &facts, + &Value::Object(JsonMap::new()), + kernel + .evaluation_context(requirement, observed) + .expect("context builds"), + ); + assert!( + matches!(raw, Err(RhaiRuntimeError::Unavailable)), + "unexpected closed error class: {:?}", + raw.err() + ); + assert_eq!( + kernel.evaluate( + &requirement.id, + &json!({"total": 1, "official_residence_code": "R-999"}), + observed, + projection(), + ), + Err(KernelError::Extraction) + ); + } + + #[test] + fn scalar_decimal_and_collection_forms_are_exact() { + let code_list = Codelist::Codes { + id: "urn:example:codes".to_owned(), + version: "1".to_owned(), + codes: vec!["A".to_owned(), "B".to_owned()], + }; + let codelists = BTreeMap::from([("urn:example:codes".to_owned(), code_list)]); + let schemas = BTreeMap::new(); + + assert_eq!( + validate_value( + &concept("form: boolean\nrequired: true\nconstraints: {}"), + &DerivedValue::Json(json!(false)), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::Boolean(false)) + ); + assert_eq!( + validate_value( + &concept( + "form: bounded-integer\nrequired: true\nconstraints: {minimum: -2, maximum: 2}" + ), + &DerivedValue::Json(json!(2)), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::Integer(2)) + ); + assert_eq!( + validate_value( + &concept("form: controlled-code\nrequired: true\nconstraints: {codelist: 'urn:example:codes', codelistVersion: '1', maximumBytes: 8}"), + &DerivedValue::Json(json!("A")), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::String("A".to_owned())) + ); + assert_eq!( + validate_value( + &concept("form: bounded-decimal\nrequired: true\nconstraints: {minimum: '-1.5', maximum: '1.5', maximumScale: 2}"), + &DerivedValue::Decimal(Decimal::parse("0.25").expect("decimal")), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::String("0.25".to_owned())) + ); + assert!(validate_value( + &concept("form: bounded-decimal\nrequired: true\nconstraints: {minimum: '-1.5', maximum: '1.5', maximumScale: 2}"), + &DerivedValue::Json(json!(0.25)), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + + let controlled = concept( + "form: controlled-code-list\nrequired: true\nconstraints: {codelist: 'urn:example:codes', codelistVersion: '1', minimumItems: 1, maximumItems: 2, unique: true}", + ); + assert!(validate_value( + &controlled, + &DerivedValue::Json(json!(["A", "A"])), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + assert!(validate_value( + &controlled, + &DerivedValue::Json(json!(["UNKNOWN"])), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + + let category_list = Codelist::Codes { + id: "urn:example:category-scheme".to_owned(), + version: "7".to_owned(), + codes: vec!["category-a".to_owned()], + }; + let category_lists = + BTreeMap::from([("codelists/categories.yaml".to_owned(), category_list)]); + assert_eq!( + validate_value( + &concept("form: controlled-category\nrequired: true\nconstraints: {categoryScheme: 'urn:example:category-scheme', schemeVersion: '7', maximumBytes: 32, codelist: 'codelists/categories.yaml'}"), + &DerivedValue::Json(json!("category-a")), + &projection(), + &category_lists, + &schemas, + ), + Ok(PublicValue::String("category-a".to_owned())) + ); + } + + #[test] + fn bucket_entity_and_structured_forms_are_closed() { + let buckets = Codelist::Codes { + id: "urn:example:bucket-scheme".to_owned(), + version: "1".to_owned(), + codes: vec!["inside".to_owned()], + }; + let codelists = BTreeMap::from([("buckets".to_owned(), buckets)]); + let bucket = concept( + "form: time-bucket\nrequired: true\nconstraints: {bucketScheme: 'urn:example:bucket-scheme', schemeVersion: '1'}", + ); + let date_bucket = concept( + "form: date-bucket\nrequired: true\nconstraints: {bucketScheme: 'urn:example:bucket-scheme', schemeVersion: '1'}", + ); + assert!(matches!( + validate_value( + &date_bucket, + &DerivedValue::Json( + json!({"form":"date-bucket","scheme":"urn:example:bucket-scheme","bucket":"inside"}) + ), + &projection(), + &codelists, + &BTreeMap::new(), + ), + Ok(PublicValue::Bucket(BucketValue { + form: BucketForm::DateBucket, + .. + })) + )); + assert!(validate_value( + &bucket, + &DerivedValue::Json(json!({"form":"time-bucket","scheme":"urn:example:bucket-scheme","bucket":"unknown"})), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + + let entity = concept( + "form: audience-scoped-entity-reference\nrequired: true\nconstraints: {maximumBytes: 160}", + ); + let seed = crate::values::EntityReferenceSeed::new("protected-seed").expect("seed"); + let public = validate_value( + &entity, + &DerivedValue::EntityReferenceSeed(seed), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .expect("entity projects"); + assert!(matches!(public, PublicValue::EntityReference(_))); + assert!(validate_value( + &entity, + &DerivedValue::Json(json!("protected-seed")), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + + let entity_list = concept( + "form: entity-reference-list\nrequired: true\nconstraints: {minimumItems: 1, maximumItems: 2, unique: true}", + ); + let duplicate = crate::values::EntityReferenceSeed::new("same-seed").expect("seed"); + assert!(validate_value( + &entity_list, + &DerivedValue::EntityReferenceSeedList(vec![duplicate.clone(), duplicate]), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + let projected = validate_value( + &entity_list, + &DerivedValue::EntityReferenceSeedList(vec![ + crate::values::EntityReferenceSeed::new("seed-a").expect("seed"), + crate::values::EntityReferenceSeed::new("seed-b").expect("seed"), + ]), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .expect("entity list projects"); + assert!(matches!(projected, PublicValue::List(_))); + + let schema_id = "urn:example:structured"; + let schema = compile_schema(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_id, + "type": "object", + "additionalProperties": false, + "required": ["status", "effective_date", "observed_at"], + "properties": { + "status": {"type": "string", "enum": ["A"]}, + "effective_date": {"type": "string", "format": "date"}, + "observed_at": {"type": "string", "format": "date-time"} + } + })) + .expect("schema compiles"); + let schemas = BTreeMap::from([(schema_id.to_owned(), schema)]); + let structured = concept( + "form: reviewed-structured-value\nrequired: true\nconstraints: {schema: 'urn:example:structured', maximumSerializedBytes: 512}", + ); + assert!(validate_value( + &structured, + &DerivedValue::Json(json!({"form":"reviewed-structured-value","schema":schema_id,"fields":{"status":"A","effective_date":"2026-02-30","observed_at":"not-an-instant"}})), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + assert!(validate_value( + &structured, + &DerivedValue::Json(json!({"form":"reviewed-structured-value","schema":schema_id,"fields":{"status":"A","effective_date":"2026-02-28","observed_at":"2026-02-28T12:00:00Z"}})), + &projection(), + &codelists, + &schemas, + ) + .is_ok()); + } + + #[test] + fn evidence_construction_is_deterministic_and_role_ordered() { + let copied = immutable_fixture("legal-parent-relationship"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let values = kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(false)), + }], + projection(), + ) + .expect("values validate"); + let construction = || EvidenceConstruction { + evidence_id: "urn:ulid:01K1EXAMPLE0000000000000000", + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: &requirement.purposes[0], + audience: AUDIENCE, + issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), + observed_at: "2026-08-02T00:00:00Z".parse().expect("time"), + subjects: vec![ + SubjectBinding { + role: "child".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }, + SubjectBinding { + role: "candidate-parent".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "B".repeat(43)), + }, + ], + }; + let first = kernel + .construct_evidence(&requirement.id, values.clone(), construction()) + .expect("constructs"); + let second = kernel + .construct_evidence(&requirement.id, values, construction()) + .expect("constructs"); + assert_eq!(first, second); + assert_eq!(first.supported_values[0].value, PublicValue::Boolean(false)); + assert_eq!(first.subjects[0].role, "child"); + assert_eq!(first.subjects[1].role, "candidate-parent"); + assert_eq!(first.valid_until, "2026-08-03T00:00:01Z"); + } + + #[tokio::test] + async fn supported_value_fixture_cases_use_the_real_gate_and_signed_round_trip() { + let fixture = supported_values_fixture(); + assert_eq!( + fixture["fixture"].as_str(), + Some("registry.evidence.supported-values/v1") + ); + assert_eq!(fixture["synthetic_only"].as_bool(), Some(true)); + + let copied = immutable_supported_values_bundle(); + let bundle = Arc::new(Bundle::load(copied.path()).expect("supported-value bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let forms = fixture["forms"].as_array().expect("forms are an array"); + assert_eq!(forms.len(), 11, "all Version 1 forms remain covered"); + assert_eq!(requirement.concepts.len(), forms.len()); + + let signer = supported_values_signer().await; + for form_fixture in forms { + let form = form_fixture["form"].as_str().expect("form name"); + let concept_id = format!("urn:example:fixture:concept:{form}"); + let concept = requirement + .concepts + .iter() + .find(|candidate| candidate.id == concept_id) + .unwrap_or_else(|| panic!("bundle declaration missing {form}")); + assert_fixture_declaration(form_fixture, concept); + + for category in ["positive", "boundary"] { + let cases = form_fixture[category] + .as_array() + .unwrap_or_else(|| panic!("{form} {category} cases")); + for (index, fixture_case) in cases.iter().enumerate() { + let derived = accepted_derived_value( + &kernel, + requirement, + form_fixture, + category, + index, + fixture_case, + ) + .unwrap_or_else(|error| { + panic!("{form} {category}[{index}] derivation failed: {error:?}") + }); + let values = gate_fixture_value(&kernel, requirement, form, derived) + .unwrap_or_else(|error| { + panic!("{form} {category}[{index}] gate failed: {error:?}") + }); + let public = values + .as_slice() + .iter() + .find(|value| value.provides_value_for == concept_id) + .unwrap_or_else(|| panic!("{form} value was not emitted")); + assert_fixture_public_shape(form, fixture_case, &public.value); + assert_signed_type_preserving_round_trip(&kernel, requirement, values, &signer) + .await; + } + } + + let negatives = form_fixture["negative"] + .as_array() + .unwrap_or_else(|| panic!("{form} negatives")); + for (index, fixture_case) in negatives.iter().enumerate() { + let derivation = + fixture_derived_value(&kernel, requirement, &concept_id, fixture_case); + if fixture_case.get("leak_surface").is_some() { + let derived = derivation.unwrap_or_else(|error| { + panic!("{form} privacy negative[{index}] must derive: {error:?}") + }); + let values = gate_fixture_value(&kernel, requirement, form, derived) + .expect("protected seed is projected by the output gate"); + let serialized = serde_json::to_string(values.as_slice()).expect("serializes"); + let debug = format!("{values:?}"); + assert!(!serialized.contains("source-seed-canary")); + assert!(!debug.contains("source-seed-canary")); + } else { + let rejected = match derivation { + Err(_) => true, + Ok(derived) => { + gate_fixture_value(&kernel, requirement, form, derived).is_err() + } + }; + assert!(rejected, "{form} negative[{index}] must fail closed"); + } + } + } + } + + #[test] + fn supported_value_fixture_global_negatives_are_enforced() { + let fixture = supported_values_fixture(); + let declared = fixture["global_negative"] + .as_array() + .expect("global negatives") + .iter() + .map(|value| value.as_str().expect("negative id")) + .collect::>(); + assert_eq!( + declared, + vec![ + "undeclared-concept", + "duplicate-concept", + "missing-required-concept", + "extra-value-metadata", + "per-value-size-plus-one", + "aggregate-result-size-plus-one", + ] + ); + + let copied = immutable_supported_values_bundle(); + let bundle = Arc::new(Bundle::load(copied.path()).expect("supported-value bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let boolean = || DerivedConceptValue { + concept_id: "urn:example:fixture:concept:boolean".to_owned(), + value: DerivedValue::Json(json!(true)), + }; + + assert_eq!( + kernel.validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:undeclared".to_owned(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ), + Err(KernelError::Output), + "undeclared-concept" + ); + assert_eq!( + kernel.validate_values(&requirement.id, vec![boolean(), boolean()], projection(),), + Err(KernelError::Output), + "duplicate-concept" + ); + assert_eq!( + kernel.validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:bounded-integer".to_owned(), + value: DerivedValue::Json(json!(0)), + }], + projection(), + ), + Err(KernelError::Output), + "missing-required-concept" + ); + + let extra_metadata = r#" + fn derive(facts, selectors, evaluation_context) { + [#{ + concept_id: "urn:example:fixture:concept:boolean", + value: true, + confidence: "not-allowed" + }] + } + "#; + let script = kernel + .runtime + .compile_derivation(extra_metadata) + .expect("negative script compiles"); + assert!( + kernel + .runtime + .derive( + &script, + &BTreeMap::new(), + &Value::Object(JsonMap::new()), + kernel + .evaluation_context( + requirement, + "2026-08-02T00:00:00Z".parse().expect("time") + ) + .expect("context"), + ) + .is_err(), + "extra-value-metadata" + ); + + let oversized = "A".repeat(MAXIMUM_PUBLIC_STRING_BYTES + 1); + let oversized_codelist = Codelist::Codes { + id: "urn:example:fixture:codelist:oversized".to_owned(), + version: "1".to_owned(), + codes: vec![oversized.clone()], + }; + assert_eq!( + validate_value( + &concept("form: controlled-code\nrequired: true\nconstraints: {codelist: oversized, codelistVersion: '1', maximumBytes: 8192}"), + &DerivedValue::Json(Value::String(oversized)), + &projection(), + &BTreeMap::from([("oversized".to_owned(), oversized_codelist)]), + &BTreeMap::new(), + ), + Err(KernelError::Output), + "per-value-size-plus-one" + ); + + let aggregate_schema_id = "urn:example:fixture:schema:aggregate:v1"; + let aggregate_schema = compile_schema(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": aggregate_schema_id, + "type": "object", + "additionalProperties": false, + "required": ["blob"], + "properties": {"blob": {"type": "string", "maxLength": 5000}} + })) + .expect("aggregate schema compiles"); + let aggregate_concepts = (0..16) + .map(|index| { + let mut candidate = concept(&format!( + "form: reviewed-structured-value\nrequired: false\nconstraints: {{schema: '{aggregate_schema_id}', maximumSerializedBytes: 8192}}" + )); + candidate.id = format!("urn:example:fixture:concept:aggregate-{index}"); + candidate + }) + .collect::>(); + let mut aggregate_requirement = requirement.clone(); + aggregate_requirement.concepts = aggregate_concepts; + let aggregate_values = (0..16) + .map(|index| DerivedConceptValue { + concept_id: format!("urn:example:fixture:concept:aggregate-{index}"), + value: DerivedValue::Json(json!({ + "form": "reviewed-structured-value", + "schema": aggregate_schema_id, + "fields": {"blob": "X".repeat(4200)} + })), + }) + .collect(); + assert_eq!( + gate_values( + &aggregate_requirement, + aggregate_values, + projection(), + &BTreeMap::new(), + &BTreeMap::from([(aggregate_schema_id.to_owned(), aggregate_schema)]), + ), + Err(KernelError::Output), + "aggregate-result-size-plus-one" + ); + } + + fn supported_values_fixture() -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance/supported-values.yaml"); + serde_norway::from_slice(&fs::read(path).expect("supported-value fixture reads")) + .expect("supported-value fixture parses") + } + + fn supported_values_bundle_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance/supported-values") + } + + fn immutable_supported_values_bundle() -> TempDir { + let temporary = tempfile::tempdir().expect("temporary directory"); + copy_tree(&supported_values_bundle_path(), temporary.path()); + make_read_only(temporary.path()); + temporary + } + + fn gate_fixture_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + form: &str, + derived: DerivedConceptValue, + ) -> Result { + let mut values = Vec::with_capacity(2); + if form != "boolean" { + values.push(DerivedConceptValue { + concept_id: "urn:example:fixture:concept:boolean".to_owned(), + value: DerivedValue::Json(json!(true)), + }); + } + values.push(derived); + kernel.validate_values(&requirement.id, values, projection()) + } + + fn assert_fixture_declaration(form_fixture: &Value, concept: &ConceptConfig) { + let form = form_fixture["form"].as_str().expect("form"); + let declaration = form_fixture["declaration"] + .as_object() + .expect("form declaration"); + assert_eq!(declaration["form"], form); + assert_eq!( + serde_json::to_value(concept.form).expect("form serializes"), + Value::String(form.to_owned()) + ); + assert_eq!( + concept.required, + declaration + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false) + ); + + let mut expected = JsonMap::new(); + for (name, value) in declaration { + let translated = match name.as_str() { + "form" | "required" | "fields" => continue, + "codelist" => "codelist", + "codelist_version" => "codelistVersion", + "category_scheme" => "categoryScheme", + "scheme_version" => "schemeVersion", + "maximum_bytes" => "maximumBytes", + "maximum_scale" => "maximumScale", + "bucket_scheme" => "bucketScheme", + "minimum_items" => "minimumItems", + "maximum_items" => "maximumItems", + "maximum_serialized_bytes" => "maximumSerializedBytes", + other => other, + }; + let value = if name == "codelist" && value.as_str() == Some("synthetic-codes") { + Value::String("codelists/synthetic-codes.yaml".to_owned()) + } else { + value.clone() + }; + expected.insert(translated.to_owned(), value); + } + if form == "controlled-category" { + expected.insert( + "codelist".to_owned(), + Value::String("codelists/categories.yaml".to_owned()), + ); + } + assert_eq!( + serde_json::to_value(&concept.constraints).expect("constraints serialize"), + Value::Object(expected), + "{form} fixture declaration must be the executable bundle declaration" + ); + } + + fn accepted_derived_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + form_fixture: &Value, + category: &str, + index: usize, + fixture_case: &Value, + ) -> Result { + let form = form_fixture["form"].as_str().expect("form"); + let concept_id = format!("urn:example:fixture:concept:{form}"); + if form == "audience-scoped-entity-reference" { + let expression = if category == "positive" { + form_fixture["derivation_positive"][index]["rhai"] + .as_str() + .expect("entity derivation") + .to_owned() + } else { + "entity_reference_seed(\"synthetic-boundary-seed\")".to_owned() + }; + return derive_expression(kernel, requirement, &concept_id, &expression); + } + if form == "entity-reference-list" { + let derivation_index = if category == "positive" { + index + } else { + index + 1 + }; + let expressions = form_fixture["derivation_positive"][derivation_index] + .as_array() + .expect("entity list derivation") + .iter() + .map(|value| value.as_str().expect("Rhai expression")) + .collect::>() + .join(", "); + return derive_expression( + kernel, + requirement, + &concept_id, + &format!("[{expressions}]"), + ); + } + fixture_derived_value(kernel, requirement, &concept_id, fixture_case) + } + + fn fixture_derived_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + concept_id: &str, + fixture_case: &Value, + ) -> Result { + if let Some(expression) = fixture_case.get("rhai") { + let expression = match expression { + Value::Array(expressions) => format!( + "[{}]", + expressions + .iter() + .map(rhai_fixture_expression) + .collect::>() + .join(", ") + ), + value => rhai_fixture_expression(value), + }; + derive_expression(kernel, requirement, concept_id, &expression) + } else { + Ok(DerivedConceptValue { + concept_id: concept_id.to_owned(), + value: DerivedValue::Json(fixture_case.clone()), + }) + } + } + + fn rhai_fixture_expression(value: &Value) -> String { + match value { + Value::String(value) + if value.starts_with("decimal(") + || value.starts_with("parse_decimal(") + || value.starts_with("entity_reference_seed(") => + { + value.clone() + } + Value::String(value) => serde_json::to_string(value).expect("string expression"), + Value::Number(value) => value.to_string(), + _ => panic!("unsupported fixture Rhai expression form"), + } + } + + fn derive_expression( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + concept_id: &str, + expression: &str, + ) -> Result { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"{concept_id}\", value: {expression} }}] }}" + ); + let script = kernel.runtime.compile_derivation(&source)?; + let values = kernel.runtime.derive( + &script, + &BTreeMap::new(), + &Value::Object(JsonMap::new()), + kernel + .evaluation_context(requirement, "2026-08-02T00:00:00Z".parse().expect("time")) + .expect("evaluation context"), + )?; + values + .into_iter() + .next() + .ok_or(RhaiRuntimeError::DerivationResult) + } + + fn assert_fixture_public_shape(form: &str, fixture_case: &Value, actual: &PublicValue) { + let actual = serde_json::to_value(actual).expect("public value serializes"); + if let Some(wire) = fixture_case.get("wire_json").and_then(Value::as_str) { + let expected: Value = serde_json::from_str(wire).expect("wire JSON parses"); + assert_eq!(actual, expected); + return; + } + match form { + "audience-scoped-entity-reference" => { + let _: PublicValue = serde_json::from_value(fixture_case.clone()) + .expect("public entity exemplar parses"); + assert_eq!(actual["form"], "audience-scoped-entity-reference"); + let reference = actual["reference"].as_str().expect("projected reference"); + assert!(reference.starts_with("urn:evidence:entity:v1_")); + } + "entity-reference-list" => { + let _: PublicValue = serde_json::from_value(fixture_case.clone()) + .expect("public entity-list exemplar parses"); + assert_eq!( + actual.as_array().map(Vec::len), + fixture_case.as_array().map(Vec::len) + ); + assert!(actual + .as_array() + .expect("public list") + .iter() + .all(|item| item["form"] == "audience-scoped-entity-reference")); + } + _ => assert_eq!(actual, *fixture_case), + } + } + + async fn supported_values_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(SUPPORTED_VALUE_PRIVATE_JWK).expect("fixture key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, "supported-values-fixture-key") + .await + .expect("fixture signer initializes") + } + + async fn assert_signed_type_preserving_round_trip( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + values: ValidatedValues, + signer: &EvidenceSigner, + ) { + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01K1SUPPORTEDVALUES0000000000", + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: "conformance", + audience: AUDIENCE, + issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), + observed_at: "2026-08-02T00:00:00Z".parse().expect("time"), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("Evidence constructs"); + let expected_values = + serde_json::to_value(&evidence.supported_values).expect("values serialize"); + let jws = signer + .sign_json(&evidence) + .await + .expect("fixture Evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("fixture JWKS builds"); + let verified = verify_flattened_jws( + &serialized, + &jwks, + &EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + &evidence.request_nonce, + StdDuration::from_secs(48 * 60 * 60), + "2026-08-02T12:00:00Z".parse().expect("time"), + StdDuration::from_secs(30), + ), + ) + .expect("signed Evidence verifies"); + assert_eq!( + serde_json::to_value(verified.supported_values).expect("verified values serialize"), + expected_values, + "JSON value types must survive construction, signing, verification, and parsing" + ); + } + + fn concept(body: &str) -> ConceptConfig { + serde_norway::from_str(&format!("id: urn:example:concept\n{body}\n")) + .expect("concept parses") + } + + fn observed_for_case(fixture: &Value, test_case: &Value) -> DateTime { + let local_date = test_case + .get("legal_local_date") + .or_else(|| { + fixture + .get("common") + .and_then(|common| common.get("legal_local_date")) + }) + .and_then(Value::as_str) + .unwrap_or("2026-08-02"); + format!("{local_date}T00:00:00Z") + .parse() + .expect("observed time") + } + + fn immutable_fixture(name: &str) -> TempDir { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(name); + let temporary = tempfile::tempdir().expect("temporary directory"); + copy_tree(&source, temporary.path()); + make_read_only(temporary.path()); + temporary + } + + fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("reads fixture") { + let entry = entry.expect("directory entry"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("file type").is_dir() { + fs::create_dir(&destination).expect("creates directory"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copies fixture"); + } + } + } + + #[cfg(unix)] + fn make_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + for entry in fs::read_dir(path).expect("reads copied fixture") { + let entry = entry.expect("directory entry"); + let child = entry.path(); + if entry.file_type().expect("file type").is_dir() { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("locks directory"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)).expect("locks file"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("locks root"); + } + + #[cfg(not(unix))] + fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("reads copied fixture") { + let entry = entry.expect("directory entry"); + let child = entry.path(); + if entry.file_type().expect("file type").is_dir() { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child).expect("metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("locks file"); + } + } + } +} diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs new file mode 100644 index 000000000..3df152990 --- /dev/null +++ b/crates/registry-evidence/src/lib.rs @@ -0,0 +1,43 @@ +//! Evidence Version 1 minimum-disclosure assertion runtime. + +#[cfg(not(unix))] +compile_error!("registry-evidence Version 1 requires a Unix target for owner and file-identity security guarantees"); + +pub mod audit; +pub mod auth; +pub mod binding; +pub mod bundle; +pub mod config; +pub mod contracts; +pub mod kernel; +pub mod local_verification; +pub mod model; +pub mod observability; +pub mod problem; +pub mod rate_limit; +pub mod rhai_runtime; +pub mod runtime; +pub mod sdjwt_vc; +pub mod secrets; +pub mod selector; +pub mod server; +pub mod signing; +pub mod source; +pub mod values; +pub mod verifier; + +#[cfg(test)] +mod runtime_tests; + +pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; +pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/v1"; +pub const EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1: &str = "registry.unsigned-evidence-envelope/v1"; +pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; +pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; +pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; +/// Compact SD-JWT VC serialization of the same assertion. The profile adds a +/// response format only; it introduces no credential lifecycle. +pub const EVIDENCE_SD_JWT_VC_MEDIA_TYPE: &str = "application/dc+sd-jwt"; +pub const EVIDENCE_SD_JWT_VC_TYP: &str = "dc+sd-jwt"; +pub const EVIDENCE_UNSIGNED_MEDIA_TYPE: &str = + "application/vnd.registrystack.evidence-unsigned+json"; diff --git a/crates/registry-evidence/src/local_verification.rs b/crates/registry-evidence/src/local_verification.rs new file mode 100644 index 000000000..8da3cbe5e --- /dev/null +++ b/crates/registry-evidence/src/local_verification.rs @@ -0,0 +1,230 @@ +//! Core-owned pre-response verification context for the local adopter path. +//! +//! Preparation authenticates and authorizes the exact retained request before +//! any source access. Verification then uses only that closed context and the +//! returned bytes. The second half cannot initialize a runtime, open audit +//! storage, resolve a source, or fetch keys over the network. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + auth::Authenticator, + bundle::DeploymentInputs, + config::{AssuranceProfile, ConceptForm, ResponseFormat}, + model::{request_nonce_is_canonical, Evidence, EvidenceRequest, JwksDocument}, + runtime::{validate_verification_material, ValidatedVerificationMaterial}, + secrets::{SecretProvider, SecretResolver}, + selector::{match_entitlement, resolve_selectors}, + verifier::{ + verify_flattened_jws, EvidenceVerificationPolicyDocument, ExpectedFormDocument, + ExpectedOutputDocument, ExpectedScalarFormDocument, ExpectedSubjectDocument, + }, +}; + +pub const LOCAL_VERIFICATION_CONTEXT_SCHEMA_V1: &str = + "registry.evidence.local-response-verification-context/v1"; + +/// One deliberately uninformative failure for the local verification seam. +/// +/// Authentication, entitlement, selector, secret, signing, context, and +/// response failures collapse here so caller-controlled values cannot reach a +/// retained CLI diagnostic. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("local response verification failed")] +pub struct LocalVerificationError; + +/// Closed trusted state retained before sending the corresponding request. +/// +/// `responseFormat` is explicit so a future encoding can reuse the common +/// policy without ever inferring its format from attacker-controlled bytes. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalVerificationContext { + schema: String, + response_format: LocalResponseFormat, + trusted_jwks: JwksDocument, + verification_policy: EvidenceVerificationPolicyDocument, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LocalResponseFormat { + SignedJws, + SdJwtVc, +} + +/// Authenticate and authorize one exact local request and close all response +/// expectations before source access. +pub async fn prepare_local_verification_context( + deployment: &DeploymentInputs, + request: &EvidenceRequest, + bearer: &str, +) -> Result { + prepare_local_verification_context_for_format( + deployment, + request, + bearer, + LocalResponseFormat::SignedJws, + ) + .await +} + +/// Close the local verification expectations for one explicitly selected +/// response format before any response or source access exists. +pub async fn prepare_local_verification_context_for_format( + deployment: &DeploymentInputs, + request: &EvidenceRequest, + bearer: &str, + response_format: LocalResponseFormat, +) -> Result { + let bundle = &deployment.bundle; + let configured_format = match response_format { + LocalResponseFormat::SignedJws => ResponseFormat::SignedJws, + LocalResponseFormat::SdJwtVc => ResponseFormat::SdJwtVc, + }; + if bundle.config.assurance_profile != AssuranceProfile::Local + || !request_nonce_is_canonical(&request.request_nonce) + || request.holder_key.is_some() + || !bundle.config.response_formats.contains(&configured_format) + { + return Err(LocalVerificationError); + } + + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .ok_or(LocalVerificationError)?; + // The local adopter path remains deliberately narrow: every value must be + // required and use one of the two forms taught by the shared start. + if requirement.concepts.is_empty() + || requirement + .concepts + .iter() + .any(|concept| !concept.required || local_expected_form(concept.form).is_none()) + { + return Err(LocalVerificationError); + } + + let authenticator = Authenticator::from_config( + &bundle.config.authentication, + bundle.config.assurance_profile, + ); + let authenticated = authenticator + .authenticate(bearer) + .await + .map_err(|_| LocalVerificationError)?; + let matched = + match_entitlement(bundle, request, &authenticated).map_err(|_| LocalVerificationError)?; + if !matched.permits_response_format(configured_format) { + return Err(LocalVerificationError); + } + let resolved = resolve_selectors(bundle, request, &authenticated, &matched) + .map_err(|_| LocalVerificationError)?; + + let secrets = SecretResolver::new( + [SecretProvider::File], + &deployment.runtime.config.secret_providers.file.root, + ) + .map_err(|_| LocalVerificationError)?; + let ValidatedVerificationMaterial { + subject_binding_secret, + signer: _, + jwks, + } = validate_verification_material(bundle, &secrets) + .await + .map_err(|_| LocalVerificationError)?; + let expected_subjects = resolved + .subjects + .iter() + .map(|subject| { + subject + .binding( + subject_binding_secret.expose_secret(), + bundle.config.subject_binding.key_version, + &bundle.config.service.trust_domain, + &resolved.audience, + &resolved.purpose, + ) + .map(|binding| ExpectedSubjectDocument { + role: subject.role.clone(), + binding, + }) + .map_err(|_| LocalVerificationError) + }) + .collect::, _>>()?; + + Ok(LocalVerificationContext { + schema: LOCAL_VERIFICATION_CONTEXT_SCHEMA_V1.to_owned(), + response_format, + trusted_jwks: jwks, + verification_policy: EvidenceVerificationPolicyDocument { + expected_assurance_profile: AssuranceProfile::Local, + issued_by: bundle.config.issuer.id.clone(), + provided_by: bundle.config.service.provider_id.clone(), + requirement: requirement.id.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: resolved.purpose, + audience: resolved.audience, + configuration_revision: bundle.revision().to_owned(), + request_nonce: request.request_nonce.clone(), + expected_subjects, + expected_outputs: requirement + .concepts + .iter() + .map(|concept| ExpectedOutputDocument { + concept: concept.id.clone(), + form: ExpectedFormDocument::Scalar( + local_expected_form(concept.form) + .expect("the local concept forms were validated"), + ), + }) + .collect(), + maximum_assertion_lifetime_seconds: requirement.validity_seconds, + clock_skew_seconds: bundle.config.signing.verifier_clock_skew_seconds, + }, + }) +} + +fn local_expected_form(form: ConceptForm) -> Option { + match form { + ConceptForm::Boolean => Some(ExpectedScalarFormDocument::Boolean), + ConceptForm::ControlledCategory => Some(ExpectedScalarFormDocument::String), + ConceptForm::ReviewedStructuredValue => Some(ExpectedScalarFormDocument::Structured), + _ => None, + } +} + +/// Strictly verify one flattened JWS against a context retained before the +/// response existed. This operation is entirely offline. +pub fn verify_local_response( + context: LocalVerificationContext, + response: &[u8], +) -> Result { + verify_local_response_at(context, response, Utc::now()) +} + +/// Deterministic clock entry point used by the expiry test. Production callers +/// use [`verify_local_response`] and cannot choose the verification instant. +pub(crate) fn verify_local_response_at( + context: LocalVerificationContext, + response: &[u8], + now: DateTime, +) -> Result { + if context.schema != LOCAL_VERIFICATION_CONTEXT_SCHEMA_V1 { + return Err(LocalVerificationError); + } + let policy = context.verification_policy.into_policy(now); + match context.response_format { + LocalResponseFormat::SignedJws => { + verify_flattened_jws(response, &context.trusted_jwks, &policy) + } + LocalResponseFormat::SdJwtVc => { + crate::verifier::verify_sd_jwt_vc(response, &context.trusted_jwks, &policy) + } + } + .map_err(|_| LocalVerificationError) +} diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs new file mode 100644 index 000000000..c53a98c4b --- /dev/null +++ b/crates/registry-evidence/src/main.rs @@ -0,0 +1,3484 @@ +//! Evidence Version 1 operator CLI and serving process. + +use std::{ + collections::BTreeMap, + fmt, fs, + fs::File, + io::{Read as _, Write as _}, + path::{Component, Path, PathBuf}, + process::ExitCode, + str::FromStr, + sync::Arc, +}; + +use chrono::{DateTime, NaiveDate, SecondsFormat, TimeZone, Utc}; +use chrono_tz::Tz; +use clap::{ArgGroup, Parser, Subcommand}; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::{ + audit::{ + verified_last_local_audit_operation, verify_audit_chain, AuditChainSummary, + EvidenceAuditError, + }, + bundle::{ArtifactFault, Bundle, BundleError, DeploymentInputs, RuntimeDocument}, + config::{AssuranceProfile, ConfigError, EvidenceConfig, OutboundTlsConfig, SelectorInput}, + kernel::{ + EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValidatedValues, + ValueProjection, + }, + local_verification::{ + prepare_local_verification_context_for_format, verify_local_response, LocalResponseFormat, + LocalVerificationContext, + }, + model::{ + EvidenceRequest, JwksDocument, LookupResult, PublicValue, ScalarOrEntityReference, + SelectorValue, SubjectBinding, + }, + problem::ProblemCode, + rhai_runtime::{DerivedConceptValue, DerivedValue, RequestParts}, + runtime::{ + source_failure_problem, validate_secret_material, AuditInitializationFault, + EvidenceRuntime, RuntimeInitializationError, + }, + secrets::{SecretProvider, SecretResolver}, + selector::{ + resolve_offline_fixture_authorization, resolve_offline_fixture_subjects, + OfflineFixtureError, ResolvedAuthorization, ResolvedSelectorValue, + }, + server, + signing::{jwks_document, EvidenceSigner}, + source::{ + project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, + }, + verifier::{ + verify_flattened_jws, verify_flattened_jws_report, verify_sd_jwt_vc_report, + EvidenceVerificationPolicy, EvidenceVerificationPolicyDocument, VerificationError, + }, +}; +use registry_platform_audit::{AuditHashSecret, OptionalHashHex}; +use registry_platform_crypto::{canonicalize_json, parse_json_strict, LocalJwkSigner, PrivateJwk}; +use serde_json::{Map as JsonMap, Value}; +use zeroize::Zeroizing; + +const DEFAULT_RUNTIME_PATH: &str = "/etc/registry-evidence/runtime.yaml"; +const OFFLINE_AUDIENCE: &str = "urn:registry-evidence:offline-evaluation"; +const OFFLINE_BINDING_KEY: [u8; 32] = [0x45; 32]; +const ANTI_RECONSTRUCTION_FIXTURE: &[u8] = + include_bytes!("../../../products/evidence/fixtures/conformance/anti-reconstruction.yaml"); + +#[derive(Debug, Parser)] +#[command(name = "evidence", version, about = "Registry Evidence Version 1")] +struct Cli { + /// One closed operator runtime file that binds the governed bundle. + #[arg( + long, + global = true, + env = "REGISTRY_EVIDENCE_RUNTIME", + default_value = DEFAULT_RUNTIME_PATH + )] + runtime: PathBuf, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Validate and compile the complete immutable bundle, and validate the + /// mounted secret material exactly as startup does. + Check, + /// Evaluate one bundle-owned fixture without source or credential access. + Evaluate { + /// Bundle-relative fixture path referenced by exactly one requirement. + #[arg(long)] + fixture: PathBuf, + }, + /// Start the native Evidence HTTP service. + Serve, + /// Re-verify one stored signed response offline against a pinned key set. + /// + /// Exactly one stored response is named, and its format is named with it. + /// The command never infers a format from the file's contents, so a + /// credential can never be re-verified under the other format's rules. + #[command(group(ArgGroup::new("stored").required(true)))] + Verify { + /// Stored flattened JWS JSON response file. + #[arg(long, group = "stored")] + jws: Option, + /// Stored compact SD-JWT VC response file. + #[arg(long = "sd-jwt-vc", group = "stored")] + sd_jwt_vc: Option, + /// Pinned trusted JWKS document. This file is the complete trust set. + #[arg(long)] + jwks: PathBuf, + /// Relying-procedure verification policy document. + #[arg(long)] + policy: PathBuf, + /// Verification instant as strict RFC 3339 UTC; system time by default. + #[arg(long)] + at: Option, + }, + /// Run a full out-of-band verification pass over the audit chain. + /// + /// Startup verification is deliberately bounded to the active segment, so + /// restart time does not grow with retained history; tampering inside an + /// already sealed segment is not caught there. This is the counterpart + /// check that catches it, meant to run out of band. + VerifyAudit, + /// Internal local-adopter seam. Bearer bytes are accepted only on stdin. + #[command(hide = true)] + PrepareLocalVerificationContext { + /// Owner-only JSON file containing the exact request to retain. + #[arg(long)] + request: PathBuf, + /// Exact response format the caller will request. + #[arg( + long, + default_value = "signed-jws", + value_parser = ["signed-jws", "sd-jwt-vc"] + )] + response_format: String, + }, + /// Internal offline response-verification seam. + #[command(hide = true)] + VerifyLocalResponse { + /// Owner-only closed context produced before the response existed. + #[arg(long)] + context: PathBuf, + /// Bounded flattened JWS JSON returned by Evidence. + #[arg(long)] + response: PathBuf, + }, + /// Internal stopped-service audit inspection seam. + #[command(hide = true)] + LocalAuditLastOperation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CliError(&'static str); + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for CliError {} + +/// A command failure: a fixed operator message, or one artifact diagnostic. +/// +/// The diagnostic names a bundle-relative artifact, a schema path, and a text +/// location so an operator can find the defect. It carries no document value, +/// which is what keeps a failed `check` safe to paste into a ticket. +/// +/// A service failure carries an owned message instead, because the operating +/// system decides both the address and the reason and neither is known when +/// this enum is written. Those two are the whole diagnosis of a failed start, +/// so a fixed string here would cost an operator the port and the cause. +#[derive(Debug, PartialEq, Eq)] +enum CommandError { + Cli(CliError), + Deployment(&'static str, ArtifactFault), + /// The audit boundary refused, with the value-free cause it reported. + /// + /// It is the one startup boundary that separates its causes, because a + /// permission bit, a chain that no longer verifies, and a second writer + /// holding the sink lock have nothing in common but the moment they fail. + Audit(&'static str, AuditInitializationFault), + Service(String), +} + +impl fmt::Display for CommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cli(error) => fmt::Display::fmt(error, formatter), + Self::Deployment(message, fault) => write!(formatter, "{message}: {fault}"), + Self::Audit(message, fault) => write!(formatter, "{message}: {fault}"), + Self::Service(reason) => write!(formatter, "service failed: {reason}"), + } + } +} + +impl std::error::Error for CommandError {} + +impl From for CommandError { + fn from(error: CliError) -> Self { + Self::Cli(error) + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct FixtureSummary { + evaluated_cases: usize, +} + +#[tokio::main] +async fn main() -> ExitCode { + match run(Cli::parse()).await { + Ok(code) => code, + Err(error) => { + eprintln!("evidence: {error}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result { + match cli.command { + Command::Check => { + let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; + let runtime = deployment.runtime; + let bundle = Arc::new(deployment.bundle); + OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("bundle compilation failed"))?; + let _source_plans = compile_source_plans(&bundle.config, &runtime)?; + // Deployment secret material is validated exactly as startup + // validates it, without opening the audit chain, so a deployment + // the server would refuse fails check instead of first start. + // Source credentials stay unresolved: readiness owns them. + let secrets = SecretResolver::new( + [SecretProvider::File], + &runtime.config.secret_providers.file.root, + ) + .map_err(|_| runtime_initialization_error(RuntimeInitializationError::Secrets))?; + validate_secret_material(&bundle, &secrets) + .await + .map_err(runtime_initialization_error)?; + println!( + "Evidence deployment {} / {} passed check ({} requirements)", + bundle.revision(), + runtime.revision(), + bundle.config.requirements.len() + ); + Ok(ExitCode::SUCCESS) + } + Command::Evaluate { fixture } => { + let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; + let runtime = deployment.runtime; + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("fixture bundle compilation failed"))?; + let source_plans = compile_source_plans(&bundle.config, &runtime)?; + let summary = evaluate_fixture(&bundle, &kernel, &source_plans, &fixture).await?; + println!( + "Evidence fixture passed ({} evaluated cases)", + summary.evaluated_cases + ); + Ok(ExitCode::SUCCESS) + } + Command::Serve => { + install_operational_logging(); + let runtime = Arc::new( + EvidenceRuntime::initialize(&cli.runtime) + .await + .map_err(runtime_initialization_error)?, + ); + // The startup announcement belongs to the server, which makes it + // after both listeners are held. Nothing is reported here, because + // a start reported before the bind describes a service that may + // never have got its port. + server::serve(runtime, shutdown_signal()) + .await + .map_err(|error| CommandError::Service(error.to_string()))?; + Ok(ExitCode::SUCCESS) + } + Command::Verify { + jws, + sd_jwt_vc, + jwks, + policy, + at, + } => { + let stored = jws + .map(StoredResponse::SignedJws) + .or_else(|| sd_jwt_vc.map(StoredResponse::SdJwtVc)) + .ok_or(CommandError::Cli(CliError( + "verify requires one stored response file", + )))?; + Ok(verify_stored_response( + &stored, + &jwks, + &policy, + at.as_deref(), + )?) + } + Command::VerifyAudit => run_verify_audit(&cli.runtime), + Command::PrepareLocalVerificationContext { + request, + response_format, + } => prepare_local_context_command(&cli.runtime, &request, &response_format).await, + Command::VerifyLocalResponse { context, response } => { + verify_local_response_command(&context, &response) + } + Command::LocalAuditLastOperation => local_audit_last_operation_command(&cli.runtime), + } +} + +/// Report a startup failure with the artifact diagnostic it carries. +/// +/// The failure class stays a fixed operator message. When the loader knew +/// which artifact failed, the value-free diagnostic is appended so that +/// `evidence check` names a file, a schema path, and a text location instead +/// of only a class. Public HTTP problems are unaffected and stay generic. +fn deployment_load_error(error: BundleError) -> CommandError { + let message = match &error { + BundleError::Unavailable => "deployment input is unavailable", + BundleError::NotImmutable(_) => "deployment input is not immutable", + BundleError::UnsupportedEntry => "deployment contains an unsupported entry", + BundleError::InvalidPath => "deployment contains an invalid path binding", + BundleError::UnknownFile(_) => "deployment artifact closure is invalid", + BundleError::TooLarge => "deployment exceeds a Version 1 size bound", + BundleError::Config(_) => "deployment configuration is invalid", + BundleError::InvalidArtifact(_) => "deployment artifact is invalid", + BundleError::InvalidScript(_) => "deployment script is invalid", + }; + match error.artifact_fault() { + Some(fault) => CommandError::Deployment(message, fault.clone()), + None => CommandError::Cli(CliError(message)), + } +} + +fn runtime_initialization_error(error: RuntimeInitializationError) -> CommandError { + match error { + RuntimeInitializationError::Bundle => { + CliError("runtime bundle initialization failed").into() + } + RuntimeInitializationError::Secrets => { + CliError("runtime secret initialization failed").into() + } + RuntimeInitializationError::Audit(fault) => { + CommandError::Audit("runtime audit initialization failed", fault) + } + RuntimeInitializationError::Signing => { + CliError("runtime signing initialization failed").into() + } + RuntimeInitializationError::Source => { + CliError("runtime source initialization failed").into() + } + RuntimeInitializationError::RateLimit => { + CliError("runtime rate-limit initialization failed").into() + } + } +} + +fn compile_source_plans( + config: &EvidenceConfig, + runtime: &RuntimeDocument, +) -> Result, CliError> { + compile_source_plans_with_runtime( + config, + &runtime.config.secret_providers.file.root, + &runtime.config.outbound_tls, + &runtime.ca_bundles, + ) +} + +fn compile_source_plans_with_runtime( + config: &EvidenceConfig, + secret_root: &str, + outbound_tls: &OutboundTlsConfig, + ca_bundles: &BTreeMap>, +) -> Result, CliError> { + let secrets = Arc::new( + SecretResolver::new([SecretProvider::File], secret_root) + .map_err(|_| CliError("source plan compilation failed"))?, + ); + let mut plans = BTreeMap::new(); + for (source_id, source) in config.sources.iter() { + let allowed_selector_sets = config.source_selector_sets(source_id); + let plan = SourceExecutor::new_with_selector_sets_and_tls( + source, + &allowed_selector_sets, + outbound_tls, + ca_bundles, + Arc::clone(&secrets), + ) + .map_err(|_| CliError("source plan compilation failed"))?; + plans.insert(source_id.to_owned(), plan); + } + Ok(plans) +} + +/// Install the operational log subscriber for the serving process. +/// +/// Records are line-delimited JSON on stdout so a collector can read them +/// without a parsing convention of its own. `EVIDENCE_LOG` selects verbosity +/// and defaults to `info`, which is the level the request boundary emits at. +/// Offline commands print their own result and install nothing, so no command +/// gains log output it did not have. +fn install_operational_logging() { + let filter = tracing_subscriber::EnvFilter::try_from_env("EVIDENCE_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_current_span(false) + .with_span_list(false) + .init(); +} + +/// Resolve on the first operator stop signal. +/// +/// A service manager and a container runtime both stop a process with +/// SIGTERM, and an interactive operator uses Ctrl-C. Both resolve here, so the +/// same drain runs either way: the server stops accepting, finishes its +/// in-flight evaluations, and closes the audit chain before the process exits. +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + match signal(SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + } + Err(error) => { + eprintln!( + "evidence: SIGTERM handler unavailable ({error}); stopping on Ctrl-C only" + ); + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +/// Longest accepted verification input. +/// +/// A stored response, a pinned key set, and a relying-procedure policy are all +/// small documents, so a larger file is refused before it is read rather than +/// pulled into memory because a path was mistyped. +const MAX_VERIFY_INPUT_BYTES: u64 = 1024 * 1024; +const MAX_LOCAL_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_LOCAL_CONTEXT_BYTES: u64 = 256 * 1024; +const MAX_LOCAL_RESPONSE_BYTES: u64 = 256 * 1024; +const MAX_LOCAL_BEARER_BYTES: usize = 64 * 1024; + +/// Exit status for a response that is authentic but no longer current. +const NOT_CURRENT_EXIT_CODE: u8 = 3; + +/// The one class reported for an input document that cannot be read or parsed. +const VERIFY_MALFORMED: CliError = CliError("stored response verification failed (malformed)"); +const LOCAL_CONTEXT_FAILED: CliError = CliError("local verification context preparation failed"); +const LOCAL_RESPONSE_FAILED: CliError = CliError("local response verification failed"); +const LOCAL_AUDIT_FAILED: CliError = CliError("local audit inspection failed"); + +/// Prepare one closed context from trusted deployment state and a retained +/// request. The bearer is read only from stdin and is never echoed. +async fn prepare_local_context_command( + runtime_path: &Path, + request_path: &Path, + response_format: &str, +) -> Result { + let deployment = DeploymentInputs::load(runtime_path).map_err(|_| LOCAL_CONTEXT_FAILED)?; + let request_bytes = + read_owner_only_input(request_path, MAX_LOCAL_REQUEST_BYTES, LOCAL_CONTEXT_FAILED)?; + let request_value = parse_json_strict(&request_bytes).map_err(|_| LOCAL_CONTEXT_FAILED)?; + let request: EvidenceRequest = + serde_json::from_value(request_value).map_err(|_| LOCAL_CONTEXT_FAILED)?; + let bearer = read_local_bearer(std::io::stdin()).map_err(|_| LOCAL_CONTEXT_FAILED)?; + let response_format = match response_format { + "signed-jws" => LocalResponseFormat::SignedJws, + "sd-jwt-vc" => LocalResponseFormat::SdJwtVc, + _ => return Err(LOCAL_CONTEXT_FAILED.into()), + }; + let context = prepare_local_verification_context_for_format( + &deployment, + &request, + &bearer, + response_format, + ) + .await + .map_err(|_| LOCAL_CONTEXT_FAILED)?; + write_canonical_json_line(&context, LOCAL_CONTEXT_FAILED)?; + Ok(ExitCode::SUCCESS) +} + +/// Verify from closed local state only. In particular, `--runtime` is not +/// loaded on this path and no network, source, secret, or audit boundary is +/// reachable after the context has been created. +fn verify_local_response_command( + context_path: &Path, + response_path: &Path, +) -> Result { + let context_bytes = + read_owner_only_input(context_path, MAX_LOCAL_CONTEXT_BYTES, LOCAL_RESPONSE_FAILED)?; + let context_value = parse_json_strict(&context_bytes).map_err(|_| LOCAL_RESPONSE_FAILED)?; + let context: LocalVerificationContext = + serde_json::from_value(context_value).map_err(|_| LOCAL_RESPONSE_FAILED)?; + let response = read_untrusted_response_input( + response_path, + MAX_LOCAL_RESPONSE_BYTES, + LOCAL_RESPONSE_FAILED, + )?; + let evidence = verify_local_response(context, &response).map_err(|_| LOCAL_RESPONSE_FAILED)?; + write_canonical_json_line(&evidence, LOCAL_RESPONSE_FAILED)?; + Ok(ExitCode::SUCCESS) +} + +/// Open one operator input without following a symlink, then enforce the same +/// owner, mode, link-count, and bounded-read posture as secret files. +fn read_owner_only_input( + path: &Path, + maximum_bytes: u64, + failure: CliError, +) -> Result, CliError> { + read_bounded_regular_input(path, maximum_bytes, true, failure) +} + +/// A signed response is untrusted input, not a trust anchor. Normal +/// `curl --output` permissions are accepted, while file identity and size +/// checks still prevent path tricks and blocking or unbounded reads. +fn read_untrusted_response_input( + path: &Path, + maximum_bytes: u64, + failure: CliError, +) -> Result, CliError> { + read_bounded_regular_input(path, maximum_bytes, false, failure) +} + +fn read_bounded_regular_input( + path: &Path, + maximum_bytes: u64, + require_owner_only: bool, + failure: CliError, +) -> Result, CliError> { + use rustix::fs::{Mode, OFlags}; + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| failure)?; + let file = File::from(descriptor); + let metadata = file.metadata().map_err(|_| failure)?; + if !metadata.is_file() + || (require_owner_only + && (metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o600)) + || metadata.nlink() != 1 + || metadata.len() > maximum_bytes + { + return Err(failure); + } + let mut bytes = Vec::new(); + file.take(maximum_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure)?; + if bytes.is_empty() || bytes.len() as u64 > maximum_bytes { + return Err(failure); + } + Ok(bytes) +} + +/// Read exactly one compact bearer from stdin with at most one shell line +/// ending. Other whitespace, control bytes, empty values, and oversize input +/// are rejected before authentication. +fn read_local_bearer(reader: impl std::io::Read) -> Result, CliError> { + let mut bytes = Zeroizing::new(Vec::new()); + reader + .take((MAX_LOCAL_BEARER_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| LOCAL_CONTEXT_FAILED)?; + if bytes.len() > MAX_LOCAL_BEARER_BYTES { + return Err(LOCAL_CONTEXT_FAILED); + } + let body = bytes + .strip_suffix(b"\r\n") + .or_else(|| bytes.strip_suffix(b"\n")) + .unwrap_or(bytes.as_slice()); + let bearer = std::str::from_utf8(body).map_err(|_| LOCAL_CONTEXT_FAILED)?; + if bearer.is_empty() + || bearer + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + { + return Err(LOCAL_CONTEXT_FAILED); + } + Ok(Zeroizing::new(bearer.to_owned())) +} + +fn write_canonical_json_line( + value: &T, + failure: CliError, +) -> Result<(), CliError> { + let value = serde_json::to_value(value).map_err(|_| failure)?; + let bytes = canonicalize_json(&value).map_err(|_| failure)?; + let mut stdout = std::io::stdout().lock(); + stdout.write_all(&bytes).map_err(|_| failure)?; + stdout.write_all(b"\n").map_err(|_| failure) +} + +/// Inspect the last local audit operation only after the writer has stopped. +/// +/// Every failure is deliberately collapsed to one value-free class. The view +/// is written only after the entire retained chain and its native events have +/// verified, so stdout can never contain a partial or unverified operation. +fn local_audit_last_operation_command(runtime_path: &Path) -> Result { + let deployment = DeploymentInputs::load(runtime_path).map_err(|_| LOCAL_AUDIT_FAILED)?; + if deployment.bundle.config.assurance_profile != AssuranceProfile::Local { + return Err(LOCAL_AUDIT_FAILED.into()); + } + let secrets = SecretResolver::new( + [SecretProvider::File], + &deployment.runtime.config.secret_providers.file.root, + ) + .map_err(|_| LOCAL_AUDIT_FAILED)?; + let audit_secret = secrets + .resolve(deployment.bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| LOCAL_AUDIT_FAILED)?; + let master_secret = AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + .map_err(|_| LOCAL_AUDIT_FAILED)?; + let view = verified_last_local_audit_operation( + Path::new(&deployment.runtime.config.audit_storage.path), + &master_secret, + ) + .map_err(|_| LOCAL_AUDIT_FAILED)?; + write_canonical_json_line(&view, LOCAL_AUDIT_FAILED)?; + Ok(ExitCode::SUCCESS) +} + +/// The one stored response an operator named, and the format its bytes are +/// parsed under. The format is an operator statement, never a guess from the +/// file's contents. +enum StoredResponse { + SignedJws(PathBuf), + SdJwtVc(PathBuf), +} + +impl StoredResponse { + fn path(&self) -> &Path { + match self { + Self::SignedJws(path) | Self::SdJwtVc(path) => path, + } + } +} + +/// Re-verify one stored signed response offline. +/// +/// The pinned key set file is the complete trust set: this command opens no +/// socket, resolves no metadata, and fetches no key. Every expectation comes +/// from the operator's policy document, which belongs to independently +/// retained trusted state such as the original request and an accepted +/// original transaction. The printed lines are the verification instant, the +/// authenticity answer, and, for an authentic response, current usability. +/// A failure reports only its closed class, so re-verification never becomes +/// an oracle for which hidden comparison failed. +fn verify_stored_response( + stored: &StoredResponse, + jwks_path: &Path, + policy_path: &Path, + at: Option<&str>, +) -> Result { + let instant = verification_instant(at)?; + println!( + "verified-at: {}", + instant.to_rfc3339_opts(SecondsFormat::Secs, true) + ); + + let response = read_verification_input(stored.path())?; + let trusted: JwksDocument = serde_json::from_value( + parse_json_strict(&read_verification_input(jwks_path)?).map_err(|_| VERIFY_MALFORMED)?, + ) + .map_err(|_| VERIFY_MALFORMED)?; + let document: EvidenceVerificationPolicyDocument = + serde_norway::from_slice(&read_verification_input(policy_path)?) + .map_err(|_| VERIFY_MALFORMED)?; + let policy = document.into_policy(instant); + + // One policy document, one set of expectations, and one report shape serve + // both response formats; only the serialization the operator named is + // parsed. + let report = match stored { + StoredResponse::SignedJws(_) => verify_flattened_jws_report(&response, &trusted, &policy), + StoredResponse::SdJwtVc(_) => verify_sd_jwt_vc_report(&response, &trusted, &policy), + }; + + match report { + Ok(report) => { + println!("authentic: yes"); + if !report.currently_valid { + println!("currently-valid: no"); + return Ok(ExitCode::from(NOT_CURRENT_EXIT_CODE)); + } + println!("currently-valid: yes"); + // Inspection output for the operator who already holds the stored + // response. It appears only once the trusted key signed the exact + // payload, every expectation held, and the assertion is current. + let inspected = serde_json::to_string_pretty(&report.evidence) + .map_err(|_| verification_error_class(VerificationError::Payload))?; + println!("{inspected}"); + Ok(ExitCode::SUCCESS) + } + Err(error) => { + println!("authentic: no"); + Err(verification_error_class(error)) + } + } +} + +/// Resolve the verification instant from `--at`, or from system time. +/// +/// `--at` is strict RFC 3339 at zero offset, so an operator cannot silently +/// re-verify against a local wall clock and read the result as UTC. +fn verification_instant(at: Option<&str>) -> Result, CliError> { + const NOT_UTC: CliError = CliError("verification instant is not strict RFC 3339 UTC"); + + let Some(text) = at else { + return Ok(Utc::now()); + }; + let parsed = DateTime::parse_from_rfc3339(text).map_err(|_| NOT_UTC)?; + if parsed.offset().local_minus_utc() != 0 { + return Err(NOT_UTC); + } + Ok(parsed.with_timezone(&Utc)) +} + +/// Read one bounded verification input file. +fn read_verification_input(path: &Path) -> Result, CliError> { + let metadata = fs::metadata(path).map_err(|_| VERIFY_MALFORMED)?; + if !metadata.is_file() || metadata.len() > MAX_VERIFY_INPUT_BYTES { + return Err(VERIFY_MALFORMED); + } + fs::read(path).map_err(|_| VERIFY_MALFORMED) +} + +/// Report one verification failure as its closed class and nothing more. +fn verification_error_class(error: VerificationError) -> CliError { + match error { + VerificationError::MalformedJws => VERIFY_MALFORMED, + VerificationError::ProtectedHeader => { + CliError("stored response verification failed (protected-header)") + } + VerificationError::Key => CliError("stored response verification failed (key)"), + VerificationError::Signature => CliError("stored response verification failed (signature)"), + VerificationError::Payload => CliError("stored response verification failed (payload)"), + VerificationError::Policy => CliError("stored response verification failed (policy)"), + VerificationError::Time => CliError("stored response verification failed (time)"), + VerificationError::Disclosure => { + CliError("stored response verification failed (disclosure)") + } + } +} + +/// Run a full out-of-band audit verification pass for the deployment named by +/// one closed operator runtime file. +/// +/// The audit storage path and hash secret are read from the same runtime +/// document and secret provider the serving process uses; this command takes +/// no path or secret flags of its own, so it can never be pointed at an audit +/// chain the deployment does not own. +fn run_verify_audit(runtime_path: &Path) -> Result { + let deployment = DeploymentInputs::load(runtime_path).map_err(deployment_load_error)?; + let secrets = SecretResolver::new( + [SecretProvider::File], + &deployment.runtime.config.secret_providers.file.root, + ) + .map_err(|_| CliError("audit verification secret resolver failed"))?; + let audit_secret = secrets + .resolve(deployment.bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| CliError("audit verification secret resolution failed"))?; + let master_secret = AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + .map_err(|_| CliError("audit verification secret is invalid"))?; + verify_audit_with_secret( + Path::new(&deployment.runtime.config.audit_storage.path), + &master_secret, + ) +} + +/// Verify one audit chain and print the operator report. +/// +/// Split from [`run_verify_audit`] so the report and the failure +/// classification can be exercised directly against a constructed chain, +/// without a full deployment bundle and runtime document on disk. +fn verify_audit_with_secret( + audit_path: &Path, + master_secret: &AuditHashSecret, +) -> Result { + match verify_audit_chain(audit_path, master_secret) { + Ok(summary) => { + println!("{}", audit_chain_report(&summary)); + Ok(ExitCode::SUCCESS) + } + Err(error) => { + let (detail, class) = audit_verification_failure(error); + println!("{detail}"); + Err(CommandError::Cli(class)) + } + } +} + +/// Render an out-of-band audit verification result for an operator. +/// +/// The head hash and the segment and record counts carry no request content, +/// so they are safe to print; nothing secret-derived beyond the chain head +/// appears here. When the active segment could not be verified, the report +/// says so plainly rather than reading as a pass of the whole chain. +fn audit_chain_report(summary: &AuditChainSummary) -> String { + let sealed_sequence = match (summary.first_sequence, summary.last_sequence) { + (Some(first), Some(last)) => format!("{first}-{last}"), + _ => "none".to_owned(), + }; + let active_segment = if summary.active_verified { + "verified".to_owned() + } else { + "not verified: a running writer holds it, so only sealed history was proven".to_owned() + }; + format!( + "segments: {}\nrecords: {}\nsealed-sequence: {sealed_sequence}\nhead: {}\nactive-segment: {active_segment}", + summary.segments, + summary.records, + OptionalHashHex(summary.head), + ) +} + +/// Classify an audit verification failure for the operator report and exit. +/// +/// A gap in the sealed sequence is archived-or-missing history, not a hash +/// break, so it is reported in those terms and kept distinguishable from +/// every other verification failure, which is corruption. +fn audit_verification_failure(error: EvidenceAuditError) -> (String, CliError) { + match error { + EvidenceAuditError::SegmentMissing { sequence } => ( + format!( + "sealed segment {sequence} is archived or missing from the chain; \ + this is not corruption" + ), + CliError("audit chain is missing sealed history"), + ), + _ => ( + "audit chain verification failed".to_owned(), + CliError("audit chain verification failed"), + ), + } +} + +async fn evaluate_fixture( + bundle: &Arc, + kernel: &OfflineKernel, + source_plans: &BTreeMap, + fixture_path: &Path, +) -> Result { + let signer = offline_fixture_signer().await?; + let fixture_name = safe_fixture_name(fixture_path)?; + let referenced = bundle + .config + .requirements + .iter() + .filter(|requirement| { + requirement + .fixtures + .as_ref() + .is_some_and(|fixtures| fixtures.as_str() == fixture_name) + }) + .collect::>(); + if referenced.len() != 1 { + return Err(CliError( + "fixture must be a captured artifact referenced by exactly one requirement", + )); + } + let requirement = referenced[0]; + let fixture = bundle + .fixtures + .get(fixture_name) + .ok_or(CliError("fixture artifact is not captured by the bundle"))?; + let fixture = serde_json::to_value(fixture) + .map_err(|_| CliError("fixture contract is not representable"))?; + let object = fixture + .as_object() + .ok_or(CliError("fixture contract must be an object"))?; + if object.get("synthetic_only") != Some(&Value::Bool(true)) { + return Err(CliError("fixture is not an approved synthetic definition")); + } + if object.get("coequal_acceptance_definition") != Some(&Value::Bool(true)) { + if object + .get("fixture") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("registry.evidence.reference.") && id.ends_with("/v1")) + { + return evaluate_reference_fixture( + bundle, + kernel, + source_plans, + &signer, + requirement, + object, + ) + .await; + } + return Err(CliError( + "fixture is not an approved synthetic acceptance definition", + )); + } + let common = object.get("common").and_then(Value::as_object); + let cases = object + .get("cases") + .and_then(Value::as_array) + .ok_or(CliError("fixture cases are unavailable"))?; + if cases.is_empty() || cases.len() > 256 { + return Err(CliError("fixture case count is invalid")); + } + + let mut summary = FixtureSummary::default(); + let mut successful_values = Vec::new(); + for case in cases { + let case = case + .as_object() + .ok_or(CliError("fixture case is not an object"))?; + let id = case + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 128) + .ok_or(CliError("fixture case identifier is invalid"))?; + + if case.get("subjects").is_some() { + require_expected(case, "pre-source-selector-rejection")?; + match resolve_offline_fixture_subjects( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) { + Ok(_) => return Err(CliError("fixture selector rejection did not occur")), + Err(OfflineFixtureError::Purpose) => { + return Err(CliError(FIXTURE_PURPOSE_FAILURE)); + } + Err(OfflineFixtureError::Authorization(_)) => {} + } + summary.evaluated_cases += 1; + continue; + } + if let Some(expected_roles) = case.get("expected_subject_roles") { + let expected_roles = expected_roles + .as_array() + .ok_or(CliError("fixture subject-role expectation is invalid"))? + .iter() + .map(|role| { + role.as_str() + .map(ToOwned::to_owned) + .ok_or(CliError("fixture subject-role expectation is invalid")) + }) + .collect::, _>>()?; + let actual_roles = resolve_offline_fixture_subjects( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "fixture subjects did not resolve"))?; + if actual_roles != expected_roles { + return Err(CliError("fixture subject roles did not match")); + } + } + let observed_at = + fixture_observed_at(case, common, requirement.observation_timezone.as_deref())?; + + if let Some(source) = case.get("source") { + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "fixture subjects did not resolve"))?; + let derivation_selectors = + fixture_selector_value(&resolved, &requirement.derivation.selector_inputs)?; + if let Some(expected) = case + .get("derivationSelectorInputs") + .or_else(|| common.and_then(|common| common.get("derivationSelectorInputs"))) + { + if expected != &derivation_selectors { + return Err(CliError( + "fixture derivation selector projection did not match", + )); + } + } + let source_config = bundle + .config + .sources + .get(&requirement.source) + .ok_or(CliError("fixture source is unavailable"))?; + let outcome = match project_fixture_response(source_config, source) { + Ok(projected) => kernel.evaluate_with_selectors( + &requirement.id, + &projected, + &derivation_selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ), + Err(_) => Err(KernelError::SourceProtocol), + }; + if let Some(values) = validate_case_outcome(id, case, outcome)? { + successful_values.push( + sign_and_verify_fixture_evidence( + bundle, + kernel, + &signer, + requirement, + &resolved, + values, + observed_at, + ) + .await?, + ); + } + summary.evaluated_cases += 1; + continue; + } + + if let Some(injected) = case.get("injected_derivation") { + validate_injected_rejection(kernel, &requirement.id, injected)?; + require_expected(case, "output-gate-rejection")?; + summary.evaluated_cases += 1; + continue; + } + + if let Some(source_failure) = case.get("source_failure") { + validate_source_failure(case, source_failure)?; + summary.evaluated_cases += 1; + continue; + } + + if let Some(companion) = case.get("companion_bundle") { + validate_companion_rejection(bundle, requirement, case, companion)?; + summary.evaluated_cases += 1; + continue; + } + + return Err(CliError( + "fixture case has no closed Version 1 evaluation form", + )); + } + validate_privacy_expectation(object, requirement, &successful_values)?; + Ok(summary) +} + +async fn evaluate_reference_fixture( + bundle: &Arc, + kernel: &OfflineKernel, + source_plans: &BTreeMap, + signer: &EvidenceSigner, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &JsonMap, +) -> Result { + require_exact_keys( + fixture, + &[ + "fixture", + "synthetic_only", + "common", + "cases", + "privacyExpectation", + ], + )?; + let common = fixture + .get("common") + .and_then(Value::as_object) + .ok_or(CliError("reference fixture common block is invalid"))?; + require_allowed_keys( + common, + &[ + "observed_at", + "purpose", + "selectors", + "verified_token_claims", + "derivationSelectorInputs", + "expectedRequestParts", + "expectedTransport", + ], + )?; + for required in [ + "observed_at", + "selectors", + "expectedRequestParts", + "expectedTransport", + ] { + if !common.contains_key(required) { + return Err(CliError("reference fixture common block is incomplete")); + } + } + let cases = fixture + .get("cases") + .and_then(Value::as_array) + .filter(|cases| !cases.is_empty() && cases.len() <= 256) + .ok_or(CliError("reference fixture case count is invalid"))?; + let mut identifiers = std::collections::BTreeSet::new(); + let mut successful_values = Vec::new(); + let mut summary = FixtureSummary::default(); + + for case in cases { + let case = case + .as_object() + .ok_or(CliError("reference fixture case is not an object"))?; + require_allowed_keys( + case, + &[ + "id", + "purpose", + "response", + "sourceFailure", + "bundleMutation", + "requestMutation", + "derivationMutation", + "derivationParameterMutation", + "selectorOverrides", + "observed_at", + "expected", + ], + )?; + let id = case + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty() && id.len() <= 128 && identifiers.insert(*id)) + .ok_or(CliError("reference fixture case identifier is invalid"))?; + let expected = case + .get("expected") + .and_then(Value::as_object) + .ok_or(CliError("reference fixture expectation is invalid"))?; + require_allowed_keys( + expected, + &[ + "lookup", + "facts", + "value", + "values", + "entityReferenceCount", + "rawReferencesDisclosed", + "signed", + "publicProblem", + "error", + "derivationRuns", + "bundle", + "outputGate", + "rejectedBefore", + "sourceRequestCount", + "expectedTransport", + ], + )?; + // A case states either the one concept value or the complete concept map, + // never both, so neither expectation can weaken the other. + if expected.contains_key("value") && expected.contains_key("values") { + return Err(CliError("reference fixture states two value expectations")); + } + let forms = [ + "response", + "sourceFailure", + "bundleMutation", + "requestMutation", + "derivationMutation", + "derivationParameterMutation", + "selectorOverrides", + ]; + let selected_forms = forms + .iter() + .filter(|name| case.contains_key(**name)) + .copied() + .collect::>(); + if selected_forms.len() != 1 { + return Err(CliError("reference fixture case form is not closed")); + } + validate_reference_expectation_keys(selected_forms[0], expected)?; + + if let Some(mutation) = case.get("bundleMutation").and_then(Value::as_str) { + if mutation != "duplicate-disclosure-family" + || expected.get("bundle").and_then(Value::as_str) != Some("rejected") + { + return Err(CliError("reference bundle mutation is invalid")); + } + validate_reference_bundle_mutation(bundle, requirement)?; + summary.evaluated_cases += 1; + continue; + } + if let Some(mutation) = case.get("requestMutation").and_then(Value::as_str) { + validate_reference_request_mutation( + bundle, + requirement, + common, + case, + expected, + mutation, + )?; + summary.evaluated_cases += 1; + continue; + } + + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + Some(common), + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "reference fixture subjects did not resolve"))?; + let source = bundle + .config + .sources + .get(&requirement.source) + .ok_or(CliError("reference fixture source is unavailable"))?; + let source_plan = source_plans + .get(&requirement.source) + .ok_or(CliError("reference fixture source plan is unavailable"))?; + let preparation_selectors = + fixture_selector_value(&resolved, &source.request.selector_inputs)?; + let prepared = match kernel.prepare(&requirement.id, &preparation_selectors) { + Ok(prepared) => prepared, + Err(error) if case.contains_key("selectorOverrides") => { + validate_reference_error(expected, error, false)?; + if expected.get("rejectedBefore").and_then(Value::as_str) != Some("credential") { + return Err(CliError( + "reference preparation rejection boundary did not match", + )); + } + require_reference_request_count(expected, 0)?; + summary.evaluated_cases += 1; + continue; + } + Err(_) => return Err(CliError("reference fixture request preparation failed")), + }; + if !case.contains_key("selectorOverrides") { + validate_reference_request_parts(common, &prepared)?; + } + let source_selectors = + reference_source_selectors(&resolved, &source.request.selector_inputs)?; + validate_reference_transport( + source, + source_plan, + &source_selectors, + &prepared, + common + .get("expectedTransport") + .and_then(Value::as_object) + .ok_or(CliError("reference transport expectation is invalid"))?, + )?; + if let Some(transport) = expected.get("expectedTransport").and_then(Value::as_object) { + validate_reference_transport( + source, + source_plan, + &source_selectors, + &prepared, + transport, + )?; + } + if case.contains_key("selectorOverrides") { + if expected.contains_key("error") + || expected.contains_key("publicProblem") + || expected.contains_key("rejectedBefore") + { + return Err(CliError( + "reference successful preparation contradicts its expectation", + )); + } + require_reference_request_count(expected, 1)?; + summary.evaluated_cases += 1; + continue; + } + let derivation_selectors = + fixture_selector_value(&resolved, &requirement.derivation.selector_inputs)?; + if let Some(expected_selectors) = common.get("derivationSelectorInputs") { + if expected_selectors != &derivation_selectors { + return Err(CliError("reference derivation selectors did not match")); + } + } else if derivation_selectors != Value::Object(JsonMap::new()) { + return Err(CliError( + "reference derivation selectors were not minimized", + )); + } + if let Some(failure) = case.get("sourceFailure").and_then(Value::as_str) { + validate_reference_source_failure(failure, expected)?; + summary.evaluated_cases += 1; + continue; + } + + let observed_at = fixture_observed_at( + case, + Some(common), + requirement.observation_timezone.as_deref(), + )?; + if let Some(mutation) = case.get("derivationMutation").and_then(Value::as_str) { + validate_reference_derivation_mutation(kernel, requirement, expected, mutation)?; + summary.evaluated_cases += 1; + continue; + } + if let Some(mutation) = case + .get("derivationParameterMutation") + .and_then(Value::as_object) + { + validate_reference_parameter_mutation( + bundle, + requirement, + cases, + mutation, + &derivation_selectors, + observed_at, + expected, + )?; + summary.evaluated_cases += 1; + continue; + } + + let response = case + .get("response") + .ok_or(CliError("reference fixture response is unavailable"))?; + let projected = project_fixture_response(source, response) + .map_err(|_| CliError("reference fixture source projection failed"))?; + if let Some(values) = validate_reference_response( + ReferenceResponseContext { + bundle, + kernel, + signer, + requirement, + resolved: &resolved, + }, + &projected, + &derivation_selectors, + observed_at, + expected, + ) + .await? + { + successful_values.push(values); + } + require_reference_request_count(expected, 1)?; + summary.evaluated_cases += 1; + let _ = id; + } + + validate_reference_privacy(fixture, requirement, &successful_values)?; + Ok(summary) +} + +struct ReferenceResponseContext<'a> { + bundle: &'a Bundle, + kernel: &'a OfflineKernel, + signer: &'a EvidenceSigner, + requirement: &'a registry_evidence::config::RequirementConfig, + resolved: &'a ResolvedAuthorization, +} + +async fn validate_reference_response( + context: ReferenceResponseContext<'_>, + response: &Value, + selectors: &Value, + observed_at: DateTime, + expected: &JsonMap, +) -> Result, CliError> { + let lookup = match context.kernel.extract(&context.requirement.id, response) { + Ok(lookup) => lookup, + Err(error) => { + validate_reference_error(expected, error, false)?; + return Ok(None); + } + }; + match lookup { + LookupResult::NoMatch => { + validate_reference_unresolved(expected, "no_match")?; + Ok(None) + } + LookupResult::Ambiguous => { + validate_reference_unresolved(expected, "ambiguous")?; + Ok(None) + } + LookupResult::Match(facts) => { + if expected.get("lookup").and_then(Value::as_str) != Some("match") { + return Err(CliError("reference lookup outcome did not match")); + } + if let Some(exact) = expected.get("facts") { + let actual = serde_json::to_value(&facts) + .map_err(|_| CliError("reference facts are not representable"))?; + if exact != &actual { + return Err(CliError("reference exact facts did not match")); + } + } + let values = match context.kernel.derive_and_validate_with_selectors( + &context.requirement.id, + &facts, + selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) { + Ok(values) => values, + Err(error) => { + validate_reference_error(expected, error, true)?; + return Ok(None); + } + }; + if expected.get("derivationRuns").and_then(Value::as_bool) != Some(true) { + return Err(CliError("reference derivation execution did not match")); + } + if let Some(exact) = expected.get("value") { + if values.as_slice().len() != 1 + || public_json(&values.as_slice()[0].value)? != *exact + { + return Err(CliError("reference scalar value did not match")); + } + } + if let Some(exact) = expected.get("values") { + let exact = exact + .as_object() + .ok_or(CliError("reference concept map is invalid"))?; + if values.as_slice().len() != exact.len() { + return Err(CliError("reference concept value did not match")); + } + for (concept, expected_value) in exact { + let disclosed = values + .as_slice() + .iter() + .find(|value| value.provides_value_for == *concept) + .ok_or(CliError("reference concept value did not match"))?; + if public_json(&disclosed.value)? != *expected_value { + return Err(CliError("reference concept value did not match")); + } + } + } + if let Some(count) = expected.get("entityReferenceCount").and_then(Value::as_u64) { + let actual = match values.as_slice() { + [value] => match &value.value { + PublicValue::List(items) => items + .iter() + .filter(|item| { + matches!(item, ScalarOrEntityReference::EntityReference(_)) + }) + .count() as u64, + _ => 0, + }, + _ => 0, + }; + if actual != count { + return Err(CliError("reference entity-reference count did not match")); + } + } + if expected + .get("rawReferencesDisclosed") + .and_then(Value::as_bool) + == Some(false) + { + let encoded = serde_json::to_string(values.as_slice()) + .map_err(|_| CliError("reference values are not representable"))?; + let mut protected_source_strings = Vec::new(); + collect_strings(response, &mut protected_source_strings); + if protected_source_strings + .iter() + .filter(|value| value.len() >= 8) + .any(|value| encoded.contains(value)) + { + return Err(CliError("reference raw source reference was disclosed")); + } + } + if expected.get("signed").and_then(Value::as_bool) != Some(true) { + return Err(CliError("reference signing expectation did not match")); + } + sign_and_verify_fixture_evidence( + context.bundle, + context.kernel, + context.signer, + context.requirement, + context.resolved, + values, + observed_at, + ) + .await + .map(Some) + } + } +} + +async fn sign_and_verify_fixture_evidence( + bundle: &Bundle, + kernel: &OfflineKernel, + signer: &EvidenceSigner, + requirement: ®istry_evidence::config::RequirementConfig, + resolved: &ResolvedAuthorization, + values: ValidatedValues, + observed_at: DateTime, +) -> Result { + let subjects = resolved + .subjects + .iter() + .map(|subject| { + Ok(SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + &OFFLINE_BINDING_KEY, + 1, + &bundle.config.service.trust_domain, + OFFLINE_AUDIENCE, + &resolved.purpose, + ) + .map_err(|_| CliError("fixture subject binding failed"))?, + }) + }) + .collect::, CliError>>()?; + let issued_at = observed_at + chrono::Duration::seconds(1); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: &resolved.purpose, + audience: OFFLINE_AUDIENCE, + issued_at, + observed_at, + subjects, + }, + ) + .map_err(|_| CliError("fixture evidence construction failed"))?; + let signed = signer + .sign_json(&evidence) + .await + .map_err(|_| CliError("fixture evidence signing failed"))?; + let jwks = jwks_document(signer.public_jwk(), []) + .map_err(|_| CliError("fixture verification key construction failed"))?; + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + std::time::Duration::from_secs(31_536_000), + issued_at, + std::time::Duration::ZERO, + ); + policy.issued_by = bundle.config.issuer.id.clone(); + policy.provided_by = bundle.config.service.provider_id.clone(); + policy.requirement = requirement.id.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = resolved.purpose.clone(); + policy.audience = OFFLINE_AUDIENCE.to_owned(); + policy.configuration_revision = bundle.revision().to_owned(); + let verified = verify_flattened_jws( + &serde_json::to_vec(&signed) + .map_err(|_| CliError("fixture signed evidence is not representable"))?, + &jwks, + &policy, + ) + .map_err(|_| CliError("fixture signed evidence verification failed"))?; + serde_json::to_value(verified) + .map_err(|_| CliError("fixture verified evidence is not representable")) +} + +async fn offline_fixture_signer() -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + const KEY_ID: &str = "offline-fixture-signing-key"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private_jwk = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new( + LocalJwkSigner::new(private_jwk) + .map_err(|_| CliError("offline fixture signer initialization failed"))?, + ); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .map_err(|_| CliError("offline fixture signer self-test failed")) +} + +fn validate_reference_unresolved( + expected: &JsonMap, + lookup: &str, +) -> Result<(), CliError> { + if expected.get("lookup").and_then(Value::as_str) != Some(lookup) + || expected.get("derivationRuns").and_then(Value::as_bool) != Some(false) + || expected.get("signed").and_then(Value::as_bool) != Some(false) + || expected.get("publicProblem").and_then(Value::as_str) != Some("evidence_not_available") + { + return Err(CliError("reference unresolved outcome did not match")); + } + Ok(()) +} + +fn validate_reference_expectation_keys( + form: &str, + expected: &JsonMap, +) -> Result<(), CliError> { + let allowed: &[&str] = match form { + "response" => &[ + "lookup", + "facts", + "value", + "values", + "entityReferenceCount", + "rawReferencesDisclosed", + "signed", + "publicProblem", + "error", + "derivationRuns", + "sourceRequestCount", + ], + "sourceFailure" => &["publicProblem", "signed", "sourceRequestCount"], + "bundleMutation" => &["bundle"], + "requestMutation" => &["rejectedBefore", "signed", "sourceRequestCount"], + "derivationMutation" => &["outputGate", "signed"], + "derivationParameterMutation" => &["error", "publicProblem", "signed", "derivationRuns"], + "selectorOverrides" => &[ + "expectedTransport", + "error", + "publicProblem", + "signed", + "rejectedBefore", + "sourceRequestCount", + ], + _ => return Err(CliError("reference fixture case form is unknown")), + }; + require_allowed_keys(expected, allowed) +} + +fn validate_reference_error( + expected: &JsonMap, + error: KernelError, + derivation_ran: bool, +) -> Result<(), CliError> { + let (internal, public_problem) = match error { + KernelError::Preparation => ("adapter_input_error", "service_unavailable"), + KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + // Derivation-input inconsistency over a uniquely found record + // collapses publicly with the unresolved classes; the internal + // category stays a value-free operator diagnostic. + KernelError::DerivationInput => ("derivation_input_error", "evidence_not_available"), + KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), + KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), + _ => ("service_unavailable", "service_unavailable"), + }; + let expected_error = expected.get("error").and_then(Value::as_str); + let expected_problem = expected.get("publicProblem").and_then(Value::as_str); + if expected_error.is_none() && expected_problem.is_none() { + return Err(CliError("reference failing case has no exact expectation")); + } + if expected_error.is_some_and(|expected| expected != internal) { + return Err(CliError("reference internal error did not match")); + } + if expected_problem.is_some_and(|expected| expected != public_problem) { + return Err(CliError("reference public problem did not match")); + } + if expected.get("signed").and_then(Value::as_bool) != Some(false) { + return Err(CliError( + "reference failed case signing expectation did not match", + )); + } + if expected.get("derivationRuns").and_then(Value::as_bool) != Some(derivation_ran) { + return Err(CliError("reference derivation execution did not match")); + } + Ok(()) +} + +fn validate_reference_request_parts( + common: &JsonMap, + actual: &RequestParts, +) -> Result<(), CliError> { + let expected = common + .get("expectedRequestParts") + .and_then(Value::as_object) + .ok_or(CliError("reference request-parts expectation is invalid"))?; + require_exact_keys(expected, &["query", "body"])?; + let query = expected + .get("query") + .and_then(Value::as_array) + .ok_or(CliError("reference query expectation is invalid"))?; + if query.len() != actual.query.len() { + return Err(CliError("reference prepared query did not match")); + } + for (expected, actual) in query.iter().zip(&actual.query) { + let expected = expected + .as_object() + .ok_or(CliError("reference query-pair expectation is invalid"))?; + require_exact_keys(expected, &["name", "value"])?; + if expected.get("name").and_then(Value::as_str) != Some(&actual.name) + || expected.get("value").and_then(Value::as_str) != Some(&actual.value) + { + return Err(CliError("reference prepared query did not match")); + } + } + let expected_body = expected.get("body").filter(|body| !body.is_null()); + if expected_body != actual.body.as_ref() { + return Err(CliError("reference prepared body did not match")); + } + Ok(()) +} + +fn validate_reference_transport( + source: ®istry_evidence::config::SourceConfig, + source_plan: &SourceExecutor, + selectors: &[ResolvedSourceSelector], + parts: &RequestParts, + expected: &JsonMap, +) -> Result<(), CliError> { + require_allowed_keys(expected, &["path", "query", "body", "fixedHeaders"])?; + let materialized = source_plan + .materialize_request(selectors, parts) + .map_err(|_| CliError("reference transport materialization failed"))?; + if expected + .get("path") + .and_then(Value::as_str) + .is_some_and(|path| materialized.path() != path) + { + return Err(CliError("reference materialized path did not match")); + } + if let Some(query) = expected.get("query").and_then(Value::as_str) { + if materialized.query().unwrap_or_default() != query { + return Err(CliError("reference encoded query did not match")); + } + } + if let Some(body) = expected.get("body").filter(|body| !body.is_null()) { + if materialized.body() != Some(body) { + return Err(CliError("reference transport body did not match")); + } + } + if let Some(headers) = expected.get("fixedHeaders").and_then(Value::as_array) { + if headers.len() != source.request.fixed_headers.len() { + return Err(CliError("reference fixed headers did not match")); + } + for (expected, actual) in headers.iter().zip(&source.request.fixed_headers) { + let expected = expected + .as_object() + .ok_or(CliError("reference fixed-header expectation is invalid"))?; + require_exact_keys(expected, &["name", "value"])?; + if expected.get("name").and_then(Value::as_str) != Some(&actual.name) + || expected.get("value").and_then(Value::as_str) != Some(&actual.value) + { + return Err(CliError("reference fixed headers did not match")); + } + } + } + Ok(()) +} + +fn reference_source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result, CliError> { + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or(CliError("reference source selector role is unavailable"))?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or(CliError("reference source selector profile is invalid"))?; + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or(CliError("reference source selector field is unavailable"))?; + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + Ok((name.clone(), value)) + }) + .collect::, CliError>>()?; + Ok(ResolvedSourceSelector { + role: input.role.clone(), + profile: alternative.profile.clone(), + values, + }) + }) + .collect() +} + +fn validate_reference_source_failure( + failure: &str, + expected: &JsonMap, +) -> Result<(), CliError> { + let error = match failure { + "timeout" => SourceError::Timeout, + "connection-refused" => SourceError::Transport, + "invalid-media-type" => SourceError::WrongMediaType, + "oversized" => SourceError::ResponseTooLarge, + "malformed-json" => SourceError::InvalidJson, + _ => return Err(CliError("reference source-failure name is invalid")), + }; + if source_failure_problem(&error) != ProblemCode::DependencyUnavailable + || expected.get("publicProblem").and_then(Value::as_str) != Some("dependency_unavailable") + || expected.get("signed").and_then(Value::as_bool) != Some(false) + { + return Err(CliError("reference source-failure mapping is invalid")); + } + require_reference_request_count(expected, 1) +} + +fn validate_reference_bundle_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, +) -> Result<(), CliError> { + let mut mutated = bundle.config.clone(); + let mut companion = requirement.clone(); + companion.id.push_str(":fixture-companion"); + companion.evidence_type.push_str(":fixture-companion"); + for concept in &mut companion.concepts { + concept.id.push_str(":fixture-companion"); + } + mutated.requirements.push(companion); + if mutated.validate() + != Err(ConfigError::Invalid( + "enabled requirements share a disclosure family", + )) + { + return Err(CliError( + "reference unsafe bundle mutation was not rejected", + )); + } + Ok(()) +} + +fn validate_reference_request_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + common: &JsonMap, + case: &JsonMap, + expected: &JsonMap, + mutation: &str, +) -> Result<(), CliError> { + if expected.get("rejectedBefore").and_then(Value::as_str) != Some("source") { + return Err(CliError("reference request rejection boundary is invalid")); + } + let selectors = common + .get("selectors") + .and_then(Value::as_object) + .ok_or(CliError("reference selectors are invalid"))?; + let mut subjects = selectors + .iter() + .map(|(role, selector)| { + let mut selector = selector + .as_object() + .cloned() + .ok_or(CliError("reference selector is invalid"))?; + selector.insert("role".to_owned(), Value::String(role.clone())); + Ok(Value::Object(selector)) + }) + .collect::, CliError>>()?; + match mutation { + "swap-subject-roles" if subjects.len() == 2 => { + let first = subjects[0]["role"].clone(); + subjects[0]["role"] = subjects[1]["role"].clone(); + subjects[1]["role"] = first; + } + "supply-grant-derived-candidate" => {} + _ => return Err(CliError("reference request-mutation name is invalid")), + } + let mut mutated = case.clone(); + mutated.insert("subjects".to_owned(), Value::Array(subjects)); + match resolve_offline_fixture_authorization( + bundle, + requirement, + Some(common), + &mutated, + OFFLINE_AUDIENCE, + ) { + Ok(_) => return Err(CliError("reference request mutation was authorized")), + Err(OfflineFixtureError::Purpose) => return Err(CliError(FIXTURE_PURPOSE_FAILURE)), + Err(OfflineFixtureError::Authorization(_)) => {} + } + if expected.get("signed").and_then(Value::as_bool) != Some(false) { + return Err(CliError("reference rejected request requires signing")); + } + require_reference_request_count(expected, 0) +} + +fn validate_reference_derivation_mutation( + kernel: &OfflineKernel, + requirement: ®istry_evidence::config::RequirementConfig, + expected: &JsonMap, + mutation: &str, +) -> Result<(), CliError> { + if mutation != "return-raw-reference" + || expected.get("outputGate").and_then(Value::as_str) != Some("rejected") + || expected.get("signed").and_then(Value::as_bool) != Some(false) + { + return Err(CliError("reference derivation mutation is invalid")); + } + let injected = vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(Value::String("PROTECTED-REFERENCE".to_owned())), + }]; + if kernel + .validate_values( + &requirement.id, + injected, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) + .is_ok() + { + return Err(CliError( + "reference derivation mutation crossed the output gate", + )); + } + Ok(()) +} + +fn validate_reference_parameter_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + cases: &[Value], + mutation: &JsonMap, + selectors: &Value, + observed_at: DateTime, + expected: &JsonMap, +) -> Result<(), CliError> { + let mut disposable = bundle.clone(); + let mut config = serde_json::to_value(&disposable.config) + .map_err(|_| CliError("reference configuration is not representable"))?; + let target = config["requirements"] + .as_array_mut() + .and_then(|requirements| { + requirements + .iter_mut() + .find(|candidate| candidate["id"].as_str() == Some(&requirement.id)) + }) + .ok_or(CliError("reference disposable requirement is unavailable"))?; + let parameters = target["derivation"]["parameters"] + .as_object_mut() + .ok_or(CliError("reference derivation parameters are invalid"))?; + for (name, value) in mutation { + if !parameters.contains_key(name) { + return Err(CliError("reference parameter mutation is unknown")); + } + parameters.insert(name.clone(), value.clone()); + } + disposable.config = serde_json::from_value(config) + .map_err(|_| CliError("reference parameter mutation is invalid"))?; + disposable + .config + .validate() + .map_err(|_| CliError("reference parameter mutation broke configuration"))?; + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .map_err(|_| CliError("reference disposable kernel did not compile"))?; + let positive = cases + .iter() + .find(|case| case.get("id").and_then(Value::as_str) == Some("positive")) + .and_then(|case| case.get("response")) + .ok_or(CliError("reference positive response is unavailable"))?; + let source = disposable + .config + .sources + .get(&requirement.source) + .ok_or(CliError("reference disposable source is unavailable"))?; + let projected = project_fixture_response(source, positive) + .map_err(|_| CliError("reference positive response projection failed"))?; + let outcome = kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ); + match outcome { + Err(error) => validate_reference_error(expected, error, true), + Ok(_) => Err(CliError("reference parameter mutation did not fail")), + } +} + +fn require_reference_request_count( + expected: &JsonMap, + actual: u64, +) -> Result<(), CliError> { + if expected + .get("sourceRequestCount") + .and_then(Value::as_u64) + .is_some_and(|count| count != actual) + || actual > 1 + { + return Err(CliError("reference source request count did not match")); + } + Ok(()) +} + +fn validate_reference_privacy( + fixture: &JsonMap, + requirement: ®istry_evidence::config::RequirementConfig, + successful_values: &[Value], +) -> Result<(), CliError> { + let source = fixture + .get("privacyExpectation") + .and_then(Value::as_object) + .ok_or(CliError("reference privacy expectation is invalid"))?; + require_exact_keys( + source, + &["evidenceContains", "evidenceExcludes", "diagnosticsExclude"], + )?; + let mut expectation = JsonMap::new(); + for (source_name, target_name) in [ + ("evidenceContains", "evidence_contains"), + ("evidenceExcludes", "evidence_excludes"), + ("diagnosticsExclude", "diagnostics_exclude"), + ] { + expectation.insert( + target_name.to_owned(), + source + .get(source_name) + .cloned() + .ok_or(CliError("reference privacy expectation is incomplete"))?, + ); + } + let projection = serde_json::json!({ + "supportsRequirement": requirement.id, + "isConformantTo": requirement.evidence_type, + "subjectRoles": requirement + .subject_roles + .iter() + .map(|role| role.role.as_str()) + .collect::>(), + "successfulValues": successful_values, + }); + validate_privacy_projection(&expectation, &projection) +} + +const FIXTURE_PURPOSE_FAILURE: &str = "fixture does not select one of the requirement's purposes"; + +/// Report an unselected fixture purpose as its own failure so a harness +/// omission is never presented as a rejected request. +fn fixture_failure(error: OfflineFixtureError, authorization: &'static str) -> CliError { + match error { + OfflineFixtureError::Purpose => CliError(FIXTURE_PURPOSE_FAILURE), + OfflineFixtureError::Authorization(_) => CliError(authorization), + } +} + +fn require_exact_keys(object: &JsonMap, expected: &[&str]) -> Result<(), CliError> { + require_allowed_keys(object, expected)?; + if expected.iter().any(|key| !object.contains_key(*key)) { + return Err(CliError("reference fixture required key is missing")); + } + Ok(()) +} + +fn require_allowed_keys(object: &JsonMap, allowed: &[&str]) -> Result<(), CliError> { + if object + .keys() + .any(|key| !allowed.iter().any(|allowed| key == allowed)) + { + return Err(CliError("reference fixture contains an unknown key")); + } + Ok(()) +} + +fn fixture_selector_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let mut selectors = JsonMap::new(); + for input in inputs { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or(CliError("fixture selector input role is unavailable"))?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or(CliError("fixture selector input profile is unavailable"))?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or(CliError("fixture selector input field is unavailable"))?; + values.insert(name.clone(), field.value.as_json()); + } + let mut selector = JsonMap::new(); + selector.insert( + "profile".to_owned(), + Value::String(alternative.profile.clone()), + ); + selector.insert("values".to_owned(), Value::Object(values)); + if selectors + .insert(input.role.clone(), Value::Object(selector)) + .is_some() + { + return Err(CliError("fixture selector input role is duplicated")); + } + } + Ok(Value::Object(selectors)) +} + +fn validate_case_outcome( + _case_id: &str, + case: &serde_json::Map, + outcome: Result, +) -> Result, CliError> { + let derivation_runs = optional_boolean(case, "derivation_runs")?; + let signed_success = optional_boolean(case, "signed_success")?; + let expected_problem = optional_string(case, "expected_public_problem")?; + + if let Some(expected_lookup @ ("no_match" | "ambiguous")) = + optional_string(case, "expected_lookup")? + { + let matches = matches!( + (expected_lookup, &outcome), + ("no_match", Ok(KernelOutcome::NoMatch)) | ("ambiguous", Ok(KernelOutcome::Ambiguous)) + ); + if !matches { + return Err(CliError( + "fixture lookup outcome did not match its contract", + )); + } + if signed_success != Some(false) || derivation_runs != Some(false) { + return Err(CliError( + "unresolved fixture must deny derivation and signed success", + )); + } + if expected_problem != Some("evidence_not_available") { + return Err(CliError("unresolved fixture public problem is not exact")); + } + return Ok(None); + } + if optional_string(case, "expected_lookup")?.is_some_and(|lookup| lookup != "match") { + return Err(CliError("fixture lookup expectation is invalid")); + } + + if let Some(problem) = expected_problem { + let exact = matches!( + (problem, &outcome), + ( + "evidence_not_available", + Err(registry_evidence::kernel::KernelError::Extraction + | registry_evidence::kernel::KernelError::DerivationInput) + ) | ( + "dependency_unavailable", + Err(registry_evidence::kernel::KernelError::SourceProtocol) + ) | ( + "service_unavailable", + Err(registry_evidence::kernel::KernelError::Script + | registry_evidence::kernel::KernelError::Output + | registry_evidence::kernel::KernelError::Bundle + | registry_evidence::kernel::KernelError::Requirement + | registry_evidence::kernel::KernelError::Evidence) + ) + ); + if !exact { + return Err(CliError( + "fixture kernel failure did not match its public problem", + )); + } + let derivation_ran = !matches!( + outcome, + Err(registry_evidence::kernel::KernelError::Extraction + | registry_evidence::kernel::KernelError::SourceProtocol) + ); + if signed_success != Some(false) || derivation_runs != Some(derivation_ran) { + return Err(CliError( + "failing fixture execution expectations did not match", + )); + } + return Ok(None); + } + + let KernelOutcome::Match(values) = + outcome.map_err(|_| CliError("fixture evaluation failed unexpectedly"))? + else { + return Err(CliError("fixture expected a unique match")); + }; + if optional_string(case, "expected_lookup")? != Some("match") { + return Err(CliError("matched fixture must require an exact match")); + } + if derivation_runs == Some(false) { + return Err(CliError("matched fixture cannot deny derivation execution")); + } + if signed_success == Some(false) { + return Err(CliError( + "matched fixture cannot deny signed-success eligibility", + )); + } + + let expected_value = case.get("expected_value"); + let expected_values = case.get("expected_values"); + if expected_value.is_some() == expected_values.is_some() { + return Err(CliError( + "matched fixture must declare exactly one value expectation", + )); + } + if let Some(expected) = expected_value { + if values.as_slice().len() != 1 || public_json(&values.as_slice()[0].value)? != *expected { + return Err(CliError("fixture value did not match its contract")); + } + } + if let Some(expected) = expected_values.and_then(Value::as_object) { + if values.as_slice().len() != expected.len() { + return Err(CliError("fixture value set did not match its contract")); + } + for (name, expected_value) in expected { + let actual = values + .as_slice() + .iter() + .filter(|candidate| { + candidate.provides_value_for == *name + || candidate + .provides_value_for + .strip_suffix(name) + .is_some_and(|prefix| prefix.ends_with(':')) + }) + .collect::>(); + if actual.len() != 1 || public_json(&actual[0].value)? != *expected_value { + return Err(CliError("fixture value set did not match its contract")); + } + } + } else if expected_values.is_some() { + return Err(CliError("fixture value-set expectation is invalid")); + } + if derivation_runs != Some(true) || signed_success != Some(true) { + return Err(CliError( + "matched fixture must require derivation and signed success", + )); + } + Ok(Some(values)) +} + +fn validate_source_failure( + case: &serde_json::Map, + source_failure: &Value, +) -> Result<(), CliError> { + let failure = match source_failure.as_str() { + Some("timeout") => SourceError::Timeout, + Some("redirect") => SourceError::Redirect, + Some("http-503") => SourceError::Status(SourceStatus::ServerError), + Some("wrong-media-type") => SourceError::WrongMediaType, + _ => return Err(CliError("fixture source-failure category is invalid")), + }; + if optional_string(case, "expected_public_problem")? != Some("dependency_unavailable") + || optional_boolean(case, "signed_success")? != Some(false) + || source_failure_problem(&failure) != ProblemCode::DependencyUnavailable + { + return Err(CliError("fixture source-failure mapping is invalid")); + } + Ok(()) +} + +fn validate_companion_rejection( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + case: &serde_json::Map, + companion: &Value, +) -> Result<(), CliError> { + let label = companion + .as_str() + .filter(|value| { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) + .ok_or(CliError("fixture companion-bundle label is invalid"))?; + require_expected(case, "bundle-rejection")?; + let matrix: Value = serde_norway::from_slice(ANTI_RECONSTRUCTION_FIXTURE) + .map_err(|_| CliError("anti-reconstruction fixture is invalid"))?; + let rejected = matrix + .get("rejected_bundles") + .and_then(Value::as_array) + .ok_or(CliError("anti-reconstruction fixture is invalid"))?; + let declaration = rejected + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(label)) + .and_then(Value::as_object) + .ok_or(CliError("fixture companion-bundle label is unknown"))?; + let shared_family = declaration + .get("shared_disclosure_family") + .and_then(Value::as_str) + .ok_or(CliError("anti-reconstruction family is invalid"))?; + let definitions = declaration + .get("definitions") + .and_then(Value::as_array) + .filter(|definitions| definitions.len() >= 2) + .ok_or(CliError( + "anti-reconstruction combination must contain multiple definitions", + ))?; + let distinct_definitions = definitions + .iter() + .map(serde_json::to_string) + .collect::, _>>() + .map_err(|_| CliError("anti-reconstruction definition is invalid"))?; + if distinct_definitions.len() != definitions.len() + || !definitions.iter().all(Value::is_object) + || declaration + .get("threat") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || declaration + .get("expected") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + return Err(CliError( + "anti-reconstruction combination declaration is incomplete", + )); + } + + let mut unsafe_config = bundle.config.clone(); + let original = unsafe_config + .requirements + .iter_mut() + .find(|candidate| candidate.id == requirement.id) + .ok_or(CliError("fixture requirement is missing"))?; + original.disclosure_guard.families = vec![shared_family.to_owned()]; + for index in 1..definitions.len() { + let suffix = format!(":fixture-companion-{index}"); + let mut companion = requirement.clone(); + companion.id.push_str(&suffix); + companion.evidence_type.push_str(&suffix); + companion.disclosure_guard.families = vec![shared_family.to_owned()]; + companion.derivation.script = registry_evidence::config::ArtifactPath::parse(&format!( + "derivations/fixture-companion-{index}.rhai" + )) + .map_err(|_| CliError("fixture companion path is invalid"))?; + companion.fixtures = Some( + registry_evidence::config::ArtifactPath::parse(&format!( + "fixtures/fixture-companion-{index}.yaml" + )) + .map_err(|_| CliError("fixture companion path is invalid"))?, + ); + for concept in &mut companion.concepts { + concept.id.push_str(&suffix); + } + unsafe_config.requirements.push(companion); + } + if unsafe_config.validate() + != Err(ConfigError::Invalid( + "enabled requirements share a disclosure family", + )) + { + return Err(CliError("unsafe companion bundle was not rejected")); + } + Ok(()) +} + +fn optional_boolean( + case: &serde_json::Map, + name: &str, +) -> Result, CliError> { + case.get(name) + .map(|value| { + value + .as_bool() + .ok_or(CliError("fixture boolean expectation is invalid")) + }) + .transpose() +} + +fn optional_string<'a>( + case: &'a serde_json::Map, + name: &str, +) -> Result, CliError> { + case.get(name) + .map(|value| { + value + .as_str() + .ok_or(CliError("fixture string expectation is invalid")) + }) + .transpose() +} + +fn validate_privacy_expectation( + fixture: &serde_json::Map, + requirement: ®istry_evidence::config::RequirementConfig, + successful_values: &[Value], +) -> Result<(), CliError> { + let expectation = fixture + .get("privacy_expectation") + .and_then(Value::as_object) + .ok_or(CliError("fixture privacy expectation is unavailable"))?; + let projection = serde_json::json!({ + "supportsRequirement": requirement.id, + "isConformantTo": requirement.evidence_type, + "subjectRoles": requirement + .subject_roles + .iter() + .map(|role| role.role.as_str()) + .collect::>(), + "successfulValues": successful_values, + }); + validate_privacy_projection(expectation, &projection) +} + +fn validate_privacy_projection( + expectation: &serde_json::Map, + projection: &Value, +) -> Result<(), CliError> { + let mut disclosed_strings = Vec::new(); + collect_strings(projection, &mut disclosed_strings); + + for expected in expectation_strings(expectation, "evidence_contains")? { + if !disclosed_strings.contains(&expected) { + return Err(CliError("fixture required disclosure is absent")); + } + } + for prohibited in expectation_strings(expectation, "evidence_excludes")? { + if disclosed_strings.contains(&prohibited) { + return Err(CliError("fixture prohibited disclosure is present")); + } + } + // CLI diagnostics are structurally static (`CliError(&'static str)`) and + // the success line contains counts only. Still exercise every declared + // diagnostic canary against the exact dynamic-free output templates so a + // future template change cannot silently weaken this fixture assertion. + for prohibited in expectation_strings(expectation, "diagnostics_exclude")? { + if [ + "Evidence fixture passed (0 evaluated cases)", + "evidence: fixture evaluation failed", + ] + .iter() + .any(|surface| surface.contains(prohibited)) + { + return Err(CliError("fixture prohibited diagnostic is present")); + } + } + Ok(()) +} + +fn expectation_strings<'a>( + expectation: &'a serde_json::Map, + name: &str, +) -> Result, CliError> { + expectation + .get(name) + .and_then(Value::as_array) + .ok_or(CliError("fixture privacy expectation is invalid"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or(CliError("fixture privacy expectation is invalid")) + }) + .collect() +} + +fn collect_strings<'a>(value: &'a Value, output: &mut Vec<&'a str>) { + match value { + Value::String(value) => output.push(value), + Value::Array(values) => { + for value in values { + collect_strings(value, output); + } + } + Value::Object(values) => { + for (key, value) in values { + output.push(key); + collect_strings(value, output); + } + } + _ => {} + } +} + +fn validate_injected_rejection( + kernel: &OfflineKernel, + requirement_id: &str, + injected: &Value, +) -> Result<(), CliError> { + let injected = injected + .as_array() + .ok_or(CliError("injected derivation fixture must be an array"))?; + let mut derived = Vec::with_capacity(injected.len()); + for value in injected { + let object = value + .as_object() + .ok_or(CliError("injected derivation member is invalid"))?; + if object.len() != 2 { + return Err(CliError("injected derivation member is not closed")); + } + let concept_id = object + .get("concept_id") + .and_then(Value::as_str) + .ok_or(CliError("injected derivation concept is invalid"))?; + let value = object + .get("value") + .cloned() + .ok_or(CliError("injected derivation value is missing"))?; + derived.push(DerivedConceptValue { + concept_id: concept_id.to_owned(), + value: DerivedValue::Json(value), + }); + } + if kernel + .validate_values( + requirement_id, + derived, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) + .is_ok() + { + return Err(CliError("injected derivation was not rejected")); + } + Ok(()) +} + +fn public_json(value: &PublicValue) -> Result { + serde_json::to_value(value).map_err(|_| CliError("fixture value is not representable")) +} + +fn fixture_observed_at( + case: &serde_json::Map, + common: Option<&serde_json::Map>, + timezone: Option<&str>, +) -> Result, CliError> { + if let Some(observed) = case.get("observed_at").and_then(Value::as_str) { + return DateTime::parse_from_rfc3339(observed) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixture observation time is invalid")); + } + + if let Some(local_date) = case.get("legal_local_date").and_then(Value::as_str) { + return local_date_at_noon(local_date, timezone); + } + + if let Some(observed) = common + .and_then(|value| value.get("observed_at")) + .and_then(Value::as_str) + { + return DateTime::parse_from_rfc3339(observed) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixture observation time is invalid")); + } + + if let Some(local_date) = common + .and_then(|value| value.get("legal_local_date")) + .and_then(Value::as_str) + { + return local_date_at_noon(local_date, timezone); + } + + DateTime::parse_from_rfc3339("1970-01-01T12:00:00Z") + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixed fixture time is invalid")) +} + +fn local_date_at_noon(local_date: &str, timezone: Option<&str>) -> Result, CliError> { + let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d") + .map_err(|_| CliError("fixture legal local date is invalid"))?; + let local_noon = date + .and_hms_opt(12, 0, 0) + .ok_or(CliError("fixture legal local date is invalid"))?; + let timezone = timezone + .map(Tz::from_str) + .transpose() + .map_err(|_| CliError("fixture observation timezone is invalid"))? + .unwrap_or(Tz::UTC); + timezone + .from_local_datetime(&local_noon) + .single() + .map(|value| value.with_timezone(&Utc)) + .ok_or(CliError("fixture legal local date cannot be resolved")) +} + +fn require_expected(case: &serde_json::Map, expected: &str) -> Result<(), CliError> { + if case.get("expected").and_then(Value::as_str) == Some(expected) { + Ok(()) + } else { + Err(CliError("fixture boundary expectation is invalid")) + } +} + +fn safe_fixture_name(path: &Path) -> Result<&str, CliError> { + if path.is_absolute() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(CliError( + "fixture path must be bundle-relative and normalized", + )); + } + let name = path + .to_str() + .filter(|value| value.starts_with("fixtures/") && value.ends_with(".yaml")) + .ok_or(CliError("fixture path is invalid"))?; + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory as _; + use registry_evidence::audit::{ + audit_segment_paths, AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, ResponseProtection, + }; + use registry_evidence::config::AssuranceProfile; + use registry_evidence::verifier::ExpectedValueForm; + use std::fs; + + #[test] + fn local_shell_seams_are_hidden_from_adopter_help() { + let command = Cli::command(); + for name in [ + "prepare-local-verification-context", + "verify-local-response", + "local-audit-last-operation", + ] { + assert!( + command + .get_subcommands() + .find(|candidate| candidate.get_name() == name) + .is_some_and(clap::Command::is_hide_set), + "{name} remains an internal shell seam" + ); + } + } + + #[test] + fn local_bearer_is_bounded_and_accepts_only_one_shell_line() { + assert_eq!( + read_local_bearer("header.payload.signature\n".as_bytes()) + .expect("one shell line is accepted") + .as_str(), + "header.payload.signature" + ); + for invalid in [ + "", + "\n", + "header.payload.signature\n\n", + "header.payload. signature", + ] { + assert!(read_local_bearer(invalid.as_bytes()).is_err()); + } + assert!(read_local_bearer(vec![b'a'; MAX_LOCAL_BEARER_BYTES + 1].as_slice()).is_err()); + } + + #[test] + fn local_documents_require_one_owner_only_regular_file() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let directory = tempfile::tempdir().expect("temporary directory"); + let safe = directory.path().join("request.json"); + fs::write(&safe, b"{}").expect("input is written"); + fs::set_permissions(&safe, fs::Permissions::from_mode(0o600)) + .expect("input becomes owner-only"); + assert_eq!( + read_owner_only_input(&safe, 2, LOCAL_CONTEXT_FAILED).expect("owner-only input reads"), + b"{}" + ); + + fs::set_permissions(&safe, fs::Permissions::from_mode(0o640)) + .expect("input becomes group-readable"); + assert!(read_owner_only_input(&safe, 2, LOCAL_CONTEXT_FAILED).is_err()); + fs::set_permissions(&safe, fs::Permissions::from_mode(0o600)) + .expect("input becomes owner-only again"); + + let link = directory.path().join("second-link.json"); + fs::hard_link(&safe, &link).expect("hard link is created"); + assert!(read_owner_only_input(&safe, 2, LOCAL_CONTEXT_FAILED).is_err()); + fs::remove_file(&link).expect("hard link is removed"); + + let symbolic = directory.path().join("symbolic.json"); + symlink(&safe, &symbolic).expect("symbolic link is created"); + assert!(read_owner_only_input(&symbolic, 2, LOCAL_CONTEXT_FAILED).is_err()); + assert!(read_owner_only_input(&safe, 1, LOCAL_CONTEXT_FAILED).is_err()); + } + + #[test] + fn local_response_accepts_curl_permissions_but_rejects_file_identity_tricks() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let directory = tempfile::tempdir().expect("temporary directory"); + let response = directory.path().join("assertion.jws.json"); + fs::write(&response, b"{}").expect("response is written"); + fs::set_permissions(&response, fs::Permissions::from_mode(0o644)) + .expect("response uses ordinary curl output permissions"); + assert_eq!( + read_untrusted_response_input(&response, 2, LOCAL_RESPONSE_FAILED) + .expect("ordinary curl output reads"), + b"{}" + ); + + let link = directory.path().join("response-link.json"); + fs::hard_link(&response, &link).expect("hard link is created"); + assert!(read_untrusted_response_input(&response, 2, LOCAL_RESPONSE_FAILED).is_err()); + fs::remove_file(&link).expect("hard link is removed"); + + let symbolic = directory.path().join("response-symbolic.json"); + symlink(&response, &symbolic).expect("symbolic link is created"); + assert!(read_untrusted_response_input(&symbolic, 2, LOCAL_RESPONSE_FAILED).is_err()); + assert!(read_untrusted_response_input(&response, 1, LOCAL_RESPONSE_FAILED).is_err()); + } + + /// Every expected form the published policy schema accepts must parse the + /// way that schema writes it. The list form is a mapping under `list`, not + /// a YAML tag, and it is the only form a list-valued concept can state. + #[test] + fn policy_documents_parse_every_expected_form_as_the_contract_writes_it() { + for (written, expected) in [ + ("boolean", ExpectedValueForm::Boolean), + ("integer", ExpectedValueForm::Integer), + ("string", ExpectedValueForm::String), + ("date-bucket", ExpectedValueForm::DateBucket), + ("time-bucket", ExpectedValueForm::TimeBucket), + ("entity-reference", ExpectedValueForm::EntityReference), + ("structured", ExpectedValueForm::Structured), + ( + "{list: {minimumItems: 1, maximumItems: 2}}", + ExpectedValueForm::List { + minimum_items: 1, + maximum_items: 2, + }, + ), + ] { + let document = verification_policy_document(&format!("form: {written}")); + let policy: EvidenceVerificationPolicyDocument = serde_norway::from_str(&document) + .unwrap_or_else(|error| panic!("`{written}` is a policy form: {error}")); + assert_eq!( + policy.into_policy(Utc::now()).expected_outputs[0].form, + expected, + "`{written}` parsed as a different form" + ); + } + } + + #[test] + fn policy_documents_reject_forms_outside_the_closed_vocabulary() { + for written in [ + "list", + "{list: {minimumItems: 1}}", + "{list: {minimumItems: 1, maximumItems: 2, extra: 3}}", + "{set: {minimumItems: 1, maximumItems: 2}}", + "date_bucket", + ] { + let document = verification_policy_document(&format!("form: {written}")); + assert!( + serde_norway::from_str::(&document).is_err(), + "`{written}` is not a policy form but parsed as one" + ); + } + } + + /// One complete policy document whose single expected output states `form`. + fn verification_policy_document(form: &str) -> String { + format!( + "expectedAssuranceProfile: evidence-grade\n\ + issuedBy: urn:example:issuer\n\ + providedBy: urn:example:provider\n\ + requirement: urn:example:requirement:v1\n\ + evidenceType: urn:example:evidence-type:v1\n\ + purpose: example-purpose\n\ + audience: https://relying-party.example\n\ + configurationRevision: sha256:0\n\ + requestNonce: example-nonce\n\ + expectedSubjects:\n\ + \x20 - {{role: subject, binding: urn:evidence:subject:v1_{binding}}}\n\ + expectedOutputs:\n\ + \x20 - concept: urn:example:concept\n\ + \x20 {form}\n\ + maximumAssertionLifetimeSeconds: 86400\n\ + clockSkewSeconds: 30\n", + binding = "A".repeat(43), + ) + } + + #[test] + fn fixture_paths_never_escape_the_captured_bundle() { + assert_eq!( + safe_fixture_name(Path::new("fixtures/cases.yaml")), + Ok("fixtures/cases.yaml") + ); + assert!(safe_fixture_name(Path::new("../fixtures/cases.yaml")).is_err()); + assert!(safe_fixture_name(Path::new("/tmp/cases.yaml")).is_err()); + } + + #[test] + fn offline_dates_are_fixed_and_never_use_ambient_time() { + let case = serde_json::json!({"legal_local_date": "2026-08-03"}); + let observed = fixture_observed_at(case.as_object().expect("object"), None, None) + .expect("date converts"); + assert_eq!(observed.to_rfc3339(), "2026-08-03T12:00:00+00:00"); + } + + #[test] + fn case_local_date_overrides_common_observation_time() { + let case = serde_json::json!({"legal_local_date": "2026-08-01"}); + let common = serde_json::json!({"observed_at": "2026-08-02T00:00:00Z"}); + let observed = fixture_observed_at( + case.as_object().expect("case object"), + common.as_object(), + Some("Asia/Bangkok"), + ) + .expect("case-local date converts"); + assert_eq!(observed.to_rfc3339(), "2026-08-01T05:00:00+00:00"); + } + + #[test] + fn symbolic_source_failures_use_the_production_public_mapper() { + for category in ["timeout", "redirect", "http-503", "wrong-media-type"] { + let case = serde_json::json!({ + "source_failure": category, + "expected_public_problem": "dependency_unavailable", + "signed_success": false + }); + let object = case.as_object().expect("object"); + assert_eq!( + validate_source_failure(object, &object["source_failure"]), + Ok(()) + ); + } + } + + #[test] + fn public_unavailability_requires_an_extraction_failure() { + let case = serde_json::json!({ + "expected_public_problem": "evidence_not_available", + "derivation_runs": false, + "signed_success": false + }); + let case = case.as_object().expect("object"); + assert!(validate_case_outcome("case", case, Ok(KernelOutcome::NoMatch)).is_err()); + assert!(validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Script), + ) + .is_err()); + assert_eq!( + validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Extraction), + ), + Ok(None) + ); + } + + #[test] + fn service_unavailability_requires_an_internal_kernel_failure() { + let case = serde_json::json!({ + "expected_public_problem": "service_unavailable", + "derivation_runs": true, + "signed_success": false + }); + let case = case.as_object().expect("object"); + assert_eq!( + validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Script), + ), + Ok(None) + ); + assert!(validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Extraction), + ) + .is_err()); + } + + #[test] + fn unresolved_lookup_rejects_derivation_or_signed_success_claims() { + for declaration in [ + serde_json::json!({ + "expected_lookup": "no_match", + "expected_public_problem": "evidence_not_available", + "derivation_runs": true + }), + serde_json::json!({ + "expected_lookup": "no_match", + "expected_public_problem": "evidence_not_available", + "signed_success": true + }), + ] { + assert!(validate_case_outcome( + "case", + declaration.as_object().expect("object"), + Ok(KernelOutcome::NoMatch), + ) + .is_err()); + } + } + + #[test] + fn reference_fixture_forms_reject_irrelevant_expectations() { + let bundle = serde_json::json!({"bundle": "rejected"}); + assert_eq!( + validate_reference_expectation_keys( + "bundleMutation", + bundle.as_object().expect("object"), + ), + Ok(()) + ); + + let irrelevant = serde_json::json!({"bundle": "rejected", "signed": false}); + assert!(validate_reference_expectation_keys( + "bundleMutation", + irrelevant.as_object().expect("object"), + ) + .is_err()); + + let transport = serde_json::json!({ + "expectedTransport": {"path": "/records"}, + "sourceRequestCount": 1 + }); + assert_eq!( + validate_reference_expectation_keys( + "selectorOverrides", + transport.as_object().expect("object"), + ), + Ok(()) + ); + assert!(validate_reference_expectation_keys( + "response", + transport.as_object().expect("object"), + ) + .is_err()); + } + + #[test] + fn reference_failures_require_exact_unsigned_stage_expectations() { + let exact = serde_json::json!({ + "error": "source_protocol_error", + "publicProblem": "dependency_unavailable", + "derivationRuns": false, + "signed": false + }); + assert_eq!( + validate_reference_error( + exact.as_object().expect("object"), + KernelError::SourceProtocol, + false, + ), + Ok(()) + ); + + let wrong_public = serde_json::json!({ + "error": "source_protocol_error", + "publicProblem": "service_unavailable", + "derivationRuns": false, + "signed": false + }); + assert!(validate_reference_error( + wrong_public.as_object().expect("object"), + KernelError::SourceProtocol, + false, + ) + .is_err()); + } + + #[test] + fn privacy_expectations_check_exact_projected_strings() { + let expectation = serde_json::json!({ + "evidence_contains": ["urn:example:concept", "subject"], + "evidence_excludes": ["raw-source-value"], + "diagnostics_exclude": ["selector-value"] + }); + let projection = serde_json::json!({ + "subjectRoles": ["subject"], + "successfulValues": [{"providesValueFor": "urn:example:concept", "value": true}] + }); + assert_eq!( + validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &projection, + ), + Ok(()) + ); + + let leaking = serde_json::json!({"value": "raw-source-value"}); + assert!(validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &leaking, + ) + .is_err()); + + let leaking_key = serde_json::json!({"raw-source-value": false}); + assert!(validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &leaking_key, + ) + .is_err()); + } + + #[test] + fn check_compiles_source_plans_without_resolving_secrets() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let valid_config = EvidenceConfig::parse_yaml(valid.as_bytes()).expect("config validates"); + let outbound_tls = OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }; + assert_eq!( + compile_source_plans_with_runtime( + &valid_config, + "/run/secrets/evidence", + &outbound_tls, + &Default::default(), + ) + .map(|plans| plans.len()), + Ok(valid_config.sources.len()) + ); + + let invalid = valid.replacen("timeoutMilliseconds: 3000", "timeoutMilliseconds: 0", 1); + assert_ne!(invalid, valid, "fixture mutation must remain effective"); + let invalid_config: EvidenceConfig = + serde_norway::from_str(&invalid).expect("closed typed shape deserializes"); + assert_eq!( + compile_source_plans_with_runtime( + &invalid_config, + "/run/secrets/evidence", + &outbound_tls, + &Default::default(), + ) + .map(|_| ()), + Err(CliError("source plan compilation failed")) + ); + } + + fn test_audit_secret() -> AuditHashSecret { + AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) + .expect("audit secret builds") + } + + fn test_audit_event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + AssuranceProfile::EvidenceGrade, + "01K1EXAMPLE0000000000000000".to_owned(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_owned(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_owned(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "person-v1".to_owned(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 5, + ) + } + + /// Change one byte of a record without changing its length, so the + /// record no longer matches the hash the chain recorded for it. + fn corrupt_audit_line(line: &str) -> String { + let mut bytes = line.as_bytes().to_vec(); + for byte in bytes.iter_mut() { + if byte.is_ascii_lowercase() { + *byte = if *byte == b'z' { b'y' } else { *byte + 1 }; + break; + } + } + String::from_utf8(bytes).expect("a corrupted record stays UTF-8") + } + + #[tokio::test] + async fn verify_audit_reports_a_clean_multi_segment_chain() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs several sealed segments plus the active one" + ); + + let secret = test_audit_secret(); + let summary = + verify_audit_chain(&path, &secret).expect("a clean multi-segment chain verifies"); + assert_eq!(summary.records, 48); + assert_eq!(summary.segments, segments.len()); + assert!(summary.active_verified); + assert_eq!(summary.first_sequence, Some(1)); + + assert!(verify_audit_with_secret(&path, &secret).is_ok()); + } + + /// Startup verification only replays the active segment; this pins the + /// counterpart it exists for: corruption planted in an already sealed + /// segment passes startup and is only caught by the out-of-band verifier. + #[tokio::test] + async fn verify_audit_fails_on_sealed_segment_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let oldest_sealed = segments[0].clone(); + let contents = fs::read_to_string(&oldest_sealed).expect("sealed segment reads"); + let mut lines: Vec = contents.lines().map(str::to_owned).collect(); + assert!( + lines.len() > 1, + "the corrupted record must not be the sealed tail" + ); + lines[0] = corrupt_audit_line(&lines[0]); + let mut rewritten = lines.join("\n"); + rewritten.push('\n'); + fs::write(&oldest_sealed, rewritten).expect("segment rewrites"); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("startup does not replay sealed history"); + assert!(restarted.ready().await); + drop(restarted); + + assert!( + verify_audit_with_secret(&path, &test_audit_secret()).is_err(), + "the out-of-band verifier must catch sealed-segment corruption" + ); + } + + /// A gap in sealed history is an operator archiving a segment, not + /// tampering, so it must be reported by sequence rather than folded into + /// the generic corruption message. + #[tokio::test] + async fn verify_audit_reports_an_archived_segment_as_missing_not_corrupt() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs a sealed segment that is neither first nor last" + ); + fs::remove_file(&segments[1]).expect("a middle segment is archived away"); + + let error = verify_audit_chain(&path, &test_audit_secret()) + .expect_err("a gap in sealed history must fail verification"); + let sequence = match &error { + EvidenceAuditError::SegmentMissing { sequence } => *sequence, + other => panic!("expected a missing-segment error, got {other:?}"), + }; + assert_eq!(sequence, 2); + + let (detail, _) = audit_verification_failure(error); + assert!( + detail.contains(&format!("segment {sequence}")) + && detail.contains("archived or missing"), + "the report must name the sequence and describe archival: {detail}" + ); + assert!( + detail.contains("not corruption"), + "the report must state plainly that this is not corruption: {detail}" + ); + + assert!(verify_audit_with_secret(&path, &test_audit_secret()).is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_every_coequal_acceptance_fixture() { + for definition in [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ] { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(definition); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }, + &Default::default(), + ) + .expect("source plans compile"); + let fixture = Path::new( + bundle.config.requirements[0] + .fixtures + .as_ref() + .expect("acceptance fixture is declared") + .as_str(), + ); + let expected_cases = bundle.fixtures[fixture.to_str().expect("fixture path")] + .get("cases") + .and_then(serde_norway::Value::as_sequence) + .expect("cases") + .len(); + assert_eq!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + Ok(FixtureSummary { + evaluated_cases: expected_cases, + }), + "{definition}" + ); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_the_combined_acceptance_bundle() { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/all-definitions"); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }, + &Default::default(), + ) + .expect("source plans compile"); + for requirement in &bundle.config.requirements { + let fixture = Path::new( + requirement + .fixtures + .as_ref() + .expect("acceptance fixture is declared") + .as_str(), + ); + assert!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture) + .await + .is_ok(), + "combined acceptance fixture failed" + ); + } + + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_every_reference_deployment_fixture() { + for project in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects") + .join(project) + .join("bundle"); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("reference bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let outbound_tls: OutboundTlsConfig = if project == "dhis2-tracker-evidence" { + serde_norway::from_str( + "systemRoots: true\ntrustProfiles:\n government-internal-pki:\n caBundleFile: /etc/registry-evidence/ca/government-internal.pem\n", + ) + .expect("private TLS profile parses") + } else { + OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + } + }; + let ca_bundles = if project == "dhis2-tracker-evidence" { + let certificate = + rcgen::generate_simple_self_signed( + vec!["tracker.dhis2.gov.example".to_owned()], + ) + .expect("generate private TLS root"); + let certificate = test_certificate_pem(certificate.cert.der().as_ref()); + BTreeMap::from([("government-internal-pki".to_owned(), certificate)]) + } else { + BTreeMap::new() + }; + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &outbound_tls, + &ca_bundles, + ) + .expect("source plans compile"); + for requirement in &bundle.config.requirements { + let fixture_path = requirement + .fixtures + .as_ref() + .expect("reference fixture is declared"); + let fixture = Path::new(fixture_path.as_str()); + let expected_cases = bundle.fixtures[fixture_path.as_str()] + .get("cases") + .and_then(serde_norway::Value::as_sequence) + .expect("cases") + .len(); + assert_eq!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + Ok(FixtureSummary { + evaluated_cases: expected_cases, + }), + "{project}/{}", + requirement.id + ); + } + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create destination"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy artifact"); + } + } + } + + #[cfg(unix)] + fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + let metadata = fs::symlink_metadata(path).expect("tree metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read tree") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set file mode"); + } + } + + #[cfg(unix)] + fn test_certificate_pem(der: &[u8]) -> Vec { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + let encoded = STANDARD.encode(der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for line in encoded.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(line).expect("base64 is UTF-8")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem.into_bytes() + } +} diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs new file mode 100644 index 000000000..edb517324 --- /dev/null +++ b/crates/registry-evidence/src/model.rs @@ -0,0 +1,694 @@ +use std::{collections::BTreeMap, fmt}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use schemars::JsonSchema; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_json::{Number, Value}; +use utoipa::ToSchema; + +use crate::config::AssuranceProfile; + +/// Exact encoded length of the required caller-generated request nonce: the +/// canonical unpadded base64url form of 32 random bytes. +pub const REQUEST_NONCE_ENCODED_LENGTH: usize = 43; +const REQUEST_NONCE_DECODED_LENGTH: usize = 32; + +/// Deterministic canonical nonce for offline fixture evaluation and internal +/// non-released request shapes. Real callers generate a fresh random value +/// for every request. +pub const OFFLINE_EVALUATION_REQUEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +/// Accept only the canonical 43-character unpadded base64url encoding of +/// exactly 32 bytes. Padding, wrong length, non-alphabet bytes, and a +/// noncanonical final symbol all fail. +pub fn request_nonce_is_canonical(nonce: &str) -> bool { + if nonce.len() != REQUEST_NONCE_ENCODED_LENGTH + || !nonce + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return false; + } + match URL_SAFE_NO_PAD.decode(nonce) { + Ok(decoded) => decoded.len() == REQUEST_NONCE_DECODED_LENGTH, + Err(_) => false, + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceRequest { + /// Uninterpreted caller-generated correlation nonce. It is echoed into the + /// Evidence payload and never reaches authorization, rate limits, Rhai, + /// source requests, logs, metrics, traces, or native audit. + pub request_nonce: String, + pub requirement: String, + pub purpose: String, + /// Unordered role set encoded as an array. Roles are resolved by name and + /// canonicalized to requirement declaration order. + pub subjects: Vec, + /// Optional holder public key echoed into the SD-JWT VC `cnf` claim. It is + /// meaningful only for the SD-JWT VC response format, never reaches + /// authorization, selectors, Rhai, source requests, or audit, and never + /// appears in the signed-JWS payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub holder_key: Option, +} + +/// Caller-supplied Ed25519 holder public key. `deny_unknown_fields` is the +/// primary defence against private key members: a body carrying `d` or any +/// other unexpected member fails to parse. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HolderPublicKey { + pub kty: String, + pub crv: String, + pub x: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +/// Exact byte length of a raw Ed25519 public key. +const HOLDER_KEY_DECODED_LENGTH: usize = 32; +const MAX_HOLDER_KEY_ID_BYTES: usize = 256; + +impl HolderPublicKey { + /// Accept only a public OKP Ed25519 JWK whose coordinate is the canonical + /// unpadded base64url encoding of exactly 32 bytes. + pub fn is_acceptable(&self) -> bool { + if self.kty != "OKP" || self.crv != "Ed25519" { + return false; + } + if self.alg.as_deref().is_some_and(|alg| alg != "EdDSA") { + return false; + } + if self + .kid + .as_deref() + .is_some_and(|kid| kid.is_empty() || kid.len() > MAX_HOLDER_KEY_ID_BYTES) + { + return false; + } + URL_SAFE_NO_PAD + .decode(&self.x) + .is_ok_and(|decoded| decoded.len() == HOLDER_KEY_DECODED_LENGTH) + } +} + +/// Requester-scoped descriptions of the exact Evidence request shapes that +/// the authenticated caller can currently invoke. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitions { + pub schema: String, + pub assurance_profile: AssuranceProfile, + pub configuration_revision: String, + pub issued_by: String, + pub provided_by: String, + pub definitions: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinition { + pub requirement: String, + pub kind: String, + pub evidence_type: String, + pub purpose: String, + pub reference_frameworks: Vec, + pub subjects: Vec, + pub concepts: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EvidenceDefinitionSubject { + pub role: String, + pub cardinality: String, + pub selector: EvidenceDefinitionSelector, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitionSelector { + pub profile: String, + pub value_origin: String, + pub fields: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitionConcept { + pub id: String, + pub form: String, +} + +/// Public validation metadata for a selector field. Controlled-code +/// definitions expose their governed scheme identity, never the bundle path or +/// the configured list of supported values. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum EvidenceSelectorField { + String { + name: String, + #[serde(rename = "minimumBytes")] + minimum_bytes: u64, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, + Date { + name: String, + }, + Integer { + name: String, + minimum: i64, + maximum: i64, + }, + Boolean { + name: String, + }, + ControlledCode { + name: String, + scheme: String, + version: String, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RequestedSubject { + pub role: String, + pub selector: RequestedSelector, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RequestedSelector { + pub profile: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum SelectorValue { + String(String), + Integer(i64), + Boolean(bool), +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Evidence { + pub schema: String, + pub assurance_profile: AssuranceProfile, + /// Exact echo of the caller's request nonce for request-response + /// correlation. The runtime does not store it or reject reuse. + pub request_nonce: String, + pub id: String, + #[serde(rename = "type")] + pub evidence_type_name: EvidenceObjectType, + pub supports_requirement: String, + pub is_conformant_to: String, + pub issued_by: String, + pub provided_by: String, + pub issued_at: String, + pub observed_at: String, + pub valid_until: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, + pub subjects: Vec, + pub supported_values: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum EvidenceObjectType { + Evidence, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjectBinding { + pub role: String, + pub binding: String, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SupportedValue { + pub provides_value_for: String, + pub value: PublicValue, +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum PublicValue { + Boolean(bool), + Integer(i64), + String(String), + Bucket(BucketValue), + EntityReference(EntityReferenceValue), + Structured(StructuredValue), + List(Vec), +} + +impl<'de> Deserialize<'de> for SelectorValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match Value::deserialize(deserializer)? { + Value::String(value) => Ok(Self::String(value)), + Value::Number(value) => safe_json_integer(&value) + .map(Self::Integer) + .ok_or_else(|| de::Error::custom("selector number is not a safe JSON integer")), + Value::Bool(value) => Ok(Self::Boolean(value)), + _ => Err(de::Error::custom("selector value is not a scalar")), + } + } +} + +impl<'de> Deserialize<'de> for PublicValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::Bool(value) => Ok(Self::Boolean(value)), + Value::Number(value) => safe_json_integer(&value) + .map(Self::Integer) + .ok_or_else(|| de::Error::custom("public number is not a safe JSON integer")), + Value::String(value) => Ok(Self::String(value)), + Value::Array(values) => serde_json::from_value(Value::Array(values)) + .map(Self::List) + .map_err(de::Error::custom), + Value::Object(object) => { + let form = object + .get("form") + .and_then(Value::as_str) + .map(str::to_owned); + let value = Value::Object(object); + match form.as_deref() { + Some("date-bucket" | "time-bucket") => serde_json::from_value(value) + .map(Self::Bucket) + .map_err(de::Error::custom), + Some("audience-scoped-entity-reference") => serde_json::from_value(value) + .map(Self::EntityReference) + .map_err(de::Error::custom), + Some("reviewed-structured-value") => serde_json::from_value(value) + .map(Self::Structured) + .map_err(de::Error::custom), + _ => Err(de::Error::custom("public object has an unsupported form")), + } + } + Value::Null => Err(de::Error::custom("public value cannot be null")), + } + } +} + +fn safe_json_integer(number: &Number) -> Option { + const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + + if let Some(value) = number.as_i64() { + return (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER) + .contains(&value) + .then_some(value); + } + if let Some(value) = number.as_u64() { + return (value <= MAX_SAFE_INTEGER as u64).then_some(value as i64); + } + let value = number.as_f64()?; + (value.is_finite() + && value.fract() == 0.0 + && value >= -(MAX_SAFE_INTEGER as f64) + && value <= MAX_SAFE_INTEGER as f64) + .then_some(value as i64) +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum ScalarOrEntityReference { + String(String), + EntityReference(EntityReferenceValue), +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct BucketValue { + pub form: BucketForm, + pub scheme: String, + pub bucket: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum BucketForm { + DateBucket, + TimeBucket, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EntityReferenceValue { + pub form: EntityReferenceForm, + pub reference: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum EntityReferenceForm { + AudienceScopedEntityReference, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct StructuredValue { + pub form: StructuredValueForm, + pub schema: String, + pub fields: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum StructuredValueForm { + ReviewedStructuredValue, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct FlattenedJws { + pub protected: String, + pub payload: String, + pub signature: String, +} + +/// Self-identifying unsigned response envelope. It deliberately does not +/// serialize as the signed Evidence payload by itself and carries no JWS +/// member, so the strict JWS verifier rejects it. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnsignedEvidenceEnvelope { + pub schema: String, + #[serde(rename = "type")] + pub envelope_type: UnsignedEnvelopeType, + pub integrity_protection: UnsignedIntegrityProtection, + pub warning: UnsignedEnvelopeWarning, + pub evidence: Evidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum UnsignedEnvelopeType { + UnsignedEvidenceEnvelope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedIntegrityProtection { + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedEnvelopeWarning { + NotCryptographicallyVerifiable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct JwksDocument { + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ProblemBody { + #[serde(rename = "type")] + pub type_uri: String, + pub title: String, + pub status: u16, + pub code: String, + pub operation: String, +} + +#[derive(Clone, PartialEq)] +pub enum LookupResult { + Match(BTreeMap), + NoMatch, + Ambiguous, +} + +macro_rules! redacted_debug { + ($($type_name:ty),+ $(,)?) => { + $( + impl fmt::Debug for $type_name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($type_name)) + .field("protected", &"") + .finish() + } + } + )+ + }; +} + +redacted_debug!( + EvidenceRequest, + HolderPublicKey, + EvidenceDefinitions, + EvidenceDefinition, + EvidenceDefinitionSubject, + EvidenceDefinitionSelector, + EvidenceDefinitionConcept, + EvidenceSelectorField, + RequestedSubject, + RequestedSelector, + SelectorValue, + Evidence, + SubjectBinding, + SupportedValue, + PublicValue, + ScalarOrEntityReference, + BucketValue, + EntityReferenceValue, + StructuredValue, + FlattenedJws, + UnsignedEvidenceEnvelope, + LookupResult, +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_integer_lexical_forms_canonicalize_to_safe_i64() { + for input in ["1", "1.0", "1e0"] { + assert_eq!( + serde_json::from_str::(input).expect("selector integer parses"), + SelectorValue::Integer(1) + ); + let public = serde_json::from_str::(input).expect("public integer parses"); + assert_eq!(public, PublicValue::Integer(1)); + assert_eq!( + serde_json::to_string(&public).expect("integer serializes"), + "1" + ); + } + for input in ["1.5", "9007199254740992", "-9007199254740992"] { + assert!(serde_json::from_str::(input).is_err()); + assert!(serde_json::from_str::(input).is_err()); + } + } + + #[test] + fn request_rejects_query_material_and_unknown_fields() { + let input = serde_json::json!({ + "requestNonce": "A".repeat(43), + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }], + "threshold": 18 + }); + assert!(serde_json::from_value::(input).is_err()); + + let caller_grant_reference = serde_json::json!({ + "requestNonce": "A".repeat(43), + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "grantId": "caller-selected-grant", + "grantAuthority": "caller-selected-authority", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }] + }); + assert!(serde_json::from_value::(caller_grant_reference).is_err()); + + let missing_nonce = serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }] + }); + assert!(serde_json::from_value::(missing_nonce).is_err()); + } + + #[test] + fn request_nonce_canonicality_is_exact() { + assert!(request_nonce_is_canonical( + "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I" + )); + assert!(request_nonce_is_canonical(&"A".repeat(43))); + + let noncanonical_final_symbol = format!("{}B", "A".repeat(42)); + for invalid in [ + "", + "short", + &"A".repeat(42), + &"A".repeat(44), + &format!("{}=", "A".repeat(42)), + &format!("{}+", "A".repeat(42)), + &format!("{}/", "A".repeat(42)), + &format!("{} ", "A".repeat(42)), + &format!("{}\u{e9}", "A".repeat(42)), + noncanonical_final_symbol.as_str(), + ] { + assert!(!request_nonce_is_canonical(invalid), "{invalid:?}"); + } + } + + #[test] + fn evidence_has_no_selector_echo_field() { + let serialized = serde_json::to_value(Evidence { + schema: crate::EVIDENCE_SCHEMA_V1.to_string(), + assurance_profile: AssuranceProfile::EvidenceGrade, + request_nonce: "A".repeat(43), + id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_string(), + is_conformant_to: "urn:example:evidence-type:v1".to_string(), + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + issued_at: "2026-08-02T00:00:00Z".to_string(), + observed_at: "2026-08-02T00:00:00Z".to_string(), + valid_until: "2026-08-03T00:00:00Z".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "a".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept".to_string(), + value: PublicValue::Boolean(true), + }], + }) + .expect("evidence serializes"); + let text = serialized.to_string(); + assert!(!text.contains("selector")); + assert!(!text.contains("opaque")); + } + + #[test] + fn debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads() { + let request = EvidenceRequest { + request_nonce: "protected-request-nonce-canary".to_owned(), + requirement: "urn:example:protected-requirement-canary".to_owned(), + purpose: "protected-purpose-canary".to_owned(), + subjects: vec![RequestedSubject { + role: "subject".to_owned(), + selector: RequestedSelector { + profile: "protected-profile-canary".to_owned(), + values: Some(BTreeMap::from([( + "protected-field-canary".to_owned(), + SelectorValue::String("protected-selector-canary".to_owned()), + )])), + }, + }], + holder_key: None, + }; + let evidence = Evidence { + schema: "protected-schema-canary".to_owned(), + assurance_profile: AssuranceProfile::EvidenceGrade, + request_nonce: "protected-request-nonce-canary".to_owned(), + id: "protected-evidence-id-canary".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "protected-requirement-canary".to_owned(), + is_conformant_to: "protected-evidence-type-canary".to_owned(), + issued_by: "protected-issuer-canary".to_owned(), + provided_by: "protected-provider-canary".to_owned(), + issued_at: "2026-08-02T00:00:00Z".to_owned(), + observed_at: "2026-08-02T00:00:00Z".to_owned(), + valid_until: "2026-08-03T00:00:00Z".to_owned(), + purpose: "protected-purpose-canary".to_owned(), + audience: "protected-audience-canary".to_owned(), + configuration_revision: "protected-revision-canary".to_owned(), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: "protected-binding-canary".to_owned(), + }], + supported_values: vec![SupportedValue { + provides_value_for: "protected-concept-canary".to_owned(), + value: PublicValue::String("protected-supported-value-canary".to_owned()), + }], + }; + let lookup = LookupResult::Match(BTreeMap::from([( + "protected-fact-name-canary".to_owned(), + serde_json::json!("protected-fact-value-canary"), + )])); + let signed = FlattenedJws { + protected: "protected-header-canary".to_owned(), + payload: "protected-payload-canary".to_owned(), + signature: "protected-signature-canary".to_owned(), + }; + let definitions = EvidenceDefinitions { + schema: "protected-discovery-schema-canary".to_owned(), + assurance_profile: AssuranceProfile::EvidenceGrade, + configuration_revision: "protected-discovery-revision-canary".to_owned(), + issued_by: "protected-discovery-issuer-canary".to_owned(), + provided_by: "protected-discovery-provider-canary".to_owned(), + definitions: Vec::new(), + }; + + let unsigned_envelope = UnsignedEvidenceEnvelope { + schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: UnsignedIntegrityProtection::None, + warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence: evidence.clone(), + }; + + for diagnostic in [ + format!("{request:?}"), + format!("{definitions:?}"), + format!("{unsigned_envelope:?}"), + format!( + "{:?}", + SelectorValue::String("protected-selector-canary".to_owned()) + ), + format!("{evidence:?}"), + format!( + "{:?}", + PublicValue::String("protected-supported-value-canary".to_owned()) + ), + format!("{lookup:?}"), + format!("{signed:?}"), + ] { + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains("canary")); + } + } +} diff --git a/crates/registry-evidence/src/observability.rs b/crates/registry-evidence/src/observability.rs new file mode 100644 index 000000000..bbd33fe3a --- /dev/null +++ b/crates/registry-evidence/src/observability.rs @@ -0,0 +1,708 @@ +//! Version 1 operational telemetry for the Evidence HTTP boundary. +//! +//! Operational records describe service health and performance only. The +//! reviewed field set is route template, operation identifier, duration, +//! status category, and safe internal error category; request bodies, selector +//! profiles or values, source responses, Supported Values, credentials, +//! tokens, authority grants, and script inputs are outside it. Both the log +//! record and the metric series below are built from that same closed set, so +//! neither can widen without a review of this module. + +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +use axum::{ + body::Body, + extract::{MatchedPath, State}, + http::{header::CONTENT_TYPE, HeaderName, HeaderValue, Method, Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use ulid::Ulid; + +use crate::{ + audit::{AuditStorageUsage, EvidenceAuditLog}, + problem::ProblemCode, + rate_limit::EvidenceRateLimiter, +}; + +/// Correlation identifier returned to the caller on every response. +/// +/// Requests carry no inbound correlation value: the listener contract fixes +/// `trustProxyIdentityHeaders` to false, so a client-supplied identifier would +/// let a caller choose the key its own records are filed under. +pub(crate) const CORRELATION_HEADER: &str = "x-request-id"; + +/// Target of the per-request operational record. +pub(crate) const REQUEST_LOG_TARGET: &str = "registry_evidence::request"; + +/// Route label used when no route template matched the request. +const UNMATCHED_ROUTE: &str = "unmatched"; + +/// Error label used when a response carries no problem code. +const NO_ERROR: &str = "none"; + +const METRICS_MEDIA_TYPE: &str = "text/plain; version=0.0.4"; + +/// Upper bounds, in seconds, of the request duration histogram. +const DURATION_BUCKETS: [f64; 9] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]; + +/// The request-scoped correlation identifier, minted once at the boundary. +/// +/// Handlers read it from the request extensions rather than minting their own, +/// so the problem body, the audit record, the operational log record, and the +/// response header all name the same operation. +#[derive(Clone)] +pub(crate) struct OperationId(Arc); + +impl OperationId { + fn new() -> Self { + Self(Ulid::new().to_string().into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +/// Read the boundary-minted identifier for this request. +/// +/// The observation layer wraps every route including both fallbacks, so the +/// extension is always present. A handler reached without one would otherwise +/// report an operation that correlates with nothing, so the missing case mints +/// a fresh identifier rather than reporting an empty one. +pub(crate) fn operation_id(extensions: &axum::http::Extensions) -> String { + extensions.get::().map_or_else( + || OperationId::new().as_str().to_owned(), + |id| id.0.to_string(), + ) +} + +/// Coarse outcome class. Operational records report the class, never the exact +/// status, because the exact status of a denial is part of the closed public +/// problem contract rather than an operational signal. +#[derive(Clone, Copy)] +enum StatusCategory { + Success, + ClientError, + ServerError, +} + +impl StatusCategory { + fn of(status: StatusCode) -> Self { + if status.is_server_error() { + Self::ServerError + } else if status.is_client_error() { + Self::ClientError + } else { + Self::Success + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::ClientError => "client_error", + Self::ServerError => "server_error", + } + } +} + +/// Observe one request: mint its identifier, serve it, then publish the +/// reviewed operational fields to the log and the metric registry. +pub(crate) async fn observe( + State(metrics): State>, + mut request: Request, + next: Next, +) -> Response { + let operation = OperationId::new(); + let route = route_template(&request); + let method = normalized_method(request.method()); + request.extensions_mut().insert(operation.clone()); + + let started = Instant::now(); + let mut response = next.run(request).await; + let elapsed = started.elapsed(); + + let status = StatusCategory::of(response.status()); + let error = response + .extensions() + .get::() + .map_or(NO_ERROR, |code| code.code()); + response.headers_mut().insert( + HeaderName::from_static(CORRELATION_HEADER), + HeaderValue::from_str(operation.as_str()) + .expect("a Crockford base32 identifier is a valid header value"), + ); + + metrics.record(route, method, status, error, elapsed); + tracing::info!( + target: REQUEST_LOG_TARGET, + route, + operation = operation.as_str(), + duration_ms = duration_milliseconds(elapsed), + status = status.as_str(), + error, + "evidence request served" + ); + response +} + +/// Resolve the matched route template. +/// +/// Only templates the router registered are reported. An unrouted request +/// reports a single fixed label rather than its requested path, which keeps +/// both the log field and the metric label set bounded by the route table and +/// prevents a caller from writing arbitrary text into either. +fn route_template(request: &Request) -> &'static str { + let Some(matched) = request.extensions().get::() else { + return UNMATCHED_ROUTE; + }; + crate::server::ROUTE_TEMPLATES + .iter() + .find(|template| **template == matched.as_str()) + .copied() + .unwrap_or(UNMATCHED_ROUTE) +} + +/// Fold the method into the closed set the route table can serve, so an +/// arbitrary request verb cannot create a metric series. +fn normalized_method(method: &Method) -> &'static str { + match *method { + Method::GET => "GET", + Method::POST => "POST", + Method::HEAD => "HEAD", + Method::OPTIONS => "OPTIONS", + _ => "other", + } +} + +fn duration_milliseconds(elapsed: Duration) -> u64 { + u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX) +} + +/// In-process request counters and duration histogram. +/// +/// Series are keyed only by the closed label set above, so the registry is +/// bounded by the route table regardless of traffic and needs no eviction. +#[derive(Default)] +pub(crate) struct Metrics { + series: Mutex>, + /// Current count of pseudonym keys tracked by the rate limiter, for the + /// `evidence_rate_limiter_tracked_keys` gauge. Unlike `series`, this is + /// not derived from request content: it is republished on every scrape + /// from [`crate::rate_limit::EvidenceRateLimiter::tracked_key_count`], + /// so it stays a single unlabeled series regardless of traffic. See + /// security invariant V1-I33. + rate_limiter_tracked_keys: AtomicUsize, + /// The limiter the metrics scrape handler samples immediately before + /// each render. `None` for registries that are never served on the + /// metrics listener (for example, an unrelated middleware test). + rate_limiter: Option>, + /// Current segment count and total on-disk bytes of the audit chain, for + /// the `evidence_audit_segments` and `evidence_audit_bytes` gauges. + /// Rotation never deletes a sealed segment, so nothing in the runtime + /// bounds this growth; these gauges are how an operator sees the footprint + /// they own. Republished on every scrape from + /// [`crate::audit::EvidenceAuditLog::storage_usage`], so they stay two + /// unlabeled series regardless of traffic. See security invariant V1-I33. + audit_segments: AtomicUsize, + audit_bytes: AtomicU64, + /// The chain the metrics scrape handler samples immediately before each + /// render. `None` for the same reason as `rate_limiter`. + audit: Option>, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct SeriesKey { + route: &'static str, + method: &'static str, + status: &'static str, + error: &'static str, +} + +#[derive(Default)] +struct Series { + requests: u64, + duration_sum: f64, + bucket_counts: [u64; DURATION_BUCKETS.len()], +} + +impl Metrics { + /// A registry that serves the metrics listener: it samples `rate_limiter` + /// on every scrape to publish the `evidence_rate_limiter_tracked_keys` + /// gauge. + pub(crate) fn new( + rate_limiter: Arc, + audit: Arc, + ) -> Self { + Self { + rate_limiter: Some(rate_limiter), + audit: Some(audit), + ..Self::default() + } + } + + fn record( + &self, + route: &'static str, + method: &'static str, + status: StatusCategory, + error: &'static str, + elapsed: Duration, + ) { + let key = SeriesKey { + route, + method, + status: status.as_str(), + error, + }; + let seconds = elapsed.as_secs_f64(); + let mut series = self + .series + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = series.entry(key).or_default(); + entry.requests += 1; + entry.duration_sum += seconds; + for (count, bound) in entry.bucket_counts.iter_mut().zip(DURATION_BUCKETS) { + if seconds <= bound { + *count += 1; + } + } + } + + /// Publish the rate limiter's current tracked-key count for the next + /// render. + /// + /// The caller reads the live count from the actual limiter (see + /// [`crate::rate_limit::EvidenceRateLimiter::tracked_key_count`]) + /// immediately before calling this, so the published gauge reflects the + /// limiter's state at scrape time rather than a value cached from an + /// earlier request. + pub(crate) fn record_rate_limiter_tracked_keys(&self, count: usize) { + self.rate_limiter_tracked_keys + .store(count, Ordering::Relaxed); + } + + /// Publish the audit chain's current footprint for the next render. + /// + /// Read live from the chain immediately before calling this, for the same + /// reason as the rate-limiter gauge. + pub(crate) fn record_audit_storage_usage(&self, usage: AuditStorageUsage) { + self.audit_segments.store(usage.segments, Ordering::Relaxed); + self.audit_bytes.store(usage.bytes, Ordering::Relaxed); + } + + /// Render the Prometheus text exposition of the current registry. + pub(crate) fn render(&self) -> String { + let series = self + .series + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut body = String::new(); + body.push_str( + "# HELP evidence_http_requests_total Requests served by the Evidence boundary.\n", + ); + body.push_str("# TYPE evidence_http_requests_total counter\n"); + for (key, value) in series.iter() { + body.push_str(&format!( + "evidence_http_requests_total{{{}}} {}\n", + labels(key), + value.requests + )); + } + body.push_str( + "# HELP evidence_http_request_duration_seconds Request duration at the Evidence boundary.\n", + ); + body.push_str("# TYPE evidence_http_request_duration_seconds histogram\n"); + for (key, value) in series.iter() { + for (count, bound) in value.bucket_counts.iter().zip(DURATION_BUCKETS) { + body.push_str(&format!( + "evidence_http_request_duration_seconds_bucket{{{},le=\"{bound}\"}} {count}\n", + labels(key) + )); + } + body.push_str(&format!( + "evidence_http_request_duration_seconds_bucket{{{},le=\"+Inf\"}} {}\n", + labels(key), + value.requests + )); + body.push_str(&format!( + "evidence_http_request_duration_seconds_sum{{{}}} {}\n", + labels(key), + value.duration_sum + )); + body.push_str(&format!( + "evidence_http_request_duration_seconds_count{{{}}} {}\n", + labels(key), + value.requests + )); + } + body.push_str( + "# HELP evidence_rate_limiter_tracked_keys Pseudonym keys currently tracked by the rate limiter.\n", + ); + body.push_str("# TYPE evidence_rate_limiter_tracked_keys gauge\n"); + body.push_str(&format!( + "evidence_rate_limiter_tracked_keys {}\n", + self.rate_limiter_tracked_keys.load(Ordering::Relaxed) + )); + body.push_str( + "# HELP evidence_audit_segments Audit chain segments on disk, sealed and active.\n", + ); + body.push_str("# TYPE evidence_audit_segments gauge\n"); + body.push_str(&format!( + "evidence_audit_segments {}\n", + self.audit_segments.load(Ordering::Relaxed) + )); + body.push_str( + "# HELP evidence_audit_bytes Bytes occupied by the audit chain across every segment.\n", + ); + body.push_str("# TYPE evidence_audit_bytes gauge\n"); + body.push_str(&format!( + "evidence_audit_bytes {}\n", + self.audit_bytes.load(Ordering::Relaxed) + )); + body + } +} + +fn labels(key: &SeriesKey) -> String { + format!( + "route=\"{}\",method=\"{}\",status=\"{}\",error=\"{}\"", + key.route, key.method, key.status, key.error + ) +} + +/// Build the metrics application. +/// +/// It is a separate application on a separate listener: the served counters +/// are operator material, and the public evidence contract does not describe +/// them. Every other path on this listener is unserved rather than delegated +/// back to the evidence routes. +pub(crate) fn metrics_app(metrics: Arc) -> Router { + Router::new() + .route("/metrics", get(render_metrics)) + .fallback(metrics_route_absent) + .with_state(metrics) +} + +async fn render_metrics(State(metrics): State>) -> Response { + // Sampled fresh on every scrape rather than cached from request + // handling, so the gauge reflects the limiter's state at scrape time. + // A registry built without a limiter (see `Metrics::default`) has + // nothing to sample and leaves the gauge at its initial zero. + if let Some(rate_limiter) = &metrics.rate_limiter { + metrics.record_rate_limiter_tracked_keys(rate_limiter.tracked_key_count().await); + } + if let Some(audit) = &metrics.audit { + // A failed read leaves the previous values standing rather than + // publishing a zero that would read as an empty chain. It is logged so + // the staleness is visible instead of silent; the sampled path is + // operator material and carries no request content. + match audit.storage_usage().await { + Ok(usage) => metrics.record_audit_storage_usage(usage), + Err(error) => tracing::warn!( + %error, + "audit storage usage could not be sampled for the capacity gauges" + ), + } + } + let mut response = (StatusCode::OK, metrics.render()).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(METRICS_MEDIA_TYPE)); + response +} + +async fn metrics_route_absent() -> Response { + StatusCode::NOT_FOUND.into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind, EvidenceAuditEvent, + ResponseProtection, + }; + + #[test] + fn series_labels_stay_bounded_by_the_closed_route_and_method_sets() { + // An arbitrary verb and an unrouted path must not each create a series. + let metrics = Metrics::default(); + for method in [ + Method::from_bytes(b"PATCH").expect("a valid method"), + Method::from_bytes(b"BREW").expect("a valid method"), + ] { + metrics.record( + UNMATCHED_ROUTE, + normalized_method(&method), + StatusCategory::ClientError, + ProblemCode::MalformedRequest.code(), + Duration::from_millis(1), + ); + } + let rendered = metrics.render(); + assert_eq!( + rendered + .matches("evidence_http_requests_total{route=\"unmatched\",method=\"other\"") + .count(), + 1, + "unrecognized methods collapse onto one series" + ); + assert!(rendered.contains("status=\"client_error\",error=\"malformed_request\"} 2\n")); + } + + #[test] + fn rate_limiter_tracked_keys_gauge_is_a_single_unlabeled_series() { + let metrics = Metrics::default(); + // Populate unrelated request series first, to prove the gauge does + // not multiply per label the way the request counter and duration + // histogram do. + metrics.record( + "/health", + "GET", + StatusCategory::Success, + NO_ERROR, + Duration::from_millis(1), + ); + metrics.record( + "/v1/evidence", + "POST", + StatusCategory::ClientError, + ProblemCode::MalformedRequest.code(), + Duration::from_millis(1), + ); + metrics.record_rate_limiter_tracked_keys(42); + + let rendered = metrics.render(); + assert_eq!( + rendered + .matches("evidence_rate_limiter_tracked_keys") + .count(), + 3, // HELP line, TYPE line, and exactly one value line + "the gauge is emitted once regardless of how many request series exist" + ); + assert!(rendered.contains("\nevidence_rate_limiter_tracked_keys 42\n")); + assert!( + !rendered.contains("evidence_rate_limiter_tracked_keys{"), + "the gauge must carry no labels" + ); + } + + #[tokio::test] + async fn rate_limiter_tracked_keys_gauge_reflects_keys_added_through_the_limiter_api() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"); + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + + let metrics = Metrics::default(); + metrics.record_rate_limiter_tracked_keys(limiter.tracked_key_count().await); + + let rendered = metrics.render(); + assert!(rendered.contains("\nevidence_rate_limiter_tracked_keys 3\n")); + } + + /// The gauge must come from the live limiter at scrape time, not from a + /// value recorded during earlier request handling. Drive a real limiter + /// through its public API, wire it into a registry the way production + /// startup does, and scrape it through the actual `/metrics` router + /// rather than calling `render` directly. + #[tokio::test] + async fn metrics_endpoint_samples_the_live_rate_limiter_at_scrape_time() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = Arc::new( + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"), + ); + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + let expected = limiter.tracked_key_count().await; + assert_eq!( + expected, 3, + "three distinct tracked keys precede the scrape" + ); + + let (_directory, audit) = scrape_audit_log().await; + let metrics = Arc::new(Metrics::new(Arc::clone(&limiter), audit)); + let server = axum_test::TestServer::new(metrics_app(metrics)); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains(&format!( + "\nevidence_rate_limiter_tracked_keys {expected}\n" + ))); + + // A key tracked after the registry was built is still visible on the + // next scrape, proving the value is sampled live rather than cached + // from construction time. + limiter + .check_request("pseudonym-c") + .await + .expect("third principal"); + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains("\nevidence_rate_limiter_tracked_keys 4\n")); + } + + /// A representative record, so the footprint the gauges report reflects a + /// real audit line rather than an artificially short one. + fn scrape_audit_event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + crate::config::AssuranceProfile::EvidenceGrade, + "01K1EXAMPLE0000000000000000".to_string(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_string(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_string(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "person-v1".to_string(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 5, + ) + } + + /// A durable chain over a temporary directory, for tests that need the + /// metrics registry's live audit sampling. The `TempDir` is returned + /// because dropping it removes the segments out from under the sink. + async fn scrape_audit_log() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + (directory, Arc::new(log)) + } + + #[tokio::test] + async fn metrics_endpoint_samples_the_live_audit_chain_at_scrape_time() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = Arc::new( + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 10, + burst_per_principal: 10, + failed_selector_attempts_per_principal_authority_per_minute: 10, + }) + .expect("limiter builds"), + ); + let (_directory, audit) = scrape_audit_log().await; + let metrics = Arc::new(Metrics::new(limiter, Arc::clone(&audit))); + let server = axum_test::TestServer::new(metrics_app(metrics)); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!( + body.contains("\nevidence_audit_segments 1\n"), + "an untouched chain reports its active segment" + ); + assert!( + body.contains("\nevidence_audit_bytes 0\n"), + "an untouched chain occupies no bytes" + ); + assert!( + !body.contains("evidence_audit_segments{") && !body.contains("evidence_audit_bytes{"), + "the capacity gauges stay unlabeled under V1-I33" + ); + + // Append past the rotation threshold, so the next scrape has to show + // both a new segment and a larger footprint. This is what proves the + // gauges are sampled live rather than cached from construction. + for _ in 0..24 { + audit + .append(scrape_audit_event(&audit)) + .await + .expect("event appends"); + } + let usage = audit.storage_usage().await.expect("usage reads"); + assert!( + usage.segments > 1, + "the appended volume must roll the chain" + ); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains(&format!("\nevidence_audit_segments {}\n", usage.segments))); + assert!(body.contains(&format!("\nevidence_audit_bytes {}\n", usage.bytes))); + } + + #[test] + fn duration_buckets_are_cumulative_and_carry_an_infinite_bound() { + let metrics = Metrics::default(); + metrics.record( + "/health", + "GET", + StatusCategory::Success, + NO_ERROR, + Duration::from_millis(30), + ); + let rendered = metrics.render(); + assert!(rendered.contains("le=\"0.025\"} 0\n")); + assert!(rendered.contains("le=\"0.05\"} 1\n")); + assert!(rendered.contains("le=\"+Inf\"} 1\n")); + assert!(rendered.contains("evidence_http_request_duration_seconds_count{route=\"/health\",method=\"GET\",status=\"success\",error=\"none\"} 1\n")); + } +} diff --git a/crates/registry-evidence/src/problem.rs b/crates/registry-evidence/src/problem.rs new file mode 100644 index 000000000..de907f2ed --- /dev/null +++ b/crates/registry-evidence/src/problem.rs @@ -0,0 +1,135 @@ +//! Closed public problem details for the Evidence HTTP boundary. + +use http::StatusCode; + +use crate::model::ProblemBody; + +const PROBLEM_BASE: &str = "https://registrystack.org/problems/evidence/"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProblemCode { + MalformedRequest, + InvalidSelector, + AuthenticationFailed, + NotAuthorized, + ResponseFormatNotAcceptable, + EvidenceNotAvailable, + RateLimited, + DependencyUnavailable, + ServiceUnavailable, +} + +impl ProblemCode { + pub const fn code(self) -> &'static str { + match self { + Self::MalformedRequest => "malformed_request", + Self::InvalidSelector => "invalid_selector", + Self::AuthenticationFailed => "authentication_failed", + Self::NotAuthorized => "not_authorized", + Self::ResponseFormatNotAcceptable => "response_format_not_acceptable", + Self::EvidenceNotAvailable => "evidence_not_available", + Self::RateLimited => "rate_limited", + Self::DependencyUnavailable => "dependency_unavailable", + Self::ServiceUnavailable => "service_unavailable", + } + } + + pub const fn status(self) -> StatusCode { + match self { + Self::MalformedRequest | Self::InvalidSelector => StatusCode::BAD_REQUEST, + Self::AuthenticationFailed => StatusCode::UNAUTHORIZED, + Self::NotAuthorized => StatusCode::FORBIDDEN, + Self::ResponseFormatNotAcceptable => StatusCode::NOT_ACCEPTABLE, + Self::EvidenceNotAvailable => StatusCode::UNPROCESSABLE_ENTITY, + Self::RateLimited => StatusCode::TOO_MANY_REQUESTS, + Self::DependencyUnavailable | Self::ServiceUnavailable => { + StatusCode::SERVICE_UNAVAILABLE + } + } + } + + pub const fn title(self) -> &'static str { + match self { + Self::MalformedRequest | Self::InvalidSelector => "Request is not valid", + Self::AuthenticationFailed => "Authentication failed", + Self::NotAuthorized => "Request is not authorized", + Self::ResponseFormatNotAcceptable => "Requested response format is not acceptable", + Self::EvidenceNotAvailable => "Evidence could not be produced", + Self::RateLimited => "Request rate exceeded", + Self::DependencyUnavailable | Self::ServiceUnavailable => { + "Service temporarily unavailable" + } + } + } + + pub fn body(self, operation: &str) -> ProblemBody { + ProblemBody { + type_uri: format!("{PROBLEM_BASE}{}", self.code()), + title: self.title().to_string(), + status: self.status().as_u16(), + code: self.code().to_string(), + operation: operation.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unresolved_internal_classes_share_one_exact_public_shape() { + let no_match = ProblemCode::EvidenceNotAvailable.body("operation-0000000000000001"); + let ambiguous = ProblemCode::EvidenceNotAvailable.body("operation-0000000000000001"); + assert_eq!(no_match, ambiguous); + assert_eq!( + serde_json::to_value(no_match).expect("problem serializes"), + serde_json::json!({ + "type": "https://registrystack.org/problems/evidence/evidence_not_available", + "title": "Evidence could not be produced", + "status": 422, + "code": "evidence_not_available", + "operation": "operation-0000000000000001" + }) + ); + } + + #[test] + fn every_code_has_the_contract_status_and_title() { + let cases = [ + (ProblemCode::MalformedRequest, 400, "Request is not valid"), + (ProblemCode::InvalidSelector, 400, "Request is not valid"), + ( + ProblemCode::AuthenticationFailed, + 401, + "Authentication failed", + ), + (ProblemCode::NotAuthorized, 403, "Request is not authorized"), + ( + ProblemCode::ResponseFormatNotAcceptable, + 406, + "Requested response format is not acceptable", + ), + ( + ProblemCode::EvidenceNotAvailable, + 422, + "Evidence could not be produced", + ), + (ProblemCode::RateLimited, 429, "Request rate exceeded"), + ( + ProblemCode::DependencyUnavailable, + 503, + "Service temporarily unavailable", + ), + ( + ProblemCode::ServiceUnavailable, + 503, + "Service temporarily unavailable", + ), + ]; + for (code, status, title) in cases { + assert_eq!(code.status().as_u16(), status); + assert_eq!(code.title(), title); + } + } +} diff --git a/crates/registry-evidence/src/rate_limit.rs b/crates/registry-evidence/src/rate_limit.rs new file mode 100644 index 000000000..dac51e990 --- /dev/null +++ b/crates/registry-evidence/src/rate_limit.rs @@ -0,0 +1,313 @@ +//! In-memory pseudonym-keyed request and failed-selector rate limits. + +use std::{collections::HashMap, time::Duration}; + +use thiserror::Error; +use tokio::time::Instant; + +const MAX_TRACKED_KEYS: usize = 100_000; + +#[derive(Debug, Clone, Copy)] +pub struct RateLimitConfig { + pub requests_per_principal_per_minute: u32, + pub burst_per_principal: u32, + pub failed_selector_attempts_per_principal_authority_per_minute: u32, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitError { + #[error("rate-limit configuration is invalid")] + Configuration, + #[error("request rate exceeded")] + RequestExceeded, + #[error("failed-selector rate exceeded")] + FailedSelectorExceeded, + #[error("rate-limit capacity is unavailable")] + Capacity, +} + +#[derive(Debug)] +struct TokenBucket { + tokens: f64, + updated_at: Instant, +} + +#[derive(Debug)] +struct FixedWindow { + started_at: Instant, + count: u32, +} + +#[derive(Debug)] +pub struct EvidenceRateLimiter { + config: RateLimitConfig, + requests: tokio::sync::Mutex>, + selector_failures: tokio::sync::Mutex>, +} + +impl EvidenceRateLimiter { + pub fn new(config: RateLimitConfig) -> Result { + if config.requests_per_principal_per_minute == 0 + || config.burst_per_principal == 0 + || config.failed_selector_attempts_per_principal_authority_per_minute == 0 + { + return Err(RateLimitError::Configuration); + } + Ok(Self { + config, + requests: tokio::sync::Mutex::new(HashMap::new()), + selector_failures: tokio::sync::Mutex::new(HashMap::new()), + }) + } + + pub async fn check_request(&self, principal_pseudonym: &str) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_pseudonym)?; + let now = Instant::now(); + let mut buckets = self.requests.lock().await; + prune_buckets(&mut buckets, now); + if !buckets.contains_key(principal_pseudonym) && buckets.len() >= MAX_TRACKED_KEYS { + return Err(RateLimitError::Capacity); + } + let capacity = f64::from(self.config.burst_per_principal); + let refill_per_second = f64::from(self.config.requests_per_principal_per_minute) / 60.0; + let bucket = buckets + .entry(principal_pseudonym.to_owned()) + .or_insert(TokenBucket { + tokens: capacity, + updated_at: now, + }); + let elapsed = now.duration_since(bucket.updated_at).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * refill_per_second).min(capacity); + bucket.updated_at = now; + if bucket.tokens < 1.0 { + return Err(RateLimitError::RequestExceeded); + } + bucket.tokens -= 1.0; + Ok(()) + } + + /// Check the selector-failure budget before source access. Call + /// [`Self::record_selector_failure`] only when selector validation or + /// authorization actually fails. + pub async fn check_selector_failure_budget( + &self, + principal_authority_pseudonym: &str, + ) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_authority_pseudonym)?; + let now = Instant::now(); + let mut windows = self.selector_failures.lock().await; + prune_windows(&mut windows, now); + match windows.get(principal_authority_pseudonym) { + Some(window) + if now.duration_since(window.started_at) < Duration::from_secs(60) + && window.count + >= self + .config + .failed_selector_attempts_per_principal_authority_per_minute => + { + Err(RateLimitError::FailedSelectorExceeded) + } + _ => Ok(()), + } + } + + pub async fn record_selector_failure( + &self, + principal_authority_pseudonym: &str, + ) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_authority_pseudonym)?; + let now = Instant::now(); + let mut windows = self.selector_failures.lock().await; + prune_windows(&mut windows, now); + if !windows.contains_key(principal_authority_pseudonym) && windows.len() >= MAX_TRACKED_KEYS + { + return Err(RateLimitError::Capacity); + } + let window = windows + .entry(principal_authority_pseudonym.to_owned()) + .or_insert(FixedWindow { + started_at: now, + count: 0, + }); + if now.duration_since(window.started_at) >= Duration::from_secs(60) { + window.started_at = now; + window.count = 0; + } + window.count = window.count.saturating_add(1); + if window.count + > self + .config + .failed_selector_attempts_per_principal_authority_per_minute + { + return Err(RateLimitError::FailedSelectorExceeded); + } + Ok(()) + } + + /// Total pseudonym keys currently tracked across both maps, toward the + /// shared [`MAX_TRACKED_KEYS`] capacity ceiling each map enforces. + /// + /// Each lock is held only long enough to read `.len()`; no other work + /// happens in either critical section, since the request path contends + /// on these same locks. + pub async fn tracked_key_count(&self) -> usize { + let requests_len = self.requests.lock().await.len(); + let selector_failures_len = self.selector_failures.lock().await.len(); + requests_len + selector_failures_len + } +} + +fn validate_pseudonym_key(key: &str) -> Result<(), RateLimitError> { + if key.is_empty() || key.len() > 256 || key.chars().any(char::is_whitespace) { + return Err(RateLimitError::Configuration); + } + Ok(()) +} + +fn prune_buckets(buckets: &mut HashMap, now: Instant) { + if buckets.len() < MAX_TRACKED_KEYS / 2 { + return; + } + buckets.retain(|_, bucket| now.duration_since(bucket.updated_at) < Duration::from_secs(600)); +} + +fn prune_windows(windows: &mut HashMap, now: Instant) { + if windows.len() < MAX_TRACKED_KEYS / 2 { + return; + } + windows.retain(|_, window| now.duration_since(window.started_at) < Duration::from_secs(120)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limiter() -> EvidenceRateLimiter { + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds") + } + + #[tokio::test] + async fn request_burst_and_refill_are_enforced() { + let limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 6_000, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"); + limiter.check_request("pseudonym-a").await.expect("first"); + limiter.check_request("pseudonym-a").await.expect("second"); + assert_eq!( + limiter.check_request("pseudonym-a").await, + Err(RateLimitError::RequestExceeded) + ); + tokio::time::sleep(Duration::from_millis(11)).await; + limiter + .check_request("pseudonym-a") + .await + .expect("refilled"); + } + + #[tokio::test] + async fn request_budget_is_shared_by_every_use_of_a_principal_key() { + let limiter = limiter(); + let principal_key = "stable-principal-pseudonym"; + + for _request_context in [ + ("adult", "service-enrolment", "audience-a"), + ("residence", "benefit-eligibility", "audience-b"), + ] { + limiter + .check_request(principal_key) + .await + .expect("shared principal budget has capacity"); + } + + assert_eq!( + limiter.check_request(principal_key).await, + Err(RateLimitError::RequestExceeded) + ); + limiter + .check_request("other-principal-pseudonym") + .await + .expect("another principal has an independent budget"); + } + + #[tokio::test] + async fn selector_failure_budget_is_separate_and_authority_scoped() { + let limiter = limiter(); + limiter + .record_selector_failure("principal-authority-a") + .await + .expect("first failure"); + limiter + .record_selector_failure("principal-authority-a") + .await + .expect("second failure"); + assert_eq!( + limiter + .check_selector_failure_budget("principal-authority-a") + .await, + Err(RateLimitError::FailedSelectorExceeded) + ); + limiter + .check_selector_failure_budget("principal-authority-b") + .await + .expect("other authority remains available"); + } + + #[tokio::test] + async fn tracked_key_count_reports_the_total_across_both_maps() { + let limiter = limiter(); + assert_eq!(limiter.tracked_key_count().await, 0); + + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + + assert_eq!(limiter.tracked_key_count().await, 3); + + // Reusing an already-tracked key does not grow the count. + limiter + .check_request("pseudonym-a") + .await + .expect("existing principal"); + assert_eq!(limiter.tracked_key_count().await, 3); + } + + #[tokio::test] + async fn selector_failure_budget_is_shared_across_request_contexts() { + let limiter = limiter(); + let principal_authority_key = "stable-principal-authority-pseudonym"; + + for _request_context in [ + ("service-enrolment", "audience-a"), + ("benefit-eligibility", "audience-b"), + ] { + limiter + .record_selector_failure(principal_authority_key) + .await + .expect("shared selector-failure budget has capacity"); + } + + assert_eq!( + limiter + .check_selector_failure_budget(principal_authority_key) + .await, + Err(RateLimitError::FailedSelectorExceeded) + ); + } +} diff --git a/crates/registry-evidence/src/rhai_runtime.rs b/crates/registry-evidence/src/rhai_runtime.rs new file mode 100644 index 000000000..590069522 --- /dev/null +++ b/crates/registry-evidence/src/rhai_runtime.rs @@ -0,0 +1,3991 @@ +//! Hardened, deterministic Rhai execution for Evidence bundle scripts. + +use std::{ + cmp::Ordering, + collections::{BTreeMap, BTreeSet}, + fmt, + sync::Arc, +}; + +use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; +use rhai::{ + Array, CallFnOptions, Dynamic, Engine, EvalAltResult, ImmutableString, Map, Module, Scope, AST, + INT, +}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + model::LookupResult, + values::{Decimal, EntityReferenceSeed}, +}; + +pub const MAXIMUM_OPERATIONS: u64 = 100_000; +pub const MAXIMUM_CALL_DEPTH: usize = 32; +pub const MAXIMUM_EXPRESSION_DEPTH: usize = 64; +pub const MAXIMUM_MODULES: usize = 0; +pub const MAXIMUM_STRING_BYTES: usize = 16_384; +pub const MAXIMUM_ARRAY_ITEMS: usize = 256; +pub const MAXIMUM_MAP_ENTRIES: usize = 256; +pub const MAXIMUM_FACT_ENTRIES: usize = 64; +pub const MAXIMUM_CONCEPT_VALUES: usize = 16; +pub const MAXIMUM_CODELIST_ENTRIES: usize = 4_096; +pub const MAXIMUM_SOURCE_INPUT_BYTES: usize = 1_048_576; +pub const MAXIMUM_RESULT_BYTES: usize = 65_536; +pub const MAXIMUM_PREPARATION_INPUT_BYTES: usize = 1_048_576; +pub const MAXIMUM_REQUEST_PARTS_BYTES: usize = 65_536; +pub const MAXIMUM_QUERY_PAIRS: usize = 64; +pub const MAXIMUM_QUERY_NAME_BYTES: usize = 64; +pub const MAXIMUM_QUERY_VALUE_BYTES: usize = 4_096; +pub const MAXIMUM_JSON_BODY_DEPTH: usize = 32; + +const MAXIMUM_BUCKETS: usize = 64; +const MAXIMUM_ENTITY_REFERENCE_ITEMS: usize = 64; +const MAXIMUM_REQUIRED_CODE_BYTES: usize = 64; +const MAXIMUM_POINTER_BYTES: usize = 256; +const MAXIMUM_POINTER_SEGMENTS: usize = 16; +/// One past the largest signed 64-bit integer, as the exclusive magnitude bound for any +/// ordinary floating-point number that crosses a runtime boundary. +const INTEGER_MAGNITUDE_LIMIT: f64 = 9_223_372_036_854_775_808.0; +/// Host-owned wrapper applied to every index operand by the startup source review. +const INDEX_GUARD_FUNCTION: &str = "__evidence_index"; + +/// Host-private marker for a `required` value that was absent. Scripts cannot name, +/// construct, catch, or observe it, and it carries no script-supplied text. +#[derive(Clone, Copy, Debug)] +struct RequiredUnavailable; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum RhaiRuntimeError { + #[error("Evidence script compilation failed")] + Compilation, + #[error("Evidence script has an invalid entry point")] + EntryPoint, + #[error("Evidence script invocation failed")] + Invocation, + #[error("Evidence required input is unavailable")] + Unavailable, + #[error("Evidence source response violates its protocol contract")] + SourceProtocol, + #[error("Evidence script input exceeds its bound")] + InputBound, + #[error("Evidence request preparation input is invalid")] + AdapterInput, + #[error("Evidence request preparation result violates the closed ABI")] + PreparationResult, + #[error("Evidence extraction result violates the closed ABI")] + ExtractionResult, + #[error("Evidence extracted facts violate their schema")] + FactSchema, + #[error("Evidence derivation result violates the closed ABI")] + DerivationResult, + #[error("Evidence derivation input violates its reviewed contract")] + DerivationInput, + #[error("Evidence evaluation context is invalid")] + EvaluationContext, + #[error("Evidence codelist is invalid")] + Codelist, +} + +/// A schema hook kept separate from bundle configuration while that layer is loaded. +pub trait FactSchemaValidator { + fn is_valid(&self, facts: &Value) -> bool; +} + +impl FactSchemaValidator for jsonschema::JSONSchema { + fn is_valid(&self, facts: &Value) -> bool { + jsonschema::JSONSchema::is_valid(self, facts) + } +} + +impl FactSchemaValidator for F +where + F: Fn(&Value) -> bool, +{ + fn is_valid(&self, facts: &Value) -> bool { + self(facts) + } +} + +#[derive(Clone, Debug)] +pub struct CompiledExtraction { + ast: AST, +} + +#[derive(Clone, Debug)] +pub struct CompiledPreparation { + ast: AST, +} + +#[derive(Clone, Debug)] +pub struct CompiledDerivation { + ast: AST, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RequestPartRequirement { + Forbidden, + Optional, + Required, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestPartsLimits { + query: RequestPartRequirement, + body: RequestPartRequirement, + maximum_query_pairs: usize, + maximum_query_name_bytes: usize, + maximum_query_value_bytes: usize, + maximum_json_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, + maximum_normalized_bytes: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RequestPartsBounds { + pub maximum_query_pairs: usize, + pub maximum_query_name_bytes: usize, + pub maximum_query_value_bytes: usize, + pub maximum_json_depth: usize, + pub maximum_collection_items: usize, + pub maximum_string_bytes: usize, + pub maximum_normalized_bytes: usize, +} + +impl RequestPartsLimits { + pub fn new( + query: RequestPartRequirement, + body: RequestPartRequirement, + bounds: RequestPartsBounds, + ) -> Result { + let RequestPartsBounds { + maximum_query_pairs, + maximum_query_name_bytes, + maximum_query_value_bytes, + maximum_json_depth, + maximum_collection_items, + maximum_string_bytes, + maximum_normalized_bytes, + } = bounds; + if maximum_query_pairs == 0 + || maximum_query_pairs > MAXIMUM_QUERY_PAIRS + || maximum_query_name_bytes == 0 + || maximum_query_name_bytes > MAXIMUM_QUERY_NAME_BYTES + || maximum_query_value_bytes == 0 + || maximum_query_value_bytes > MAXIMUM_QUERY_VALUE_BYTES + || maximum_json_depth == 0 + || maximum_json_depth > MAXIMUM_JSON_BODY_DEPTH + || maximum_collection_items == 0 + || maximum_collection_items > MAXIMUM_ARRAY_ITEMS + || maximum_string_bytes == 0 + || maximum_string_bytes > MAXIMUM_STRING_BYTES + || maximum_normalized_bytes == 0 + || maximum_normalized_bytes > MAXIMUM_REQUEST_PARTS_BYTES + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(Self { + query, + body, + maximum_query_pairs, + maximum_query_name_bytes, + maximum_query_value_bytes, + maximum_json_depth, + maximum_collection_items, + maximum_string_bytes, + maximum_normalized_bytes, + }) + } +} + +#[derive(Clone, PartialEq)] +pub struct QueryPair { + pub name: String, + pub value: String, +} + +impl fmt::Debug for QueryPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("QueryPair") + .field("name", &"[redacted]") + .field("value", &"[redacted]") + .finish() + } +} + +#[derive(Clone, PartialEq)] +pub struct RequestParts { + pub query: Vec, + pub body: Option, +} + +impl fmt::Debug for RequestParts { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RequestParts") + .field("query_pairs", &self.query.len()) + .field("body_present", &self.body.is_some()) + .finish() + } +} + +/// Strict proleptic-Gregorian date exposed to Rhai as an opaque typed value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CalendarDate(NaiveDate); + +impl CalendarDate { + pub fn parse(input: &str) -> Result { + if !is_canonical_date_text(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + NaiveDate::parse_from_str(input, "%Y-%m-%d") + .map(Self) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + + pub fn as_naive_date(self) -> NaiveDate { + self.0 + } +} + +/// Strict RFC 3339 instant normalized to UTC without ambient clock access. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct UtcInstant(DateTime); + +impl UtcInstant { + pub fn parse(input: &str) -> Result { + if !is_strict_rfc3339(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + DateTime::parse_from_rfc3339(input) + .map(|value| Self(value.with_timezone(&Utc))) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + + pub fn as_utc(self) -> DateTime { + self.0 + } +} + +/// A validated local clock time carrying its explicit UTC offset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegalLocalTime(ImmutableString); + +impl LegalLocalTime { + pub fn parse(input: &str) -> Result { + if !is_strict_local_time(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok(Self(input.into())) + } +} + +/// Read-only, bounded mapping handle. Rhai can only pass it to `codelist_lookup`. +#[derive(Clone)] +pub struct CodelistHandle { + entries: Arc>, +} + +impl CodelistHandle { + pub fn new(entries: BTreeMap) -> Result { + if entries.len() > MAXIMUM_CODELIST_ENTRIES + || entries.iter().any(|(input, output)| { + input.is_empty() + || output.is_empty() + || input.len() > MAXIMUM_STRING_BYTES + || output.len() > MAXIMUM_STRING_BYTES + }) + { + return Err(RhaiRuntimeError::Codelist); + } + Ok(Self { + entries: Arc::new(entries), + }) + } +} + +impl fmt::Debug for CodelistHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CodelistHandle") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +/// The only values derivation may pass to the lead output gate. +#[derive(Clone)] +pub enum DerivedValue { + Json(Value), + Decimal(Decimal), + EntityReferenceSeed(EntityReferenceSeed), + EntityReferenceSeedList(Vec), +} + +impl fmt::Debug for DerivedValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(value) => formatter + .debug_struct("DerivedValue::Json") + .field("form", &json_form(value)) + .finish(), + Self::Decimal(_) => formatter.write_str("DerivedValue::Decimal([REDACTED])"), + Self::EntityReferenceSeed(_) => { + formatter.write_str("DerivedValue::EntityReferenceSeed([REDACTED])") + } + Self::EntityReferenceSeedList(values) => formatter + .debug_struct("DerivedValue::EntityReferenceSeedList") + .field("count", &values.len()) + .finish(), + } + } +} + +fn json_form(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[derive(Clone)] +pub struct DerivedConceptValue { + pub concept_id: String, + pub value: DerivedValue, +} + +impl fmt::Debug for DerivedConceptValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DerivedConceptValue") + .field("concept_id", &self.concept_id) + .field("value", &self.value) + .finish() + } +} + +/// Exact deterministic input to `derive`. +#[derive(Clone, Debug)] +pub struct EvaluationContext { + observed_at: UtcInstant, + legal_local_date: CalendarDate, + legal_local_time: LegalLocalTime, + parameters: Map, + codelists: BTreeMap, +} + +impl EvaluationContext { + pub fn new( + observed_at: UtcInstant, + legal_local_date: CalendarDate, + legal_local_time: LegalLocalTime, + parameters: &Value, + codelists: BTreeMap, + ) -> Result { + if codelists.len() > MAXIMUM_MAP_ENTRIES + || codelists + .keys() + .any(|name| name.is_empty() || name.len() > MAXIMUM_STRING_BYTES) + { + return Err(RhaiRuntimeError::EvaluationContext); + } + validate_json_bound(parameters, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::EvaluationContext)?; + let parameters = parameters_to_map(parameters)?; + Ok(Self { + observed_at, + legal_local_date, + legal_local_time, + parameters, + codelists, + }) + } + + fn into_dynamic(self) -> Dynamic { + let mut codelists = Map::new(); + for (name, handle) in self.codelists { + codelists.insert(name.into(), Dynamic::from(handle)); + } + let mut context = Map::new(); + context.insert("observed_at".into(), Dynamic::from(self.observed_at)); + context.insert( + "legal_local_date".into(), + Dynamic::from(self.legal_local_date), + ); + context.insert( + "legal_local_time".into(), + Dynamic::from(self.legal_local_time), + ); + context.insert("parameters".into(), Dynamic::from(self.parameters)); + context.insert("codelists".into(), Dynamic::from(codelists)); + Dynamic::from(context) + } +} + +/// One immutable capability allowlist used by every bundle script. +pub struct RhaiRuntime { + engine: Engine, +} + +impl fmt::Debug for RhaiRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhaiRuntime()") + } +} + +impl Default for RhaiRuntime { + fn default() -> Self { + Self::new() + } +} + +impl RhaiRuntime { + pub fn new() -> Self { + let mut engine = Engine::new_raw(); + engine + .set_max_operations(MAXIMUM_OPERATIONS) + .set_max_call_levels(MAXIMUM_CALL_DEPTH) + .set_max_expr_depths(MAXIMUM_EXPRESSION_DEPTH, MAXIMUM_EXPRESSION_DEPTH) + .set_max_modules(MAXIMUM_MODULES) + .set_max_string_size(MAXIMUM_STRING_BYTES) + .set_max_array_size(MAXIMUM_ARRAY_ITEMS) + .set_max_map_size(MAXIMUM_MAP_ENTRIES) + .set_allow_anonymous_fn(false) + .disable_symbol("import") + .disable_symbol("export") + .disable_symbol("eval") + .disable_symbol("print") + .disable_symbol("debug") + .disable_symbol("while") + .disable_symbol("until") + .disable_symbol("loop") + .disable_symbol("do") + .disable_symbol("switch") + .disable_symbol("try") + .disable_symbol("catch") + .disable_symbol("..") + .disable_symbol("..=") + .disable_symbol("?.") + .disable_symbol("??"); + + let mut iterators = Module::new(); + iterators.set_iterable::(); + engine.register_global_module(iterators.into()); + + register_language_essentials(&mut engine); + register_evidence_primitives(&mut engine); + + Self { engine } + } + + pub fn compile_preparation( + &self, + source: &str, + ) -> Result { + self.compile_exact(source, "prepare", 2) + .map(|ast| CompiledPreparation { ast }) + } + + pub fn compile_extraction(&self, source: &str) -> Result { + self.compile_exact(source, "extract", 2) + .map(|ast| CompiledExtraction { ast }) + } + + pub fn compile_derivation(&self, source: &str) -> Result { + self.compile_exact(source, "derive", 3) + .map(|ast| CompiledDerivation { ast }) + } + + pub fn prepare( + &self, + script: &CompiledPreparation, + selectors: &Value, + parameters: &Value, + limits: &RequestPartsLimits, + ) -> Result { + validate_adapter_inputs(selectors, parameters)?; + let selectors = adapter_object_to_dynamic(selectors)?; + let parameters = adapter_object_to_dynamic(parameters)?; + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "prepare", + (selectors, parameters), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Preparation))?; + decode_request_parts(result, limits) + } + + pub fn extract( + &self, + script: &CompiledExtraction, + source_response: &Value, + parameters: &Value, + fact_schema: &V, + ) -> Result + where + V: FactSchemaValidator + ?Sized, + { + validate_json_bound(source_response, MAXIMUM_SOURCE_INPUT_BYTES)?; + if !json_numbers_are_supported(source_response) { + return Err(RhaiRuntimeError::InputBound); + } + validate_adapter_object(parameters)?; + let input = + rhai::serde::to_dynamic(source_response).map_err(|_| RhaiRuntimeError::InputBound)?; + let parameters = adapter_object_to_dynamic(parameters)?; + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "extract", + (input, parameters), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Extraction))?; + decode_lookup_result(result, fact_schema) + } + + pub fn derive( + &self, + script: &CompiledDerivation, + facts: &BTreeMap, + selectors: &Value, + evaluation_context: EvaluationContext, + ) -> Result, RhaiRuntimeError> { + if facts.len() > MAXIMUM_FACT_ENTRIES { + return Err(RhaiRuntimeError::InputBound); + } + let facts_value = Value::Object( + facts + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + ); + validate_json_bound(&facts_value, MAXIMUM_RESULT_BYTES)?; + if !json_numbers_are_supported(&facts_value) { + return Err(RhaiRuntimeError::InputBound); + } + let facts = + rhai::serde::to_dynamic(facts_value).map_err(|_| RhaiRuntimeError::InputBound)?; + validate_adapter_object(selectors)?; + let selectors = adapter_object_to_dynamic(selectors)?; + let context = evaluation_context.into_dynamic(); + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "derive", + (facts, selectors, context), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Derivation))?; + decode_derivation_result(result) + } + + fn compile_exact( + &self, + source: &str, + function_name: &str, + parameter_count: usize, + ) -> Result { + if source.len() > MAXIMUM_RESULT_BYTES { + return Err(RhaiRuntimeError::InputBound); + } + let guarded = guarded_script_source(source)?; + let ast = self + .engine + .compile(&guarded) + .map_err(|_| RhaiRuntimeError::Compilation)?; + let mut names = BTreeSet::new(); + let mut entry_points = 0usize; + for function in ast.iter_functions() { + if !names.insert(function.name) { + return Err(RhaiRuntimeError::EntryPoint); + } + if function.name == function_name { + if function.params.len() != parameter_count + || function.access != rhai::FnAccess::Public + { + return Err(RhaiRuntimeError::EntryPoint); + } + entry_points += 1; + } + } + if entry_points != 1 { + return Err(RhaiRuntimeError::EntryPoint); + } + Ok(ast) + } + + #[cfg(test)] + fn engine(&self) -> &Engine { + &self.engine + } +} + +fn register_language_essentials(engine: &mut Engine) { + engine + .register_fn("==", |left: INT, right: INT| left == right) + .register_fn("!=", |left: INT, right: INT| left != right) + .register_fn("<", |left: INT, right: INT| left < right) + .register_fn("<=", |left: INT, right: INT| left <= right) + .register_fn(">", |left: INT, right: INT| left > right) + .register_fn(">=", |left: INT, right: INT| left >= right) + .register_fn("!", |value: bool| !value) + .register_fn("==", |left: bool, right: bool| left == right) + .register_fn("!=", |left: bool, right: bool| left != right) + .register_fn("==", |left: ImmutableString, right: ImmutableString| { + left == right + }) + .register_fn("!=", |left: ImmutableString, right: ImmutableString| { + left != right + }) + .register_get("len", |array: &mut Array| { + INT::try_from(array.len()).unwrap_or(INT::MAX) + }) + .register_fn("len", |map: Map| { + INT::try_from(map.len()).unwrap_or(INT::MAX) + }) + .register_fn("contains", |map: Map, name: ImmutableString| { + map.contains_key(name.as_str()) + }) + .register_fn("push", bounded_array_push) + .register_fn("replace", literal_string_replace) + .register_fn("parse_integer", parse_integer) + .register_fn(INDEX_GUARD_FUNCTION, guard_index) + .register_fn(INDEX_GUARD_FUNCTION, guard_index_key); +} + +fn register_evidence_primitives(engine: &mut Engine) { + engine + .register_type_with_name::("Date") + .register_type_with_name::("Instant") + .register_type_with_name::("LegalLocalTime") + .register_type_with_name::("Decimal") + .register_type_with_name::("EntityReferenceSeed") + .register_type_with_name::("CodelistHandle") + .register_fn("parse_date", parse_date) + .register_fn("parse_instant", parse_instant) + .register_fn("decimal", parse_decimal) + .register_fn("parse_decimal", parse_decimal) + .register_fn("integer_to_decimal", integer_to_decimal) + .register_fn("add_calendar_years", add_calendar_years) + .register_fn("add_calendar_months", add_calendar_months) + .register_fn("compare_dates", compare_dates) + .register_fn("compare_instants", compare_instants) + .register_fn("days_between", days_between) + .register_fn("compare_decimals", compare_decimals) + .register_fn("bucket_number", bucket_number) + .register_fn("entity_reference_seed", entity_reference_seed) + .register_fn("codelist_lookup", codelist_lookup) + .register_fn("list_contains", list_contains) + .register_fn("set_contains", set_contains) + .register_fn("required", required) + .register_fn("is_missing", is_missing) + .register_fn("get_path", get_path); +} + +/// Resolves one RFC 6901 JSON Pointer against a decoded value, the same pointer +/// grammar source projection already uses, so one document shape is described one way. +/// +/// Absence at any step is reported as the unit marker `is_missing` and `required` +/// already understand, because a projected response legitimately omits a selected +/// leaf. A malformed or oversized pointer is instead a fault in the script's own +/// text, so it fails closed rather than passing as absence. +fn get_path(value: Dynamic, pointer: &str) -> Result> { + let mut current = value; + for token in parse_json_pointer(pointer)? { + let next = if let Some(map) = current.read_lock::() { + map.get(token.as_str()).cloned() + } else if let Some(array) = current.read_lock::() { + array.get(reference_token_index(&token)?).cloned() + } else { + None + }; + match next { + Some(value) => current = value, + None => return Ok(Dynamic::UNIT), + } + } + Ok(current) +} + +/// Splits a pointer into its unescaped reference tokens under fixed ceilings. Index +/// tokens stay unchecked here because only the value being traversed says which +/// tokens address an array. +fn parse_json_pointer(pointer: &str) -> Result, Box> { + if pointer.len() > MAXIMUM_POINTER_BYTES { + return Err(primitive_error("pointer_out_of_bounds")); + } + if pointer.is_empty() { + return Ok(Vec::new()); + } + let Some(body) = pointer.strip_prefix('/') else { + return Err(primitive_error("invalid_pointer")); + }; + let tokens: Vec<&str> = body.split('/').collect(); + if tokens.len() > MAXIMUM_POINTER_SEGMENTS { + return Err(primitive_error("pointer_out_of_bounds")); + } + tokens.into_iter().map(unescape_reference_token).collect() +} + +fn unescape_reference_token(token: &str) -> Result> { + let mut unescaped = String::with_capacity(token.len()); + let mut characters = token.chars(); + while let Some(character) = characters.next() { + if character != '~' { + unescaped.push(character); + continue; + } + match characters.next() { + Some('0') => unescaped.push('~'), + Some('1') => unescaped.push('/'), + _ => return Err(primitive_error("invalid_pointer")), + } + } + Ok(unescaped) +} + +/// Accepts only the canonical decimal index RFC 6901 defines, which leaves no way to +/// express the negative index Rhai would otherwise count from the end of an array. +fn reference_token_index(token: &str) -> Result> { + let canonical = token == "0" + || (!token.is_empty() + && !token.starts_with('0') + && token.bytes().all(|byte| byte.is_ascii_digit())); + if !canonical { + return Err(primitive_error("invalid_pointer")); + } + token + .parse() + .map_err(|_| primitive_error("pointer_out_of_bounds")) +} + +fn parse_date(input: &str) -> Result> { + CalendarDate::parse(input).map_err(|_| primitive_error("invalid_date")) +} + +fn parse_instant(input: &str) -> Result> { + UtcInstant::parse(input).map_err(|_| primitive_error("invalid_instant")) +} + +fn parse_decimal(input: &str) -> Result> { + Decimal::parse(input).map_err(|_| primitive_error("invalid_decimal")) +} + +fn integer_to_decimal(value: INT) -> Decimal { + Decimal::from_integer(value) +} + +fn add_calendar_years(date: CalendarDate, years: INT) -> Result> { + if !(-1_000..=1_000).contains(&years) { + return Err(primitive_error("calendar_years_out_of_bounds")); + } + let months = years + .checked_mul(12) + .ok_or_else(|| primitive_error("calendar_years_out_of_bounds"))?; + add_calendar_months(date, months) +} + +fn add_calendar_months( + date: CalendarDate, + months: INT, +) -> Result> { + if !(-12_000..=12_000).contains(&months) { + return Err(primitive_error("calendar_months_out_of_bounds")); + } + let month_index = i64::from(date.0.year()) + .checked_mul(12) + .and_then(|value| value.checked_add(i64::from(date.0.month0()))) + .and_then(|value| value.checked_add(months)) + .ok_or_else(|| primitive_error("invalid_calendar_result"))?; + let year = i32::try_from(month_index.div_euclid(12)) + .map_err(|_| primitive_error("invalid_calendar_result"))?; + let month = u32::try_from(month_index.rem_euclid(12) + 1) + .map_err(|_| primitive_error("invalid_calendar_result"))?; + let day = date.0.day().min(last_day_of_month(year, month)?); + NaiveDate::from_ymd_opt(year, month, day) + .map(CalendarDate) + .ok_or_else(|| primitive_error("invalid_calendar_result")) +} + +fn compare_dates(left: CalendarDate, right: CalendarDate) -> INT { + ordering_value(left.cmp(&right)) +} + +fn compare_instants(left: UtcInstant, right: UtcInstant) -> INT { + ordering_value(left.cmp(&right)) +} + +fn days_between(first: CalendarDate, second: CalendarDate) -> Result> { + let days = second.0.signed_duration_since(first.0).num_days(); + if !(-365_000..=365_000).contains(&days) { + return Err(primitive_error("calendar_days_out_of_bounds")); + } + Ok(days) +} + +fn compare_decimals(left: Decimal, right: Decimal) -> INT { + ordering_value(left.compare(&right)) +} + +fn bucket_number(value: Decimal, boundaries: Array) -> Result> { + if boundaries.is_empty() || boundaries.len() > MAXIMUM_BUCKETS { + return Err(primitive_error("invalid_numeric_buckets")); + } + + let mut parsed = Vec::with_capacity(boundaries.len()); + let mut codes = BTreeSet::new(); + for boundary in boundaries { + let map = boundary + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + if !has_exact_keys(&map, &["minimumInclusive", "maximumExclusive", "code"]) { + return Err(primitive_error("invalid_numeric_buckets")); + } + let minimum = map["minimumInclusive"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + let maximum = map["maximumExclusive"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + let code = map["code"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + if minimum.compare(&maximum) != Ordering::Less + || code.is_empty() + || code.len() > MAXIMUM_STRING_BYTES + || !codes.insert(code.to_string()) + { + return Err(primitive_error("invalid_numeric_buckets")); + } + if parsed.last().is_some_and( + |(_, previous_maximum, _): &(Decimal, Decimal, ImmutableString)| { + previous_maximum.compare(&minimum) != Ordering::Equal + }, + ) { + return Err(primitive_error("invalid_numeric_buckets")); + } + parsed.push((minimum, maximum, code)); + } + + parsed + .into_iter() + .find(|(minimum, maximum, _)| { + value.compare(minimum) != Ordering::Less && value.compare(maximum) == Ordering::Less + }) + .map(|(_, _, code)| code) + .ok_or_else(|| primitive_error("number_outside_bucket_range")) +} + +fn entity_reference_seed(input: &str) -> Result> { + EntityReferenceSeed::new(input).map_err(|_| primitive_error("invalid_entity_reference_seed")) +} + +fn codelist_lookup(handle: CodelistHandle, code: &str) -> Dynamic { + handle + .entries + .get(code) + .cloned() + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) +} + +fn list_contains(values: Array, needle: Dynamic) -> Result> { + let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; + let values = bounded_scalar_values(&values)?; + Ok(values.contains(&needle)) +} + +fn set_contains(values: Array, needle: Dynamic) -> Result> { + let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; + let values = bounded_scalar_values(&values)?; + let mut unique = BTreeSet::new(); + for value in values { + if !unique.insert(value) { + return Err(primitive_error("set_contains_duplicate")); + } + } + Ok(unique.contains(&needle)) +} + +/// Validates the whole bounded collection before any containment answer exists, so a +/// value that violates the declared `array` input fails even when an earlier +/// item already matches. +fn bounded_scalar_values(values: &Array) -> Result, Box> { + if values.len() > MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + values + .iter() + .map(|value| scalar_value(value).ok_or_else(|| primitive_error("invalid_scalar"))) + .collect() +} + +fn bounded_array_push(array: &mut Array, value: Dynamic) -> Result<(), Box> { + if array.len() >= MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + array.push(value); + Ok(()) +} + +fn literal_string_replace( + value: &mut ImmutableString, + from: &str, + to: &str, +) -> Result<(), Box> { + let occurrences = if from.is_empty() { + value.chars().count().saturating_add(1) + } else { + value.match_indices(from).count() + }; + let retained = if from.is_empty() { + value.len() + } else { + value + .len() + .checked_sub( + occurrences + .checked_mul(from.len()) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?, + ) + .ok_or_else(|| primitive_error("string_out_of_bounds"))? + }; + let output_len = retained + .checked_add( + occurrences + .checked_mul(to.len()) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?, + ) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?; + if output_len > MAXIMUM_STRING_BYTES { + return Err(primitive_error("string_out_of_bounds")); + } + *value = value.replace(from, to).into(); + Ok(()) +} + +fn parse_integer(value: &str) -> Result> { + let digits = value.strip_prefix('-').unwrap_or(value); + if digits.is_empty() + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + || value.starts_with('+') + { + return Err(primitive_error("invalid_integer")); + } + value + .parse::() + .map_err(|_| primitive_error("invalid_integer")) +} + +/// Returns the value, or terminates the invocation with the host-private unavailable +/// signal. +/// +/// The second argument is validated as a safe shape and then deliberately discarded. +/// Shape validation cannot prove that a code is a reviewed bundle literal rather than a +/// value derived from protected source data, so carrying it into any observable failure +/// would open a disclosure channel. Every unavailable termination therefore collapses to +/// the same value-free class in public problems, audit, and service logs. +fn required(value: Dynamic, error_code: &str) -> Result> { + if !is_safe_error_code(error_code) { + return Err(primitive_error("invalid_required_error_code")); + } + if value.is_unit() { + return Err(EvalAltResult::ErrorRuntime( + Dynamic::from(RequiredUnavailable), + rhai::Position::NONE, + ) + .into()); + } + Ok(value) +} + +#[derive(Clone, Copy)] +enum ScriptStage { + Preparation, + Extraction, + Derivation, +} + +fn classify_invocation_error(error: Box, stage: ScriptStage) -> RhaiRuntimeError { + if contains_unavailable_signal(&error) { + RhaiRuntimeError::Unavailable + } else { + match stage { + ScriptStage::Preparation if contains_runtime_signal(&error, "adapter_input_error") => { + RhaiRuntimeError::AdapterInput + } + ScriptStage::Extraction if contains_runtime_signal(&error, "source_protocol_error") => { + RhaiRuntimeError::SourceProtocol + } + ScriptStage::Derivation + if contains_runtime_signal(&error, "derivation_input_error") => + { + RhaiRuntimeError::DerivationInput + } + _ => RhaiRuntimeError::Invocation, + } + } +} + +fn contains_unavailable_signal(error: &EvalAltResult) -> bool { + match error { + EvalAltResult::ErrorRuntime(value, _) => value.is::(), + EvalAltResult::ErrorInFunctionCall(_, _, inner, _) + | EvalAltResult::ErrorInModule(_, inner, _) => contains_unavailable_signal(inner), + _ => false, + } +} + +fn contains_runtime_signal(error: &EvalAltResult, expected: &str) -> bool { + match error { + EvalAltResult::ErrorRuntime(value, _) => value + .clone() + .try_cast::() + .is_some_and(|signal| signal.as_str() == expected), + EvalAltResult::ErrorInFunctionCall(_, _, inner, _) + | EvalAltResult::ErrorInModule(_, inner, _) => contains_runtime_signal(inner, expected), + _ => false, + } +} + +fn is_missing(value: Dynamic) -> bool { + value.is_unit() +} + +fn decode_lookup_result( + result: Dynamic, + fact_schema: &V, +) -> Result +where + V: FactSchemaValidator + ?Sized, +{ + let map = result + .try_cast::() + .ok_or(RhaiRuntimeError::ExtractionResult)?; + let outcome = map + .get("outcome") + .and_then(|value| value.clone().try_cast::()) + .ok_or(RhaiRuntimeError::ExtractionResult)?; + match outcome.as_str() { + "no_match" if has_exact_keys(&map, &["outcome"]) => Ok(LookupResult::NoMatch), + "ambiguous" if has_exact_keys(&map, &["outcome"]) => Ok(LookupResult::Ambiguous), + "match" if has_exact_keys(&map, &["outcome", "facts"]) => { + let facts_dynamic = map.get("facts").ok_or(RhaiRuntimeError::ExtractionResult)?; + if !dynamic_is_json(facts_dynamic, FloatAdmission::AdapterSurface) { + return Err(RhaiRuntimeError::ExtractionResult); + } + let facts: Value = rhai::serde::from_dynamic(facts_dynamic) + .map_err(|_| RhaiRuntimeError::ExtractionResult)?; + let object = facts + .as_object() + .ok_or(RhaiRuntimeError::ExtractionResult)?; + if object.len() > MAXIMUM_FACT_ENTRIES { + return Err(RhaiRuntimeError::ExtractionResult); + } + validate_json_bound(&facts, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::ExtractionResult)?; + if !fact_schema.is_valid(&facts) { + return Err(RhaiRuntimeError::FactSchema); + } + Ok(LookupResult::Match( + object + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + )) + } + _ => Err(RhaiRuntimeError::ExtractionResult), + } +} + +fn decode_derivation_result(result: Dynamic) -> Result, RhaiRuntimeError> { + let array = result + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)?; + if array.is_empty() || array.len() > MAXIMUM_CONCEPT_VALUES { + return Err(RhaiRuntimeError::DerivationResult); + } + let mut result = Vec::with_capacity(array.len()); + let mut identifiers = BTreeSet::new(); + let mut total_bytes = 2usize; + for item in array { + let map = item + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)?; + if !has_exact_keys(&map, &["concept_id", "value"]) { + return Err(RhaiRuntimeError::DerivationResult); + } + let concept_id = map["concept_id"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)? + .to_string(); + if concept_id.is_empty() + || concept_id.len() > MAXIMUM_STRING_BYTES + || !identifiers.insert(concept_id.clone()) + { + return Err(RhaiRuntimeError::DerivationResult); + } + let value = decode_derived_value(map["value"].clone())?; + let encoded_identifier = serde_json::to_vec(&concept_id) + .map_err(|_| RhaiRuntimeError::DerivationResult)? + .len(); + total_bytes = total_bytes + .checked_add(encoded_identifier) + .and_then(|total| total.checked_add(derived_value_size(&value).ok()?)) + .and_then(|total| total.checked_add(29)) + .ok_or(RhaiRuntimeError::DerivationResult)?; + if total_bytes > MAXIMUM_RESULT_BYTES { + return Err(RhaiRuntimeError::DerivationResult); + } + result.push(DerivedConceptValue { concept_id, value }); + } + Ok(result) +} + +fn decode_derived_value(value: Dynamic) -> Result { + if value.is::() { + return Ok(DerivedValue::Decimal(value.cast::())); + } + if value.is::() { + return Ok(DerivedValue::EntityReferenceSeed( + value.cast::(), + )); + } + if value.is_array() { + let array = value.clone_cast::(); + if array.iter().any(Dynamic::is::) { + if array.is_empty() + || array.len() > MAXIMUM_ENTITY_REFERENCE_ITEMS + || !array.iter().all(Dynamic::is::) + { + return Err(RhaiRuntimeError::DerivationResult); + } + return Ok(DerivedValue::EntityReferenceSeedList( + array + .into_iter() + .map(Dynamic::cast::) + .collect(), + )); + } + } + if !dynamic_is_json(&value, FloatAdmission::Rejected) { + return Err(RhaiRuntimeError::DerivationResult); + } + let json = rhai::serde::from_dynamic::(&value) + .map_err(|_| RhaiRuntimeError::DerivationResult)?; + validate_json_bound(&json, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::DerivationResult)?; + Ok(DerivedValue::Json(json)) +} + +/// Reviews the reviewed-language surface and returns the source with every index +/// operand routed through the host-owned index guard. The raw engine remains the +/// capability boundary; this lexical review is an enforceable language contract over +/// governed bundle scripts and is not claimed as a sandbox perimeter. +/// +/// This scanner mirrors the pinned `rhai` lexer's operand-position and +/// brace-disambiguation rules. Bumping the `rhai` dependency requires +/// re-validating this scanner against the new lexer: over-acceptance only weakens +/// a defense-in-depth guard over trusted scripts (the runtime negative-index guard +/// still holds), while over-rejection breaks a valid adapter and is caught by the +/// scanner and fixture tests. +fn guarded_script_source(source: &str) -> Result { + validate_top_level_functions(source)?; + let mut insertions = review_script_bytes(source)?; + insertions.sort_by_key(|(offset, _)| *offset); + let mut guarded = String::with_capacity(source.len()); + let mut copied = 0usize; + for (offset, guard) in insertions { + guarded.push_str(&source[copied..offset]); + match guard { + IndexGuard::Open => { + guarded.push_str(INDEX_GUARD_FUNCTION); + guarded.push('('); + } + IndexGuard::Close => guarded.push(')'), + } + copied = offset; + } + guarded.push_str(&source[copied..]); + Ok(guarded) +} + +fn validate_top_level_functions(source: &str) -> Result<(), RhaiRuntimeError> { + let mut cursor = ScriptCursor::new(source); + while cursor.skip_trivia()? { + let _private = cursor.consume_word("private"); + cursor.skip_trivia()?; + if !cursor.consume_word("fn") { + return Err(RhaiRuntimeError::Compilation); + } + cursor.skip_trivia()?; + if !cursor.consume_identifier() { + return Err(RhaiRuntimeError::Compilation); + } + cursor.skip_trivia()?; + cursor.consume_balanced(b'(', b')')?; + cursor.skip_trivia()?; + cursor.consume_balanced(b'{', b'}')?; + } + Ok(()) +} + +/// Walks the script the way Rhai 1.25.1 tokenizes it, rejects the forbidden constructs, +/// and reports where the index guard must wrap an index operand. +fn review_script_bytes(source: &str) -> Result, RhaiRuntimeError> { + let mut cursor = ScriptCursor::new(source); + let mut insertions = Vec::new(); + let mut braces = Vec::new(); + let mut previous_significant = None; + let mut previous_ends_value = false; + while cursor.index < cursor.bytes.len() { + if cursor.skip_comment()? { + continue; + } + let byte = cursor.bytes[cursor.index]; + if byte.is_ascii_whitespace() { + cursor.index += 1; + continue; + } + match byte { + b'"' | b'\'' => { + cursor.skip_quoted()?; + previous_significant = Some(byte); + previous_ends_value = true; + } + b'`' => return Err(RhaiRuntimeError::Compilation), + b'#' => { + cursor.consume_map_literal_start()?; + braces.push(BraceKind::MapLiteral); + previous_significant = Some(b'{'); + previous_ends_value = false; + } + b'{' => { + if expects_operand(previous_significant) { + return Err(RhaiRuntimeError::Compilation); + } + cursor.index += 1; + braces.push(BraceKind::Block); + previous_significant = Some(b'{'); + previous_ends_value = false; + } + b'}' => { + let kind = braces.pop().ok_or(RhaiRuntimeError::Compilation)?; + cursor.index += 1; + previous_significant = Some(b'}'); + previous_ends_value = kind == BraceKind::MapLiteral; + } + b'[' if previous_ends_value => { + let close = cursor.matching_bracket()?; + cursor.index += 1; + if cursor.negative_literal_follows()? { + return Err(RhaiRuntimeError::Compilation); + } + insertions.push((cursor.index, IndexGuard::Open)); + insertions.push((close, IndexGuard::Close)); + previous_significant = Some(b'['); + previous_ends_value = false; + } + _ if is_identifier_start(byte) => { + let word = cursor.take_identifier(); + if word == "Fn" + || word == INDEX_GUARD_FUNCTION + || matches!(word, "call" | "curry") && previous_significant == Some(b'.') + || word == "if" && expects_operand(previous_significant) + { + return Err(RhaiRuntimeError::Compilation); + } + previous_significant = word.as_bytes().last().copied(); + previous_ends_value = !keyword_precedes_value(word); + } + _ => { + cursor.index += 1; + previous_significant = Some(byte); + previous_ends_value = matches!(byte, b')' | b']') || byte.is_ascii_digit(); + } + } + } + if !braces.is_empty() { + return Err(RhaiRuntimeError::Compilation); + } + Ok(insertions) +} + +/// Whether the previous significant byte leaves an expression waiting for an operand. +/// +/// Rhai indexes a block or an `if` chain that sits in operand position, so `}` there ends +/// a value while `}` at statement position does not. A byte scanner cannot tell those two +/// closing braces apart, and a missing guard would restore negative indexing. Both forms +/// are therefore outside the reviewed language, which keeps every remaining `[` exactly +/// classifiable. +fn expects_operand(previous_significant: Option) -> bool { + matches!( + previous_significant, + Some( + b'=' | b'(' + | b',' + | b':' + | b'[' + | b'+' + | b'-' + | b'*' + | b'/' + | b'%' + | b'<' + | b'>' + | b'!' + | b'&' + | b'|' + | b'^' + | b'?' + ) + ) +} + +/// Reserved words after which `[` opens an array literal instead of indexing a value. +fn keyword_precedes_value(word: &str) -> bool { + matches!( + word, + "as" | "break" + | "case" + | "catch" + | "const" + | "continue" + | "do" + | "else" + | "export" + | "fn" + | "for" + | "if" + | "import" + | "in" + | "let" + | "loop" + | "private" + | "return" + | "switch" + | "throw" + | "try" + | "until" + | "while" + ) +} + +#[derive(Clone, Copy)] +enum IndexGuard { + Open, + Close, +} + +/// Whether a closing brace ends a map literal, which is a value, or a block, which is +/// not. The distinction decides whether a following `[` indexes or opens an array. +#[derive(Clone, Copy, PartialEq, Eq)] +enum BraceKind { + Block, + MapLiteral, +} + +/// Rejects a negative array index however it was computed, because Rhai counts a +/// negative index from the end of the array. +fn guard_index(index: INT) -> Result> { + if index < 0 { + return Err(primitive_error("negative_index")); + } + Ok(index) +} + +fn guard_index_key(key: ImmutableString) -> ImmutableString { + key +} + +struct ScriptCursor<'a> { + source: &'a str, + bytes: &'a [u8], + index: usize, +} + +impl<'a> ScriptCursor<'a> { + fn new(source: &'a str) -> Self { + Self { + source, + bytes: source.as_bytes(), + index: 0, + } + } + + fn skip_trivia(&mut self) -> Result { + loop { + while self + .bytes + .get(self.index) + .is_some_and(u8::is_ascii_whitespace) + { + self.index += 1; + } + if !self.skip_comment()? { + return Ok(self.index < self.bytes.len()); + } + } + } + + fn skip_noise(&mut self) -> Result { + if self.skip_comment()? { + return Ok(true); + } + match self.bytes.get(self.index).copied() { + Some(b'"' | b'\'') => { + self.skip_quoted()?; + Ok(true) + } + Some(b'`') => Err(RhaiRuntimeError::Compilation), + Some(b'#') => { + self.check_map_literal_start()?; + Ok(false) + } + _ => Ok(false), + } + } + + /// Rhai 1.25.1 reads `#"..."#` and `##"..."##` as raw strings, whose bodies may + /// contain quotes. Only `#{` stays inside the reviewed language, so every other `#` + /// fails rather than letting this scanner and the Rhai tokenizer disagree about + /// where a string ends. + fn check_map_literal_start(&self) -> Result<(), RhaiRuntimeError> { + if self.bytes.get(self.index + 1) != Some(&b'{') { + return Err(RhaiRuntimeError::Compilation); + } + Ok(()) + } + + fn consume_map_literal_start(&mut self) -> Result<(), RhaiRuntimeError> { + self.check_map_literal_start()?; + self.index += 2; + Ok(()) + } + + /// Position of the `]` that closes the bracket at the cursor. + fn matching_bracket(&self) -> Result { + let mut cursor = ScriptCursor { + source: self.source, + bytes: self.bytes, + index: self.index, + }; + let mut depth = 0usize; + while cursor.index < cursor.bytes.len() { + if cursor.skip_noise()? { + continue; + } + match cursor.bytes[cursor.index] { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + return Ok(cursor.index); + } + } + _ => {} + } + cursor.index += 1; + } + Err(RhaiRuntimeError::Compilation) + } + + /// Whether the expression at the cursor opens with a negative numeric literal. + fn negative_literal_follows(&self) -> Result { + let mut lookahead = ScriptCursor { + source: self.source, + bytes: self.bytes, + index: self.index, + }; + lookahead.skip_trivia()?; + if lookahead.bytes.get(lookahead.index) != Some(&b'-') { + return Ok(false); + } + lookahead.index += 1; + lookahead.skip_trivia()?; + Ok(lookahead + .bytes + .get(lookahead.index) + .is_some_and(u8::is_ascii_digit)) + } + + fn take_identifier(&mut self) -> &'a str { + let start = self.index; + self.index += 1; + while self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + self.index += 1; + } + &self.source[start..self.index] + } + + fn skip_comment(&mut self) -> Result { + if self.bytes.get(self.index..self.index + 2) == Some(b"//") { + self.index += 2; + while self.index < self.bytes.len() && self.bytes[self.index] != b'\n' { + self.index += 1; + } + return Ok(true); + } + if self.bytes.get(self.index..self.index + 2) == Some(b"/*") { + self.index += 2; + while self.index + 1 < self.bytes.len() + && self.bytes.get(self.index..self.index + 2) != Some(b"*/") + { + self.index += 1; + } + if self.bytes.get(self.index..self.index + 2) != Some(b"*/") { + return Err(RhaiRuntimeError::Compilation); + } + self.index += 2; + return Ok(true); + } + Ok(false) + } + + fn skip_quoted(&mut self) -> Result<(), RhaiRuntimeError> { + let quote = self.bytes[self.index]; + self.index += 1; + while self.index < self.bytes.len() { + match self.bytes[self.index] { + b'\\' => self.index = self.index.saturating_add(2), + byte if byte == quote => { + self.index += 1; + return Ok(()); + } + _ => self.index += 1, + } + } + Err(RhaiRuntimeError::Compilation) + } + + fn consume_word(&mut self, expected: &str) -> bool { + let remaining = &self.source[self.index..]; + if !remaining.starts_with(expected) { + return false; + } + let end = self.index + expected.len(); + if self + .bytes + .get(end) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + return false; + } + self.index = end; + true + } + + fn consume_identifier(&mut self) -> bool { + if !self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_start(*byte)) + { + return false; + } + self.index += 1; + while self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + self.index += 1; + } + true + } + + fn consume_balanced(&mut self, open: u8, close: u8) -> Result<(), RhaiRuntimeError> { + if self.bytes.get(self.index) != Some(&open) { + return Err(RhaiRuntimeError::Compilation); + } + let mut depth = 0usize; + while self.index < self.bytes.len() { + if self.skip_noise()? { + continue; + } + match self.bytes[self.index] { + byte if byte == open => depth += 1, + byte if byte == close => { + depth -= 1; + self.index += 1; + if depth == 0 { + return Ok(()); + } + continue; + } + _ => {} + } + self.index += 1; + } + Err(RhaiRuntimeError::Compilation) + } +} + +fn is_identifier_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' +} + +fn is_identifier_continue(byte: u8) -> bool { + is_identifier_start(byte) || byte.is_ascii_digit() +} + +fn validate_adapter_inputs(selectors: &Value, parameters: &Value) -> Result<(), RhaiRuntimeError> { + validate_adapter_object(selectors)?; + validate_adapter_object(parameters)?; + let combined = Value::Array(vec![selectors.clone(), parameters.clone()]); + let size = serde_json::to_vec(&combined) + .map_err(|_| RhaiRuntimeError::AdapterInput)? + .len(); + if size > MAXIMUM_PREPARATION_INPUT_BYTES { + return Err(RhaiRuntimeError::InputBound); + } + Ok(()) +} + +fn validate_adapter_object(value: &Value) -> Result<(), RhaiRuntimeError> { + if !value.is_object() || !adapter_value_is_supported(value) { + return Err(RhaiRuntimeError::AdapterInput); + } + Ok(()) +} + +fn adapter_value_is_supported(value: &Value) -> bool { + match value { + Value::Bool(_) => true, + Value::Number(value) => value.as_i64().is_some(), + Value::String(value) => value.len() <= MAXIMUM_STRING_BYTES, + Value::Array(values) => { + values.len() <= MAXIMUM_ARRAY_ITEMS && values.iter().all(adapter_value_is_supported) + } + Value::Object(values) => { + values.len() <= MAXIMUM_MAP_ENTRIES + && !is_typed_derivation_envelope(values) + && values.iter().all(|(name, value)| { + name.len() <= MAXIMUM_STRING_BYTES && adapter_value_is_supported(value) + }) + } + Value::Null => false, + } +} + +fn adapter_object_to_dynamic(value: &Value) -> Result { + let object = value.as_object().ok_or(RhaiRuntimeError::AdapterInput)?; + object + .iter() + .map(|(name, value)| Ok((name.as_str().into(), adapter_value_to_dynamic(value)?))) + .collect::>() + .map(Dynamic::from) +} + +fn adapter_value_to_dynamic(value: &Value) -> Result { + match value { + Value::Bool(value) => Ok(Dynamic::from(*value)), + Value::Number(value) => value + .as_i64() + .map(Dynamic::from) + .ok_or(RhaiRuntimeError::AdapterInput), + Value::String(value) if value.len() <= MAXIMUM_STRING_BYTES => { + Ok(Dynamic::from(value.clone())) + } + Value::Array(values) if values.len() <= MAXIMUM_ARRAY_ITEMS => values + .iter() + .map(adapter_value_to_dynamic) + .collect::>() + .map(Dynamic::from), + Value::Object(values) + if values.len() <= MAXIMUM_MAP_ENTRIES && !is_typed_derivation_envelope(values) => + { + values + .iter() + .map(|(name, value)| { + if name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::AdapterInput); + } + Ok((name.as_str().into(), adapter_value_to_dynamic(value)?)) + }) + .collect::>() + .map(Dynamic::from) + } + _ => Err(RhaiRuntimeError::AdapterInput), + } +} + +fn is_typed_derivation_envelope(values: &serde_json::Map) -> bool { + values.len() == 2 + && values.get("type") == Some(&Value::String("decimal".to_string())) + && values.get("value").is_some_and(Value::is_string) +} + +fn decode_request_parts( + result: Dynamic, + limits: &RequestPartsLimits, +) -> Result { + let map = result + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if !has_exact_keys(&map, &["query", "body"]) { + return Err(RhaiRuntimeError::PreparationResult); + } + + let query = map["query"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if query.len() > limits.maximum_query_pairs || query.len() > MAXIMUM_QUERY_PAIRS { + return Err(RhaiRuntimeError::PreparationResult); + } + let query = query + .into_iter() + .map(|pair| { + let pair = pair + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if !has_exact_keys(&pair, &["name", "value"]) { + return Err(RhaiRuntimeError::PreparationResult); + } + let name = pair["name"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)? + .to_string(); + let value = pair["value"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)? + .to_string(); + if name.is_empty() + || name.len() > limits.maximum_query_name_bytes + || name.len() > MAXIMUM_QUERY_NAME_BYTES + || name.len() > limits.maximum_string_bytes + || value.len() > limits.maximum_query_value_bytes + || value.len() > MAXIMUM_QUERY_VALUE_BYTES + || value.len() > limits.maximum_string_bytes + || name.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + || value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(QueryPair { name, value }) + }) + .collect::, RhaiRuntimeError>>()?; + + validate_part_requirement(limits.query, !query.is_empty())?; + let body = if map["body"].is_unit() { + None + } else { + if !dynamic_is_json(&map["body"], FloatAdmission::AdapterSurface) { + return Err(RhaiRuntimeError::PreparationResult); + } + let body = rhai::serde::from_dynamic::(&map["body"]) + .map_err(|_| RhaiRuntimeError::PreparationResult)?; + if !json_numbers_are_supported(&body) + || !json_value_within_limits( + &body, + limits.maximum_json_depth, + limits.maximum_collection_items, + limits.maximum_string_bytes, + ) + { + return Err(RhaiRuntimeError::PreparationResult); + } + Some(body) + }; + validate_part_requirement(limits.body, body.is_some())?; + + let normalized = Value::Object(serde_json::Map::from_iter([ + ("body".to_string(), body.clone().unwrap_or(Value::Null)), + ( + "query".to_string(), + Value::Array( + query + .iter() + .map(|pair| { + Value::Object(serde_json::Map::from_iter([ + ("name".to_string(), Value::String(pair.name.clone())), + ("value".to_string(), Value::String(pair.value.clone())), + ])) + }) + .collect(), + ), + ), + ])); + let normalized_size = serde_json::to_vec(&normalized) + .map_err(|_| RhaiRuntimeError::PreparationResult)? + .len(); + if normalized_size > limits.maximum_normalized_bytes + || normalized_size > MAXIMUM_REQUEST_PARTS_BYTES + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(RequestParts { query, body }) +} + +fn validate_part_requirement( + requirement: RequestPartRequirement, + present: bool, +) -> Result<(), RhaiRuntimeError> { + match (requirement, present) { + (RequestPartRequirement::Forbidden, true) | (RequestPartRequirement::Required, false) => { + Err(RhaiRuntimeError::PreparationResult) + } + _ => Ok(()), + } +} + +fn json_value_within_limits( + value: &Value, + maximum_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, +) -> bool { + fn visit( + value: &Value, + container_depth: usize, + maximum_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, + ) -> bool { + match value { + Value::String(value) => value.len() <= maximum_string_bytes, + Value::Array(values) => { + container_depth < maximum_depth + && values.len() <= maximum_collection_items + && values.iter().all(|value| { + visit( + value, + container_depth + 1, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) + }) + } + Value::Object(values) => { + container_depth < maximum_depth + && values.len() <= maximum_collection_items + && values.iter().all(|(name, value)| { + name.len() <= maximum_string_bytes + && visit( + value, + container_depth + 1, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) + }) + } + _ => true, + } + } + visit( + value, + 0, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) +} + +/// Numeric admission for JSON decoded into Rhai. An integer token outside the signed +/// 64-bit range fails here instead of reaching a script as a precision-losing float; a +/// provider identifier beyond that range must be represented as a string. +fn json_numbers_are_supported(value: &Value) -> bool { + match value { + Value::Number(value) => value.is_i64() || value.as_f64().is_some_and(is_supported_float), + Value::Array(values) => values.iter().all(json_numbers_are_supported), + Value::Object(values) => values.values().all(json_numbers_are_supported), + _ => true, + } +} + +/// An ordinary float is carried only when it is finite and its magnitude stays inside the +/// signed 64-bit integer range, which keeps every admitted number distinguishable from a +/// silently truncated large integer token. +fn is_supported_float(value: f64) -> bool { + value.is_finite() && value.abs() < INTEGER_MAGNITUDE_LIMIT +} + +/// Whether ordinary Rhai floats may appear in a decoded value. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FloatAdmission { + /// Request preparation and source extraction carry provider-shaped JSON, which may + /// contain finite ordinary floats. + AdapterSurface, + /// Public derived values use the declared integer or exact Decimal forms only. + Rejected, +} + +fn dynamic_is_json(value: &Dynamic, floats: FloatAdmission) -> bool { + if value.is_unit() || value.is_bool() || value.is_int() || value.is_string() { + return true; + } + if value.is_float() { + return floats == FloatAdmission::AdapterSurface + && value.as_float().is_ok_and(is_supported_float); + } + if value.is_array() { + return value + .clone_cast::() + .iter() + .all(|value| dynamic_is_json(value, floats)); + } + if value.is_map() { + return value + .clone_cast::() + .values() + .all(|value| dynamic_is_json(value, floats)); + } + false +} + +fn derived_value_size(value: &DerivedValue) -> Result { + match value { + DerivedValue::Json(value) => serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .map_err(|_| RhaiRuntimeError::DerivationResult), + DerivedValue::Decimal(value) => Ok(value.canonical().len()), + DerivedValue::EntityReferenceSeed(value) => Ok(value.expose_for_projection().len()), + DerivedValue::EntityReferenceSeedList(values) => { + values.iter().try_fold(0usize, |sum, value| { + sum.checked_add(value.expose_for_projection().len()) + .ok_or(RhaiRuntimeError::DerivationResult) + }) + } + } +} + +fn parameters_to_map(parameters: &Value) -> Result { + let object = parameters + .as_object() + .ok_or(RhaiRuntimeError::EvaluationContext)?; + if object.len() > MAXIMUM_MAP_ENTRIES { + return Err(RhaiRuntimeError::EvaluationContext); + } + object + .iter() + .map(|(name, value)| { + if name.is_empty() || name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok((name.as_str().into(), parameter_to_dynamic(value)?)) + }) + .collect() +} + +fn parameter_to_dynamic(value: &Value) -> Result { + match value { + Value::Bool(value) => Ok(Dynamic::from(*value)), + Value::Number(value) => value + .as_i64() + .map(Dynamic::from) + .ok_or(RhaiRuntimeError::EvaluationContext), + Value::String(value) if value.len() <= MAXIMUM_STRING_BYTES => { + Ok(Dynamic::from(value.clone())) + } + Value::Array(values) if values.len() <= MAXIMUM_ARRAY_ITEMS => values + .iter() + .map(parameter_to_dynamic) + .collect::>() + .map(Dynamic::from), + Value::Object(object) + if object.len() == 2 + && object.get("type") == Some(&Value::String("decimal".to_string())) + && object.get("value").is_some_and(Value::is_string) => + { + let text = object["value"] + .as_str() + .ok_or(RhaiRuntimeError::EvaluationContext)?; + Decimal::parse(text) + .map(Dynamic::from) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + Value::Object(object) if object.len() <= MAXIMUM_MAP_ENTRIES => object + .iter() + .map(|(name, value)| { + if name.is_empty() || name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok((name.as_str().into(), parameter_to_dynamic(value)?)) + }) + .collect::>() + .map(Dynamic::from), + _ => Err(RhaiRuntimeError::EvaluationContext), + } +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum ScalarValue { + Boolean(bool), + Integer(INT), + String(String), + Date(CalendarDate), + Instant(UtcInstant), + Decimal(String), +} + +impl fmt::Debug for ScalarValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let form = match self { + Self::Boolean(_) => "boolean", + Self::Integer(_) => "integer", + Self::String(_) => "string", + Self::Date(_) => "date", + Self::Instant(_) => "instant", + Self::Decimal(_) => "decimal", + }; + formatter + .debug_struct("ScalarValue") + .field("form", &form) + .field("value", &"[REDACTED]") + .finish() + } +} + +fn scalar_value(value: &Dynamic) -> Option { + if value.is_bool() { + return Some(ScalarValue::Boolean(value.as_bool().ok()?)); + } + if value.is_int() { + return Some(ScalarValue::Integer(value.as_int().ok()?)); + } + if value.is_string() { + return Some(ScalarValue::String( + value.clone_cast::().to_string(), + )); + } + if value.is::() { + return Some(ScalarValue::Date(value.clone_cast::())); + } + if value.is::() { + return Some(ScalarValue::Instant(value.clone_cast::())); + } + if value.is::() { + return Some(ScalarValue::Decimal( + value.clone_cast::().canonical().to_string(), + )); + } + None +} + +fn validate_json_bound( + value: &Value, + maximum_serialized_bytes: usize, +) -> Result<(), RhaiRuntimeError> { + let serialized = serde_json::to_vec(value).map_err(|_| RhaiRuntimeError::InputBound)?; + if serialized.len() > maximum_serialized_bytes || !json_collections_are_bounded(value) { + return Err(RhaiRuntimeError::InputBound); + } + Ok(()) +} + +fn json_collections_are_bounded(value: &Value) -> bool { + match value { + Value::String(value) => value.len() <= MAXIMUM_STRING_BYTES, + Value::Array(values) => { + values.len() <= MAXIMUM_ARRAY_ITEMS && values.iter().all(json_collections_are_bounded) + } + Value::Object(values) => { + values.len() <= MAXIMUM_MAP_ENTRIES + && values.iter().all(|(name, value)| { + name.len() <= MAXIMUM_STRING_BYTES && json_collections_are_bounded(value) + }) + } + _ => true, + } +} + +fn has_exact_keys(map: &Map, keys: &[&str]) -> bool { + map.len() == keys.len() && keys.iter().all(|key| map.contains_key(*key)) +} + +fn ordering_value(ordering: Ordering) -> INT { + match ordering { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + } +} + +fn last_day_of_month(year: i32, month: u32) -> Result> { + let (next_year, next_month) = if month == 12 { + (year.checked_add(1), 1) + } else { + (Some(year), month + 1) + }; + let next_year = next_year.ok_or_else(|| primitive_error("invalid_calendar_result"))?; + let first_next = NaiveDate::from_ymd_opt(next_year, next_month, 1) + .ok_or_else(|| primitive_error("invalid_calendar_result"))?; + Ok((first_next - Duration::days(1)).day()) +} + +fn is_canonical_date_text(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + +fn is_strict_rfc3339(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || !value.is_ascii() + || bytes.get(10) != Some(&b'T') + || !is_canonical_date_text(&value[..10]) + || bytes.get(13) != Some(&b':') + || bytes.get(16) != Some(&b':') + { + return false; + } + let hour = parse_two_digits(&bytes[11..13]); + let minute = parse_two_digits(&bytes[14..16]); + let second = parse_two_digits(&bytes[17..19]); + if !matches!(hour, Some(0..=23)) + || !matches!(minute, Some(0..=59)) + || !matches!(second, Some(0..=59)) + { + return false; + } + has_strict_fraction_and_offset(&value[19..]) +} + +fn is_strict_local_time(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 9 + || !value.is_ascii() + || bytes.get(2) != Some(&b':') + || bytes.get(5) != Some(&b':') + { + return false; + } + let hour = parse_two_digits(&bytes[0..2]); + let minute = parse_two_digits(&bytes[3..5]); + let second = parse_two_digits(&bytes[6..8]); + matches!(hour, Some(0..=23)) + && matches!(minute, Some(0..=59)) + && matches!(second, Some(0..=59)) + && has_strict_fraction_and_offset(&value[8..]) +} + +fn has_strict_fraction_and_offset(suffix: &str) -> bool { + let (fraction, offset) = if let Some(rest) = suffix.strip_prefix('.') { + let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digit_count == 0 || digit_count > 9 { + return false; + } + (&rest[..digit_count], &rest[digit_count..]) + } else { + ("", suffix) + }; + if !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + if offset == "Z" { + return true; + } + let bytes = offset.as_bytes(); + if bytes.len() != 6 || !matches!(bytes[0], b'+' | b'-') || bytes[3] != b':' { + return false; + } + matches!(parse_two_digits(&bytes[1..3]), Some(0..=23)) + && matches!(parse_two_digits(&bytes[4..6]), Some(0..=59)) +} + +fn parse_two_digits(value: &[u8]) -> Option { + if value.len() != 2 || !value.iter().all(u8::is_ascii_digit) { + return None; + } + Some((value[0] - b'0') * 10 + value[1] - b'0') +} + +fn is_safe_error_code(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAXIMUM_REQUIRED_CODE_BYTES + && bytes[0].is_ascii_lowercase() + && bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn primitive_error(code: &str) -> Box { + EvalAltResult::ErrorRuntime(code.into(), rhai::Position::NONE).into() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + const ADULT_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai" + )); + const ADULT_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai" + )); + const RESIDENCE_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai" + )); + const RESIDENCE_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai" + )); + const LICENCE_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai" + )); + const LICENCE_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai" + )); + const RELATIONSHIP_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai" + )); + const RELATIONSHIP_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai" + )); + + fn runtime() -> RhaiRuntime { + RhaiRuntime::new() + } + + fn context( + parameters: Value, + codelists: BTreeMap, + ) -> EvaluationContext { + EvaluationContext::new( + UtcInstant::parse("2026-08-02T00:00:00Z").expect("instant"), + CalendarDate::parse("2026-08-02").expect("date"), + LegalLocalTime::parse("07:00:00+07:00").expect("time"), + ¶meters, + codelists, + ) + .expect("context") + } + + fn request_limits( + query: RequestPartRequirement, + body: RequestPartRequirement, + ) -> RequestPartsLimits { + RequestPartsLimits::new( + query, + body, + RequestPartsBounds { + maximum_query_pairs: MAXIMUM_QUERY_PAIRS, + maximum_query_name_bytes: MAXIMUM_QUERY_NAME_BYTES, + maximum_query_value_bytes: MAXIMUM_QUERY_VALUE_BYTES, + maximum_json_depth: MAXIMUM_JSON_BODY_DEPTH, + maximum_collection_items: MAXIMUM_ARRAY_ITEMS, + maximum_string_bytes: MAXIMUM_STRING_BYTES, + maximum_normalized_bytes: MAXIMUM_REQUEST_PARTS_BYTES, + }, + ) + .expect("request limits") + } + + #[test] + fn engine_has_exact_normative_limits_and_disabled_module_syntax() { + let runtime = runtime(); + let engine = runtime.engine(); + assert_eq!(engine.max_operations(), MAXIMUM_OPERATIONS); + assert_eq!(engine.max_call_levels(), MAXIMUM_CALL_DEPTH); + assert_eq!(engine.max_expr_depth(), MAXIMUM_EXPRESSION_DEPTH); + assert_eq!(engine.max_function_expr_depth(), MAXIMUM_EXPRESSION_DEPTH); + assert_eq!(engine.max_modules(), MAXIMUM_MODULES); + assert_eq!(engine.max_string_size(), MAXIMUM_STRING_BYTES); + assert_eq!(engine.max_array_size(), MAXIMUM_ARRAY_ITEMS); + assert_eq!(engine.max_map_size(), MAXIMUM_MAP_ENTRIES); + assert!(engine.is_symbol_disabled("import")); + assert!(engine.is_symbol_disabled("export")); + assert!(engine.is_symbol_disabled("eval")); + assert!(engine.is_symbol_disabled("print")); + assert!(engine.is_symbol_disabled("debug")); + for symbol in [ + "while", "until", "loop", "do", "switch", "try", "catch", "..", "..=", "?.", "??", + ] { + assert!(engine.is_symbol_disabled(symbol), "{symbol}"); + } + assert!(!engine.allow_anonymous_fn()); + } + + #[test] + fn preparation_runs_named_helpers_with_fresh_isolated_inputs() { + let runtime = runtime(); + let script = runtime + .compile_preparation( + r#" + fn query_pair(name, value) { #{ name: name, value: value } } + fn prepare(selectors, parameters) { + let query = []; + query.push(query_pair("filter", selectors.subject.values.reference)); + query.push(query_pair("filter", parameters.status)); + let provider_name = parameters.provider_name; + provider_name.replace("_", "-"); + selectors.subject.values.reference = "mutated-only-locally"; + #{ + query: query, + body: #{ + provider: provider_name, + limit: parse_integer(parameters.limit) + } + } + } + "#, + ) + .expect("preparation compiles"); + let selectors = json!({ + "subject": {"profile": "reference-v1", "values": {"reference": "person-1"}} + }); + let parameters = json!({"status": "ACTIVE", "provider_name": "source_a", "limit": "02"}); + let limits = request_limits( + RequestPartRequirement::Required, + RequestPartRequirement::Required, + ); + for _ in 0..2 { + assert_eq!( + runtime + .prepare(&script, &selectors, ¶meters, &limits) + .expect("prepares"), + RequestParts { + query: vec![ + QueryPair { + name: "filter".to_string(), + value: "person-1".to_string(), + }, + QueryPair { + name: "filter".to_string(), + value: "ACTIVE".to_string(), + }, + ], + body: Some(json!({"limit": 2, "provider": "source-a"})), + } + ); + } + assert_eq!(selectors["subject"]["values"]["reference"], "person-1"); + assert_eq!(parameters["provider_name"], "source_a"); + } + + #[test] + fn preparation_inputs_and_request_parts_are_closed_and_bounded() { + let runtime = runtime(); + let passthrough = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: parameters.query, body: () } }", + ) + .expect("compiles"); + let limits = request_limits( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + ); + + for invalid in [ + json!({"value": null}), + json!({"value": 1.25}), + json!({"value": {"type": "decimal", "value": "1.25"}}), + ] { + assert_eq!( + runtime.prepare(&passthrough, &json!({}), &invalid, &limits), + Err(RhaiRuntimeError::AdapterInput) + ); + } + + for parameters in [ + json!({"query": [{"name": "", "value": "x"}]}), + json!({"query": [{"name": "x\r", "value": "y"}]}), + json!({"query": [{"name": "x", "value": "y\n"}]}), + json!({"query": [{"name": "x", "value": 1}]}), + ] { + assert!(matches!( + runtime.prepare(&passthrough, &json!({}), ¶meters, &limits), + Err(RhaiRuntimeError::AdapterInput | RhaiRuntimeError::PreparationResult) + )); + } + + let unknown = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: (), extra: true } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&unknown, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + for body in ["parse_date(\"2026-08-02\")", "'x'"] { + let source = + format!("fn prepare(selectors, parameters) {{ #{{ query: [], body: {body} }} }}"); + let script = runtime.compile_preparation(&source).expect("compiles"); + assert_eq!( + runtime.prepare(&script, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + } + let forbidden_query = request_limits( + RequestPartRequirement::Forbidden, + RequestPartRequirement::Optional, + ); + let query = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [#{name: \"x\", value: \"y\"}], body: () } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&query, &json!({}), &json!({}), &forbidden_query), + Err(RhaiRuntimeError::PreparationResult) + ); + + let strict = RequestPartsLimits::new( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + RequestPartsBounds { + maximum_query_pairs: 1, + maximum_query_name_bytes: 1, + maximum_query_value_bytes: 1, + maximum_json_depth: 1, + maximum_collection_items: 1, + maximum_string_bytes: 1, + maximum_normalized_bytes: 32, + }, + ) + .expect("strict limits"); + assert_eq!( + runtime.prepare(&query, &json!({}), &json!({}), &strict), + Err(RhaiRuntimeError::PreparationResult) + ); + } + + #[test] + fn compiler_rejects_every_forbidden_candidate_construct() { + let runtime = runtime(); + let bodies = [ + "while true {}", + "until true {}", + "loop {}", + "do {} while false", + "switch 1 { 1 => true, _ => false }", + "try { throw \"x\"; } catch (error) {}", + "let pointer = Fn(\"helper\");", + "let closure = |value| value;", + "let closure = || 1;", + "let text = `value=${selectors}`;", + "let value = [1, 2, 3][0..1];", + "let value = [1, 2, 3][-1];", + "let value = parameters?.value;", + "let value = parameters.value ?? 0;", + "let pointer = parameters.pointer; pointer.call();", + "let pointer = parameters.pointer; pointer.curry(1);", + ]; + for body in bodies { + let source = format!( + "fn prepare(selectors, parameters) {{ {body} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_err(), + "construct compiled: {body}" + ); + } + } + + #[test] + fn compiler_allows_unique_helpers_but_rejects_entry_overloads() { + let runtime = runtime(); + assert!(runtime + .compile_preparation( + "fn helper(value) { value } fn prepare(selectors, parameters) { #{query: [], body: helper(())} }" + ) + .is_ok()); + for source in [ + "fn prepare(a, b) { #{query: [], body: ()} } fn prepare(a) { a }", + "fn helper(a) { a } fn helper(a, b) { b } fn prepare(a, b) { #{query: [], body: ()} }", + "private fn prepare(a, b) { #{query: [], body: ()} }", + ] { + assert_eq!( + runtime.compile_preparation(source).unwrap_err(), + RhaiRuntimeError::EntryPoint + ); + } + } + + #[test] + fn strict_integer_parser_and_mutating_helpers_enforce_bounds() { + for (input, expected) in [("0", 0), ("-0", 0), ("0012", 12), ("-42", -42)] { + assert_eq!(parse_integer(input).expect("integer"), expected); + } + for invalid in [ + "", + "+1", + " 1", + "1 ", + "1.0", + "1e2", + "١", + "9223372036854775808", + "-9223372036854775809", + ] { + assert!(parse_integer(invalid).is_err(), "{invalid}"); + } + let mut full = vec![Dynamic::UNIT; MAXIMUM_ARRAY_ITEMS]; + assert!(bounded_array_push(&mut full, Dynamic::UNIT).is_err()); + let mut value: ImmutableString = "a".repeat(MAXIMUM_STRING_BYTES).into(); + assert!(literal_string_replace(&mut value, "", "x").is_err()); + } + + #[test] + fn compile_requires_one_exact_entry_point_and_rejects_top_level_statements() { + let runtime = runtime(); + assert_eq!( + runtime + .compile_extraction("fn derive(x) { x }") + .unwrap_err(), + RhaiRuntimeError::EntryPoint + ); + assert!(runtime + .compile_extraction("fn extract(x, parameters) { x } fn helper() {}") + .is_ok()); + + assert_eq!( + runtime + .compile_extraction( + r#" + throw("top_level_must_not_run"); + fn extract(source_response, parameters) { #{ outcome: "no_match" } } + "#, + ) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + } + + #[test] + fn ambient_and_diagnostic_capabilities_are_unavailable() { + let runtime = runtime(); + for forbidden in [ + "print(\"x\")", + "debug(\"x\")", + "eval(\"40 + 2\")", + "get_env(\"HOME\")", + "timestamp()", + ] { + let source = format!( + "fn extract(source_response, parameters) {{ {forbidden}; #{{ outcome: \"no_match\" }} }}" + ); + if let Ok(script) = runtime.compile_extraction(&source) { + assert_eq!( + runtime.extract(&script, &json!({}), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Invocation), + "{forbidden} must be unavailable" + ); + } + } + assert!(matches!( + runtime.compile_extraction( + "import \"outside\" as outside; fn extract(x, parameters) { #{ outcome: \"no_match\" } }" + ), + Err(RhaiRuntimeError::Compilation) + )); + } + + #[test] + fn extraction_decodes_only_the_closed_union_and_validates_facts() { + let runtime = runtime(); + let valid = runtime + .compile_extraction( + r#" + fn extract(source_response, parameters) { + #{ outcome: "match", facts: #{ code: source_response.code } } + } + "#, + ) + .expect("compiles"); + let schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": {"code": {"type": "string"}} + })) + .expect("schema"); + assert_eq!( + runtime.extract(&valid, &json!({"code": "A"}), &json!({}), &schema), + Ok(LookupResult::Match(BTreeMap::from([( + "code".to_string(), + json!("A") + )]))) + ); + assert_eq!( + runtime.extract(&valid, &json!({"code": 1}), &json!({}), &schema), + Err(RhaiRuntimeError::FactSchema) + ); + + for body in [ + "#{ outcome: \"no_match\", facts: #{} }", + "#{ outcome: \"ambiguous\", count: 2 }", + "#{ outcome: \"match\", facts: #{}, candidates: [] }", + "#{ outcome: \"unknown\" }", + ] { + let source = format!("fn extract(source_response, parameters) {{ {body} }}"); + let script = runtime.compile_extraction(&source).expect("compiles"); + assert_eq!( + runtime.extract(&script, &json!({}), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::ExtractionResult) + ); + } + } + + #[test] + fn extraction_source_input_uses_the_configured_one_mebibyte_boundary() { + fn sized_json_array(serialized_bytes: usize, items: usize) -> Value { + let payload_bytes = serialized_bytes + .checked_sub(1 + 3 * items) + .expect("serialized target fits array framing"); + let base = payload_bytes / items; + let remainder = payload_bytes % items; + let values = (0..items) + .map(|index| Value::String("x".repeat(base + usize::from(index < remainder)))) + .collect::>(); + let value = Value::Array(values); + assert_eq!( + serde_json::to_vec(&value).expect("serializes").len(), + serialized_bytes + ); + value + } + + let runtime = runtime(); + let script = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"no_match\" } }", + ) + .expect("compiles"); + for size in [MAXIMUM_RESULT_BYTES + 1, MAXIMUM_SOURCE_INPUT_BYTES] { + assert_eq!( + runtime.extract( + &script, + &sized_json_array(size, 64), + &json!({}), + &|_: &Value| true, + ), + Ok(LookupResult::NoMatch), + "source input of {size} bytes remains valid" + ); + } + assert_eq!( + runtime.extract( + &script, + &sized_json_array(MAXIMUM_SOURCE_INPUT_BYTES + 1, 64), + &json!({}), + &|_: &Value| true, + ), + Err(RhaiRuntimeError::InputBound) + ); + } + + #[test] + fn dates_instants_and_calendar_arithmetic_are_strict_and_bounded() { + assert!(CalendarDate::parse("2024-02-29").is_ok()); + for invalid in ["2023-02-29", "2026-8-02", "+2026-08-02", "2026-08-02Z"] { + assert!(CalendarDate::parse(invalid).is_err(), "{invalid}"); + } + assert!(UtcInstant::parse("2026-08-02T07:00:00+07:00").is_ok()); + for invalid in [ + "2026-08-02 00:00:00Z", + "2026-08-02T00:00:60Z", + "2026-08-02T00:00:00", + "2026-08-02T00:00:00.1234567890Z", + ] { + assert!(UtcInstant::parse(invalid).is_err(), "{invalid}"); + } + + let leap = CalendarDate::parse("2008-02-29").expect("date"); + assert_eq!( + add_calendar_years(leap, 18).expect("adds").as_naive_date(), + NaiveDate::from_ymd_opt(2026, 2, 28).expect("date") + ); + let month_end = CalendarDate::parse("2025-01-31").expect("date"); + assert_eq!( + add_calendar_months(month_end, 1) + .expect("adds") + .as_naive_date(), + NaiveDate::from_ymd_opt(2025, 2, 28).expect("date") + ); + assert!(add_calendar_years(leap, 1_001).is_err()); + assert!(days_between( + CalendarDate::parse("0001-01-01").expect("date"), + CalendarDate::parse("2001-01-01").expect("date") + ) + .is_err()); + } + + #[test] + fn candidate_numeric_string_and_opaque_type_surface_is_pinned() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let text = "source_adapter"; + text.replace("_", "-"); + [#{ + concept_id: "surface", + value: [ + 1.5 + 2.0 == 3.5, 1 + 2.5 == 3.5, 2.5 + 1 == 3.5, + 5.0 % 2.0 == 1.0, 2.0 ** 3.0 == 8.0, + 1.5 < 2.0, 1 < 2.0, 2.0 >= 1, + "ab" + "cd", "abc" - "b", text, + type_of(1.5), type_of(1), + type_of(parse_date("2026-08-02")), + type_of(parse_instant("2026-08-02T00:00:00Z")), + type_of(evaluation_context.legal_local_time), + type_of(decimal("1.25")), + type_of(entity_reference_seed("reference")), + type_of(evaluation_context.codelists["codes"]) + ] + }] + } + "#, + ) + .expect("compiles"); + let codelist = CodelistHandle::new(BTreeMap::from([("A".to_string(), "B".to_string())])) + .expect("codelist"); + let values = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context( + json!({}), + BTreeMap::from([("codes".to_string(), codelist.clone())]), + ), + ) + .expect("derives"); + assert!(matches!( + &values[0].value, + DerivedValue::Json(value) + if value == &json!([ + true, true, true, true, true, + true, true, true, + "abcd", "ac", "source-adapter", + "f64", "i64", + "Date", "Instant", "LegalLocalTime", "Decimal", + "EntityReferenceSeed", "CodelistHandle" + ]) + )); + + // The float arithmetic surface still exists inside a script, but an ordinary + // float result is not a public derived value. + let float_output = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"surface\", value: 1.5 + 2.0 }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &float_output, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::from([("codes".to_string(), codelist)])) + ), + Err(RhaiRuntimeError::DerivationResult) + )); + } + + #[test] + fn operation_exhaustion_terminates_a_hostile_script_with_a_value_free_error() { + let runtime = runtime(); + // Unbounded loop syntax is disabled, so exhaustion is driven by + // nesting bounded iteration over the largest admissible fact array: + // 256 * 256 iterations exceeds the 100,000-operation ceiling. + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let total = 0; + for outer in facts.items { + for inner in facts.items { + total += 1; + } + } + [#{ concept_id: "count", value: total }] + } + "#, + ) + .expect("compiles"); + // A small input proves the script itself is well-formed, so the + // failure below is the operation ceiling and nothing else. + let small = BTreeMap::from([("items".to_string(), json!([1, 2, 3, 4]))]); + let values = runtime + .derive( + &script, + &small, + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("the bounded variant derives"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!(16))); + + let items = (0..256).collect::>(); + let facts = BTreeMap::from([("items".to_string(), json!(items))]); + let error = runtime + .derive( + &script, + &facts, + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect_err("operation exhaustion terminates the invocation"); + assert!(matches!(error, RhaiRuntimeError::Invocation)); + let diagnostic = format!("{error} {error:?}"); + assert!(!diagnostic.contains("operations")); + assert!(!diagnostic.contains("256")); + } + + #[test] + fn exact_decimals_and_validated_contiguous_buckets_work() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let exact = decimal("1.25"); + let integer = integer_to_decimal(1); + [ + #{ concept_id: "decimal", value: exact }, + #{ concept_id: "comparison", value: compare_decimals(exact, integer) }, + #{ + concept_id: "bucket", + value: bucket_number(exact, evaluation_context.parameters.buckets) + } + ] + } + "#, + ) + .expect("compiles"); + let parameters = json!({ + "buckets": [ + { + "minimumInclusive": {"type": "decimal", "value": "0"}, + "maximumExclusive": {"type": "decimal", "value": "1"}, + "code": "low" + }, + { + "minimumInclusive": {"type": "decimal", "value": "1"}, + "maximumExclusive": {"type": "decimal", "value": "2"}, + "code": "high" + } + ] + }); + let values = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context(parameters, BTreeMap::new()), + ) + .expect("derives"); + assert!(matches!( + &values[0].value, + DerivedValue::Decimal(value) if value.canonical() == "1.25" + )); + assert!(matches!(&values[1].value, DerivedValue::Json(value) if value == &json!(1))); + assert!(matches!(&values[2].value, DerivedValue::Json(value) if value == &json!("high"))); + + let invalid = vec![boundary("0", "1", "a"), boundary("2", "3", "b")]; + assert!(bucket_number(Decimal::parse("1").expect("decimal"), invalid).is_err()); + } + + #[test] + fn derived_value_debug_redacts_every_value_carrier() { + let seed = + EntityReferenceSeed::new("entity-reference-debug-canary").expect("seed is valid"); + let values = [ + DerivedConceptValue { + concept_id: "urn:example:concept:string".to_owned(), + value: DerivedValue::Json(json!("json-debug-canary")), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:decimal".to_owned(), + value: DerivedValue::Decimal(Decimal::parse("8192.125").expect("decimal")), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:reference".to_owned(), + value: DerivedValue::EntityReferenceSeed(seed.clone()), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:references".to_owned(), + value: DerivedValue::EntityReferenceSeedList(vec![seed]), + }, + ]; + let diagnostic = format!("{values:?}"); + for canary in [ + "json-debug-canary", + "8192.125", + "entity-reference-debug-canary", + ] { + assert!(!diagnostic.contains(canary), "protected value leaked"); + } + assert!(diagnostic.contains("urn:example:concept:string")); + assert!(diagnostic.contains("form: \"string\"")); + assert!(diagnostic.contains("count: 1")); + + let scalars = [ + ScalarValue::String("scalar-debug-canary".to_owned()), + ScalarValue::Integer(8_192_125), + ScalarValue::Decimal("8192.125".to_owned()), + ]; + let diagnostic = format!("{scalars:?}"); + for canary in ["scalar-debug-canary", "8192125", "8192.125"] { + assert!(!diagnostic.contains(canary), "protected value leaked"); + } + } + + #[test] + fn request_parts_debug_redacts_query_and_body_values() { + let parts = RequestParts { + query: vec![QueryPair { + name: "query-name-debug-canary".to_owned(), + value: "query-value-debug-canary".to_owned(), + }], + body: Some(serde_json::json!({ + "body-name-debug-canary": "body-value-debug-canary" + })), + }; + + let diagnostic = format!("{parts:?}"); + for protected in [ + "query-name-debug-canary", + "query-value-debug-canary", + "body-name-debug-canary", + "body-value-debug-canary", + ] { + assert!( + !diagnostic.contains(protected), + "request parts debug leaked protected material" + ); + } + assert!(diagnostic.contains("query_pairs: 1")); + assert!(diagnostic.contains("body_present: true")); + } + + #[test] + fn codelist_required_missing_and_exact_collections_work() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup(evaluation_context.codelists["regions"], facts.code); + [ + #{ concept_id: "mapped", value: required(mapped, "unknown_code") }, + #{ concept_id: "missing", value: is_missing(facts.absent) }, + #{ concept_id: "list", value: list_contains([1, "1", true], "1") }, + #{ concept_id: "set", value: set_contains(["A", "B"], "B") } + ] + } + "#, + ) + .expect("compiles"); + let handle = + CodelistHandle::new(BTreeMap::from([("R-101".to_string(), "NORTH".to_string())])) + .expect("codelist"); + let values = runtime + .derive( + &script, + &BTreeMap::from([("code".to_string(), json!("R-101"))]), + &json!({}), + context(json!({}), BTreeMap::from([("regions".to_string(), handle)])), + ) + .expect("derives"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!("NORTH"))); + assert!(matches!(&values[1].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(matches!(&values[2].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(matches!(&values[3].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(set_contains( + vec![Dynamic::from("A"), Dynamic::from("A")], + Dynamic::from("A") + ) + .is_err()); + assert!(required(Dynamic::UNIT, "protected value").is_err()); + assert!(!is_missing(Dynamic::from(false))); + } + + #[test] + fn derivation_decode_is_closed_and_retains_protected_types() { + let runtime = runtime(); + let protected = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + [ + #{ concept_id: "one", value: entity_reference_seed(facts.seed) }, + #{ + concept_id: "many", + value: [entity_reference_seed("a"), entity_reference_seed("b")] + } + ] + } + "#, + ) + .expect("compiles"); + let values = runtime + .derive( + &protected, + &BTreeMap::from([("seed".to_string(), json!("protected-canary"))]), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("derives"); + assert!(matches!( + values[0].value, + DerivedValue::EntityReferenceSeed(_) + )); + assert!(matches!( + &values[1].value, + DerivedValue::EntityReferenceSeedList(values) if values.len() == 2 + )); + assert!(!format!("{:?}", values).contains("protected-canary")); + + for result in [ + "[#{ concept_id: \"x\", value: true, extra: false }]", + "[#{ concept_id: \"x\", value: true }, #{ concept_id: \"x\", value: false }]", + "#{ concept_id: \"x\", value: true }", + ] { + let source = format!("fn derive(facts, selectors, evaluation_context) {{ {result} }}"); + let script = runtime.compile_derivation(&source).expect("compiles"); + assert!(matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::DerivationResult) + )); + } + } + + #[test] + fn operation_limit_and_fresh_scope_fail_closed() { + let runtime = runtime(); + let runaway = runtime + .compile_derivation("fn derive(facts, selectors, evaluation_context) { while true {} }") + .unwrap_err(); + assert_eq!(runaway, RhaiRuntimeError::Compilation); + + let local_only = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let invocation_local = 1; + [#{ concept_id: "value", value: invocation_local }] + } + "#, + ) + .expect("compiles"); + for _ in 0..2 { + assert!(runtime + .derive( + &local_only, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ) + .is_ok()); + } + } + + #[test] + fn required_primitive_reports_closed_unavailability() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + [#{ concept_id: "urn:example:concept", value: required(facts.absent, "required_fact_missing") }] + }"#, + ) + .expect("script compiles"); + assert!(matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Unavailable) + )); + + let forged = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + throw "registry_evidence_required_unavailable"; + }"#, + ) + .expect("script compiles"); + assert!(matches!( + runtime.derive( + &forged, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )); + assert!(runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + try { required(facts.absent, "missing"); } catch (error) {} + [#{concept_id: "x", value: true}] + }"#, + ) + .is_err()); + } + + #[test] + fn get_path_resolves_bounded_pointers_and_reports_absence_as_missing() { + let runtime = runtime(); + let response = json!({ + "total": 1, + "person": {"date_of_birth": "1970-01-01", "names": [{"given": "A"}, {"given": "B"}]}, + "a/b": "slash", + "m~n": "tilde" + }); + + fn resolved(runtime: &RhaiRuntime, response: &Value, body: &str) -> Value { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: {body} }} }} + }}" + )) + .expect("compiles"); + match runtime.extract(&script, response, &json!({}), &|_: &Value| true) { + Ok(LookupResult::Match(facts)) => facts["value"].clone(), + other => panic!("expected a match, got {other:?}"), + } + } + + for (body, expected) in [ + (r#"get_path(source_response, "/total")"#, json!(1)), + ( + r#"get_path(source_response, "/person/date_of_birth")"#, + json!("1970-01-01"), + ), + ( + r#"get_path(source_response, "/person/names/1/given")"#, + json!("B"), + ), + (r#"get_path(source_response, "/a~1b")"#, json!("slash")), + (r#"get_path(source_response, "/m~0n")"#, json!("tilde")), + ( + r#"get_path(get_path(source_response, "/person"), "/date_of_birth")"#, + json!("1970-01-01"), + ), + (r#"len(get_path(source_response, ""))"#, json!(4)), + ] { + assert_eq!(resolved(&runtime, &response, body), expected, "{body}"); + } + + // Absence of any kind is the script's decision to make, so it arrives as the + // same unit marker `is_missing` and `required` already understand. + for body in [ + r#"get_path(source_response, "/absent")"#, + r#"get_path(source_response, "/person/absent/deeper")"#, + r#"get_path(source_response, "/person/names/9")"#, + r#"get_path(source_response, "/total/deeper")"#, + ] { + assert_eq!( + resolved(&runtime, &response, &format!("is_missing({body})")), + json!(true), + "{body}" + ); + } + + let unavailable = runtime + .compile_extraction( + r#"fn extract(source_response, parameters) { + #{ outcome: "match", facts: #{ value: required(get_path(source_response, "/absent"), "required_fact_missing") } } + }"#, + ) + .expect("compiles"); + assert_eq!( + runtime.extract(&unavailable, &response, &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Unavailable) + ); + } + + #[test] + fn get_path_rejects_malformed_and_oversized_pointers() { + let runtime = runtime(); + let response = json!({"total": 1, "person": {"names": [{"given": "A"}]}}); + + let long_pointer = format!("/{}", "x".repeat(MAXIMUM_POINTER_BYTES)); + let deep_pointer = "/x".repeat(MAXIMUM_POINTER_SEGMENTS + 1); + for pointer in [ + "total", // no leading separator + "/person/names/-1", // negative index, however it was written + "/person/names/01", // non-canonical index + "/person/names/0x1", // non-numeric index + "/person/names/ 0", // padded index + "/bad~", // dangling escape + "/bad~2", // undefined escape + long_pointer.as_str(), // beyond the pointer byte ceiling + deep_pointer.as_str(), // beyond the pointer segment ceiling + ] { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: get_path(source_response, \"{pointer}\") }} }} + }}" + )) + .expect("compiles"); + assert_eq!( + runtime.extract(&script, &response, &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Invocation), + "{pointer}" + ); + } + + // The ceilings admit the largest well-formed pointer, so the rejections above + // are boundary decisions rather than an accidentally narrower primitive. + for pointer in [ + format!("/{}", "x".repeat(MAXIMUM_POINTER_BYTES - 1)), + "/x".repeat(MAXIMUM_POINTER_SEGMENTS), + ] { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: is_missing(get_path(source_response, \"{pointer}\")) }} }} + }}" + )) + .expect("compiles"); + assert_eq!( + runtime.extract(&script, &response, &json!({}), &|_: &Value| true), + Ok(LookupResult::Match(BTreeMap::from([( + "value".to_string(), + json!(true) + )]))), + "{pointer}" + ); + } + } + + #[test] + fn all_four_acceptance_script_pairs_run_through_one_runtime() { + let runtime = runtime(); + + let adult_facts = matched_facts( + &runtime, + ADULT_EXTRACTION, + json!({"total": 1, "date_of_birth": "2008-08-02"}), + ); + let adult = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(ADULT_DERIVATION)) + .expect("adult derivation compiles"), + &adult_facts, + &json!({}), + context(json!({"minimum_age_years": 18}), BTreeMap::new()), + ) + .expect("adult derives"); + assert!(matches!(&adult[0].value, DerivedValue::Json(value) if value == &json!(true))); + + let residence_facts = matched_facts( + &runtime, + RESIDENCE_EXTRACTION, + json!({"total": 1, "official_residence_code": "R-101"}), + ); + let region_map = CodelistHandle::new(BTreeMap::from([ + ("R-101".to_string(), "REGION-NORTH".to_string()), + ("R-201".to_string(), "REGION-SOUTH".to_string()), + ])) + .expect("codelist"); + let residence = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(RESIDENCE_DERIVATION)) + .expect("residence derivation compiles"), + &residence_facts, + &json!({}), + context( + json!({}), + BTreeMap::from([("region-map".to_string(), region_map)]), + ), + ) + .expect("residence derives"); + assert!( + matches!(&residence[0].value, DerivedValue::Json(value) if value == &json!("REGION-NORTH")) + ); + + let licence_facts = matched_facts( + &runtime, + LICENCE_EXTRACTION, + json!({ + "total": 1, + "records": [{ + "licence_state": "CURRENT", + "valid_from": "2025-01-01", + "valid_until": "2026-08-20", + "historical_states": ["PENDING"] + }] + }), + ); + let licence = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(LICENCE_DERIVATION)) + .expect("licence derivation compiles"), + &licence_facts, + &json!({}), + context( + json!({ + "active_state": "CURRENT", + "expiry_buckets": [ + bucket_parameter("-365000", "0", "expired"), + bucket_parameter("0", "31", "within-30-days"), + bucket_parameter("31", "91", "within-90-days"), + bucket_parameter("91", "365001", "later") + ] + }), + BTreeMap::new(), + ), + ) + .expect("licence derives"); + assert!(matches!(&licence[0].value, DerivedValue::Json(value) if value == &json!(true))); + assert!( + matches!(&licence[1].value, DerivedValue::Json(value) if value == &json!("within-30-days")) + ); + + let relationship_facts = matched_facts( + &runtime, + RELATIONSHIP_EXTRACTION, + json!({ + "total": 1, + "records": [{ + "returned_child_reference": "synthetic-child-record-001", + "parent_references": ["synthetic-parent-reference-001"], + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }] + }), + ); + let relationship = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(RELATIONSHIP_DERIVATION)) + .expect("relationship derivation compiles"), + &relationship_facts, + &json!({ + "child": { + "profile": "civil-record-reference-v1", + "values": {"record_reference": "synthetic-child-record-001"} + }, + "candidate-parent": { + "profile": "person-reference-v1", + "values": {"person_reference": "synthetic-parent-reference-001"} + } + }), + context( + json!({ + "matching_policy": "exact-opaque-reference-membership-v1", + "candidate_reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "legal_authority_attestation": "urn:example:fixture:governance:legal-parent-register:v1" + }), + BTreeMap::new(), + ), + ) + .expect("relationship derives"); + assert!( + matches!(&relationship[0].value, DerivedValue::Json(value) if value == &json!(true)) + ); + } + + #[test] + fn protected_seed_has_no_comparison_or_string_conversion_capability() { + let runtime = runtime(); + for expression in [ + "{ let seed = entity_reference_seed(facts.seed); seed == seed }", + "{ let seed = entity_reference_seed(facts.seed); seed.to_string() }", + "{ let seed = entity_reference_seed(facts.seed); debug(seed) }", + ] { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: {expression} }}] }}" + ); + match runtime.compile_derivation(&source) { + Ok(script) => assert!(matches!( + runtime.derive( + &script, + &BTreeMap::from([("seed".to_string(), json!("protected-canary"))]), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )), + Err(RhaiRuntimeError::Compilation) => {} + Err(error) => panic!("unexpected compilation result: {error}"), + } + } + } + + #[test] + fn list_contains_validates_every_element_before_any_answer() { + for unsupported in [ + Dynamic::UNIT, + Dynamic::from(Map::new()), + Dynamic::from(Array::new()), + Dynamic::from(1.5_f64), + Dynamic::from(EntityReferenceSeed::new("seed").expect("seed")), + ] { + assert!( + list_contains( + vec![Dynamic::from("A"), unsupported.clone()], + Dynamic::from("A") + ) + .is_err(), + "a match before an invalid element answered instead of failing" + ); + assert!(list_contains( + vec![unsupported.clone(), Dynamic::from("A")], + Dynamic::from("A") + ) + .is_err()); + assert!( + set_contains(vec![Dynamic::from("A"), unsupported], Dynamic::from("A")).is_err() + ); + } + + assert!(list_contains( + vec![Dynamic::from("A"), Dynamic::from("A")], + Dynamic::from("A") + ) + .expect("duplicates are containment, not a set")); + + let mixed = vec![ + Dynamic::from(1_i64), + Dynamic::from("1"), + Dynamic::from(true), + ]; + for needle in [ + Dynamic::from(1_i64), + Dynamic::from("1"), + Dynamic::from(true), + ] { + assert!(list_contains(mixed.clone(), needle).expect("valid scalars")); + } + for absent in [ + Dynamic::from(2_i64), + Dynamic::from("true"), + Dynamic::from(false), + ] { + assert!(!list_contains(mixed.clone(), absent).expect("valid scalars")); + } + + let decimals = vec![Dynamic::from(Decimal::parse("1.25").expect("decimal"))]; + assert!(list_contains( + decimals.clone(), + Dynamic::from(Decimal::parse("1.25").expect("decimal")) + ) + .expect("exact decimal comparison")); + assert!(!list_contains( + decimals.clone(), + Dynamic::from(Decimal::parse("1.26").expect("decimal")) + ) + .expect("exact decimal comparison")); + assert!(!list_contains( + vec![Dynamic::from(integer_to_decimal(1))], + Dynamic::from(1_i64) + ) + .expect("decimal and integer stay distinct")); + + assert!(list_contains( + vec![Dynamic::from("A"); MAXIMUM_ARRAY_ITEMS + 1], + Dynamic::from("A") + ) + .is_err()); + assert!(list_contains(vec![Dynamic::from("A")], Dynamic::UNIT).is_err()); + } + + #[test] + fn negative_array_indexes_fail_instead_of_selecting_from_the_end() { + let runtime = runtime(); + let facts = || BTreeMap::from([("values".to_string(), json!(["first", "last"]))]); + let derivation = |index: &str| { + format!( + r#"fn derive(facts, selectors, evaluation_context) {{ + let position = {index}; + [#{{ concept_id: "x", value: facts.values[position] }}] + }}"# + ) + }; + + let computed_negative = runtime + .compile_derivation(&derivation( + "compare_dates(evaluation_context.legal_local_date, parse_date(\"2100-01-01\"))", + )) + .expect("computed index compiles"); + assert!( + matches!( + runtime.derive( + &computed_negative, + &facts(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + ), + "a computed negative index selected from the end" + ); + + let computed_forward = runtime + .compile_derivation(&derivation( + "compare_dates(parse_date(\"2100-01-01\"), evaluation_context.legal_local_date)", + )) + .expect("computed index compiles"); + let values = runtime + .derive( + &computed_forward, + &facts(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("a non-negative computed index still resolves"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!("last"))); + + let computed_key = runtime + .compile_extraction( + r#"fn extract(source_response, parameters) { + let field = parameters.field; + #{ outcome: "match", facts: #{ code: source_response["record"][field] } } + }"#, + ) + .expect("computed map key compiles"); + assert_eq!( + runtime.extract( + &computed_key, + &json!({"record": {"code": "A"}}), + &json!({"field": "code"}), + &|_: &Value| true, + ), + Ok(LookupResult::Match(BTreeMap::from([( + "code".to_string(), + json!("A") + )]))) + ); + + assert_eq!( + runtime + .compile_derivation(&derivation("0")) + .and_then(|script| runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: facts.values[-1] }] }", + ) + .map(|_| script)) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + assert!( + runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + let offsets = [-1, 0]; + [#{ concept_id: "x", value: list_contains(offsets, 0) }] + }"#, + ) + .is_ok(), + "a negative number inside an array literal is not an index" + ); + } + + #[test] + fn script_scanner_agrees_with_rhai_string_comment_and_pointer_tokenization() { + let runtime = runtime(); + for rejected in [ + // Rhai reads #"A"B"# as one raw string; a scanner that treats every quote as a + // plain delimiter desynchronizes and hides the code between two raw strings. + "let a = #\"A\"B\"#; parameters.call(); let c = #\"D\"E\"#;", + "let a = #\"raw\"#;", + "let a = ##\"raw \"# still raw\"##;", + "let a = `interpolated ${parameters}`;", + "let a = Fn(\"helper\");", + "parameters.call();", + "parameters.curry(1);", + "let a = parameters.values[-1];", + "let a = parameters.values[ - 1];", + "let a = 1; /* unterminated", + "let a = __evidence_index(1);", + // Rhai indexes a block or an if chain in operand position, so its closing + // brace ends a value that a byte scanner cannot distinguish from the closing + // brace of a statement block. + "let v = [10, 20]; let a = if true { v } else { v }[-1];", + "let a = if true { 1 } else { 2 };", + "let a = { 1 };", + ] { + let source = format!( + "fn prepare(selectors, parameters) {{ {rejected} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_err(), + "accepted: {rejected}" + ); + } + for accepted in [ + "// Fn(\"helper\") and values[-1]\n", + "/* Fn(\"helper\") and values[-1] */", + "let a = \"values[-1] Fn\";", + "let a = 'x';", + "let a = [-1, 0];", + "let a = #{ inner: [1] }[\"inner\"][0];", + // A statement block is followed by an array literal, not by an index. + "if true { let b = 1; }\n [-1, 0];", + "for x in [1, 2] { let b = x; }", + "if true { let b = 1; } else if false { let b = 2; }", + ] { + let source = format!( + "fn prepare(selectors, parameters) {{ {accepted} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_ok(), + "rejected: {accepted}" + ); + } + assert_eq!( + runtime + .compile_preparation( + "let leaked = 1; fn prepare(selectors, parameters) { #{ query: [], body: () } }", + ) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + + // Only index operands are rewritten, and every one of them is. + assert_eq!( + guarded_script_source( + "fn f(a) { let b = a[\"k\"][0]; let c = [1, 2]; let d = #{ k: [1] }[\"k\"]; }" + ) + .expect("reviewed"), + "fn f(a) { let b = a[__evidence_index(\"k\")][__evidence_index(0)]; \ + let c = [1, 2]; let d = #{ k: [1] }[__evidence_index(\"k\")]; }" + ); + assert_eq!( + guarded_script_source("fn f(a) { let b = a[a[\"i\"]]; }").expect("reviewed"), + "fn f(a) { let b = a[__evidence_index(a[__evidence_index(\"i\")])]; }" + ); + } + + #[test] + fn public_derived_values_reject_ordinary_floats() { + let runtime = runtime(); + for float in [ + "1.5", + "1.0", + "[1.5]", + "#{ ratio: 1.5 }", + "0.0 / 0.0", + "1.5 + 2.0", + ] { + let source = + format!("fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: {float} }}] }}"); + let script = runtime.compile_derivation(&source).expect("compiles"); + assert!( + matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::DerivationResult) + ), + "ordinary float accepted at the derivation output gate: {float}" + ); + } + + let declared = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + [ + #{ concept_id: "integer", value: 2 }, + #{ concept_id: "exact", value: decimal("1.25") } + ] + }"#, + ) + .expect("compiles"); + let values = runtime + .derive( + &declared, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("declared numeric forms remain available"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!(2))); + assert!( + matches!(&values[1].value, DerivedValue::Decimal(value) if value.canonical() == "1.25") + ); + + let extraction = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"match\", facts: #{ ratio: source_response.ratio } } }", + ) + .expect("compiles"); + assert_eq!( + runtime.extract( + &extraction, + &json!({"ratio": 1.25}), + &json!({}), + &|_: &Value| true + ), + Ok(LookupResult::Match(BTreeMap::from([( + "ratio".to_string(), + json!(1.25) + )]))) + ); + + let limits = request_limits( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + ); + let preparation = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: #{ ratio: 1.25 } } }", + ) + .expect("compiles"); + assert_eq!( + runtime + .prepare(&preparation, &json!({}), &json!({}), &limits) + .expect("finite adapter floats remain available") + .body, + Some(json!({"ratio": 1.25})) + ); + let non_finite = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: #{ ratio: 0.0 / 0.0 } } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&non_finite, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + } + + #[test] + fn out_of_range_json_integer_tokens_fail_before_rhai() { + let runtime = runtime(); + let script = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"no_match\" } }", + ) + .expect("compiles"); + let response = |token: &str| { + serde_json::from_str::(&format!("{{\"value\": {token}}}")).expect("parses") + }; + for accepted in [ + "0", + "9223372036854775807", + "-9223372036854775808", + "1.25", + "1e3", + ] { + assert_eq!( + runtime.extract(&script, &response(accepted), &json!({}), &|_: &Value| true), + Ok(LookupResult::NoMatch), + "{accepted}" + ); + } + for rejected in [ + "9223372036854775808", + "18446744073709551615", + "99999999999999999999999", + "1e30", + ] { + assert_eq!( + runtime.extract(&script, &response(rejected), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::InputBound), + "{rejected}" + ); + } + assert_eq!( + runtime.extract( + &script, + &json!({"value": "99999999999999999999999"}), + &json!({}), + &|_: &Value| true + ), + Ok(LookupResult::NoMatch), + "an identifier outside the signed 64-bit range must arrive as a string" + ); + + let derivation = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: true }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &derivation, + &BTreeMap::from([( + "value".to_string(), + response("99999999999999999999999")["value"].clone() + )]), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::InputBound) + )); + } + + #[test] + fn required_unavailability_carries_no_supplied_code_into_any_surface() { + let runtime = runtime(); + for code in ["required_fact_missing", "other_missing_input_9"] { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: required(facts.absent, \"{code}\") }}] }}" + ); + let script = runtime.compile_derivation(&source).expect("compiles"); + let error = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect_err("unavailable"); + assert_eq!(error, RhaiRuntimeError::Unavailable); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(code), "supplied code reached a surface"); + } + + // A code that fails review validation stops before the unavailable signal. + let unsafe_code = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: required(facts.absent, \"Protected Value\") }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &unsafe_code, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )); + assert!(required(Dynamic::from(1_i64), "Protected Value").is_err()); + assert!(required(Dynamic::from(1_i64), "").is_err()); + assert!(required(Dynamic::from(1_i64), "safe_code").is_ok()); + } + + fn boundary(minimum: &str, maximum: &str, code: &str) -> Dynamic { + let mut map = Map::new(); + map.insert( + "minimumInclusive".into(), + Dynamic::from(Decimal::parse(minimum).expect("decimal")), + ); + map.insert( + "maximumExclusive".into(), + Dynamic::from(Decimal::parse(maximum).expect("decimal")), + ); + map.insert("code".into(), Dynamic::from(code.to_string())); + Dynamic::from(map) + } + + fn bucket_parameter(minimum: &str, maximum: &str, code: &str) -> Value { + json!({ + "minimumInclusive": {"type": "decimal", "value": minimum}, + "maximumExclusive": {"type": "decimal", "value": maximum}, + "code": code + }) + } + + fn matched_facts( + runtime: &RhaiRuntime, + source: &str, + response: Value, + ) -> BTreeMap { + let label = if source == ADULT_EXTRACTION { + "adult" + } else if source == RESIDENCE_EXTRACTION { + "residence" + } else if source == LICENCE_EXTRACTION { + "licence" + } else { + "relationship" + }; + let script = runtime + .compile_extraction(&candidate_extraction(source)) + .expect("extraction compiles"); + let parameters = match label { + "licence" => { + json!({"requestedFields": "licence_state,valid_from,valid_until", "resultLimit": "2"}) + } + "relationship" => json!({ + "requestedFields": [ + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete" + ], + "resultLimit": 2, + "referenceNamespace": "urn:example:fixture:person-reference", + "relationshipSetContract": "urn:example:fixture:legal-parent-set:v1", + "relationshipSetComplete": true + }), + _ => json!({}), + }; + match runtime + .extract(&script, &response, ¶meters, &|_: &Value| true) + .unwrap_or_else(|error| panic!("{label} extraction failed: {error}")) + { + LookupResult::Match(facts) => facts, + other => panic!("expected match, got {other:?}"), + } + } + + fn candidate_extraction(source: &str) -> String { + source.replacen( + "fn extract(source_response)", + "fn extract(source_response, parameters)", + 1, + ) + } + + fn candidate_derivation(source: &str) -> String { + source.replacen( + "fn derive(facts, evaluation_context)", + "fn derive(facts, selectors, evaluation_context)", + 1, + ) + } +} diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs new file mode 100644 index 000000000..3fc2b2f6f --- /dev/null +++ b/crates/registry-evidence/src/runtime.rs @@ -0,0 +1,1772 @@ +//! Complete authenticated Evidence evaluation and fail-closed release pipeline. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + path::Path, + str, + sync::Arc, + time::Instant, +}; + +use chrono::Utc; +use registry_platform_audit::{AuditError, AuditHashSecret}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; +use serde_json::{Map as JsonMap, Value}; +use thiserror::Error; + +use crate::{ + audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind as AuditAuthorityKind, EvidenceAuditError, EvidenceAuditEvent, + EvidenceAuditLog, ResponseProtection, + }, + auth::{AuthenticatedContext, Authenticator}, + bundle::{Bundle, DeploymentInputs}, + config::{ + AssuranceProfile, AuthorityKind, ConceptForm, RequirementKind, ResponseFormat, + RuntimeConfig, SelectorField, SelectorInput, SubjectCardinality, ValueOrigin, + }, + contracts::definitions_contract_accepts, + kernel::{EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection}, + model::{ + request_nonce_is_canonical, EvidenceDefinition, EvidenceDefinitionConcept, + EvidenceDefinitionSelector, EvidenceDefinitionSubject, EvidenceDefinitions, + EvidenceRequest, EvidenceSelectorField, FlattenedJws, JwksDocument, RequestedSelector, + RequestedSubject, SelectorValue, SubjectBinding, UnsignedEnvelopeType, + UnsignedEnvelopeWarning, UnsignedEvidenceEnvelope, UnsignedIntegrityProtection, + }, + problem::ProblemCode, + rate_limit::{EvidenceRateLimiter, RateLimitConfig, RateLimitError}, + sdjwt_vc, + secrets::{ProtectedSecret, SecretProvider, SecretResolver}, + selector::{ + match_entitlement, resolve_selectors, validate_entitlement_context, + validate_subject_binding_key, AuthorizationError, MatchedEntitlement, + ResolvedAuthorization, ResolvedSelectorValue, + }, + signing::{jwks_document, EvidenceSigner}, + source::{ResolvedSourceSelector, SourceError, SourceExecutor}, + EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, + EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, +}; + +const MAX_OPERATION_BYTES: usize = 128; + +#[derive(Debug, Error)] +pub enum RuntimeInitializationError { + #[error("the immutable Evidence bundle could not be loaded")] + Bundle, + #[error("the Evidence secret resolver could not initialize")] + Secrets, + #[error("the Evidence audit boundary could not initialize: {0}")] + Audit(AuditInitializationFault), + #[error("the Evidence signing boundary could not initialize")] + Signing, + #[error("an Evidence source plan could not initialize")] + Source, + #[error("the Evidence rate limiter could not initialize")] + RateLimit, +} + +/// Why the audit boundary refused to initialize. +/// +/// A mode an operator fixes with `chmod`, a chain that no longer verifies, and +/// a second writer already holding the sink lock are three unrelated faults +/// with three unrelated remedies. They are reported separately because from +/// outside the process they are indistinguishable, and the wrong guess sends an +/// operator hunting for tampering in what is a permission bit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditInitializationFault { + /// `auditStorage` bounds or the audit key version are out of range. + Configuration, + /// The audit hash secret is missing, unreadable, or too weak. + Secret, + /// The audit file or lock is not owner-only and singly linked, or its + /// directory is not controlled by the service owner. + Storage, + /// Another writer already holds the sink's single-writer lock. + Locked, + /// A chain is present but its records do not verify against the head. + Chain, +} + +impl AuditInitializationFault { + /// The value-free cause, for the operator message this fault appears in. + /// It names the fault and never the audit path, which the operator already + /// has in the runtime file. + pub fn cause(self) -> &'static str { + match self { + Self::Configuration => "the audit storage configuration is out of range", + Self::Secret => "the audit hash secret is unusable", + Self::Storage => { + "the audit file or lock is not owner-only, or its directory is unavailable or not owner-controlled" + } + Self::Locked => "another writer already holds the audit sink lock", + Self::Chain => "the existing audit chain did not verify", + } + } +} + +impl fmt::Display for AuditInitializationFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.cause()) + } +} + +impl From<&EvidenceAuditError> for AuditInitializationFault { + fn from(error: &EvidenceAuditError) -> Self { + match error { + EvidenceAuditError::Configuration => Self::Configuration, + EvidenceAuditError::InvalidEvent | EvidenceAuditError::SegmentMissing { .. } => { + Self::Chain + } + EvidenceAuditError::Audit(audit) => match audit { + AuditError::Io(error) if error.kind() == std::io::ErrorKind::InvalidData => { + Self::Chain + } + AuditError::Io(_) => Self::Storage, + AuditError::SinkLocked { .. } => Self::Locked, + AuditError::EmptyEnvVarName + | AuditError::EnvVarUnavailable { .. } + | AuditError::EnvVarNotUnicode { .. } + | AuditError::EmptySecret { .. } + | AuditError::WeakSecret { .. } => Self::Secret, + // Everything else the sink can report at startup is a statement + // about chain state: a record that does not parse, a hash that + // does not match, or a head that cannot be read. + _ => Self::Chain, + }, + } + } +} + +/// Deployment secret material, resolved and validated exactly as service +/// startup validates it. +pub struct ValidatedSecretMaterial { + pub audit_secret: ProtectedSecret, + pub subject_binding_secret: ProtectedSecret, + pub signer: EvidenceSigner, + pub jwks: JwksDocument, +} + +/// Secret-derived material needed to prepare an independent verification +/// context. Unlike complete startup validation, this boundary never resolves +/// the audit key or opens audit storage. +pub struct ValidatedVerificationMaterial { + pub subject_binding_secret: ProtectedSecret, + pub signer: EvidenceSigner, + pub jwks: JwksDocument, +} + +/// Resolve and validate the audit, subject-binding, and signing secret +/// material a bundle names, with no side effects: nothing is written and no +/// audit chain is opened. Startup builds its runtime state from the returned +/// material, and `check` runs the same validation so a deployment whose +/// mounted secrets the server would refuse fails check instead of first +/// start. Source credentials are deliberately not resolved here: readiness +/// owns them. +pub async fn validate_secret_material( + bundle: &Bundle, + secrets: &SecretResolver, +) -> Result { + let audit_secret = secrets + .resolve(bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; + AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; + + let verification = validate_verification_material(bundle, secrets).await?; + + Ok(ValidatedSecretMaterial { + audit_secret, + subject_binding_secret: verification.subject_binding_secret, + signer: verification.signer, + jwks: verification.jwks, + }) +} + +/// Resolve and validate only the binding and signing material required to +/// create a pre-response verification context. This deliberately excludes +/// source credentials and the audit boundary. +pub async fn validate_verification_material( + bundle: &Bundle, + secrets: &SecretResolver, +) -> Result { + let subject_binding_secret = secrets + .resolve(bundle.config.subject_binding.secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Secrets)?; + validate_subject_binding_key( + subject_binding_secret.expose_secret(), + bundle.config.subject_binding.key_version, + &bundle.config.service.trust_domain, + ) + .map_err(|_| RuntimeInitializationError::Secrets)?; + + let signing_secret = secrets + .resolve(bundle.config.signing.active_key_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let signing_json = str::from_utf8(signing_secret.expose_secret()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let private_jwk = + PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; + let provider = Arc::new( + LocalJwkSigner::new(private_jwk).map_err(|_| RuntimeInitializationError::Signing)?, + ); + let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) + .await + .map_err(|_| RuntimeInitializationError::Signing)?; + let retired = bundle + .retired_public_jwks + .values() + .map(|value| { + serde_json::to_string(value) + .map_err(|_| RuntimeInitializationError::Signing) + .and_then(|json| { + PublicJwk::parse(&json).map_err(|_| RuntimeInitializationError::Signing) + }) + }) + .collect::, _>>()?; + let jwks = jwks_document(signer.public_jwk(), retired) + .map_err(|_| RuntimeInitializationError::Signing)?; + + Ok(ValidatedVerificationMaterial { + subject_binding_secret, + signer, + jwks, + }) +} + +/// One safe failure classification for the public HTTP boundary. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RuntimeFailure { + problem: ProblemCode, + category: &'static str, +} + +impl RuntimeFailure { + pub fn problem(self) -> ProblemCode { + self.problem + } + + pub fn category(self) -> &'static str { + self.category + } +} + +impl std::fmt::Debug for RuntimeFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RuntimeFailure") + .field("problem", &self.problem) + .field("category", &self.category) + .finish() + } +} + +impl std::fmt::Display for RuntimeFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the Evidence operation did not complete") + } +} + +impl std::error::Error for RuntimeFailure {} + +/// One released response: the exact final immutable bytes that were serialized +/// before the durable disclosure-release audit event, plus their exact media +/// type. The HTTP boundary returns these bytes unchanged. +pub struct ReleasedEvidence { + format: ResponseFormat, + media_type: &'static str, + bytes: Vec, +} + +impl ReleasedEvidence { + pub fn format(&self) -> ResponseFormat { + self.format + } + + pub fn media_type(&self) -> &'static str { + self.media_type + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn into_bytes(self) -> Vec { + self.bytes + } +} + +impl std::fmt::Debug for ReleasedEvidence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ReleasedEvidence") + .field("format", &self.format) + .field("media_type", &self.media_type) + .field("bytes", &"") + .finish() + } +} + +/// All runtime state is derived from one captured immutable bundle revision. +pub struct EvidenceRuntime { + kernel: OfflineKernel, + runtime_config: RuntimeConfig, + runtime_revision: String, + authenticator: Authenticator, + sources: BTreeMap, + audit: Arc, + signer: EvidenceSigner, + jwks: JwksDocument, + subject_binding_secret: ProtectedSecret, + rate_limiter: Arc, +} + +impl std::fmt::Debug for EvidenceRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceRuntime") + .field("configuration_revision", &self.kernel.bundle().revision()) + .field("source_count", &self.sources.len()) + .field("signing_key_id", &self.signer.key_id()) + .finish_non_exhaustive() + } +} + +impl EvidenceRuntime { + /// Capture and initialize the complete Version 1 deployment at one revision. + pub async fn initialize(runtime_path: &Path) -> Result { + Self::initialize_internal(runtime_path, None).await + } + + #[cfg(test)] + pub(crate) async fn initialize_with_authenticator( + runtime_path: &Path, + authenticator: Authenticator, + ) -> Result { + Self::initialize_internal(runtime_path, Some(authenticator)).await + } + + async fn initialize_internal( + runtime_path: &Path, + authenticator_override: Option, + ) -> Result { + let deployment = + DeploymentInputs::load(runtime_path).map_err(|_| RuntimeInitializationError::Bundle)?; + let runtime_document = deployment.runtime; + let runtime_config = runtime_document.config.clone(); + let runtime_revision = runtime_document.revision().to_owned(); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| RuntimeInitializationError::Bundle)?; + + let secrets = Arc::new( + SecretResolver::new( + [SecretProvider::File], + &runtime_config.secret_providers.file.root, + ) + .map_err(|_| RuntimeInitializationError::Secrets)?, + ); + + let material = validate_secret_material(&bundle, &secrets).await?; + let audit = EvidenceAuditLog::initialize( + &runtime_config.audit_storage.path, + runtime_config.audit_storage.maximum_file_bytes, + material.audit_secret.expose_secret().to_vec(), + bundle.config.audit.hash_key_version, + ) + .await + .map_err(|error| RuntimeInitializationError::Audit((&error).into()))?; + + let mut sources = BTreeMap::new(); + for (source_id, source) in bundle.config.sources.iter() { + let allowed_selector_sets = bundle.config.source_selector_sets(source_id); + let executor = SourceExecutor::new_with_selector_sets_and_tls( + source, + &allowed_selector_sets, + &runtime_config.outbound_tls, + &runtime_document.ca_bundles, + Arc::clone(&secrets), + ) + .map_err(|_| RuntimeInitializationError::Source)?; + sources.insert(source_id.to_owned(), executor); + } + + let configured_limits = &bundle.config.rate_limits; + let rate_limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: u32::try_from( + configured_limits.requests_per_principal_per_minute, + ) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + burst_per_principal: u32::try_from(configured_limits.burst_per_principal) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + failed_selector_attempts_per_principal_authority_per_minute: u32::try_from( + configured_limits.failed_selector_attempts_per_principal_authority_per_minute, + ) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + }) + .map_err(|_| RuntimeInitializationError::RateLimit)?; + let rate_limiter = Arc::new(rate_limiter); + + Ok(Self { + kernel, + runtime_config, + runtime_revision, + authenticator: authenticator_override.unwrap_or_else(|| { + Authenticator::from_config( + &bundle.config.authentication, + bundle.config.assurance_profile, + ) + }), + sources, + audit: Arc::new(audit), + signer: material.signer, + jwks: material.jwks, + subject_binding_secret: material.subject_binding_secret, + rate_limiter, + }) + } + + pub fn bundle(&self) -> &Bundle { + self.kernel.bundle() + } + + pub fn runtime_config(&self) -> &RuntimeConfig { + &self.runtime_config + } + + pub fn runtime_revision(&self) -> &str { + &self.runtime_revision + } + + pub fn jwks(&self) -> &JwksDocument { + &self.jwks + } + + /// The rate limiter whose tracked-key count backs the + /// `evidence_rate_limiter_tracked_keys` gauge on the metrics listener. + /// + /// Returned as a shared handle, independent of this runtime's own + /// lifetime, so the metrics listener can hold it and sample it fresh on + /// every scrape. + pub(crate) fn rate_limiter(&self) -> Arc { + Arc::clone(&self.rate_limiter) + } + + /// The audit chain, for the capacity gauge. + /// + /// Shared for the same reason as the rate limiter: the metrics listener + /// samples the chain's on-disk footprint at scrape time rather than + /// caching a value taken at startup. + pub(crate) fn audit(&self) -> Arc { + Arc::clone(&self.audit) + } + + #[cfg(test)] + pub(crate) fn replace_signer_for_test(&mut self, signer: EvidenceSigner) { + self.signer = signer; + } + + /// Readiness proves all locally required material and source credentials. + /// It never performs an evidence-data request. + /// + /// It also asks the access-token issuer for its key set, but only so an + /// issuer that has gone quiet is named in the log while requests still + /// work. The answer does not decide readiness: the verifier keeps serving + /// a key set it cannot recheck for a bounded while, and an issuer outage + /// that pulled every replica out of rotation at once would be a cascading + /// failure, not a diagnosis. + pub async fn ready(&self) -> bool { + self.authenticator.probe_key_source().await; + if validate_subject_binding_key( + self.subject_binding_secret.expose_secret(), + self.bundle().config.subject_binding.key_version, + &self.bundle().config.service.trust_domain, + ) + .is_err() + || !self.signer.ready() + || !self.audit.ready().await + { + return false; + } + for source in self.sources.values() { + if source.credentials_ready().await.is_err() { + return false; + } + } + true + } + + /// Attempt the access-token issuer's key set once, so a `jwksUri` this + /// deployment cannot use is named at startup rather than discovered one + /// rejected request at a time. It reports; it does not refuse to start. + pub async fn announce_key_source(&self) { + self.authenticator.announce_key_source().await; + } + + /// List only the complete request shapes that the authenticated caller can + /// currently invoke. Discovery performs no source access, credential + /// resolution, signing, or evidence-data audit. + pub async fn discover( + &self, + access_token: &str, + ) -> Result { + let context = self + .authenticator + .authenticate(access_token) + .await + .map_err(|_| failure(ProblemCode::AuthenticationFailed, "authentication"))?; + let rate_scope = rate_limit_scope(&self.bundle().config.service.trust_domain); + let request_limit_key = self + .audit + .pseudonym("request-rate", &rate_scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_request(&request_limit_key) + .await + .map_err(map_request_limit)?; + + let mut candidates = BTreeSet::new(); + for (_, authority) in self.bundle().config.authority_profiles.iter() { + for grant in &authority.grants { + let mut subjects = grant + .subjects + .iter() + .map(|subject| (subject.role.clone(), subject.selector_profile.clone())) + .collect::>(); + subjects.sort(); + candidates.insert((grant.requirement.clone(), grant.purpose.clone(), subjects)); + } + } + + let mut definitions = Vec::new(); + for (requirement, purpose, subjects) in candidates { + // Discovery only probes the authorization boundary; this internal + // request shape is never evaluated or released, so it carries the + // fixed non-random placeholder nonce. + let request = EvidenceRequest { + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), + requirement, + purpose, + subjects: subjects + .into_iter() + .map(|(role, profile)| RequestedSubject { + role, + selector: RequestedSelector { + profile, + values: None, + }, + }) + .collect(), + holder_key: None, + }; + let Ok(matched) = match_entitlement(self.bundle(), &request, &context) else { + continue; + }; + if validate_entitlement_context(self.bundle(), &context, &matched).is_err() { + continue; + } + definitions.push(self.discovery_definition(&request, &matched)?); + } + let response = EvidenceDefinitions { + schema: EVIDENCE_DEFINITIONS_SCHEMA_V1.to_owned(), + assurance_profile: self.bundle().config.assurance_profile, + configuration_revision: self.bundle().revision().to_owned(), + issued_by: self.bundle().config.issuer.id.clone(), + provided_by: self.bundle().config.service.provider_id.clone(), + definitions, + }; + let contract_value = serde_json::to_value(&response) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "discovery-contract"))?; + if !definitions_contract_accepts(&contract_value) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "discovery-contract"))? + { + return Err(failure( + ProblemCode::ServiceUnavailable, + "discovery-contract", + )); + } + Ok(response) + } + + fn discovery_definition( + &self, + request: &EvidenceRequest, + matched: &MatchedEntitlement, + ) -> Result { + let requirement = self + .kernel + .requirement(&request.requirement) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "discovery-requirement"))?; + let subjects = requirement + .subject_roles + .iter() + .map(|role| { + let granted = matched + .subjects() + .iter() + .find(|subject| subject.role == role.role) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "discovery-subject"))?; + let profile = self + .bundle() + .config + .selector_profiles + .get(&granted.selector_profile) + .ok_or_else(|| { + failure(ProblemCode::ServiceUnavailable, "discovery-selector") + })?; + let fields = profile + .fields + .iter() + .map(|(name, field)| self.discovery_selector_field(name, field)) + .collect::, RuntimeFailure>>()?; + Ok(EvidenceDefinitionSubject { + role: role.role.clone(), + cardinality: subject_cardinality_name(role.cardinality).to_owned(), + selector: EvidenceDefinitionSelector { + profile: granted.selector_profile.clone(), + value_origin: value_origin_name(granted.value_origin).to_owned(), + fields, + }, + }) + }) + .collect::, RuntimeFailure>>()?; + let concepts = requirement + .concepts + .iter() + .map(|concept| EvidenceDefinitionConcept { + id: concept.id.clone(), + form: concept_form_name(concept.form).to_owned(), + }) + .collect(); + + Ok(EvidenceDefinition { + requirement: requirement.id.clone(), + kind: requirement_kind_name(requirement.kind).to_owned(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + reference_frameworks: requirement.reference_frameworks.clone(), + subjects, + concepts, + }) + } + + fn discovery_selector_field( + &self, + name: &str, + field: &SelectorField, + ) -> Result { + Ok(match field { + SelectorField::String { + minimum_bytes, + maximum_bytes, + } => EvidenceSelectorField::String { + name: name.to_owned(), + minimum_bytes: *minimum_bytes, + maximum_bytes: *maximum_bytes, + }, + SelectorField::Date => EvidenceSelectorField::Date { + name: name.to_owned(), + }, + SelectorField::Integer { minimum, maximum } => EvidenceSelectorField::Integer { + name: name.to_owned(), + minimum: *minimum, + maximum: *maximum, + }, + SelectorField::Boolean => EvidenceSelectorField::Boolean { + name: name.to_owned(), + }, + SelectorField::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + } => { + let list = self.bundle().codelist(codelist).ok_or_else(|| { + failure(ProblemCode::ServiceUnavailable, "discovery-codelist") + })?; + EvidenceSelectorField::ControlledCode { + name: name.to_owned(), + scheme: list.id().to_owned(), + version: codelist_version.clone(), + maximum_bytes: *maximum_bytes, + } + } + }) + } + + /// Run the fixed authenticated signed-default path and return the JWS. + /// + /// This convenience wrapper deserializes the exact released bytes; the + /// HTTP boundary uses [`EvidenceRuntime::evaluate_with_format`] so the + /// bytes serialized before release audit are the bytes returned. + pub async fn evaluate( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + ) -> Result { + let released = self + .evaluate_at( + operation, + access_token, + request, + ResponseFormat::SignedJws, + None, + ) + .await?; + serde_json::from_slice(released.bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")) + } + + /// Run the fixed authenticated path for one explicitly resolved response + /// format through serialization and durable release audit. + pub async fn evaluate_with_format( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + format: ResponseFormat, + ) -> Result { + self.evaluate_at(operation, access_token, request, format, None) + .await + } + + #[cfg(test)] + pub(crate) async fn evaluate_at_for_test( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + format: ResponseFormat, + evaluation_time: chrono::DateTime, + ) -> Result { + self.evaluate_at( + operation, + access_token, + request, + format, + Some(evaluation_time), + ) + .await + } + + async fn evaluate_at( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + format: ResponseFormat, + evaluation_time: Option>, + ) -> Result { + if operation.len() < 16 + || operation.len() > MAX_OPERATION_BYTES + || operation.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return Err(failure(ProblemCode::ServiceUnavailable, "operation-id")); + } + // The nonce is validated before authentication and never used again + // until evidence construction echoes it. + if !request_nonce_is_canonical(&request.request_nonce) { + return Err(failure(ProblemCode::MalformedRequest, "request-nonce")); + } + // An unacceptable holder key fails before any credential acquisition or + // source access. The key never reaches authorization, selectors, Rhai, + // sources, or audit. + if request + .holder_key + .as_ref() + .is_some_and(|key| !key.is_acceptable()) + { + return Err(failure(ProblemCode::MalformedRequest, "holder-key")); + } + let started = Instant::now(); + let context = self + .authenticator + .authenticate(access_token) + .await + .map_err(|_| failure(ProblemCode::AuthenticationFailed, "authentication"))?; + let rate_scope = rate_limit_scope(&self.bundle().config.service.trust_domain); + let request_limit_key = self + .audit + .pseudonym("request-rate", &rate_scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_request(&request_limit_key) + .await + .map_err(map_request_limit)?; + + let scope = audit_scope( + &self.bundle().config.service.trust_domain, + &request.purpose, + context.evidence_audience(), + ); + let requester_pseudonym = self + .audit + .pseudonym("requester", &scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + + let matched = match_entitlement(self.bundle(), request, &context).map_err(map_authority)?; + // The immutable bundle and the one complete matched grant must both + // permit the requested format. API selection creates no permission, + // and the denial does not reveal which layer withheld it. + if !self.bundle().config.response_formats.contains(&format) + || !matched.permits_response_format(format) + { + return Err(failure(ProblemCode::NotAuthorized, "response-format")); + } + let selector_limit_input = canonical_pair( + context.principal().as_bytes(), + matched.authority_profile().as_bytes(), + ) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "selector-rate-key"))?; + let selector_limit_key = self + .audit + .pseudonym("selector-failure-rate", &rate_scope, &selector_limit_input) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_selector_failure_budget(&selector_limit_key) + .await + .map_err(map_selector_limit)?; + let resolved = match resolve_selectors(self.bundle(), request, &context, &matched) { + Ok(resolved) => resolved, + Err(error) => { + if error == AuthorizationError::Selector { + self.rate_limiter + .record_selector_failure(&selector_limit_key) + .await + .map_err(map_selector_limit)?; + } + return Err(map_authority(error)); + } + }; + + let material = + self.audit_material(&scope, requester_pseudonym, &context, &resolved, format)?; + let (source_id, adapter_id) = self.source_identity(&request.requirement)?; + let mut access_event = material.event( + operation, + AuditPhase::AccessAttempt, + AuditDecision::Authorized, + elapsed_millis(started), + ); + access_event.source_id = Some(source_id.clone()); + access_event.adapter_id = Some(adapter_id.clone()); + self.audit + .append(access_event) + .await + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "access-audit"))?; + + let executor = self + .sources + .get(&source_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let requirement = self + .kernel + .requirement(&request.requirement) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "requirement"))?; + let source = self + .bundle() + .config + .sources + .get(&source_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let preparation_selector_value = + source_selector_input_value(&resolved, &source.request.selector_inputs)?; + let selectors = source_selectors(&resolved, &source.request.selector_inputs)?; + let request_parts = match self + .kernel + .prepare(&request.requirement, &preparation_selector_value) + { + Ok(parts) => parts, + Err(error) => { + let category = kernel_failure_category(error); + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(kernel_failure_problem(error), category)); + } + }; + let source_response = match executor.execute(&selectors, &request_parts).await { + Ok(response) => response, + Err(error) => { + let category = source_failure_category(&error); + self.append_failure( + &material, + operation, + AuditDecision::DependencyFailure, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(source_failure_problem(&error), category)); + } + }; + let observed_at = evaluation_time.unwrap_or_else(Utc::now); + let derivation_selectors = + selector_input_value(&resolved, &requirement.derivation.selector_inputs)?; + let values = match self.kernel.evaluate_with_selectors( + &request.requirement, + &source_response, + &derivation_selectors, + observed_at, + ValueProjection { + audience: context.evidence_audience(), + binding_key: self.subject_binding_secret.expose_secret(), + binding_key_version: self.bundle().config.subject_binding.key_version, + }, + ) { + Ok(KernelOutcome::Match(values)) => values, + Ok(KernelOutcome::NoMatch) => { + self.append_failure( + &material, + operation, + AuditDecision::NoMatch, + "no-match", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(evidence_unavailable_failure()); + } + Ok(KernelOutcome::Ambiguous) => { + self.append_failure( + &material, + operation, + AuditDecision::Ambiguous, + "ambiguous", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(evidence_unavailable_failure()); + } + Err(error) => { + let category = kernel_failure_category(error); + let problem = kernel_failure_problem(error); + let decision = match error { + KernelError::Extraction | KernelError::DerivationInput => { + AuditDecision::FactMissing + } + KernelError::SourceProtocol => AuditDecision::DependencyFailure, + _ => AuditDecision::EvaluationFailure, + }; + self.append_failure( + &material, + operation, + decision, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(problem, category)); + } + }; + + let subjects = match self.subject_bindings(&resolved) { + Ok(subjects) => subjects, + Err(error) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "subject-binding", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + // `issued_at` is read after the source round-trip, so a backward wall-clock + // adjustment between it and `observed_at` could otherwise make `issued_at` + // precede `observed_at` and fail evidence construction. Clamp the wall-clock + // read so issuance never predates observation; an injected evaluation time + // keeps both stamps equal. + let issued_at = evaluation_time.unwrap_or_else(|| Utc::now().max(observed_at)); + let evidence = match self.kernel.construct_evidence( + &request.requirement, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + request_nonce: &request.request_nonce, + purpose: &request.purpose, + audience: context.evidence_audience(), + issued_at, + observed_at, + subjects, + }, + ) { + Ok(evidence) => evidence, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "evidence-construction", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure( + ProblemCode::ServiceUnavailable, + "evidence-construction", + )); + } + }; + let disclosed_concepts = evidence + .supported_values + .iter() + .map(|value| value.provides_value_for.clone()) + .collect::>(); + + // Serialize the final immutable response bytes before the durable + // disclosure-release audit; the released bytes are exactly these. A + // signed-path failure never downgrades to unsigned output. + let (bytes, media_type, signing_key_id) = match format { + ResponseFormat::SignedJws => { + let signed = match self.signer.sign_json(&evidence).await { + Ok(signed) => signed, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + }; + let bytes = serde_json::to_vec(&signed) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")); + let bytes = match bytes { + Ok(bytes) => bytes, + Err(error) => { + // Serialization of the already-signed artifact failed; + // record it with the same decision as the unsigned path + // so the audit taxonomy for release-serialization is one + // class regardless of format. + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "release-serialization", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + ( + bytes, + EVIDENCE_JWS_MEDIA_TYPE, + Some(self.signer.key_id().to_owned()), + ) + } + ResponseFormat::SdJwtVc => { + // The projection re-encodes the constructed payload and + // re-derives nothing. + let structured_projections = self + .kernel + .requirement(&request.requirement) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "sd-jwt-vc-mapping"))? + .concepts + .iter() + .filter_map(|concept| { + concept + .sd_jwt_vc + .as_ref() + .map(|projection| (concept.id.clone(), projection.claim.clone())) + }) + .collect::>(); + let input = match sdjwt_vc::issuance_input( + &evidence, + request.holder_key.as_ref(), + &structured_projections, + ) { + Ok(input) => input, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "sd-jwt-vc-mapping", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure( + ProblemCode::ServiceUnavailable, + "sd-jwt-vc-mapping", + )); + } + }; + // A signing failure is a safe 503. It never falls back to the + // signed-JWS or unsigned format. + let serialized = match self.signer.sign_sd_jwt_vc(input).await { + Ok(serialized) => serialized, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + }; + ( + serialized.into_bytes(), + EVIDENCE_SD_JWT_VC_MEDIA_TYPE, + Some(self.signer.key_id().to_owned()), + ) + } + ResponseFormat::UnsignedJson => { + // No signing operation runs, but the ordinary signing + // dependency must still be ready for the deployment. + if !self.signer.ready() { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + let envelope = UnsignedEvidenceEnvelope { + schema: EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: UnsignedIntegrityProtection::None, + warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence, + }; + let bytes = serde_json::to_vec(&envelope) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")); + let bytes = match bytes { + Ok(bytes) => bytes, + Err(error) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "release-serialization", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + (bytes, EVIDENCE_UNSIGNED_MEDIA_TYPE, None) + } + }; + + let mut release = material.event( + operation, + AuditPhase::DisclosureRelease, + AuditDecision::Released, + elapsed_millis(started), + ); + release.source_id = Some(source_id); + release.adapter_id = Some(adapter_id); + release.disclosed_concepts = Some(disclosed_concepts); + release.evidence_id = Some(evidence_id); + release.signing_key_id = signing_key_id; + self.audit + .append(release) + .await + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-audit"))?; + Ok(ReleasedEvidence { + format, + media_type, + bytes, + }) + } + + fn source_identity(&self, requirement_id: &str) -> Result<(String, String), RuntimeFailure> { + let requirement = self + .kernel + .requirement(requirement_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "requirement"))?; + let source = self + .bundle() + .config + .sources + .get(&requirement.source) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let adapter_id = Path::new(source.extract_script.as_str()) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty() && name.len() <= 128) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "adapter-id"))?; + Ok((requirement.source.clone(), adapter_id.to_owned())) + } + + fn audit_material( + &self, + scope: &str, + requester_pseudonym: String, + context: &AuthenticatedContext, + resolved: &ResolvedAuthorization, + format: ResponseFormat, + ) -> Result { + let actor_pseudonym = context + .actor() + .map(|actor| self.audit.pseudonym("actor", scope, actor.as_bytes())) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let grant_pseudonym = resolved + .grant_id + .as_deref() + .map(|grant| self.audit.pseudonym("grant", scope, grant.as_bytes())) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let subjects = resolved + .subjects + .iter() + .map(|subject| { + let protected = subject + .audit_pseudonym_input(&resolved.audience, &resolved.purpose) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "subject-pseudonym"))?; + let pseudonym = self + .audit + .pseudonym("subject-selector-bundle", scope, &protected) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + Ok(AuditSubject { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_bundle_pseudonym: Some(pseudonym), + }) + }) + .collect::, RuntimeFailure>>()?; + Ok(AuditMaterial { + assurance_profile: self.bundle().config.assurance_profile, + requirement: resolved.requirement.clone(), + bundle_revision: self.bundle().revision().to_owned(), + purpose: resolved.purpose.clone(), + requester_pseudonym, + actor_pseudonym, + authority: AuditAuthority { + kind: map_authority_kind(resolved.authority_kind), + grant_pseudonym, + }, + subjects, + response_protection: map_response_protection(format), + }) + } + + fn subject_bindings( + &self, + resolved: &ResolvedAuthorization, + ) -> Result, RuntimeFailure> { + resolved + .subjects + .iter() + .map(|subject| { + subject + .binding( + self.subject_binding_secret.expose_secret(), + self.bundle().config.subject_binding.key_version, + &self.bundle().config.service.trust_domain, + &resolved.audience, + &resolved.purpose, + ) + .map(|binding| SubjectBinding { + role: subject.role.clone(), + binding, + }) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "subject-binding")) + }) + .collect() + } + + #[allow(clippy::too_many_arguments)] + async fn append_failure( + &self, + material: &AuditMaterial, + operation: &str, + decision: AuditDecision, + category: &'static str, + source_id: &str, + adapter_id: &str, + started: Instant, + ) -> Result<(), RuntimeFailure> { + let phase = match decision { + AuditDecision::NoMatch | AuditDecision::Ambiguous | AuditDecision::FactMissing => { + AuditPhase::Denial + } + _ => AuditPhase::TransientFailure, + }; + let mut event = material.event(operation, phase, decision, elapsed_millis(started)); + event.source_id = Some(source_id.to_owned()); + event.adapter_id = Some(adapter_id.to_owned()); + event.safe_error_category = Some(category.to_owned()); + self.audit + .append(event) + .await + .map(|_| ()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "failure-audit")) + } +} + +struct AuditMaterial { + assurance_profile: AssuranceProfile, + requirement: String, + bundle_revision: String, + purpose: String, + requester_pseudonym: String, + actor_pseudonym: Option, + authority: AuditAuthority, + subjects: Vec, + response_protection: ResponseProtection, +} + +impl AuditMaterial { + fn event( + &self, + operation: &str, + phase: AuditPhase, + decision: AuditDecision, + duration_milliseconds: u64, + ) -> EvidenceAuditEvent { + let mut event = EvidenceAuditEvent::new( + self.assurance_profile, + operation.to_owned(), + phase, + self.requirement.clone(), + self.bundle_revision.clone(), + self.purpose.clone(), + self.requester_pseudonym.clone(), + self.authority.clone(), + self.subjects.clone(), + self.response_protection, + decision, + duration_milliseconds, + ); + event.actor_pseudonym = self.actor_pseudonym.clone(); + event + } +} + +fn map_response_protection(format: ResponseFormat) -> ResponseProtection { + match format { + ResponseFormat::SignedJws => ResponseProtection::Signed, + ResponseFormat::UnsignedJson => ResponseProtection::Unsigned, + ResponseFormat::SdJwtVc => ResponseProtection::SdJwtVc, + } +} + +fn source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result, RuntimeFailure> { + inputs + .iter() + .map(|input| { + let Some(subject) = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + else { + return Ok(None); + }; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or_else(selector_contract_failure)?; + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or_else(selector_contract_failure)?; + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + Ok((name.clone(), value)) + }) + .collect::, RuntimeFailure>>()?; + Ok(Some(ResolvedSourceSelector { + role: input.role.clone(), + profile: alternative.profile.clone(), + values, + })) + }) + .collect::, RuntimeFailure>>() + .map(|selectors| selectors.into_iter().flatten().collect()) +} + +fn source_selector_input_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let active = inputs + .iter() + .filter(|input| { + resolved + .subjects + .iter() + .any(|subject| subject.role == input.role) + }) + .cloned() + .collect::>(); + if active.is_empty() { + return Err(selector_contract_failure()); + } + selector_input_value(resolved, &active) +} + +fn selector_input_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let mut selectors = JsonMap::new(); + for input in inputs { + let (subject, alternative) = selector_input_subject(resolved, input)?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or_else(selector_contract_failure)?; + values.insert(name.clone(), field.value.as_json()); + } + let mut selector = JsonMap::new(); + selector.insert( + "profile".to_owned(), + Value::String(alternative.profile.clone()), + ); + selector.insert("values".to_owned(), Value::Object(values)); + if selectors + .insert(input.role.clone(), Value::Object(selector)) + .is_some() + { + return Err(selector_contract_failure()); + } + } + Ok(Value::Object(selectors)) +} + +fn selector_input_subject<'a>( + resolved: &'a ResolvedAuthorization, + input: &'a SelectorInput, +) -> Result< + ( + &'a crate::selector::ResolvedSubject, + &'a crate::config::SelectorInputAlternative, + ), + RuntimeFailure, +> { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or_else(selector_contract_failure)?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or_else(selector_contract_failure)?; + Ok((subject, alternative)) +} + +fn selector_contract_failure() -> RuntimeFailure { + failure(ProblemCode::ServiceUnavailable, "selector-contract") +} + +fn failure(problem: ProblemCode, category: &'static str) -> RuntimeFailure { + RuntimeFailure { problem, category } +} + +fn evidence_unavailable_failure() -> RuntimeFailure { + failure(ProblemCode::EvidenceNotAvailable, "evidence-unavailable") +} + +fn map_authority(error: AuthorizationError) -> RuntimeFailure { + match error { + AuthorizationError::Selector => failure(ProblemCode::InvalidSelector, "selector"), + AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority => { + failure(ProblemCode::NotAuthorized, "authorization") + } + AuthorizationError::Binding => failure(ProblemCode::ServiceUnavailable, "subject-binding"), + } +} + +fn map_request_limit(error: RateLimitError) -> RuntimeFailure { + match error { + RateLimitError::RequestExceeded => failure(ProblemCode::RateLimited, "request-rate"), + _ => failure(ProblemCode::ServiceUnavailable, "request-rate"), + } +} + +fn map_selector_limit(error: RateLimitError) -> RuntimeFailure { + match error { + RateLimitError::FailedSelectorExceeded => { + failure(ProblemCode::RateLimited, "selector-rate") + } + _ => failure(ProblemCode::ServiceUnavailable, "selector-rate"), + } +} + +fn source_failure_category(error: &SourceError) -> &'static str { + match error { + SourceError::Credential => "source-credential", + SourceError::Concurrency => "source-concurrency", + SourceError::Timeout => "source-timeout", + SourceError::Redirect => "source-redirect", + SourceError::Status(_) => "source-status", + SourceError::WrongMediaType => "source-media-type", + SourceError::ResponseTooLarge => "source-response-size", + SourceError::InvalidJson + | SourceError::ErrorEnvelope + | SourceError::ProjectionViolation => "source-protocol", + SourceError::InvalidPlan | SourceError::InvalidSelectors | SourceError::Transport => { + "source-unavailable" + } + } +} + +/// Map a closed source-boundary failure to its public problem class. +/// +/// The offline fixture command uses this same function, so its symbolic +/// failure cases cannot drift from the production release pipeline. +pub fn source_failure_problem(_error: &SourceError) -> ProblemCode { + ProblemCode::DependencyUnavailable +} + +fn kernel_failure_category(error: KernelError) -> &'static str { + match error { + KernelError::Preparation => "request-preparation", + KernelError::Extraction => "fact-unavailable", + KernelError::DerivationInput => "derivation-input", + KernelError::SourceProtocol => "source-protocol", + KernelError::Script => "script-failure", + KernelError::Output => "output-gate", + KernelError::Bundle | KernelError::Requirement | KernelError::Evidence => "kernel", + } +} + +/// Map a closed kernel failure to its public problem class. The unresolved +/// classes, including derivation-input inconsistency over a uniquely found +/// record, collapse to one public shape so status codes cannot become an +/// existence oracle. Native audit keeps only a value-free category. +fn kernel_failure_problem(error: KernelError) -> ProblemCode { + match error { + KernelError::Preparation => ProblemCode::ServiceUnavailable, + KernelError::Extraction | KernelError::DerivationInput => ProblemCode::EvidenceNotAvailable, + KernelError::SourceProtocol => ProblemCode::DependencyUnavailable, + KernelError::Script + | KernelError::Output + | KernelError::Bundle + | KernelError::Requirement + | KernelError::Evidence => ProblemCode::ServiceUnavailable, + } +} + +fn map_authority_kind(kind: AuthorityKind) -> AuditAuthorityKind { + match kind { + AuthorityKind::Statutory => AuditAuthorityKind::Statutory, + AuthorityKind::Organizational => AuditAuthorityKind::Organizational, + AuthorityKind::Consent => AuditAuthorityKind::Consent, + AuthorityKind::Delegated => AuditAuthorityKind::Delegated, + AuthorityKind::ExplicitRequest => AuditAuthorityKind::ExplicitRequest, + } +} + +fn requirement_kind_name(kind: RequirementKind) -> &'static str { + match kind { + RequirementKind::Criterion => "criterion", + RequirementKind::InformationRequirement => "information-requirement", + RequirementKind::Constraint => "constraint", + } +} + +fn subject_cardinality_name(cardinality: SubjectCardinality) -> &'static str { + match cardinality { + SubjectCardinality::One => "one", + } +} + +fn value_origin_name(origin: ValueOrigin) -> &'static str { + match origin { + ValueOrigin::AuthenticatedContext => "authenticated-context", + ValueOrigin::AuthenticatedGrant => "authenticated-grant", + ValueOrigin::Request => "request", + } +} + +fn concept_form_name(form: ConceptForm) -> &'static str { + match form { + ConceptForm::Boolean => "boolean", + ConceptForm::ControlledCode => "controlled-code", + ConceptForm::ControlledCategory => "controlled-category", + ConceptForm::BoundedInteger => "bounded-integer", + ConceptForm::BoundedDecimal => "bounded-decimal", + ConceptForm::DateBucket => "date-bucket", + ConceptForm::TimeBucket => "time-bucket", + ConceptForm::AudienceScopedEntityReference => "audience-scoped-entity-reference", + ConceptForm::ControlledCodeList => "controlled-code-list", + ConceptForm::EntityReferenceList => "entity-reference-list", + ConceptForm::ReviewedStructuredValue => "reviewed-structured-value", + } +} + +fn audit_scope(trust_domain: &str, purpose: &str, audience: &str) -> String { + format!( + "v1:{}:{trust_domain}:{}:{purpose}:{}:{audience}", + trust_domain.len(), + purpose.len(), + audience.len() + ) +} + +/// Rate-limit pseudonyms deliberately omit request-controlled dimensions so a +/// principal cannot multiply its budget by varying purpose, audience, or +/// requirement. The pseudonym class and protected input distinguish request +/// and selector-failure budgets within this deployment scope. +fn rate_limit_scope(trust_domain: &str) -> String { + format!("v1-rate:{}:{trust_domain}", trust_domain.len()) +} + +fn canonical_pair(first: &[u8], second: &[u8]) -> Option> { + let first_length = u32::try_from(first.len()).ok()?; + let second_length = u32::try_from(second.len()).ok()?; + let mut output = Vec::with_capacity(8 + first.len() + second.len()); + output.extend_from_slice(&first_length.to_be_bytes()); + output.extend_from_slice(first); + output.extend_from_slice(&second_length.to_be_bytes()); + output.extend_from_slice(second); + Some(output) +} + +fn elapsed_millis(started: Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()) + .unwrap_or(u64::MAX) + .min(86_400_000) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_failures_and_rate_keys_are_value_free() { + let failure = failure(ProblemCode::InvalidSelector, "selector"); + let rendered = format!("{failure:?} {failure}"); + assert!(!rendered.contains("protected-principal")); + + let first = canonical_pair(b"a", b"bc").expect("pair"); + let second = canonical_pair(b"ab", b"c").expect("pair"); + assert_ne!(first, second); + } + + #[test] + fn audit_scope_is_unambiguous_for_component_boundaries() { + assert_ne!( + audit_scope("urn:a", "bc", "https://d.invalid"), + audit_scope("urn:ab", "c", "https://d.invalid") + ); + } + + #[test] + fn rate_limit_scope_cannot_be_fragmented_by_request_dimensions() { + let trust_domain = "urn:example:evidence"; + let shared = rate_limit_scope(trust_domain); + + for (requirement, purpose, audience) in [ + ("adult", "service-enrolment", "https://one.invalid"), + ("residence", "benefit-eligibility", "https://two.invalid"), + ("professional", "licence-check", "https://three.invalid"), + ] { + assert_eq!(rate_limit_scope(trust_domain), shared); + assert_ne!(audit_scope(trust_domain, purpose, audience), shared); + assert!(!shared.contains(requirement)); + assert!(!shared.contains(purpose)); + assert!(!shared.contains(audience)); + } + } + + #[test] + fn public_unavailability_does_not_distinguish_no_match_from_ambiguity() { + let no_match = evidence_unavailable_failure(); + let ambiguous = evidence_unavailable_failure(); + + assert_eq!(no_match.problem(), ambiguous.problem()); + assert_eq!(no_match.category(), ambiguous.category()); + assert_eq!(format!("{no_match:?}"), format!("{ambiguous:?}")); + } + + #[test] + fn fact_absence_and_trusted_script_failures_have_distinct_public_classes() { + assert_eq!( + kernel_failure_problem(KernelError::Extraction), + ProblemCode::EvidenceNotAvailable + ); + for failure in [KernelError::Script, KernelError::Output] { + assert_eq!( + kernel_failure_problem(failure), + ProblemCode::ServiceUnavailable + ); + } + } + + #[test] + fn derivation_input_inconsistency_collapses_with_the_unresolved_classes() { + assert_eq!( + kernel_failure_problem(KernelError::DerivationInput), + kernel_failure_problem(KernelError::Extraction) + ); + assert_eq!( + kernel_failure_problem(KernelError::DerivationInput), + ProblemCode::EvidenceNotAvailable + ); + assert_ne!( + kernel_failure_category(KernelError::DerivationInput), + kernel_failure_category(KernelError::Extraction) + ); + } +} diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs new file mode 100644 index 000000000..23ecdf478 --- /dev/null +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -0,0 +1,5800 @@ +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + fs, + io::Write as _, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use axum::{body::Body, http::Request as HttpRequest}; +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditChainHasher, AuditHashSecret}; +use registry_platform_crypto::{ + sign, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, + SigningProvider, +}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tower::ServiceExt as _; +use wiremock::{ + matchers::{body_json, header, method, path, query_param}, + Mock, MockServer, ResponseTemplate, +}; + +use crate::{ + audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind as AuditAuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, + ResponseProtection, + }, + auth::{AuthenticationClaimsConfig, Authenticator}, + bundle::DeploymentInputs, + config::{AssuranceProfile, ResponseFormat}, + contracts::evidence_contract_accepts, + local_verification::{ + prepare_local_verification_context, verify_local_response, verify_local_response_at, + LocalVerificationContext, + }, + model::{ + Evidence, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, FlattenedJws, + HolderPublicKey, PublicValue, RequestedSelector, RequestedSubject, SelectorValue, + UnsignedEvidenceEnvelope, + }, + observability::{metrics_app, CORRELATION_HEADER, REQUEST_LOG_TARGET}, + problem::ProblemCode, + runtime::{EvidenceRuntime, RuntimeInitializationError}, + server::{build_app, build_app_at_for_test, build_app_with_metrics, serve_listener_for_test}, + signing::EvidenceSigner, + verifier::{ + verify_flattened_jws, verify_sd_jwt_vc, EvidenceVerificationPolicy, ExpectedValueForm, + }, + EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, +}; + +const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-auth-key"}"#; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-evidence-key"}"#; +const TOKEN_ISSUER: &str = "https://identity.invalid"; +const TOKEN_AUDIENCE: &str = "evidence-fixture"; +const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; +const AUTHORITY: &str = "statutory-caseworker-v1"; +const BEARER: &str = "source-bearer-canary"; +const BASIC_USER: &str = "source-user-canary"; +const BASIC_PASSWORD: &str = "source-password-canary"; +const PARENT_REFERENCE: &str = "synthetic-parent-reference-001"; +const NON_PARENT_REFERENCE: &str = "synthetic-non-parent-reference-003"; +const LOG_TOKEN_CANARY: &str = "operational-log-token-canary"; +const LOG_SELECTOR_CANARY: &str = "operational-log-selector-canary"; + +struct AcceptanceRuntime { + _temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + runtime: Arc, + server: MockServer, + audit_path: PathBuf, +} + +struct PreparedAcceptance { + temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + server: MockServer, + audit_path: PathBuf, +} + +/// A prepared bundle and runtime file with no opinion about what serves the +/// configured source origin. +struct PreparedFixture { + temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + audit_path: PathBuf, +} + +struct FailAfterSelfTestSigner { + delegate: LocalJwkSigner, + calls: AtomicUsize, +} + +struct UnavailableReadinessSigner { + delegate: LocalJwkSigner, +} + +/// Start the real Evidence HTTP router over TCP for one operator-driven curl, +/// then verify the returned JWS and durable audit before completing. +/// +/// This intentionally uses the deterministic acceptance source and a static +/// test JWKS. Live provider credentials are neither needed nor read here. +#[tokio::test] +#[ignore = "operator-driven local curl checkpoint"] +async fn first_curl_exercises_and_verifies_the_evidence_server() { + let fixture = acceptance_runtime().await; + // The documented optional unsigned curl may run before the signed one, so + // the deterministic source accepts one or two identical lookups. + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .expect(1..=2) + .mount(&fixture.server) + .await; + + let request = adult_request(); + let token = access_token(Some(parent_grant_claims())); + let state_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/evidence/.first-curl"); + fs::create_dir_all(&state_root).expect("first-curl state directory is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&state_root, fs::Permissions::from_mode(0o700)) + .expect("first-curl state directory is owner-only"); + } + let definitions_path = state_root.join("definitions.json"); + let response_path = state_root.join("response.json"); + let unsigned_path = state_root.join("response-unsigned.json"); + for stale in [&definitions_path, &response_path, &unsigned_path] { + match fs::remove_file(stale) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("stale first-curl output could not be removed: {error}"), + } + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:18080") + .await + .expect("first-curl listener binds on 127.0.0.1:18080"); + let address = listener + .local_addr() + .expect("listener address is available"); + write_secret( + &state_root, + "request.json", + &serde_json::to_string_pretty(&request).expect("request serializes"), + ); + write_secret( + &state_root, + "session.env", + &format!("EVIDENCE_ACCESS_TOKEN={token}\n"), + ); + + println!( + "Evidence first-curl server is ready at http://{address}. The ignored session.env contains only the short-lived synthetic bearer token. Use the plain curl command in products/evidence/FIRST-CURL-TEST.md." + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let runtime = Arc::clone(&fixture.runtime); + let server = tokio::spawn(async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + + let definitions = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&definitions_path) { + if let Ok(definitions) = serde_json::from_slice::(&bytes) { + break definitions; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence discovery returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("curl discovery response arrives within three minutes"); + assert_eq!(definitions.definitions.len(), 4); + assert_eq!( + definitions.configuration_revision, + fixture.runtime.bundle().revision() + ); + let serialized_definitions = + serde_json::to_string(&definitions).expect("discovery response serializes"); + for prohibited in [ + "fixture-agency", + "statutory-caseworker-v1", + "source-a", + "adapters/", + "derivations/", + "codelists/", + "secret:", + ] { + assert!(!serialized_definitions.contains(prohibited)); + } + + let serialized = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&response_path) { + if serde_json::from_slice::(&bytes).is_ok() { + break bytes; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("curl response arrives within three minutes"); + + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request, &serialized), + ) + .expect("curl response JWS verifies against the running Evidence JWKS"); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); + assert_minimized_payload(&serialized); + + shutdown_tx + .send(()) + .expect("first-curl server is still running"); + server + .await + .expect("first-curl server task joins") + .expect("first-curl server stops cleanly"); + + // The optional unsigned leg, when the operator ran it, produced its own + // self-identifying envelope and its own pair of durable audit events. + let unsigned_leg_ran = match fs::read(&unsigned_path) { + Ok(bytes) => { + let envelope: UnsignedEvidenceEnvelope = serde_json::from_slice(&bytes) + .expect("unsigned first-curl output parses as the closed envelope"); + assert_eq!(envelope.evidence.request_nonce, request.request_nonce); + assert!( + verify_flattened_jws( + &bytes, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request, &serialized), + ) + .is_err(), + "the strict JWS verifier must reject the unsigned envelope" + ); + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => panic!("unsigned first-curl output could not be read: {error}"), + }; + let audit = fs::read_to_string(&fixture.audit_path).expect("first-curl audit is readable"); + let expected_audit_lines = if unsigned_leg_ran { 4 } else { 2 }; + assert_eq!(audit.lines().count(), expected_audit_lines); + assert!(!audit.contains(&request.request_nonce)); + if unsigned_leg_ran { + println!( + "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200 in both formats, the JWS verified, the unsigned envelope was self-identifying and rejected by the JWS verifier, adult-status was true, minimization held, and all four audit events were durable." + ); + } else { + println!( + "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." + ); + } +} + +#[async_trait] +impl SigningProvider for UnavailableReadinessSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.delegate.algorithm() + } + + fn key_id(&self) -> &str { + self.delegate.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.delegate.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + KeyReadiness::NotReady + } + + async fn sign(&self, payload: &[u8]) -> Result, SigningError> { + self.delegate.sign(payload).await + } +} + +#[async_trait] +impl SigningProvider for FailAfterSelfTestSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.delegate.algorithm() + } + + fn key_id(&self) -> &str { + self.delegate.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.delegate.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + KeyReadiness::Ready + } + + async fn sign(&self, payload: &[u8]) -> Result, SigningError> { + if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { + self.delegate.sign(payload).await + } else { + Err(SigningError::external("synthetic unavailable signer")) + } + } +} + +#[tokio::test] +async fn real_router_serves_all_definitions_concurrently_without_crossing_boundaries() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let health = http.get("/health").await; + health.assert_status_ok(); + assert_eq!(health.json::(), json!({"status": "ok"})); + let ready = http.get("/ready").await; + ready.assert_status_ok(); + assert_eq!(ready.json::(), json!({"status": "ready"})); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let jwks = http.get("/.well-known/evidence/jwks.json").await; + jwks.assert_status_ok(); + assert_eq!(jwks.header("content-type"), "application/jwk-set+json"); + assert_eq!( + jwks.json::(), + *fixture.runtime.jwks() + ); + + let standard_token = access_token(None); + let parent_token = access_token(Some(parent_grant_claims())); + let standard_discovery = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {standard_token}")) + .await; + standard_discovery.assert_status_ok(); + assert_eq!( + standard_discovery.header("content-type"), + "application/json" + ); + let standard_definitions = standard_discovery.json::(); + assert_eq!(standard_definitions.definitions.len(), 3); + assert_eq!( + standard_definitions.configuration_revision, + fixture.runtime.bundle().revision() + ); + assert!(standard_definitions + .definitions + .iter() + .all(|definition| !definition.requirement.contains("legal-parent"))); + + let parent_discovery = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {parent_token}")) + .await; + parent_discovery.assert_status_ok(); + let parent_definitions = parent_discovery.json::(); + assert_eq!(parent_definitions.definitions.len(), 4); + let adult_definition = parent_definitions + .definitions + .iter() + .find(|definition| definition.requirement.ends_with(":adult-status:v1")) + .expect("authorized adult definition is discoverable"); + assert!(adult_definition.subjects[0].selector.fields.iter().any( + |field| matches!(field, EvidenceSelectorField::Date { name } if name == "birth_date") + )); + let parent_definition = parent_definitions + .definitions + .iter() + .find(|definition| { + definition + .requirement + .ends_with(":legal-parent-relationship:v1") + }) + .expect("grant-backed relationship definition is discoverable"); + assert_eq!( + parent_definition.subjects[1].selector.value_origin, + "authenticated-grant" + ); + let serialized_discovery = + serde_json::to_string(&parent_definitions).expect("discovery serializes"); + for prohibited in [ + "fixture-agency", + "statutory-caseworker-v1", + "source-a", + "adapters/", + "derivations/", + "codelists/", + "secret:", + ] { + assert!( + !serialized_discovery.contains(prohibited), + "discovery exposed protected deployment material" + ); + } + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "metadata discovery must not create evidence-data audit records" + ); + + mount_success_sources(&fixture.server, false).await; + let adult_request = adult_request(); + let residence_request = residence_request(); + let licence_request = licence_request(); + let parent_request = parent_request(); + + let adult = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&adult_request); + let residence = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&residence_request); + let licence = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&licence_request); + let parent = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {parent_token}")) + .json(&parent_request); + let (adult, residence, licence, parent) = tokio::join!(adult, residence, licence, parent); + + for (response, request, expected_concept, expected_value, expected_roles) in [ + ( + adult, + &adult_request, + "urn:example:fixture:concept:adult-status", + PublicValue::Boolean(true), + vec!["subject"], + ), + ( + residence, + &residence_request, + "urn:example:fixture:concept:residence-region", + PublicValue::String("REGION-NORTH".to_owned()), + vec!["subject"], + ), + ( + licence, + &licence_request, + "urn:example:fixture:concept:licence-active", + PublicValue::Boolean(true), + vec!["subject"], + ), + ( + parent, + &parent_request, + "urn:example:fixture:concept:legal-parent-relationship-confirmed", + PublicValue::Boolean(false), + vec!["child", "candidate-parent"], + ), + ] { + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jose+json"); + let jws = response.json::(); + let serialized = serde_json::to_vec(&jws).expect("router JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, request, &serialized), + ) + .expect("router response verifies"); + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + expected_roles + ); + assert!(evidence.supported_values.iter().any(|value| { + value.provides_value_for == expected_concept && value.value == expected_value + })); + assert_minimized_payload(&serialized); + } + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 4); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 4); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +/// One identifier is minted per request at the boundary and stays the same +/// everywhere an operator can observe it. Without a response header the +/// identifier the problem body reports is the only copy the caller ever sees, +/// so a support report cannot be joined to a server-side record. +#[tokio::test] +async fn every_response_carries_the_request_scoped_correlation_identifier() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let health = http.get("/health").await; + health.assert_status_ok(); + let first = correlation_id(&health); + + // A rejected request reports one identifier, not one per error site. + let denied = http.get("/v1/evidence-definitions").await; + assert_eq!(denied.status_code(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(correlation_id(&denied), denied.json::()["operation"]); + + // An unrouted request correlates on the same terms. + let unknown = http.get("/absent").await; + assert_eq!( + correlation_id(&unknown), + unknown.json::()["operation"] + ); + + // Identifiers are request-scoped, never process-scoped. + let second = http.get("/health").await; + assert_ne!(first, correlation_id(&second)); +} + +/// Section 12 fixes exactly what an operational record may contain. Anything +/// outside that set is a disclosure the record has never been reviewed for, so +/// the field set is asserted whole rather than field by field. +#[test] +fn operational_logs_carry_only_the_reviewed_fields_and_disclose_no_value() { + let emitted = capture_evidence_logs(|| async { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + http.get("/health").await.assert_status_ok(); + + // The body parses and its selector values are held in memory before + // authentication rejects the request, so this exercises the disclosure + // path rather than an early parse failure. + let rejected = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {LOG_TOKEN_CANARY}")) + .json(&request( + "urn:example:fixture:requirement:adult-status:v1", + "fixture-eligibility", + vec![requested_subject( + "subject", + "person-demographics-v1", + Some([ + ("given_name", LOG_SELECTOR_CANARY), + ("family_name", "Diallo"), + ("birth_date", "2000-01-01"), + ]), + )], + )) + .await; + assert_eq!( + rejected.status_code(), + axum::http::StatusCode::UNAUTHORIZED, + "the canary token is not a verifiable access token" + ); + }); + + let served: Vec<&Value> = emitted + .iter() + .filter(|record| record["target"] == json!(REQUEST_LOG_TARGET)) + .collect(); + assert_eq!(served.len(), 2, "one operational record per served request"); + + for record in &served { + let fields = record["fields"] + .as_object() + .expect("an operational record carries fields"); + assert_eq!( + fields.keys().map(String::as_str).collect::>(), + BTreeSet::from([ + "message", + "route", + "operation", + "duration_ms", + "status", + "error" + ]) + ); + assert!(fields["duration_ms"].is_u64()); + assert!(!fields["operation"] + .as_str() + .expect("the operation identifier is a string") + .is_empty()); + } + + // The route template, never the requested path, and a status category + // rather than a code. + assert_eq!(served[0]["fields"]["route"], json!("/health")); + assert_eq!(served[0]["fields"]["status"], json!("success")); + assert_eq!(served[0]["fields"]["error"], json!("none")); + assert_eq!(served[1]["fields"]["route"], json!("/v1/evidence")); + assert_eq!(served[1]["fields"]["status"], json!("client_error")); + assert_eq!(served[1]["fields"]["error"], json!("authentication_failed")); + + // No token, selector value, purpose, or requirement identity reaches an + // operational record this crate emits. + let raw = serde_json::to_string(&emitted).expect("captured records serialize"); + for canary in [ + LOG_TOKEN_CANARY, + LOG_SELECTOR_CANARY, + "Diallo", + "2000-01-01", + "fixture-eligibility", + "adult-status", + ] { + assert!( + !raw.contains(canary), + "an operational log disclosed {canary}" + ); + } +} + +/// Counters describe traffic. They must say how the boundary behaved without +/// naming what any request asked for, and their label set must stay bounded by +/// the route table rather than by anything a caller can send. +#[tokio::test] +async fn metrics_report_bounded_series_without_disclosing_request_content() { + let fixture = acceptance_runtime().await; + let (app, metrics) = build_app_with_metrics(Arc::clone(&fixture.runtime)); + let http = TestServer::new(app); + + http.get("/health").await.assert_status_ok(); + http.get("/health").await.assert_status_ok(); + let denied = http.get("/v1/evidence-definitions").await; + assert_eq!(denied.status_code(), axum::http::StatusCode::UNAUTHORIZED); + http.get("/absent-path-canary").await; + + let exposition = TestServer::new(metrics_app(Arc::clone(&metrics))); + let rendered = exposition.get("/metrics").await; + rendered.assert_status_ok(); + assert_eq!(rendered.header("content-type"), "text/plain; version=0.0.4"); + let body = rendered.text(); + + assert!(body.contains( + "evidence_http_requests_total{route=\"/health\",method=\"GET\",status=\"success\",error=\"none\"} 2\n" + )); + assert!(body.contains( + "evidence_http_requests_total{route=\"/v1/evidence-definitions\",method=\"GET\",status=\"client_error\",error=\"authentication_failed\"} 1\n" + )); + assert!(body.contains("evidence_http_request_duration_seconds_count{route=\"/health\"")); + + // An unrouted request is one fixed label, never the path the caller chose. + assert!(body.contains("route=\"unmatched\"")); + assert!(!body.contains("absent-path-canary")); + for canary in privacy_canaries() { + assert!(!body.contains(canary), "metrics disclosed {canary}"); + } + + // The metrics application answers for metrics only; it is not a second + // way to reach the evidence routes. + for path in ["/health", "/v1/evidence-definitions", "/openapi.json"] { + assert_eq!( + exposition.get(path).await.status_code(), + axum::http::StatusCode::NOT_FOUND, + "the metrics listener served {path}" + ); + } +} + +/// The metrics endpoint is opt-in deployment surface on its own socket. The +/// evidence listener must not gain a metrics route, and the two listeners must +/// share one lifecycle so shutdown leaves nothing behind. +#[tokio::test] +async fn a_configured_metrics_listener_serves_beside_the_evidence_listener() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let evidence_port = reserved_port(); + let metrics_port = reserved_port(); + let mut document = + fs::read_to_string(&prepared.runtime_path).expect("runtime configuration is readable"); + replace_exact( + &mut document, + "port: 8080", + &format!("port: {evidence_port}"), + 1, + ); + document.push_str(&format!( + "metricsListener:\n bindHost: 127.0.0.1\n port: {metrics_port}\n" + )); + // The prepared document is already read-only, as deployment requires, so + // this variant is written before the runtime captures it. + make_file_writable(&prepared.runtime_path); + fs::write(&prepared.runtime_path, &document).expect("runtime configuration is rewritten"); + make_file_read_only(&prepared.runtime_path); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the runtime initializes with a metrics listener"), + ); + + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let server = tokio::spawn(crate::server::serve(runtime, async move { + let _ = stopped.await; + })); + let client = reqwest::Client::new(); + let evidence = format!("http://127.0.0.1:{evidence_port}"); + let telemetry = format!("http://127.0.0.1:{metrics_port}"); + await_ready(&client, &format!("{evidence}/health")).await; + + let exposition = client + .get(format!("{telemetry}/metrics")) + .send() + .await + .expect("the metrics listener answers"); + assert_eq!(exposition.status(), reqwest::StatusCode::OK); + assert!(exposition + .text() + .await + .expect("the exposition is readable") + .contains("evidence_http_requests_total{route=\"/health\"")); + + // The evidence listener gained no metrics route of its own. + let on_evidence_listener = client + .get(format!("{evidence}/metrics")) + .send() + .await + .expect("the evidence listener answers"); + assert_eq!( + on_evidence_listener.status(), + reqwest::StatusCode::BAD_REQUEST + ); + assert_eq!( + on_evidence_listener + .json::() + .await + .expect("problem body")["code"], + json!("malformed_request") + ); + + let _ = stop.send(()); + server + .await + .expect("the service task joins") + .expect("the service stops cleanly"); + + // One shutdown closes both sockets. + assert!(client + .get(format!("{telemetry}/metrics")) + .send() + .await + .is_err()); + assert!(client + .get(format!("{evidence}/health")) + .send() + .await + .is_err()); +} + +/// Reserve an ephemeral port and release it, so a test can name a port in +/// configuration that the operating system has just confirmed is free. +fn reserved_port() -> u16 { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is available"); + listener + .local_addr() + .expect("the reserved socket has an address") + .port() +} + +async fn await_ready(client: &reqwest::Client, url: &str) { + for _ in 0..100 { + if let Ok(response) = client.get(url).send().await { + if response.status().is_success() { + return; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the evidence listener never became reachable at {url}"); +} + +fn correlation_id(response: &axum_test::TestResponse) -> Value { + json!(response + .header(CORRELATION_HEADER) + .to_str() + .expect("the correlation header is ASCII")) +} + +/// Collect every record this crate emits while `body` runs. +/// +/// The subscriber is thread-local and the request future is driven to +/// completion on this thread, so records emitted from the detached evaluation +/// task land in the same buffer. +/// +/// Earlier tests serve requests with no subscriber installed, which caches the +/// request boundary's callsite as permanently uninteresting and drops the +/// global maximum level to off. A thread-local subscriber does not undo that on +/// its own, so one subscriber is installed process-wide on first use and routes +/// each record to the buffer of the thread that emitted it. Tests running +/// concurrently on other threads have no buffer and their records are dropped. +fn capture_evidence_logs(body: F) -> Vec +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + static INSTALLED: std::sync::Once = std::sync::Once::new(); + INSTALLED.call_once(|| { + tracing::subscriber::set_global_default( + tracing_subscriber::fmt() + .json() + .with_max_level(tracing::Level::INFO) + .with_writer(CapturedLogs) + .finish(), + ) + .expect("this test binary installs no other subscriber"); + }); + + let buffer = Arc::new(std::sync::Mutex::new(Vec::::new())); + CAPTURED_LOGS.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&buffer))); + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a single-threaded executor builds"); + executor.block_on(body()); + CAPTURED_LOGS.with(|slot| *slot.borrow_mut() = None); + + let raw = buffer + .lock() + .expect("the log buffer is not poisoned") + .clone(); + String::from_utf8(raw) + .expect("operational records are UTF-8") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record["target"] + .as_str() + .is_some_and(|target| target.starts_with("registry_evidence")) + }) + .collect() +} + +thread_local! { + /// The buffer `capture_evidence_logs` is filling on this thread, if any. + static CAPTURED_LOGS: std::cell::RefCell>>>> = + const { std::cell::RefCell::new(None) }; +} + +#[derive(Clone)] +struct CapturedLogs; + +impl std::io::Write for CapturedLogs { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + // A record emitted outside a capture window belongs to a test that is + // not inspecting logs, so it is deliberately discarded rather than + // written anywhere. + let _ = CAPTURED_LOGS.try_with(|slot| { + if let Some(sink) = slot.borrow().as_ref() { + sink.lock() + .expect("the log buffer is not poisoned") + .extend_from_slice(buffer); + } + }); + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLogs { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + self.clone() + } +} + +#[tokio::test] +async fn openapi_route_serves_the_generated_contract_without_authentication_or_source_access() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let document = http.get("/openapi.json").await; + document.assert_status_ok(); + assert_eq!(document.header("content-type"), "application/openapi+json"); + assert_eq!(document.header("cache-control"), "no-store"); + + // The served bytes are the committed release artifact, not a second + // hand-maintained description of the same routes. + let generated = crate::contracts::documents().expect("generated contracts build"); + assert_eq!(document.text(), generated[crate::contracts::OPENAPI_FILE]); + + // The document is static public material: it names no definition, reveals + // no deployment revision, and reaches no source. + let served = document.json::(); + assert_eq!(served["openapi"], json!("3.1.0")); + assert!(served["paths"]["/openapi.json"]["get"].is_object()); + assert!(!document + .text() + .contains(&fixture.runtime.bundle().revision().to_string())); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // Only GET is in the contract; anything else joins the closed unknown-route + // problem response. + let rejected = http.post("/openapi.json").await; + assert_eq!( + rejected.status_code(), + ProblemCode::MalformedRequest.status() + ); + assert_eq!( + rejected.json::()["code"], + json!(ProblemCode::MalformedRequest.code()) + ); + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert!(audit.is_empty()); +} + +#[tokio::test] +async fn discovery_requires_authentication_and_returns_no_unentitled_definitions() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let missing = http.get("/v1/evidence-definitions").await; + assert_eq!(missing.status_code(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!( + missing.json::()["code"], + json!("authentication_failed") + ); + + let filtered = http + .get("/v1/evidence-definitions?requirement=caller-selected") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .await; + assert_eq!(filtered.status_code(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(filtered.json::()["code"], json!("malformed_request")); + + let body_response = build_app(Arc::clone(&fixture.runtime)) + .oneshot( + HttpRequest::builder() + .uri("/v1/evidence-definitions") + .header("authorization", format!("Bearer {}", access_token(None))) + .body(Body::from("{}")) + .expect("discovery request is valid"), + ) + .await + .expect("discovery router responds"); + assert_eq!(body_response.status(), axum::http::StatusCode::BAD_REQUEST); + + let now = Utc::now().timestamp(); + let unentitled = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "unentitled-discovery-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["unentitled-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let response = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {unentitled}")) + .await; + response.assert_status_ok(); + assert!(response + .json::() + .definitions + .is_empty()); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "denied discovery must not create evidence-data audit records" + ); +} + +#[tokio::test] +async fn discovery_omits_an_authority_shape_that_the_runtime_would_deny_as_ambiguous() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "authorityProfiles:\n statutory-caseworker-v1:", + r#"authorityProfiles: + overlapping-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} + statutory-caseworker-v1:"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("overlapping runtime initializes for fail-closed request decisions"), + ); + let http = TestServer::new(build_app(runtime)); + + let response = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .await; + response.assert_status_ok(); + let definitions = response.json::(); + assert_eq!(definitions.definitions.len(), 2); + assert!(definitions + .definitions + .iter() + .all(|definition| !definition.requirement.ends_with(":adult-status:v1"))); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!(fs::read_to_string(&prepared.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn discovery_uses_the_bounded_per_principal_request_budget() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + "rateLimits: {requestsPerPrincipalPerMinute: 1, burstPerPrincipal: 1, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("rate-limited discovery runtime initializes"), + ); + let http = TestServer::new(build_app(runtime)); + let token = access_token(None); + + http.get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + let limited = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!( + limited.status_code(), + axum::http::StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!(limited.json::()["code"], json!("rate_limited")); + assert_eq!(limited.header("retry-after"), "1"); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); +} + +#[tokio::test] +async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture() { + let fixture = acceptance_runtime().await; + let captured_revision = fixture.runtime.bundle().revision().to_owned(); + let captured_runtime_revision = fixture.runtime.runtime_revision().to_owned(); + let captured_config = fixture + .runtime + .bundle() + .artifact("evidence.yaml") + .expect("captured configuration exists") + .to_vec(); + let adapter_path = "adapters/adult-status-source.rhai"; + let captured_adapter = fixture + .runtime + .bundle() + .artifact(adapter_path) + .expect("captured adapter exists") + .to_vec(); + + make_writable(&fixture.bundle_root); + make_file_writable(&fixture.runtime_path); + fs::write( + &fixture.runtime_path, + b"this: is-not-the-captured-runtime\n", + ) + .expect("replace on-disk runtime configuration"); + fs::write( + fixture.bundle_root.join("evidence.yaml"), + b"this: is-not-the-captured-revision\n", + ) + .expect("replace on-disk configuration"); + fs::remove_file(fixture.bundle_root.join(adapter_path)).expect("remove on-disk adapter"); + fs::write( + fixture.bundle_root.join("adapters/fallback.rhai"), + b"fn extract(_) { no_match() }\n", + ) + .expect("add an unreferenced fallback-like artifact"); + + assert_eq!(fixture.runtime.bundle().revision(), captured_revision); + assert_eq!( + fixture.runtime.runtime_revision(), + captured_runtime_revision + ); + assert_eq!( + fixture.runtime.bundle().artifact("evidence.yaml"), + Some(captured_config.as_slice()) + ); + assert_eq!( + fixture.runtime.bundle().artifact(adapter_path), + Some(captured_adapter.as_slice()) + ); + assert!(fixture + .runtime + .bundle() + .artifact("adapters/fallback.rhai") + .is_none()); + + mount_adult_source(&fixture.server, None).await; + let request = adult_request(); + let jws = fixture + .runtime + .evaluate( + "operation-captured-bundle-revision", + &access_token(None), + &request, + ) + .await + .expect("captured runtime still evaluates with captured artifacts"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request, &serialized), + ) + .expect("captured-revision assertion verifies"); + assert_eq!(evidence.configuration_revision, captured_revision); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); +} + +#[tokio::test] +async fn local_runtime_without_fixture_references_keeps_the_real_security_path() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let local_issuer = prepared.server.uri(); + let auth_private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + Mock::given(method("GET")) + .and(path("/.well-known/jwks.json")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"keys": [auth_private.public()]})), + ) + .mount(&prepared.server) + .await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let strict = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + let mut local = strict + .replace( + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + ) + .lines() + .filter(|line| !line.trim_start().starts_with("fixtures:")) + .collect::>() + .join("\n"); + replace_exact( + &mut local, + "issuer: https://identity.invalid", + &format!("issuer: {local_issuer}"), + 1, + ); + replace_exact( + &mut local, + "jwksUri: https://identity.invalid/.well-known/jwks.json", + &format!("jwksUri: {local_issuer}/.well-known/jwks.json"), + 1, + ); + fs::write(&configuration_path, local).expect("local configuration is written"); + let fixture_directory = prepared.bundle_root.join("fixtures"); + for entry in fs::read_dir(&fixture_directory).expect("fixture directory reads") { + fs::remove_file(entry.expect("fixture entry reads").path()) + .expect("unreferenced fixture is removed"); + } + make_read_only(&prepared.bundle_root); + + // Close independent expectations before the source exists and before a + // response or audit record can exist. Preparation uses the deployed + // profile-aware authenticator, not the in-memory test override. + let request = adult_request(); + let token = access_token_for_issuer(&local_issuer, "requester-principal-canary", None); + let deployment = DeploymentInputs::load(&prepared.runtime_path) + .expect("the immutable local deployment reloads"); + let context = prepare_local_verification_context(&deployment, &request, &token) + .await + .expect("real local token closes the verification context"); + assert!( + !prepared.audit_path.exists(), + "context preparation never opens audit storage" + ); + let preparation_requests = prepared + .server + .received_requests() + .await + .expect("authentication request journal is available"); + assert!( + preparation_requests.iter().all(|request| { + request.method.as_str() == "GET" && request.url.path() == "/.well-known/jwks.json" + }), + "context preparation reaches only the configured authentication JWKS" + ); + + let mut bad_token = token.clone(); + let last = bad_token.pop().expect("token has a signature"); + bad_token.push(if last == 'A' { 'B' } else { 'A' }); + assert!( + prepare_local_verification_context(&deployment, &request, &bad_token) + .await + .is_err(), + "a token that fails real signature verification cannot create context" + ); + + let mut holder_request = request.clone(); + holder_request.holder_key = Some(HolderPublicKey { + kty: "OKP".to_owned(), + crv: "Ed25519".to_owned(), + x: "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc".to_owned(), + alg: Some("EdDSA".to_owned()), + kid: Some("acceptable-holder-key".to_owned()), + }); + assert!( + holder_request + .holder_key + .as_ref() + .is_some_and(HolderPublicKey::is_acceptable), + "the negative exercises an otherwise acceptable holder key" + ); + assert!( + prepare_local_verification_context(&deployment, &holder_request, &token) + .await + .is_err(), + "the signed-JWS-only seam rejects even an acceptable holder key" + ); + + let mut wrong_request = request.clone(); + wrong_request.request_nonce = URL_SAFE_NO_PAD.encode([0x22; 32]); + let wrong_request_context = + prepare_local_verification_context(&deployment, &wrong_request, &token) + .await + .expect("an independently valid request creates its own context"); + + let mut wrong_subject = request.clone(); + wrong_subject.subjects[0] + .selector + .values + .as_mut() + .expect("adult selectors are request-owned") + .insert( + "family_name".to_owned(), + SelectorValue::String("Different".to_owned()), + ); + let wrong_subject_context = + prepare_local_verification_context(&deployment, &wrong_subject, &token) + .await + .expect("an authorized different subject creates a different binding"); + + let runtime = Arc::new( + EvidenceRuntime::initialize(&prepared.runtime_path) + .await + .expect("local runtime initializes without an authenticator override"), + ); + assert_eq!( + runtime.bundle().config.assurance_profile, + AssuranceProfile::Local + ); + assert!(runtime.bundle().fixtures.is_empty()); + + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let definitions_response = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {token}")) + .await; + definitions_response.assert_status_ok(); + let definitions = definitions_response.json::(); + assert_eq!(definitions.assurance_profile, AssuranceProfile::Local); + + mount_adult_source(&prepared.server, None).await; + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&request) + .await; + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jose+json"); + let jws = response.json::(); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let verified = verify_local_response(context.clone(), &serialized) + .expect("the response strictly verifies against pre-response state"); + assert_eq!(verified.request_nonce, request.request_nonce); + assert_eq!( + verified.supported_values[0].value, + PublicValue::Boolean(true) + ); + assert!( + verify_local_response(wrong_request_context, &serialized).is_err(), + "a response cannot verify against another retained request" + ); + assert!( + verify_local_response(wrong_subject_context, &serialized).is_err(), + "a response cannot verify against another subject binding" + ); + + let context_json = serde_json::to_value(&context).expect("context serializes"); + assert_eq!( + context_json["trustedJwks"], + serde_json::to_value(runtime.jwks()).expect("runtime JWKS serializes"), + "the closed context pins the exact public signing JWKS" + ); + for (pointer, replacement, reason) in [ + ( + "/verificationPolicy/configurationRevision", + json!("sha256:wrong-bundle"), + "another bundle revision", + ), + ( + "/verificationPolicy/expectedSubjects/0/binding", + json!(format!("urn:evidence:subject:v1_{}", "A".repeat(43))), + "a changed retained subject binding", + ), + ( + "/verificationPolicy/expectedAssuranceProfile", + json!("production"), + "a production assurance expectation", + ), + ( + "/trustedJwks/keys/0/x", + json!(URL_SAFE_NO_PAD.encode([0x44; 32])), + "a changed trust key", + ), + ] { + let mut changed = context_json.clone(); + *changed + .pointer_mut(pointer) + .unwrap_or_else(|| panic!("context pointer {pointer} exists")) = replacement; + let changed: LocalVerificationContext = + serde_json::from_value(changed).expect("changed context remains structurally closed"); + assert!( + verify_local_response(changed, &serialized).is_err(), + "response must fail against {reason}" + ); + } + + let mut tampered_response = serde_json::to_value(&jws).expect("flattened response serializes"); + tampered_response["signature"] = json!("A".repeat(86)); + assert!( + verify_local_response( + context.clone(), + &serde_json::to_vec(&tampered_response).expect("tampered response serializes"), + ) + .is_err(), + "response tampering fails closed" + ); + + let expired_at = DateTime::parse_from_rfc3339(&verified.valid_until) + .expect("validUntil parses") + .with_timezone(&Utc) + + chrono::Duration::seconds( + i64::try_from(runtime.bundle().config.signing.verifier_clock_skew_seconds) + .expect("clock skew fits i64") + + 1, + ); + assert!( + verify_local_response_at(context, &serialized, expired_at).is_err(), + "an expired response fails strict local verification" + ); + + let evidence = verify_flattened_jws( + &serialized, + runtime.jwks(), + &verification_policy(&runtime, &request, &serialized), + ) + .expect("local assertion verifies under an explicit local expectation"); + assert_eq!(evidence.assurance_profile, AssuranceProfile::Local); + + let events = fs::read_to_string(&prepared.audit_path).expect("audit reads"); + let events = events + .lines() + .map(|line| serde_json::from_str::(line).expect("audit event parses")) + .collect::>(); + assert_eq!(events.len(), 2); + assert!(events + .iter() + .all(|event| event["record"]["assuranceProfile"] == json!("local"))); +} + +#[tokio::test] +async fn admitted_evaluation_survives_client_disconnect_and_keeps_audit_chain_usable() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(750))).await; + + let request = adult_request(); + let client_token = access_token(None); + let body = serde_json::to_vec(&request).expect("request serializes"); + let app = build_app(Arc::clone(&fixture.runtime)); + let client_bound = tokio::spawn(async move { + app.oneshot( + HttpRequest::builder() + .method("POST") + .uri("/v1/evidence") + .header("authorization", format!("Bearer {client_token}")) + .header("content-type", "application/json") + .body(Body::from(body)) + .expect("HTTP request builds"), + ) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + client_bound.abort(); + assert!( + client_bound + .await + .expect_err("client-bound handler is cancelled") + .is_cancelled(), + "the regression must exercise handler cancellation" + ); + + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); + wait_for_runtime_ready(&fixture.runtime).await; + + fixture.server.reset().await; + mount_adult_source(&fixture.server, None).await; + fixture + .runtime + .evaluate( + "operation-after-client-disconnect", + &access_token(None), + &adult_request(), + ) + .await + .expect("a later evaluation can append to the same audit chain"); + let audit = wait_for_audit_counts(&fixture.audit_path, 2, 2).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 2); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 2); + assert!(fixture.runtime.ready().await); +} + +#[tokio::test] +async fn graceful_shutdown_waits_for_admitted_evaluation_and_terminal_audit() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(750))).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener + .local_addr() + .expect("listener address is available"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let runtime = Arc::clone(&fixture.runtime); + let serving = tokio::spawn(async move { + crate::server::serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + tokio::task::yield_now().await; + + let client = reqwest::Client::builder() + .no_proxy() + .build() + .expect("test client builds"); + let request = adult_request(); + let token = access_token(None); + let response = tokio::spawn(async move { + client + .post(format!("http://{address}/v1/evidence")) + .bearer_auth(token) + .json(&request) + .send() + .await + .expect("request completes") + }); + wait_for_source_request_count(&fixture.server, 1).await; + shutdown_tx + .send(()) + .expect("server still observes shutdown signal"); + assert!( + !serving.is_finished(), + "shutdown cannot finish while protected evaluation is active" + ); + + let response = response.await.expect("request task completes"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + tokio::time::timeout(Duration::from_secs(3), serving) + .await + .expect("server drains before timeout") + .expect("server task does not panic") + .expect("server exits cleanly"); + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); +} + +#[tokio::test] +async fn weak_subject_binding_key_fails_initialization_before_source_access() { + let prepared = prepare_acceptance("0123456789012345678901234567890").await; + let result = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await; + let error = match result { + Ok(_) => panic!("a 31-byte subject-binding key must be rejected"), + Err(error) => error, + }; + assert!( + matches!(error, RuntimeInitializationError::Secrets), + "weak binding key failed at the wrong boundary: {error:?}" + ); + assert!( + prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "initialization cannot contact an evidence source" + ); +} + +#[tokio::test] +async fn readiness_fails_for_missing_credentials_tampered_audit_and_unready_signing() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + fs::remove_file(prepared.temporary.path().join("secrets/source-a-token")) + .expect("source credential is removed"); + assert!( + !runtime.ready().await, + "missing source credentials deny readiness" + ); + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + fs::write(&prepared.audit_path, b"{}\n").expect("audit is tampered"); + assert!( + !runtime.ready().await, + "invalid audit chain denies readiness" + ); + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let provider: Arc = Arc::new(UnavailableReadinessSigner { + delegate: LocalJwkSigner::new(private).expect("test signer builds"), + }); + let signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("provider self-test succeeds independently of readiness posture"); + runtime.replace_signer_for_test(signer); + assert!( + !runtime.ready().await, + "unready signing provider denies readiness" + ); +} + +/// Readiness asks the access-token issuer for its key set and does not let the +/// answer decide readiness. +/// +/// The issuer is not this deployment's to fix, and every replica shares it. A +/// readiness check that failed on it would take the whole deployment out of +/// rotation at once, for a cause removing it from rotation cannot address, and +/// would do so while the verifier was still accepting tokens signed by keys +/// already in hand. The probe stays because the log line it produces is worth +/// having; readiness stays local because the traffic decision is. +#[tokio::test] +async fn readiness_reports_an_unretrievable_issuer_key_set_without_denying_readiness() { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let issuer = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"keys": [private.public()]}))) + .mount(&issuer) + .await; + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let reachable = EvidenceRuntime::initialize_with_authenticator( + &prepared.runtime_path, + fetching_authenticator(&format!("{}/jwks", issuer.uri())), + ) + .await + .expect("runtime initializes"); + assert!( + reachable.ready().await, + "a retrievable issuer key set is ready" + ); + + // Port 1 on the loopback interface refuses: the shape of a private CA the + // service does not trust or an issuer that is down, where the address is + // configured and nothing answers on it. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let unreachable = EvidenceRuntime::initialize_with_authenticator( + &prepared.runtime_path, + fetching_authenticator("http://127.0.0.1:1/jwks"), + ) + .await + .expect("runtime initializes"); + assert!( + unreachable.ready().await, + "an unretrievable issuer key set is reported, not made a traffic decision" + ); + // The probe answered, which is what the report is made from, and answering + // did not disturb the local readiness verdict on a second check either. + assert!( + unreachable.ready().await, + "a repeated check under a suppressed probe holds the same verdict" + ); +} + +#[tokio::test] +async fn access_audit_failure_blocks_credentials_and_source_access() { + let fixture = acceptance_runtime().await; + fs::write(&fixture.audit_path, b"{}\n").expect("audit tamper writes"); + + let error = fixture + .runtime + .evaluate( + "operation-access-audit-failure", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("an unverifiable access audit must fail closed"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "source credentials and data requests remain untouched" + ); +} + +#[tokio::test] +async fn missing_principal_never_falls_back_to_client_id_or_azp() { + let fixture = acceptance_runtime().await; + let now = Utc::now().timestamp(); + let token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "client_id": "fallback-client-canary", + "azp": "fallback-authorized-party-canary", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let error = fixture + .runtime + .evaluate("operation-missing-principal", &token, &adult_request()) + .await + .expect_err("a token without the configured principal claim is denied"); + assert_eq!(error.problem(), ProblemCode::AuthenticationFailed); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "authentication failure cannot acquire source credentials" + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("fallback-client-canary")); + assert!(!audit.contains("fallback-authorized-party-canary")); +} + +/// A confirmation claim binds the token to a sender-provided proof this +/// profile does not validate. Accepting it as an ordinary bearer would +/// silently discard the constraint the authorization server issued it under, +/// so the only safe outcome is denial. +#[tokio::test] +async fn sender_constrained_tokens_are_denied_rather_than_downgraded() { + for confirmation in [ + json!({"jkt": "sender-constraint-canary"}), + json!({"x5t#S256": "sender-constraint-canary"}), + json!({"jwk": {"kty": "OKP", "crv": "Ed25519", "x": "sender-constraint-canary"}}), + ] { + let fixture = acceptance_runtime().await; + let token = access_token(Some(json!({"cnf": confirmation}))); + let error = fixture + .runtime + .evaluate("operation-sender-constrained", &token, &adult_request()) + .await + .expect_err("a confirmation-bound token is denied"); + assert_eq!(error.problem(), ProblemCode::AuthenticationFailed); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "a sender-constrained token cannot acquire source credentials" + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("sender-constraint-canary")); + } +} + +#[tokio::test] +async fn disclosure_audit_failure_prevents_signed_response_release() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(500))).await; + let runtime = Arc::clone(&fixture.runtime); + let token = access_token(None); + let request = adult_request(); + let evaluation = tokio::spawn(async move { + runtime + .evaluate("operation-disclosure-audit-failure", &token, &request) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + fs::OpenOptions::new() + .append(true) + .open(&fixture.audit_path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("audit tamper writes after access acceptance"); + let error = evaluation + .await + .expect("evaluation task completes") + .expect_err("release audit failure cannot return a signed response"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + assert!( + !audit.contains("date_of_birth") && !audit.contains("2000-01-01"), + "audit failure diagnostics cannot retain source or disclosed values" + ); +} + +#[tokio::test] +async fn signing_failure_is_transient_audited_and_never_releases_unsigned_evidence() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let error = runtime + .evaluate( + "operation-signing-failure", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("signing failure cannot produce any success representation"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"decision\":\"signing-failure\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn configured_jwks_path_is_mechanically_the_served_route() { + let fixture = acceptance_runtime().await; + let configured_path = fixture.runtime.bundle().config.signing.jwks_path.clone(); + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let response = http.get(&configured_path).await; + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jwk-set+json"); + let keys = response.json::(); + assert_eq!(&keys, fixture.runtime.jwks()); +} + +/// The declared existence-disclosure mode is an enforced invariant, not an +/// inert field: every enabled requirement declares the one closed collapse +/// mode, and the runtime's unresolved outcomes for that mode share one public +/// problem shape whether the record was absent, ambiguous, or uniquely found +/// with inconsistent derivation inputs. +#[tokio::test] +async fn declared_existence_disclosure_mode_governs_the_public_collapse() { + let fixture = acceptance_runtime().await; + for requirement in &fixture.runtime.bundle().config.requirements { + assert_eq!( + requirement.existence_disclosure, + crate::config::ExistenceDisclosure::CollapseUnresolved, + "{}", + requirement.id + ); + } + + mount_parent_source( + &fixture.server, + json!({"total": 1, "records": [{ + "returned_child_reference": "synthetic-other-child-record", + "parent_references": ["synthetic-parent-reference-001"], + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }]}), + ) + .await; + let mismatch = fixture + .runtime + .evaluate( + "operation-existence-derivation-mismatch", + &access_token(Some(parent_grant_claims())), + &parent_request(), + ) + .await + .expect_err("a returned-child mismatch is not signed evidence"); + assert_eq!(mismatch.problem(), ProblemCode::EvidenceNotAvailable); + + fixture.server.reset().await; + mount_parent_source(&fixture.server, json!({"total": 0, "records": []})).await; + let unknown = fixture + .runtime + .evaluate( + "operation-existence-unknown-record", + &access_token(Some(parent_grant_claims())), + &parent_request(), + ) + .await + .expect_err("an unknown record is not signed evidence"); + + // A uniquely found record with inconsistent derivation inputs is publicly + // indistinguishable from no record at all. + assert_eq!(mismatch.problem(), unknown.problem()); + let operation = "operation-existence-problem-shape-check-000001"; + assert_eq!( + serde_json::to_value(mismatch.problem().body(operation)).expect("problem serializes"), + serde_json::to_value(unknown.problem().body(operation)).expect("problem serializes"), + ); +} + +#[tokio::test] +async fn request_nonce_is_strict_and_never_reaches_source_or_audit() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + + // Missing, empty, short, long, padded, wrong-alphabet, oversized, and + // noncanonical final-symbol nonces fail as malformed requests before + // authorization, credential acquisition, or source access. + let base = serde_json::to_value(adult_request()).expect("request serializes"); + let mut variants = Vec::new(); + let mut missing = base.clone(); + missing + .as_object_mut() + .expect("request is an object") + .remove("requestNonce"); + variants.push(missing); + for nonce in [ + String::new(), + "A".repeat(42), + "A".repeat(44), + format!("{}=", "A".repeat(42)), + format!("{}+", "A".repeat(42)), + format!("{}B", "A".repeat(42)), + "A".repeat(4096), + ] { + let mut variant = base.clone(); + variant["requestNonce"] = json!(nonce); + variants.push(variant); + } + for variant in variants { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&variant) + .await; + assert_eq!(response.status_code(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(response.json::()["code"], json!("malformed_request")); + } + // A duplicate requestNonce member fails strict JSON parsing. + let duplicate = build_app(Arc::clone(&fixture.runtime)) + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/v1/evidence") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"requestNonce":"{0}","requestNonce":"{0}","requirement":"urn:example:fixture:requirement:adult-status:v1","purpose":"fixture-eligibility","subjects":[{{"role":"subject","selector":{{"profile":"person-demographics-v1","values":{{"given_name":"Amina","family_name":"Diallo","birth_date":"2000-01-01"}}}}}}]}}"#, + "A".repeat(43) + ))) + .expect("duplicate-nonce request builds"), + ) + .await + .expect("router responds"); + assert_eq!(duplicate.status(), axum::http::StatusCode::BAD_REQUEST); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "invalid nonces must fail before credential acquisition or source access" + ); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "invalid nonces must not fabricate audit events" + ); + + // A unique canary nonce is echoed exactly into the signed payload and + // reaches nothing else: not the source request, not native audit. + mount_adult_source(&fixture.server, None).await; + let request = adult_request(); + let nonce = request.request_nonce.clone(); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&request) + .await; + response.assert_status_ok(); + let jws = response.json::(); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("payload parses"); + assert_eq!(evidence.request_nonce, nonce, "exact nonce echo"); + + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert!( + !audit.contains(&nonce), + "the request nonce must never be recorded in native audit" + ); + for received in fixture + .server + .received_requests() + .await + .expect("request journal is available") + { + assert!(!received.url.as_str().contains(&nonce)); + assert!(!String::from_utf8_lossy(&received.body).contains(&nonce)); + for header_name in received.headers.keys() { + let value = received.headers[header_name].to_str().unwrap_or_default(); + assert!(!value.contains(&nonce)); + } + } +} + +#[tokio::test] +async fn accept_negotiation_is_closed_and_fails_before_source_access() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + + for invalid in [ + "application/json", + "application/jose+json, application/vnd.registrystack.evidence-unsigned+json", + "application/jose+json;q=0.9", + "application/vnd.registrystack.evidence-unsigned+json; charset=utf-8", + "application/*", + "text/html", + ] { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", invalid) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::NOT_ACCEPTABLE, + "{invalid}" + ); + assert_eq!( + response.json::()["code"], + json!("response_format_not_acceptable") + ); + assert_eq!(response.header("vary"), "Accept"); + } + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "unsupported negotiation must fail before source access" + ); + assert!(fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty()); + + // Missing Accept, */*, and the exact signed media type all select JWS. + mount_adult_source(&fixture.server, None).await; + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", "*/*") + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jose+json"); + assert_eq!(response.header("vary"), "Accept"); +} + +#[tokio::test] +async fn unsigned_output_requires_both_bundle_and_grant_permission() { + // Grant permits unsigned but the immutable bundle does not enable it. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws]", + 1, + ); + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("bundle-restricted runtime initializes"), + ); + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let token = access_token(None); + let bundle_denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + bundle_denied.status_code(), + axum::http::StatusCode::FORBIDDEN + ); + let bundle_denied_body = bundle_denied.json::(); + assert_eq!(bundle_denied_body["code"], json!("not_authorized")); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // The bundle enables unsigned but the matched grant withholds it. Another + // grant's permission cannot be unioned in, and the denial is identical. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json]", + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws]", + 1, + ); + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("grant-restricted runtime initializes"), + ); + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let grant_denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + grant_denied.status_code(), + axum::http::StatusCode::FORBIDDEN + ); + let grant_denied_body = grant_denied.json::(); + assert_eq!(grant_denied_body["code"], json!("not_authorized")); + // The two denials must not reveal which layer withheld permission. + assert_eq!(bundle_denied_body["code"], grant_denied_body["code"]); + assert_eq!(bundle_denied_body["title"], grant_denied_body["title"]); + assert_eq!(bundle_denied_body["status"], grant_denied_body["status"]); + // The signed default remains available under the restricted grant. + mount_adult_source(&prepared.server, None).await; + let signed = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + signed.assert_status_ok(); + assert_eq!(signed.header("content-type"), "application/jose+json"); + + // Runtime configuration is closed and cannot enable a response format. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime_document = + fs::read_to_string(&prepared.runtime_path).expect("runtime configuration is readable"); + runtime_document.push_str("responseFormats: [signed-jws, unsigned-json]\n"); + make_file_writable(&prepared.runtime_path); + fs::write(&prepared.runtime_path, runtime_document).expect("runtime override is written"); + make_file_read_only(&prepared.runtime_path); + let error = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect_err("a runtime response-format override must fail startup closed"); + assert!(matches!(error, RuntimeInitializationError::Bundle)); +} + +#[tokio::test] +async fn unsigned_envelope_is_exact_audited_and_never_a_signing_fallback() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, None).await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + let request = adult_request(); + let nonce = request.request_nonce.clone(); + + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!( + response.header("content-type"), + EVIDENCE_UNSIGNED_MEDIA_TYPE + ); + assert_eq!(response.header("vary"), "Accept"); + assert_eq!(response.header("cache-control"), "no-store"); + + let body = response.text(); + let value: Value = serde_json::from_str(&body).expect("unsigned body is JSON"); + let members = value + .as_object() + .expect("envelope is an object") + .keys() + .cloned() + .collect::>(); + assert_eq!( + members, + BTreeSet::from([ + "schema".to_owned(), + "type".to_owned(), + "integrityProtection".to_owned(), + "warning".to_owned(), + "evidence".to_owned(), + ]), + "the envelope has no JWS member and no signing-key claim" + ); + assert_eq!( + value["schema"], + json!("registry.unsigned-evidence-envelope/v1") + ); + assert_eq!(value["type"], json!("UnsignedEvidenceEnvelope")); + assert_eq!(value["integrityProtection"], json!("none")); + assert_eq!(value["warning"], json!("not-cryptographically-verifiable")); + let envelope: UnsignedEvidenceEnvelope = + serde_json::from_str(&body).expect("envelope parses strictly"); + assert_eq!(envelope.evidence.request_nonce, nonce); + assert!( + evidence_contract_accepts(&value["evidence"]).expect("evidence contract is available"), + "the nested evidence is the same closed core object" + ); + + // The strict JWS verifier rejects the unsigned representation. + let mut policy = verification_policy_stub(&fixture.runtime, &request); + policy.request_nonce = nonce; + assert!(verify_flattened_jws(body.as_bytes(), fixture.runtime.jwks(), &policy).is_err()); + + // Audit records the closed protection mode; the release event carries no + // signing key identity for unsigned output. + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + let events = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .collect::>(); + let access = events + .iter() + .find(|event| event["record"]["phase"] == json!("access-attempt")) + .expect("access event exists"); + let release = events + .iter() + .find(|event| event["record"]["phase"] == json!("disclosure-release")) + .expect("release event exists"); + assert_eq!(access["record"]["responseProtection"], json!("unsigned")); + assert_eq!(release["record"]["responseProtection"], json!("unsigned")); + assert!(release["record"]["signingKeyId"].is_null()); + assert!(release["record"]["evidenceId"].is_string()); + assert!(release["record"]["disclosedConcepts"].is_array()); + assert!(!audit.contains(&envelope.evidence.request_nonce)); + + // An unready ordinary signing dependency also denies unsigned output. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(UnavailableReadinessSigner { delegate }); + let unready_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(unready_signer); + mount_adult_source(&prepared.server, None).await; + let error = runtime + .evaluate_with_format( + "operation-unsigned-unready-signing", + &access_token(None), + &adult_request(), + ResponseFormat::UnsignedJson, + ) + .await + .expect_err("unsigned output still requires the signing dependency to be ready"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +#[tokio::test] +async fn signing_failure_returns_a_problem_and_never_an_unsigned_body() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let http = TestServer::new(build_app(Arc::new(runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(response.header("content-type"), "application/problem+json"); + let text = response.text(); + assert!( + !text.contains("integrityProtection") && !text.contains("UnsignedEvidenceEnvelope"), + "a signed-path failure must never downgrade to unsigned output" + ); +} + +#[tokio::test] +async fn disclosure_audit_failure_prevents_unsigned_response_release() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(500))).await; + let runtime = Arc::clone(&fixture.runtime); + let token = access_token(None); + let request = adult_request(); + let evaluation = tokio::spawn(async move { + runtime + .evaluate_with_format( + "operation-unsigned-audit-failure", + &token, + &request, + ResponseFormat::UnsignedJson, + ) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + fs::OpenOptions::new() + .append(true) + .open(&fixture.audit_path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("audit tamper writes after access acceptance"); + let error = evaluation + .await + .expect("evaluation task completes") + .expect_err("release audit failure cannot return an unsigned response"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +#[tokio::test] +async fn all_four_definitions_pass_the_explicitly_authorized_unsigned_path() { + let fixture = acceptance_runtime().await; + mount_success_sources(&fixture.server, true).await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let cases = [ + (access_token(None), adult_request()), + (access_token(None), residence_request()), + (access_token(None), licence_request()), + (access_token(Some(parent_grant_claims())), parent_request()), + ]; + for (token, request) in cases { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!( + response.header("content-type"), + EVIDENCE_UNSIGNED_MEDIA_TYPE + ); + let envelope: UnsignedEvidenceEnvelope = + serde_json::from_str(&response.text()).expect("envelope parses strictly"); + assert_eq!(envelope.evidence.request_nonce, request.request_nonce); + assert!(!envelope.evidence.supported_values.is_empty()); + assert_eq!( + envelope.evidence.subjects.len(), + request.subjects.len(), + "unsigned output binds the same declaration-ordered roles" + ); + } + let audit = wait_for_audit_counts(&fixture.audit_path, 4, 4).await; + assert_eq!( + audit.matches("\"responseProtection\":\"unsigned\"").count(), + 8, + "every unsigned access and release event records the closed mode" + ); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +/// Rewrite the acceptance bundle so the immutable bundle, and optionally the +/// matched grant, enable the SD-JWT VC response format. +async fn sd_jwt_vc_acceptance(grant_permits: bool) -> PreparedAcceptance { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, unsigned-json, sd-jwt-vc]", + 1, + ); + if grant_permits { + replace_exact( + &mut configuration, + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json]", + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json, sd-jwt-vc]", + 1, + ); + } + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + prepared +} + +async fn runtime_for(prepared: &PreparedAcceptance) -> Arc { + Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"), + ) +} + +#[tokio::test] +async fn sd_jwt_format_not_permitted_by_bundle() { + // The stock acceptance bundle enables signed and unsigned output only. + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + + assert_eq!(response.status_code(), axum::http::StatusCode::FORBIDDEN); + assert_eq!(response.json::()["code"], json!("not_authorized")); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "an unenabled response format is denied before source access" + ); + assert!(fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn sd_jwt_format_not_permitted_by_grant() { + // The bundle enables the format but the one matched grant withholds it. + let prepared = sd_jwt_vc_acceptance(false).await; + let runtime = runtime_for(&prepared).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); + assert_eq!(denied.json::()["code"], json!("not_authorized")); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // With both gates open the same assertion is released as an SD-JWT VC. + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + // One evaluation per response format; both must reach the same source. + mount_adult_source_expecting(&prepared.server, None, 2).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let token = access_token(None); + let request = adult_request(); + + // The signed default establishes the independent expectations a relying + // party retains, so the credential is verified against the other format's + // payload rather than against itself. + let signed = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + signed.assert_status_ok(); + let jws = signed.json::(); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"); + let expected: Evidence = serde_json::from_slice(&payload).expect("payload parses"); + let policy = EvidenceVerificationPolicy::from_accepted_transaction( + &expected, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + + let credential = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + credential.assert_status_ok(); + assert_eq!( + credential.header("content-type"), + EVIDENCE_SD_JWT_VC_MEDIA_TYPE + ); + assert_eq!(credential.header("vary"), "Accept"); + assert_eq!(credential.header("cache-control"), "no-store"); + + let serialized = credential.text(); + assert!(serialized.ends_with('~'), "no key-binding JWT is issued"); + let verified = verify_sd_jwt_vc(serialized.as_bytes(), runtime.jwks(), &policy) + .expect("the credential verifies against the signed transaction's expectations"); + assert_eq!(verified.supported_values, expected.supported_values); + assert_eq!(verified.subjects, expected.subjects); + assert_eq!(verified.request_nonce, request.request_nonce); + + // Audit records the closed protection mode and the signing key identity. + let audit = wait_for_audit_counts(&prepared.audit_path, 2, 2).await; + let events = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .collect::>(); + let releases = events + .iter() + .filter(|event| event["record"]["phase"] == json!("disclosure-release")) + .collect::>(); + let credential_release = releases + .iter() + .find(|event| event["record"]["responseProtection"] == json!("sd-jwt-vc")) + .expect("the credential release records the SD-JWT VC mode"); + assert_eq!( + credential_release["record"]["signingKeyId"], + json!("acceptance-evidence-key") + ); + assert!(!audit.contains(&request.request_nonce)); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn sd_jwt_holder_key_with_private_member_rejected() { + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + // No source is mounted: no request may reach acquisition. + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let mut body = serde_json::to_value(adult_request()).expect("request serializes"); + + for holder_key in [ + json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A" + }), + json!({ + "kty": "oct", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "k": "c2VjcmV0LWtleS1jYW5hcnk" + }), + ] { + body["holderKey"] = holder_key; + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "a holder key carrying private material is not a request" + ); + let problem = response.json::(); + assert_eq!(problem["code"], json!("malformed_request")); + let text = response.text(); + assert!( + !text.contains("nWGxne") && !text.contains("c2VjcmV0"), + "rejected key material is never echoed" + ); + } + + assert!( + prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "an unacceptable holder key fails before credential acquisition" + ); + assert!(fs::read_to_string(&prepared.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn sd_jwt_holder_key_wrong_algorithm_rejected() { + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + mount_adult_source(&prepared.server, None).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let mut body = serde_json::to_value(adult_request()).expect("request serializes"); + + for holder_key in [ + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "ES256"}), + json!({"kty": "EC", "crv": "P-256", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "EdDSA"}), + json!({"kty": "OKP", "crv": "X25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}), + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg"}), + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo="}), + ] { + body["holderKey"] = holder_key.clone(); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "{holder_key} is outside the closed holder-key profile" + ); + assert_eq!(response.json::()["code"], json!("malformed_request")); + } + + // The same request without a holder key still succeeds, so the rejection + // is the key's and not the format's. + body.as_object_mut() + .expect("request is an object") + .remove("holderKey"); + let accepted = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + accepted.assert_status_ok(); + assert!(!accepted.text().contains("cnf")); +} + +#[tokio::test] +async fn sd_jwt_signing_failure_no_fallback_format() { + let prepared = sd_jwt_vc_acceptance(true).await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let http = TestServer::new(build_app(Arc::new(runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + + assert_eq!( + response.status_code(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(response.header("content-type"), "application/problem+json"); + let text = response.text(); + assert!( + !text.contains('~') && !text.contains("integrityProtection"), + "a failed credential signature never falls back to another format" + ); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +/// Serve the operator-driven SD-JWT VC demo documented in `SD-JWT-VC-DEMO.md`. +/// +/// The immutable bundle and the one complete matched grant both enable the +/// credential format, so one deterministic request is released twice: once as +/// the signed default and once as an SD-JWT VC. The harness verifies the +/// credential against expectations taken from the signed transaction rather +/// than from the credential's own bytes, then leaves the pinned key set and a +/// policy document so the operator can re-verify the stored credential offline +/// with `evidence verify --sd-jwt-vc`. +#[tokio::test] +#[ignore = "operator-driven local curl demo"] +async fn sd_jwt_vc_demo_serves_a_credential_for_curl() { + let prepared = sd_jwt_vc_acceptance(true).await; + // One source call per response format. Both formats answer the same + // request, so neither may reach the source more than once. + mount_adult_source_expecting(&prepared.server, None, 2).await; + let runtime = runtime_for(&prepared).await; + + let state_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/evidence/.sd-jwt-vc-demo"); + fs::create_dir_all(&state_root).expect("demo state directory is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&state_root, fs::Permissions::from_mode(0o700)) + .expect("demo state directory is owner-only"); + } + let signed_path = state_root.join("response.jws.json"); + let credential_path = state_root.join("credential.txt"); + let metadata_path = state_root.join("issuer-metadata.json"); + let jwks_path = state_root.join("trusted.jwks.json"); + let policy_path = state_root.join("verification-policy.yaml"); + for stale in [ + &signed_path, + &credential_path, + &metadata_path, + &jwks_path, + &policy_path, + ] { + match fs::remove_file(stale) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("stale demo output could not be removed: {error}"), + } + } + + let request = adult_request(); + write_secret( + &state_root, + "request.json", + &serde_json::to_string_pretty(&request).expect("request serializes"), + ); + write_secret( + &state_root, + "session.env", + &format!("EVIDENCE_ACCESS_TOKEN={}\n", access_token(None)), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:18081") + .await + .expect("demo listener binds on 127.0.0.1:18081"); + let address = listener + .local_addr() + .expect("listener address is available"); + println!( + "Evidence SD-JWT VC demo server is ready at http://{address}. The ignored session.env contains only the short-lived synthetic bearer token. Use the curl commands in products/evidence/SD-JWT-VC-DEMO.md." + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let served = Arc::clone(&runtime); + let server = tokio::spawn(async move { + serve_listener_for_test(served, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + + // The signed default is fetched first, because a relying party's + // expectations come from the transaction it accepted, never from the + // credential it is about to check. + let signed = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&signed_path) { + if serde_json::from_slice::(&bytes).is_ok() { + break bytes; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the signed curl response arrives within three minutes"); + let policy = verification_policy(&runtime, &request, &signed); + let accepted = verify_flattened_jws(&signed, runtime.jwks(), &policy) + .expect("the signed response verifies against the running Evidence JWKS"); + + // A partial write looks like a malformed credential, so the last + // verification failure is retained outside the polling future and reported + // on timeout rather than swallowed. + let last_failure = RefCell::new(None); + let credential_wait = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&credential_path) { + if bytes.ends_with(b"~") { + match verify_sd_jwt_vc(&bytes, runtime.jwks(), &policy) { + Ok(verified) => { + break ( + String::from_utf8(bytes).expect("credential is ASCII"), + verified, + ) + } + Err(error) => *last_failure.borrow_mut() = Some(format!("{error:?}")), + } + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + let (credential, verified) = match credential_wait { + Ok(pair) => pair, + Err(_) => panic!( + "the credential curl response arrives and verifies within three minutes; \ + last verification failure: {:?}", + last_failure.borrow() + ), + }; + + assert!( + credential.ends_with('~'), + "no key-binding JWT is issued or expected" + ); + assert_eq!( + credential + .split('~') + .filter(|part| !part.is_empty()) + .count(), + 1 + accepted.supported_values.len(), + "the unprojected demo credential carries one root disclosure per supported value" + ); + assert_eq!(verified.supported_values, accepted.supported_values); + assert_eq!(verified.subjects, accepted.subjects); + assert_eq!(verified.request_nonce, request.request_nonce); + + shutdown_tx.send(()).expect("demo server is still running"); + server + .await + .expect("demo server task joins") + .expect("demo server stops cleanly"); + + // What an offline relying party keeps besides the credential: the closed + // policy document, written from the accepted transaction. The pinned key + // set is not written here, because the demo fetches it the way a relying + // party does, from the issuer metadata route. + fs::write(&policy_path, demo_verification_policy_document(&policy)) + .expect("the verification policy is written"); + + let audit = wait_for_audit_counts(&prepared.audit_path, 2, 2).await; + let releases = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .filter(|event| event["record"]["phase"] == json!("disclosure-release")) + .map(|event| { + event["record"]["responseProtection"] + .as_str() + .expect("every release records a protection mode") + .to_owned() + }) + .collect::>(); + assert_eq!( + releases, + BTreeSet::from(["sd-jwt-vc".to_owned(), "signed".to_owned()]), + "each release records its own closed protection mode" + ); + assert!(!audit.contains(&request.request_nonce)); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } + + println!( + "PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, the credential verified against the signed transaction's expectations, minimization held, and both releases recorded their protection mode." + ); +} + +/// Render the accepted transaction's expectations as the closed policy document +/// the `evidence verify` command parses. +fn demo_verification_policy_document(policy: &EvidenceVerificationPolicy) -> String { + let document = json!({ + "issuedBy": policy.issued_by, + "providedBy": policy.provided_by, + "requirement": policy.requirement, + "evidenceType": policy.evidence_type, + "purpose": policy.purpose, + "audience": policy.audience, + "configurationRevision": policy.configuration_revision, + "requestNonce": policy.request_nonce, + "expectedSubjects": policy + .expected_subjects + .iter() + .map(|subject| json!({"role": subject.role, "binding": subject.binding})) + .collect::>(), + "expectedOutputs": policy + .expected_outputs + .iter() + .map(|output| json!({"concept": output.concept, "form": expected_form_document(&output.form)})) + .collect::>(), + "maximumAssertionLifetimeSeconds": policy.maximum_assertion_lifetime.as_secs(), + "clockSkewSeconds": policy.clock_skew.as_secs(), + }); + serde_norway::to_string(&document).expect("the policy document serializes as YAML") +} + +/// The closed expected value-form vocabulary as a policy document writes it. +fn expected_form_document(form: &ExpectedValueForm) -> Value { + match form { + ExpectedValueForm::Boolean => json!("boolean"), + ExpectedValueForm::Integer => json!("integer"), + ExpectedValueForm::String => json!("string"), + ExpectedValueForm::DateBucket => json!("date-bucket"), + ExpectedValueForm::TimeBucket => json!("time-bucket"), + ExpectedValueForm::EntityReference => json!("entity-reference"), + ExpectedValueForm::Structured => json!("structured"), + ExpectedValueForm::List { + minimum_items, + maximum_items, + } => json!({"list": {"minimumItems": minimum_items, "maximumItems": maximum_items}}), + } +} + +#[tokio::test] +async fn reordered_grant_subjects_resolve_by_role_and_emit_declaration_order() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + r#" subjects: + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference"#, + r#" subjects: + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request}"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("a valid bundle with reordered grant subjects initializes"), + ); + mount_parent_source( + &prepared.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + + // The request array order is also reversed independently of grant order. + let mut request = parent_request(); + request.subjects.reverse(); + let jws = runtime + .evaluate( + "operation-reordered-grant-subjects", + &access_token(Some(parent_grant_claims())), + &request, + ) + .await + .expect("reordered grant subjects resolve by unique role"); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("Evidence payload is base64url"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("Evidence payload is JSON"); + assert_eq!(evidence.subjects[0].role, "child"); + assert_eq!(evidence.subjects[1].role, "candidate-parent"); + + // Audit subjects also use requirement declaration order. + let audit = wait_for_audit_counts(&prepared.audit_path, 1, 1).await; + let first_event = + serde_json::from_str::(audit.lines().next().expect("audit has events")) + .expect("audit line is JSON"); + let roles = first_event["record"]["subjects"] + .as_array() + .expect("audit subjects are an array") + .iter() + .map(|subject| subject["role"].as_str().expect("role is text").to_owned()) + .collect::>(); + assert_eq!(roles, ["child", "candidate-parent"]); + + // The verifier accepts the expected subject set in any expectation order. + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let mut policy = verification_policy_stub(&runtime, &request); + policy.request_nonce = request.request_nonce.clone(); + policy.expected_subjects = evidence + .subjects + .iter() + .rev() + .map(|subject| crate::verifier::ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(); + policy.expected_outputs = evidence + .supported_values + .iter() + .map(|value| crate::verifier::ExpectedOutput { + concept: value.provides_value_for.clone(), + form: crate::verifier::ExpectedValueForm::Boolean, + }) + .collect(); + verify_flattened_jws(&serialized, runtime.jwks(), &policy) + .expect("declaration-ordered subjects verify against unordered expectations"); +} + +/// Policy scaffold with the independent bundle-derived fields filled in and +/// empty subject and output expectations for the caller to complete. +fn verification_policy_stub( + runtime: &EvidenceRuntime, + request: &EvidenceRequest, +) -> EvidenceVerificationPolicy { + let requirement = runtime + .bundle() + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is loaded"); + EvidenceVerificationPolicy { + assurance_profile: runtime.bundle().config.assurance_profile, + issued_by: runtime.bundle().config.issuer.id.clone(), + provided_by: runtime.bundle().config.service.provider_id.clone(), + requirement: request.requirement.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + audience: EVIDENCE_AUDIENCE.to_owned(), + configuration_revision: runtime.bundle().revision().to_owned(), + request_nonce: request.request_nonce.clone(), + expected_subjects: Vec::new(), + expected_outputs: Vec::new(), + maximum_assertion_lifetime: Duration::from_secs(48 * 60 * 60), + now: Utc::now(), + clock_skew: Duration::from_secs(30), + } +} + +#[tokio::test] +async fn multi_role_request_order_is_not_semantic_and_output_uses_declaration_order() { + let fixture = acceptance_runtime().await; + mount_parent_source( + &fixture.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + let mut request = parent_request(); + request.subjects.reverse(); + + let response = fixture + .runtime + .evaluate( + "operation-reversed-subject-order", + &access_token(Some(parent_grant_claims())), + &request, + ) + .await + .expect("roles resolve independently of request array order"); + let payload = URL_SAFE_NO_PAD + .decode(response.payload) + .expect("Evidence payload is base64url"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("Evidence payload is JSON"); + assert_eq!(evidence.subjects[0].role, "child"); + assert_eq!(evidence.subjects[1].role, "candidate-parent"); + assert_eq!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .len(), + 1 + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); +} + +#[tokio::test] +async fn security_contract_rejects_unknown_and_unauthorized_requests_before_source_access() { + let fixture = acceptance_runtime().await; + let now = Utc::now().timestamp(); + let unentitled_token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "unentitled-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["unentitled-tag"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + + let mut unknown_requirement = adult_request(); + unknown_requirement.requirement = "urn:example:fixture:requirement:unknown:v1".to_owned(); + let mut unauthorized_purpose = adult_request(); + unauthorized_purpose.purpose = "caller-selected-purpose".to_owned(); + let mut unauthorized_profile = adult_request(); + unauthorized_profile.subjects[0].selector.profile = "caller-selected-profile-v1".to_owned(); + + for (operation, token, request) in [ + ( + "operation-unknown-requirement", + access_token(None), + unknown_requirement, + ), + ( + "operation-unauthorized-purpose", + access_token(None), + unauthorized_purpose, + ), + ( + "operation-unauthorized-profile", + access_token(None), + unauthorized_profile, + ), + ( + "operation-selector-possession-without-authority", + unentitled_token, + adult_request(), + ), + ] { + let error = fixture + .runtime + .evaluate(operation, &token, &request) + .await + .expect_err("caller material cannot create authority"); + assert_eq!(error.problem(), ProblemCode::NotAuthorized); + } + + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("\"phase\":\"access-attempt\"")); + for protected in [ + "unentitled-principal", + "caller-selected-purpose", + "caller-selected-profile-v1", + ] { + assert!(!audit.contains(protected)); + } +} + +#[tokio::test] +async fn failed_selector_budget_is_enforced_by_the_runtime_and_scoped_to_authority() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + "rateLimits: {requestsPerPrincipalPerMinute: 120, burstPerPrincipal: 20, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 2}", + 1, + ); + replace_exact( + &mut configuration, + "authorityProfiles:\n statutory-caseworker-v1:", + r#"authorityProfiles: + alternate-caseworker-v1: + kind: statutory + requesterTags: [alternate-fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} + statutory-caseworker-v1:"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with bounded selector budget initializes"); + + let mut invalid = adult_request(); + invalid.subjects[0] + .selector + .values + .as_mut() + .expect("adult selector has values") + .remove("birth_date"); + let primary_token = access_token_for("shared-selector-principal", None); + let mut retained_failures = String::new(); + for attempt in 0..2 { + let error = runtime + .evaluate( + &format!("operation-selector-failure-primary-{attempt}"), + &primary_token, + &invalid, + ) + .await + .expect_err("invalid selectors consume the configured budget"); + assert_eq!(error.problem(), ProblemCode::InvalidSelector); + retained_failures.push_str(&format!("{error:?} {error}\n")); + } + let exhausted = runtime + .evaluate( + "operation-selector-failure-primary-exhausted", + &primary_token, + &invalid, + ) + .await + .expect_err("the next request is rejected before selector resolution"); + assert_eq!(exhausted.problem(), ProblemCode::RateLimited); + retained_failures.push_str(&format!("{exhausted:?} {exhausted}\n")); + + let now = Utc::now().timestamp(); + let alternate_token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "shared-selector-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["alternate-fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let alternate = runtime + .evaluate( + "operation-selector-failure-alternate-authority", + &alternate_token, + &invalid, + ) + .await + .expect_err("a different matched authority has an independent selector budget"); + assert_eq!(alternate.problem(), ProblemCode::InvalidSelector); + retained_failures.push_str(&format!("{alternate:?} {alternate}\n")); + + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert!( + audit.is_empty(), + "pre-material selector failures are not audited" + ); + retained_failures.push_str(&audit); + for protected in [ + "shared-selector-principal", + "alternate-fixture-agency", + "Amina", + "Diallo", + "2000-01-01", + ] { + assert!(!audit.contains(protected)); + assert!(!retained_failures.contains(protected)); + } + for canary in privacy_canaries() { + assert!( + !retained_failures.contains(canary), + "public selector failures and audit remain value-free" + ); + } +} + +#[tokio::test] +async fn one_runtime_proves_all_definitions_and_collapses_unresolved_relationships() { + let fixture = acceptance_runtime().await; + assert!( + fixture.runtime.ready().await, + "readiness accepts local credentials and performs no evidence-data lookup" + ); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + mount_success_sources(&fixture.server, true).await; + let standard_token = access_token(None); + let parent_token = access_token(Some(parent_grant_claims())); + + let cases = [ + ( + "adult", + adult_request(), + standard_token.as_str(), + "urn:example:fixture:concept:adult-status", + PublicValue::Boolean(true), + 1, + ), + ( + "residence", + residence_request(), + standard_token.as_str(), + "urn:example:fixture:concept:residence-region", + PublicValue::String("REGION-NORTH".to_owned()), + 1, + ), + ( + "licence", + licence_request(), + standard_token.as_str(), + "urn:example:fixture:concept:licence-active", + PublicValue::Boolean(true), + 1, + ), + ( + "parent-true", + parent_request(), + parent_token.as_str(), + "urn:example:fixture:concept:legal-parent-relationship-confirmed", + PublicValue::Boolean(true), + 2, + ), + ]; + + for (operation_suffix, request, token, concept, expected, role_count) in cases { + let jws = fixture + .runtime + .evaluate( + &format!("operation-acceptance-{operation_suffix}"), + token, + &request, + ) + .await + .expect("full Evidence path signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request, &serialized), + ) + .expect("released JWS verifies under the exact relying-procedure policy"); + assert_eq!(evidence.subjects.len(), role_count); + if role_count == 2 { + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + ["child", "candidate-parent"] + ); + } + assert!(evidence + .supported_values + .iter() + .any(|value| value.provides_value_for == concept && value.value == expected)); + assert_minimized_payload(&serialized); + } + + fixture.server.reset().await; + let non_parent = non_parent_candidate(); + mount_parent_source( + &fixture.server, + parent_source_response(vec!["synthetic-parent-reference-002"]), + ) + .await; + let non_parent_token = access_token(Some(parent_grant_claims_for(non_parent))); + let false_request = parent_request(); + let false_jws = fixture + .runtime + .evaluate( + "operation-acceptance-parent-false", + &non_parent_token, + &false_request, + ) + .await + .expect("exact non-membership in the complete governed parent set is signed"); + let false_serialized = serde_json::to_vec(&false_jws).expect("JWS serializes"); + let false_evidence = verify_flattened_jws( + &false_serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &false_request, &false_serialized), + ) + .expect("negative Evidence verifies"); + assert_eq!( + false_evidence.supported_values[0].value, + PublicValue::Boolean(false) + ); + + for (suffix, response) in [ + ("none", json!({"total": 0, "records": []})), + ("ambiguous", json!({"total": 2, "records": [{}, {}]})), + ] { + fixture.server.reset().await; + mount_parent_source(&fixture.server, response).await; + let error = fixture + .runtime + .evaluate( + &format!("operation-acceptance-parent-{suffix}"), + &parent_token, + &parent_request(), + ) + .await + .expect_err("unresolved pairs never produce signed Evidence"); + assert_eq!(error.problem(), ProblemCode::EvidenceNotAvailable); + } + + fixture.server.reset().await; + let swapped_roles = request( + "urn:example:fixture:requirement:legal-parent-relationship:v1", + "fixture-enrolment", + vec![ + requested_subject( + "candidate-parent", + "civil-record-reference-v1", + Some([("record_reference", "synthetic-child-record-001")]), + ), + requested_subject::<[(&str, &str); 0]>("child", "person-reference-v1", None), + ], + ); + let swapped_error = fixture + .runtime + .evaluate( + "operation-acceptance-swapped-parent-roles", + &parent_token, + &swapped_roles, + ) + .await + .expect_err("role/profile substitution is rejected before source access"); + assert_eq!(swapped_error.problem(), ProblemCode::NotAuthorized); + + let substituted = parent_request_with_candidate_values(); + let error = fixture + .runtime + .evaluate( + "operation-acceptance-substitution", + &parent_token, + &substituted, + ) + .await + .expect_err("caller candidate substitution is rejected before source access"); + assert_eq!(error.problem(), ProblemCode::InvalidSelector); + let unauthorized = fixture + .runtime + .evaluate( + "operation-acceptance-unauthorized", + &access_token(Some(json!({ + "evidence_grant_id": "grant-canary", + "evidence_authority": "different-authority", + "grant": {"candidate_parent": parent_candidate()} + }))), + &parent_request(), + ) + .await + .expect_err("authority substitution is rejected before source access"); + assert_eq!(unauthorized.problem(), ProblemCode::NotAuthorized); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 7); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 5); + assert_eq!(audit.matches("\"phase\":\"denial\"").count(), 2); + let retained_failures = format!( + "{swapped_error:?} {swapped_error}\n{error:?} {error}\n{unauthorized:?} {unauthorized}\n{audit}" + ); + for canary in privacy_canaries() { + assert!( + !retained_failures.contains(canary), + "public failures and audit must not retain protected selector, grant, source, or secret material" + ); + } +} + +#[tokio::test] +async fn runtime_output_gate_rejects_every_fixture_injected_derivation_without_release() { + let cases = [ + ( + "adult", + "derivations/adult-status.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:adult-status", value: "true"}] }"#, + ), + ( + "residence", + "derivations/residence-region.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:residence-region", value: "R-101"}] }"#, + ), + ( + "licence", + "derivations/professional-licence.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:licence-active", value: true}, #{concept_id: "urn:example:fixture:concept:licence-expiry-category", value: "2026-08-20"}] }"#, + ), + ( + "relationship", + "derivations/legal-parent-relationship.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:legal-parent-relationship-confirmed", value: true}, #{concept_id: "urn:example:fixture:concept:related-subject-name", value: "PrivacyCanary"}] }"#, + ), + ]; + + for (definition, script_path, script) in cases { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + fs::write(prepared.bundle_root.join(script_path), script) + .expect("test derivation replacement succeeds"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with a syntactically valid injected derivation initializes"); + let (request, token) = match definition { + "adult" => { + mount_adult_source(&prepared.server, None).await; + (adult_request(), access_token(None)) + } + "residence" => { + mount_residence_source(&prepared.server).await; + (residence_request(), access_token(None)) + } + "licence" => { + mount_licence_source(&prepared.server).await; + (licence_request(), access_token(None)) + } + "relationship" => { + mount_parent_source( + &prepared.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + (parent_request(), access_token(Some(parent_grant_claims()))) + } + _ => unreachable!("closed acceptance definitions"), + }; + + let error = runtime + .evaluate( + &format!("operation-output-gate-{definition}"), + &token, + &request, + ) + .await + .expect_err("invalid derived output cannot reach signing or release"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!( + audit + .matches("\"safeErrorCategory\":\"output-gate\"") + .count(), + 1 + ); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + let requests = prepared + .server + .received_requests() + .await + .expect("request journal is available"); + assert_eq!(requests.len(), 1); + let retained = format!( + "{error:?} {error}\n{audit}\n{} {}", + requests[0].method, + requests[0].url.path() + ); + for canary in privacy_canaries() { + assert!( + !retained.contains(canary), + "public failure, audit, and non-sensitive request metadata remain minimized" + ); + } + } +} + +#[tokio::test] +async fn runtime_rejects_an_extra_extracted_fact_before_derivation_or_release() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let adapter_path = prepared + .bundle_root + .join("adapters/adult-status-source.rhai"); + let mut adapter = fs::read_to_string(&adapter_path).expect("adapter is readable"); + replace_exact( + &mut adapter, + "facts: #{date_of_birth: date_of_birth}", + "facts: #{date_of_birth: date_of_birth, unexpected_private_fact: \"PrivacyCanary\"}", + 1, + ); + fs::write(adapter_path, adapter).expect("test adapter replacement succeeds"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with a syntactically valid adapter initializes"); + mount_adult_source(&prepared.server, None).await; + + let error = runtime + .evaluate( + "operation-extra-extracted-fact-rejection", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("a fact outside the closed fact schema cannot reach derivation"); + assert_eq!(error.problem(), ProblemCode::EvidenceNotAvailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!( + audit + .matches("\"safeErrorCategory\":\"fact-unavailable\"") + .count(), + 1 + ); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + let requests = prepared + .server + .received_requests() + .await + .expect("request journal is available"); + assert_eq!(requests.len(), 1); + let retained = format!( + "{error:?} {error}\n{audit}\n{} {}", + requests[0].method, + requests[0].url.path() + ); + for canary in privacy_canaries() { + assert!( + !retained.contains(canary), + "public failure, audit, and non-sensitive request metadata remain minimized" + ); + } +} + +#[tokio::test] +async fn every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification() { + let fixture = acceptance_runtime().await; + let definitions = [ + ( + "adult", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml" + ) + .as_slice(), + ), + ( + "residence", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml" + ) + .as_slice(), + ), + ( + "licence", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml" + ) + .as_slice(), + ), + ( + "relationship", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml" + ) + .as_slice(), + ), + ]; + let mut executed = BTreeMap::new(); + let mut runtime_equivalents = BTreeSet::new(); + let mut startup_only = BTreeSet::new(); + + for (definition, bytes) in definitions { + let matrix: Value = serde_norway::from_slice(bytes).expect("acceptance matrix parses"); + let common = matrix["common"].as_object().expect("common is an object"); + for (index, case) in matrix["cases"] + .as_array() + .expect("cases are an array") + .iter() + .enumerate() + { + let case = case.as_object().expect("case is an object"); + let case_id = case["id"].as_str().expect("case id is text"); + if case.contains_key("injected_derivation") { + runtime_equivalents.insert(format!("{definition}/{case_id}:output-gate")); + continue; + } + if case.contains_key("companion_bundle") { + startup_only.insert(format!("{definition}/{case_id}:bundle-rejection")); + continue; + } + if case.get("expected").and_then(Value::as_str) == Some("pre-source-selector-rejection") + { + runtime_equivalents.insert(format!("{definition}/{case_id}:pre-source-rejection")); + continue; + } + + fixture.server.reset().await; + let request = match definition { + "adult" => adult_request(), + "residence" => residence_request(), + "licence" => licence_request(), + "relationship" => parent_request(), + _ => unreachable!("closed definition set"), + }; + let source_path = match definition { + "adult" | "residence" => "/v1/facts", + "licence" => "/v1/records", + "relationship" => "/v1/child-relationships", + _ => unreachable!("closed definition set"), + }; + let source_method = if definition == "licence" { + "GET" + } else { + "POST" + }; + let is_relationship = definition == "relationship"; + let response = if let Some(source) = case.get("source") { + ResponseTemplate::new(200).set_body_json(source) + } else { + match case.get("source_failure").and_then(Value::as_str) { + Some("timeout") => ResponseTemplate::new(200) + .set_body_json(json!({"total": 1})) + .set_delay(Duration::from_millis(3_050)), + Some("http-503") => ResponseTemplate::new(503), + Some("wrong-media-type") => { + ResponseTemplate::new(200).set_body_raw("{}", "text/plain") + } + Some("redirect") => { + ResponseTemplate::new(302).insert_header("location", "/prohibited-redirect") + } + _ => panic!("case must have source or closed source failure"), + } + }; + let mut source_mock = Mock::given(method(source_method)) + .and(path(source_path)) + .and(header("accept", "application/json")); + if is_relationship { + source_mock = source_mock + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(parent_source_request())); + } + source_mock + .respond_with(response) + .expect(1) + .mount(&fixture.server) + .await; + + let principal = format!("principal-{definition}-{index}"); + let token_claims = case + .get("verified_token_claims") + .cloned() + .or_else(|| is_relationship.then(|| parent_grant_claims_for(parent_candidate()))); + let token = access_token_for(&principal, token_claims); + let evaluation_time = acceptance_case_time(case, common); + let audit_before = fs::read_to_string(&fixture.audit_path) + .expect("audit is readable before router request") + .lines() + .count(); + let http = TestServer::new(build_app_at_for_test( + Arc::clone(&fixture.runtime), + evaluation_time, + )); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&request) + .await; + + if case.contains_key("expected_value") || case.contains_key("expected_values") { + response.assert_status_ok(); + let jws = response.json::(); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let mut policy = verification_policy(&fixture.runtime, &request, &serialized); + policy.now = evaluation_time; + let evidence = verify_flattened_jws(&serialized, fixture.runtime.jwks(), &policy) + .expect("matrix JWS verifies"); + if let Some(expected) = case.get("expected_value") { + assert_eq!( + serde_json::to_value(&evidence.supported_values[0].value) + .expect("value serializes"), + *expected, + "{definition}/{case_id}" + ); + } else { + let expected = case["expected_values"] + .as_object() + .expect("expected values are an object"); + for value in &evidence.supported_values { + let short = value + .provides_value_for + .rsplit(':') + .next() + .expect("concept has a suffix"); + assert_eq!( + serde_json::to_value(&value.value).expect("value serializes"), + expected[short], + "{definition}/{case_id}/{short}" + ); + } + } + assert_minimized_payload(&serialized); + } else { + let expected = match case + .get("expected_public_problem") + .and_then(Value::as_str) + .expect("failed case names its public problem") + { + "evidence_not_available" => ProblemCode::EvidenceNotAvailable, + "dependency_unavailable" => ProblemCode::DependencyUnavailable, + "service_unavailable" => ProblemCode::ServiceUnavailable, + _ => panic!("unknown public problem"), + }; + response.assert_status(expected.status()); + assert_eq!( + response.json::()["code"], + expected.code(), + "{definition}/{case_id}" + ); + } + + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + let new_events = audit.lines().skip(audit_before).collect::>(); + assert_eq!(new_events.len(), 2, "{definition}/{case_id}"); + let first: Value = serde_json::from_str(new_events[0]).expect("access audit parses"); + let second: Value = serde_json::from_str(new_events[1]).expect("terminal audit parses"); + assert_eq!( + first["operation"], second["operation"], + "{definition}/{case_id}" + ); + for protected in [ + principal.as_str(), + "2000-01-01", + "2008-08-02", + "R-101", + "R-102", + "R-201", + "R-999", + "CURRENT", + "SUSPENDED", + "PENDING", + "UNIQUE", + "CONFIRMED", + "NOT_CONFIRMED", + "Amadou", + "Keita", + "1974-02-11", + "related_subject_name", + "synthetic-other-child-record", + "synthetic-parent-reference-001", + "synthetic-parent-reference-002", + "synthetic-non-parent-reference-003", + "synthetic-substitute-reference", + "PrivacyCanary", + ] { + assert!( + !audit.contains(protected), + "audit leaked protected material" + ); + } + assert_eq!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .iter() + .filter(|request| request.url.path() == source_path) + .count(), + 1, + "{definition}/{case_id} must perform exactly one evidence-data request" + ); + *executed.entry(definition).or_insert(0_usize) += 1; + } + } + + assert_eq!( + executed, + BTreeMap::from([ + ("adult", 11), + ("residence", 9), + ("licence", 10), + ("relationship", 20) + ]) + ); + assert_eq!( + runtime_equivalents, + BTreeSet::from([ + "adult/negative-wrong-derived-type:output-gate".to_owned(), + "licence/negative-exact-date-leak:output-gate".to_owned(), + "relationship/negative-caller-candidate-substitution:pre-source-rejection".to_owned(), + "relationship/negative-extra-family-fact:output-gate".to_owned(), + "relationship/negative-swapped-roles:pre-source-rejection".to_owned(), + "residence/negative-overly-precise-output:output-gate".to_owned(), + ]), + "every runtime-applicable non-source fixture has a named executable equivalent in this module" + ); + assert_eq!( + startup_only, + BTreeSet::from([ + "adult/anti-reconstruction:bundle-rejection".to_owned(), + "licence/anti-reconstruction:bundle-rejection".to_owned(), + "relationship/anti-reconstruction:bundle-rejection".to_owned(), + "residence/anti-reconstruction:bundle-rejection".to_owned(), + ]), + "only companion-bundle conflicts remain startup-only because an invalid bundle cannot initialize an EvidenceRuntime" + ); +} + +/// Many simultaneous evaluations must leave exactly one verifiable audit chain: +/// two records per released assertion, no forked or interleaved hash links, and +/// one distinct evidence identity per request. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_evidence_requests_keep_one_verifiable_audit_chain() { + let load = LoadFixture::start().await; + let outcomes = load.run(LOAD_CONCURRENCY, LOAD_CONCURRENCY).await; + assert_eq!( + outcomes.released(), + LOAD_CONCURRENCY, + "every admitted concurrent request releases evidence; observed {:?}", + outcomes.status_counts() + ); + + let audit = load.shutdown().await; + assert_eq!( + audit.matches("\"phase\":\"access-attempt\"").count(), + LOAD_CONCURRENCY + ); + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + LOAD_CONCURRENCY + ); + assert_eq!( + released_evidence_ids(&audit).len(), + LOAD_CONCURRENCY, + "concurrent releases must not share an evidence identity" + ); + + let verification = verify_jsonl_lines_with_hasher(audit.lines(), &acceptance_audit_hasher()) + .expect("the concurrently written audit chain verifies under the deployment key"); + assert_eq!(verification.records, LOAD_CONCURRENCY * 2); +} + +/// Report sustained request throughput against the two candidate ceilings, so +/// the dominant one is attributed from measurement rather than argument. +/// +/// This asserts only correctness invariants. The rates themselves are reported, +/// never asserted: they are properties of the host filesystem and core count, +/// and a threshold here would be a flake generator. Read the report, record the +/// numbers with the hardware they came from, and compare across changes. +/// +/// ```text +/// cargo test -p registry-evidence --lib -- --ignored --nocapture soak_reports +/// ``` +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "opt-in throughput soak; rates are host-specific and reported, not asserted"] +async fn soak_reports_request_throughput_against_the_audit_ceiling() { + let load = LoadFixture::start().await; + + // Warm the OAuth-free source path, JWKS cache, and connection pool so + // first-request costs do not land inside the measured window. + let _ = load.run(LOAD_CONCURRENCY, LOAD_CONCURRENCY).await; + + let outcomes = load.run(SOAK_REQUESTS, LOAD_CONCURRENCY).await; + let released = outcomes.released(); + let request_rate = released as f64 / outcomes.elapsed.as_secs_f64(); + + let source_rate = load.measure_source_rate(SOURCE_PROBE_REQUESTS).await; + let append_rate = load.measure_audit_append_rate(AUDIT_PROBE_APPENDS).await; + // Every released assertion writes an access-attempt record before source + // access and a disclosure-release record after it. + let audit_ceiling = append_rate / 2.0; + + let percentiles = outcomes.latency_percentiles(); + println!( + "\n=== Evidence throughput report ===\n\ + host : {} logical cores\n\ + load : {SOAK_REQUESTS} requests, {LOAD_CONCURRENCY} concurrent\n\ + released : {released} in {:.2}s\n\ + \n\ + observed request : {request_rate:.0} rps\n\ + audit ceiling : {audit_ceiling:.0} rps ({append_rate:.0} appends/s / 2 records per request)\n\ + mock source floor : {source_rate:.0} rps\n\ + \n\ + latency p50/p95/p99 : {:.1} / {:.1} / {:.1} ms\n\ + audit share of ceiling: {:.0}%\n\ + ==================================\n", + std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get), + outcomes.elapsed.as_secs_f64(), + percentiles.0.as_secs_f64() * 1000.0, + percentiles.1.as_secs_f64() * 1000.0, + percentiles.2.as_secs_f64() * 1000.0, + if audit_ceiling > 0.0 { + request_rate / audit_ceiling * 100.0 + } else { + 0.0 + }, + ); + + assert_eq!( + released, + SOAK_REQUESTS, + "sustained load must not shed requests; observed {:?}", + outcomes.status_counts() + ); + + let audit = load.shutdown().await; + let expected_releases = SOAK_REQUESTS + LOAD_CONCURRENCY; + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + expected_releases + ); + assert_eq!(released_evidence_ids(&audit).len(), expected_releases); + let verification = verify_jsonl_lines_with_hasher(audit.lines(), &acceptance_audit_hasher()) + .expect("the audit chain written under sustained load verifies"); + assert_eq!(verification.records, expected_releases * 2); +} + +fn acceptance_case_time( + case: &serde_json::Map, + common: &serde_json::Map, +) -> chrono::DateTime { + if let Some(date) = case + .get("legal_local_date") + .or_else(|| common.get("legal_local_date")) + .and_then(Value::as_str) + { + return format!("{date}T05:00:00Z") + .parse() + .expect("fixed legal-local fixture date converts"); + } + case.get("observed_at") + .or_else(|| common.get("observed_at")) + .and_then(Value::as_str) + .unwrap_or("2026-08-02T00:00:00Z") + .parse() + .expect("fixed observation time parses") +} + +async fn acceptance_runtime() -> AcceptanceRuntime { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("one immutable acceptance runtime initializes"), + ); + AcceptanceRuntime { + _temporary: prepared.temporary, + bundle_root: prepared.bundle_root, + runtime_path: prepared.runtime_path, + runtime, + server: prepared.server, + audit_path: prepared.audit_path, + } +} + +async fn prepare_acceptance(binding_secret: &str) -> PreparedAcceptance { + let server = MockServer::start().await; + let prepared = prepare_fixture( + binding_secret, + &server.uri(), + &FixtureCeilings::deployment_defaults(), + ); + PreparedAcceptance { + temporary: prepared.temporary, + bundle_root: prepared.bundle_root, + runtime_path: prepared.runtime_path, + server, + audit_path: prepared.audit_path, + } +} + +/// Copy the acceptance bundle into a temporary root, point every source at +/// `source_origin`, write the synthetic secrets and the runtime configuration +/// under `ceilings`, then make the bundle and runtime file read-only. +fn prepare_fixture( + binding_secret: &str, + source_origin: &str, + ceilings: &FixtureCeilings, +) -> PreparedFixture { + let temporary = tempfile::tempdir().expect("temporary acceptance root"); + let bundle_root = temporary.path().join("bundle"); + let runtime_path = temporary.path().join("runtime.yaml"); + let secret_root = temporary.path().join("secrets"); + let audit_path = temporary.path().join("audit.jsonl"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + fs::create_dir(&secret_root).expect("secret root is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("secret root is owner-only"); + } + copy_tree(&fixture_root(), &bundle_root); + + rewrite_deployment_values(&bundle_root, source_origin); + apply_fixture_ceilings(&bundle_root, ceilings); + write_secret( + &secret_root, + "audit-hash-key", + "audit-hash-secret-canary-32-bytes-minimum", + ); + write_secret(&secret_root, "subject-binding-key", binding_secret); + write_secret(&secret_root, "signing-key", EVIDENCE_PRIVATE_JWK); + write_secret(&secret_root, "source-a-token", BEARER); + write_secret(&secret_root, "source-b-token", BEARER); + write_secret(&secret_root, "source-c-username", BASIC_USER); + write_secret(&secret_root, "source-c-password", BASIC_PASSWORD); + write_secret(&secret_root, "source-d-token", BEARER); + write_runtime_config( + &runtime_path, + &bundle_root, + &secret_root, + &audit_path, + ceilings, + ); + make_file_read_only(&runtime_path); + make_read_only(&bundle_root); + + PreparedFixture { + temporary, + bundle_root, + runtime_path, + audit_path, + } +} + +fn authenticator() -> Authenticator { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) + .expect("static auth JWKS parses"); + let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + fetcher, + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + +/// The same authenticator, but resolving the issuer's keys over HTTP from a +/// given address rather than from a key set held in memory. +/// +/// The fetch policy is the permissive one so a loopback test server is a legal +/// address; the deployed policy is built in `Authenticator::from_config` and is +/// not what this exercises. +fn fetching_authenticator(jwks_uri: &str) -> Authenticator { + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new_with_fetch_url_policy( + jwks_uri.to_owned(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + +fn access_token(extra: Option) -> String { + access_token_for("requester-principal-canary", extra) +} + +fn access_token_for(principal: &str, extra: Option) -> String { + access_token_for_issuer(TOKEN_ISSUER, principal, extra) +} + +fn access_token_for_issuer(issuer: &str, principal: &str, extra: Option) -> String { + let now = Utc::now().timestamp(); + let mut claims = json!({ + "iss": issuer, + "aud": TOKEN_AUDIENCE, + "sub": principal, + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + }); + if let Some(Value::Object(extra)) = extra { + claims + .as_object_mut() + .expect("claims are an object") + .extend(extra); + } + signed_access_token(claims) +} + +fn signed_access_token(claims: Value) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "kid": "acceptance-auth-key", + "typ": "at+jwt" + })) + .expect("JWT header serializes"), + ); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let signing_input = format!("{header}.{payload}"); + let key = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth key parses"); + let signature = + URL_SAFE_NO_PAD.encode(sign(signing_input.as_bytes(), &key).expect("JWT signs")); + format!("{signing_input}.{signature}") +} + +fn parent_grant_claims() -> Value { + parent_grant_claims_for(parent_candidate()) +} + +fn parent_grant_claims_for(candidate: Value) -> Value { + json!({ + "evidence_grant_id": "synthetic-parentage-grant-001", + "evidence_authority": AUTHORITY, + "grant": {"candidate_parent": candidate} + }) +} + +fn parent_candidate() -> Value { + json!({"person_reference": PARENT_REFERENCE}) +} + +fn non_parent_candidate() -> Value { + json!({"person_reference": NON_PARENT_REFERENCE}) +} + +async fn mount_success_sources(server: &MockServer, candidate_is_parent: bool) { + mount_adult_source(server, None).await; + mount_residence_source(server).await; + mount_licence_source(server).await; + let parents = if candidate_is_parent { + vec![PARENT_REFERENCE] + } else { + vec!["synthetic-parent-reference-002"] + }; + mount_parent_source(server, parent_source_response(parents)).await; +} + +async fn mount_residence_source(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": {"record_reference": "synthetic-residence-record-001"}, + "fields": ["official_residence_code"], + "limit": 2 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "official_residence_code": "R-101" + }))) + .expect(1) + .mount(server) + .await; +} + +async fn mount_licence_source(server: &MockServer) { + let basic = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{BASIC_USER}:{BASIC_PASSWORD}")) + ); + Mock::given(method("GET")) + .and(path("/v1/records")) + .and(header("accept", "application/json")) + .and(header("authorization", basic.as_str())) + .and(query_param("limit", "2")) + .and(query_param("licence_reference", "synthetic-licence-001")) + .and(query_param("registry_region", "RR-A")) + .and(query_param( + "fields", + "licence_state,valid_from,valid_until", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "records": [{ + "licence_state": "CURRENT", + "valid_from": "2000-01-01", + "valid_until": "2099-12-31", + "historical_states": ["PENDING"] + }] + }))) + .expect(1) + .mount(server) + .await; +} + +async fn mount_adult_source(server: &MockServer, delay: Option) { + mount_adult_source_expecting(server, delay, 1).await; +} + +/// The same fixture source mounted for an exact number of evaluations. +async fn mount_adult_source_expecting(server: &MockServer, delay: Option, expected: u64) { + let response = ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + })); + let response = delay.map_or(response.clone(), |delay| response.set_delay(delay)); + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }))) + .respond_with(response) + .expect(expected) + .mount(server) + .await; +} + +async fn mount_parent_source(server: &MockServer, response: Value) { + Mock::given(method("POST")) + .and(path("/v1/child-relationships")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(parent_source_request())) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(server) + .await; +} + +fn parent_source_request() -> Value { + json!({ + "lookup": {"record_reference": "synthetic-child-record-001"}, + "fields": [ + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete" + ], + "limit": 2 + }) +} + +fn parent_source_response(parent_references: Vec<&str>) -> Value { + json!({ + "total": 1, + "records": [{ + "returned_child_reference": "synthetic-child-record-001", + "parent_references": parent_references, + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }] + }) +} + +async fn wait_for_source_request_count(server: &MockServer, expected: usize) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let count = server + .received_requests() + .await + .expect("request journal is available") + .len(); + if count >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("source request arrives before the test deadline"); +} + +async fn wait_for_audit_counts(path: &Path, access: usize, release: usize) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let contents = fs::read_to_string(path).expect("audit is readable"); + if contents.matches("\"phase\":\"access-attempt\"").count() >= access + && contents.matches("\"phase\":\"disclosure-release\"").count() >= release + { + return contents; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("terminal audit records arrive before the test deadline") +} + +async fn wait_for_runtime_ready(runtime: &EvidenceRuntime) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if runtime.ready().await { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("runtime returns to ready after detached evaluation completes"); +} + +fn adult_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:adult-status:v1", + "fixture-eligibility", + vec![requested_subject( + "subject", + "person-demographics-v1", + Some([ + ("given_name", "Amina"), + ("family_name", "Diallo"), + ("birth_date", "2000-01-01"), + ]), + )], + ) +} + +fn residence_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:residence-region:v1", + "fixture-routing", + vec![requested_subject( + "subject", + "residence-record-v1", + Some([("record_reference", "synthetic-residence-record-001")]), + )], + ) +} + +fn licence_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:professional-licence-status:v1", + "fixture-registration", + vec![requested_subject( + "subject", + "licence-register-v1", + Some([ + ("licence_reference", "synthetic-licence-001"), + ("registry_region", "RR-A"), + ]), + )], + ) +} + +fn parent_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:legal-parent-relationship:v1", + "fixture-enrolment", + vec![ + requested_subject( + "child", + "civil-record-reference-v1", + Some([("record_reference", "synthetic-child-record-001")]), + ), + requested_subject::<[(&str, &str); 0]>("candidate-parent", "person-reference-v1", None), + ], + ) +} + +fn parent_request_with_candidate_values() -> EvidenceRequest { + let mut request = parent_request(); + request.subjects[1].selector.values = Some(BTreeMap::from([( + "person_reference".to_owned(), + SelectorValue::String("synthetic-substitute-reference".to_owned()), + )])); + request +} + +fn request(requirement: &str, purpose: &str, subjects: Vec) -> EvidenceRequest { + EvidenceRequest { + request_nonce: fresh_request_nonce(), + requirement: requirement.to_owned(), + purpose: purpose.to_owned(), + subjects, + holder_key: None, + } +} + +/// A unique canonical nonce per constructed request, so exact-echo and +/// non-propagation assertions cannot pass by collision. +fn fresh_request_nonce() -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let mut bytes = [0u8; 32]; + let unique = ulid::Ulid::new().to_bytes(); + bytes[..16].copy_from_slice(&unique); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn requested_subject<'a, I>(role: &str, profile: &str, values: Option) -> RequestedSubject +where + I: IntoIterator, +{ + RequestedSubject { + role: role.to_owned(), + selector: RequestedSelector { + profile: profile.to_owned(), + values: values.map(|entries| { + entries + .into_iter() + .map(|(name, value)| (name.to_owned(), SelectorValue::String(value.to_owned()))) + .collect() + }), + }, + } +} + +/// Independent policy expectations for one live response. The identity, +/// requirement, purpose, audience, revision, and nonce expectations come from +/// the bundle and the retained request; the subject-binding and output-shape +/// expectations are lifted from the response as an accepted first +/// transaction, exactly as a relying party stores them for later checks. +fn verification_policy( + runtime: &EvidenceRuntime, + request: &EvidenceRequest, + serialized_jws: &[u8], +) -> EvidenceVerificationPolicy { + let requirement = runtime + .bundle() + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is loaded"); + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("flattened JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("flattened JWS payload is base64url"); + let evidence: Evidence = + serde_json::from_slice(&payload).expect("Evidence payload parses for expectations"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + policy.issued_by = runtime.bundle().config.issuer.id.clone(); + policy.provided_by = runtime.bundle().config.service.provider_id.clone(); + policy.requirement = request.requirement.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = request.purpose.clone(); + policy.audience = EVIDENCE_AUDIENCE.to_owned(); + policy.configuration_revision = runtime.bundle().revision().to_owned(); + policy +} + +fn assert_minimized_payload(serialized_jws: &[u8]) { + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("flattened JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("flattened JWS payload is base64url"); + let text = String::from_utf8(payload).expect("Evidence payload is UTF-8 JSON"); + for canary in privacy_canaries() { + assert!( + !text.contains(canary), + "signed JWS retained protected material" + ); + } + for forbidden_field in [ + "record_reference", + "given_name", + "family_name", + "birth_date", + "licence_reference", + "registry_region", + "role_resolution", + "relationship_status", + "date_of_birth", + "official_residence_code", + "historical_states", + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete", + "person_reference", + ] { + assert!(!text.contains(forbidden_field)); + } +} + +fn privacy_canaries() -> &'static [&'static str] { + &[ + "Amina", + "Binta", + "Diallo", + "Other", + "Subject", + "Amadou", + "Keita", + "2000-01-01", + "1970-06-15", + "1971-01-01", + "1974-02-11", + "synthetic-child-record-001", + "synthetic-other-child-record", + "synthetic-residence-record-001", + "synthetic-licence-001", + "synthetic-parentage-grant-001", + "synthetic-parent-reference-001", + "synthetic-parent-reference-002", + "synthetic-non-parent-reference-003", + "synthetic-substitute-reference", + "PrivacyCanary", + "source-bearer-canary", + "source-user-canary", + "source-password-canary", + "audit-hash-secret-canary-32-bytes-minimum", + "subject-binding-secret-canary-32-bytes-minimum", + ] +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/all-definitions") +} + +fn rewrite_deployment_values(bundle_root: &Path, source_origin: &str) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied configuration is readable"); + replace_exact(&mut text, "https://source.invalid", source_origin, 4); + replace_exact( + &mut text, + "fixture-key-2026-01", + "acceptance-evidence-key", + 1, + ); + fs::write(path, text).expect("deployment-only fixture rewrite succeeds"); +} + +/// The deployment ceilings a prepared fixture runs under. +/// +/// Every field here is a production-meaningful default in the acceptance +/// bundle or runtime file. They exist as a struct only so a throughput +/// measurement can lift the ones that would otherwise be the thing measured; +/// the lifted values are measurement scaffolding and are not a recommended +/// deployment posture. +struct FixtureCeilings { + maximum_concurrent_requests: u32, + audit_maximum_file_bytes: u64, + requests_per_principal_per_minute: u64, + burst_per_principal: u64, + source_concurrency_limit: u16, +} + +impl FixtureCeilings { + /// The values the tracked acceptance bundle and the acceptance runtime + /// file carry. Every existing fixture runs under exactly these. + fn deployment_defaults() -> Self { + Self { + maximum_concurrent_requests: 64, + audit_maximum_file_bytes: 10_485_760, + requests_per_principal_per_minute: 60, + burst_per_principal: 10, + source_concurrency_limit: 8, + } + } +} + +/// Rewrite the copied bundle's rate limits and per-source outbound concurrency +/// to `ceilings`. The tracked fixture under `products/evidence/fixtures` is +/// never touched: only the temporary copy is, and only before it is sealed +/// read-only. +fn apply_fixture_ceilings(bundle_root: &Path, ceilings: &FixtureCeilings) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied configuration is readable"); + replace_exact( + &mut text, + "requestsPerPrincipalPerMinute: 60", + &format!( + "requestsPerPrincipalPerMinute: {}", + ceilings.requests_per_principal_per_minute + ), + 1, + ); + replace_exact( + &mut text, + "burstPerPrincipal: 10", + &format!("burstPerPrincipal: {}", ceilings.burst_per_principal), + 1, + ); + replace_exact( + &mut text, + "concurrencyLimit: 8", + &format!("concurrencyLimit: {}", ceilings.source_concurrency_limit), + 4, + ); + fs::write(path, text).expect("deployment-only ceiling rewrite succeeds"); +} + +fn write_runtime_config( + runtime_path: &Path, + bundle_root: &Path, + secret_root: &Path, + audit_path: &Path, + ceilings: &FixtureCeilings, +) { + let document = format!( + r#"version: 1 +bundleDirectory: {} +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: {} + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: {} +auditStorage: + path: {} + maximumFileBytes: {} +outboundTls: + systemRoots: true + trustProfiles: {{}} +"#, + bundle_root.display(), + ceilings.maximum_concurrent_requests, + secret_root.display(), + audit_path.display(), + ceilings.audit_maximum_file_bytes, + ); + fs::write(runtime_path, document).expect("immutable runtime configuration is written"); +} + +fn replace_exact(text: &mut String, from: &str, to: &str, expected: usize) { + assert_eq!( + text.matches(from).count(), + expected, + "fixture drift for {from}" + ); + *text = text.replace(from, to); +} + +fn write_secret(root: &Path, name: &str, value: &str) { + let path = root.join(name); + fs::write(&path, value.as_bytes()).expect("synthetic secret is written"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("synthetic secret is owner-only"); + } +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("acceptance fixture is readable") { + let entry = entry.expect("fixture directory entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture file type is readable") + .is_dir() + { + fs::create_dir(&destination).expect("fixture directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_file_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o444)) + .expect("runtime configuration is immutable"); +} + +#[cfg(unix)] +fn make_file_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) + .expect("runtime configuration becomes writable for mutation test"); +} + +#[cfg(not(unix))] +fn make_file_read_only(path: &Path) { + let mut permissions = fs::metadata(path).expect("runtime metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(path, permissions).expect("runtime configuration is immutable"); +} + +#[cfg(not(unix))] +fn make_file_writable(path: &Path) { + let mut permissions = fs::metadata(path).expect("runtime metadata").permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions) + .expect("runtime configuration becomes writable for mutation test"); +} + +#[cfg(unix)] +fn make_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle directory entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("bundle file type is readable") + .is_dir() + { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("bundle root is immutable"); +} + +#[cfg(unix)] +fn make_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("bundle directory becomes writable for mutation test"); + for entry in fs::read_dir(path).expect("captured bundle remains readable") { + let entry = entry.expect("captured bundle entry remains readable"); + let child = entry.path(); + if entry + .file_type() + .expect("captured bundle file type remains readable") + .is_dir() + { + make_writable(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o644)) + .expect("bundle file becomes writable for mutation test"); + } + } +} + +#[cfg(not(unix))] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle directory entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("bundle file type is readable") + .is_dir() + { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child).expect("bundle metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("bundle file is immutable"); + } + } +} + +#[cfg(not(unix))] +fn make_writable(path: &Path) { + for entry in fs::read_dir(path).expect("captured bundle remains readable") { + let entry = entry.expect("captured bundle entry remains readable"); + let child = entry.path(); + if entry + .file_type() + .expect("captured bundle file type remains readable") + .is_dir() + { + make_writable(&child); + } else { + let mut permissions = fs::metadata(&child) + .expect("captured bundle metadata remains readable") + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(child, permissions) + .expect("bundle file becomes writable for mutation test"); + } + } +} + +// Concurrency and throughput harness. + +/// Simultaneous virtual clients. Held below the fixture listener's 64 admitted +/// request slots so admission queueing never reads as a throughput limit. +const LOAD_CONCURRENCY: usize = 32; + +/// Requests issued by one measured soak window. +const SOAK_REQUESTS: usize = 512; + +/// Direct source calls used to establish the mock-source floor. +const SOURCE_PROBE_REQUESTS: usize = 256; + +/// Appends used to measure the audit sink ceiling on the same filesystem. +const AUDIT_PROBE_APPENDS: usize = 200; + +/// Probe ceiling. The probe writes a small fraction of this. +const AUDIT_PROBE_MAXIMUM_BYTES: u64 = 64 * 1024 * 1024; + +/// The acceptance runtime behind a real TCP listener, driven by real HTTP +/// clients. Load is applied over sockets rather than through an in-process +/// router so admission, the detached evaluation task, and connection handling +/// are all measured. +struct LoadFixture { + _temporary: TempDir, + _source: MockServer, + source_origin: String, + audit_path: PathBuf, + probe_directory: PathBuf, + address: std::net::SocketAddr, + client: reqwest::Client, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, + runtime: Arc, +} + +/// One measured load window. +struct LoadOutcomes { + statuses: Vec, + latencies: Vec, + elapsed: Duration, +} + +impl LoadOutcomes { + fn released(&self) -> usize { + self.statuses + .iter() + .filter(|status| **status == 200) + .count() + } + + fn status_counts(&self) -> BTreeMap { + let mut counts = BTreeMap::new(); + for status in &self.statuses { + *counts.entry(*status).or_insert(0_usize) += 1; + } + counts + } + + /// Nearest-rank p50, p95, and p99 over the whole window. + fn latency_percentiles(&self) -> (Duration, Duration, Duration) { + if self.latencies.is_empty() { + return (Duration::ZERO, Duration::ZERO, Duration::ZERO); + } + let mut sorted = self.latencies.clone(); + sorted.sort_unstable(); + let at = |fraction: f64| { + let rank = (fraction * sorted.len() as f64).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] + }; + (at(0.50), at(0.95), at(0.99)) + } +} + +impl LoadFixture { + async fn start() -> Self { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + mount_unmetered_adult_source(&prepared.server).await; + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the load fixture runtime initializes"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the load listener binds"); + let address = listener + .local_addr() + .expect("the load listener has an address"); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn({ + let runtime = Arc::clone(&runtime); + async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + } + }); + + let client = reqwest::Client::builder() + .pool_max_idle_per_host(LOAD_CONCURRENCY) + .timeout(Duration::from_secs(30)) + .build() + .expect("the load client builds"); + + Self { + source_origin: prepared.server.uri(), + probe_directory: prepared + .audit_path + .parent() + .expect("the audit path has a parent") + .to_path_buf(), + audit_path: prepared.audit_path, + _temporary: prepared.temporary, + _source: prepared.server, + address, + client, + shutdown, + serving, + runtime, + } + } + + /// Issue `total` adult-status requests across `concurrency` clients. + /// + /// Each request carries its own principal so the per-principal token bucket + /// never becomes the thing under measurement. Tokens are signed up front, + /// outside the timed window. + async fn run(&self, total: usize, concurrency: usize) -> LoadOutcomes { + let tokens: Arc> = Arc::new( + (0..total) + .map(|index| access_token_for(&format!("load-principal-{index:06}"), None)) + .collect(), + ); + let body = + Arc::new(serde_json::to_vec(&adult_request()).expect("the load request serializes")); + let endpoint = Arc::new(format!("http://{}/v1/evidence", self.address)); + + let started = Instant::now(); + let mut workers = Vec::with_capacity(concurrency); + for worker in 0..concurrency { + let client = self.client.clone(); + let tokens = Arc::clone(&tokens); + let body = Arc::clone(&body); + let endpoint = Arc::clone(&endpoint); + workers.push(tokio::spawn(async move { + let mut results = Vec::new(); + let mut index = worker; + while index < tokens.len() { + let attempt = Instant::now(); + let response = client + .post(endpoint.as_str()) + .header("authorization", format!("Bearer {}", tokens[index])) + .header("content-type", "application/json") + .body(body.as_ref().clone()) + .send() + .await + .expect("the load request completes"); + let status = response.status().as_u16(); + // Drain the body so the connection returns to the pool. + let _ = response + .bytes() + .await + .expect("the load response body reads"); + results.push((status, attempt.elapsed())); + index += concurrency; + } + results + })); + } + + let mut statuses = Vec::with_capacity(total); + let mut latencies = Vec::with_capacity(total); + for worker in workers { + for (status, latency) in worker.await.expect("a load worker completes") { + statuses.push(status); + latencies.push(latency); + } + } + LoadOutcomes { + statuses, + latencies, + elapsed: started.elapsed(), + } + } + + /// Call the mock source directly to establish the harness floor: no result + /// above this rate is attributable to Evidence. + async fn measure_source_rate(&self, requests: usize) -> f64 { + let endpoint = Arc::new(format!("{}/v1/facts", self.source_origin)); + let started = Instant::now(); + let mut workers = Vec::with_capacity(LOAD_CONCURRENCY); + for worker in 0..LOAD_CONCURRENCY { + let client = self.client.clone(); + let endpoint = Arc::clone(&endpoint); + workers.push(tokio::spawn(async move { + let mut index = worker; + while index < requests { + let response = client + .post(endpoint.as_str()) + .header("accept", "application/json") + .header("authorization", format!("Bearer {BEARER}")) + .json(&adult_source_request()) + .send() + .await + .expect("the source probe request completes"); + let _ = response.bytes().await.expect("the source probe body reads"); + index += LOAD_CONCURRENCY; + } + })); + } + for worker in workers { + worker.await.expect("a source probe worker completes"); + } + requests as f64 / started.elapsed().as_secs_f64() + } + + /// Append through a real keyed sink on the same filesystem as the runtime's + /// own audit file. Appends are serialized by the chain, so this is a + /// sequential measurement by construction, not by choice of harness. + async fn measure_audit_append_rate(&self, appends: usize) -> f64 { + let path = self.probe_directory.join("audit-throughput-probe.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + AUDIT_PROBE_MAXIMUM_BYTES, + b"audit-hash-secret-canary-32-bytes-minimum".to_vec(), + 1, + ) + .await + .expect("the audit throughput probe initializes"); + + let started = Instant::now(); + for index in 0..appends { + log.append(audit_probe_event(index)) + .await + .expect("the audit throughput probe appends"); + } + appends as f64 / started.elapsed().as_secs_f64() + } + + /// Stop the listener, drain detached evaluations, and return the audit file. + async fn shutdown(self) -> String { + let _ = self.shutdown.send(()); + self.serving + .await + .expect("the load listener task completes") + .expect("the load listener shuts down cleanly"); + drop(self.runtime); + fs::read_to_string(&self.audit_path).expect("the load audit file is readable") + } +} + +/// The adult source mock without a call-count expectation, so one mount serves +/// a whole load window. +async fn mount_unmetered_adult_source(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(adult_source_request())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .mount(server) + .await; +} + +fn adult_source_request() -> Value { + json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }) +} + +/// A valid access-attempt event shaped like the ones the runtime writes, so the +/// probe measures the real serialization, hashing, and fsync path. +fn audit_probe_event(index: usize) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + AssuranceProfile::EvidenceGrade, + format!("audit-throughput-probe-{index:012}"), + AuditPhase::AccessAttempt, + "urn:example:fixture:requirement:adult-status:v1".to_owned(), + "audit-throughput-probe".to_owned(), + "fixture-eligibility".to_owned(), + "hmac-sha256:v1:audit-throughput-probe-requester".to_owned(), + AuditAuthority { + kind: AuditAuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "person-demographics-v1".to_owned(), + selector_bundle_pseudonym: None, + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 0, + ) +} + +fn acceptance_audit_hasher() -> AuditChainHasher { + AuditChainHasher::keyed( + AuditHashSecret::new(b"audit-hash-secret-canary-32-bytes-minimum".to_vec()) + .expect("the acceptance audit secret is accepted"), + ) +} + +/// Distinct evidence identities across every disclosure-release record. +fn released_evidence_ids(audit: &str) -> BTreeSet { + audit + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("an audit line is JSON")) + .filter_map(|envelope| { + envelope + .get("record")? + .get("evidenceId")? + .as_str() + .map(str::to_owned) + }) + .collect() +} + +// Sustained end-to-end throughput measurement. + +/// Requests offered simultaneously by the sustained driver. +/// +/// Two durable audit appends sit on every request's critical path, and the +/// audit sink commits in groups: appends that arrive while a durable write is +/// in flight join the next batch instead of each paying an `fsync`. Its rate +/// therefore rises with the number of concurrent appenders and collapses +/// toward one `fsync` per append when only a few are in flight, so the offered +/// concurrency has to keep batches full. 128 also leaves Little's law room: +/// sustaining 1000 requests per second needs roughly `1000 * latency_seconds` +/// requests in flight, so 128 covers per-request latencies up to about 128 ms. +const SUSTAINED_CONCURRENCY: usize = 128; + +/// The measured window. Ten seconds is long enough for group-commit batching, +/// connection reuse, and the verifier and source caches to reach steady state, +/// and short enough that the check stays runnable on demand rather than +/// becoming a nightly job. +const SUSTAINED_WINDOW: Duration = Duration::from_secs(10); + +/// An unmeasured window that absorbs first-request script compilation, lazy +/// initialization, connection establishment, and audit file growth. +const SUSTAINED_WARMUP: Duration = Duration::from_secs(3); + +/// The end-to-end rate this check exists to prove, in requests per second. +const SUSTAINED_TARGET_RPS: f64 = 1000.0; + +/// How far the constant source's own ceiling must sit above the Evidence +/// result before the run is read as a measurement of Evidence. +/// +/// A source held at a fraction `f` of its own ceiling contributes about `f` of +/// the saturation, so a factor of five keeps the source below 20 percent +/// utilization while the service under test is at 100 percent. Below this +/// factor the harness is close enough to the result to be part of what was +/// measured, and the run is reported as inconclusive rather than as a pass or +/// a failure. +const SUSTAINED_SOURCE_HEADROOM: f64 = 5.0; + +/// The one constant body the sustained source returns. It satisfies the +/// projection and fact schema the acceptance bundle declares for that source. +const CONSTANT_SOURCE_BODY: &str = r#"{"total":1,"date_of_birth":"2000-01-01"}"#; + +/// The ceilings the sustained fixture runs under. +/// +/// Each lifted value is a production-meaningful default that would otherwise +/// become the thing measured, not a recommended deployment posture: +/// +/// - `requestsPerPrincipalPerMinute: 60` with `burstPerPrincipal: 10` is one +/// request per second per principal, so an unlifted run measures the rate +/// limiter returning `rate_limited`. +/// - `maximumConcurrentRequests: 64` admits fewer requests than this driver +/// offers, so an unlifted run measures admission queueing. +/// - `concurrencyLimit: 8` per source caps outbound calls in flight, so an +/// unlifted run measures the source semaphore. +/// - `maximumFileBytes: 10485760` rotates the audit segment several times +/// inside a measured window, so an unlifted run measures segment rotation. +/// +/// Deployments should keep the tracked defaults and tune from real traffic. +fn sustained_ceilings() -> FixtureCeilings { + FixtureCeilings { + maximum_concurrent_requests: 512, + audit_maximum_file_bytes: 1_073_741_824, + requests_per_principal_per_minute: 1_000_000, + burst_per_principal: 100_000, + source_concurrency_limit: 256, + } +} + +/// The upstream source used for sustained measurement: one `axum` handler +/// returning a constant JSON body over a real socket. +/// +/// A matching mock server puts request matching, shared-state locking, and +/// per-call allocation on the measured path, and a rate measured through one +/// describes the harness rather than Evidence. This handler matches nothing, +/// locks nothing, and allocates only its response. +struct ConstantSource { + origin: String, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, +} + +async fn start_constant_source() -> ConstantSource { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the constant source listener binds"); + let address = listener + .local_addr() + .expect("the constant source listener has an address"); + let router = axum::Router::new().route( + "/v1/facts", + axum::routing::post(|| async { + // Built explicitly so the response carries exactly one + // `Content-Type`, which is what the source client requires. + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(CONSTANT_SOURCE_BODY)) + .expect("the constant source response builds") + }), + ); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + ConstantSource { + origin: format!("http://{address}"), + shutdown, + serving, + } +} + +/// One flat-out window against one endpoint. Every attempt lands in exactly +/// one of `statuses` or `transport_failures`, so nothing is dropped. +#[derive(Default)] +struct SustainedOutcome { + statuses: BTreeMap, + latencies: Vec, + transport_failures: Vec, + elapsed: Duration, +} + +impl SustainedOutcome { + fn attempted(&self) -> usize { + self.latencies.len() + self.transport_failures.len() + } + + fn released(&self) -> usize { + self.statuses.get(&200).copied().unwrap_or_default() + } + + /// Attempts that did not return 200, including transport failures. + fn unexpected(&self) -> usize { + self.attempted() - self.released() + } + + fn rate(&self) -> f64 { + if self.elapsed.is_zero() { + return 0.0; + } + self.released() as f64 / self.elapsed.as_secs_f64() + } + + /// Nearest-rank p50, p95, and p99 over the whole window. + fn latency_percentiles(&self) -> (Duration, Duration, Duration) { + if self.latencies.is_empty() { + return (Duration::ZERO, Duration::ZERO, Duration::ZERO); + } + let mut sorted = self.latencies.clone(); + sorted.sort_unstable(); + let at = |fraction: f64| { + let rank = (fraction * sorted.len() as f64).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] + }; + (at(0.50), at(0.95), at(0.99)) + } + + /// A short, operator-readable account of everything that was not a 200. + fn failure_summary(&self) -> String { + let statuses: Vec = self + .statuses + .iter() + .filter(|(status, _)| **status != 200) + .map(|(status, count)| format!("{status} x{count}")) + .collect(); + let mut summary = if statuses.is_empty() { + "no non-2xx statuses".to_owned() + } else { + statuses.join(", ") + }; + if let Some(first) = self.transport_failures.first() { + summary.push_str(&format!( + "; {} transport failures, first: {first}", + self.transport_failures.len() + )); + } + summary + } +} + +/// Issue requests flat out for `window` across `SUSTAINED_CONCURRENCY` closed +/// loop workers. Worker `n` presents `authorizations[n]`, so the caller decides +/// whether load spreads across principals or concentrates on one. +/// +/// Both the service and the constant source are driven through this function +/// with the same client, worker count, header set, and window, so the two +/// reported rates are comparable. +async fn drive_flat_out( + client: &reqwest::Client, + endpoint: &str, + body: &bytes::Bytes, + authorizations: &[String], + window: Duration, +) -> SustainedOutcome { + assert_eq!( + authorizations.len(), + SUSTAINED_CONCURRENCY, + "one authorization per worker" + ); + let endpoint = Arc::new(endpoint.to_owned()); + let started = Instant::now(); + let deadline = started + window; + let mut workers = Vec::with_capacity(SUSTAINED_CONCURRENCY); + for authorization in authorizations { + let client = client.clone(); + let endpoint = Arc::clone(&endpoint); + let body = body.clone(); + let authorization = authorization.clone(); + workers.push(tokio::spawn(async move { + let mut attempts: Vec> = Vec::new(); + while Instant::now() < deadline { + let issued = Instant::now(); + let sent = client + .post(endpoint.as_str()) + .header("authorization", authorization.as_str()) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await; + attempts.push(match sent { + // Draining the body returns the connection to the pool. A + // body that fails to arrive is a failed request, not a + // successful one with a footnote. + Ok(response) => { + let status = response.status().as_u16(); + match response.bytes().await { + Ok(_) => Ok((status, issued.elapsed())), + Err(error) => Err(error.to_string()), + } + } + Err(error) => Err(error.to_string()), + }); + } + attempts + })); + } + + let mut outcome = SustainedOutcome::default(); + for worker in workers { + for attempt in worker.await.expect("a sustained load worker completes") { + match attempt { + Ok((status, latency)) => { + *outcome.statuses.entry(status).or_default() += 1; + outcome.latencies.push(latency); + } + Err(failure) => outcome.transport_failures.push(failure), + } + } + } + outcome.elapsed = started.elapsed(); + outcome +} + +/// The acceptance runtime behind a real TCP listener, with a constant +/// in-process upstream source, driven by a real HTTP client over sockets. +struct SustainedFixture { + _temporary: TempDir, + source: ConstantSource, + address: std::net::SocketAddr, + client: reqwest::Client, + /// One access token per worker, signed once outside every measured window. + authorizations: Vec, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, + runtime: Arc, + audit_path: PathBuf, +} + +impl SustainedFixture { + async fn start() -> Self { + let source = start_constant_source().await; + let prepared = prepare_fixture( + "subject-binding-secret-canary-32-bytes-minimum", + &source.origin, + &sustained_ceilings(), + ); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the sustained load runtime initializes"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the sustained load listener binds"); + let address = listener + .local_addr() + .expect("the sustained load listener has an address"); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn({ + let runtime = Arc::clone(&runtime); + async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + } + }); + + let client = reqwest::Client::builder() + .pool_max_idle_per_host(SUSTAINED_CONCURRENCY) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .expect("the sustained load client builds"); + + // One principal per worker keeps the rate limiter's tracked key set + // small and its per-check pruning cheap, so neither the token bucket + // nor its map becomes the thing measured. + let authorizations = (0..SUSTAINED_CONCURRENCY) + .map(|worker| { + format!( + "Bearer {}", + access_token_for(&format!("sustained-principal-{worker:04}"), None) + ) + }) + .collect(); + + Self { + _temporary: prepared.temporary, + source, + address, + client, + authorizations, + shutdown, + serving, + runtime, + audit_path: prepared.audit_path, + } + } + + /// Drive the whole Evidence request path for `window`. + async fn drive_service(&self, window: Duration) -> SustainedOutcome { + let body = bytes::Bytes::from( + serde_json::to_vec(&adult_request()).expect("the sustained request serializes"), + ); + drive_flat_out( + &self.client, + &format!("http://{}/v1/evidence", self.address), + &body, + &self.authorizations, + window, + ) + .await + } + + /// Drive the constant source directly to establish the harness ceiling the + /// Evidence result has to sit well below. + /// + /// The probe opens its own connections and gets no warm-up, so it + /// understates the source. That is the safe direction: it can only shrink + /// the reported headroom, never inflate it. + async fn drive_source(&self, window: Duration) -> SustainedOutcome { + let body = bytes::Bytes::from( + serde_json::to_vec(&adult_source_request()).expect("the probe body serializes"), + ); + drive_flat_out( + &self.client, + &format!("{}/v1/facts", self.source.origin), + &body, + &self.authorizations, + window, + ) + .await + } + + /// Stop the listener and the source, drain detached evaluations, and return + /// the durable audit file. + async fn shutdown(self) -> String { + let _ = self.shutdown.send(()); + self.serving + .await + .expect("the sustained listener task completes") + .expect("the sustained listener shuts down cleanly"); + let _ = self.source.shutdown.send(()); + self.source + .serving + .await + .expect("the constant source task completes") + .expect("the constant source shuts down cleanly"); + drop(self.runtime); + fs::read_to_string(&self.audit_path).expect("the sustained audit file is readable") + } +} + +/// Sustain 1000 end-to-end requests per second through the whole request path. +/// +/// Every measured request runs token verification, rate limiting, Rhai request +/// preparation, one outbound source call, Rhai extraction, evidence +/// construction, Ed25519 signing, and two durable audit appends, over real +/// sockets against the real router. At 1000 requests per second that is 2000 +/// audit appends per second. +/// +/// The upstream source is a constant in-process handler whose own ceiling is +/// measured in the same run, under the same client, worker count, header set, +/// and window shape. When that ceiling is not at least +/// `SUSTAINED_SOURCE_HEADROOM` times the Evidence result, the harness is part +/// of what was measured and the run is reported as inconclusive rather than as +/// a pass or a failure. +/// +/// The target holds with and without `--release`; the recorded figures in +/// `products/evidence/OPERATOR-CONTRACT.md` come from the optimized build. +/// +/// ```text +/// cargo test --release -p registry-evidence --lib -- --ignored --nocapture sustained_load_holds_one_thousand_requests_per_second +/// ``` +#[tokio::test(flavor = "multi_thread")] +#[ignore = "opt-in sustained throughput target; host-specific and long running"] +async fn sustained_load_holds_one_thousand_requests_per_second() { + let fixture = SustainedFixture::start().await; + + let warmup = fixture.drive_service(SUSTAINED_WARMUP).await; + assert_eq!( + warmup.unexpected(), + 0, + "warm-up must already be clean; observed {}", + warmup.failure_summary() + ); + + let measured = fixture.drive_service(SUSTAINED_WINDOW).await; + let source = fixture.drive_source(SUSTAINED_WINDOW).await; + + let rate = measured.rate(); + let source_rate = source.rate(); + let headroom = if rate > 0.0 { source_rate / rate } else { 0.0 }; + let percentiles = measured.latency_percentiles(); + println!( + "\n=== Evidence sustained throughput ===\n\ + host : {} logical cores\n\ + offered concurrency : {SUSTAINED_CONCURRENCY} in flight, {} principals\n\ + measured window : {:.1}s after a {:.1}s warm-up\n\ + \n\ + requests released : {} in {:.2}s\n\ + achieved rate : {rate:.0} rps ({:.0} audit appends/s)\n\ + target : {SUSTAINED_TARGET_RPS:.0} rps\n\ + non-2xx and failures : {} ({})\n\ + \n\ + latency p50 : {:.2} ms\n\ + latency p95 : {:.2} ms\n\ + latency p99 : {:.2} ms\n\ + \n\ + constant source rate : {source_rate:.0} rps standalone, {} non-2xx and failures\n\ + source headroom : {headroom:.1}x over Evidence (validity floor {SUSTAINED_SOURCE_HEADROOM:.1}x)\n\ + =====================================\n", + std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get), + SUSTAINED_CONCURRENCY, + SUSTAINED_WINDOW.as_secs_f64(), + SUSTAINED_WARMUP.as_secs_f64(), + measured.released(), + measured.elapsed.as_secs_f64(), + rate * 2.0, + measured.unexpected(), + measured.failure_summary(), + percentiles.0.as_secs_f64() * 1000.0, + percentiles.1.as_secs_f64() * 1000.0, + percentiles.2.as_secs_f64() * 1000.0, + source.unexpected(), + ); + + assert_eq!( + measured.unexpected(), + 0, + "sustained load must not shed requests; observed {}", + measured.failure_summary() + ); + assert_eq!( + source.unexpected(), + 0, + "the constant source probe must not shed requests; observed {}", + source.failure_summary() + ); + assert!( + headroom >= SUSTAINED_SOURCE_HEADROOM, + "INCONCLUSIVE: the constant source sustained {source_rate:.0} rps against Evidence at \ + {rate:.0} rps, only {headroom:.1}x. Below {SUSTAINED_SOURCE_HEADROOM:.1}x the harness is \ + part of what was measured, so this run is neither a pass nor a failure" + ); + assert!( + rate >= SUSTAINED_TARGET_RPS, + "sustained {rate:.0} rps, below the {SUSTAINED_TARGET_RPS:.0} rps target" + ); + + // Both audit appends are on the request's critical path, so a released + // assertion that skipped one would be a silently cheaper request. + let released = warmup.released() + measured.released(); + let audit = fixture.shutdown().await; + assert_eq!( + audit.matches("\"phase\":\"access-attempt\"").count(), + released, + "one access-attempt record per released assertion" + ); + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + released, + "one disclosure-release record per released assertion" + ); +} diff --git a/crates/registry-evidence/src/sdjwt_vc.rs b/crates/registry-evidence/src/sdjwt_vc.rs new file mode 100644 index 000000000..8f5f5fa55 --- /dev/null +++ b/crates/registry-evidence/src/sdjwt_vc.rs @@ -0,0 +1,729 @@ +//! Projection of a constructed Evidence payload onto the frozen SD-JWT VC +//! profile in `products/evidence/contracts/sd-jwt-vc-profile.yaml`, and the +//! inverse projection used by the relying-party verifier. +//! +//! The projection re-derives nothing. It re-encodes the exact payload the +//! signed-JWS format would carry: the always-disclosed claims become public +//! JWT claims, and each supported value becomes exactly one selective +//! disclosure keyed by its concept identifier. The inverse rebuilds that same +//! payload so one policy engine serves both response formats. + +use std::collections::BTreeMap; + +use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; +use registry_platform_crypto::PublicJwk; +use registry_platform_sdjwt::{ + Disclosure, HolderConfirmation, ObjectDisclosure, SdJwtIssuanceInput, +}; +use serde::Serialize; +use serde_json::{Map, Value}; +use thiserror::Error; + +use crate::{ + model::{Evidence, HolderPublicKey}, + EVIDENCE_SCHEMA_V1, +}; + +/// Claims the issuer writes itself. `status` is absent because Version 1 +/// publishes no credential status. +const ISSUER_OWNED_CLAIMS: [&str; 10] = [ + "iss", "sub", "iat", "exp", "vct", "id", "jti", "_sd", "_sd_alg", "cnf", +]; + +/// The profile's always-disclosed claims that carry Evidence members, in the +/// sorted order the issuance input uses. +const ALWAYS_DISCLOSED_CLAIMS: [&str; 10] = [ + "assuranceProfile", + "audience", + "configurationRevision", + "issuedBy", + "observedAt", + "providedBy", + "purpose", + "requestNonce", + "subjects", + "supportsRequirement", +]; + +const STRUCTURED_VALUES_CLAIM: &str = "structuredValues"; + +/// A constructed payload that cannot be projected onto the profile. Every +/// variant is an internal invariant violation rather than caller input, except +/// `HolderKey`, which the runtime rejects earlier and re-checks here so the +/// unacceptable key can never reach a signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum SdJwtVcMappingError { + #[error("an evidence timestamp is not an RFC 3339 instant")] + Timestamp, + #[error("the evidence carries no role-bound subject")] + Subjects, + #[error("an evidence claim is not representable as JSON")] + Claim, + #[error("the holder public key is not an acceptable Ed25519 public JWK")] + HolderKey, + #[error("the SD-JWT VC structured projection is inconsistent with the evidence value")] + StructuredProjection, +} + +/// Map a constructed payload and an optional holder key onto the issuance +/// input. The public claim set is exactly the profile's always-disclosed list +/// minus the members the issuer writes itself (`iss`, `sub`, `iat`, `exp`, +/// `vct`, `jti`, `_sd_alg`). +pub fn issuance_input( + evidence: &Evidence, + holder_key: Option<&HolderPublicKey>, + structured_projections: &BTreeMap, +) -> Result { + // Deterministic because the kernel canonicalizes subjects to requirement + // declaration order. The complete set travels as a public claim; `sub` is + // a convenience projection of the first role. + let subject = evidence + .subjects + .first() + .ok_or(SdJwtVcMappingError::Subjects)?; + + let mut public_claims = BTreeMap::new(); + public_claims.insert("issuedBy".to_string(), string_claim(&evidence.issued_by)); + public_claims.insert( + "providedBy".to_string(), + string_claim(&evidence.provided_by), + ); + public_claims.insert( + "supportsRequirement".to_string(), + string_claim(&evidence.supports_requirement), + ); + public_claims.insert("purpose".to_string(), string_claim(&evidence.purpose)); + public_claims.insert("audience".to_string(), string_claim(&evidence.audience)); + public_claims.insert( + "assuranceProfile".to_string(), + claim_value(&evidence.assurance_profile)?, + ); + public_claims.insert( + "observedAt".to_string(), + string_claim(&evidence.observed_at), + ); + public_claims.insert( + "configurationRevision".to_string(), + string_claim(&evidence.configuration_revision), + ); + public_claims.insert( + "requestNonce".to_string(), + string_claim(&evidence.request_nonce), + ); + public_claims.insert("subjects".to_string(), claim_value(&evidence.subjects)?); + + let mut disclosures = Vec::with_capacity(evidence.supported_values.len()); + let mut object_disclosures = Vec::new(); + let mut structured_values = Map::new(); + for (position, supported) in evidence.supported_values.iter().enumerate() { + if let Some(claim) = structured_projections.get(&supported.provides_value_for) { + let crate::model::PublicValue::Structured(structured) = &supported.value else { + return Err(SdJwtVcMappingError::StructuredProjection); + }; + let fields = structured + .fields + .iter() + .map(|(name, value)| Disclosure { + name: name.clone(), + value: value.clone(), + }) + .collect::>(); + if fields.is_empty() { + return Err(SdJwtVcMappingError::StructuredProjection); + } + object_disclosures.push(ObjectDisclosure { + name: claim.clone(), + fields, + }); + structured_values.insert( + claim.clone(), + serde_json::json!({ + "providesValueFor": supported.provides_value_for, + "form": "reviewed-structured-value", + "schema": structured.schema, + "position": position, + }), + ); + continue; + } + disclosures.push(Disclosure { + name: supported.provides_value_for.clone(), + value: claim_value(&supported.value)?, + }); + } + if !structured_values.is_empty() { + public_claims.insert( + STRUCTURED_VALUES_CLAIM.to_string(), + Value::Object(structured_values), + ); + } + + Ok(SdJwtIssuanceInput { + // The technical provider controlling the signing key, matching the + // signed-JWS trust statement. The named legal issuer travels as + // `issuedBy`. + iss: evidence.provided_by.clone(), + sub_ref: subject.binding.clone(), + credential_id: Some(evidence.id.clone()), + iat: unix_seconds(&evidence.issued_at)?, + exp: unix_seconds(&evidence.valid_until)?, + vct: evidence.is_conformant_to.clone(), + // Version 1 publishes no credential status, so no status reference can + // be constructed for one. + status: None, + public_claims, + cnf: holder_key.map(confirmation).transpose()?, + disclosures, + object_disclosures, + }) +} + +/// Build the `cnf` member. The key identifier stays inside the JWK so the +/// confirmation carries exactly one confirmation method. +fn confirmation(key: &HolderPublicKey) -> Result { + if !key.is_acceptable() { + return Err(SdJwtVcMappingError::HolderKey); + } + Ok(HolderConfirmation { + jwk: PublicJwk { + kty: key.kty.clone(), + kid: key.kid.clone(), + alg: key.alg.clone(), + crv: Some(key.crv.clone()), + x: Some(key.x.clone()), + y: None, + n: None, + e: None, + }, + kid: None, + }) +} + +fn unix_seconds(value: &str) -> Result { + DateTime::parse_from_rfc3339(value) + .map(|instant| instant.timestamp()) + .map_err(|_| SdJwtVcMappingError::Timestamp) +} + +fn string_claim(value: &str) -> Value { + Value::String(value.to_string()) +} + +fn claim_value(value: &T) -> Result { + serde_json::to_value(value).map_err(|_| SdJwtVcMappingError::Claim) +} + +/// A verified token whose claim set is not the profile's projection of an +/// Evidence payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum SdJwtVcClaimError { + #[error("the token carries a claim the profile does not publish")] + UnexpectedClaim, + #[error("the token is missing a claim the profile always discloses")] + MissingClaim, + #[error("a token claim has the wrong type or an inconsistent value")] + ClaimShape, + #[error("a token timestamp is not a representable instant")] + Timestamp, +} + +/// Rebuild the Evidence payload that a compact SD-JWT VC projects from. +/// +/// The claim set is closed: any member outside the issuer-owned claims and the +/// profile's always-disclosed claims fails, which is how a relying party +/// detects a prohibited `status`, `aud`, `nbf`, or smuggled selector claim. The +/// result is a JSON payload, not a parsed `Evidence`, so the caller applies the +/// same contract validation and deserialization the signed-JWS path applies. +pub fn evidence_payload_from_claims( + claims: &Map, + disclosed: &[(String, Value)], +) -> Result { + let structured = structured_value_metadata(claims)?; + for name in claims.keys() { + if !ISSUER_OWNED_CLAIMS.contains(&name.as_str()) + && !ALWAYS_DISCLOSED_CLAIMS.contains(&name.as_str()) + && name != STRUCTURED_VALUES_CLAIM + && !structured.contains_key(name) + { + return Err(SdJwtVcClaimError::UnexpectedClaim); + } + } + + let id = string_of(claims, "id")?; + if string_of(claims, "jti")? != id { + return Err(SdJwtVcClaimError::ClaimShape); + } + + // The profile sources `iss` and `providedBy` from the same + // `service.providerId`, so a token that disagrees with itself about the + // technical provider is not the projection of any payload. + if string_of(claims, "iss")? != string_of(claims, "providedBy")? { + return Err(SdJwtVcClaimError::ClaimShape); + } + + let subjects = claims + .get("subjects") + .ok_or(SdJwtVcClaimError::MissingClaim)? + .clone(); + // `sub` is a projection of the first role-bound subject, never an + // independent identifier. + let first_binding = subjects + .as_array() + .and_then(|roles| roles.first()) + .and_then(|role| role.get("binding")) + .and_then(Value::as_str) + .ok_or(SdJwtVcClaimError::ClaimShape)?; + if string_of(claims, "sub")? != first_binding { + return Err(SdJwtVcClaimError::ClaimShape); + } + + let mut supported_values = Vec::with_capacity(disclosed.len()); + let mut concepts = std::collections::BTreeSet::::new(); + for (concept, value) in disclosed { + if !concepts.insert(concept.clone()) { + return Err(SdJwtVcClaimError::ClaimShape); + } + supported_values.push(serde_json::json!({ + "providesValueFor": concept, + "value": value, + })); + } + let mut projected = Vec::with_capacity(structured.len()); + for (claim, metadata) in structured { + if !concepts.insert(metadata.concept.clone()) { + return Err(SdJwtVcClaimError::ClaimShape); + } + let fields = claims + .get(&claim) + .and_then(Value::as_object) + .filter(|fields| !fields.is_empty() && fields.len() <= 64) + .ok_or(SdJwtVcClaimError::ClaimShape)?; + projected.push(( + metadata.position, + serde_json::json!({ + "providesValueFor": metadata.concept, + "value": { + "form": "reviewed-structured-value", + "schema": metadata.schema, + "fields": fields, + }, + }), + )); + } + projected.sort_by_key(|(position, _)| *position); + for (position, value) in projected { + if position > supported_values.len() { + return Err(SdJwtVcClaimError::ClaimShape); + } + supported_values.insert(position, value); + } + + Ok(serde_json::json!({ + "schema": EVIDENCE_SCHEMA_V1, + "assuranceProfile": claims.get("assuranceProfile").ok_or(SdJwtVcClaimError::MissingClaim)?, + "requestNonce": claims.get("requestNonce").ok_or(SdJwtVcClaimError::MissingClaim)?, + "id": id, + "type": "Evidence", + "supportsRequirement": claims.get("supportsRequirement").ok_or(SdJwtVcClaimError::MissingClaim)?, + "isConformantTo": claims.get("vct").ok_or(SdJwtVcClaimError::MissingClaim)?, + "issuedBy": claims.get("issuedBy").ok_or(SdJwtVcClaimError::MissingClaim)?, + "providedBy": claims.get("providedBy").ok_or(SdJwtVcClaimError::MissingClaim)?, + "issuedAt": rfc3339_of(claims, "iat")?, + "observedAt": claims.get("observedAt").ok_or(SdJwtVcClaimError::MissingClaim)?, + "validUntil": rfc3339_of(claims, "exp")?, + "purpose": claims.get("purpose").ok_or(SdJwtVcClaimError::MissingClaim)?, + "audience": claims.get("audience").ok_or(SdJwtVcClaimError::MissingClaim)?, + "configurationRevision": claims.get("configurationRevision").ok_or(SdJwtVcClaimError::MissingClaim)?, + "subjects": subjects, + "supportedValues": supported_values, + })) +} + +struct StructuredValueMetadata { + concept: String, + schema: String, + position: usize, +} + +fn structured_value_metadata( + claims: &Map, +) -> Result, SdJwtVcClaimError> { + let Some(value) = claims.get(STRUCTURED_VALUES_CLAIM) else { + return Ok(BTreeMap::new()); + }; + let object = value.as_object().ok_or(SdJwtVcClaimError::ClaimShape)?; + if object.is_empty() || object.len() > 16 { + return Err(SdJwtVcClaimError::ClaimShape); + } + let mut result = BTreeMap::new(); + let mut concepts = std::collections::BTreeSet::new(); + let mut positions = std::collections::BTreeSet::new(); + for (claim, metadata) in object { + let metadata = metadata + .as_object() + .filter(|metadata| { + metadata.len() == 4 + && metadata.contains_key("providesValueFor") + && metadata.contains_key("form") + && metadata.contains_key("schema") + && metadata.contains_key("position") + }) + .ok_or(SdJwtVcClaimError::ClaimShape)?; + if metadata.get("form").and_then(Value::as_str) != Some("reviewed-structured-value") { + return Err(SdJwtVcClaimError::ClaimShape); + } + let concept = metadata + .get("providesValueFor") + .and_then(Value::as_str) + .ok_or(SdJwtVcClaimError::ClaimShape)? + .to_owned(); + let schema = metadata + .get("schema") + .and_then(Value::as_str) + .ok_or(SdJwtVcClaimError::ClaimShape)? + .to_owned(); + let position = metadata + .get("position") + .and_then(Value::as_u64) + .and_then(|position| usize::try_from(position).ok()) + .filter(|position| *position < 16) + .ok_or(SdJwtVcClaimError::ClaimShape)?; + if !concepts.insert(concept.clone()) + || !positions.insert(position) + || !claims.get(claim).is_some_and(Value::is_object) + { + return Err(SdJwtVcClaimError::ClaimShape); + } + result.insert( + claim.clone(), + StructuredValueMetadata { + concept, + schema, + position, + }, + ); + } + Ok(result) +} + +fn string_of<'a>(claims: &'a Map, name: &str) -> Result<&'a str, SdJwtVcClaimError> { + claims + .get(name) + .ok_or(SdJwtVcClaimError::MissingClaim)? + .as_str() + .ok_or(SdJwtVcClaimError::ClaimShape) +} + +/// `iat` and `exp` are the profile's only numeric time claims. The kernel +/// emits every Evidence timestamp at whole-second UTC precision, so the +/// rebuilt string is the exact string the signed-JWS payload carries. +fn rfc3339_of(claims: &Map, name: &str) -> Result { + let seconds = claims + .get(name) + .ok_or(SdJwtVcClaimError::MissingClaim)? + .as_i64() + .ok_or(SdJwtVcClaimError::ClaimShape)?; + Utc.timestamp_opt(seconds, 0) + .single() + .map(|instant| instant.to_rfc3339_opts(SecondsFormat::Secs, true)) + .ok_or(SdJwtVcClaimError::Timestamp) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + EvidenceObjectType, PublicValue, SubjectBinding, SupportedValue, + OFFLINE_EVALUATION_REQUEST_NONCE, + }; + use crate::EVIDENCE_SCHEMA_V1; + + fn evidence() -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_string(), + assurance_profile: crate::config::AssuranceProfile::EvidenceGrade, + request_nonce: OFFLINE_EVALUATION_REQUEST_NONCE.to_string(), + id: "urn:evidence:assertion:v1_2f0a".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:adult-status".to_string(), + is_conformant_to: "urn:example:evidence-type:adult-status".to_string(), + issued_by: "urn:example:issuer:civil-registry".to_string(), + provided_by: "urn:example:provider:evidence-service".to_string(), + issued_at: "2026-08-02T09:15:00Z".to_string(), + observed_at: "2026-08-02T09:14:59Z".to_string(), + valid_until: "2026-08-02T09:20:00Z".to_string(), + purpose: "age-gated-service".to_string(), + audience: "urn:example:relying-party:library".to_string(), + configuration_revision: "rev-7".to_string(), + subjects: vec![ + SubjectBinding { + role: "applicant".to_string(), + binding: "urn:evidence:subject:v1_aaaa".to_string(), + }, + SubjectBinding { + role: "guardian".to_string(), + binding: "urn:evidence:subject:v1_bbbb".to_string(), + }, + ], + supported_values: vec![ + SupportedValue { + provides_value_for: "urn:example:concept:is-adult".to_string(), + value: PublicValue::Boolean(true), + }, + SupportedValue { + provides_value_for: "urn:example:concept:jurisdiction".to_string(), + value: PublicValue::String("SE".to_string()), + }, + ], + } + } + + fn holder_key() -> HolderPublicKey { + HolderPublicKey { + kty: "OKP".to_string(), + crv: "Ed25519".to_string(), + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_string(), + alg: None, + kid: None, + } + } + + #[test] + fn projects_the_always_disclosed_claims() { + let evidence = evidence(); + let input = issuance_input(&evidence, None, &BTreeMap::new()).expect("evidence maps"); + + assert_eq!(input.iss, "urn:example:provider:evidence-service"); + assert_eq!(input.sub_ref, "urn:evidence:subject:v1_aaaa"); + assert_eq!( + input.credential_id.as_deref(), + Some("urn:evidence:assertion:v1_2f0a") + ); + assert_eq!(input.vct, "urn:example:evidence-type:adult-status"); + assert_eq!(input.iat, 1_785_662_100); + assert_eq!(input.exp, 1_785_662_400); + assert!(input.status.is_none()); + assert!(input.cnf.is_none()); + + let names: Vec<&str> = input.public_claims.keys().map(String::as_str).collect(); + assert_eq!( + names, + [ + "assuranceProfile", + "audience", + "configurationRevision", + "issuedBy", + "observedAt", + "providedBy", + "purpose", + "requestNonce", + "subjects", + "supportsRequirement", + ] + ); + assert_eq!( + input.public_claims["issuedBy"], + Value::String("urn:example:issuer:civil-registry".to_string()) + ); + assert_eq!( + input.public_claims["requestNonce"], + Value::String(OFFLINE_EVALUATION_REQUEST_NONCE.to_string()) + ); + assert_eq!( + input.public_claims["subjects"], + serde_json::json!([ + {"role": "applicant", "binding": "urn:evidence:subject:v1_aaaa"}, + {"role": "guardian", "binding": "urn:evidence:subject:v1_bbbb"}, + ]) + ); + } + + #[test] + fn selectively_discloses_exactly_one_value_per_concept() { + let evidence = evidence(); + let input = issuance_input(&evidence, None, &BTreeMap::new()).expect("evidence maps"); + + let disclosed: Vec<(&str, &Value)> = input + .disclosures + .iter() + .map(|disclosure| (disclosure.name.as_str(), &disclosure.value)) + .collect(); + assert_eq!( + disclosed, + [ + ("urn:example:concept:is-adult", &Value::Bool(true)), + ( + "urn:example:concept:jurisdiction", + &Value::String("SE".to_string()) + ), + ] + ); + } + + #[test] + fn omits_the_prohibited_claims() { + let evidence = evidence(); + let input = issuance_input(&evidence, None, &BTreeMap::new()).expect("evidence maps"); + + for prohibited in ["status", "nbf", "aud", "selector", "grant", "actor"] { + assert!( + !input.public_claims.contains_key(prohibited), + "{prohibited} must not be published" + ); + assert!( + !input + .disclosures + .iter() + .any(|disclosure| disclosure.name == prohibited), + "{prohibited} must not be disclosed" + ); + } + } + + #[test] + fn embeds_an_acceptable_holder_key_as_confirmation() { + let evidence = evidence(); + let mut key = holder_key(); + key.kid = Some("holder-1".to_string()); + key.alg = Some("EdDSA".to_string()); + + let confirmation = issuance_input(&evidence, Some(&key), &BTreeMap::new()) + .expect("evidence maps") + .cnf + .expect("confirmation is present"); + assert_eq!(confirmation.jwk.kty, "OKP"); + assert_eq!(confirmation.jwk.crv.as_deref(), Some("Ed25519")); + assert_eq!(confirmation.jwk.x.as_deref(), Some(key.x.as_str())); + assert_eq!(confirmation.jwk.kid.as_deref(), Some("holder-1")); + assert_eq!(confirmation.jwk.alg.as_deref(), Some("EdDSA")); + assert!(confirmation.kid.is_none()); + } + + #[test] + fn rejects_unacceptable_holder_keys() { + let evidence = evidence(); + let mut wrong_curve = holder_key(); + wrong_curve.crv = "P-256".to_string(); + let mut wrong_algorithm = holder_key(); + wrong_algorithm.alg = Some("ES256".to_string()); + let mut wrong_key_type = holder_key(); + wrong_key_type.kty = "EC".to_string(); + let mut short_coordinate = holder_key(); + short_coordinate.x = "11qYAYKxCrfVS_7TyWQHOg".to_string(); + let mut padded_coordinate = holder_key(); + padded_coordinate.x = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo=".to_string(); + + for key in [ + wrong_curve, + wrong_algorithm, + wrong_key_type, + short_coordinate, + padded_coordinate, + ] { + assert_eq!( + issuance_input(&evidence, Some(&key), &BTreeMap::new()).unwrap_err(), + SdJwtVcMappingError::HolderKey + ); + } + } + + #[test] + fn rejects_private_key_members_before_mapping() { + let body = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", + }); + assert!(serde_json::from_value::(body).is_err()); + } + + #[test] + fn rejects_payloads_the_profile_cannot_represent() { + let mut no_subjects = evidence(); + no_subjects.subjects.clear(); + assert_eq!( + issuance_input(&no_subjects, None, &BTreeMap::new()).unwrap_err(), + SdJwtVcMappingError::Subjects + ); + + let mut bad_issued_at = evidence(); + bad_issued_at.issued_at = "2026-08-02 09:15:00".to_string(); + assert_eq!( + issuance_input(&bad_issued_at, None, &BTreeMap::new()).unwrap_err(), + SdJwtVcMappingError::Timestamp + ); + + let mut bad_valid_until = evidence(); + bad_valid_until.valid_until = "never".to_string(); + assert_eq!( + issuance_input(&bad_valid_until, None, &BTreeMap::new()).unwrap_err(), + SdJwtVcMappingError::Timestamp + ); + } + + /// The claim map and resolved disclosures the verifier hands to the + /// inverse projection once the signature and the digests check out. + fn verified_claims(evidence: &Evidence) -> (Map, Vec<(String, Value)>) { + let input = issuance_input(evidence, None, &BTreeMap::new()).expect("evidence maps"); + let credential_id = input.credential_id.clone().expect("credential identifier"); + let mut claims = Map::new(); + claims.insert("iss".to_string(), Value::String(input.iss.clone())); + claims.insert("sub".to_string(), Value::String(input.sub_ref.clone())); + claims.insert("iat".to_string(), Value::from(input.iat)); + claims.insert("exp".to_string(), Value::from(input.exp)); + claims.insert("vct".to_string(), Value::String(input.vct.clone())); + claims.insert("id".to_string(), Value::String(credential_id.clone())); + claims.insert("jti".to_string(), Value::String(credential_id)); + for (name, value) in &input.public_claims { + claims.insert(name.clone(), value.clone()); + } + let disclosed = input + .disclosures + .iter() + .map(|disclosure| (disclosure.name.clone(), disclosure.value.clone())) + .collect(); + (claims, disclosed) + } + + #[test] + fn rebuilds_the_payload_from_a_conformant_claim_set() { + let evidence = evidence(); + let (claims, disclosed) = verified_claims(&evidence); + + let payload = evidence_payload_from_claims(&claims, &disclosed).expect("claims rebuild"); + + assert_eq!(payload["providedBy"], Value::String(evidence.provided_by)); + assert_eq!(payload["issuedBy"], Value::String(evidence.issued_by)); + } + + #[test] + fn rejects_an_issuer_claim_that_is_not_the_provider() { + let evidence = evidence(); + let (mut claims, disclosed) = verified_claims(&evidence); + // The profile sources `iss` and `providedBy` from the same + // `service.providerId`, so a token that disagrees with itself about + // who signed it is not the profile's projection of any payload. + claims.insert( + "iss".to_string(), + Value::String("urn:example:provider:other-service".to_string()), + ); + + assert_eq!( + evidence_payload_from_claims(&claims, &disclosed).unwrap_err(), + SdJwtVcClaimError::ClaimShape + ); + } + + #[test] + fn rejects_a_claim_set_without_an_issuer() { + let evidence = evidence(); + let (mut claims, disclosed) = verified_claims(&evidence); + claims.remove("iss"); + + assert_eq!( + evidence_payload_from_claims(&claims, &disclosed).unwrap_err(), + SdJwtVcClaimError::MissingClaim + ); + } +} diff --git a/crates/registry-evidence/src/secrets.rs b/crates/registry-evidence/src/secrets.rs new file mode 100644 index 000000000..14a567748 --- /dev/null +++ b/crates/registry-evidence/src/secrets.rs @@ -0,0 +1,384 @@ +//! Bounded resolution of runtime secret references. + +use std::{ + collections::BTreeSet, + env, fmt, + fs::File, + io::Read, + path::{Path, PathBuf}, +}; + +use thiserror::Error; +use zeroize::Zeroizing; + +/// The maximum size of a resolved secret value. +pub const MAX_SECRET_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretProvider { + Environment, + File, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SecretError { + #[error("the secret reference is invalid")] + InvalidReference, + #[error("the secret reference uses a disabled provider")] + ProviderDisabled, + #[error("the secret provider configuration is invalid")] + InvalidProviderConfiguration, + #[error("the referenced secret is unavailable")] + Unavailable, + #[error("the referenced secret file is unsafe")] + UnsafeFile, + #[error("the referenced secret could not be read")] + Read, + #[error("the referenced secret value is invalid")] + InvalidValue, +} + +/// Secret bytes that are erased when dropped and never exposed by `Debug`. +pub struct ProtectedSecret(Zeroizing>); + +impl ProtectedSecret { + /// Explicitly borrow the secret for the smallest possible consumer scope. + pub fn expose_secret(&self) -> &[u8] { + self.0.as_slice() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for ProtectedSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedSecret([REDACTED])") + } +} + +/// Resolves only the providers explicitly enabled by runtime configuration. +#[derive(Debug)] +pub struct SecretResolver { + providers: BTreeSet, + file_root: PathBuf, +} + +impl SecretResolver { + pub fn new( + providers: impl IntoIterator, + file_root: impl Into, + ) -> Result { + let providers = providers.into_iter().collect::>(); + let file_root = file_root.into(); + if providers.is_empty() + || (providers.contains(&SecretProvider::File) && !file_root.is_absolute()) + { + return Err(SecretError::InvalidProviderConfiguration); + } + Ok(Self { + providers, + file_root, + }) + } + + pub fn resolve(&self, reference: &str) -> Result { + let (provider, name) = parse_reference(reference)?; + if !self.providers.contains(&provider) { + return Err(SecretError::ProviderDisabled); + } + + let bytes = match provider { + SecretProvider::Environment => read_environment(name)?, + SecretProvider::File => read_secret_file(&self.file_root, name)?, + }; + validate_secret(bytes) + } +} + +fn parse_reference(reference: &str) -> Result<(SecretProvider, &str), SecretError> { + if let Some(name) = reference.strip_prefix("secret:env/") { + if valid_environment_name(name) { + return Ok((SecretProvider::Environment, name)); + } + } else if let Some(name) = reference.strip_prefix("secret:file/") { + if valid_file_name(name) { + return Ok((SecretProvider::File, name)); + } + } + Err(SecretError::InvalidReference) +} + +fn valid_environment_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'A'..=b'Z')) + && bytes.len() <= 128 + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn valid_file_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn read_environment(name: &str) -> Result>, SecretError> { + let value = env::var_os(name).ok_or(SecretError::Unavailable)?; + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStringExt as _; + value.into_vec() + }; + #[cfg(not(unix))] + let bytes = value + .into_string() + .map_err(|_| SecretError::InvalidValue)? + .into_bytes(); + Ok(Zeroizing::new(bytes)) +} + +#[cfg(unix)] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + use rustix::fs::{Mode, OFlags}; + + let root = rustix::fs::open( + root, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::DIRECTORY, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let secret = rustix::fs::openat( + &root, + name, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let file = File::from(secret); + validate_file_metadata(&file)?; + read_bounded(file) +} + +#[cfg(unix)] +fn validate_file_metadata(file: &File) -> Result<(), SecretError> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let metadata = file.metadata().map_err(|_| SecretError::Read)?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o600 + || metadata.nlink() != 1 + { + return Err(SecretError::UnsafeFile); + } + Ok(()) +} + +#[cfg(not(unix))] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + let path = root.join(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| SecretError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(SecretError::UnsafeFile); + } + let file = File::open(path).map_err(|_| SecretError::Unavailable)?; + read_bounded(file) +} + +fn read_bounded(file: File) -> Result>, SecretError> { + let mut bytes = Zeroizing::new(Vec::new()); + file.take((MAX_SECRET_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| SecretError::Read)?; + Ok(bytes) +} + +fn validate_secret(bytes: Zeroizing>) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SECRET_BYTES || bytes.contains(&0) { + return Err(SecretError::InvalidValue); + } + Ok(ProtectedSecret(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") + } + + #[test] + fn references_use_only_the_two_exact_contract_grammars() { + for valid in [ + "secret:env/A", + "secret:env/SOURCE_2_PASSWORD", + "secret:file/a", + "secret:file/source-token_v2.json", + ] { + assert!(parse_reference(valid).is_ok(), "{valid}"); + } + for invalid in [ + "secret:env/", + "secret:env/lower", + "secret:env/A-B", + "secret:environment/A", + "secret:file/Upper", + "secret:file/../token", + "secret:file/nested/token", + "secret:file/.token", + "secret:file/token\0suffix", + "plain-value", + ] { + assert_eq!(parse_reference(invalid), Err(SecretError::InvalidReference)); + } + assert!(parse_reference(&format!("secret:env/A{}", "B".repeat(127))).is_ok()); + assert_eq!( + parse_reference(&format!("secret:env/A{}", "B".repeat(128))), + Err(SecretError::InvalidReference) + ); + } + + #[test] + fn provider_allowlist_is_enforced_before_lookup() { + let resolver = + SecretResolver::new([SecretProvider::File], "/safe-root").expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:env/DEFINITELY_NOT_PRESENT"), + Err(SecretError::ProviderDisabled) + )); + } + + #[test] + fn environment_secret_is_bounded_and_debug_is_redacted() { + let _guard = environment_lock(); + const NAME: &str = "REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST"; + env::set_var(NAME, "environment-canary"); + let resolver = + SecretResolver::new([SecretProvider::Environment], "").expect("resolver builds"); + let secret = resolver + .resolve("secret:env/REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST") + .expect("secret resolves"); + env::remove_var(NAME); + + assert!( + secret.expose_secret() == b"environment-canary", + "resolved environment secret bytes differ" + ); + assert_eq!(format!("{secret:?}"), "ProtectedSecret([REDACTED])"); + assert!(!format!("{secret:?}").contains("environment-canary")); + } + + #[test] + fn empty_nul_and_oversized_values_are_rejected_without_echo() { + for value in [ + Vec::new(), + b"canary\0value".to_vec(), + vec![b'x'; MAX_SECRET_BYTES + 1], + ] { + let error = validate_secret(Zeroizing::new(value)).expect_err("invalid secret"); + assert_eq!(error, SecretError::InvalidValue); + assert_eq!(error.to_string(), "the referenced secret value is invalid"); + } + } + + #[cfg(unix)] + mod unix { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt as _}; + + fn write_secret(root: &Path, name: &str, value: &[u8], mode: u32) { + let path = root.join(name); + fs::write(&path, value).expect("write secret"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set mode"); + } + + #[test] + fn file_secret_uses_open_file_owner_and_exact_mode_checks() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "source-token", b"file-canary", 0o600); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + let secret = resolver + .resolve("secret:file/source-token") + .expect("safe file resolves"); + assert!( + secret.expose_secret() == b"file-canary", + "resolved file secret bytes differ" + ); + + write_secret(root.path(), "unsafe-token", b"unsafe-canary", 0o640); + assert!(matches!( + resolver.resolve("secret:file/unsafe-token"), + Err(SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_symlinks_and_non_regular_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "target", b"symlink-canary", 0o600); + symlink(root.path().join("target"), root.path().join("link")).expect("create symlink"); + fs::create_dir(root.path().join("directory")).expect("create directory"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + assert!(matches!( + resolver.resolve("secret:file/link"), + Err(SecretError::Unavailable) + )); + assert!(matches!( + resolver.resolve("secret:file/directory"), + Err(SecretError::Unavailable | SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_every_name_for_a_hard_link() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "first", b"hard-link-canary", 0o600); + fs::hard_link(root.path().join("first"), root.path().join("second")) + .expect("create hard link"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + for name in ["first", "second"] { + assert!(matches!( + resolver.resolve(&format!("secret:file/{name}")), + Err(SecretError::UnsafeFile) + )); + } + } + + #[test] + fn file_secret_read_is_bounded() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret( + root.path(), + "oversized", + &vec![b'x'; MAX_SECRET_BYTES + 1], + 0o600, + ); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:file/oversized"), + Err(SecretError::InvalidValue) + )); + } + } +} diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs new file mode 100644 index 000000000..7424c8de0 --- /dev/null +++ b/crates/registry-evidence/src/selector.rs @@ -0,0 +1,1124 @@ +//! One fail-closed authorization and selector-resolution decision. + +use std::{collections::BTreeSet, fmt}; + +use chrono::NaiveDate; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + auth::AuthenticatedContext, + binding::{ + subject_binding, BindingError, SelectorField as BindingField, + SelectorScalar as BindingScalar, SubjectBindingInput, + }, + bundle::{Bundle, Codelist}, + config::{ + AuthorityKind, GrantedSubject, ResponseFormat, SelectorField as ConfiguredField, + SelectorProfile, ValueOrigin, MAX_SAFE_INTEGER, + }, + model::{EvidenceRequest, RequestedSubject, SelectorValue}, +}; + +const MAX_CANONICAL_BYTES: usize = 64 * 1024; + +/// Validate configured subject-binding key material through the same primitive +/// used for released subject bindings. This keeps readiness from duplicating +/// the crypto boundary's key-length and key-version invariants. +pub(crate) fn validate_subject_binding_key( + key: &[u8], + key_version: u32, + trust_domain: &str, +) -> Result<(), AuthorizationError> { + let fields = [BindingField { + name: "readiness", + value: BindingScalar::Boolean(true), + }]; + subject_binding( + key, + key_version, + SubjectBindingInput { + trust_domain, + audience: "urn:registry-evidence:readiness", + purpose: "readiness", + role: "readiness", + profile: "readiness-v1", + fields: &fields, + }, + ) + .map(|_| ()) + .map_err(|_: BindingError| AuthorizationError::Binding) +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationError { + #[error("the Evidence request is not authorized")] + Unauthorized, + #[error("the Evidence request selector is invalid")] + Selector, + #[error("the Evidence authorization decision is ambiguous")] + AmbiguousAuthority, + #[error("the Evidence subject binding could not be constructed")] + Binding, +} + +/// Failure of the offline fixture harness, which resolves a captured fixture +/// case through the ordinary authorization boundary. +/// +/// A fixture that does not state which purpose it exercises is a fixture +/// contract failure, not an authorization denial. Keeping the two apart stops +/// a harness omission from reading as a rejected request. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum OfflineFixtureError { + #[error("the offline fixture does not select one of the requirement's purposes")] + Purpose, + #[error(transparent)] + Authorization(#[from] AuthorizationError), +} + +#[derive(Clone, PartialEq, Eq)] +pub enum ResolvedSelectorValue { + String(String), + Date(String), + Integer(i64), + Boolean(bool), + ControlledCode(String), +} + +impl fmt::Debug for ResolvedSelectorValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl ResolvedSelectorValue { + pub fn as_json(&self) -> Value { + match self { + Self::String(value) | Self::Date(value) | Self::ControlledCode(value) => { + Value::String(value.clone()) + } + Self::Integer(value) => Value::from(*value), + Self::Boolean(value) => Value::from(*value), + } + } + + fn binding_scalar(&self) -> BindingScalar<'_> { + match self { + Self::String(value) => BindingScalar::String(value), + Self::Date(value) => BindingScalar::Date(value), + Self::Integer(value) => BindingScalar::Integer(*value), + Self::Boolean(value) => BindingScalar::Boolean(*value), + Self::ControlledCode(value) => BindingScalar::ControlledCode(value), + } + } + + fn canonical_bytes(&self) -> &[u8] { + match self { + Self::String(value) | Self::Date(value) | Self::ControlledCode(value) => { + value.as_bytes() + } + Self::Integer(_) | Self::Boolean(_) => &[], + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedSelectorField { + pub name: String, + pub value: ResolvedSelectorValue, +} + +impl fmt::Debug for ResolvedSelectorField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedSelectorField") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedSubject { + pub role: String, + pub selector_profile: String, + pub value_origin: ValueOrigin, + pub fields: Vec, +} + +impl fmt::Debug for ResolvedSubject { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedSubject") + .field("role", &self.role) + .field("selector_profile", &self.selector_profile) + .field("value_origin", &self.value_origin) + .field( + "field_names", + &self + .fields + .iter() + .map(|field| &field.name) + .collect::>(), + ) + .finish() + } +} + +impl ResolvedSubject { + pub fn value(&self, field_name: &str) -> Option<&ResolvedSelectorValue> { + self.fields + .iter() + .find(|field| field.name == field_name) + .map(|field| &field.value) + } + + pub fn binding( + &self, + key: &[u8], + key_version: u32, + trust_domain: &str, + audience: &str, + purpose: &str, + ) -> Result { + let fields = self + .fields + .iter() + .map(|field| BindingField { + name: &field.name, + value: field.value.binding_scalar(), + }) + .collect::>(); + subject_binding( + key, + key_version, + SubjectBindingInput { + trust_domain, + audience, + purpose, + role: &self.role, + profile: &self.selector_profile, + fields: &fields, + }, + ) + .map_err(|_: BindingError| AuthorizationError::Binding) + } + + /// Canonical protected input for the one permitted per-subject audit pseudonym. + pub fn audit_pseudonym_input( + &self, + audience: &str, + purpose: &str, + ) -> Result, AuthorizationError> { + let mut output = Vec::new(); + output.push(0x01); + push_component(&mut output, audience.as_bytes())?; + push_component(&mut output, purpose.as_bytes())?; + push_component(&mut output, self.role.as_bytes())?; + push_component(&mut output, self.selector_profile.as_bytes())?; + push_count(&mut output, self.fields.len())?; + for field in &self.fields { + push_component(&mut output, field.name.as_bytes())?; + match &field.value { + ResolvedSelectorValue::String(value) => { + output.push(0x01); + push_component(&mut output, value.as_bytes())?; + } + ResolvedSelectorValue::Date(value) => { + output.push(0x02); + push_component(&mut output, value.as_bytes())?; + } + ResolvedSelectorValue::Integer(value) => { + output.push(0x03); + push_component(&mut output, value.to_string().as_bytes())?; + } + ResolvedSelectorValue::Boolean(value) => { + output.push(0x04); + push_component(&mut output, &[u8::from(*value)])?; + } + ResolvedSelectorValue::ControlledCode(value) => { + output.push(0x05); + push_component(&mut output, value.as_bytes())?; + } + } + } + Ok(output) + } +} + +#[derive(Clone)] +pub struct ResolvedAuthorization { + pub authority_profile: String, + pub authority_kind: AuthorityKind, + pub grant_id: Option, + pub grant_authority: Option, + pub requirement: String, + pub purpose: String, + pub audience: String, + pub subjects: Vec, +} + +impl fmt::Debug for ResolvedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedAuthorization") + .field("authority_profile", &"") + .field("authority_kind", &self.authority_kind) + .field("grant_id", &self.grant_id.as_ref().map(|_| "")) + .field( + "grant_authority", + &self.grant_authority.as_ref().map(|_| ""), + ) + .field("requirement", &self.requirement) + .field("purpose", &self.purpose) + .field("audience", &self.audience) + .field("subjects", &self.subjects) + .finish() + } +} + +#[derive(Clone)] +pub struct MatchedEntitlement { + authority_profile: String, + authority_kind: AuthorityKind, + response_formats: Vec, + subjects: Vec, +} + +impl MatchedEntitlement { + pub fn authority_profile(&self) -> &str { + &self.authority_profile + } + + pub fn authority_kind(&self) -> AuthorityKind { + self.authority_kind + } + + /// Report whether this one complete matched grant permits the requested + /// response format. Permissions are never unioned across grants. + pub fn permits_response_format(&self, format: ResponseFormat) -> bool { + self.response_formats.contains(&format) + } + + pub(crate) fn subjects(&self) -> &[GrantedSubject] { + &self.subjects + } +} + +impl fmt::Debug for MatchedEntitlement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MatchedEntitlement") + .field("authority_profile", &"") + .field("authority_kind", &self.authority_kind) + .field("subject_count", &self.subjects.len()) + .finish() + } +} + +/// Match exactly one complete entitlement without inspecting selector values. +/// +/// Keeping this decision separate lets the service charge invalid selector +/// attempts to the matched authority profile before resolving any protected +/// values. It performs no credential resolution and no source access. +pub fn match_entitlement( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, +) -> Result { + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .ok_or(AuthorizationError::Unauthorized)?; + if !requirement + .purposes + .iter() + .any(|purpose| purpose == &request.purpose) + { + return Err(AuthorizationError::Unauthorized); + } + validate_request_subject_shape(requirement, &request.subjects)?; + + let mut matched = Vec::new(); + for (authority_profile, authority) in bundle.config.authority_profiles.iter() { + if !authority.requester_tags.iter().all(|required| { + context + .requester_tags() + .iter() + .any(|actual| actual == required) + }) { + continue; + } + if context.actor().is_some() && authority.kind != AuthorityKind::Delegated { + continue; + } + for grant in &authority.grants { + if grant.requirement != request.requirement + || grant.purpose != request.purpose + || !same_subject_tuples(&grant.subjects, &request.subjects) + { + continue; + } + let uses_authenticated_grant = grant + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant && context.grant_authority() != Some(authority_profile) { + continue; + } + matched.push(MatchedEntitlement { + authority_profile: authority_profile.to_owned(), + authority_kind: authority.kind, + response_formats: grant.response_formats.clone(), + subjects: grant.subjects.clone(), + }); + } + } + + match matched.len() { + 1 => Ok(matched.remove(0)), + 0 => Err(AuthorizationError::Unauthorized), + _ => Err(AuthorizationError::AmbiguousAuthority), + } +} + +/// Resolve the complete role-bound selector set for one matched entitlement. +/// +/// This is the first operation that inspects caller or authenticated-grant +/// selector values. It still performs no credential resolution or source +/// access. +pub fn resolve_selectors( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, + matched: &MatchedEntitlement, +) -> Result { + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .ok_or(AuthorizationError::Unauthorized)?; + let subjects = resolve_grant_subjects( + bundle, + requirement, + &matched.subjects, + &request.subjects, + context, + )?; + let uses_authenticated_grant = matched + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + Ok(ResolvedAuthorization { + authority_profile: matched.authority_profile.clone(), + authority_kind: matched.authority_kind, + grant_id: uses_authenticated_grant + .then(|| context.grant_id().map(ToOwned::to_owned)) + .flatten(), + grant_authority: uses_authenticated_grant + .then(|| context.grant_authority().map(ToOwned::to_owned)) + .flatten(), + requirement: request.requirement.clone(), + purpose: request.purpose.clone(), + audience: context.evidence_audience().to_owned(), + subjects, + }) +} + +/// Confirm that selector values owned by the authenticated context or grant +/// are present and valid before advertising an entitlement. Request-owned +/// selector values are intentionally not inspected during discovery. +pub(crate) fn validate_entitlement_context( + bundle: &Bundle, + context: &AuthenticatedContext, + matched: &MatchedEntitlement, +) -> Result<(), AuthorizationError> { + let uses_authenticated_grant = matched + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant + && (context.grant_id().is_none() + || context.grant_authority() != Some(matched.authority_profile())) + { + return Err(AuthorizationError::Unauthorized); + } + + for grant in &matched.subjects { + if grant.value_origin == ValueOrigin::Request { + continue; + } + let profile = bundle + .config + .selector_profiles + .get(&grant.selector_profile) + .ok_or(AuthorizationError::Unauthorized)?; + let claims = grant + .value_claims + .as_ref() + .ok_or(AuthorizationError::Unauthorized)?; + let values = claims + .iter() + .map(|(field, path)| { + let value = context + .claim_path(path) + .and_then(selector_value_from_claim) + .ok_or(AuthorizationError::Selector)?; + Ok((field.to_owned(), value)) + }) + .collect::>()?; + validate_values(bundle, profile, &values)?; + } + Ok(()) +} + +/// Resolve exactly one complete entitlement and its complete selector set. +/// +/// Service code that applies the failed-selector budget uses +/// [`match_entitlement`] and [`resolve_selectors`] separately. Offline callers +/// may use this convenience wrapper. +pub fn authorize_and_resolve( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, +) -> Result { + let matched = match_entitlement(bundle, request, context)?; + resolve_selectors(bundle, request, context, &matched) +} + +/// Exercise the normal authorization and selector boundary for one captured +/// offline fixture subject set without token, credential, or source access. +/// +/// Fixture JSON uses the compact `{role, profile, values}` representation from +/// the reviewed product bundles. No selector value is included in an error. +pub fn resolve_offline_fixture_authorization( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + common: Option<&serde_json::Map>, + case: &serde_json::Map, + audience: &str, +) -> Result { + let purpose = fixture_purpose(&requirement.purposes, common, case)?; + let subjects = if let Some(subjects) = case + .get("subjects") + .or_else(|| common.and_then(|value| value.get("subjects"))) + .and_then(Value::as_array) + { + subjects + .iter() + .map(parse_fixture_subject) + .collect::, _>>()? + } else { + fixture_subjects_from_selectors(bundle, requirement, purpose, common, case)? + }; + let request = EvidenceRequest { + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), + requirement: requirement.id.clone(), + purpose: purpose.to_owned(), + subjects, + holder_key: None, + }; + let (authority_name, authority) = bundle + .config + .authority_profiles + .iter() + .find(|(_, authority)| { + authority.grants.iter().any(|grant| { + grant.requirement == request.requirement + && grant.purpose == request.purpose + && same_subject_tuples(&grant.subjects, &request.subjects) + }) + }) + .ok_or(AuthorizationError::Unauthorized)?; + let claims = case + .get("verified_token_claims") + .or_else(|| common.and_then(|value| value.get("verified_token_claims"))) + .cloned() + .unwrap_or_else(|| Value::Object(Default::default())); + let grant_id = claims + .get("evidence_grant_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let grant_authority = claims + .get("evidence_authority") + .and_then(Value::as_str) + .unwrap_or(authority_name) + .to_owned(); + let context = AuthenticatedContext::offline_fixture_context( + authority.requester_tags.clone(), + audience, + grant_id.as_deref(), + Some(&grant_authority), + claims, + ); + Ok(authorize_and_resolve(bundle, &request, &context)?) +} + +/// Resolve the purpose one fixture case exercises. +/// +/// A requirement declaring more than one purpose must say which one the case +/// covers, in the case or in the inherited common block. The harness never +/// chooses on the fixture's behalf, so offline evaluation reaches every +/// authorized purpose and no purpose is silently skipped. +fn fixture_purpose<'a>( + purposes: &'a [String], + common: Option<&serde_json::Map>, + case: &serde_json::Map, +) -> Result<&'a str, OfflineFixtureError> { + match case + .get("purpose") + .or_else(|| common.and_then(|common| common.get("purpose"))) + { + Some(declared) => { + let declared = declared.as_str().ok_or(OfflineFixtureError::Purpose)?; + purposes + .iter() + .find(|purpose| purpose.as_str() == declared) + .map(String::as_str) + .ok_or(OfflineFixtureError::Authorization( + AuthorizationError::Unauthorized, + )) + } + None => match purposes { + [only] => Ok(only.as_str()), + _ => Err(OfflineFixtureError::Purpose), + }, + } +} + +fn fixture_subjects_from_selectors( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + purpose: &str, + common: Option<&serde_json::Map>, + case: &serde_json::Map, +) -> Result, AuthorizationError> { + let mut selectors = case + .get("selectors") + .or_else(|| common.and_then(|value| value.get("selectors"))) + .and_then(Value::as_object) + .cloned() + .ok_or(AuthorizationError::Selector)?; + if let Some(overrides) = case.get("selectorOverrides") { + let overrides = overrides.as_object().ok_or(AuthorizationError::Selector)?; + for (role, replacement) in overrides { + let replacement = replacement + .as_object() + .filter(|object| object.keys().all(|key| key == "profile" || key == "values")) + .ok_or(AuthorizationError::Selector)?; + let selector = selectors + .get_mut(role) + .and_then(Value::as_object_mut) + .ok_or(AuthorizationError::Selector)?; + for (name, value) in replacement { + selector.insert(name.clone(), value.clone()); + } + } + } + + let mut requested = Vec::with_capacity(requirement.subject_roles.len()); + for configured_role in &requirement.subject_roles { + let mut selector = selectors + .remove(&configured_role.role) + .and_then(|value| value.as_object().cloned()) + .ok_or(AuthorizationError::Selector)?; + selector.insert( + "role".to_owned(), + Value::String(configured_role.role.clone()), + ); + requested.push(parse_fixture_subject(&Value::Object(selector))?); + } + if !selectors.is_empty() { + return Err(AuthorizationError::Selector); + } + + let grant = bundle + .config + .authority_profiles + .iter() + .flat_map(|(_, authority)| authority.grants.iter()) + .find(|grant| { + grant.requirement == requirement.id + && grant.purpose == purpose + && same_subject_tuples(&grant.subjects, &requested) + }) + .ok_or(AuthorizationError::Unauthorized)?; + for subject in &mut requested { + let granted = grant + .subjects + .iter() + .find(|granted| granted.role == subject.role) + .ok_or(AuthorizationError::Unauthorized)?; + if granted.value_origin != ValueOrigin::Request { + subject.selector.values = None; + } + } + Ok(requested) +} + +pub fn resolve_offline_fixture_subjects( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + common: Option<&serde_json::Map>, + case: &serde_json::Map, + audience: &str, +) -> Result, OfflineFixtureError> { + resolve_offline_fixture_authorization(bundle, requirement, common, case, audience).map( + |resolved| { + resolved + .subjects + .into_iter() + .map(|subject| subject.role) + .collect() + }, + ) +} + +fn parse_fixture_subject(value: &Value) -> Result { + let object = value.as_object().ok_or(AuthorizationError::Selector)?; + if object + .keys() + .any(|key| !matches!(key.as_str(), "role" | "profile" | "values")) + { + return Err(AuthorizationError::Selector); + } + let role = object + .get("role") + .and_then(Value::as_str) + .ok_or(AuthorizationError::Selector)?; + let profile = object + .get("profile") + .and_then(Value::as_str) + .ok_or(AuthorizationError::Selector)?; + let values = object + .get("values") + .map(|values| { + values + .as_object() + .ok_or(AuthorizationError::Selector)? + .iter() + .map(|(name, value)| { + let value = match value { + Value::String(value) => SelectorValue::String(value.clone()), + Value::Number(value) => value + .as_i64() + .map(SelectorValue::Integer) + .ok_or(AuthorizationError::Selector)?, + Value::Bool(value) => SelectorValue::Boolean(*value), + _ => return Err(AuthorizationError::Selector), + }; + Ok((name.clone(), value)) + }) + .collect::>() + }) + .transpose()?; + Ok(RequestedSubject { + role: role.to_owned(), + selector: crate::model::RequestedSelector { + profile: profile.to_owned(), + values, + }, + }) +} + +fn validate_request_subject_shape( + requirement: &crate::config::RequirementConfig, + requested: &[RequestedSubject], +) -> Result<(), AuthorizationError> { + if requested.len() != requirement.subject_roles.len() { + return Err(AuthorizationError::Unauthorized); + } + let mut roles = BTreeSet::new(); + for subject in requested { + if !roles.insert(subject.role.as_str()) { + return Err(AuthorizationError::Unauthorized); + } + let configured = requirement + .subject_roles + .iter() + .find(|configured| configured.role == subject.role) + .ok_or(AuthorizationError::Unauthorized)?; + if !configured + .selector_profiles + .iter() + .any(|profile| profile == &subject.selector.profile) + { + return Err(AuthorizationError::Unauthorized); + } + } + Ok(()) +} + +fn same_subject_tuples(granted: &[GrantedSubject], requested: &[RequestedSubject]) -> bool { + granted.len() == requested.len() + && granted.iter().all(|grant| { + requested.iter().any(|subject| { + subject.role == grant.role && subject.selector.profile == grant.selector_profile + }) + }) +} + +/// Resolve subjects by unique role and emit the requirement's declaration +/// order. Neither grant order nor request array order is semantic. +fn resolve_grant_subjects( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + granted: &[GrantedSubject], + requested: &[RequestedSubject], + context: &AuthenticatedContext, +) -> Result, AuthorizationError> { + let uses_authenticated_grant = granted + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant + && (context.grant_id().is_none() || context.grant_authority().is_none()) + { + return Err(AuthorizationError::Unauthorized); + } + if granted.len() != requirement.subject_roles.len() { + return Err(AuthorizationError::Unauthorized); + } + + requirement + .subject_roles + .iter() + .map(|declared| { + let grant = granted + .iter() + .find(|grant| grant.role == declared.role) + .ok_or(AuthorizationError::Unauthorized)?; + let subject = requested + .iter() + .find(|subject| subject.role == grant.role) + .ok_or(AuthorizationError::Unauthorized)?; + let profile = bundle + .config + .selector_profiles + .get(&grant.selector_profile) + .ok_or(AuthorizationError::Unauthorized)?; + let input = match grant.value_origin { + ValueOrigin::Request => subject + .selector + .values + .as_ref() + .ok_or(AuthorizationError::Selector)? + .clone(), + ValueOrigin::AuthenticatedContext | ValueOrigin::AuthenticatedGrant => { + if subject.selector.values.is_some() { + return Err(AuthorizationError::Selector); + } + let claims = grant + .value_claims + .as_ref() + .ok_or(AuthorizationError::Unauthorized)?; + claims + .iter() + .map(|(field, path)| { + let value = context + .claim_path(path) + .and_then(selector_value_from_claim) + .ok_or(AuthorizationError::Selector)?; + Ok((field.to_owned(), value)) + }) + .collect::>()? + } + }; + let fields = validate_values(bundle, profile, &input)?; + Ok(ResolvedSubject { + role: grant.role.clone(), + selector_profile: subject.selector.profile.clone(), + value_origin: grant.value_origin, + fields, + }) + }) + .collect() +} + +fn selector_value_from_claim(value: &Value) -> Option { + match value { + Value::String(value) => Some(SelectorValue::String(value.clone())), + Value::Number(value) => value.as_i64().map(SelectorValue::Integer), + Value::Bool(value) => Some(SelectorValue::Boolean(*value)), + _ => None, + } +} + +fn validate_values( + bundle: &Bundle, + profile: &SelectorProfile, + values: &std::collections::BTreeMap, +) -> Result, AuthorizationError> { + if values.len() != profile.fields.len() + || profile + .fields + .keys() + .any(|field| !values.contains_key(field)) + || values + .keys() + .any(|field| !profile.fields.contains_key(field)) + { + return Err(AuthorizationError::Selector); + } + + let mut aggregate_bytes = 0_u64; + let mut output = Vec::with_capacity(values.len()); + for (name, configured) in profile.fields.iter() { + let supplied = values.get(name).ok_or(AuthorizationError::Selector)?; + let resolved = validate_value(bundle, configured, supplied)?; + let value_bytes = match &resolved { + ResolvedSelectorValue::Integer(value) => value.to_string().len(), + ResolvedSelectorValue::Boolean(_) => 1, + _ => resolved.canonical_bytes().len(), + }; + aggregate_bytes = aggregate_bytes + .checked_add(u64::try_from(value_bytes).map_err(|_| AuthorizationError::Selector)?) + .ok_or(AuthorizationError::Selector)?; + output.push(ResolvedSelectorField { + name: name.to_owned(), + value: resolved, + }); + } + if aggregate_bytes > profile.maximum_aggregate_bytes { + return Err(AuthorizationError::Selector); + } + Ok(output) +} + +fn validate_value( + bundle: &Bundle, + configured: &ConfiguredField, + supplied: &SelectorValue, +) -> Result { + match (configured, supplied) { + ( + ConfiguredField::String { + minimum_bytes, + maximum_bytes, + }, + SelectorValue::String(value), + ) if bounded(value.len(), *minimum_bytes, *maximum_bytes) => { + Ok(ResolvedSelectorValue::String(value.clone())) + } + (ConfiguredField::Date, SelectorValue::String(value)) + if canonical_date(value).is_some() => + { + Ok(ResolvedSelectorValue::Date(value.clone())) + } + (ConfiguredField::Integer { minimum, maximum }, SelectorValue::Integer(value)) + if value >= minimum + && value <= maximum + && value.unsigned_abs() <= MAX_SAFE_INTEGER as u64 => + { + Ok(ResolvedSelectorValue::Integer(*value)) + } + (ConfiguredField::Boolean, SelectorValue::Boolean(value)) => { + Ok(ResolvedSelectorValue::Boolean(*value)) + } + ( + ConfiguredField::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + }, + SelectorValue::String(value), + ) if bounded(value.len(), 1, *maximum_bytes) => { + let list = bundle + .codelist(codelist) + .ok_or(AuthorizationError::Selector)?; + if list.version() != codelist_version || !codelist_contains_selector_value(list, value) + { + return Err(AuthorizationError::Selector); + } + Ok(ResolvedSelectorValue::ControlledCode(value.clone())) + } + _ => Err(AuthorizationError::Selector), + } +} + +fn codelist_contains_selector_value(codelist: &Codelist, value: &str) -> bool { + match codelist { + Codelist::Codes { codes, .. } => codes.iter().any(|code| code == value), + Codelist::Mapping { entries, .. } => entries.contains_key(value), + } +} + +fn canonical_date(value: &str) -> Option { + if value.len() != 10 { + return None; + } + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .filter(|date| date.format("%Y-%m-%d").to_string() == value) +} + +fn bounded(actual: usize, minimum: u64, maximum: u64) -> bool { + u64::try_from(actual).is_ok_and(|actual| (minimum..=maximum).contains(&actual)) +} + +fn push_component(output: &mut Vec, component: &[u8]) -> Result<(), AuthorizationError> { + if component.is_empty() || output.len().saturating_add(component.len()) > MAX_CANONICAL_BYTES { + return Err(AuthorizationError::Selector); + } + let length = u32::try_from(component.len()).map_err(|_| AuthorizationError::Selector)?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(component); + Ok(()) +} + +fn push_count(output: &mut Vec, count: usize) -> Result<(), AuthorizationError> { + let count = u32::try_from(count).map_err(|_| AuthorizationError::Selector)?; + output.extend_from_slice(&count.to_be_bytes()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_selector_debug_never_exposes_values() { + let subject = ResolvedSubject { + role: "subject".to_owned(), + selector_profile: "opaque-v1".to_owned(), + value_origin: ValueOrigin::Request, + fields: vec![ResolvedSelectorField { + name: "opaque".to_owned(), + value: ResolvedSelectorValue::String("selector-canary".to_owned()), + }], + }; + let debug = format!("{subject:?}"); + assert!(!debug.contains("selector-canary")); + assert!(debug.contains("opaque")); + } + + #[test] + fn canonical_selector_input_is_order_and_audience_sensitive() { + let subject = ResolvedSubject { + role: "subject".to_owned(), + selector_profile: "opaque-v1".to_owned(), + value_origin: ValueOrigin::Request, + fields: vec![ResolvedSelectorField { + name: "field".to_owned(), + value: ResolvedSelectorValue::String("value".to_owned()), + }], + }; + let first = subject + .audit_pseudonym_input("urn:audience:a", "purpose") + .expect("canonicalizes"); + let second = subject + .audit_pseudonym_input("urn:audience:b", "purpose") + .expect("canonicalizes"); + assert_ne!(first, second); + } + + #[test] + fn selector_claim_values_are_scalar_only() { + assert!(selector_value_from_claim(&serde_json::json!({"value": "x"})).is_none()); + assert!(selector_value_from_claim(&serde_json::json!(["x"])).is_none()); + assert_eq!( + selector_value_from_claim(&serde_json::json!(false)), + Some(SelectorValue::Boolean(false)) + ); + } + + fn fixture_object(value: Value) -> serde_json::Map { + value + .as_object() + .expect("fixture block is an object") + .clone() + } + + #[test] + fn a_single_purpose_requirement_needs_no_declared_fixture_purpose() { + let purposes = vec!["only-purpose".to_owned()]; + assert_eq!( + fixture_purpose(&purposes, None, &fixture_object(serde_json::json!({}))), + Ok("only-purpose") + ); + } + + #[test] + fn a_fixture_selects_one_of_several_declared_purposes() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": "second"})) + ), + Ok("second") + ); + } + + #[test] + fn a_common_fixture_purpose_applies_to_every_case() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let common = fixture_object(serde_json::json!({"purpose": "second"})); + assert_eq!( + fixture_purpose( + &purposes, + Some(&common), + &fixture_object(serde_json::json!({})) + ), + Ok("second") + ); + } + + #[test] + fn a_case_fixture_purpose_overrides_the_common_purpose() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let common = fixture_object(serde_json::json!({"purpose": "first"})); + assert_eq!( + fixture_purpose( + &purposes, + Some(&common), + &fixture_object(serde_json::json!({"purpose": "second"})) + ), + Ok("second") + ); + } + + #[test] + fn a_multi_purpose_requirement_without_a_declared_purpose_is_a_fixture_error() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let error = fixture_purpose(&purposes, None, &fixture_object(serde_json::json!({}))) + .expect_err("an unselected purpose is rejected"); + assert_eq!(error, OfflineFixtureError::Purpose); + assert_ne!( + error, + OfflineFixtureError::Authorization(AuthorizationError::Unauthorized), + "an unselected fixture purpose must not be reported as an authorization denial" + ); + } + + #[test] + fn a_non_string_fixture_purpose_is_a_fixture_error() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": 1})) + ), + Err(OfflineFixtureError::Purpose) + ); + } + + #[test] + fn a_fixture_purpose_outside_the_requirement_is_unauthorized() { + let purposes = vec!["first".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": "other"})) + ), + Err(OfflineFixtureError::Authorization( + AuthorizationError::Unauthorized + )) + ); + } +} diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs new file mode 100644 index 000000000..3ab93429a --- /dev/null +++ b/crates/registry-evidence/src/server.rs @@ -0,0 +1,1094 @@ +//! Native Evidence Version 1 HTTP boundary. +//! +//! Request admission, body collection, and concurrency queueing observe the +//! configured request timeout. Once [`EvidenceRuntime::evaluate`] starts, this +//! boundary deliberately does not wrap it in a cancelling timeout: evaluation +//! contains the durable access-audit, signing, and durable release-audit +//! critical section. + +use std::{ + future::{Future, IntoFuture}, + io, + net::{IpAddr, SocketAddr}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use axum::{ + body::{to_bytes, Body}, + extract::State, + http::{ + header::{ + ACCEPT, AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, RETRY_AFTER, VARY, + }, + HeaderMap, HeaderValue, Request, StatusCode, + }, + middleware::{from_fn, from_fn_with_state, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use registry_platform_crypto::parse_json_strict; +use registry_platform_httpsec::CspBuilder; +use serde::Serialize; +use tokio::{net::TcpListener, sync::Semaphore}; + +use crate::{ + config::{ListenerConfig, ResponseFormat}, + contracts::{request_contract_accepts, served_openapi_document}, + model::{request_nonce_is_canonical, EvidenceRequest, JwksDocument}, + observability::{self, operation_id, Metrics}, + problem::ProblemCode, + runtime::{EvidenceRuntime, RuntimeFailure}, + EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, +}; + +const EVIDENCE_ROUTE: &str = "/v1/evidence"; +const DEFINITIONS_ROUTE: &str = "/v1/evidence-definitions"; +const HEALTH_ROUTE: &str = "/health"; +const OPENAPI_ROUTE: &str = "/openapi.json"; +const READY_ROUTE: &str = "/ready"; +const JWKS_ROUTE: &str = "/.well-known/evidence/jwks.json"; +const JWT_VC_ISSUER_ROUTE: &str = "/.well-known/jwt-vc-issuer"; + +/// Every route template this listener registers. +/// +/// Operational telemetry labels requests with a member of this set or with a +/// single fixed unmatched label, so a caller cannot introduce a label value. +pub(crate) const ROUTE_TEMPLATES: [&str; 7] = [ + EVIDENCE_ROUTE, + DEFINITIONS_ROUTE, + HEALTH_ROUTE, + OPENAPI_ROUTE, + READY_ROUTE, + JWKS_ROUTE, + JWT_VC_ISSUER_ROUTE, +]; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; +const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; +const OPENAPI_MEDIA_TYPE: &str = "application/openapi+json"; +const RETRY_AFTER_SECONDS: &str = "1"; + +#[derive(Clone)] +struct ServerState { + runtime: Arc, + maximum_request_bytes: usize, + request_timeout: Duration, + request_slots: Arc, + evaluations: EvaluationTracker, + #[cfg(test)] + evaluation_time: Option>, +} + +/// Build the complete Version 1 application from one immutable runtime. +#[cfg(test)] +pub(crate) fn build_app(runtime: Arc) -> Router { + build_app_with_tracker(runtime).0 +} + +#[cfg(test)] +pub(crate) fn build_app_at_for_test( + runtime: Arc, + evaluation_time: chrono::DateTime, +) -> Router { + build_app_with_tracker_at(runtime, Some(evaluation_time)).0 +} + +/// Build the application together with the registry its observation layer +/// feeds, so a test can read the counters a request produced. +#[cfg(test)] +pub(crate) fn build_app_with_metrics(runtime: Arc) -> (Router, Arc) { + let (app, _evaluations, metrics) = build_app_with_tracker_at(runtime, None); + (app, metrics) +} + +fn build_app_with_tracker( + runtime: Arc, +) -> (Router, EvaluationTracker, Arc) { + build_app_with_tracker_at(runtime, None) +} + +fn build_app_with_tracker_at( + runtime: Arc, + evaluation_time: Option>, +) -> (Router, EvaluationTracker, Arc) { + #[cfg(not(test))] + let _ = evaluation_time; + let listener = &runtime.runtime_config().listener; + let maximum_request_bytes = listener.maximum_request_bytes as usize; + let request_timeout = Duration::from_millis(listener.request_timeout_milliseconds); + let maximum_concurrent_requests = listener.maximum_concurrent_requests as usize; + let evaluations = EvaluationTracker::default(); + // Captured before `runtime` moves into the evidence app's state, so the + // metrics registry can sample the same rate limiter the evidence routes + // use, on the separate opt-in metrics listener. + let rate_limiter = runtime.rate_limiter(); + let audit = runtime.audit(); + let state = Arc::new(ServerState { + runtime, + maximum_request_bytes, + request_timeout, + request_slots: Arc::new(Semaphore::new(maximum_concurrent_requests)), + evaluations: evaluations.clone(), + #[cfg(test)] + evaluation_time, + }); + + let routes = Router::new() + .route(EVIDENCE_ROUTE, post(create_evidence)) + .route(DEFINITIONS_ROUTE, get(discover_evidence)) + .route(HEALTH_ROUTE, get(health)) + .route(OPENAPI_ROUTE, get(openapi)) + .route(READY_ROUTE, get(ready)) + .route(JWKS_ROUTE, get(jwks)) + .route(JWT_VC_ISSUER_ROUTE, get(jwt_vc_issuer_metadata)) + .fallback(unknown_route) + .method_not_allowed_fallback(unknown_route) + .with_state(state); + let metrics = Arc::new(Metrics::new(rate_limiter, audit)); + ( + response_layers(routes, Arc::clone(&metrics)), + evaluations, + metrics, + ) +} + +#[derive(Clone, Default)] +struct EvaluationTracker { + inner: Arc, +} + +#[derive(Default)] +struct EvaluationTrackerInner { + active: AtomicUsize, + idle: tokio::sync::Notify, +} + +impl EvaluationTracker { + fn spawn(&self, future: F) -> tokio::task::JoinHandle + where + F: Future + Send + 'static, + T: Send + 'static, + { + self.inner.active.fetch_add(1, Ordering::AcqRel); + let guard = ActiveEvaluation { + tracker: self.clone(), + }; + tokio::spawn(async move { + let _guard = guard; + future.await + }) + } + + async fn wait_idle(&self) { + loop { + let idle = self.inner.idle.notified(); + if self.inner.active.load(Ordering::Acquire) == 0 { + return; + } + idle.await; + } + } +} + +struct ActiveEvaluation { + tracker: EvaluationTracker, +} + +impl Drop for ActiveEvaluation { + fn drop(&mut self) { + if self.tracker.inner.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.tracker.inner.idle.notify_one(); + } + } +} + +fn response_layers(routes: Router, metrics: Arc) -> Router { + routes + .layer(from_fn(add_no_store)) + .layer(registry_platform_httpsec::corp_conditional()) + .layer( + registry_platform_httpsec::security_headers(CspBuilder::restrictive()).without_hsts(), + ) + // Outermost, so that every response including both fallbacks carries a + // correlation identifier and produces exactly one operational record. + .layer(from_fn_with_state(metrics, observability::observe)) +} + +/// Bind the configured private listener and serve until graceful shutdown. +pub async fn serve(runtime: Arc, shutdown: F) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let listener_config = runtime.runtime_config().listener.clone(); + let metrics_config = runtime.runtime_config().metrics_listener.clone(); + let bundle_revision = runtime.bundle().revision().to_owned(); + let runtime_revision = runtime.runtime_revision().to_owned(); + let listener = bind(&listener_config.bind_host, listener_config.port).await?; + let startup_runtime = Arc::clone(&runtime); + let (app, evaluations, metrics) = build_app_with_tracker(runtime); + + // Both listeners are bound before either serves, so a misconfigured + // metrics binding fails startup instead of leaving a service that reports + // healthy while publishing no telemetry. + let metrics_listener = match &metrics_config { + Some(config) => Some(bind(&config.bind_host, config.port).await?), + None => None, + }; + + // Announced only once both listeners are held, because whatever already + // owns a taken port answers in this service's place. A start announced + // before the bind reads as success to anyone who backgrounds the process + // and greps the log, and their verification run then tests the deployment + // that won the port. + tracing::info!( + target: "registry_evidence::startup", + bundle_revision, + runtime_revision, + bind_host = listener_config.bind_host, + port = listener_config.port, + metrics = metrics_config.is_some(), + "evidence service listening" + ); + // Said here rather than left to the first rejected request, which an + // operator reads as a bad token. Reported, not fatal: readiness is what + // withholds traffic until the key set is in hand. + startup_runtime.announce_key_source().await; + let (stop_metrics, metrics_stopped) = tokio::sync::watch::channel(()); + let metrics_server = metrics_listener.map(|listener| { + let mut stopped = metrics_stopped; + tokio::spawn(async move { + axum::serve(listener, observability::metrics_app(metrics)) + .with_graceful_shutdown(async move { + // A dropped sender also ends the wait, so the metrics + // listener cannot outlive a failed evidence listener. + let _ = stopped.changed().await; + }) + .await + }) + }); + + let result = serve_listener(listener, app, &listener_config, async move { + shutdown.await; + drop(stop_metrics); + }) + .await; + if let Some(server) = metrics_server { + let _ = server.await; + } + + // A disconnected client can cause axum to drop its handler future. The + // admitted evaluation itself is owned by a detached task, so the server + // must explicitly drain those tasks before production shutdown returns. + evaluations.wait_idle().await; + result +} + +/// Bind one listener, naming the address in any failure. +/// +/// A bind failure is reported to an operator who configured two addresses and +/// controls neither exclusively. Without the address, the two most common +/// causes read alike and neither points at its fix: a port already taken by +/// another process, and an address this host does not own. +async fn bind(bind_host: &str, port: u16) -> io::Result { + let ip = bind_host.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{bind_host} is not an address: {error}"), + ) + })?; + TcpListener::bind(SocketAddr::new(ip, port)) + .await + .map_err(|error| { + io::Error::new(error.kind(), format!("could not bind {ip}:{port}: {error}")) + }) +} + +/// Serve a pre-bound listener and drain client-bound handlers on shutdown. +/// +/// The grace duration is an operational target, not a cancellation boundary. +/// A request already inside runtime evaluation is allowed to complete even if +/// it exceeds that target, preserving the audit and signing invariants. The +/// production [`serve`] entry point additionally drains detached evaluations. +async fn serve_listener( + listener: TcpListener, + app: Router, + config: &ListenerConfig, + shutdown: F, +) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let grace = Duration::from_millis(config.shutdown_grace_milliseconds); + let (shutdown_started_tx, shutdown_started_rx) = tokio::sync::oneshot::channel(); + let graceful = async move { + shutdown.await; + let _ = shutdown_started_tx.send(()); + }; + let server = axum::serve(listener, app) + .with_graceful_shutdown(graceful) + .into_future(); + tokio::pin!(server); + + let grace_watch = async move { + if shutdown_started_rx.await.is_ok() { + tokio::time::sleep(grace).await; + tracing::warn!( + target: "registry_evidence::server", + "graceful shutdown target elapsed; waiting for protected operations to finish" + ); + } + std::future::pending::<()>().await; + }; + tokio::pin!(grace_watch); + + tokio::select! { + result = &mut server => result, + () = &mut grace_watch => unreachable!("shutdown grace watcher never completes"), + } +} + +#[cfg(test)] +pub(crate) async fn serve_listener_for_test( + runtime: Arc, + listener: TcpListener, + shutdown: F, +) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let listener_config = runtime.runtime_config().listener.clone(); + let (app, evaluations, _metrics) = build_app_with_tracker(runtime); + let result = serve_listener(listener, app, &listener_config, shutdown).await; + evaluations.wait_idle().await; + result +} + +/// Every `/v1/evidence` response varies on the negotiated `Accept` value. +async fn create_evidence( + State(state): State>, + request: Request, +) -> Response { + let mut response = create_evidence_negotiated(state, request).await; + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept")); + response +} + +async fn create_evidence_negotiated(state: Arc, request: Request) -> Response { + let operation = operation_id(request.extensions()); + let started = Instant::now(); + + let access_token = match bearer_token(request.headers()) { + Ok(token) => token.to_owned(), + Err(code) => return problem_response(code, &operation), + }; + // Strict media negotiation resolves the requested response format before + // the body is read and long before credential acquisition or source + // access. Selection creates no permission; the bundle and matched grant + // decide authorization later. + let format = match resolve_response_format(request.headers()) { + Ok(format) => format, + Err(code) => return problem_response(code, &operation), + }; + if !has_exact_content_type(request.headers(), JSON_MEDIA_TYPE) + || content_length_exceeds(request.headers(), state.maximum_request_bytes) + { + return problem_response(ProblemCode::MalformedRequest, &operation); + } + + let admission_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let request_slot = match tokio::time::timeout( + admission_budget, + Arc::clone(&state.request_slots).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) | Err(_) => { + return problem_response(ProblemCode::ServiceUnavailable, &operation) + } + }; + + let body_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let body = match tokio::time::timeout( + body_budget, + to_bytes(request.into_body(), state.maximum_request_bytes), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => return problem_response(ProblemCode::MalformedRequest, &operation), + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let evidence_request = match parse_evidence_request(&body) { + Ok(request) => request, + Err(code) => return problem_response(code, &operation), + }; + + // The tracker-owned task, rather than this client-bound handler, owns the + // admitted concurrency permit and complete fail-closed evaluation. If the + // client disconnects while the handler awaits the join handle, dropping + // that handle detaches the task instead of cancelling its audit writes. + let evaluation_operation = operation.clone(); + let runtime = Arc::clone(&state.runtime); + let evaluations = state.evaluations.clone(); + #[cfg(test)] + let evaluation_time = state.evaluation_time; + let evaluation = evaluations.spawn(async move { + let _request_slot = request_slot; + #[cfg(test)] + if let Some(evaluation_time) = evaluation_time { + return runtime + .evaluate_at_for_test( + &evaluation_operation, + &access_token, + &evidence_request, + format, + evaluation_time, + ) + .await; + } + runtime + .evaluate_with_format( + &evaluation_operation, + &access_token, + &evidence_request, + format, + ) + .await + }); + let result = match evaluation.await { + Ok(result) => result, + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + + match result { + // Release exactly the immutable bytes serialized before the durable + // disclosure-release audit event, with their exact media type. + Ok(released) => { + let media_type = released.media_type(); + bytes_response(StatusCode::OK, media_type, released.into_bytes()) + } + Err(failure) => runtime_failure_response(failure, &operation), + } +} + +/// Resolve the closed Version 1 `Accept` matrix. Missing, `*/*`, and the exact +/// signed media type select signed JWS; only the exact unsigned vendor media +/// type selects the unsigned envelope, and only the exact SD-JWT VC media type +/// selects that serialization. Duplicate, combined, parameterized, weighted, or +/// unknown negotiation is not acceptable. +fn resolve_response_format(headers: &HeaderMap) -> Result { + let mut values = headers.get_all(ACCEPT).iter(); + let Some(value) = values.next() else { + return Ok(ResponseFormat::SignedJws); + }; + if values.next().is_some() { + return Err(ProblemCode::ResponseFormatNotAcceptable); + } + match value.as_bytes() { + b"*/*" => Ok(ResponseFormat::SignedJws), + value if value == EVIDENCE_JWS_MEDIA_TYPE.as_bytes() => Ok(ResponseFormat::SignedJws), + value if value == EVIDENCE_UNSIGNED_MEDIA_TYPE.as_bytes() => { + Ok(ResponseFormat::UnsignedJson) + } + value if value == EVIDENCE_SD_JWT_VC_MEDIA_TYPE.as_bytes() => Ok(ResponseFormat::SdJwtVc), + _ => Err(ProblemCode::ResponseFormatNotAcceptable), + } +} + +async fn discover_evidence( + State(state): State>, + request: Request, +) -> Response { + let operation = operation_id(request.extensions()); + let started = Instant::now(); + let access_token = match bearer_token(request.headers()) { + Ok(token) => token.to_owned(), + Err(code) => return problem_response(code, &operation), + }; + if request.uri().query().is_some() || content_length_exceeds(request.headers(), 0) { + return problem_response(ProblemCode::MalformedRequest, &operation); + } + let admission_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let _request_slot = match tokio::time::timeout( + admission_budget, + Arc::clone(&state.request_slots).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) | Err(_) => { + return problem_response(ProblemCode::ServiceUnavailable, &operation) + } + }; + let body_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + match tokio::time::timeout(body_budget, to_bytes(request.into_body(), 0)).await { + Ok(Ok(body)) if body.is_empty() => {} + Ok(Ok(_)) | Ok(Err(_)) => { + return problem_response(ProblemCode::MalformedRequest, &operation) + } + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + } + let discovery_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + match tokio::time::timeout(discovery_budget, state.runtime.discover(&access_token)).await { + Ok(Ok(definitions)) => { + match serialize_response(StatusCode::OK, JSON_MEDIA_TYPE, &definitions) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } + } + Ok(Err(failure)) => runtime_failure_response(failure, &operation), + Err(_) => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +async fn health() -> Response { + static_json_response(StatusCode::OK, r#"{"status":"ok"}"#) +} + +/// Publish the generated public contract. The document is static release +/// material, so this route takes no credential and reaches no dependency. +async fn openapi(request: Request) -> Response { + match served_openapi_document() { + Some(document) => bytes_response( + StatusCode::OK, + OPENAPI_MEDIA_TYPE, + document.as_bytes().to_vec(), + ), + None => problem_response( + ProblemCode::ServiceUnavailable, + &operation_id(request.extensions()), + ), + } +} + +async fn ready(State(state): State>, request: Request) -> Response { + let operation = operation_id(request.extensions()); + match tokio::time::timeout(state.request_timeout, state.runtime.ready()).await { + Ok(true) => static_json_response(StatusCode::OK, r#"{"status":"ready"}"#), + Ok(false) | Err(_) => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +async fn jwks(State(state): State>, request: Request) -> Response { + let operation = operation_id(request.extensions()); + match serialize_response(StatusCode::OK, JWKS_MEDIA_TYPE, state.runtime.jwks()) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +/// JWT VC Issuer Metadata. Discovery is not a trust anchor: it republishes the +/// same public keys under the provider identity the assertion already names. +/// Resolution is meaningful only when that identity is the HTTPS origin of the +/// deployment; a URN provider identity simply has no resolution path. +async fn jwt_vc_issuer_metadata( + State(state): State>, + request: Request, +) -> Response { + let operation = operation_id(request.extensions()); + let metadata = JwtVcIssuerMetadata { + issuer: &state.runtime.bundle().config.service.provider_id, + jwks: state.runtime.jwks(), + }; + match serialize_response(StatusCode::OK, JSON_MEDIA_TYPE, &metadata) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +#[derive(Serialize)] +struct JwtVcIssuerMetadata<'a> { + issuer: &'a str, + jwks: &'a JwksDocument, +} + +async fn unknown_route(request: Request) -> Response { + problem_response( + ProblemCode::MalformedRequest, + &operation_id(request.extensions()), + ) +} + +async fn add_no_store(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn parse_evidence_request(bytes: &[u8]) -> Result { + let value = parse_json_strict(bytes).map_err(|_| ProblemCode::MalformedRequest)?; + match request_contract_accepts(&value) { + Ok(true) => {} + Ok(false) => return Err(ProblemCode::MalformedRequest), + Err(_) => return Err(ProblemCode::ServiceUnavailable), + } + let request: EvidenceRequest = + serde_json::from_value(value).map_err(|_| ProblemCode::MalformedRequest)?; + // The transport schema pins length and alphabet; canonicality of the + // final base64url symbol is checked here, before authentication. + if !request_nonce_is_canonical(&request.request_nonce) { + return Err(ProblemCode::MalformedRequest); + } + Ok(request) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, ProblemCode> { + let mut values = headers.get_all(AUTHORIZATION).iter(); + let value = values.next().ok_or(ProblemCode::AuthenticationFailed)?; + if values.next().is_some() { + return Err(ProblemCode::AuthenticationFailed); + } + let value = value + .to_str() + .map_err(|_| ProblemCode::AuthenticationFailed)?; + // The HTTP authentication grammar matches the scheme case-insensitively. + // The single-header, single-space, and token-value rules stay exact. + let (scheme, token) = value + .split_once(' ') + .ok_or(ProblemCode::AuthenticationFailed)?; + if !scheme.eq_ignore_ascii_case("Bearer") + || token.is_empty() + || token + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte == b',') + { + return Err(ProblemCode::AuthenticationFailed); + } + Ok(token) +} + +fn has_exact_content_type(headers: &HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + let Some(value) = values.next() else { + return false; + }; + values.next().is_none() && value.as_bytes() == expected.as_bytes() +} + +fn content_length_exceeds(headers: &HeaderMap, maximum: usize) -> bool { + let mut values = headers.get_all(CONTENT_LENGTH).iter(); + let Some(value) = values.next() else { + return false; + }; + if values.next().is_some() { + return true; + } + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .is_none_or(|length| length > maximum as u64) +} + +fn remaining(limit: Duration, started: Instant) -> Option { + limit.checked_sub(started.elapsed()) +} + +fn runtime_failure_response(failure: RuntimeFailure, operation: &str) -> Response { + problem_response(failure.problem(), operation) +} + +fn problem_response(code: ProblemCode, operation: &str) -> Response { + let body = code.body(operation); + let mut response = serialize_response(code.status(), PROBLEM_MEDIA_TYPE, &body) + .unwrap_or_else(|| empty_response(StatusCode::INTERNAL_SERVER_ERROR)); + // The observation layer reads the code from here rather than from the + // response body, so the operational record names the same reviewed error + // category the caller was given without reparsing public bytes. + response.extensions_mut().insert(code); + if code == ProblemCode::AuthenticationFailed { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer"), + ); + } + if code == ProblemCode::RateLimited { + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static(RETRY_AFTER_SECONDS)); + } + response +} + +fn serialize_response( + status: StatusCode, + media_type: &'static str, + value: &T, +) -> Option { + let bytes = serde_json::to_vec(value).ok()?; + Some(bytes_response(status, media_type, bytes)) +} + +fn static_json_response(status: StatusCode, body: &'static str) -> Response { + bytes_response(status, JSON_MEDIA_TYPE, body.as_bytes().to_vec()) +} + +fn bytes_response(status: StatusCode, media_type: &'static str, bytes: Vec) -> Response { + let mut response = (status, Body::from(bytes)).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(media_type)); + response +} + +fn empty_response(status: StatusCode) -> Response { + (status, Body::empty()).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + /// A port this service did not get is the one failure whose symptom is + /// another service answering correctly in its place. The refusal has to + /// carry the address, because an operator reading it is deciding whether + /// the configuration is wrong or a previous instance is still running. + #[tokio::test] + async fn a_taken_port_is_refused_by_address() { + let held = bind("127.0.0.1", 0).await.expect("an ephemeral port binds"); + let taken = held.local_addr().expect("the held listener has an address"); + + let refused = bind("127.0.0.1", taken.port()) + .await + .expect_err("a held port cannot be bound twice"); + assert_eq!(refused.kind(), io::ErrorKind::AddrInUse); + assert!( + refused + .to_string() + .starts_with(&format!("could not bind 127.0.0.1:{}: ", taken.port())), + "the refusal names the address it failed on: {refused}" + ); + + let malformed = bind("localhost", 0) + .await + .expect_err("a host name is not an address"); + assert_eq!(malformed.kind(), io::ErrorKind::InvalidInput); + assert!( + malformed + .to_string() + .starts_with("localhost is not an address: "), + "the refusal names the value it could not parse: {malformed}" + ); + } + + #[test] + fn authorization_requires_one_unambiguous_bearer_value() { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer three.parts.value"), + ); + assert_eq!( + bearer_token(&headers).expect("single bearer accepted"), + "three.parts.value" + ); + + // The authentication scheme is case-insensitive per the HTTP grammar. + for scheme in ["bearer", "BEARER", "BeArEr"] { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("{scheme} three.parts.value")) + .expect("test header is valid"), + ); + assert_eq!( + bearer_token(&headers).expect("case-insensitive scheme accepted"), + "three.parts.value" + ); + } + + for invalid in [ + "Basic three.parts.value", + "Bearer", + "Bearer three.parts.value", + "Bearer three.parts.value ", + "Bearer three.parts.value,other", + ] { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(invalid).expect("test header is valid"), + ); + assert_eq!( + bearer_token(&headers), + Err(ProblemCode::AuthenticationFailed) + ); + } + + headers.clear(); + headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer first")); + headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer second")); + assert_eq!( + bearer_token(&headers), + Err(ProblemCode::AuthenticationFailed) + ); + } + + const TEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + #[test] + fn request_json_is_strict_and_closed() { + let valid = br#"{ + "requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "requirement":"urn:example:requirement:v1", + "purpose":"review", + "subjects":[{ + "role":"subject", + "selector":{"profile":"opaque-v1","values":{"opaque":"value"}} + }] + }"#; + assert!(parse_evidence_request(valid).is_ok()); + for number in ["1.0", "1e0"] { + let request = r#"{"requestNonce":"NONCE","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":NUMBER}}}]}"# + .replace("NONCE", TEST_NONCE) + .replace("NUMBER", number); + let parsed = parse_evidence_request(request.as_bytes()) + .expect("schema-valid integral JSON number is accepted"); + assert_eq!( + parsed.subjects[0] + .selector + .values + .as_ref() + .and_then(|values| values.get("opaque")), + Some(&crate::model::SelectorValue::Integer(1)) + ); + } + assert_eq!( + parse_evidence_request( + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"a","requirement":"b","purpose":"p","subjects":[]}"# + ), + Err(ProblemCode::MalformedRequest) + ); + assert_eq!( + parse_evidence_request( + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"a","purpose":"p","subjects":[],"query":"hidden"}"# + ), + Err(ProblemCode::MalformedRequest) + ); + + let base = r#"{"requestNonce":"NONCE","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#; + for invalid in [ + base.replace("\"requestNonce\":\"NONCE\",", ""), + base.replace("NONCE", ""), + base.replace("NONCE", &"A".repeat(42)), + base.replace("NONCE", &"A".repeat(44)), + base.replace("NONCE", &format!("{}=", "A".repeat(42))), + base.replace("NONCE", &format!("{}+", "A".repeat(42))), + base.replace("NONCE", &format!("{}B", "A".repeat(42))), + format!( + r#"{{"requestNonce":"{TEST_NONCE}","requestNonce":"{TEST_NONCE}","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{{"role":"subject","selector":{{"profile":"opaque-v1","values":{{"opaque":"value"}}}}}}]}}"# + ), + ] { + assert_eq!( + parse_evidence_request(invalid.as_bytes()), + Err(ProblemCode::MalformedRequest), + "{invalid}" + ); + } + + for invalid in [ + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"not a URI","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"Uppercase","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"Uppercase","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":9007199254740992}}}]}"#.as_slice(), + ] { + assert_eq!( + parse_evidence_request(invalid), + Err(ProblemCode::MalformedRequest) + ); + } + } + + #[test] + fn accept_negotiation_matrix_is_closed_and_exact() { + let mut headers = HeaderMap::new(); + assert_eq!( + resolve_response_format(&headers), + Ok(ResponseFormat::SignedJws) + ); + + for (value, expected) in [ + ("*/*", ResponseFormat::SignedJws), + ("application/jose+json", ResponseFormat::SignedJws), + ( + "application/vnd.registrystack.evidence-unsigned+json", + ResponseFormat::UnsignedJson, + ), + ] { + headers.insert(ACCEPT, HeaderValue::from_static(value)); + assert_eq!(resolve_response_format(&headers), Ok(expected), "{value}"); + } + + for invalid in [ + "application/json", + "application/jose+json, application/json", + "application/jose+json;q=0.9", + "application/vnd.registrystack.evidence-unsigned+json; charset=utf-8", + "application/*", + "*/*;q=1", + " application/jose+json", + "APPLICATION/JOSE+JSON", + ] { + headers.insert( + ACCEPT, + HeaderValue::from_str(invalid).expect("test header is valid"), + ); + assert_eq!( + resolve_response_format(&headers), + Err(ProblemCode::ResponseFormatNotAcceptable), + "{invalid}" + ); + } + + headers.clear(); + headers.append(ACCEPT, HeaderValue::from_static("application/jose+json")); + headers.append(ACCEPT, HeaderValue::from_static("application/jose+json")); + assert_eq!( + resolve_response_format(&headers), + Err(ProblemCode::ResponseFormatNotAcceptable) + ); + } + + #[tokio::test] + async fn problem_responses_have_closed_media_and_challenge_headers() { + let authentication = problem_response( + ProblemCode::AuthenticationFailed, + "01K1EVIDENCEOPERATION0000000", + ); + assert_eq!(authentication.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + authentication.headers().get(CONTENT_TYPE), + Some(&HeaderValue::from_static(PROBLEM_MEDIA_TYPE)) + ); + assert_eq!( + authentication + .headers() + .get(axum::http::header::WWW_AUTHENTICATE), + Some(&HeaderValue::from_static("Bearer")) + ); + + let rate = problem_response(ProblemCode::RateLimited, "01K1EVIDENCEOPERATION0000000"); + assert_eq!(rate.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + rate.headers().get(RETRY_AFTER), + Some(&HeaderValue::from_static(RETRY_AFTER_SECONDS)) + ); + } + + #[test] + fn operation_ids_meet_the_audit_contract() { + // The public `operation` field is frozen to a 26-character Crockford + // Base32 ULID by the generated problem schema (^[0-9A-HJKMNP-TV-Z]{26}$). + // Pin the producer to that exact shape so a future change (for example + // swapping ULID for a hyphenated UUID) fails loudly here instead of + // silently breaking the frozen contract. + let operation = operation_id(&axum::http::Extensions::new()); + assert_operation_contract(&operation); + } + + fn assert_operation_contract(operation: &str) { + const CROCKFORD_UPPER: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + assert_eq!(operation.len(), 26, "operation id is a 26-character ULID"); + assert!( + operation + .bytes() + .all(|byte| CROCKFORD_UPPER.contains(&byte)), + "operation id uses only Crockford Base32 uppercase symbols" + ); + // 26 lies inside the frozen 16..=128 audit-operation length range. + assert!(!operation.bytes().any(|byte| byte.is_ascii_whitespace())); + } + + #[tokio::test] + async fn response_layers_apply_no_store_and_security_headers_without_hsts() { + let app = response_layers( + Router::new().route("/probe", get(|| async { StatusCode::NO_CONTENT })), + Arc::new(Metrics::default()), + ); + let response = app + .oneshot( + Request::builder() + .uri("/probe") + .body(Body::empty()) + .expect("test request builds"), + ) + .await + .expect("infallible router responds"); + + assert_eq!( + response.headers().get(CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert_eq!( + response.headers().get("x-content-type-options"), + Some(&HeaderValue::from_static("nosniff")) + ); + assert_eq!( + response.headers().get("x-frame-options"), + Some(&HeaderValue::from_static("DENY")) + ); + assert!(response + .headers() + .get("strict-transport-security") + .is_none()); + // The identifier the caller can quote back is produced by the boundary + // itself, so it must meet the same frozen shape as the audit field. + assert_operation_contract( + response + .headers() + .get(crate::observability::CORRELATION_HEADER) + .expect("every response carries a correlation identifier") + .to_str() + .expect("the correlation header is ASCII"), + ); + } + + #[tokio::test] + async fn evaluation_tracker_drains_a_detached_task_without_a_missed_wakeup() { + let tracker = EvaluationTracker::default(); + let (release, blocked) = tokio::sync::oneshot::channel(); + let detached = tracker.spawn(async move { + let _ = blocked.await; + }); + drop(detached); + + let waiter = tokio::spawn({ + let tracker = tracker.clone(); + async move { tracker.wait_idle().await } + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + release.send(()).expect("detached task is still running"); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("detached task drains") + .expect("drain waiter does not panic"); + } +} diff --git a/crates/registry-evidence/src/signing.rs b/crates/registry-evidence/src/signing.rs new file mode 100644 index 000000000..e6a52f6d4 --- /dev/null +++ b/crates/registry-evidence/src/signing.rs @@ -0,0 +1,439 @@ +//! Evidence-owned flattened JWS construction and key publication. + +use std::{collections::BTreeSet, sync::Arc}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{ + verify, KeyReadiness, PublicJwk, SigningAlgorithm, SigningError as ProviderSigningError, + SigningProvider, +}; +use registry_platform_sdjwt::{SdJwtError, SdJwtIssuanceInput, SdJwtIssuer}; +use serde::Serialize; +use thiserror::Error; + +use crate::{ + model::{FlattenedJws, JwksDocument}, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, +}; + +const MAX_KEY_ID_BYTES: usize = 256; +const MAX_PUBLISHED_KEYS: usize = 33; + +#[derive(Debug, Error)] +pub enum EvidenceSigningError { + #[error("the configured signing algorithm is not allowed")] + Algorithm, + #[error("the configured signing key identifier is invalid")] + KeyId, + #[error("the signing key identifier does not match the configured active key")] + ActiveKeyId, + #[error("the signing provider is unavailable")] + Provider(#[from] ProviderSigningError), + #[error("the signing provider failed its startup self-test")] + SelfTest, + #[error("the protected header could not be serialized")] + HeaderSerialization(#[source] serde_json::Error), + #[error("the evidence payload could not be serialized")] + PayloadSerialization(#[source] serde_json::Error), + #[error("the published key set contains an invalid or duplicate key identifier")] + PublishedKey, + #[error("the published key set could not be serialized")] + KeySerialization(#[source] serde_json::Error), + #[error("the SD-JWT VC serialization could not be produced")] + SdJwtVc(#[source] SdJwtError), +} + +#[derive(Debug, Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +/// Evidence's single active Ed25519 signer. +pub struct EvidenceSigner { + provider: Arc, +} + +impl std::fmt::Debug for EvidenceSigner { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceSigner") + .field("algorithm", &self.provider.algorithm()) + .field("key_id", &self.provider.key_id()) + .finish_non_exhaustive() + } +} + +impl EvidenceSigner { + pub async fn initialize( + provider: Arc, + configured_active_key_id: &str, + ) -> Result { + validate_provider(provider.as_ref(), configured_active_key_id)?; + + let self_test_message = b"registry-evidence-signing-readiness-v1"; + let signature = provider.sign(self_test_message).await?; + verify(self_test_message, &signature, &provider.public_jwk()) + .map_err(|_| EvidenceSigningError::SelfTest)?; + + Ok(Self { provider }) + } + + pub fn key_id(&self) -> &str { + self.provider.key_id() + } + + pub fn public_jwk(&self) -> PublicJwk { + self.provider.public_jwk() + } + + /// Report the current signing-provider posture without exposing key data. + pub fn ready(&self) -> bool { + self.provider.readiness() == KeyReadiness::Ready + } + + /// Serialize and sign the exact JSON representation of a validated Evidence value. + pub async fn sign_json( + &self, + evidence: &T, + ) -> Result { + let payload = + serde_json::to_vec(evidence).map_err(EvidenceSigningError::PayloadSerialization)?; + self.sign_bytes(&payload).await + } + + /// Sign exact UTF-8 Evidence JSON bytes as a flattened JWS JSON value. + pub async fn sign_bytes( + &self, + evidence_json: &[u8], + ) -> Result { + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: self.provider.key_id(), + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .map_err(EvidenceSigningError::HeaderSerialization)?; + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(evidence_json); + let signing_input = [protected.as_bytes(), b".", payload.as_bytes()].concat(); + let signature = self.provider.sign(&signing_input).await?; + + Ok(FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + }) + } + + /// Serialize the same assertion as a compact SD-JWT VC. The signer is the + /// one active key already used for the flattened JWS; the profile adds no + /// second key, algorithm, or key ceremony. + pub async fn sign_sd_jwt_vc( + &self, + input: SdJwtIssuanceInput, + ) -> Result { + SdJwtIssuer::from_signing_provider(Arc::clone(&self.provider)) + .issue(input) + .await + .map(|signed| signed.jwt) + .map_err(EvidenceSigningError::SdJwtVc) + } +} + +pub fn jwks_document( + active: PublicJwk, + retired: impl IntoIterator, +) -> Result { + let mut seen = BTreeSet::new(); + let mut keys = Vec::new(); + for key in std::iter::once(active).chain(retired) { + if keys.len() == MAX_PUBLISHED_KEYS { + return Err(EvidenceSigningError::PublishedKey); + } + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(EvidenceSigningError::Algorithm); + } + let key_id = key + .kid + .as_deref() + .ok_or(EvidenceSigningError::PublishedKey)?; + validate_key_id(key_id)?; + if !seen.insert(key_id.to_owned()) { + return Err(EvidenceSigningError::PublishedKey); + } + keys.push(serde_json::to_value(key).map_err(EvidenceSigningError::KeySerialization)?); + } + Ok(JwksDocument { keys }) +} + +fn validate_provider( + provider: &dyn SigningProvider, + configured_active_key_id: &str, +) -> Result<(), EvidenceSigningError> { + if provider.algorithm() != SigningAlgorithm::EdDsa { + return Err(EvidenceSigningError::Algorithm); + } + validate_key_id(provider.key_id())?; + if provider.key_id() != configured_active_key_id { + return Err(EvidenceSigningError::ActiveKeyId); + } + let public = provider.public_jwk(); + if public.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + || public.kid.as_deref() != Some(provider.key_id()) + { + return Err(EvidenceSigningError::Algorithm); + } + Ok(()) +} + +fn validate_key_id(key_id: &str) -> Result<(), EvidenceSigningError> { + if key_id.is_empty() || key_id.len() > MAX_KEY_ID_BYTES || key_id.chars().any(char::is_control) + { + return Err(EvidenceSigningError::KeyId); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeMap; + + const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + const FIXTURE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"fixture-key-2026-01"}"#; + const SAME_KID_DIFFERENT_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"evidence-key-1"}"#; + + async fn signer() -> EvidenceSigner { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes") + } + + async fn fixture_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(FIXTURE_PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + EvidenceSigner::initialize(provider, "fixture-key-2026-01") + .await + .expect("signer initializes") + } + + #[tokio::test] + async fn flattened_jws_has_exact_protected_header_and_valid_signature() { + let signer = signer().await; + let evidence = serde_json::json!({"schema": crate::EVIDENCE_SCHEMA_V1}); + let jws = signer.sign_json(&evidence).await.expect("evidence signs"); + + let protected_bytes = URL_SAFE_NO_PAD + .decode(&jws.protected) + .expect("protected header decodes"); + let protected: serde_json::Value = + serde_json::from_slice(&protected_bytes).expect("protected header parses"); + assert_eq!( + protected, + serde_json::json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": "evidence+jws", + "cty": "application/evidence+json" + }) + ); + + let signing_input = format!("{}.{}", jws.protected, jws.payload); + let signature = URL_SAFE_NO_PAD + .decode(&jws.signature) + .expect("signature decodes"); + verify(signing_input.as_bytes(), &signature, &signer.public_jwk()) + .expect("signature verifies"); + } + + #[tokio::test] + async fn configured_key_id_must_match_provider() { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + let error = EvidenceSigner::initialize(provider, "different-key") + .await + .expect_err("mismatched key id is rejected"); + assert!(matches!(error, EvidenceSigningError::ActiveKeyId)); + } + + #[tokio::test] + async fn jwks_contains_public_material_only() { + let signer = signer().await; + let document = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let json = serde_json::to_value(document).expect("JWKS serializes"); + assert_eq!(json["keys"].as_array().map(Vec::len), Some(1)); + assert!(json["keys"][0].get("d").is_none()); + + let duplicate_private = + PrivateJwk::parse(SAME_KID_DIFFERENT_PRIVATE_JWK).expect("rotated test key parses"); + let duplicate = LocalJwkSigner::new(duplicate_private) + .expect("rotated signer builds") + .public_jwk(); + assert!(matches!( + jwks_document(signer.public_jwk(), [duplicate]), + Err(EvidenceSigningError::PublishedKey) + )); + + let retired = (0..32).map(|index| { + let mut key = signer.public_jwk(); + key.kid = Some(format!("retired-evidence-key-{index:02}")); + key + }); + let boundary = jwks_document(signer.public_jwk(), retired).expect("33 keys are allowed"); + assert_eq!(boundary.keys.len(), 33); + + let too_many = (0..33).map(|index| { + let mut key = signer.public_jwk(); + key.kid = Some(format!("excess-evidence-key-{index:02}")); + key + }); + assert!(matches!( + jwks_document(signer.public_jwk(), too_many), + Err(EvidenceSigningError::PublishedKey) + )); + } + + /// The SD-JWT VC fixture is the adopter-facing wire contract, so it must be + /// reproduced by the production issuance path over every golden payload: + /// the exact protected header, one root disclosure per unprojected golden + /// value, sorted unique digests over the encoded disclosure bytes, and a + /// trailing tilde. Structured field projection has a focused verifier test. + #[tokio::test] + async fn sd_jwt_vc_fixture_serialization_and_protected_header_are_exact() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml" + )) + .expect("SD-JWT VC fixture parses"); + assert_eq!( + fixture["media_type"].as_str(), + Some(crate::EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + ); + let expected_header = fixture["protected_header"]["exact_json"] + .as_str() + .expect("fixture header is text"); + let signer = fixture_signer().await; + + for case in fixture["cases"] + .as_array() + .expect("fixture cases are an array") + { + let relative = case["payload"].as_str().expect("payload path is text"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance") + .join(relative); + let evidence: crate::model::Evidence = + serde_json::from_slice(&std::fs::read(path).expect("golden payload reads")) + .expect("golden payload is an Evidence document"); + let input = crate::sdjwt_vc::issuance_input(&evidence, None, &BTreeMap::new()) + .expect("golden payload maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("golden payload serializes"); + + let body = serialized + .strip_suffix('~') + .expect("the serialization ends with the key-binding terminator"); + let mut segments = body.split('~'); + let jwt = segments.next().expect("issuer-signed JWT segment"); + let disclosures = segments.collect::>(); + assert_eq!( + disclosures.len(), + evidence.supported_values.len(), + "{} discloses one root value per unprojected supported value", + case["id"] + ); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3, "the JWT is compact JWS serialized"); + assert_eq!( + URL_SAFE_NO_PAD.decode(parts[0]).expect("header decodes"), + expected_header.as_bytes() + ); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).expect("payload decodes")) + .expect("payload parses"); + assert_eq!(claims["_sd_alg"], serde_json::json!("sha-256")); + + let digests = claims["_sd"] + .as_array() + .expect("_sd is an array") + .iter() + .map(|value| value.as_str().expect("digest is text").to_owned()) + .collect::>(); + let mut sorted = digests.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(digests, sorted, "_sd is sorted and carries no repeat"); + for disclosure in &disclosures { + let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); + assert!( + digests.contains(&digest), + "a disclosure is absent from _sd in {}", + case["id"] + ); + } + + let signature = URL_SAFE_NO_PAD.decode(parts[2]).expect("signature decodes"); + verify( + format!("{}.{}", parts[0], parts[1]).as_bytes(), + &signature, + &signer.public_jwk(), + ) + .expect("fixture signature verifies"); + } + } + + #[tokio::test] + async fn jws_fixture_payload_bytes_and_protected_header_are_exact() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/jws-cases.yaml" + )) + .expect("JWS fixture parses"); + let expected_header = fixture["protected_header"]["exact_json"] + .as_str() + .expect("fixture header is text"); + let signer = fixture_signer().await; + for case in fixture["cases"] + .as_array() + .expect("fixture cases are an array") + { + let relative = case["payload"].as_str().expect("payload path is text"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance") + .join(relative); + let payload = std::fs::read(path).expect("golden payload reads"); + assert_eq!(payload.last(), Some(&b'\n')); + let jws = signer.sign_bytes(&payload).await.expect("payload signs"); + assert_eq!( + URL_SAFE_NO_PAD + .decode(&jws.protected) + .expect("header decodes"), + expected_header.as_bytes() + ); + assert_eq!( + URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"), + payload + ); + let signing_input = format!("{}.{}", jws.protected, jws.payload); + let signature = URL_SAFE_NO_PAD + .decode(&jws.signature) + .expect("signature decodes"); + verify(signing_input.as_bytes(), &signature, &signer.public_jwk()) + .expect("fixture signature verifies"); + } + } +} diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs new file mode 100644 index 000000000..a4def3b06 --- /dev/null +++ b/crates/registry-evidence/src/source.rs @@ -0,0 +1,1750 @@ +//! Exact, bounded HTTP/JSON source execution for Evidence Version 1. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use http::{HeaderMap, HeaderName, HeaderValue}; +use registry_platform_httputil::{read_bounded, BoundedReadError}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue}; +use thiserror::Error; +use tokio::sync::{Mutex, Semaphore}; +use url::{Host, Url}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::config::{ + validate_local_unauthenticated_source_origin, AcquisitionPosture, CredentialPlacement, + FixedRequest, HttpMethod, OutboundTlsConfig, PathBindingConfig, PreparationChannelPolicy, + SecretRef, SourceAuthentication, SourceConfig, SourceSelectorSet, +}; +use crate::model::SelectorValue; +use crate::rhai_runtime::RequestParts; +use crate::secrets::{ProtectedSecret, SecretResolver}; + +const TOKEN_RESPONSE_MAXIMUM_BYTES: u64 = 8 * 1024; +const PRIVATE_CA_MAXIMUM_BYTES: u64 = 1024 * 1024; +const PROJECTED_RESPONSE_MAXIMUM_BYTES: usize = 65_536; +const JSON_MEDIA_TYPE: &str = "application/json"; +const GRAPHQL_JSON_MEDIA_TYPE: &str = "application/graphql-response+json"; + +/// A role-bound selector that has already passed authentication, +/// authorization, exact-field, type, and size validation. +/// +/// This type intentionally has no `Debug` implementation because its values +/// are protected request material. +pub struct ResolvedSourceSelector { + pub role: String, + pub profile: String, + pub values: BTreeMap, +} + +/// A safe status category that does not retain a response or request URL. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceStatus { + Unauthorized, + Forbidden, + RateLimited, + ServerError, + Other, +} + +/// Closed source failures. No variant retains request, response, selector, or +/// credential material. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SourceError { + #[error("the fixed source plan is invalid")] + InvalidPlan, + #[error("the resolved source selector set is invalid")] + InvalidSelectors, + #[error("source credentials are unavailable or invalid")] + Credential, + #[error("the source concurrency boundary is unavailable")] + Concurrency, + #[error("the source request timed out")] + Timeout, + #[error("the source transport is unavailable")] + Transport, + #[error("the source attempted a redirect")] + Redirect, + #[error("the source returned a rejected status category")] + Status(SourceStatus), + #[error("the source returned an unsupported media type")] + WrongMediaType, + #[error("the source response exceeded a configured bound")] + ResponseTooLarge, + #[error("the source returned invalid JSON")] + InvalidJson, + #[error("the source returned an error envelope")] + ErrorEnvelope, + #[error("the source response did not satisfy its acquisition projection")] + ProjectionViolation, +} + +/// Executes one immutable source plan. The client has redirects, retries, +/// ambient proxies, pagination, cookies, and caller-controlled headers absent. +pub struct SourceExecutor { + client: reqwest::Client, + request: RequestPlan, + authentication: AuthenticationPlan, + secrets: Arc, + concurrency: Semaphore, + concurrency_admission_timeout: Duration, +} + +/// The validated non-credential transport material for one fixed source request. +/// +/// The full URL remains private so callers cannot obtain source authority, and +/// this type deliberately exposes no fixed headers or authentication material. +/// Path, query, and body access is intended for the trusted offline fixture +/// harness. Its diagnostic representation is always value-free. +#[derive(Clone, PartialEq)] +pub struct MaterializedSourceRequest { + url: Url, + body: Option, +} + +impl MaterializedSourceRequest { + pub fn path(&self) -> &str { + self.url.path() + } + + pub fn query(&self) -> Option<&str> { + self.url.query() + } + + pub fn body(&self) -> Option<&JsonValue> { + self.body.as_ref() + } +} + +impl fmt::Debug for MaterializedSourceRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MaterializedSourceRequest") + .field("path", &"") + .field("query", &self.query().map(|_| "")) + .field("body", &self.body().map(|_| "")) + .finish() + } +} + +struct RequestPlan { + base_url: Url, + path: SourcePath, + method: HttpMethod, + fixed_headers: HeaderMap, + selector_inputs: BTreeMap>>, + allowed_selector_sets: Vec, + posture: AcquisitionPosture, + projection: ProjectionNode, + maximum_response_bytes: u64, +} + +enum SourcePath { + Fixed(String), + Template { + template: String, + bindings: BTreeMap, + }, +} + +struct PathBindingPlan { + role: String, + profile: String, + field: String, +} + +enum AuthenticationPlan { + None, + Basic { + username_ref: SecretRef, + password_ref: SecretRef, + }, + StaticBearer { + token_ref: SecretRef, + }, + StaticApiKey { + header_name: HeaderName, + value_ref: SecretRef, + }, + Oauth2(Box), +} + +struct OauthPlan { + token_endpoint: Url, + client_id_ref: SecretRef, + client_secret_ref: SecretRef, + scope: Option, + credential_placement: CredentialPlacement, + maximum_cache_lifetime: Duration, + /// Lifetime used when the provider omits `expires_in`. + assumed_lifetime: Option, + admission_timeout: Duration, + cache: Mutex>, +} + +struct CachedToken { + token: ProtectedToken, + expires_at: Instant, +} + +struct ProtectedToken(Zeroizing>); + +impl ProtectedToken { + fn from_string(value: String) -> Result { + if value.is_empty() || value.len() > TOKEN_RESPONSE_MAXIMUM_BYTES as usize { + return Err(SourceError::Credential); + } + Ok(Self(Zeroizing::new(value.into_bytes()))) + } + + fn expose(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl Clone for ProtectedToken { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.to_vec())) + } +} + +#[derive(Default)] +struct ProjectionNode { + terminal: bool, + keys: BTreeMap, + wildcard: Option>, +} + +impl SourceExecutor { + /// Compile a standalone source with system TLS roots. Sources that name a + /// private trust profile require `new_with_selector_sets_and_tls`. + pub fn new(source: &SourceConfig, secrets: Arc) -> Result { + let allowed = conservative_selector_sets(source)?; + Self::new_with_selector_sets(source, &allowed, secrets) + } + + /// Compile a source with system TLS roots and explicitly allowed selector + /// tuples. + pub fn new_with_selector_sets( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + secrets: Arc, + ) -> Result { + if source.tls_trust_profile.is_some() { + return Err(SourceError::InvalidPlan); + } + Self::compile(source, allowed_selector_sets, None, secrets) + } + + /// Compile a source against runtime-owned TLS trust bindings. System roots + /// remain enabled and the selected private CA bundle is additive. + pub fn new_with_selector_sets_and_tls( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + outbound_tls: &OutboundTlsConfig, + captured_ca_bundles: &BTreeMap>, + secrets: Arc, + ) -> Result { + Self::compile( + source, + allowed_selector_sets, + Some((outbound_tls, captured_ca_bundles)), + secrets, + ) + } + + fn compile( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, + secrets: Arc, + ) -> Result { + if matches!(source.authentication, SourceAuthentication::None {}) + && (source.tls_trust_profile.is_some() + || validate_local_unauthenticated_source_origin(&source.base_url).is_err()) + { + return Err(SourceError::InvalidPlan); + } + let timeout = Duration::from_millis(source.request.timeout_milliseconds); + if timeout.is_zero() + || source.request.timeout_milliseconds > 30_000 + || source.request.maximum_response_bytes == 0 + || source.request.maximum_response_bytes > 1_048_576 + || source.request.concurrency_limit == 0 + || source.request.concurrency_limit > 256 + { + return Err(SourceError::InvalidPlan); + } + let base_url = validate_url(&source.base_url, true)?; + let authentication = compile_authentication(&source.authentication, timeout)?; + let request = compile_request( + &source.request, + allowed_selector_sets, + source.posture, + base_url, + &authentication, + )?; + let client = build_client(timeout, source, outbound_tls)?; + Ok(Self { + client, + request, + authentication, + secrets, + concurrency: Semaphore::new(usize::from(source.request.concurrency_limit)), + concurrency_admission_timeout: timeout, + }) + } + + /// Make exactly one evidence-data request using validated Rhai preparation + /// output. Path expansion remains Rust-owned and selector-bound. + pub async fn execute( + &self, + selectors: &[ResolvedSourceSelector], + request_parts: &RequestParts, + ) -> Result { + let materialized = self.materialize_request(selectors, request_parts)?; + let _permit = + acquire_source_slot(&self.concurrency, self.concurrency_admission_timeout).await?; + let method = match self.request.method { + HttpMethod::GET => reqwest::Method::GET, + HttpMethod::POST => reqwest::Method::POST, + }; + let mut request = self + .client + .request(method, materialized.url.clone()) + .headers(self.request.fixed_headers.clone()); + if let Some((authentication_name, authentication_value)) = + self.authentication_header().await? + { + request = request.header(authentication_name, authentication_value); + } + if !self.request.fixed_headers.contains_key(ACCEPT) { + request = request.header(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)); + } + if let Some(body) = materialized.body() { + request = request.json(body); + } + let response = request.send().await.map_err(map_transport_error)?; + parse_data_response( + response, + self.request.maximum_response_bytes, + self.request.posture, + &self.request.projection, + ) + .await + } + + /// Validate and materialize only path, encoded query, and JSON body. + /// + /// This performs no concurrency admission, credential resolution, or I/O. + /// The same result is consumed directly by [`Self::execute`]. + pub fn materialize_request( + &self, + selectors: &[ResolvedSourceSelector], + request_parts: &RequestParts, + ) -> Result { + if matches!(self.request.method, HttpMethod::GET) && request_parts.body.is_some() { + return Err(SourceError::InvalidPlan); + } + let selectors = self.request.validate_selectors(selectors)?; + let url = self.request.materialize_url(&selectors, request_parts)?; + Ok(MaterializedSourceRequest { + url, + body: request_parts.body.clone(), + }) + } + + /// Resolve and validate credentials without making an evidence-data + /// request. OAuth may perform its bounded token bootstrap. + pub async fn credentials_ready(&self) -> Result<(), SourceError> { + self.authentication_header().await.map(|_| ()) + } + + async fn authentication_header( + &self, + ) -> Result, SourceError> { + let value = match &self.authentication { + AuthenticationPlan::None => return Ok(None), + AuthenticationPlan::Basic { + username_ref, + password_ref, + } => { + let username = resolve(&self.secrets, username_ref)?; + let password = resolve(&self.secrets, password_ref)?; + basic_authorization(&username, &password)? + } + AuthenticationPlan::StaticBearer { token_ref } => { + let token = resolve(&self.secrets, token_ref)?; + bearer_authorization(token.expose_secret())? + } + AuthenticationPlan::StaticApiKey { + header_name, + value_ref, + } => { + let secret = resolve(&self.secrets, value_ref)?; + return Ok(Some(( + header_name.clone(), + sensitive_header(secret.expose_secret())?, + ))); + } + AuthenticationPlan::Oauth2(plan) => { + let token = plan.access_token(&self.client, &self.secrets).await?; + bearer_authorization(token.expose())? + } + }; + Ok(Some((AUTHORIZATION, value))) + } +} + +/// Apply the exact production response-size, envelope, and projection rules +/// to an already parsed synthetic fixture response. +pub fn project_fixture_response( + source: &SourceConfig, + response: &JsonValue, +) -> Result { + let raw = serde_json::to_vec(response).map_err(|_| SourceError::InvalidJson)?; + if raw.len() + > usize::try_from(source.request.maximum_response_bytes) + .map_err(|_| SourceError::ResponseTooLarge)? + { + return Err(SourceError::ResponseTooLarge); + } + if response + .as_object() + .is_some_and(|object| object.contains_key("errors")) + { + return Err(SourceError::ErrorEnvelope); + } + let projection = compile_projection(&source.request.projection)?; + let projected = project_value(response, &projection)?; + if serde_json::to_vec(&projected) + .map_err(|_| SourceError::ProjectionViolation)? + .len() + > PROJECTED_RESPONSE_MAXIMUM_BYTES + { + return Err(SourceError::ResponseTooLarge); + } + Ok(projected) +} + +fn build_client( + timeout: Duration, + source: &SourceConfig, + outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, +) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(timeout) + .connect_timeout(timeout.min(Duration::from_secs(10))) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + // Select rustls explicitly. Cargo unifies reqwest's feature set across + // the whole workspace, so another workspace member enabling + // reqwest's native-tls feature must not silently change which TLS + // backend this client uses. + .use_rustls_tls() + // Evidence source execution is exactly one request per plan step; a + // transport-level retry would duplicate an outbound call the caller + // did not ask for and is not accounted for in the one-request + // contract. + .retry(reqwest::retry::never()); + if let Some(profile_name) = source.tls_trust_profile.as_deref() { + let (tls, captured_ca_bundles) = outbound_tls.ok_or(SourceError::InvalidPlan)?; + if !tls.system_roots { + return Err(SourceError::InvalidPlan); + } + let binding = tls + .trust_profiles + .get(profile_name) + .ok_or(SourceError::InvalidPlan)?; + if binding.ca_bundle_file.is_empty() { + return Err(SourceError::InvalidPlan); + } + let pem = captured_ca_bundles + .get(profile_name) + .ok_or(SourceError::InvalidPlan)?; + if pem.is_empty() || pem.len() as u64 > PRIVATE_CA_MAXIMUM_BYTES { + return Err(SourceError::InvalidPlan); + } + let certificates = + reqwest::Certificate::from_pem_bundle(pem).map_err(|_| SourceError::InvalidPlan)?; + if certificates.is_empty() { + return Err(SourceError::InvalidPlan); + } + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + } + builder.build().map_err(|_| SourceError::InvalidPlan) +} + +fn conservative_selector_sets( + source: &SourceConfig, +) -> Result, SourceError> { + let mut sets = vec![Vec::new()]; + for input in &source.request.selector_inputs { + if input.alternatives.is_empty() { + return Err(SourceError::InvalidPlan); + } + let mut next = Vec::new(); + for set in &sets { + for alternative in &input.alternatives { + let mut candidate = set.clone(); + candidate.push((input.role.clone(), alternative.profile.clone())); + next.push(candidate); + } + } + sets = next; + } + if sets.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(sets) +} + +fn compile_request( + request: &FixedRequest, + allowed_selector_sets: &[SourceSelectorSet], + posture: AcquisitionPosture, + base_url: Url, + authentication: &AuthenticationPlan, +) -> Result { + if request.method == HttpMethod::GET + && request.preparation_limits.json_body != PreparationChannelPolicy::Forbidden + { + return Err(SourceError::InvalidPlan); + } + let selector_inputs = compile_selector_inputs(request)?; + let allowed_selector_sets = + compile_allowed_selector_sets(&selector_inputs, allowed_selector_sets)?; + let path = compile_source_path(request, &selector_inputs)?; + validate_bindings_are_reachable(&path, &allowed_selector_sets)?; + let fixed_headers = compile_fixed_headers(request, authentication)?; + let projection = compile_projection(&request.projection)?; + Ok(RequestPlan { + base_url, + path, + method: request.method, + fixed_headers, + selector_inputs, + allowed_selector_sets, + posture, + projection, + maximum_response_bytes: request.maximum_response_bytes, + }) +} + +async fn acquire_source_slot<'a>( + semaphore: &'a Semaphore, + timeout: Duration, +) -> Result, SourceError> { + tokio::time::timeout(timeout, semaphore.acquire()) + .await + .map_err(|_| SourceError::Timeout)? + .map_err(|_| SourceError::Concurrency) +} + +fn compile_selector_inputs( + request: &FixedRequest, +) -> Result>>, SourceError> { + let mut output = BTreeMap::new(); + for input in &request.selector_inputs { + let profiles = output + .entry(input.role.clone()) + .or_insert_with(BTreeMap::new); + for alternative in &input.alternatives { + let fields = alternative.fields.iter().cloned().collect::>(); + if fields.len() != alternative.fields.len() + || profiles + .insert(alternative.profile.clone(), fields) + .is_some() + { + return Err(SourceError::InvalidPlan); + } + } + if profiles.is_empty() { + return Err(SourceError::InvalidPlan); + } + } + if output.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(output) +} + +fn compile_allowed_selector_sets( + inputs: &BTreeMap>>, + configured: &[SourceSelectorSet], +) -> Result, SourceError> { + let mut unique = BTreeSet::new(); + for configured_set in configured { + if configured_set.is_empty() || configured_set.len() > inputs.len() { + return Err(SourceError::InvalidPlan); + } + let mut set = configured_set.clone(); + set.sort(); + let mut roles = BTreeSet::new(); + for (role, profile) in &set { + if !roles.insert(role) + || !inputs + .get(role) + .is_some_and(|profiles| profiles.contains_key(profile)) + { + return Err(SourceError::InvalidPlan); + } + } + if !unique.insert(set) { + return Err(SourceError::InvalidPlan); + } + } + if unique.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(unique.into_iter().collect()) +} + +/// Refuse any allowed selector set that cannot fill the path template. +/// +/// `materialize_url` resolves each placeholder against the set the request +/// actually activated, so a binding is mandatory per set, not across their +/// union. A set missing one has no value to substitute and fails every request +/// it serves. That set exists because an authority grant produced it, which is +/// a startup fact, so refuse it at startup rather than per request. +fn validate_bindings_are_reachable( + path: &SourcePath, + allowed: &[SourceSelectorSet], +) -> Result<(), SourceError> { + let SourcePath::Template { bindings, .. } = path else { + return Ok(()); + }; + for set in allowed { + let activated = set + .iter() + .map(|(role, profile)| (role.as_str(), profile.as_str())) + .collect::>(); + if bindings + .values() + .any(|binding| !activated.contains(&(binding.role.as_str(), binding.profile.as_str()))) + { + return Err(SourceError::InvalidPlan); + } + } + Ok(()) +} + +fn compile_source_path( + request: &FixedRequest, + inputs: &BTreeMap>>, +) -> Result { + match (&request.path, &request.path_template) { + (Some(path), None) if request.path_bindings.is_empty() => { + validate_request_path(path)?; + Ok(SourcePath::Fixed(path.clone())) + } + (None, Some(template)) => { + validate_template_shape(template, &request.path_bindings)?; + let mut bindings = BTreeMap::new(); + for (name, binding) in request.path_bindings.iter() { + validate_path_binding(binding, inputs)?; + bindings.insert( + name.to_owned(), + PathBindingPlan { + role: binding.role.clone(), + profile: binding.profile.clone(), + field: binding.field.clone(), + }, + ); + } + Ok(SourcePath::Template { + template: template.clone(), + bindings, + }) + } + _ => Err(SourceError::InvalidPlan), + } +} + +fn validate_path_binding( + binding: &PathBindingConfig, + inputs: &BTreeMap>>, +) -> Result<(), SourceError> { + if inputs + .get(&binding.role) + .and_then(|profiles| profiles.get(&binding.profile)) + .is_some_and(|fields| fields.contains(&binding.field)) + { + Ok(()) + } else { + Err(SourceError::InvalidPlan) + } +} + +fn validate_template_shape( + template: &str, + bindings: &crate::config::OrderedMap, +) -> Result<(), SourceError> { + if !template.starts_with('/') + || template.starts_with("//") + || template.contains(['?', '#', '\\']) + { + return Err(SourceError::InvalidPlan); + } + let mut names = BTreeSet::new(); + for segment in template.split('/').skip(1) { + if segment.is_empty() || matches!(segment, "." | "..") { + return Err(SourceError::InvalidPlan); + } + if let Some(name) = segment + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + { + if name.is_empty() || !names.insert(name) { + return Err(SourceError::InvalidPlan); + } + } else if segment.contains(['{', '}']) { + return Err(SourceError::InvalidPlan); + } + } + if names == bindings.keys().collect::>() { + Ok(()) + } else { + Err(SourceError::InvalidPlan) + } +} + +fn compile_fixed_headers( + request: &FixedRequest, + authentication: &AuthenticationPlan, +) -> Result { + let mut headers = HeaderMap::new(); + for fixed in &request.fixed_headers { + if reserved_configured_header(&fixed.name) { + return Err(SourceError::InvalidPlan); + } + let name = + HeaderName::from_bytes(fixed.name.as_bytes()).map_err(|_| SourceError::InvalidPlan)?; + let value = HeaderValue::from_str(&fixed.value).map_err(|_| SourceError::InvalidPlan)?; + if headers.insert(name, value).is_some() { + return Err(SourceError::InvalidPlan); + } + } + let authentication_name = match authentication { + AuthenticationPlan::StaticApiKey { header_name, .. } => header_name, + _ => &AUTHORIZATION, + }; + if headers.contains_key(authentication_name) { + return Err(SourceError::InvalidPlan); + } + Ok(headers) +} + +/// Reject a bundle-configured header name before any credential is resolved. +/// +/// Startup configuration validation already rejects these names. This is the +/// defensive second check on the request path, and it deliberately calls the +/// one shared closed classifier so the two deny sets cannot drift apart. +fn reserved_configured_header(name: &str) -> bool { + crate::config::is_reserved_header_name(name) +} + +fn compile_authentication( + authentication: &SourceAuthentication, + admission_timeout: Duration, +) -> Result { + match authentication { + SourceAuthentication::None {} => Ok(AuthenticationPlan::None), + SourceAuthentication::Basic { + username_ref, + password_ref, + } => Ok(AuthenticationPlan::Basic { + username_ref: username_ref.clone(), + password_ref: password_ref.clone(), + }), + SourceAuthentication::StaticBearer { token_ref } => Ok(AuthenticationPlan::StaticBearer { + token_ref: token_ref.clone(), + }), + SourceAuthentication::StaticApiKey { + header_name, + value_ref, + } => { + if reserved_configured_header(header_name) { + return Err(SourceError::InvalidPlan); + } + Ok(AuthenticationPlan::StaticApiKey { + header_name: HeaderName::from_bytes(header_name.as_bytes()) + .map_err(|_| SourceError::InvalidPlan)?, + value_ref: value_ref.clone(), + }) + } + SourceAuthentication::Oauth2ClientCredentials { + token_endpoint, + client_id_ref, + client_secret_ref, + scope, + credential_placement, + maximum_cache_seconds, + assumed_lifetime_seconds, + } => { + let token_endpoint = validate_url(token_endpoint, false)?; + if token_endpoint.query().is_some() { + return Err(SourceError::InvalidPlan); + } + Ok(AuthenticationPlan::Oauth2(Box::new(OauthPlan { + token_endpoint, + client_id_ref: client_id_ref.clone(), + client_secret_ref: client_secret_ref.clone(), + scope: scope.clone(), + credential_placement: *credential_placement, + maximum_cache_lifetime: Duration::from_secs(*maximum_cache_seconds), + assumed_lifetime: assumed_lifetime_seconds.map(Duration::from_secs), + admission_timeout, + cache: Mutex::new(None), + }))) + } + } +} + +impl RequestPlan { + fn validate_selectors<'a>( + &self, + selectors: &'a [ResolvedSourceSelector], + ) -> Result, SourceError> { + let mut index = BTreeMap::new(); + let mut active = Vec::new(); + let mut roles = BTreeSet::new(); + for selector in selectors { + if !roles.insert(selector.role.as_str()) + || index + .insert( + (selector.role.as_str(), selector.profile.as_str()), + selector, + ) + .is_some() + { + return Err(SourceError::InvalidSelectors); + } + let fields = self + .selector_inputs + .get(&selector.role) + .and_then(|profiles| profiles.get(&selector.profile)) + .ok_or(SourceError::InvalidSelectors)?; + if selector.values.keys().collect::>() + != fields.iter().collect::>() + { + return Err(SourceError::InvalidSelectors); + } + active.push((selector.role.clone(), selector.profile.clone())); + } + active.sort(); + if !self.allowed_selector_sets.contains(&active) { + return Err(SourceError::InvalidSelectors); + } + Ok(index) + } + + fn materialize_url( + &self, + selectors: &BTreeMap<(&str, &str), &ResolvedSourceSelector>, + parts: &RequestParts, + ) -> Result { + let path = match &self.path { + SourcePath::Fixed(path) => path.clone(), + SourcePath::Template { template, bindings } => { + let mut rendered = String::new(); + for segment in template.split('/').skip(1) { + rendered.push('/'); + if let Some(name) = segment + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + { + let binding = bindings.get(name).ok_or(SourceError::InvalidPlan)?; + let selector = selectors + .get(&(binding.role.as_str(), binding.profile.as_str())) + .ok_or(SourceError::InvalidSelectors)?; + let value = selector + .values + .get(&binding.field) + .ok_or(SourceError::InvalidSelectors)?; + rendered.push_str(&encode_path_selector(value)?); + } else { + rendered.push_str(segment); + } + } + rendered + } + }; + let mut url = join_source_path(&self.base_url, &path)?; + if !parts.query.is_empty() { + let mut query = String::new(); + for pair in &parts.query { + if pair.name.is_empty() + || pair.name.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + || pair.value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + { + return Err(SourceError::InvalidPlan); + } + if !query.is_empty() { + query.push('&'); + } + encode_query_component(&pair.name, &mut query); + query.push('='); + encode_query_component(&pair.value, &mut query); + } + url.set_query(Some(&query)); + } + Ok(url) + } +} + +fn encode_query_component(value: &str, output: &mut String) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(char::from(byte)); + } else { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } +} + +fn encode_path_selector(value: &SelectorValue) -> Result { + let text = match value { + SelectorValue::String(value) => value.clone(), + SelectorValue::Integer(value) => value.to_string(), + SelectorValue::Boolean(value) => value.to_string(), + }; + if text.is_empty() + || matches!(text.as_str(), "." | "..") + || text.chars().any(char::is_control) + || text.contains(['/', '\\', '%']) + { + return Err(SourceError::InvalidSelectors); + } + let mut encoded = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(&mut encoded, "%{byte:02X}").map_err(|_| SourceError::InvalidSelectors)?; + } + } + Ok(encoded) +} + +impl OauthPlan { + async fn access_token( + &self, + client: &reqwest::Client, + secrets: &SecretResolver, + ) -> Result { + let mut cache = tokio::time::timeout(self.admission_timeout, self.cache.lock()) + .await + .map_err(|_| SourceError::Timeout)?; + let now = Instant::now(); + if let Some(cached) = cache.as_ref() { + if cached.expires_at > now { + return Ok(cached.token.clone()); + } + } + *cache = None; + let client_id = resolve(secrets, &self.client_id_ref)?; + let client_secret = resolve(secrets, &self.client_secret_ref)?; + let client_id_text = protected_text(&client_id)?; + let client_secret_text = protected_text(&client_secret)?; + let mut form = vec![("grant_type", "client_credentials")]; + if let Some(scope) = self.scope.as_deref() { + form.push(("scope", scope)); + } + // Both placements keep the client credentials out of the request URI, + // where proxy and ingress logs would capture them. + let mut request = client + .post(self.token_endpoint.clone()) + .header(ACCEPT, JSON_MEDIA_TYPE); + match self.credential_placement { + CredentialPlacement::BasicHeader => { + request = request.header( + AUTHORIZATION, + basic_authorization(&client_id, &client_secret)?, + ); + } + CredentialPlacement::FormBody => { + form.push(("client_id", client_id_text)); + form.push(("client_secret", client_secret_text)); + } + } + request = request.form(&form); + drop(form); + drop(client_id); + drop(client_secret); + let response = request.send().await.map_err(map_transport_error)?; + let (token, lifetime) = + parse_token_response(response, self.scope.as_deref(), self.assumed_lifetime).await?; + let cache_lifetime = lifetime.min(self.maximum_cache_lifetime); + if !cache_lifetime.is_zero() { + let expires_at = Instant::now() + .checked_add(cache_lifetime) + .ok_or(SourceError::Credential)?; + *cache = Some(CachedToken { + token: token.clone(), + expires_at, + }); + } + Ok(token) + } +} + +fn compile_projection(paths: &[String]) -> Result { + if paths.is_empty() { + return Err(SourceError::InvalidPlan); + } + let mut root = ProjectionNode::default(); + for path in paths { + let segments = parse_projection_pointer(path)?; + let mut node = &mut root; + for segment in segments { + if node.terminal { + return Err(SourceError::InvalidPlan); + } + match segment { + ProjectionSegment::Key(key) => { + if node.wildcard.is_some() { + return Err(SourceError::InvalidPlan); + } + node = node.keys.entry(key).or_default(); + } + ProjectionSegment::Wildcard => { + if !node.keys.is_empty() { + return Err(SourceError::InvalidPlan); + } + node = node + .wildcard + .get_or_insert_with(|| Box::new(ProjectionNode::default())); + } + } + } + if node.terminal || !node.keys.is_empty() || node.wildcard.is_some() { + return Err(SourceError::InvalidPlan); + } + node.terminal = true; + } + Ok(root) +} + +enum ProjectionSegment { + Key(String), + Wildcard, +} + +fn parse_projection_pointer(pointer: &str) -> Result, SourceError> { + if !pointer.starts_with('/') + || pointer.starts_with("//") + || pointer.chars().any(char::is_control) + { + return Err(SourceError::InvalidPlan); + } + pointer[1..] + .split('/') + .map(|raw| { + if raw.is_empty() { + return Err(SourceError::InvalidPlan); + } + if raw == "*" { + return Ok(ProjectionSegment::Wildcard); + } + let mut decoded = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(character) = chars.next() { + if character == '~' { + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return Err(SourceError::InvalidPlan), + } + } else { + decoded.push(character); + } + } + if decoded.is_empty() || decoded.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(SourceError::InvalidPlan); + } + Ok(ProjectionSegment::Key(decoded)) + }) + .collect() +} + +fn project_value(value: &JsonValue, node: &ProjectionNode) -> Result { + if node.terminal { + return Ok(value.clone()); + } + if let Some(wildcard) = &node.wildcard { + let array = value.as_array().ok_or(SourceError::ProjectionViolation)?; + return array + .iter() + .map(|item| project_value(item, wildcard)) + .collect::, _>>() + .map(JsonValue::Array); + } + let object = value.as_object().ok_or(SourceError::ProjectionViolation)?; + let mut projected = JsonMap::new(); + for (key, child) in &node.keys { + match object.get(key) { + Some(value) => { + projected.insert(key.clone(), project_value(value, child)?); + } + None if child.terminal => {} + None => return Err(SourceError::ProjectionViolation), + } + } + Ok(JsonValue::Object(projected)) +} + +fn resolve( + resolver: &SecretResolver, + reference: &SecretRef, +) -> Result { + resolver + .resolve(reference.as_str()) + .map_err(|_| SourceError::Credential) +} + +fn protected_text(secret: &ProtectedSecret) -> Result<&str, SourceError> { + std::str::from_utf8(secret.expose_secret()).map_err(|_| SourceError::Credential) +} + +fn basic_authorization( + username: &ProtectedSecret, + password: &ProtectedSecret, +) -> Result { + if username.is_empty() || password.is_empty() || username.expose_secret().contains(&b':') { + return Err(SourceError::Credential); + } + let mut joined = Zeroizing::new(Vec::with_capacity(username.len() + password.len() + 1)); + joined.extend_from_slice(username.expose_secret()); + joined.push(b':'); + joined.extend_from_slice(password.expose_secret()); + let encoded = + Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(joined.as_slice())); + let mut header = Zeroizing::new(Vec::with_capacity(6 + encoded.len())); + header.extend_from_slice(b"Basic "); + header.extend_from_slice(encoded.as_bytes()); + sensitive_header(&header) +} + +fn bearer_authorization(token: &[u8]) -> Result { + if token.is_empty() { + return Err(SourceError::Credential); + } + let mut header = Zeroizing::new(Vec::with_capacity(7 + token.len())); + header.extend_from_slice(b"Bearer "); + header.extend_from_slice(token); + sensitive_header(&header) +} + +fn sensitive_header(bytes: &[u8]) -> Result { + let mut value = HeaderValue::from_bytes(bytes).map_err(|_| SourceError::Credential)?; + value.set_sensitive(true); + Ok(value) +} + +async fn parse_data_response( + response: reqwest::Response, + maximum_bytes: u64, + _posture: AcquisitionPosture, + projection: &ProjectionNode, +) -> Result { + reject_response_status(&response)?; + let media_type = response_media_type(&response)?; + if media_type != JSON_MEDIA_TYPE && media_type != GRAPHQL_JSON_MEDIA_TYPE { + return Err(SourceError::WrongMediaType); + } + let bytes = Zeroizing::new( + read_bounded(response, maximum_bytes) + .await + .map_err(map_bounded_read_error)?, + ); + let value = parse_strict_json(&bytes)?; + drop(bytes); + if value + .as_object() + .is_some_and(|object| object.contains_key("errors")) + { + return Err(SourceError::ErrorEnvelope); + } + let projected = project_value(&value, projection)?; + if serde_json::to_vec(&projected) + .map_err(|_| SourceError::ProjectionViolation)? + .len() + > PROJECTED_RESPONSE_MAXIMUM_BYTES + { + return Err(SourceError::ResponseTooLarge); + } + Ok(projected) +} + +async fn parse_token_response( + response: reqwest::Response, + expected_scope: Option<&str>, + assumed_lifetime: Option, +) -> Result<(ProtectedToken, Duration), SourceError> { + // `reject_response_status` classifies only redirect/status outcomes, never a + // timeout, so every rejection here is a credential-exchange failure. + reject_response_status(&response).map_err(|_| SourceError::Credential)?; + if response_media_type(&response).map_err(|_| SourceError::Credential)? != JSON_MEDIA_TYPE { + return Err(SourceError::Credential); + } + let bytes = Zeroizing::new( + read_bounded(response, TOKEN_RESPONSE_MAXIMUM_BYTES) + .await + .map_err(|_| SourceError::Credential)?, + ); + let mut object = parse_strict_json(&bytes) + .map_err(|_| SourceError::Credential)? + .as_object() + .cloned() + .ok_or(SourceError::Credential)?; + drop(bytes); + let access_token = match object.remove("access_token") { + Some(JsonValue::String(value)) => value, + _ => return Err(SourceError::Credential), + }; + let token_type = match object.remove("token_type") { + Some(JsonValue::String(value)) => value, + _ => return Err(SourceError::Credential), + }; + if !token_type.eq_ignore_ascii_case("bearer") { + return Err(SourceError::Credential); + } + // RFC 6749 section 5.1 makes `expires_in` recommended rather than required. + // A provider that omits it is accepted only when the bundle states the + // lifetime to assume; a present but unusable value is never rescued by it. + let lifetime = match object.remove("expires_in") { + Some(JsonValue::Number(value)) => Duration::from_secs( + value + .as_u64() + .filter(|seconds| *seconds > 0) + .ok_or(SourceError::Credential)?, + ), + Some(_) => return Err(SourceError::Credential), + None => assumed_lifetime.ok_or(SourceError::Credential)?, + }; + if let Some(scope) = object.remove("scope") { + let JsonValue::String(scope) = scope else { + return Err(SourceError::Credential); + }; + if scope.is_empty() + || scope.len() > 512 + || expected_scope.is_some_and(|expected| scope != expected) + { + return Err(SourceError::Credential); + } + } + // RFC 6749 section 5.1 permits members beyond the ones it defines, and + // deployed authorization servers send them: `refresh_expires_in` and + // `not-before-policy` from Keycloak, `ext_expires_in` from Entra ID. + // Refusing them would refuse the providers Evidence is documented against. + // + // Ignoring is not trusting. Nothing here reads an unread member, no script + // ever sees the token response, and `parse_strict_json` refuses duplicate + // members, so an extension cannot arrive as a second `access_token`. What + // remains is that one of them may carry credential material of its own, so + // scrub the strings before the map is dropped rather than leaving them for + // the allocator, the same reason the response bytes are zeroized above. + for (_, value) in &mut object { + if let JsonValue::String(value) = value { + value.zeroize(); + } + } + drop(object); + Ok((ProtectedToken::from_string(access_token)?, lifetime)) +} + +fn reject_response_status(response: &reqwest::Response) -> Result<(), SourceError> { + let status = response.status(); + if status.is_redirection() { + return Err(SourceError::Redirect); + } + if status.is_success() { + return Ok(()); + } + Err(SourceError::Status(match status.as_u16() { + 401 => SourceStatus::Unauthorized, + 403 => SourceStatus::Forbidden, + 429 => SourceStatus::RateLimited, + 500..=599 => SourceStatus::ServerError, + _ => SourceStatus::Other, + })) +} + +fn response_media_type(response: &reqwest::Response) -> Result<&str, SourceError> { + let mut values = response.headers().get_all(CONTENT_TYPE).iter(); + let value = values.next().ok_or(SourceError::WrongMediaType)?; + if values.next().is_some() { + return Err(SourceError::WrongMediaType); + } + let value = value.to_str().map_err(|_| SourceError::WrongMediaType)?; + let media_type = value.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case(JSON_MEDIA_TYPE) { + Ok(JSON_MEDIA_TYPE) + } else if media_type.eq_ignore_ascii_case(GRAPHQL_JSON_MEDIA_TYPE) { + Ok(GRAPHQL_JSON_MEDIA_TYPE) + } else { + Ok(media_type) + } +} + +fn map_transport_error(error: reqwest::Error) -> SourceError { + if error.is_timeout() { + SourceError::Timeout + } else { + SourceError::Transport + } +} + +fn map_bounded_read_error(error: BoundedReadError) -> SourceError { + match error { + BoundedReadError::Transport(error) => map_transport_error(error), + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => SourceError::ResponseTooLarge, + _ => SourceError::Transport, + } +} + +fn validate_url(value: &str, origin_only: bool) -> Result { + let url = Url::parse(value).map_err(|_| SourceError::InvalidPlan)?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(SourceError::InvalidPlan); + } + if origin_only && (url.path() != "/" || url.query().is_some()) { + return Err(SourceError::InvalidPlan); + } + match url.scheme() { + "https" if url.host().is_some() => {} + "http" => match url.host() { + Some(Host::Ipv4(ip)) if ip.is_loopback() => {} + Some(Host::Ipv6(ip)) if ip == std::net::Ipv6Addr::LOCALHOST => {} + _ => return Err(SourceError::InvalidPlan), + }, + _ => return Err(SourceError::InvalidPlan), + } + Ok(url) +} + +fn validate_request_path(path: &str) -> Result<(), SourceError> { + if path.len() < 2 + || !path.starts_with('/') + || path.starts_with("//") + || path.contains(['?', '#', '\\']) + || path + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + return Err(SourceError::InvalidPlan); + } + Ok(()) +} + +fn join_source_path(base: &Url, path: &str) -> Result { + validate_request_path(path)?; + let value = format!("{}{}", base.as_str().trim_end_matches('/'), path); + let url = Url::parse(&value).map_err(|_| SourceError::InvalidPlan)?; + if url.scheme() != base.scheme() + || url.host() != base.host() + || url.port_or_known_default() != base.port_or_known_default() + { + return Err(SourceError::InvalidPlan); + } + Ok(url) +} + +struct StrictJson(JsonValue); + +impl<'de> Deserialize<'de> for StrictJson { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(StrictJsonVisitor) + } +} + +struct StrictJsonVisitor; + +impl<'de> Visitor<'de> for StrictJsonVisitor { + type Value = StrictJson; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a duplicate-free JSON value") + } + fn visit_bool(self, value: bool) -> Result { + Ok(StrictJson(JsonValue::Bool(value))) + } + fn visit_i64(self, value: i64) -> Result { + Ok(StrictJson(JsonValue::Number(value.into()))) + } + fn visit_u64(self, value: u64) -> Result { + Ok(StrictJson(JsonValue::Number(value.into()))) + } + fn visit_f64(self, value: f64) -> Result { + JsonNumber::from_f64(value) + .map(JsonValue::Number) + .map(StrictJson) + .ok_or_else(|| de::Error::custom("invalid JSON number")) + } + fn visit_str(self, value: &str) -> Result { + self.visit_string(value.to_owned()) + } + fn visit_string(self, value: String) -> Result { + Ok(StrictJson(JsonValue::String(value))) + } + fn visit_none(self) -> Result { + Ok(StrictJson(JsonValue::Null)) + } + fn visit_unit(self) -> Result { + Ok(StrictJson(JsonValue::Null)) + } + fn visit_some>(self, deserializer: D) -> Result { + StrictJson::deserialize(deserializer) + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); + while let Some(value) = sequence.next_element::()? { + values.push(value.0); + } + Ok(StrictJson(JsonValue::Array(values))) + } + fn visit_map>(self, mut mapping: A) -> Result { + let mut object = JsonMap::new(); + while let Some((key, value)) = mapping.next_entry::()? { + if object.insert(key, value.0).is_some() { + return Err(de::Error::custom("duplicate JSON object member")); + } + } + Ok(StrictJson(JsonValue::Object(object))) + } +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictJson::deserialize(&mut deserializer).map_err(|_| SourceError::InvalidJson)?; + deserializer.end().map_err(|_| SourceError::InvalidJson)?; + Ok(value.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn projection_supports_nested_arrays_escapes_and_literal_dots() { + let plan = compile_projection(&[ + "/total".into(), + "/results/*/declaration/mother.personReference".into(), + "/results/*/a~1b/~0value".into(), + ]) + .expect("projection compiles"); + let input = json!({ + "total": 1, + "ignored": "gone", + "results": [{ + "declaration": {"mother.personReference": "P-1", "private": "gone"}, + "a/b": {"~value": true, "ignored": false} + }] + }); + assert_eq!( + project_value(&input, &plan), + Ok(json!({ + "total": 1, + "results": [{ + "declaration": {"mother.personReference": "P-1"}, + "a/b": {"~value": true} + }] + })) + ); + } + + #[test] + fn projection_omits_missing_leaves_but_rejects_missing_or_mistyped_intermediates() { + let plan = + compile_projection(&["/results/*/optional".into()]).expect("projection compiles"); + assert_eq!( + project_value(&json!({"results": [{}]}), &plan), + Ok(json!({"results": [{}]})) + ); + assert_eq!( + project_value(&json!({}), &plan), + Err(SourceError::ProjectionViolation) + ); + assert_eq!( + project_value(&json!({"results": {}}), &plan), + Err(SourceError::ProjectionViolation) + ); + } + + #[test] + fn projection_rejects_duplicates_conflicts_indexes_and_mixed_container_shapes() { + for paths in [ + vec!["/a".into(), "/a".into()], + vec!["/a".into(), "/a/b".into()], + vec!["/a/0".into()], + vec!["/a/*/x".into(), "/a/b".into()], + ] { + assert!(compile_projection(&paths).is_err()); + } + } + + #[test] + fn path_selector_encoding_is_single_pass_and_hostile_values_fail_closed() { + assert_eq!( + encode_path_selector(&SelectorValue::String("A B".into())), + Ok("A%20B".into()) + ); + for value in [".", "..", "a/b", "a\\b", "a%2Fb", "a\nb"] { + assert_eq!( + encode_path_selector(&SelectorValue::String(value.into())), + Err(SourceError::InvalidSelectors) + ); + } + } + + #[test] + fn duplicate_json_members_are_rejected() { + assert_eq!( + parse_strict_json(br#"{"a":1,"a":2}"#), + Err(SourceError::InvalidJson) + ); + } + + #[tokio::test] + async fn saturated_source_admission_fails_at_the_configured_timeout() { + let server = wiremock::MockServer::start().await; + let source: SourceConfig = serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": server.uri(), + "posture": "source-derived", + "authentication": { + "kind": "static-bearer", + "tokenRef": "secret:file/missing-source-token" + }, + "request": { + "method": "POST", + "path": "/data", + "fixedHeaders": [], + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "forbidden", + "jsonBody": "required", + "maximumJsonDepth": 4, + "maximumCollectionItems": 4, + "maximumStringBytes": 64, + "maximumNormalizedBytes": 1024 + }, + "projection": ["/ok"], + "redirects": "deny", + "timeoutMilliseconds": 20, + "maximumResponseBytes": 1024, + "concurrencyLimit": 1 + }, + "responseSchema": "schemas/response.schema.yaml", + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes"); + let secret_root = tempfile::tempdir().expect("temporary secret root"); + let secrets = Arc::new( + SecretResolver::new([crate::secrets::SecretProvider::File], secret_root.path()) + .expect("secret resolver builds"), + ); + let executor = SourceExecutor::new(&source, secrets).expect("source executor builds"); + let _occupied = executor + .concurrency + .acquire() + .await + .expect("source slot is available"); + let selectors = [ResolvedSourceSelector { + role: "subject".into(), + profile: "record-v1".into(), + values: BTreeMap::from([( + "record_id".into(), + SelectorValue::String("synthetic-record".into()), + )]), + }]; + let started = Instant::now(); + assert!(matches!( + executor + .execute( + &selectors, + &RequestParts { + query: Vec::new(), + body: Some(json!({"requested": true})), + }, + ) + .await, + Err(SourceError::Timeout) + )); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + } + + /// Spins up a TLS server whose certificate is signed by a private + /// certificate authority that the client under test is never told to + /// trust. The resulting handshake failure is specific to whichever TLS + /// backend the client actually uses, which makes it a proof that the + /// backend selected in `build_client` is the one in effect at runtime. + async fn spawn_untrusted_tls_server( + server_subject_alt_name: &str, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let mut ca_parameters = rcgen::CertificateParams::new(Vec::::new()) + .expect("private CA parameters are valid"); + ca_parameters.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca_key = rcgen::KeyPair::generate().expect("private CA key generates"); + let ca_certificate = ca_parameters + .self_signed(&ca_key) + .expect("private CA certificate generates"); + + let server_parameters = + rcgen::CertificateParams::new(vec![server_subject_alt_name.to_owned()]) + .expect("server certificate parameters are valid"); + let server_key = rcgen::KeyPair::generate().expect("server key generates"); + let server_certificate = server_parameters + .signed_by(&server_key, &ca_certificate, &ca_key) + .expect("private CA signs server certificate"); + let private_key = tokio_rustls::rustls::pki_types::PrivateKeyDer::Pkcs8( + tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from(server_key.serialize_der()), + ); + let server_config = tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![server_certificate.der().clone()], private_key) + .expect("TLS server configuration builds"); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("TLS test server binds"); + let address = listener.local_addr().expect("TLS server address"); + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + // The client is expected to abort the handshake once it + // evaluates the certificate chain, so no request or response + // handling is needed here. + let _ = acceptor.accept(stream).await; + }); + (address, handle) + } + + #[tokio::test] + async fn evidence_client_uses_rustls_and_fails_closed_on_an_unrecognized_certificate_authority() + { + let (address, _server) = spawn_untrusted_tls_server("127.0.0.1").await; + let source: SourceConfig = serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": format!("https://{address}"), + "posture": "source-derived", + "authentication": { + "kind": "static-bearer", + "tokenRef": "secret:file/missing-source-token" + }, + "request": { + "method": "POST", + "path": "/data", + "fixedHeaders": [], + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "forbidden", + "jsonBody": "required", + "maximumJsonDepth": 4, + "maximumCollectionItems": 4, + "maximumStringBytes": 64, + "maximumNormalizedBytes": 1024 + }, + "projection": ["/ok"], + "redirects": "deny", + "timeoutMilliseconds": 2000, + "maximumResponseBytes": 1024, + "concurrencyLimit": 1 + }, + "responseSchema": "schemas/response.schema.yaml", + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes"); + let client = build_client(Duration::from_secs(5), &source, None).expect("client builds"); + let error = client + .get(format!("https://{address}/")) + .send() + .await + .expect_err("an unrecognized certificate authority is rejected"); + let mut messages = Vec::new(); + let mut current: &dyn std::error::Error = &error; + while let Some(source) = current.source() { + messages.push(source.to_string()); + current = source; + } + assert!( + messages + .iter() + .any(|message| message.contains("invalid peer certificate")), + "expected a rustls certificate-validation error in the source chain, got: {messages:?}" + ); + } + + #[tokio::test] + async fn saturated_oauth_single_flight_fails_before_credentials_or_transport() { + let secret_root = tempfile::tempdir().expect("temporary secret root"); + let secrets = + SecretResolver::new([crate::secrets::SecretProvider::File], secret_root.path()) + .expect("secret resolver builds"); + let plan = OauthPlan { + token_endpoint: Url::parse("http://127.0.0.1:1/token") + .expect("synthetic endpoint parses"), + client_id_ref: SecretRef::parse("secret:file/missing-client-id") + .expect("secret reference parses"), + client_secret_ref: SecretRef::parse("secret:file/missing-client-secret") + .expect("secret reference parses"), + scope: Some("fixture.read".into()), + credential_placement: CredentialPlacement::FormBody, + maximum_cache_lifetime: Duration::from_secs(60), + assumed_lifetime: None, + admission_timeout: Duration::from_millis(20), + cache: Mutex::new(None), + }; + let _occupied = plan.cache.lock().await; + let client = reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client builds"); + let started = Instant::now(); + let result = + tokio::time::timeout(Duration::from_secs(1), plan.access_token(&client, &secrets)) + .await + .expect("OAuth admission is bounded by its configured timeout"); + assert!(matches!(result, Err(SourceError::Timeout))); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} diff --git a/crates/registry-evidence/src/values.rs b/crates/registry-evidence/src/values.rs new file mode 100644 index 000000000..53edb0814 --- /dev/null +++ b/crates/registry-evidence/src/values.rs @@ -0,0 +1,259 @@ +//! Core-owned exact and protected values crossing the Rhai boundary. + +use std::{cmp::Ordering, fmt, str::FromStr}; + +use serde::{Serialize, Serializer}; +use thiserror::Error; +use zeroize::Zeroizing; + +const MAX_DECIMAL_PRECISION: usize = 28; +const MAX_DECIMAL_SCALE: u32 = 9; +const MAX_ENTITY_SEED_BYTES: usize = 512; + +#[derive(Clone, PartialEq, Eq)] +pub struct Decimal { + coefficient: i128, + scale: u32, + canonical: String, +} + +impl Decimal { + pub fn parse(input: &str) -> Result { + if input.is_empty() + || input.starts_with('+') + || input.contains(['e', 'E']) + || matches!(input, "NaN" | "Infinity" | "-Infinity") + { + return Err(DecimalError::Lexical); + } + + let (negative, unsigned) = match input.strip_prefix('-') { + Some(unsigned) => (true, unsigned), + None => (false, input), + }; + if unsigned.is_empty() { + return Err(DecimalError::Lexical); + } + let (integer, fraction) = match unsigned.split_once('.') { + Some((integer, fraction)) => (integer, Some(fraction)), + None => (unsigned, None), + }; + + if integer.is_empty() + || !integer.bytes().all(|byte| byte.is_ascii_digit()) + || (integer.len() > 1 && integer.starts_with('0')) + { + return Err(DecimalError::Lexical); + } + let scale = match fraction { + Some(fraction) + if !fraction.is_empty() + && fraction.bytes().all(|byte| byte.is_ascii_digit()) + && !fraction.ends_with('0') => + { + u32::try_from(fraction.len()).map_err(|_| DecimalError::Scale)? + } + Some(_) => return Err(DecimalError::Lexical), + None => 0, + }; + if scale > MAX_DECIMAL_SCALE { + return Err(DecimalError::Scale); + } + + let digits = match fraction { + Some(fraction) => format!("{integer}{fraction}"), + None => integer.to_owned(), + }; + let significant = digits.trim_start_matches('0'); + let precision = significant.len().max(1); + if precision > MAX_DECIMAL_PRECISION { + return Err(DecimalError::Precision); + } + let magnitude = if significant.is_empty() { + 0 + } else { + i128::from_str(significant).map_err(|_| DecimalError::Precision)? + }; + if magnitude == 0 && (negative || scale != 0) { + return Err(DecimalError::Zero); + } + let coefficient = if negative { -magnitude } else { magnitude }; + + Ok(Self { + coefficient, + scale, + canonical: input.to_owned(), + }) + } + + pub fn from_integer(value: i64) -> Self { + Self { + coefficient: i128::from(value), + scale: 0, + canonical: value.to_string(), + } + } + + pub fn canonical(&self) -> &str { + &self.canonical + } + + pub fn scale(&self) -> u32 { + self.scale + } + + pub fn compare(&self, other: &Self) -> Ordering { + match self.scale.cmp(&other.scale) { + Ordering::Equal => self.coefficient.cmp(&other.coefficient), + Ordering::Less => self + .coefficient + .saturating_mul(power_of_ten(other.scale - self.scale)) + .cmp(&other.coefficient), + Ordering::Greater => self.coefficient.cmp( + &other + .coefficient + .saturating_mul(power_of_ten(self.scale - other.scale)), + ), + } + } +} + +impl fmt::Debug for Decimal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Decimal([REDACTED])") + } +} + +impl Serialize for Decimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.canonical) + } +} + +impl FromStr for Decimal { + type Err = DecimalError; + + fn from_str(input: &str) -> Result { + Self::parse(input) + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum DecimalError { + #[error("decimal text is not canonical")] + Lexical, + #[error("decimal zero must be represented as 0")] + Zero, + #[error("decimal precision exceeds the supported bound")] + Precision, + #[error("decimal scale exceeds the supported bound")] + Scale, +} + +fn power_of_ten(exponent: u32) -> i128 { + 10_i128.pow(exponent) +} + +pub struct EntityReferenceSeed(Zeroizing>); + +impl EntityReferenceSeed { + pub fn new(input: &str) -> Result { + let bytes = input.as_bytes(); + if bytes.is_empty() || bytes.len() > MAX_ENTITY_SEED_BYTES { + return Err(EntityReferenceSeedError); + } + Ok(Self(Zeroizing::new(bytes.to_vec()))) + } + + pub(crate) fn expose_for_projection(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl Clone for EntityReferenceSeed { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.to_vec())) + } +} + +impl fmt::Debug for EntityReferenceSeed { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EntityReferenceSeed()") + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("entity-reference seed is empty or exceeds its bound")] +pub struct EntityReferenceSeedError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_decimals_round_trip_as_json_strings() { + for text in [ + "0", + "1", + "-1", + "0.25", + "-10.5", + "1234567890123456789012345678", + "0.000000001", + ] { + let value = Decimal::parse(text).expect("decimal parses"); + assert_eq!(value.canonical(), text); + assert_eq!( + serde_json::to_string(&value).expect("serializes"), + format!("\"{text}\"") + ); + } + } + + #[test] + fn noncanonical_or_excessive_decimals_are_rejected() { + for text in [ + "", + "+1", + "01.0", + "1.0", + "1.", + ".1", + "-0", + "-0.0", + "0.0", + "1e2", + "NaN", + "12345678901234567890123456789", + "0.1234567891", + ] { + assert!(Decimal::parse(text).is_err(), "{text} must be rejected"); + } + } + + #[test] + fn decimal_comparison_is_exact_across_scales() { + let one = Decimal::parse("1").expect("parses"); + let one_point_five = Decimal::parse("1.5").expect("parses"); + let negative_tenth = Decimal::parse("-0.1").expect("parses"); + assert_eq!(one.compare(&one_point_five), Ordering::Less); + assert_eq!(one_point_five.compare(&one), Ordering::Greater); + assert_eq!(negative_tenth.compare(&one), Ordering::Less); + } + + #[test] + fn entity_seed_debug_is_redacted() { + let seed = EntityReferenceSeed::new("protected-canary").expect("seed builds"); + let debug = format!("{seed:?}"); + assert!(!debug.contains("protected-canary")); + assert_eq!(debug, "EntityReferenceSeed()"); + + let decimal = Decimal::parse("8192.125").expect("decimal parses"); + let debug = format!("{decimal:?}"); + assert!(!debug.contains("8192.125")); + assert_eq!(debug, "Decimal([REDACTED])"); + } +} diff --git a/crates/registry-evidence/src/verifier.rs b/crates/registry-evidence/src/verifier.rs new file mode 100644 index 000000000..619cd4435 --- /dev/null +++ b/crates/registry-evidence/src/verifier.rs @@ -0,0 +1,1978 @@ +//! Strict verifier for the Evidence Version 1 flattened JWS profile and for +//! the SD-JWT VC profile that projects the same payload. + +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use registry_platform_crypto::{parse_json_strict, verify, PublicJwk, SigningAlgorithm}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{ + config::AssuranceProfile, + contracts::evidence_contract_accepts, + model::{Evidence, FlattenedJws, JwksDocument}, + sdjwt_vc::evidence_payload_from_claims, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, EVIDENCE_SD_JWT_VC_TYP, +}; + +const MAX_JWS_BYTES: usize = 256 * 1024; +const MAX_PROTECTED_BYTES: usize = 8 * 1024; +const MAX_PAYLOAD_BYTES: usize = 128 * 1024; +const MAX_TRUSTED_KEYS: usize = 33; +/// One disclosure per Supported Value, bounded well above the largest +/// requirement a Version 1 bundle can declare. +const MAX_DISCLOSURES: usize = 64; +const MAX_DISCLOSURE_BYTES: usize = 8 * 1024; +const MINIMUM_SALT_BYTES: usize = 16; +const MAXIMUM_SALT_BYTES: usize = 64; + +/// Complete relying-procedure expectations for strict verification. +/// +/// Every expectation comes from independent trusted state such as the relying +/// procedure, a previously trusted binding, or a trusted requirement contract. +/// Copying values out of the JWS under verification proves nothing. +#[derive(Debug, Clone)] +pub struct EvidenceVerificationPolicy { + pub assurance_profile: AssuranceProfile, + pub issued_by: String, + pub provided_by: String, + pub requirement: String, + pub evidence_type: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, + /// The exact nonce from the independently retained original request. + pub request_nonce: String, + /// Expected role-bound opaque subject bindings as an unordered set of + /// unique pairs. Subject order alone is never semantic. + pub expected_subjects: Vec, + /// Expected concept identifiers, value forms, and cardinalities. + pub expected_outputs: Vec, + /// Longest acceptable `validUntil - issuedAt` interval. + pub maximum_assertion_lifetime: Duration, + pub now: DateTime, + pub clock_skew: Duration, +} + +/// Closed wire document for independently retained verification expectations. +/// +/// The runtime-facing policy keeps an explicit verification instant and Rust +/// durations. This document is the serializable form used by offline operator +/// boundaries, including the local pre-response context. It never learns +/// expectations from the response it is asked to verify. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceVerificationPolicyDocument { + pub(crate) expected_assurance_profile: AssuranceProfile, + pub(crate) issued_by: String, + pub(crate) provided_by: String, + pub(crate) requirement: String, + pub(crate) evidence_type: String, + pub(crate) purpose: String, + pub(crate) audience: String, + pub(crate) configuration_revision: String, + /// The exact nonce from the independently retained original request. + pub(crate) request_nonce: String, + pub(crate) expected_subjects: Vec, + pub(crate) expected_outputs: Vec, + pub(crate) maximum_assertion_lifetime_seconds: u64, + #[serde(default)] + pub(crate) clock_skew_seconds: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExpectedSubjectDocument { + pub(crate) role: String, + pub(crate) binding: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExpectedOutputDocument { + pub(crate) concept: String, + pub(crate) form: ExpectedFormDocument, +} + +/// The closed expected value-form vocabulary as written in a policy document. +/// +/// The two alternatives are untagged because the policy schema writes a scalar +/// form as a plain string and the list form as a mapping under `list`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum ExpectedFormDocument { + Scalar(ExpectedScalarFormDocument), + List(ExpectedListFormDocument), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ExpectedScalarFormDocument { + Boolean, + Integer, + String, + DateBucket, + TimeBucket, + EntityReference, + Structured, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExpectedListFormDocument { + pub(crate) list: ExpectedListDocument, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExpectedListDocument { + pub(crate) minimum_items: usize, + pub(crate) maximum_items: usize, +} + +impl EvidenceVerificationPolicyDocument { + pub fn into_policy(self, now: DateTime) -> EvidenceVerificationPolicy { + EvidenceVerificationPolicy { + assurance_profile: self.expected_assurance_profile, + issued_by: self.issued_by, + provided_by: self.provided_by, + requirement: self.requirement, + evidence_type: self.evidence_type, + purpose: self.purpose, + audience: self.audience, + configuration_revision: self.configuration_revision, + request_nonce: self.request_nonce, + expected_subjects: self + .expected_subjects + .into_iter() + .map(|subject| ExpectedSubject { + role: subject.role, + binding: subject.binding, + }) + .collect(), + expected_outputs: self + .expected_outputs + .into_iter() + .map(|output| ExpectedOutput { + concept: output.concept, + form: expected_value_form_document(output.form), + }) + .collect(), + maximum_assertion_lifetime: Duration::from_secs( + self.maximum_assertion_lifetime_seconds, + ), + now, + clock_skew: Duration::from_secs(self.clock_skew_seconds), + } + } +} + +fn expected_value_form_document(document: ExpectedFormDocument) -> ExpectedValueForm { + match document { + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean) => { + ExpectedValueForm::Boolean + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Integer) => { + ExpectedValueForm::Integer + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::String) => { + ExpectedValueForm::String + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::DateBucket) => { + ExpectedValueForm::DateBucket + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::TimeBucket) => { + ExpectedValueForm::TimeBucket + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::EntityReference) => { + ExpectedValueForm::EntityReference + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Structured) => { + ExpectedValueForm::Structured + } + ExpectedFormDocument::List(wrapper) => ExpectedValueForm::List { + minimum_items: wrapper.list.minimum_items, + maximum_items: wrapper.list.maximum_items, + }, + } +} + +impl EvidenceVerificationPolicy { + /// Build expectations from evidence accepted in an original trusted + /// transaction, for later re-verification of the stored response. + /// + /// This is only meaningful when `evidence` was itself verified and + /// accepted at transaction time and then retained under the relying + /// party's record policy. Parsing an untrusted JWS and passing its own + /// values back as expectations proves nothing. The expected nonce comes + /// from the independently retained original request, never from the + /// response. + pub fn from_accepted_transaction( + evidence: &Evidence, + retained_request_nonce: &str, + maximum_assertion_lifetime: Duration, + now: DateTime, + clock_skew: Duration, + ) -> Self { + Self { + assurance_profile: evidence.assurance_profile, + issued_by: evidence.issued_by.clone(), + provided_by: evidence.provided_by.clone(), + requirement: evidence.supports_requirement.clone(), + evidence_type: evidence.is_conformant_to.clone(), + purpose: evidence.purpose.clone(), + audience: evidence.audience.clone(), + configuration_revision: evidence.configuration_revision.clone(), + request_nonce: retained_request_nonce.to_owned(), + expected_subjects: evidence + .subjects + .iter() + .map(|subject| ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(), + expected_outputs: evidence + .supported_values + .iter() + .map(|value| ExpectedOutput { + concept: value.provides_value_for.clone(), + form: expected_form_of(&value.value), + }) + .collect(), + maximum_assertion_lifetime, + now, + clock_skew, + } + } +} + +fn expected_form_of(value: &crate::model::PublicValue) -> ExpectedValueForm { + use crate::model::{BucketForm, PublicValue}; + match value { + PublicValue::Boolean(_) => ExpectedValueForm::Boolean, + PublicValue::Integer(_) => ExpectedValueForm::Integer, + PublicValue::String(_) => ExpectedValueForm::String, + PublicValue::Bucket(bucket) => { + if bucket.form == BucketForm::DateBucket { + ExpectedValueForm::DateBucket + } else { + ExpectedValueForm::TimeBucket + } + } + PublicValue::EntityReference(_) => ExpectedValueForm::EntityReference, + PublicValue::Structured(_) => ExpectedValueForm::Structured, + PublicValue::List(items) => ExpectedValueForm::List { + minimum_items: items.len(), + maximum_items: items.len(), + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ExpectedSubject { + pub role: String, + pub binding: String, +} + +#[derive(Debug, Clone)] +pub struct ExpectedOutput { + pub concept: String, + pub form: ExpectedValueForm, +} + +/// Closed expected form for one disclosed Supported Value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExpectedValueForm { + Boolean, + Integer, + String, + DateBucket, + TimeBucket, + EntityReference, + Structured, + List { + minimum_items: usize, + maximum_items: usize, + }, +} + +/// Result of verifying a stored signed response. +/// +/// A returned report means the trusted key signed the exact payload and every +/// policy expectation held. Current usability is reported separately so an +/// expired assertion can remain cryptographically authentic without being +/// treated as current evidence. +#[derive(Debug)] +pub struct VerificationReport { + pub evidence: Evidence, + pub currently_valid: bool, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum VerificationError { + #[error("flattened JWS is malformed")] + MalformedJws, + #[error("protected JWS header is not allowed")] + ProtectedHeader, + #[error("JWS key identifier is unknown or ambiguous")] + Key, + #[error("JWS signature is invalid")] + Signature, + #[error("Evidence payload is malformed")] + Payload, + #[error("Evidence payload does not match the relying procedure")] + Policy, + #[error("Evidence payload is outside its validity interval")] + Time, + #[error("SD-JWT VC disclosures do not match the signed digests")] + Disclosure, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProtectedHeader { + alg: String, + kid: String, + typ: String, + cty: String, +} + +/// The SD-JWT VC header carries no `cty`: the credential type travels in the +/// signed `vct` claim. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdJwtHeader { + alg: String, + kid: String, + typ: String, +} + +/// Strict one-call verification: cryptographic authenticity, every policy +/// expectation, and current validity must all hold. +pub fn verify_flattened_jws( + serialized_jws: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + let report = verify_flattened_jws_report(serialized_jws, trusted_jwks, policy)?; + if !report.currently_valid { + return Err(VerificationError::Time); + } + Ok(report.evidence) +} + +/// Verify a stored signed response against a pinned trusted key set and the +/// complete independent policy, reporting cryptographic authenticity +/// separately from current validity. +pub fn verify_flattened_jws_report( + serialized_jws: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + if serialized_jws.is_empty() || serialized_jws.len() > MAX_JWS_BYTES { + return Err(VerificationError::MalformedJws); + } + let strict = parse_json_strict(serialized_jws).map_err(|_| VerificationError::MalformedJws)?; + let jws: FlattenedJws = + serde_json::from_value(strict).map_err(|_| VerificationError::MalformedJws)?; + + let protected_bytes = decode_bounded( + &jws.protected, + MAX_PROTECTED_BYTES, + VerificationError::ProtectedHeader, + )?; + let protected_strict = + parse_json_strict(&protected_bytes).map_err(|_| VerificationError::ProtectedHeader)?; + let protected: ProtectedHeader = + serde_json::from_value(protected_strict).map_err(|_| VerificationError::ProtectedHeader)?; + if protected.alg != "EdDSA" + || protected.typ != EVIDENCE_JWS_TYP + || protected.cty != EVIDENCE_JWS_CTY + || protected.kid.is_empty() + || protected.kid.len() > 256 + || protected.kid.chars().any(char::is_control) + { + return Err(VerificationError::ProtectedHeader); + } + + let keys = trusted_keys(trusted_jwks)?; + let key = keys.get(&protected.kid).ok_or(VerificationError::Key)?; + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(VerificationError::Key); + } + let signature = decode_bounded( + &jws.signature, + MAX_PROTECTED_BYTES, + VerificationError::Signature, + )?; + let signing_input = [jws.protected.as_bytes(), b".", jws.payload.as_bytes()].concat(); + verify(&signing_input, &signature, key).map_err(|_| VerificationError::Signature)?; + + // Parse and act on the payload only after signature verification. + let payload = decode_bounded(&jws.payload, MAX_PAYLOAD_BYTES, VerificationError::Payload)?; + let payload_strict = parse_json_strict(&payload).map_err(|_| VerificationError::Payload)?; + if !evidence_contract_accepts(&payload_strict).map_err(|_| VerificationError::Payload)? { + return Err(VerificationError::Payload); + } + let evidence: Evidence = + serde_json::from_value(payload_strict).map_err(|_| VerificationError::Payload)?; + let currently_valid = validate_policy(&evidence, policy)?; + Ok(VerificationReport { + evidence, + currently_valid, + }) +} + +/// Strict one-call verification of an issued SD-JWT VC: cryptographic +/// authenticity, complete disclosure resolution, every policy expectation, and +/// current validity must all hold. +pub fn verify_sd_jwt_vc( + serialized: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + let report = verify_sd_jwt_vc_report(serialized, trusted_jwks, policy)?; + if !report.currently_valid { + return Err(VerificationError::Time); + } + Ok(report.evidence) +} + +/// Verify an issued SD-JWT VC against a pinned trusted key set and the +/// complete independent policy, reporting cryptographic authenticity +/// separately from current validity. +/// +/// Version 1 issues only complete credentials, so verification requires every +/// signed digest to be resolved by exactly one presented disclosure. A holder +/// presenting a subset is out of scope for the issuance profile, and the +/// relying procedure's expected output contract would reject it in any case. +/// A key-binding JWT is never accepted here: the serialization must end with +/// the trailing tilde that marks its absence. +pub fn verify_sd_jwt_vc_report( + serialized: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + if serialized.is_empty() || serialized.len() > MAX_JWS_BYTES { + return Err(VerificationError::MalformedJws); + } + let serialized = + std::str::from_utf8(serialized).map_err(|_| VerificationError::MalformedJws)?; + let body = serialized + .strip_suffix('~') + .ok_or(VerificationError::MalformedJws)?; + let mut segments = body.split('~'); + let jwt = segments.next().ok_or(VerificationError::MalformedJws)?; + let encoded_disclosures: Vec<&str> = segments.collect(); + if encoded_disclosures.len() > MAX_DISCLOSURES + || encoded_disclosures.iter().any(|value| value.is_empty()) + { + return Err(VerificationError::MalformedJws); + } + + let mut parts = jwt.split('.'); + let (Some(encoded_header), Some(encoded_payload), Some(encoded_signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(VerificationError::MalformedJws); + }; + + let header_bytes = decode_bounded( + encoded_header, + MAX_PROTECTED_BYTES, + VerificationError::ProtectedHeader, + )?; + let header_strict = + parse_json_strict(&header_bytes).map_err(|_| VerificationError::ProtectedHeader)?; + let header: SdJwtHeader = + serde_json::from_value(header_strict).map_err(|_| VerificationError::ProtectedHeader)?; + if header.alg != "EdDSA" + || header.typ != EVIDENCE_SD_JWT_VC_TYP + || header.kid.is_empty() + || header.kid.len() > 256 + || header.kid.chars().any(char::is_control) + { + return Err(VerificationError::ProtectedHeader); + } + + let keys = trusted_keys(trusted_jwks)?; + let key = keys.get(&header.kid).ok_or(VerificationError::Key)?; + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(VerificationError::Key); + } + let signature = decode_bounded( + encoded_signature, + MAX_PROTECTED_BYTES, + VerificationError::Signature, + )?; + let signing_input = [encoded_header.as_bytes(), b".", encoded_payload.as_bytes()].concat(); + verify(&signing_input, &signature, key).map_err(|_| VerificationError::Signature)?; + + // Parse and act on the payload only after signature verification. + let payload_bytes = decode_bounded( + encoded_payload, + MAX_PAYLOAD_BYTES, + VerificationError::Payload, + )?; + let payload_strict = + parse_json_strict(&payload_bytes).map_err(|_| VerificationError::Payload)?; + let Value::Object(mut claims) = payload_strict else { + return Err(VerificationError::Payload); + }; + + let digests = signed_digests(&mut claims)?; + let disclosed = resolve_disclosures(&encoded_disclosures, &digests, &mut claims)?; + if let Some(confirmation) = claims.remove("cnf") { + validate_confirmation(&confirmation)?; + } + + let payload = evidence_payload_from_claims(&claims, &disclosed) + .map_err(|_| VerificationError::Payload)?; + if !evidence_contract_accepts(&payload).map_err(|_| VerificationError::Payload)? { + return Err(VerificationError::Payload); + } + let evidence: Evidence = + serde_json::from_value(payload).map_err(|_| VerificationError::Payload)?; + let currently_valid = validate_policy(&evidence, policy)?; + Ok(VerificationReport { + evidence, + currently_valid, + }) +} + +/// Take the signed digest set out of the claims. The set must be sorted and +/// free of duplicates, matching the issuance profile exactly. +fn signed_digests(claims: &mut Map) -> Result, VerificationError> { + if claims + .remove("_sd_alg") + .and_then(|alg| alg.as_str().map(str::to_owned)) + != Some("sha-256".to_string()) + { + return Err(VerificationError::Disclosure); + } + let listed = claims + .remove("_sd") + .ok_or(VerificationError::Disclosure)? + .as_array() + .ok_or(VerificationError::Disclosure)? + .iter() + .map(|digest| digest.as_str().map(str::to_owned)) + .collect::>>() + .ok_or(VerificationError::Disclosure)?; + if listed.len() > MAX_DISCLOSURES + || listed.windows(2).any(|pair| pair[0] >= pair[1]) + || listed.iter().any(|digest| digest.len() != 43) + { + return Err(VerificationError::Disclosure); + } + Ok(listed) +} + +/// Resolve every presented disclosure against the signed digests. Each digest +/// must be claimed by exactly one disclosure, each disclosure must carry a +/// distinct name, and no disclosure may shadow a public claim. +fn resolve_disclosures( + encoded: &[&str], + digests: &[String], + claims: &mut Map, +) -> Result, VerificationError> { + #[derive(Clone)] + enum Location { + Root, + Object(String), + } + + let mut locations = BTreeMap::::new(); + for digest in digests { + if locations.insert(digest.clone(), Location::Root).is_some() { + return Err(VerificationError::Disclosure); + } + } + let structured_claims = match claims.get("structuredValues") { + None => Vec::new(), + Some(Value::Object(metadata)) => metadata.keys().cloned().collect::>(), + Some(_) => return Err(VerificationError::Disclosure), + }; + for claim in structured_claims { + let object = claims + .get_mut(&claim) + .and_then(Value::as_object_mut) + .ok_or(VerificationError::Disclosure)?; + if object.len() != 1 { + return Err(VerificationError::Disclosure); + } + let nested = object + .remove("_sd") + .and_then(|value| value.as_array().cloned()) + .ok_or(VerificationError::Disclosure)?; + let nested = nested + .iter() + .map(|digest| digest.as_str().map(str::to_owned)) + .collect::>>() + .ok_or(VerificationError::Disclosure)?; + if nested.is_empty() + || nested.len() > 64 + || nested.windows(2).any(|pair| pair[0] >= pair[1]) + || nested.iter().any(|digest| digest.len() != 43) + { + return Err(VerificationError::Disclosure); + } + for digest in nested { + if locations + .insert(digest, Location::Object(claim.clone())) + .is_some() + { + return Err(VerificationError::Disclosure); + } + } + } + if encoded.len() != locations.len() { + return Err(VerificationError::Disclosure); + } + let mut resolved = Vec::with_capacity(encoded.len()); + let mut seen_digests = BTreeSet::new(); + let mut root_names = BTreeSet::new(); + let mut object_names = BTreeMap::>::new(); + for disclosure in encoded { + if disclosure.len() > MAX_DISCLOSURE_BYTES { + return Err(VerificationError::Disclosure); + } + let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); + let location = locations + .get(&digest) + .cloned() + .ok_or(VerificationError::Disclosure)?; + if !seen_digests.insert(digest) { + return Err(VerificationError::Disclosure); + } + let decoded = decode_bounded( + disclosure, + MAX_DISCLOSURE_BYTES, + VerificationError::Disclosure, + )?; + let strict = parse_json_strict(&decoded).map_err(|_| VerificationError::Disclosure)?; + let Value::Array(members) = strict else { + return Err(VerificationError::Disclosure); + }; + let [salt, name, value] = members.as_slice() else { + return Err(VerificationError::Disclosure); + }; + let salt = salt.as_str().ok_or(VerificationError::Disclosure)?; + let salt_bytes = URL_SAFE_NO_PAD + .decode(salt) + .map_err(|_| VerificationError::Disclosure)?; + if salt_bytes.len() < MINIMUM_SALT_BYTES || salt_bytes.len() > MAXIMUM_SALT_BYTES { + return Err(VerificationError::Disclosure); + } + let name = name.as_str().ok_or(VerificationError::Disclosure)?; + match location { + Location::Root => { + if claims.contains_key(name) || !root_names.insert(name.to_owned()) { + return Err(VerificationError::Disclosure); + } + resolved.push((name.to_owned(), value.clone())); + } + Location::Object(claim) => { + if name.is_empty() + || name.len() > 128 + || name == "_sd" + || name == "..." + || name.chars().any(char::is_control) + || !object_names + .entry(claim.clone()) + .or_default() + .insert(name.to_owned()) + { + return Err(VerificationError::Disclosure); + } + let object = claims + .get_mut(&claim) + .and_then(Value::as_object_mut) + .ok_or(VerificationError::Disclosure)?; + if object.insert(name.to_owned(), value.clone()).is_some() { + return Err(VerificationError::Disclosure); + } + } + } + } + Ok(resolved) +} + +/// The confirmation, when present, carries exactly one Ed25519 public key and +/// no private material. +fn validate_confirmation(confirmation: &Value) -> Result<(), VerificationError> { + let Some(members) = confirmation.as_object() else { + return Err(VerificationError::Payload); + }; + if members.len() != 1 { + return Err(VerificationError::Payload); + } + let jwk = members.get("jwk").ok_or(VerificationError::Payload)?; + let key: PublicJwk = + serde_json::from_value(jwk.clone()).map_err(|_| VerificationError::Payload)?; + if key.kty != "OKP" + || key.crv.as_deref() != Some("Ed25519") + || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + { + return Err(VerificationError::Payload); + } + Ok(()) +} + +fn trusted_keys(jwks: &JwksDocument) -> Result, VerificationError> { + if jwks.keys.is_empty() || jwks.keys.len() > MAX_TRUSTED_KEYS { + return Err(VerificationError::Key); + } + let mut output = BTreeMap::new(); + for value in &jwks.keys { + let key: PublicJwk = + serde_json::from_value(value.clone()).map_err(|_| VerificationError::Key)?; + let kid = key.kid.clone().ok_or(VerificationError::Key)?; + if kid.is_empty() + || kid.len() > 256 + || kid.chars().any(char::is_control) + || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + || output.insert(kid, key).is_some() + { + return Err(VerificationError::Key); + } + } + Ok(output) +} + +/// Compare every policy expectation after signature and schema verification. +/// +/// Every mismatch, including the expected nonce, expected role-bound subject +/// set, and expected output contract, returns the one generic policy error so +/// verification does not reveal which hidden comparison failed. The returned +/// boolean is current validity, which is reported separately from +/// authenticity and policy conformance. +fn validate_policy( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result { + if evidence.schema != EVIDENCE_SCHEMA_V1 + || evidence.assurance_profile != policy.assurance_profile + || evidence.issued_by != policy.issued_by + || evidence.provided_by != policy.provided_by + || evidence.supports_requirement != policy.requirement + || evidence.is_conformant_to != policy.evidence_type + || evidence.purpose != policy.purpose + || evidence.audience != policy.audience + || evidence.configuration_revision != policy.configuration_revision + || evidence.subjects.is_empty() + || evidence.supported_values.is_empty() + || evidence.request_nonce != policy.request_nonce + { + return Err(VerificationError::Policy); + } + validate_expected_subjects(evidence, policy)?; + validate_expected_outputs(evidence, policy)?; + + let issued = parse_time(&evidence.issued_at)?; + let observed = parse_time(&evidence.observed_at)?; + let valid_until = parse_time(&evidence.valid_until)?; + let skew = + chrono::Duration::from_std(policy.clock_skew).map_err(|_| VerificationError::Time)?; + let maximum_lifetime = chrono::Duration::from_std(policy.maximum_assertion_lifetime) + .map_err(|_| VerificationError::Time)?; + let expiration_with_skew = valid_until + .checked_add_signed(skew) + .ok_or(VerificationError::Time)?; + // Internal chronology and the accepted-lifetime ceiling are hard errors; + // an internally inconsistent or over-long assertion is never acceptable. + if issued < observed + || valid_until <= observed + || valid_until <= issued + || valid_until - issued > maximum_lifetime + { + return Err(VerificationError::Time); + } + let latest_acceptable_issue = policy + .now + .checked_add_signed(skew) + .ok_or(VerificationError::Time)?; + let currently_valid = issued <= latest_acceptable_issue + && observed <= latest_acceptable_issue + && policy.now < expiration_with_skew; + Ok(currently_valid) +} + +/// Compare the unordered set of unique expected `(role, binding)` pairs. +fn validate_expected_subjects( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result<(), VerificationError> { + let mut expected = policy.expected_subjects.clone(); + expected.sort(); + if expected.is_empty() || expected.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(VerificationError::Policy); + } + let mut actual = evidence + .subjects + .iter() + .map(|subject| ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect::>(); + actual.sort(); + if actual != expected { + return Err(VerificationError::Policy); + } + Ok(()) +} + +/// Compare the expected concept identifiers, value forms, and cardinalities. +fn validate_expected_outputs( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result<(), VerificationError> { + let expected = &policy.expected_outputs; + if expected.is_empty() || evidence.supported_values.len() != expected.len() { + return Err(VerificationError::Policy); + } + let mut concepts = BTreeMap::new(); + for output in expected { + if concepts + .insert(output.concept.as_str(), &output.form) + .is_some() + { + return Err(VerificationError::Policy); + } + } + let mut seen = std::collections::BTreeSet::new(); + for value in &evidence.supported_values { + let concept = value.provides_value_for.as_str(); + let Some(form) = concepts.get(concept) else { + return Err(VerificationError::Policy); + }; + if !seen.insert(concept) || !value_matches_form(&value.value, form) { + return Err(VerificationError::Policy); + } + } + Ok(()) +} + +fn value_matches_form(value: &crate::model::PublicValue, form: &ExpectedValueForm) -> bool { + use crate::model::{BucketForm, PublicValue}; + match (value, form) { + (PublicValue::Boolean(_), ExpectedValueForm::Boolean) + | (PublicValue::Integer(_), ExpectedValueForm::Integer) + | (PublicValue::String(_), ExpectedValueForm::String) + | (PublicValue::EntityReference(_), ExpectedValueForm::EntityReference) + | (PublicValue::Structured(_), ExpectedValueForm::Structured) => true, + (PublicValue::Bucket(bucket), ExpectedValueForm::DateBucket) => { + bucket.form == BucketForm::DateBucket + } + (PublicValue::Bucket(bucket), ExpectedValueForm::TimeBucket) => { + bucket.form == BucketForm::TimeBucket + } + ( + PublicValue::List(items), + ExpectedValueForm::List { + minimum_items, + maximum_items, + }, + ) => items.len() >= *minimum_items && items.len() <= *maximum_items, + _ => false, + } +} + +fn parse_time(input: &str) -> Result, VerificationError> { + DateTime::parse_from_rfc3339(input) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| VerificationError::Time) +} + +fn decode_bounded( + input: &str, + maximum: usize, + error: VerificationError, +) -> Result, VerificationError> { + let decoded = URL_SAFE_NO_PAD.decode(input).map_err(|_| error)?; + if decoded.is_empty() || decoded.len() > maximum { + return Err(error); + } + Ok(decoded) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use serde_json::{json, Value}; + + use super::*; + use crate::{ + model::{ + EvidenceObjectType, PublicValue, StructuredValue, StructuredValueForm, SubjectBinding, + SupportedValue, + }, + signing::{jwks_document, EvidenceSigner}, + }; + + const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + const RETIRED_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"retired-evidence-key"}"#; + + async fn sign_with_protected_header( + private_jwk: &str, + protected_header: Value, + evidence: &Evidence, + ) -> (Vec, PublicJwk) { + sign_payload_bytes( + private_jwk, + protected_header, + &serde_json::to_vec(evidence).expect("Evidence serializes"), + ) + .await + } + + async fn sign_payload_bytes( + private_jwk: &str, + protected_header: Value, + payload_bytes: &[u8], + ) -> (Vec, PublicJwk) { + let private = PrivateJwk::parse(private_jwk).expect("test key parses"); + let signer = LocalJwkSigner::new(private).expect("test signer builds"); + let protected = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&protected_header).expect("protected header serializes")); + let payload = URL_SAFE_NO_PAD.encode(payload_bytes); + let signing_input = format!("{protected}.{payload}"); + let signature = signer + .sign(signing_input.as_bytes()) + .await + .expect("test JWS signs"); + let jws = FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + }; + ( + serde_json::to_vec(&jws).expect("JWS serializes"), + signer.public_jwk(), + ) + } + + const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; + + fn fixture_evidence() -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_string(), + assurance_profile: AssuranceProfile::EvidenceGrade, + request_nonce: FIXTURE_NONCE.to_string(), + id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_string(), + is_conformant_to: "urn:example:type:v1".to_string(), + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + issued_at: "2026-08-02T00:00:00Z".to_string(), + observed_at: "2026-08-02T00:00:00Z".to_string(), + valid_until: "2026-08-03T00:00:00Z".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept".to_string(), + value: PublicValue::Boolean(false), + }], + } + } + + async fn signed_evidence( + evidence: Evidence, + now: DateTime, + ) -> (Vec, JwksDocument, EvidenceVerificationPolicy) { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("signer builds")); + let signer = EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes"); + let jws = signer.sign_json(&evidence).await.expect("evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for(&evidence, now); + (serialized, jwks, policy) + } + + /// Build expectations equal to one known evidence value. Production + /// relying parties obtain these from independent trusted state; the test + /// simulates that state from the fixture it controls. + fn policy_for(evidence: &Evidence, now: DateTime) -> EvidenceVerificationPolicy { + EvidenceVerificationPolicy::from_accepted_transaction( + evidence, + &evidence.request_nonce, + Duration::from_secs(48 * 60 * 60), + now, + Duration::from_secs(30), + ) + } + + async fn signed_fixture() -> (Vec, JwksDocument, EvidenceVerificationPolicy) { + signed_evidence( + fixture_evidence(), + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await + } + + #[tokio::test] + async fn signed_false_round_trips_and_verifies() { + let (jws, jwks, policy) = signed_fixture().await; + let evidence = verify_flattened_jws(&jws, &jwks, &policy).expect("JWS verifies"); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(false) + ); + } + + #[tokio::test] + async fn authentic_local_assertions_fail_deployable_assurance_expectations() { + let mut local = fixture_evidence(); + local.assurance_profile = AssuranceProfile::Local; + let (jws, jwks, mut strict_policy) = signed_evidence( + local.clone(), + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + strict_policy.assurance_profile = AssuranceProfile::Production; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &strict_policy), + Err(VerificationError::Policy) + ); + + let signer = fixture_signer().await; + let input = crate::sdjwt_vc::issuance_input(&local, None, &BTreeMap::new()) + .expect("local evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("local SD-JWT VC serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + assert_eq!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &strict_policy), + Err(VerificationError::Policy) + ); + } + + #[tokio::test] + async fn signed_payload_must_satisfy_the_complete_evidence_schema() { + let mut cases = Vec::new(); + + let mut invalid_id = fixture_evidence(); + invalid_id.id = "not a URI".to_owned(); + cases.push(invalid_id); + + let mut invalid_role = fixture_evidence(); + invalid_role.subjects[0].role = "Uppercase".to_owned(); + cases.push(invalid_role); + + let mut invalid_binding = fixture_evidence(); + invalid_binding.subjects[0].binding = "raw-subject-identifier".to_owned(); + cases.push(invalid_binding); + + let mut invalid_concept = fixture_evidence(); + invalid_concept.supported_values[0].provides_value_for = "not a URI".to_owned(); + cases.push(invalid_concept); + + let mut empty_public_string = fixture_evidence(); + empty_public_string.supported_values[0].value = PublicValue::String(String::new()); + cases.push(empty_public_string); + + let mut excessive_subjects = fixture_evidence(); + excessive_subjects.subjects = (0..9) + .map(|index| SubjectBinding { + role: format!("subject-{index}"), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }) + .collect(); + cases.push(excessive_subjects); + + for evidence in cases { + let (jws, jwks, policy) = signed_evidence( + evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Payload) + ); + } + } + + #[tokio::test] + async fn signed_schema_integer_lexical_forms_verify_without_type_loss() { + let base = serde_json::to_string(&fixture_evidence()).expect("Evidence serializes"); + assert_eq!(base.matches("\"value\":false").count(), 1); + let header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (_, _, mut policy) = signed_fixture().await; + policy.expected_outputs[0].form = ExpectedValueForm::Integer; + + for number in ["1.0", "1e0"] { + let payload = base.replace("\"value\":false", &format!("\"value\":{number}")); + let (serialized, public) = + sign_payload_bytes(PRIVATE_JWK, header.clone(), payload.as_bytes()).await; + let jwks = jwks_document(public, []).expect("JWKS builds"); + let evidence = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("schema-valid integral JSON number verifies"); + assert_eq!(evidence.supported_values[0].value, PublicValue::Integer(1)); + } + } + + #[tokio::test] + async fn payload_and_protected_header_mutation_fail() { + let (jws, jwks, policy) = signed_fixture().await; + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let payload = value["payload"].as_str().expect("payload").to_string(); + value["payload"] = Value::String(format!("A{}", &payload[1..])); + assert!(matches!( + verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::Signature) + )); + + let (jws, jwks, policy) = signed_fixture().await; + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let protected = value["protected"].as_str().expect("protected").to_string(); + value["protected"] = Value::String(format!("A{}", &protected[1..])); + assert!(verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ) + .is_err()); + } + + #[tokio::test] + async fn duplicate_jws_members_and_unknown_kid_are_rejected() { + let (jws, mut jwks, policy) = signed_fixture().await; + let value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let duplicate = format!( + "{{\"protected\":{},\"protected\":{},\"payload\":{},\"signature\":{}}}", + value["protected"], value["protected"], value["payload"], value["signature"] + ); + assert_eq!( + verify_flattened_jws(duplicate.as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + jwks.keys.clear(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Key) + ); + } + + #[tokio::test] + async fn signature_never_substitutes_for_provider_and_issuer_trust_policy() { + let (jws, jwks, policy) = signed_fixture().await; + let mut untrusted_provider = policy.clone(); + untrusted_provider.provided_by = "urn:example:untrusted-provider".to_owned(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &untrusted_provider), + Err(VerificationError::Policy) + ); + + let mut untrusted_issuer = policy; + untrusted_issuer.issued_by = "urn:example:untrusted-issuer".to_owned(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &untrusted_issuer), + Err(VerificationError::Policy) + ); + } + + #[tokio::test] + async fn signed_chronology_and_clock_arithmetic_fail_closed() { + let mut reversed = fixture_evidence(); + reversed.observed_at = "2026-08-02T00:01:00Z".to_owned(); + let (jws, jwks, policy) = signed_evidence( + reversed, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + let mut expired_when_issued = fixture_evidence(); + expired_when_issued.issued_at = "2026-08-03T00:00:00Z".to_owned(); + expired_when_issued.valid_until = "2026-08-03T00:00:00Z".to_owned(); + let (jws, jwks, policy) = signed_evidence( + expired_when_issued, + "2026-08-03T00:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + let (jws, jwks, mut policy) = signed_fixture().await; + policy.now = DateTime::::MAX_UTC; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + } + + #[tokio::test] + async fn complete_jws_negative_fixture_is_executable() { + let fixture: Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/jws-cases.yaml" + )) + .expect("JWS fixture parses"); + let negatives = fixture["negative"] + .as_array() + .expect("negative cases are an array") + .iter() + .map(|value| value.as_str().expect("negative case is text")) + .collect::>(); + assert_eq!( + negatives, + [ + "mutate one protected-header byte", + "mutate one payload byte", + "remove signature", + "add an unprotected header", + "add jku, x5u, jwk, x5c, crit, or b64", + "unknown kid", + "algorithm mismatch", + "signed payload violates the Evidence JSON Schema", + "duplicate evidence object beside payload", + "signing-provider failure", + ] + ); + + let evidence = fixture_evidence(); + let base_header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (valid, public) = + sign_with_protected_header(PRIVATE_JWK, base_header.clone(), &evidence).await; + let jwks = jwks_document(public, []).expect("JWKS builds"); + let (_, _, policy) = signed_fixture().await; + assert!(verify_flattened_jws(&valid, &jwks, &policy).is_ok()); + + let mut missing_signature: Value = serde_json::from_slice(&valid).expect("JWS parses"); + missing_signature + .as_object_mut() + .expect("JWS is an object") + .remove("signature"); + assert_eq!( + verify_flattened_jws( + &serde_json::to_vec(&missing_signature).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::MalformedJws) + ); + + for extra in [ + ("header", json!({"kid": "evidence-key-1"})), + ( + "evidence", + serde_json::to_value(&evidence).expect("Evidence serializes"), + ), + ] { + let mut value: Value = serde_json::from_slice(&valid).expect("JWS parses"); + value + .as_object_mut() + .expect("JWS is an object") + .insert(extra.0.to_owned(), extra.1); + assert_eq!( + verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::MalformedJws) + ); + } + + for (name, value) in [ + ("jku", json!("https://attacker.invalid/jwks.json")), + ("x5u", json!("https://attacker.invalid/cert.pem")), + ("jwk", json!({"kty": "OKP"})), + ("x5c", json!(["certificate-canary"])), + ("crit", json!(["exp"])), + ("b64", json!(false)), + ] { + let mut header = base_header.clone(); + header + .as_object_mut() + .expect("header is an object") + .insert(name.to_owned(), value); + let (serialized, public) = + sign_with_protected_header(PRIVATE_JWK, header, &evidence).await; + let keys = jwks_document(public, []).expect("JWKS builds"); + assert_eq!( + verify_flattened_jws(&serialized, &keys, &policy), + Err(VerificationError::ProtectedHeader), + "{name}" + ); + } + + for (header, expected) in [ + ( + json!({ + "alg": "EdDSA", "kid": "unknown-key", "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }), + VerificationError::Key, + ), + ( + json!({ + "alg": "HS256", "kid": "evidence-key-1", "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }), + VerificationError::ProtectedHeader, + ), + ] { + let (serialized, _) = sign_with_protected_header(PRIVATE_JWK, header, &evidence).await; + assert_eq!( + verify_flattened_jws(&serialized, &jwks, &policy), + Err(expected) + ); + } + } + + #[tokio::test] + async fn expected_nonce_must_match_and_reuse_is_not_replay_prevention() { + let (jws, jwks, policy) = signed_fixture().await; + + // Changing the expected nonce fails with the generic policy mismatch. + let mut wrong_expectation = policy.clone(); + wrong_expectation.request_nonce = "B".repeat(43); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &wrong_expectation), + Err(VerificationError::Policy) + ); + + // Changing the signed nonce fails: re-signing a mutated payload with + // the same trusted key still mismatches the retained expectation. + let mut mutated = fixture_evidence(); + mutated.request_nonce = "B".repeat(43); + let header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (mutated_jws, public) = sign_with_protected_header(PRIVATE_JWK, header, &mutated).await; + let mutated_jwks = jwks_document(public, []).expect("JWKS builds"); + assert_eq!( + verify_flattened_jws(&mutated_jws, &mutated_jwks, &policy), + Err(VerificationError::Policy) + ); + + // The runtime does not store nonces, so verifying the same stored + // response twice with the same expectation succeeds. The nonce proves + // correlation with the retained request, never one-time use. + assert!(verify_flattened_jws(&jws, &jwks, &policy).is_ok()); + assert!(verify_flattened_jws(&jws, &jwks, &policy).is_ok()); + } + + #[tokio::test] + async fn expected_subject_set_is_unordered_unique_and_exact() { + let mut evidence = fixture_evidence(); + evidence.subjects = vec![ + SubjectBinding { + role: "child".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }, + SubjectBinding { + role: "candidate-parent".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "B".repeat(43)), + }, + ]; + let (jws, jwks, policy) = signed_evidence( + evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + + // Subject order alone is non-semantic. + let mut reordered = policy.clone(); + reordered.expected_subjects.reverse(); + assert!(verify_flattened_jws(&jws, &jwks, &reordered).is_ok()); + + // Missing, extra, duplicated, substituted, and wrong-key-version + // expectations all fail with the one generic policy mismatch. + let mut missing = policy.clone(); + missing.expected_subjects.pop(); + let mut extra = policy.clone(); + extra.expected_subjects.push(ExpectedSubject { + role: "witness".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "C".repeat(43)), + }); + let mut duplicated = policy.clone(); + let first = duplicated.expected_subjects[0].clone(); + duplicated.expected_subjects.push(first); + let mut substituted = policy.clone(); + substituted.expected_subjects[0].binding = + format!("urn:evidence:subject:v1_{}", "D".repeat(43)); + let mut wrong_key_version = policy.clone(); + wrong_key_version.expected_subjects[0].binding = wrong_key_version.expected_subjects[0] + .binding + .replace(":v1_", ":v2_"); + let mut empty = policy.clone(); + empty.expected_subjects.clear(); + for broken in [ + missing, + extra, + duplicated, + substituted, + wrong_key_version, + empty, + ] { + assert_eq!( + verify_flattened_jws(&jws, &jwks, &broken), + Err(VerificationError::Policy) + ); + } + } + + #[tokio::test] + async fn expected_output_contract_is_exact_after_signature_verification() { + let (jws, jwks, policy) = signed_fixture().await; + + let mut missing = policy.clone(); + missing.expected_outputs.clear(); + let mut extra = policy.clone(); + extra.expected_outputs.push(ExpectedOutput { + concept: "urn:example:other-concept".to_string(), + form: ExpectedValueForm::Boolean, + }); + let mut duplicated = policy.clone(); + duplicated.expected_outputs.push(ExpectedOutput { + concept: policy.expected_outputs[0].concept.clone(), + form: ExpectedValueForm::Boolean, + }); + let mut wrong_concept = policy.clone(); + wrong_concept.expected_outputs[0].concept = "urn:example:unexpected".to_string(); + let mut wrong_form = policy.clone(); + wrong_form.expected_outputs[0].form = ExpectedValueForm::String; + let mut wrong_cardinality = policy.clone(); + wrong_cardinality.expected_outputs[0].form = ExpectedValueForm::List { + minimum_items: 2, + maximum_items: 4, + }; + for broken in [ + missing, + extra, + duplicated, + wrong_concept, + wrong_form, + wrong_cardinality, + ] { + assert_eq!( + verify_flattened_jws(&jws, &jwks, &broken), + Err(VerificationError::Policy) + ); + } + } + + #[tokio::test] + async fn authenticity_is_reported_separately_from_current_validity() { + // Verified after expiry: still authentic and policy-conformant, but + // not current evidence. + let (jws, jwks, mut policy) = signed_fixture().await; + policy.now = "2026-08-04T00:00:00Z".parse().expect("time parses"); + let report = verify_flattened_jws_report(&jws, &jwks, &policy) + .expect("expired assertion remains cryptographically authentic"); + assert!(!report.currently_valid); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + // While current, both entry points agree. + let (jws, jwks, policy) = signed_fixture().await; + let report = verify_flattened_jws_report(&jws, &jwks, &policy).expect("report verifies"); + assert!(report.currently_valid); + + // A mutated payload is not authentic in either entry point. + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let payload = value["payload"].as_str().expect("payload").to_string(); + value["payload"] = Value::String(format!("A{}", &payload[1..])); + let mutated = serde_json::to_vec(&value).expect("serializes"); + assert!(verify_flattened_jws_report(&mutated, &jwks, &policy).is_err()); + } + + #[tokio::test] + async fn assertion_lifetime_above_the_accepted_maximum_fails() { + let (jws, jwks, mut policy) = signed_fixture().await; + policy.maximum_assertion_lifetime = Duration::from_secs(60 * 60); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + assert!(verify_flattened_jws_report(&jws, &jwks, &policy).is_err()); + } + + #[tokio::test] + async fn unsigned_envelope_is_rejected_by_the_strict_jws_verifier() { + let (_, jwks, policy) = signed_fixture().await; + let envelope = crate::model::UnsignedEvidenceEnvelope { + schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: crate::model::UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: crate::model::UnsignedIntegrityProtection::None, + warning: crate::model::UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence: fixture_evidence(), + }; + let serialized = serde_json::to_vec(&envelope).expect("envelope serializes"); + assert_eq!( + verify_flattened_jws(&serialized, &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + assert!(verify_flattened_jws_report(&serialized, &jwks, &policy).is_err()); + } + + #[tokio::test] + async fn retired_public_key_verifies_only_while_published_and_payload_is_current() { + let evidence = fixture_evidence(); + let header = json!({ + "alg": "EdDSA", + "kid": "retired-evidence-key", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (serialized, retired_public) = + sign_with_protected_header(RETIRED_PRIVATE_JWK, header, &evidence).await; + + let active_private = PrivateJwk::parse(PRIVATE_JWK).expect("active key parses"); + let active_public = LocalJwkSigner::new(active_private) + .expect("active signer builds") + .public_jwk(); + let with_retired = + jwks_document(active_public.clone(), [retired_public]).expect("rotated JWKS builds"); + let without_retired = jwks_document(active_public, []).expect("active JWKS builds"); + let (_, _, policy) = signed_fixture().await; + + assert!(verify_flattened_jws(&serialized, &with_retired, &policy).is_ok()); + assert_eq!( + verify_flattened_jws(&serialized, &without_retired, &policy), + Err(VerificationError::Key) + ); + + let mut outside_window = policy; + outside_window.now = "2026-08-03T00:00:31Z".parse().expect("time parses"); + assert_eq!( + verify_flattened_jws(&serialized, &with_retired, &outside_window), + Err(VerificationError::Time) + ); + } + + #[tokio::test] + async fn active_plus_maximum_retired_keys_is_a_usable_trusted_set() { + let (jws, _, policy) = signed_fixture().await; + let private = PrivateJwk::parse(PRIVATE_JWK).expect("active key parses"); + let active = LocalJwkSigner::new(private) + .expect("active signer builds") + .public_jwk(); + let retired = (0..32).map(|index| { + let mut key = active.clone(); + key.kid = Some(format!("retired-evidence-key-{index:02}")); + key + }); + let maximum = jwks_document(active.clone(), retired).expect("maximum JWKS builds"); + assert_eq!(maximum.keys.len(), MAX_TRUSTED_KEYS); + assert!(verify_flattened_jws(&jws, &maximum, &policy).is_ok()); + + let mut excess = maximum; + let mut extra = active; + extra.kid = Some("retired-evidence-key-excess".to_owned()); + excess + .keys + .push(serde_json::to_value(extra).expect("extra key serializes")); + assert_eq!( + verify_flattened_jws(&jws, &excess, &policy), + Err(VerificationError::Key) + ); + } + + /// Issue the fixture as an SD-JWT VC through the same signer, mapping, and + /// key set the runtime uses. + async fn issued_sd_jwt_vc() -> (String, JwksDocument, EvidenceVerificationPolicy) { + let evidence = fixture_evidence(); + let signer = fixture_signer().await; + let input = crate::sdjwt_vc::issuance_input(&evidence, None, &BTreeMap::new()) + .expect("evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("SD-JWT VC serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + (serialized, jwks, policy) + } + + async fn fixture_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("signer builds")); + EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes") + } + + /// Split an issued serialization into its JWT and its disclosures. + fn split_sd_jwt(serialized: &str) -> (String, Vec) { + let body = serialized.strip_suffix('~').expect("trailing tilde"); + let mut segments = body.split('~'); + let jwt = segments.next().expect("JWT segment").to_owned(); + (jwt, segments.map(str::to_owned).collect()) + } + + fn join_sd_jwt(jwt: &str, disclosures: &[String]) -> String { + let mut serialized = jwt.to_owned(); + for disclosure in disclosures { + serialized.push('~'); + serialized.push_str(disclosure); + } + serialized.push('~'); + serialized + } + + fn encode_disclosure(salt: &str, name: &str, value: Value) -> String { + URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&json!([salt, name, value])).expect("disclosure serializes")) + } + + /// Decode the signed JWT payload of an issued serialization. + fn sd_jwt_claims(jwt: &str) -> Map { + let encoded = jwt.split('.').nth(1).expect("payload segment"); + let decoded = URL_SAFE_NO_PAD.decode(encoded).expect("payload decodes"); + serde_json::from_slice(&decoded).expect("payload parses") + } + + /// Re-encode a JWT with a replaced segment, keeping the original signature. + fn replace_segment(jwt: &str, index: usize, replacement: &str) -> String { + let mut parts: Vec = jwt.split('.').map(str::to_owned).collect(); + parts[index] = replacement.to_owned(); + parts.join(".") + } + + #[tokio::test] + async fn sd_jwt_vc_round_trips_and_verifies_under_the_same_policy() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let evidence = + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy).expect("SD-JWT VC verifies"); + // The rebuilt payload is the payload the signed JWS would carry. + assert_eq!(evidence, fixture_evidence()); + } + + #[tokio::test] + async fn structured_value_round_trips_as_top_level_field_disclosures() { + let mut evidence = fixture_evidence(); + evidence.supported_values = vec![SupportedValue { + provides_value_for: "urn:example:concept:birth-certificate".to_owned(), + value: PublicValue::Structured(StructuredValue { + form: StructuredValueForm::ReviewedStructuredValue, + schema: "urn:example:schema:birth-certificate:v1".to_owned(), + fields: BTreeMap::from([ + ("dateOfBirth".to_owned(), json!("2000-05-23")), + ("familyName".to_owned(), json!("Smith")), + ("givenName".to_owned(), json!("John")), + ( + "placeOfBirth".to_owned(), + json!({"city": "Dusseldorf", "country": "DE"}), + ), + ]), + }), + }]; + let projections = BTreeMap::from([( + "urn:example:concept:birth-certificate".to_owned(), + "birthCertificate".to_owned(), + )]); + let signer = fixture_signer().await; + let input = crate::sdjwt_vc::issuance_input(&evidence, None, &projections) + .expect("structured evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("SD-JWT VC serializes"); + let (jwt, disclosures) = split_sd_jwt(&serialized); + let claims = sd_jwt_claims(&jwt); + assert_eq!( + claims["birthCertificate"] + .as_object() + .expect("container object") + .keys() + .collect::>(), + vec!["_sd"] + ); + assert_eq!(disclosures.len(), 4); + + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + let verified = verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy) + .expect("field-disclosed credential verifies"); + assert_eq!(verified, evidence); + } + + #[tokio::test] + async fn sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material() { + let evidence = fixture_evidence(); + let signer = fixture_signer().await; + let holder = crate::model::HolderPublicKey { + kty: "OKP".to_owned(), + crv: "Ed25519".to_owned(), + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_owned(), + alg: Some("EdDSA".to_owned()), + kid: Some("holder-1".to_owned()), + }; + let input = crate::sdjwt_vc::issuance_input(&evidence, Some(&holder), &BTreeMap::new()) + .expect("evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("SD-JWT VC serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + + let (jwt, _) = split_sd_jwt(&serialized); + let claims = sd_jwt_claims(&jwt); + assert_eq!(claims["cnf"]["jwk"]["x"], json!(holder.x)); + assert!(claims["cnf"]["jwk"].get("d").is_none()); + assert!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy).is_ok(), + "a confirmed credential still verifies" + ); + } + + #[tokio::test] + async fn sd_jwt_disclosure_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let original = URL_SAFE_NO_PAD + .decode(&disclosures[0]) + .expect("disclosure decodes"); + let members: Vec = serde_json::from_slice(&original).expect("disclosure parses"); + let salt = members[0].as_str().expect("salt is a string"); + let name = members[1].as_str().expect("name is a string"); + + let flipped = encode_disclosure(salt, name, json!(true)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[flipped]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + let renamed = encode_disclosure(salt, "urn:example:other-concept", json!(false)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[renamed]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + let unsalted = encode_disclosure("", name, json!(false)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[unsalted]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + } + + #[tokio::test] + async fn sd_jwt_added_disclosure_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + + let mut extra = disclosures.clone(); + extra.push(encode_disclosure( + "0123456789abcdef0123ab", + "urn:example:extra-concept", + json!(true), + )); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &extra).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + // Presenting the same signed disclosure twice claims one digest twice. + let mut repeated = disclosures.clone(); + repeated.push(disclosures[0].clone()); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &repeated).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + } + + #[tokio::test] + async fn sd_jwt_removed_digest_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, _) = split_sd_jwt(&serialized); + + // Version 1 issues complete credentials, so an unresolved signed digest + // is a mutation rather than a selective presentation. + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + // A stripped trailing tilde is not a valid issued serialization. + assert_eq!( + verify_sd_jwt_vc(serialized.trim_end_matches('~').as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + } + + #[tokio::test] + async fn sd_jwt_payload_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let mut claims = sd_jwt_claims(&jwt); + claims.insert( + "audience".to_owned(), + json!("urn:example:other-relying-party"), + ); + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let mutated = replace_segment(&jwt, 1, &replacement); + + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::Signature) + ); + } + + #[tokio::test] + async fn sd_jwt_protected_header_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + + for header in [ + json!({"alg": "none", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP}), + json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": "JWT"}), + json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP, "jwk": {"kty": "OKP"}}), + ] { + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); + let mutated = replace_segment(&jwt, 0, &replacement); + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::ProtectedHeader) + ); + } + } + + #[tokio::test] + async fn sd_jwt_unknown_kid_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let header = json!({ + "alg": "EdDSA", + "kid": "some-other-key", + "typ": EVIDENCE_SD_JWT_VC_TYP + }); + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); + let mutated = replace_segment(&jwt, 0, &replacement); + + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::Key) + ); + } + + #[tokio::test] + async fn sd_jwt_prohibited_claim_rejected() { + let signer = fixture_signer().await; + let evidence = fixture_evidence(); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + + for (name, value) in [ + ( + "status", + json!({"status_list": {"uri": "https://example.test/status"}}), + ), + ("aud", json!("urn:example:relying-party")), + ("nbf", json!(1_785_662_100_i64)), + ("selector", json!({"profile": "national-identifier"})), + ] { + let mut input = crate::sdjwt_vc::issuance_input(&evidence, None, &BTreeMap::new()) + .expect("evidence maps"); + if name == "status" { + input.status = Some(value.clone()); + } else { + input.public_claims.insert(name.to_owned(), value.clone()); + } + let Ok(serialized) = signer.sign_sd_jwt_vc(input).await else { + // The issuer refuses reserved claim names outright, which is a + // stronger outcome than verifier rejection. + continue; + }; + assert_eq!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy), + Err(VerificationError::Payload), + "{name} must never be published" + ); + } + } + + #[tokio::test] + async fn sd_jwt_rejected_by_flattened_jws_verifier() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + assert_eq!( + verify_flattened_jws(serialized.as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + + // The reverse also holds: a flattened JWS is not an SD-JWT VC. + let (jws, _, _) = signed_fixture().await; + assert_eq!( + verify_sd_jwt_vc(&jws, &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + } +} diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs new file mode 100644 index 000000000..c26fba1af --- /dev/null +++ b/crates/registry-evidence/tests/cli.rs @@ -0,0 +1,1227 @@ +#![cfg(unix)] + +use std::{ + collections::BTreeMap, + fs, + net::TcpStream, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +/// A value planted in every mutated artifact so a diagnostic that leaks a +/// document value fails loudly instead of quietly. +const CANARY: &str = "s3cr3t-canary-value"; + +#[test] +fn actual_binary_checks_and_evaluates_an_immutable_project() { + let staged = tempfile::tempdir().expect("temporary deployment"); + let project = Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence", + ); + copy_tree(&project.join("bundle"), &staged.path().join("bundle")); + let secret_root = staged.path().join("secrets"); + fs::create_dir(&secret_root).expect("create private secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("set private secret-root mode"); + + stage_reference_secrets(&secret_root); + + let runtime = fs::read_to_string(project.join("runtime.yaml")).expect("read runtime template"); + let bundle_path = staged.path().join("bundle"); + let bundle_directory = bundle_path.to_str().expect("temporary path is UTF-8"); + let audit_path = staged.path().join("audit.jsonl"); + let runtime = runtime + .replacen("/etc/registry-evidence/bundle", bundle_directory, 1) + .replacen( + "/run/secrets/registry-evidence", + secret_root.to_str().expect("temporary path is UTF-8"), + 1, + ) + .replacen( + "/var/lib/registry-evidence/audit/evidence.jsonl", + audit_path.to_str().expect("temporary path is UTF-8"), + 1, + ); + let runtime_path = staged.path().join("runtime.yaml"); + fs::write(&runtime_path, runtime).expect("stage runtime"); + set_tree_mode(&bundle_path, 0o555, 0o444); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("set immutable runtime mode"); + + let check = invoke(&runtime_path, &["check"]); + let evaluate = invoke( + &runtime_path, + &["evaluate", "--fixture", "fixtures/adult-status-cases.yaml"], + ); + + set_tree_mode(&bundle_path, 0o755, 0o644); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o644)) + .expect("restore runtime mode"); + assert_success( + &check, + "Evidence deployment ", + " passed check (3 requirements)\n", + ); + assert_success( + &evaluate, + "Evidence fixture passed (", + " evaluated cases)\n", + ); +} + +/// Stage the platform secrets the reference project's bundle names, with a +/// signing key generated for this run under the bundle's `activeKeyId`. +/// Source credentials stay absent: `check` must not resolve them. +fn stage_reference_secrets(secret_root: &Path) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let write = |name: &str, value: &str| { + let path = secret_root.join(name); + fs::write(&path, value).expect("write reference secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only secret mode"); + }; + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"evidence-signing-2026-01","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + write("audit-hmac-key", "audit-hash-secret-32-bytes-minimum-value"); + write( + "subject-binding-hmac-key", + "subject-binding-secret-32-bytes-minimum-value", + ); + write("signing-ed25519-private-jwk", &private_jwk); +} + +/// One deployment failure class, with the exact operator text it must produce. +/// +/// The expected text is split into a prefix and a suffix so a case that +/// reports a text location can pin the cause and the location shape without +/// pinning a line number that ordinary fixture edits would move. +struct FailureCase { + label: &'static str, + break_deployment: fn(&Deployment), + prefix: &'static str, + suffix: &'static str, +} + +#[test] +fn check_names_a_safe_artifact_and_a_value_free_cause_for_every_failure_class() { + let cases = [ + FailureCase { + label: "malformed bundle YAML", + break_deployment: |deployment| { + deployment.append("bundle/evidence.yaml", &format!("trailing: [{CANARY}\n")); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: document is not well-formed YAML (line ", + suffix: ")\n", + }, + FailureCase { + label: "unknown bundle field", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " principalClaim: sub\n", + &format!(" principalClaim: sub\n unknownField: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: unknown field at authentication (line ", + suffix: ")\n", + }, + FailureCase { + label: "wrong bundle field type", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + "version: 1\n", + &format!("version: \"{CANARY}\"\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: field has the wrong type at version (line ", + suffix: ")\n", + }, + FailureCase { + label: "unaccepted bundle field variant", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " kind: oidc-access-token\n", + &format!(" kind: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: field value is not one of the accepted variants at authentication.kind (line ", + suffix: ")\n", + }, + FailureCase { + label: "configuration cross-reference", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " source: source-a\n", + &format!(" source: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: requirement references an unknown source\n", + suffix: "", + }, + FailureCase { + label: "artifact closure references a missing file", + break_deployment: |deployment| { + deployment.remove("bundle/derivations/adult-status.rhai"); + }, + prefix: "evidence: deployment artifact closure is invalid: artifact derivations/adult-status.rhai: the configuration references an artifact the bundle does not contain\n", + suffix: "", + }, + FailureCase { + label: "artifact closure carries an unreferenced file", + break_deployment: |deployment| { + deployment.write("bundle/schemas/orphan.schema.yaml", &format!("x: {CANARY}\n")); + }, + prefix: "evidence: deployment artifact closure is invalid: artifact schemas/orphan.schema.yaml: the bundle contains an artifact the configuration does not reference\n", + suffix: "", + }, + FailureCase { + label: "unsafe artifact name is never echoed", + break_deployment: |deployment| { + deployment.write( + &format!("bundle/fixtures/orphan {CANARY}.yaml"), + "synthetic_only: true\n", + ); + }, + prefix: "evidence: deployment artifact closure is invalid: the bundle contains an artifact the configuration does not reference\n", + suffix: "", + }, + FailureCase { + label: "script", + break_deployment: |deployment| { + deployment.append( + "bundle/derivations/adult-status.rhai", + &format!("\nthis is not rhai {CANARY}(((\n"), + ); + }, + prefix: "evidence: deployment script is invalid: artifact derivations/adult-status.rhai: script does not compile\n", + suffix: "", + }, + FailureCase { + label: "fact schema", + break_deployment: |deployment| { + deployment.write( + "bundle/schemas/adult-status-facts.schema.yaml", + &format!("type: [{CANARY}]\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact schemas/adult-status-facts.schema.yaml: fact schema must close the root object\n", + suffix: "", + }, + FailureCase { + label: "codelist", + break_deployment: |deployment| { + deployment.write( + "bundle/codelists/residence-region-map.yaml", + &format!("id: broken\nversion: \"1\"\nentries: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact codelists/residence-region-map.yaml: codelist YAML is invalid\n", + suffix: "", + }, + FailureCase { + label: "fixture", + break_deployment: |deployment| { + deployment.write( + "bundle/fixtures/adult-status-cases.yaml", + &format!("synthetic_only: true\ncases: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact fixtures/adult-status-cases.yaml: fixture cases are missing\n", + suffix: "", + }, + FailureCase { + label: "unknown runtime field", + break_deployment: |deployment| { + deployment.append("runtime.yaml", &format!("unknownField: {CANARY}\n")); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: unknown field (line ", + suffix: ")\n", + }, + FailureCase { + label: "wrong runtime field type", + break_deployment: |deployment| { + deployment.replace( + "runtime.yaml", + " port: 8080\n", + &format!(" port: \"{CANARY}\"\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: field has the wrong type at listener.port (line ", + suffix: ")\n", + }, + FailureCase { + label: "runtime operator path", + break_deployment: |deployment| { + deployment.replace_line( + "runtime.yaml", + "bundleDirectory: ", + &format!("bundleDirectory: relative/{CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: absolute operator path is invalid\n", + suffix: "", + }, + ]; + + for case in cases { + let deployment = Deployment::stage("all-definitions"); + (case.break_deployment)(&deployment); + let output = deployment.check(); + + assert!( + !output.status.success(), + "{}: check accepted a broken deployment", + case.label + ); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert!(stdout.is_empty(), "{}: check wrote output", case.label); + assert!( + stderr.starts_with(case.prefix) && stderr.ends_with(case.suffix), + "{}: unexpected diagnostic {stderr:?}", + case.label + ); + assert!( + !stdout.contains(CANARY) && !stderr.contains(CANARY), + "{}: diagnostic disclosed a document value", + case.label + ); + } +} + +/// Secret material the server would refuse at startup must already fail +/// `check`, with the same fixed operator message startup produces. Each case +/// stages the complete acceptance secret set and then breaks exactly one +/// piece of it. +#[test] +fn check_rejects_secret_material_the_server_would_refuse_at_startup() { + struct SecretFailureCase { + label: &'static str, + break_secrets: fn(&Deployment), + expected: &'static str, + } + let cases = [ + SecretFailureCase { + label: "signing key kid differs from the bundle's activeKeyId", + break_secrets: |deployment| deployment.write_mismatched_signing_key(), + expected: "evidence: runtime signing initialization failed\n", + }, + SecretFailureCase { + label: "audit hash key below the minimum length", + break_secrets: |deployment| deployment.write_secret("audit-hash-key", "short"), + expected: "evidence: runtime audit initialization failed: the audit hash secret is \ + unusable\n", + }, + SecretFailureCase { + label: "subject binding key missing", + break_secrets: |deployment| deployment.remove("secrets/subject-binding-key"), + expected: "evidence: runtime secret initialization failed\n", + }, + ]; + + for case in cases { + let deployment = Deployment::stage("all-definitions"); + deployment.stage_acceptance_secrets(); + (case.break_secrets)(&deployment); + let output = deployment.check(); + + assert!( + !output.status.success(), + "{}: check accepted secret material the server would refuse", + case.label + ); + assert!( + output.stdout.is_empty(), + "{}: check wrote output for a refused deployment", + case.label + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, case.expected, + "{}: unexpected diagnostic", + case.label + ); + } +} + +/// One audit initialization failure class, with the exact operator text it +/// must produce and any handle the fault needs held open while `serve` runs. +struct AuditFaultCase { + label: &'static str, + break_audit: fn(&Deployment) -> Option, + expected: &'static str, +} + +/// The audit boundary refuses to start for unrelated reasons, and from outside +/// the process they are indistinguishable: a mode an operator fixes with +/// `chmod`, a chain that no longer verifies, and a second writer already +/// holding the sink lock are three different questions with three different +/// answers. Each names itself, and none of them names the audit path, which +/// the operator already has in the runtime file. +#[test] +fn serve_names_why_the_audit_boundary_refused_to_initialize() { + let cases = [ + AuditFaultCase { + label: "an audit file readable beyond its owner", + break_audit: |deployment| { + deployment.stage_audit_chain(""); + set_mode(&deployment.path("audit.jsonl"), 0o644); + None + }, + expected: "evidence: runtime audit initialization failed: the audit file or lock is \ + not owner-only, or its directory is unavailable or not owner-controlled\n", + }, + AuditFaultCase { + label: "an audit chain that does not verify", + break_audit: |deployment| { + deployment.stage_audit_chain("{\"not\":\"an audit record\"}\n"); + None + }, + expected: "evidence: runtime audit initialization failed: the existing audit chain \ + did not verify\n", + }, + AuditFaultCase { + label: "a second writer holding the audit sink lock", + break_audit: |deployment| { + let path = deployment.path("audit.jsonl.lock"); + fs::write(&path, "").expect("stage audit sink lock"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only audit lock mode"); + let held = fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("open audit sink lock"); + held.try_lock().expect("hold the audit sink lock"); + Some(held) + }, + expected: "evidence: runtime audit initialization failed: another writer already \ + holds the audit sink lock\n", + }, + ]; + + for case in cases { + let deployment = Deployment::stage_on_port("all-definitions", free_port()); + deployment.stage_acceptance_secrets(); + deployment.seal(); + let _held = (case.break_audit)(&deployment); + let output = invoke(&deployment.path("runtime.yaml"), &["serve"]); + deployment.unseal(); + + assert!( + !output.status.success(), + "{}: serve started on a refused audit boundary", + case.label + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, case.expected, + "{}: unexpected diagnostic", + case.label + ); + } +} + +/// The documented audit rotation procedure, executed against the real binary. +/// +/// The procedure is: stop the service with SIGTERM, archive the audit file by +/// rename, start the service again on the same path, and confirm readiness on +/// the new chain. This proves the stop and start-new-chain steps of the +/// operator procedure and the SIGTERM handling that makes the stop step +/// possible at all. +#[test] +fn serve_stops_on_sigterm_and_restarts_on_an_archived_audit_chain() { + let port = free_port(); + let deployment = Deployment::stage_on_port("all-definitions", port); + deployment.stage_acceptance_secrets(); + deployment.seal(); + + let mut service = deployment.serve(); + wait_until_ready(port); + let first = deployment.path("audit.jsonl"); + assert!(first.is_file(), "the service did not open an audit chain"); + stop(&mut service); + + // Archive by rename: the audit file must stay a singly linked owner-only + // regular file, so a copy-and-truncate rotation is not the procedure. + let archive = deployment.path("audit-archived.jsonl"); + fs::rename(&first, &archive).expect("archive the audit chain"); + assert!(!first.exists(), "the archived chain was left in place"); + + let mut restarted = deployment.serve(); + wait_until_ready(port); + assert!(first.is_file(), "the restart did not start a new chain"); + stop(&mut restarted); + + assert!(archive.is_file(), "the archived chain was disturbed"); + + // Rollback is the same stop, rename, start sequence in reverse: the new + // chain is set aside and the archived chain resumes at the original path. + let superseded = deployment.path("audit-superseded.jsonl"); + fs::rename(&first, &superseded).expect("set the new chain aside"); + fs::rename(&archive, &first).expect("restore the archived chain"); + let mut rolled_back = deployment.serve(); + wait_until_ready(port); + stop(&mut rolled_back); + assert!( + superseded.is_file(), + "the superseded chain was disturbed during rollback" + ); + deployment.unseal(); +} + +/// The staged verification key identifier, echoed by the protected header. +const VERIFY_KEY_ID: &str = "verify-fixture-key"; + +/// A staged Ed25519 test key. It signs fixture assertions in this test binary +/// only and is not a deployment key. +const VERIFY_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"verify-fixture-key"}"#; + +/// A staged request nonce, of the exact 43-character request-nonce shape. +const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; + +/// A different nonce of the same shape, carrying the canary so a policy +/// diagnostic that echoed the expected value would fail loudly. +const CANARY_NONCE: &str = "s3cr3t-canary-value000000000000000000000000"; + +#[test] +fn verify_accepts_an_authentic_and_current_stored_response() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_eq!( + output.status.code(), + Some(0), + "verify rejected a good response" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.starts_with( + "verified-at: 2026-08-02T12:00:00Z\nauthentic: yes\ncurrently-valid: yes\n" + ), + "unexpected verification output {stdout:?}" + ); + assert!( + stdout.contains(&format!("\"requestNonce\": \"{FIXTURE_NONCE}\"")), + "verify did not print the verified Evidence for inspection" + ); +} + +#[test] +fn verify_separates_authenticity_from_current_validity() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + let output = stored.verify(Some("2026-08-05T00:00:00Z")); + + assert_eq!( + output.status.code(), + Some(3), + "an expired response did not report its own exit status" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + assert_eq!( + std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"), + "verified-at: 2026-08-05T00:00:00Z\nauthentic: yes\ncurrently-valid: no\n", + "an expired response must stay authentic without being current" + ); +} + +#[test] +fn verify_rejects_a_tampered_payload_without_naming_a_value() { + let mut tampered = fixture_evidence(); + tampered["supportedValues"][0]["value"] = serde_json::Value::String(CANARY.to_owned()); + let stored = StoredResponse::stage(&fixture_evidence(), &tampered, &fixture_policy()); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (signature)\n", + ); +} + +#[test] +fn verify_reports_only_the_generic_policy_class_for_a_wrong_expected_nonce() { + let policy = fixture_policy().replacen(FIXTURE_NONCE, CANARY_NONCE, 1); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (policy)\n", + ); +} + +#[test] +fn verify_rejects_a_policy_document_with_an_unknown_field() { + let policy = format!("{}unknownField: {CANARY}\n", fixture_policy()); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "", + "evidence: stored response verification failed (malformed)\n", + ); +} + +#[test] +fn verify_rejects_a_verification_instant_that_is_not_strict_utc() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + + for at in ["2026-08-02T12:00:00+02:00", "2026-08-02", CANARY] { + let output = stored.verify(Some(at)); + assert_eq!(output.status.code(), Some(1), "verify accepted {at:?}"); + assert!( + output.stdout.is_empty(), + "verify printed an unusable instant" + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, "evidence: verification instant is not strict RFC 3339 UTC\n", + "unexpected verification diagnostic" + ); + } +} + +#[test] +fn verify_accepts_an_authentic_and_current_stored_sd_jwt_vc() { + let stored = StoredCredential::stage(&fixture_policy(), |credential| credential); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_eq!( + output.status.code(), + Some(0), + "verify rejected a good credential" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.contains("authentic: yes\n") && stdout.contains("currently-valid: yes\n"), + "verify did not report the credential as authentic and current" + ); + assert!( + stdout.contains("urn:example:concept"), + "verify did not print the rebuilt Evidence for inspection" + ); +} + +#[test] +fn verify_rejects_a_stored_sd_jwt_vc_whose_disclosure_was_replaced() { + // Substitute a well-formed disclosure of the same claim with the opposite + // value. Its digest is absent from the signed `_sd`, so the credential + // fails without the signature itself being touched. + let stored = StoredCredential::stage(&fixture_policy(), |credential| { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let body = credential + .strip_suffix('~') + .expect("the credential ends with the key-binding terminator"); + let (jwt, disclosure) = body.split_once('~').expect("the credential discloses"); + let decoded: serde_json::Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(disclosure) + .expect("disclosure decodes"), + ) + .expect("disclosure parses"); + let replaced = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!([decoded[0], decoded[1], true])) + .expect("disclosure serializes"), + ); + format!("{jwt}~{replaced}~") + }); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (disclosure)\n", + ); +} + +#[test] +fn verify_requires_exactly_one_stored_response_format() { + let stored = StoredCredential::stage(&fixture_policy(), |credential| credential); + for arguments in [ + vec![], + vec![ + "--jws".to_owned(), + stored.path("response.sd-jwt").display().to_string(), + "--sd-jwt-vc".to_owned(), + stored.path("response.sd-jwt").display().to_string(), + ], + ] { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .args(&arguments) + .arg("--jwks") + .arg(stored.path("trusted.jwks.json")) + .arg("--policy") + .arg(stored.path("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + let output = command.output().expect("evidence binary starts"); + assert_eq!( + output.status.code(), + Some(2), + "verify accepted an ambiguous stored-response selection" + ); + assert!( + output.stdout.is_empty(), + "verify began before selecting a stored response" + ); + } +} + +/// Assert one closed verification failure: exit 1, the chosen instant, the +/// expected remaining stdout, only the closed class on stderr, and no leaked +/// document value on either stream. +fn assert_verification_failure( + output: &Output, + instant: &str, + remaining_stdout: &str, + stderr: &str, +) { + assert_eq!(output.status.code(), Some(1), "verify accepted a bad input"); + let printed_out = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + let printed_err = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + printed_out, + format!("verified-at: {instant}\n{remaining_stdout}"), + "unexpected verification output" + ); + assert_eq!(printed_err, stderr, "unexpected verification diagnostic"); + assert!( + !printed_out.contains(CANARY) && !printed_err.contains(CANARY), + "verification disclosed a document value" + ); +} + +/// The stored Evidence payload the verify tests sign and re-verify. +fn fixture_evidence() -> serde_json::Value { + serde_json::json!({ + "schema": "registry.assertion-evidence/v1", + "assuranceProfile": "evidence-grade", + "requestNonce": FIXTURE_NONCE, + "id": "urn:ulid:01K1EXAMPLE0000000000000000", + "type": "Evidence", + "supportsRequirement": "urn:example:requirement:v1", + "isConformantTo": "urn:example:type:v1", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "casework", + "audience": "urn:example:audience", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "subjects": [{"role": "subject", "binding": format!("urn:evidence:subject:v1_{}", "A".repeat(43))}], + "supportedValues": [{"providesValueFor": "urn:example:concept", "value": false}], + }) +} + +/// The relying-procedure policy matching that payload. +/// +/// A real relying party builds this from independently retained trusted state. +/// The test simulates that state from the fixture it controls. +fn fixture_policy() -> String { + format!( + "expectedAssuranceProfile: evidence-grade +issuedBy: urn:example:issuer +providedBy: urn:example:provider +requirement: urn:example:requirement:v1 +evidenceType: urn:example:type:v1 +purpose: casework +audience: urn:example:audience +configurationRevision: sha256:{revision} +requestNonce: {FIXTURE_NONCE} +expectedSubjects: + - role: subject + binding: urn:evidence:subject:v1_{binding} +expectedOutputs: + - concept: urn:example:concept + form: boolean +maximumAssertionLifetimeSeconds: 172800 +clockSkewSeconds: 30 +", + revision = "0".repeat(64), + binding = "A".repeat(43), + ) +} + +/// The three files an operator holds for offline re-verification: one stored +/// signed response, one pinned trusted key set, and one policy document. +struct StoredResponse { + root: tempfile::TempDir, +} + +impl StoredResponse { + /// Sign `signed`, store `stored` as the response payload, and stage + /// `policy`. Passing different payloads produces a tampered response whose + /// signature no longer covers the stored bytes. + fn stage(signed: &serde_json::Value, stored: &serde_json::Value, policy: &str) -> Self { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::Signer as _; + + let root = tempfile::tempdir().expect("temporary verification inputs"); + let key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let protected = URL_SAFE_NO_PAD.encode(format!( + r#"{{"alg":"EdDSA","kid":"{VERIFY_KEY_ID}","typ":"evidence+jws","cty":"application/evidence+json"}}"# + )); + let signed_payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(signed).expect("Evidence payload serializes")); + let signature = URL_SAFE_NO_PAD.encode( + key.sign(format!("{protected}.{signed_payload}").as_bytes()) + .to_bytes(), + ); + let stored_payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(stored).expect("Evidence payload serializes")); + + fs::write( + root.path().join("response.jws.json"), + format!( + r#"{{"protected":"{protected}","payload":"{stored_payload}","signature":"{signature}"}}"# + ), + ) + .expect("stage the stored response"); + fs::write( + root.path().join("trusted.jwks.json"), + format!( + r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"{VERIFY_KEY_ID}","x":"{}"}}]}}"#, + URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()) + ), + ) + .expect("stage the pinned key set"); + fs::write(root.path().join("policy.yaml"), policy).expect("stage the policy"); + Self { root } + } + + /// Run `verify` with no runtime file staged, so the command proves it needs + /// no deployment and opens no socket. + fn verify(&self, at: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .arg("--jws") + .arg(self.root.path().join("response.jws.json")) + .arg("--jwks") + .arg(self.root.path().join("trusted.jwks.json")) + .arg("--policy") + .arg(self.root.path().join("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + if let Some(at) = at { + command.arg("--at").arg(at); + } + command.output().expect("evidence binary starts") + } +} + +/// The SD-JWT VC counterpart of `StoredResponse`. The same assertion is +/// serialized through the production issuance path, so the command is proven +/// against the bytes an adopter actually receives rather than a hand-built +/// approximation. +struct StoredCredential { + root: tempfile::TempDir, +} + +impl StoredCredential { + /// Issue the fixture assertion, apply `mutate` to the serialization, and + /// stage it beside the pinned key set and the policy. + fn stage(policy: &str, mutate: impl FnOnce(String) -> String) -> Self { + use registry_evidence::{ + model::Evidence, + sdjwt_vc::issuance_input, + signing::{jwks_document, EvidenceSigner}, + }; + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use std::sync::Arc; + + let root = tempfile::tempdir().expect("temporary verification inputs"); + let evidence: Evidence = + serde_json::from_value(fixture_evidence()).expect("the fixture is an Evidence payload"); + let private = PrivateJwk::parse(VERIFY_PRIVATE_JWK).expect("fixture key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + + let (credential, trusted) = tokio::runtime::Runtime::new() + .expect("issuance runtime starts") + .block_on(async { + let signer = EvidenceSigner::initialize(provider, VERIFY_KEY_ID) + .await + .expect("signer initializes"); + let input = + issuance_input(&evidence, None, &BTreeMap::new()).expect("the fixture maps"); + let credential = signer + .sign_sd_jwt_vc(input) + .await + .expect("credential serializes"); + let trusted = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + (credential, trusted) + }); + + fs::write(root.path().join("response.sd-jwt"), mutate(credential)) + .expect("stage the stored credential"); + fs::write( + root.path().join("trusted.jwks.json"), + serde_json::to_vec(&trusted).expect("JWKS serializes"), + ) + .expect("stage the pinned key set"); + fs::write(root.path().join("policy.yaml"), policy).expect("stage the policy"); + Self { root } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + fn verify(&self, at: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .arg("--sd-jwt-vc") + .arg(self.path("response.sd-jwt")) + .arg("--jwks") + .arg(self.path("trusted.jwks.json")) + .arg("--policy") + .arg(self.path("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + if let Some(at) = at { + command.arg("--at").arg(at); + } + command.output().expect("evidence binary starts") + } +} + +fn stop(service: &mut Child) { + let pid = rustix::process::Pid::from_raw( + i32::try_from(service.id()).expect("child identifier is a pid"), + ) + .expect("child identifier is a pid"); + rustix::process::kill_process(pid, rustix::process::Signal::TERM).expect("send SIGTERM"); + let status = service.wait().expect("service exits"); + assert!( + status.success(), + "SIGTERM did not stop the service cleanly: {status}" + ); +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("reserve a local port") + .local_addr() + .expect("reserved port") + .port() +} + +/// Poll `/ready` until the service reports a healthy audit chain. +/// +/// Readiness covers the subject-binding key, the signer, the audit chain head, +/// and every source credential, so a ready service proves the whole startup +/// path completed rather than only that a socket is open. +fn wait_until_ready(port: u16) { + let deadline = Instant::now() + Duration::from_secs(20); + let mut last = String::new(); + while Instant::now() < deadline { + if let Some(status) = probe(port, "/ready") { + if status == "HTTP/1.1 200 OK" { + return; + } + last = status; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("the service never became ready (last status {last:?})"); +} + +fn probe(port: u16, path: &str) -> Option { + use std::io::{BufRead as _, BufReader, Write as _}; + + let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut status = String::new(); + BufReader::new(stream).read_line(&mut status).ok()?; + Some(status.trim_end().to_owned()) +} + +/// A staged deployment: one acceptance bundle, one operator runtime file, and +/// one private secret root under a single temporary directory. +struct Deployment { + root: tempfile::TempDir, + port: u16, +} + +impl Deployment { + fn stage(case: &str) -> Self { + Self::stage_on_port(case, 8080) + } + + fn stage_on_port(case: &str, port: u16) -> Self { + let deployment = Self { + root: tempfile::tempdir().expect("temporary deployment"), + port, + }; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(case); + copy_tree(&source, &deployment.path("bundle")); + let secrets = deployment.path("secrets"); + fs::create_dir(&secrets).expect("create private secret root"); + fs::set_permissions(&secrets, fs::Permissions::from_mode(0o700)) + .expect("set private secret-root mode"); + fs::write( + deployment.path("runtime.yaml"), + deployment.runtime_document(), + ) + .expect("stage runtime"); + deployment + } + + fn path(&self, relative: &str) -> PathBuf { + self.root.path().join(relative) + } + + fn runtime_document(&self) -> String { + format!( + "version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {port} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 5000 +secretProviders: + file: + root: {secrets} +auditStorage: + path: {audit} + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {{}} +", + bundle = self.path("bundle").display(), + port = self.port, + secrets = self.path("secrets").display(), + audit = self.path("audit.jsonl").display(), + ) + } + + fn write(&self, relative: &str, contents: &str) { + fs::write(self.path(relative), contents).expect("write staged artifact"); + } + + fn append(&self, relative: &str, contents: &str) { + let path = self.path(relative); + let mut text = fs::read_to_string(&path).expect("read staged artifact"); + text.push_str(contents); + fs::write(path, text).expect("write staged artifact"); + } + + fn remove(&self, relative: &str) { + fs::remove_file(self.path(relative)).expect("remove staged artifact"); + } + + fn replace(&self, relative: &str, from: &str, to: &str) { + let path = self.path(relative); + let text = fs::read_to_string(&path).expect("read staged artifact"); + assert!(text.contains(from), "staged artifact has no {from:?}"); + fs::write(path, text.replacen(from, to, 1)).expect("write staged artifact"); + } + + fn replace_line(&self, relative: &str, prefix: &str, line: &str) { + let path = self.path(relative); + let text = fs::read_to_string(&path).expect("read staged artifact"); + let replaced = text + .lines() + .map(|current| { + if current.starts_with(prefix) { + line.to_owned() + } else { + format!("{current}\n") + } + }) + .collect::(); + assert_ne!(replaced, text, "staged artifact has no {prefix:?} line"); + fs::write(path, replaced).expect("write staged artifact"); + } + + fn write_secret(&self, name: &str, value: &str) { + let path = self.path("secrets").join(name); + fs::write(&path, value).expect("write staged secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only secret mode"); + } + + /// Stage every logical secret the acceptance bundle references. + /// + /// The signing key is generated for this run so no private key material is + /// tracked, and the source credentials are synthetic constants that never + /// reach a network because the test performs no evidence request. + fn stage_acceptance_secrets(&self) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fixture-key-2026-01","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + self.write_secret("audit-hash-key", "audit-hash-secret-32-bytes-minimum-value"); + self.write_secret( + "subject-binding-key", + "subject-binding-secret-32-bytes-minimum-value", + ); + self.write_secret("signing-key", &private_jwk); + self.write_secret("source-a-token", "synthetic-source-token"); + self.write_secret("source-b-token", "synthetic-source-token"); + self.write_secret("source-c-username", "synthetic-source-user"); + self.write_secret("source-c-password", "synthetic-source-password"); + self.write_secret("source-d-token", "synthetic-source-token"); + } + + /// Place an audit chain the service will find on start, owner-only as the + /// sink requires. A case that is about a mode widens it afterwards. + fn stage_audit_chain(&self, contents: &str) { + let path = self.path("audit.jsonl"); + fs::write(&path, contents).expect("stage audit chain"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only audit chain mode"); + } + + /// Overwrite the staged signing key with a fresh key whose kid is not + /// the bundle's `signing.activeKeyId`. + fn write_mismatched_signing_key(&self) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"not-the-active-key","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + self.write_secret("signing-key", &private_jwk); + } + + /// Start `serve` against the sealed deployment. + fn serve(&self) -> Child { + Command::new(env!("CARGO_BIN_EXE_evidence")) + .arg("--runtime") + .arg(self.path("runtime.yaml")) + .arg("serve") + .env_remove("REGISTRY_EVIDENCE_RUNTIME") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("evidence service starts") + } + + /// Run `check` against the sealed deployment, then restore write access so + /// the temporary directory can be cleaned up. + fn check(&self) -> Output { + self.seal(); + let output = invoke(&self.path("runtime.yaml"), &["check"]); + self.unseal(); + output + } + + fn seal(&self) { + set_tree_mode(&self.path("bundle"), 0o555, 0o444); + fs::set_permissions(self.path("runtime.yaml"), fs::Permissions::from_mode(0o444)) + .expect("seal runtime"); + } + + fn unseal(&self) { + set_tree_mode(&self.path("bundle"), 0o755, 0o644); + fs::set_permissions(self.path("runtime.yaml"), fs::Permissions::from_mode(0o644)) + .expect("unseal runtime"); + } +} + +impl Drop for Deployment { + fn drop(&mut self) { + if self.path("bundle").is_dir() { + set_tree_mode(&self.path("bundle"), 0o755, 0o644); + } + } +} + +fn invoke(runtime: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidence")) + .arg("--runtime") + .arg(runtime) + .args(arguments) + .env_remove("REGISTRY_EVIDENCE_RUNTIME") + .output() + .expect("evidence binary starts") +} + +fn assert_success(output: &Output, prefix: &str, suffix: &str) { + assert!(output.status.success(), "evidence command failed"); + assert!( + output.stderr.is_empty(), + "evidence command wrote diagnostics" + ); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.starts_with(prefix) && stdout.ends_with(suffix), + "evidence command output shape changed" + ); +} + +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create staged directory"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source entry type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy staged artifact"); + } + } +} + +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set staged mode"); +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + let metadata = fs::symlink_metadata(path).expect("staged path metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read staged tree") { + set_tree_mode( + &entry.expect("staged entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set staged directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set staged file mode"); + } +} diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs new file mode 100644 index 000000000..03e765ea3 --- /dev/null +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -0,0 +1,1295 @@ +//! Executable proof for the complete Evidence Version 1 reference deployments. + +#![cfg(unix)] + +use std::collections::BTreeSet; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::bundle::{Bundle, DeploymentInputs}; +use registry_evidence::config::{ConfigError, SelectorInput}; +use registry_evidence::kernel::{ + EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection, +}; +use registry_evidence::model::{ + LookupResult, PublicValue, ScalarOrEntityReference, SubjectBinding, +}; +use registry_evidence::problem::ProblemCode; +use registry_evidence::rhai_runtime::{QueryPair, RequestParts}; +use registry_evidence::runtime::source_failure_problem; +use registry_evidence::selector::{resolve_offline_fixture_authorization, ResolvedAuthorization}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{project_fixture_response, SourceError}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use serde::Deserialize; +use serde_json::{Map as JsonMap, Value}; +use tempfile::TempDir; +use zeroize::Zeroizing; + +const AUDIENCE: &str = "urn:registry-evidence:reference-project-fixtures"; +const BINDING_KEY: &[u8] = b"reference-project-binding-key-v1"; +const TEST_CA: &str = "-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureContract { + fixture: String, + synthetic_only: bool, + common: FixtureCommon, + cases: Vec, + #[serde(rename = "privacyExpectation")] + privacy_expectation: PrivacyExpectation, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureCommon { + observed_at: String, + #[serde(default)] + purpose: Option, + selectors: Value, + #[serde(default, rename = "verified_token_claims")] + verified_token_claims: Option, + #[serde(default, rename = "derivationSelectorInputs")] + derivation_selector_inputs: Option, + #[serde(rename = "expectedRequestParts")] + expected_request_parts: ExpectedRequestParts, + #[serde(rename = "expectedTransport")] + expected_transport: ExpectedTransport, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureCase { + id: String, + #[serde(default)] + purpose: Option, + #[serde(default)] + response: Option, + #[serde(default, rename = "sourceFailure")] + source_failure: Option, + #[serde(default, rename = "bundleMutation")] + bundle_mutation: Option, + #[serde(default, rename = "requestMutation")] + request_mutation: Option, + #[serde(default, rename = "derivationMutation")] + derivation_mutation: Option, + #[serde(default, rename = "derivationParameterMutation")] + derivation_parameter_mutation: Option>, + #[serde(default, rename = "selectorOverrides")] + selector_overrides: Option, + #[serde(default)] + observed_at: Option, + expected: Expected, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct Expected { + #[serde(default)] + lookup: Option, + #[serde(default)] + facts: Option, + #[serde(default)] + value: Option, + #[serde(default)] + values: Option, + #[serde(default, rename = "entityReferenceCount")] + entity_reference_count: Option, + #[serde(default, rename = "rawReferencesDisclosed")] + raw_references_disclosed: Option, + #[serde(default)] + signed: Option, + #[serde(default, rename = "publicProblem")] + public_problem: Option, + #[serde(default)] + error: Option, + #[serde(default, rename = "derivationRuns")] + derivation_runs: Option, + #[serde(default)] + bundle: Option, + #[serde(default, rename = "outputGate")] + output_gate: Option, + #[serde(default, rename = "rejectedBefore")] + rejected_before: Option, + #[serde(default, rename = "sourceRequestCount")] + source_request_count: Option, + #[serde(default, rename = "expectedTransport")] + expected_transport: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedRequestParts { + query: Vec, + body: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedQueryPair { + name: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransport { + #[serde(default)] + path: Option, + #[serde(default)] + query: Option, + #[serde(default)] + body: Option, + #[serde(default, rename = "fixedHeaders")] + fixed_headers: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedHeader { + name: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrivacyExpectation { + #[serde(rename = "evidenceContains")] + evidence_contains: Vec, + #[serde(rename = "evidenceExcludes")] + evidence_excludes: Vec, + #[serde(rename = "diagnosticsExclude")] + diagnostics_exclude: Vec, +} + +struct LoadedProject { + _temporary: TempDir, + runtime_path: PathBuf, + bundle: Arc, + kernel: OfflineKernel, +} + +struct FixtureExecution<'a> { + bundle: &'a Arc, + kernel: &'a OfflineKernel, + requirement: &'a registry_evidence::config::RequirementConfig, + fixture: &'a FixtureContract, + signer: &'a EvidenceSigner, +} + +impl Drop for LoadedProject { + fn drop(&mut self) { + set_tree_mode(self._temporary.path(), 0o755, 0o644); + } +} + +#[tokio::test] +async fn reference_deployment_projects_execute_the_closed_fixture_contract() { + for project_name in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { + let project = load_project(project_name); + let signer = fixture_signer().await; + for requirement in &project.bundle.config.requirements { + let fixture_value = project + .bundle + .fixtures + .get( + requirement + .fixtures + .as_ref() + .expect("reference fixture is declared") + .as_str(), + ) + .unwrap_or_else(|| panic!("{project_name}: fixture artifact missing")); + let fixture: FixtureContract = serde_json::from_value( + serde_json::to_value(fixture_value) + .unwrap_or_else(|_| panic!("{project_name}: fixture conversion failed")), + ) + .unwrap_or_else(|_| panic!("{project_name}: fixture vocabulary is not closed")); + validate_contract_shape(project_name, &fixture); + execute_fixture( + project_name, + &project.bundle, + &project.kernel, + requirement, + &fixture, + &signer, + ) + .await; + } + + assert!(project.runtime_path.exists()); + } +} + +fn load_project(project_name: &str) -> LoadedProject { + let project_root = projects_root().join(project_name); + let original_runtime = fs::read_to_string(project_root.join("runtime.yaml")) + .unwrap_or_else(|_| panic!("{project_name}: runtime is unreadable")); + registry_evidence::config::RuntimeConfig::parse_yaml(original_runtime.as_bytes()) + .unwrap_or_else(|_| panic!("{project_name}: checked-in runtime is invalid")); + + let temporary = tempfile::tempdir().expect("temporary project deployment"); + let bundle_root = temporary.path().join("bundle"); + fs::create_dir(&bundle_root).expect("temporary bundle root"); + copy_tree(&project_root.join("bundle"), &bundle_root); + + let secret_root = temporary.path().join("secrets"); + fs::create_dir(&secret_root).expect("temporary secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("temporary secret root is private"); + let audit_path = temporary.path().join("audit.jsonl"); + let ca_path = temporary.path().join("reference-ca.pem"); + fs::write(&ca_path, TEST_CA).expect("temporary CA writes"); + + let mut local_runtime = original_runtime; + replace_once( + &mut local_runtime, + "/etc/registry-evidence/bundle", + &bundle_root.display().to_string(), + ); + replace_once( + &mut local_runtime, + "/run/secrets/registry-evidence", + &secret_root.display().to_string(), + ); + replace_once( + &mut local_runtime, + "/var/lib/registry-evidence/audit/evidence.jsonl", + &audit_path.display().to_string(), + ); + if local_runtime.contains("/etc/registry-evidence/ca/government-internal.pem") { + replace_once( + &mut local_runtime, + "/etc/registry-evidence/ca/government-internal.pem", + &ca_path.display().to_string(), + ); + } + let runtime_path = temporary.path().join("runtime.yaml"); + fs::write(&runtime_path, local_runtime).expect("temporary runtime writes"); + set_tree_mode(&bundle_root, 0o555, 0o444); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("temporary runtime is immutable"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)) + .expect("temporary CA is immutable"); + + let deployment = DeploymentInputs::load(&runtime_path).unwrap_or_else(|error| { + panic!("{project_name}: production deployment loading failed: {error:?}") + }); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .unwrap_or_else(|_| panic!("{project_name}: production kernel compilation failed")); + LoadedProject { + _temporary: temporary, + runtime_path, + bundle, + kernel, + } +} + +fn validate_contract_shape(project_name: &str, fixture: &FixtureContract) { + assert!( + fixture.synthetic_only, + "{project_name}: fixture is not synthetic" + ); + assert!( + fixture.fixture.starts_with("registry.evidence.reference.") + && fixture.fixture.ends_with("/v1"), + "{project_name}: fixture identifier is invalid" + ); + assert!( + !fixture.cases.is_empty() && fixture.cases.len() <= 256, + "{project_name}: fixture case count is invalid" + ); + let mut ids = BTreeSet::new(); + for case in &fixture.cases { + assert!( + !case.id.is_empty() && case.id.len() <= 128 && ids.insert(case.id.as_str()), + "{project_name}: fixture case identifier is invalid or duplicated" + ); + let primary_inputs = [ + case.response.is_some(), + case.source_failure.is_some(), + case.bundle_mutation.is_some(), + case.request_mutation.is_some(), + case.derivation_mutation.is_some(), + case.derivation_parameter_mutation.is_some(), + case.selector_overrides.is_some(), + ] + .into_iter() + .filter(|present| *present) + .count(); + assert_eq!( + primary_inputs, 1, + "{project_name}/{}: case input form is not closed", + case.id + ); + assert!( + !(case.expected.value.is_some() && case.expected.values.is_some()), + "{project_name}/{}: a case states either one concept value or the complete concept map", + case.id + ); + } +} + +async fn execute_fixture( + project_name: &str, + bundle: &Arc, + kernel: &OfflineKernel, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &FixtureContract, + signer: &EvidenceSigner, +) { + let execution = FixtureExecution { + bundle, + kernel, + requirement, + fixture, + signer, + }; + let mut verified_payloads = Vec::new(); + for case in &fixture.cases { + let label = format!("{project_name}/{}", case.id); + if let Some(mutation) = &case.bundle_mutation { + require_name(&label, mutation, "duplicate-disclosure-family"); + execute_bundle_mutation(&label, bundle, requirement, &case.expected); + continue; + } + if let Some(mutation) = &case.request_mutation { + execute_request_mutation(&label, bundle, requirement, fixture, case, mutation); + continue; + } + + let case_object = fixture_case_object(fixture, case); + let common_object = fixture_common_object(fixture); + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + Some(&common_object), + &case_object, + AUDIENCE, + ) + .unwrap_or_else(|_| panic!("{label}: authorization or selector resolution failed")); + let source = bundle + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: requirement source is absent")); + let preparation_selectors = selector_projection(&resolved, &source.request.selector_inputs) + .unwrap_or_else(|| panic!("{label}: preparation selector projection failed")); + let prepared = kernel + .prepare(&requirement.id, &preparation_selectors) + .unwrap_or_else(|_| panic!("{label}: request preparation failed")); + if case.selector_overrides.is_none() { + assert_request_parts(&label, &prepared, &fixture.common.expected_request_parts); + } + assert_transport( + &label, + source, + &prepared, + &fixture.common.expected_transport, + ); + if let Some(expected) = &case.expected.expected_transport { + assert_transport(&label, source, &prepared, expected); + } + let derivation_selectors = + selector_projection(&resolved, &requirement.derivation.selector_inputs) + .unwrap_or_else(|| panic!("{label}: derivation selector projection failed")); + if case.selector_overrides.is_none() { + if let Some(expected) = &fixture.common.derivation_selector_inputs { + assert!( + same_json(expected, &derivation_selectors), + "{label}: minimized derivation selectors mismatch" + ); + } else { + assert_eq!( + derivation_selectors, + Value::Object(JsonMap::new()), + "{label}: derivation selectors were not minimized to empty" + ); + } + } + + if case.selector_overrides.is_some() { + assert_expected_count(&label, &case.expected, 1); + continue; + } + if let Some(failure) = &case.source_failure { + execute_source_failure(&label, failure, &case.expected); + continue; + } + let observed_at = observed_at(&label, fixture, case); + if let Some(mutation) = &case.derivation_mutation { + require_name(&label, mutation, "return-raw-reference"); + execute_derivation_mutation( + &label, + &execution, + &derivation_selectors, + observed_at, + &case.expected, + ); + continue; + } + if let Some(mutation) = &case.derivation_parameter_mutation { + execute_parameter_mutation( + &label, + &execution, + mutation, + &derivation_selectors, + observed_at, + &case.expected, + ); + continue; + } + + let response = case + .response + .as_ref() + .unwrap_or_else(|| panic!("{label}: response is absent")); + let projected = project_fixture_response(source, response) + .unwrap_or_else(|_| panic!("{label}: production source projection failed")); + let payload = execute_response( + &label, + &execution, + &resolved, + &derivation_selectors, + observed_at, + &projected, + &case.expected, + ) + .await; + if let Some(payload) = payload { + verified_payloads.push(payload); + } + } + assert_privacy( + project_name, + &fixture.privacy_expectation, + &verified_payloads, + ); +} + +async fn execute_response( + label: &str, + execution: &FixtureExecution<'_>, + resolved: &ResolvedAuthorization, + derivation_selectors: &Value, + observed_at: DateTime, + response: &Value, + expected: &Expected, +) -> Option { + let bundle = execution.bundle; + let kernel = execution.kernel; + let requirement = execution.requirement; + let signer = execution.signer; + assert_expected_count(label, expected, 1); + let lookup = match kernel.extract(&requirement.id, response) { + Ok(lookup) => lookup, + Err(error) => { + assert_kernel_error(label, expected, error, false); + return None; + } + }; + match lookup { + LookupResult::NoMatch => { + assert_lookup(label, expected, "no_match"); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); + assert_public_problem(label, expected, "evidence_not_available"); + None + } + LookupResult::Ambiguous => { + assert_lookup(label, expected, "ambiguous"); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); + assert_public_problem(label, expected, "evidence_not_available"); + None + } + LookupResult::Match(facts) => { + assert_lookup(label, expected, "match"); + if let Some(expected_facts) = &expected.facts { + let actual = serde_json::to_value(&facts) + .unwrap_or_else(|_| panic!("{label}: facts are not representable")); + assert!( + same_json(expected_facts, &actual), + "{label}: exact facts mismatch" + ); + } + let values = match kernel.derive_and_validate_with_selectors( + &requirement.id, + &facts, + derivation_selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) { + Ok(values) => values, + Err(error) => { + assert_derivation(label, expected, true); + assert_kernel_error(label, expected, error, true); + return None; + } + }; + assert_derivation(label, expected, true); + assert_values(label, expected, values.as_slice()); + + let issued_at = observed_at + chrono::Duration::seconds(1); + let subjects = resolved + .subjects + .iter() + .map(|subject| SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + BINDING_KEY, + 1, + &bundle.config.service.trust_domain, + AUDIENCE, + &resolved.purpose, + ) + .unwrap_or_else(|_| panic!("{label}: subject binding failed")), + }) + .collect(); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: &resolved.purpose, + audience: AUDIENCE, + issued_at, + observed_at, + subjects, + }, + ) + .unwrap_or_else(|_| panic!("{label}: evidence construction failed")); + let signed = signer + .sign_json(&evidence) + .await + .unwrap_or_else(|_| panic!("{label}: evidence signing failed")); + let jwks = jwks_document(signer.public_jwk(), []) + .unwrap_or_else(|_| panic!("{label}: fixture JWKS construction failed")); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + issued_at, + Duration::from_secs(0), + ); + policy.issued_by = bundle.config.issuer.id.clone(); + policy.provided_by = bundle.config.service.provider_id.clone(); + policy.requirement = requirement.id.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = resolved.purpose.clone(); + policy.audience = AUDIENCE.to_owned(); + policy.configuration_revision = bundle.revision().to_owned(); + let verified = verify_flattened_jws( + &serde_json::to_vec(&signed) + .unwrap_or_else(|_| panic!("{label}: signed evidence encoding failed")), + &jwks, + &policy, + ) + .unwrap_or_else(|_| panic!("{label}: signed evidence verification failed")); + if expected.signed == Some(false) { + panic!("{label}: successful case prohibited signing"); + } + Some( + serde_json::to_value(verified) + .unwrap_or_else(|_| panic!("{label}: verified payload encoding failed")), + ) + } + } +} + +fn execute_bundle_mutation( + label: &str, + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + expected: &Expected, +) { + assert_eq!( + expected.bundle.as_deref(), + Some("rejected"), + "{label}: bundle rejection expectation is absent" + ); + let mut mutated = bundle.config.clone(); + let mut companion = requirement.clone(); + companion.id.push_str(":fixture-companion"); + companion.evidence_type.push_str(":fixture-companion"); + for concept in &mut companion.concepts { + concept.id.push_str(":fixture-companion"); + } + mutated.requirements.push(companion); + assert_eq!( + mutated.validate(), + Err(ConfigError::Invalid( + "enabled requirements share a disclosure family" + )), + "{label}: unsafe disclosure-family mutation was not rejected" + ); + assert_expected_count(label, expected, 0); + assert_not_signed(label, expected); +} + +fn execute_request_mutation( + label: &str, + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &FixtureContract, + case: &FixtureCase, + mutation: &str, +) { + assert_eq!( + case.expected.rejected_before.as_deref(), + Some("source"), + "{label}: request mutation boundary is not exact" + ); + let selectors = fixture + .common + .selectors + .as_object() + .unwrap_or_else(|| panic!("{label}: common selectors are invalid")); + let mut subjects = selectors + .iter() + .map(|(role, selector)| { + let mut selector = selector + .as_object() + .cloned() + .unwrap_or_else(|| panic!("{label}: common selector is invalid")); + selector.insert("role".to_owned(), Value::String(role.clone())); + Value::Object(selector) + }) + .collect::>(); + match mutation { + "swap-subject-roles" => { + assert_eq!(subjects.len(), 2, "{label}: swap mutation needs two roles"); + let first = subjects[0]["role"].clone(); + subjects[0]["role"] = subjects[1]["role"].clone(); + subjects[1]["role"] = first; + } + "supply-grant-derived-candidate" => {} + _ => panic!("{label}: request mutation name is not closed"), + } + let mut case_object = fixture_case_object(fixture, case); + case_object.insert("subjects".to_owned(), Value::Array(subjects)); + let common_object = fixture_common_object(fixture); + assert!( + resolve_offline_fixture_authorization( + bundle, + requirement, + Some(&common_object), + &case_object, + AUDIENCE, + ) + .is_err(), + "{label}: request mutation crossed the authorization boundary" + ); + assert_expected_count(label, &case.expected, 0); + assert_not_signed(label, &case.expected); +} + +fn execute_source_failure(label: &str, failure: &str, expected: &Expected) { + let source_error = match failure { + "timeout" => SourceError::Timeout, + "connection-refused" => SourceError::Transport, + "invalid-media-type" => SourceError::WrongMediaType, + "oversized" => SourceError::ResponseTooLarge, + "malformed-json" => SourceError::InvalidJson, + _ => panic!("{label}: source failure name is not closed"), + }; + assert_eq!( + source_failure_problem(&source_error), + ProblemCode::DependencyUnavailable, + "{label}: source failure did not use the production safe mapping" + ); + assert_eq!( + expected.public_problem.as_deref(), + Some("dependency_unavailable"), + "{label}: source failure public problem is not exact" + ); + assert_expected_count(label, expected, 1); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); +} + +fn execute_derivation_mutation( + label: &str, + execution: &FixtureExecution<'_>, + selectors: &Value, + observed_at: DateTime, + expected: &Expected, +) { + let bundle = execution.bundle; + let requirement = execution.requirement; + let fixture = execution.fixture; + assert_eq!( + expected.output_gate.as_deref(), + Some("rejected"), + "{label}: output-gate expectation is absent" + ); + let mut disposable = bundle.as_ref().clone(); + disposable + .scripts + .get_mut(requirement.derivation.script.as_str()) + .unwrap_or_else(|| panic!("{label}: derivation artifact is absent")) + .source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{concept_id: \"{}\", value: \"PERSON-SYNTHETIC-A\"}}] }}", + requirement.concepts[0].id + ); + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .unwrap_or_else(|_| panic!("{label}: disposable derivation did not compile")); + let response = positive_response(label, fixture); + let source = disposable + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: source is absent")); + let projected = project_fixture_response(source, response) + .unwrap_or_else(|_| panic!("{label}: positive source projection failed")); + assert_eq!( + kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ), + Err(KernelError::Output), + "{label}: raw-reference derivation crossed the output gate" + ); + assert_expected_count(label, expected, 1); + assert_not_signed(label, expected); +} + +fn execute_parameter_mutation( + label: &str, + execution: &FixtureExecution<'_>, + mutation: &JsonMap, + selectors: &Value, + observed_at: DateTime, + expected: &Expected, +) { + let bundle = execution.bundle; + let requirement = execution.requirement; + let fixture = execution.fixture; + let mut disposable = bundle.as_ref().clone(); + let mut config = serde_json::to_value(&disposable.config) + .unwrap_or_else(|_| panic!("{label}: config is not representable")); + let requirements = config["requirements"] + .as_array_mut() + .unwrap_or_else(|| panic!("{label}: requirement list is unavailable")); + let target = requirements + .iter_mut() + .find(|candidate| candidate["id"].as_str() == Some(requirement.id.as_str())) + .unwrap_or_else(|| panic!("{label}: disposable requirement is absent")); + let parameters = target["derivation"]["parameters"] + .as_object_mut() + .unwrap_or_else(|| panic!("{label}: derivation parameters are unavailable")); + for (name, value) in mutation { + assert!( + parameters.contains_key(name), + "{label}: parameter mutation introduced an unknown parameter" + ); + parameters.insert(name.clone(), value.clone()); + } + disposable.config = serde_json::from_value(config) + .unwrap_or_else(|_| panic!("{label}: parameter mutation is not typed")); + disposable + .config + .validate() + .unwrap_or_else(|_| panic!("{label}: parameter mutation broke startup validation")); + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .unwrap_or_else(|_| panic!("{label}: disposable kernel compilation failed")); + let source = disposable + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: source is absent")); + let projected = project_fixture_response(source, positive_response(label, fixture)) + .unwrap_or_else(|_| panic!("{label}: positive source projection failed")); + let outcome = kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ); + assert_kernel_error_result(label, expected, outcome, true); + assert_expected_count(label, expected, 1); +} + +fn positive_response<'a>(label: &str, fixture: &'a FixtureContract) -> &'a Value { + fixture + .cases + .iter() + .find(|case| case.id == "positive") + .and_then(|case| case.response.as_ref()) + .unwrap_or_else(|| panic!("{label}: positive companion response is absent")) +} + +fn assert_kernel_error_result( + label: &str, + expected: &Expected, + outcome: Result, + derivation_ran: bool, +) { + match outcome { + Err(error) => assert_kernel_error(label, expected, error, derivation_ran), + Ok(_) => panic!("{label}: expected failure returned a kernel outcome"), + } +} + +fn assert_kernel_error(label: &str, expected: &Expected, error: KernelError, derivation_ran: bool) { + let (expected_signal, expected_problem) = match error { + KernelError::Preparation => ("adapter_input_error", "service_unavailable"), + KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + // The public class collapses with the unresolved lookup classes so a + // uniquely found record with inconsistent derivation inputs is not + // distinguishable from no match. + KernelError::DerivationInput => ("derivation_input_error", "evidence_not_available"), + KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), + KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), + _ => ("service_unavailable", "service_unavailable"), + }; + if let Some(signal) = expected.error.as_deref() { + assert_eq!( + signal, expected_signal, + "{label}: internal error class mismatch" + ); + } + if let Some(problem) = expected.public_problem.as_deref() { + assert_eq!( + problem, expected_problem, + "{label}: public problem mismatch" + ); + } + if expected.error.is_none() && expected.public_problem.is_none() { + panic!("{label}: failing case has no exact error expectation"); + } + assert_not_signed(label, expected); +} + +fn assert_values( + label: &str, + expected: &Expected, + values: &[registry_evidence::model::SupportedValue], +) { + if let Some(expected_value) = &expected.value { + assert_eq!(values.len(), 1, "{label}: scalar value count mismatch"); + let actual = serde_json::to_value(&values[0].value) + .unwrap_or_else(|_| panic!("{label}: scalar value encoding failed")); + assert!( + same_json(expected_value, &actual), + "{label}: scalar value mismatch" + ); + } + // A requirement disclosing more than one concept states every concept it + // discloses, so a new or leaked concept cannot pass unnoticed. + if let Some(expected_values) = &expected.values { + let expected_values = expected_values + .as_object() + .unwrap_or_else(|| panic!("{label}: expected concept map is not an object")); + assert_eq!( + values.len(), + expected_values.len(), + "{label}: concept value count mismatch" + ); + for (concept_id, expected_value) in expected_values { + let value = values + .iter() + .find(|value| &value.provides_value_for == concept_id) + .unwrap_or_else(|| panic!("{label}: expected concept is absent")); + let actual = serde_json::to_value(&value.value) + .unwrap_or_else(|_| panic!("{label}: concept value encoding failed")); + assert!( + same_json(expected_value, &actual), + "{label}: concept value mismatch" + ); + } + } + if let Some(expected_count) = expected.entity_reference_count { + assert_eq!(values.len(), 1, "{label}: reference concept count mismatch"); + let actual_count = match &values[0].value { + PublicValue::List(items) => items + .iter() + .filter(|item| matches!(item, ScalarOrEntityReference::EntityReference(_))) + .count(), + _ => 0, + }; + assert_eq!( + actual_count, expected_count, + "{label}: entity-reference count mismatch" + ); + } + if let Some(disclosed) = expected.raw_references_disclosed { + let encoded = serde_json::to_string(values) + .unwrap_or_else(|_| panic!("{label}: supported value encoding failed")); + let actual = ["PERSON-SYNTHETIC-A", "PERSON-SYNTHETIC-B"] + .iter() + .any(|reference| encoded.contains(reference)); + assert_eq!( + actual, disclosed, + "{label}: raw-reference disclosure expectation mismatch" + ); + } +} + +fn assert_request_parts(label: &str, actual: &RequestParts, expected: &ExpectedRequestParts) { + let expected_query = expected + .query + .iter() + .map(|pair| QueryPair { + name: pair.name.clone(), + value: pair.value.clone(), + }) + .collect::>(); + assert_eq!( + actual.query, expected_query, + "{label}: request query mismatch" + ); + assert!( + same_optional_json(expected.body.as_ref(), actual.body.as_ref()), + "{label}: request body mismatch" + ); +} + +fn assert_transport( + label: &str, + source: ®istry_evidence::config::SourceConfig, + parts: &RequestParts, + expected: &ExpectedTransport, +) { + if let Some(path) = &expected.path { + assert_eq!( + source.request.path.as_deref(), + Some(path.as_str()), + "{label}: fixed transport path mismatch" + ); + } + if let Some(headers) = &expected.fixed_headers { + let actual = source + .request + .fixed_headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(); + let expected = headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(); + assert_eq!( + actual, expected, + "{label}: fixed transport headers mismatch" + ); + } + if let Some(query) = &expected.query { + assert_eq!( + encode_query(&parts.query), + *query, + "{label}: encoded transport query mismatch" + ); + } + if expected.body.is_some() { + assert!( + same_optional_json(expected.body.as_ref(), parts.body.as_ref()), + "{label}: normalized transport body mismatch" + ); + } +} + +fn encode_query(query: &[QueryPair]) -> String { + let mut encoded = String::new(); + for pair in query { + if !encoded.is_empty() { + encoded.push('&'); + } + encode_query_component(&pair.name, &mut encoded); + encoded.push('='); + encode_query_component(&pair.value, &mut encoded); + } + encoded +} + +fn encode_query_component(value: &str, output: &mut String) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(char::from(byte)); + } else { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } +} + +fn selector_projection( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Option { + let mut output = JsonMap::new(); + for input in inputs { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role)?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile)?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject.fields.iter().find(|field| &field.name == name)?; + values.insert(name.clone(), field.value.as_json()); + } + output.insert( + input.role.clone(), + serde_json::json!({"profile": alternative.profile, "values": values}), + ); + } + Some(Value::Object(output)) +} + +fn fixture_common_object(fixture: &FixtureContract) -> JsonMap { + let mut common = JsonMap::new(); + common.insert("selectors".to_owned(), fixture.common.selectors.clone()); + if let Some(purpose) = &fixture.common.purpose { + common.insert("purpose".to_owned(), Value::String(purpose.clone())); + } + if let Some(claims) = &fixture.common.verified_token_claims { + common.insert("verified_token_claims".to_owned(), claims.clone()); + } + common +} + +fn fixture_case_object(fixture: &FixtureContract, case: &FixtureCase) -> JsonMap { + let mut output = JsonMap::new(); + if let Some(overrides) = &case.selector_overrides { + output.insert("selectorOverrides".to_owned(), overrides.clone()); + } + if let Some(purpose) = &case.purpose { + output.insert("purpose".to_owned(), Value::String(purpose.clone())); + } + if let Some(claims) = &fixture.common.verified_token_claims { + output.insert("verified_token_claims".to_owned(), claims.clone()); + } + output +} + +fn observed_at(label: &str, fixture: &FixtureContract, case: &FixtureCase) -> DateTime { + DateTime::parse_from_rfc3339( + case.observed_at + .as_deref() + .unwrap_or(&fixture.common.observed_at), + ) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| panic!("{label}: observation instant is invalid")) +} + +fn assert_lookup(label: &str, expected: &Expected, actual: &str) { + if let Some(expected) = expected.lookup.as_deref() { + assert_eq!(expected, actual, "{label}: lookup outcome mismatch"); + } +} + +fn assert_derivation(label: &str, expected: &Expected, actual: bool) { + if let Some(expected) = expected.derivation_runs { + assert_eq!(expected, actual, "{label}: derivation execution mismatch"); + } +} + +fn assert_not_signed(label: &str, expected: &Expected) { + if expected.signed == Some(true) { + panic!("{label}: unsuccessful case required a signature"); + } +} + +fn assert_public_problem(label: &str, expected: &Expected, actual: &str) { + if let Some(expected) = expected.public_problem.as_deref() { + assert_eq!(expected, actual, "{label}: public problem mismatch"); + } +} + +fn assert_expected_count(label: &str, expected: &Expected, actual: usize) { + if let Some(expected) = expected.source_request_count { + assert_eq!(expected, actual, "{label}: source request count mismatch"); + } + assert!( + actual <= 1, + "{label}: fixture attempted multiple source requests" + ); +} + +fn assert_privacy(project_name: &str, expectation: &PrivacyExpectation, payloads: &[Value]) { + let projection = Value::Array(payloads.to_vec()); + let mut strings = Vec::new(); + collect_strings(&projection, &mut strings); + for required in &expectation.evidence_contains { + assert!( + strings.contains(&required.as_str()), + "{project_name}: required Evidence disclosure is absent" + ); + } + for prohibited in &expectation.evidence_excludes { + assert!( + !strings.contains(&prohibited.as_str()), + "{project_name}: prohibited Evidence disclosure is present" + ); + } + const DIAGNOSTIC_SURFACES: &[&str] = &[ + "fixture vocabulary is not closed", + "authorization or selector resolution failed", + "request preparation failed", + "exact facts mismatch", + "scalar value mismatch", + "concept value mismatch", + "expected concept is absent", + "signed evidence verification failed", + ]; + for prohibited in &expectation.diagnostics_exclude { + assert!( + DIAGNOSTIC_SURFACES + .iter() + .all(|surface| !surface.contains(prohibited)), + "{project_name}: protected value appears in a diagnostic template" + ); + } +} + +fn collect_strings<'a>(value: &'a Value, output: &mut Vec<&'a str>) { + match value { + Value::String(value) => output.push(value), + Value::Array(values) => { + for value in values { + collect_strings(value, output); + } + } + Value::Object(values) => { + for (key, value) in values { + output.push(key); + collect_strings(value, output); + } + } + _ => {} + } +} + +fn same_json(left: &Value, right: &Value) -> bool { + serde_json::to_vec(left).ok() == serde_json::to_vec(right).ok() +} + +fn same_optional_json(left: Option<&Value>, right: Option<&Value>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => same_json(left, right), + _ => false, + } +} + +fn require_name(label: &str, actual: &str, expected: &str) { + assert_eq!(actual, expected, "{label}: mutation name is not closed"); +} + +async fn fixture_signer() -> EvidenceSigner { + const KEY_ID: &str = "evidence-signing-2026-01"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .expect("fixture signer initializes") +} + +fn projects_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects") + .canonicalize() + .expect("reference deployment projects exist") +} + +fn replace_once(text: &mut String, from: &str, to: &str) { + assert_eq!( + text.matches(from).count(), + 1, + "runtime fixture binding drifted" + ); + *text = text.replacen(from, to, 1); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("reference bundle is readable") { + let entry = entry.expect("reference bundle entry is readable"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("entry type is readable").is_dir() { + fs::create_dir(&destination).expect("reference bundle directory copies"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("reference bundle file copies"); + } + } +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + if !path.exists() { + return; + } + if path.is_dir() { + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("directory mode updates"); + for entry in fs::read_dir(path).expect("mode target is readable") { + set_tree_mode( + &entry.expect("mode target entry is readable").path(), + directory_mode, + file_mode, + ); + } + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("file mode updates"); + } +} diff --git a/crates/registry-evidence/tests/live_sources.rs b/crates/registry-evidence/tests/live_sources.rs new file mode 100644 index 000000000..1e1feb2b5 --- /dev/null +++ b/crates/registry-evidence/tests/live_sources.rs @@ -0,0 +1,761 @@ +//! Opt-in, ignored, read-only compatibility checks for approved public demos. +//! +//! Credentials and selectors are loaded only from exact owner-only files outside +//! the repository. Errors and status output are deliberately value-free. + +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use registry_platform_crypto::parse_json_strict; +use reqwest::{header::CONTENT_TYPE, redirect::Policy, Client, Response, StatusCode, Url}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +const MAX_CREDENTIAL_FILE_BYTES: u64 = 16 * 1024; +const MAX_TOKEN_RESPONSE_BYTES: usize = 8 * 1024; +const MAX_SOURCE_RESPONSE_BYTES: usize = 256 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LiveError { + Skipped, + CredentialFile, + Configuration, + Authentication, + Unavailable, + SchemaDrift, + ExcessDisclosure, +} + +#[tokio::test] +#[ignore = "opt-in read-only public-demo check; requires EVIDENCE_DHIS2_LIVE_ENV_FILE"] +async fn dhis2() { + run_live("dhis2", run_dhis2).await; +} + +#[tokio::test] +#[ignore = "opt-in read-only public-demo check; requires EVIDENCE_OPENCRVS_LIVE_ENV_FILE"] +async fn opencrvs() { + run_live("opencrvs", run_opencrvs).await; +} + +async fn run_live(profile: &'static str, operation: F) +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let started = Instant::now(); + let result = operation().await; + let (phase, outcome) = match result { + Ok(()) => ("complete", "pass"), + Err(LiveError::Skipped) => ("configuration", "skip"), + Err(LiveError::CredentialFile | LiveError::Configuration) => { + ("configuration", "inconclusive") + } + Err(LiveError::Authentication) => ("authentication", "inconclusive"), + Err(LiveError::Unavailable) => ("lookup", "inconclusive"), + Err(LiveError::SchemaDrift) => ("lookup-schema", "fail"), + Err(LiveError::ExcessDisclosure) => ("lookup-minimization", "fail"), + }; + eprintln!( + "live-source profile={profile} phase={phase} outcome={outcome} duration_ms={}", + started.elapsed().as_millis() + ); + match result { + Ok(()) | Err(LiveError::Skipped) => {} + Err(LiveError::SchemaDrift | LiveError::ExcessDisclosure) => { + panic!("authenticated live source contract requires investigation") + } + Err(_) => {} + } +} + +async fn run_dhis2() -> Result<(), LiveError> { + let path = credential_path("EVIDENCE_DHIS2_LIVE_ENV_FILE", None)?; + let config = read_exact_credentials( + &path, + &[ + "DHIS2_BASE_URL", + "DHIS2_USERNAME", + "DHIS2_PASSWORD", + "DHIS2_TEST_PROGRAM_ID", + "DHIS2_TEST_ORG_UNIT_ID", + "DHIS2_TEST_TRACKED_ENTITY_ID", + ], + )?; + let base = safe_https_base(required(&config, "DHIS2_BASE_URL")?)?; + let client = live_client()?; + + let metadata = base + .join("api/system/info?fields=version") + .map_err(|_| LiveError::Configuration)?; + let response = client + .get(metadata) + .basic_auth( + required(&config, "DHIS2_USERNAME")?, + Some(required(&config, "DHIS2_PASSWORD")?), + ) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), true)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let metadata = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let metadata = parse_json_strict(&metadata).map_err(|_| LiveError::SchemaDrift)?; + if metadata.get("version").and_then(Value::as_str).is_none() { + return Err(LiveError::SchemaDrift); + } + + let lookup = base + .join("api/tracker/trackedEntities") + .map_err(|_| LiveError::Configuration)?; + let lookup_query = dhis2_lookup_query(&config)?; + let response = client + .get(lookup) + .basic_auth( + required(&config, "DHIS2_USERNAME")?, + Some(required(&config, "DHIS2_PASSWORD")?), + ) + .query(&lookup_query) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), false)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let body = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let body = parse_json_strict(&body).map_err(|_| LiveError::SchemaDrift)?; + validate_dhis2_lookup(&body, required(&config, "DHIS2_TEST_TRACKED_ENTITY_ID")?) +} + +async fn run_opencrvs() -> Result<(), LiveError> { + let path = credential_path("EVIDENCE_OPENCRVS_LIVE_ENV_FILE", None)?; + let config = read_exact_credentials( + &path, + &[ + "OPENCRVS_CLIENT_ID", + "OPENCRVS_SECRET", + "OPENCRVS_URL", + "OPENCRVS_TEST_TRACKING_ID", + ], + )?; + let (token_url, search_url) = opencrvs_urls(required(&config, "OPENCRVS_URL")?)?; + let client = live_client()?; + + // Form-body placement mirrors the reviewed reference bundle and keeps the + // client credentials out of the token URL, where a proxy or ingress log + // would capture them. + let response = client + .post(token_url) + .form(&[ + ("client_id", required(&config, "OPENCRVS_CLIENT_ID")?), + ("client_secret", required(&config, "OPENCRVS_SECRET")?), + ("grant_type", "client_credentials"), + ]) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), true)?; + require_json_media(&response, LiveError::Authentication)?; + let token_body = bounded_body(response, MAX_TOKEN_RESPONSE_BYTES).await?; + let token_body = parse_json_strict(&token_body).map_err(|_| LiveError::Authentication)?; + let mut token_object = token_body + .as_object() + .cloned() + .ok_or(LiveError::Authentication)?; + // This check must stay as strict as the product's own token parser in + // `source::parse_token_response`. A live check that accepts a response the + // product rejects reports a passing profile for a source Evidence cannot + // actually reach. + if !token_object.keys().all(|key| { + matches!( + key.as_str(), + "access_token" | "token_type" | "expires_in" | "scope" + ) + }) { + return Err(LiveError::Authentication); + } + let token = token_object + .remove("access_token") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .filter(|value| !value.is_empty() && value.len() <= MAX_TOKEN_RESPONSE_BYTES) + .map(Zeroizing::new) + .ok_or(LiveError::Authentication)?; + // `token_type` is required by RFC 6749 section 5.1 and by the product. + if !token_object + .remove("token_type") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .is_some_and(|token_type| token_type.eq_ignore_ascii_case("bearer")) + { + return Err(LiveError::Authentication); + } + // `expires_in` is only recommended, so an absent lifetime is accepted here + // exactly as the product accepts it under a configured assumed lifetime. + // A present lifetime must still be a positive integer. + if token_object + .remove("expires_in") + .is_some_and(|value| value.as_u64().is_none_or(|seconds| seconds == 0)) + { + return Err(LiveError::Authentication); + } + if token_object + .remove("scope") + .is_some_and(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(LiveError::Authentication); + } + + let tracking_id = required(&config, "OPENCRVS_TEST_TRACKING_ID")?; + let search_body = opencrvs_tracking_id_search(tracking_id); + let response = client + .post(search_url) + .bearer_auth(token.as_str()) + .json(&search_body) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), false)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let body = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let body = parse_json_strict(&body).map_err(|_| LiveError::SchemaDrift)?; + validate_opencrvs_search(&body, tracking_id) +} + +fn dhis2_lookup_query( + config: &BTreeMap>, +) -> Result, LiveError> { + Ok(vec![ + ( + "program", + required(config, "DHIS2_TEST_PROGRAM_ID")?.to_owned(), + ), + ( + "orgUnits", + required(config, "DHIS2_TEST_ORG_UNIT_ID")?.to_owned(), + ), + ( + "trackedEntities", + required(config, "DHIS2_TEST_TRACKED_ENTITY_ID")?.to_owned(), + ), + ( + "fields", + "trackedEntity,attributes[attribute,value]".to_owned(), + ), + ("pageSize", "2".to_owned()), + ("page", "1".to_owned()), + ("totalPages", "true".to_owned()), + ]) +} + +fn validate_dhis2_lookup(body: &Value, expected_tracked_entity: &str) -> Result<(), LiveError> { + let object = body.as_object().ok_or(LiveError::SchemaDrift)?; + if !object + .keys() + .all(|key| matches!(key.as_str(), "pager" | "trackedEntities")) + { + return Err(LiveError::ExcessDisclosure); + } + let pager = object + .get("pager") + .and_then(Value::as_object) + .ok_or(LiveError::SchemaDrift)?; + if pager.get("page").and_then(Value::as_u64) != Some(1) + || pager.get("pageSize").and_then(Value::as_u64) != Some(2) + || !pager + .keys() + .all(|key| matches!(key.as_str(), "page" | "pageSize" | "total" | "pageCount")) + { + return Err(LiveError::SchemaDrift); + } + let records = object + .get("trackedEntities") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + if records.len() > 2 { + return Err(LiveError::ExcessDisclosure); + } + if records.len() != 1 { + return Err(LiveError::Unavailable); + } + let record = records[0].as_object().ok_or(LiveError::SchemaDrift)?; + if !record.contains_key("trackedEntity") || !record.contains_key("attributes") { + return Err(LiveError::SchemaDrift); + } + if !record + .keys() + .all(|key| matches!(key.as_str(), "trackedEntity" | "attributes")) + { + return Err(LiveError::ExcessDisclosure); + } + if record.get("trackedEntity").and_then(Value::as_str) != Some(expected_tracked_entity) { + return Err(LiveError::SchemaDrift); + } + let attributes = record + .get("attributes") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + if attributes.is_empty() { + return Err(LiveError::SchemaDrift); + } + for attribute in attributes { + let attribute = attribute.as_object().ok_or(LiveError::SchemaDrift)?; + if attribute.get("attribute").and_then(Value::as_str).is_none() + || attribute.get("value").and_then(Value::as_str).is_none() + { + return Err(LiveError::SchemaDrift); + } + if !attribute + .keys() + .all(|key| matches!(key.as_str(), "attribute" | "value")) + { + return Err(LiveError::ExcessDisclosure); + } + } + Ok(()) +} + +fn opencrvs_tracking_id_search(tracking_id: &str) -> Value { + json!({ + "query": { + "type": "and", + "clauses": [{ + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": { + "type": "exact", + "term": tracking_id + } + }] + }, + "limit": 2, + "offset": 0 + }) +} + +fn validate_opencrvs_search(body: &Value, expected_tracking_id: &str) -> Result<(), LiveError> { + let object = body.as_object().ok_or(LiveError::SchemaDrift)?; + let results = object + .get("results") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + let total = object + .get("total") + .and_then(Value::as_u64) + .ok_or(LiveError::SchemaDrift)?; + if results.len() > 2 { + return Err(LiveError::ExcessDisclosure); + } + if results.len() as u64 > total { + return Err(LiveError::SchemaDrift); + } + if total != 1 || results.len() != 1 { + return Err(LiveError::Unavailable); + } + let returned_tracking_id = results[0] + .get("trackingId") + .and_then(Value::as_str) + .ok_or(LiveError::SchemaDrift)?; + if returned_tracking_id != expected_tracking_id { + return Err(LiveError::SchemaDrift); + } + Ok(()) +} + +fn credential_path(variable: &str, default: Option<&str>) -> Result { + let value = env::var_os(variable) + .or_else(|| default.map(Into::into)) + .ok_or(LiveError::Skipped)?; + let path = PathBuf::from(value); + if !path.exists() && default.is_some() && env::var_os(variable).is_none() { + return Err(LiveError::Skipped); + } + if !path.is_absolute() { + return Err(LiveError::CredentialFile); + } + Ok(path) +} + +fn read_exact_credentials( + path: &Path, + required_keys: &[&str], +) -> Result>, LiveError> { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .map_err(|_| LiveError::CredentialFile)?; + let canonical = path.canonicalize().map_err(|_| LiveError::CredentialFile)?; + if canonical.starts_with(&repository) { + return Err(LiveError::CredentialFile); + } + let file = open_owner_only(path)?; + let metadata = file.metadata().map_err(|_| LiveError::CredentialFile)?; + if metadata.len() == 0 || metadata.len() > MAX_CREDENTIAL_FILE_BYTES { + return Err(LiveError::CredentialFile); + } + let mut text = Zeroizing::new(String::new()); + file.take(MAX_CREDENTIAL_FILE_BYTES + 1) + .read_to_string(&mut text) + .map_err(|_| LiveError::CredentialFile)?; + if text.len() as u64 != metadata.len() || text.contains('\0') { + return Err(LiveError::CredentialFile); + } + + let allow = required_keys.iter().copied().collect::>(); + let mut values = BTreeMap::new(); + for line in text.lines() { + if line.is_empty() || line.trim() != line { + return Err(LiveError::CredentialFile); + } + let (key, value) = line.split_once('=').ok_or(LiveError::CredentialFile)?; + if !allow.contains(key) + || value.is_empty() + || value.len() > 16 * 1024 + || values + .insert(key.to_owned(), Zeroizing::new(value.to_owned())) + .is_some() + { + return Err(LiveError::CredentialFile); + } + } + if values.len() != required_keys.len() + || required_keys.iter().any(|key| !values.contains_key(*key)) + { + return Err(LiveError::CredentialFile); + } + Ok(values) +} + +#[cfg(unix)] +fn open_owner_only(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt as _; + + let metadata = fs::symlink_metadata(path).map_err(|_| LiveError::CredentialFile)?; + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.mode() & 0o777 != 0o600 + { + return Err(LiveError::CredentialFile); + } + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) + .map_err(|_| LiveError::CredentialFile)?; + let file = File::from(fd); + let opened = file.metadata().map_err(|_| LiveError::CredentialFile)?; + if opened.dev() != metadata.dev() + || opened.ino() != metadata.ino() + || opened.uid() != rustix::process::getuid().as_raw() + || opened.uid() != metadata.uid() + || opened.mode() & 0o777 != 0o600 + || opened.nlink() != 1 + { + return Err(LiveError::CredentialFile); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_owner_only(_path: &Path) -> Result { + Err(LiveError::CredentialFile) +} + +fn required<'a>( + config: &'a BTreeMap>, + key: &str, +) -> Result<&'a str, LiveError> { + config + .get(key) + .map(|value| value.as_str()) + .ok_or(LiveError::Configuration) +} + +fn live_client() -> Result { + Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(REQUEST_TIMEOUT) + .user_agent("registry-evidence-live-source-check/1") + .build() + .map_err(|_| LiveError::Configuration) +} + +fn require_json_media(response: &Response, failure: LiveError) -> Result<(), LiveError> { + let mut values = response.headers().get_all(CONTENT_TYPE).iter(); + let value = values.next().ok_or(failure)?; + if values.next().is_some() { + return Err(failure); + } + let value = value.to_str().map_err(|_| failure)?; + let media_type = value.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case("application/json") { + Ok(()) + } else { + Err(failure) + } +} + +fn safe_https_base(value: &str) -> Result { + let mut url = Url::parse(value).map_err(|_| LiveError::Configuration)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(LiveError::Configuration); + } + url.set_query(None); + url.set_fragment(None); + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url) +} + +fn opencrvs_urls(value: &str) -> Result<(Url, Url), LiveError> { + let normalized = if value.contains("://") { + value.to_owned() + } else { + format!("https://{value}") + }; + let base = safe_https_base(&normalized)?; + if base.path() != "/" || base.port().is_some() { + return Err(LiveError::Configuration); + } + let host = base.host_str().ok_or(LiveError::Configuration)?; + let mut labels = host.split('.').collect::>(); + if labels.len() < 2 { + return Err(LiveError::Configuration); + } + if matches!(labels[0], "gateway" | "register" | "auth" | "events") { + labels.remove(0); + } + if labels.len() < 2 { + return Err(LiveError::Configuration); + } + let domain = labels.join("."); + let token = Url::parse(&format!("https://auth.{domain}/token")) + .map_err(|_| LiveError::Configuration)?; + let search = Url::parse(&format!("https://events.{domain}/events/search")) + .map_err(|_| LiveError::Configuration)?; + Ok((token, search)) +} + +fn require_success(status: StatusCode, authentication: bool) -> Result<(), LiveError> { + if status.is_success() { + Ok(()) + } else if authentication && matches!(status.as_u16(), 400 | 401 | 403) { + Err(LiveError::Authentication) + } else { + Err(LiveError::Unavailable) + } +} + +async fn bounded_body(mut response: Response, maximum: usize) -> Result, LiveError> { + if response + .content_length() + .is_some_and(|length| length > maximum as u64) + { + return Err(LiveError::ExcessDisclosure); + } + let mut output = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| LiveError::Unavailable)? { + if output.len().saturating_add(chunk.len()) > maximum { + return Err(LiveError::ExcessDisclosure); + } + output.extend_from_slice(&chunk); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + #[cfg(unix)] + #[test] + fn exact_file_parser_accepts_only_owner_only_external_files() { + let temporary = tempfile::tempdir().expect("external temporary directory"); + let path = temporary.path().join("profile.env"); + fs::write(&path, "A=one\nB=two\n").expect("fixture file writes"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode sets"); + let parsed = read_exact_credentials(&path, &["A", "B"]).expect("exact file parses"); + assert_eq!(parsed.len(), 2); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).expect("mode sets"); + assert_eq!( + read_exact_credentials(&path, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + + #[cfg(unix)] + #[test] + fn parser_rejects_unknown_duplicate_empty_and_symlinked_inputs() { + let temporary = tempfile::tempdir().expect("external temporary directory"); + for (name, contents) in [ + ("unknown", "A=one\nC=two\n"), + ("duplicate", "A=one\nA=two\nB=three\n"), + ("empty", "A=\nB=two\n"), + ("shell", "A=$(command)\nB=two\nEXTRA=value\n"), + ] { + let path = temporary.path().join(name); + fs::write(&path, contents).expect("fixture file writes"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode sets"); + assert_eq!( + read_exact_credentials(&path, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + let target = temporary.path().join("target"); + let link = temporary.path().join("link"); + fs::write(&target, "A=one\nB=two\n").expect("target writes"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("mode sets"); + std::os::unix::fs::symlink(&target, &link).expect("symlink creates"); + assert_eq!( + read_exact_credentials(&link, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + + #[test] + fn service_urls_are_fixed_and_never_carry_credentials() { + let (token, search) = opencrvs_urls("https://register.example.test").expect("URL derives"); + assert_eq!(token.as_str(), "https://auth.example.test/token"); + assert_eq!(search.as_str(), "https://events.example.test/events/search"); + let (token, search) = opencrvs_urls("example.test").expect("bare domain derives"); + assert_eq!(token.as_str(), "https://auth.example.test/token"); + assert_eq!(search.as_str(), "https://events.example.test/events/search"); + assert!(opencrvs_urls("http://example.test").is_err()); + assert!(safe_https_base("https://user:secret@example.test").is_err()); + } + + #[test] + fn dhis2_tracker_query_is_deployment_scoped_and_bounded() { + let config = BTreeMap::from([ + ( + "DHIS2_TEST_PROGRAM_ID".to_owned(), + Zeroizing::new("PROGRAM-CANARY".to_owned()), + ), + ( + "DHIS2_TEST_ORG_UNIT_ID".to_owned(), + Zeroizing::new("ORG-UNIT-CANARY".to_owned()), + ), + ( + "DHIS2_TEST_TRACKED_ENTITY_ID".to_owned(), + Zeroizing::new("TRACKED-ENTITY-CANARY".to_owned()), + ), + ]); + assert!( + dhis2_lookup_query(&config) + == Ok(vec![ + ("program", "PROGRAM-CANARY".to_owned()), + ("orgUnits", "ORG-UNIT-CANARY".to_owned()), + ("trackedEntities", "TRACKED-ENTITY-CANARY".to_owned()), + ( + "fields", + "trackedEntity,attributes[attribute,value]".to_owned(), + ), + ("pageSize", "2".to_owned()), + ("page", "1".to_owned()), + ("totalPages", "true".to_owned()), + ]), + "DHIS2 lookup query did not match the fixed bounded shape" + ); + assert_eq!( + validate_dhis2_lookup( + &json!({ + "pager": {"page": 1, "pageSize": 2}, + "trackedEntities": [{ + "trackedEntity": "TRACKED-ENTITY-CANARY", + "attributes": [{"attribute": "ATTRIBUTE-CANARY", "value": "VALUE-CANARY"}] + }] + }), + "TRACKED-ENTITY-CANARY" + ), + Ok(()) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({"pager": {"page": 1, "pageSize": 2}, "trackedEntities": []}), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::Unavailable) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({"pager": {"page": 1, "pageSize": 2}, "trackedEntities": [{}, {}, {}]}), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::ExcessDisclosure) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({ + "pager": {"page": 1, "pageSize": 2}, + "trackedEntities": [{ + "trackedEntity": "WRONG-ENTITY", + "attributes": [{"attribute": "ATTRIBUTE-CANARY", "value": "VALUE-CANARY"}] + }] + }), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::SchemaDrift) + ); + } + + #[test] + fn opencrvs_tracking_id_search_is_exact_and_bounded() { + let selector = "TRACKING-CANARY"; + assert!( + opencrvs_tracking_id_search(selector) + == json!({ + "query": { + "type": "and", + "clauses": [{ + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": { + "type": "exact", + "term": selector + } + }] + }, + "limit": 2, + "offset": 0 + }), + "OpenCRVS search body did not match the fixed bounded shape" + ); + let exact = json!({ + "results": [{ + "trackingId": selector + }], + "total": 1 + }); + assert_eq!(validate_opencrvs_search(&exact, selector), Ok(())); + assert_eq!( + validate_opencrvs_search( + &json!({"results": [{"trackingId": "WRONG-TRACKING-CANARY"}], "total": 1}), + selector + ), + Err(LiveError::SchemaDrift) + ); + assert_eq!( + validate_opencrvs_search(&json!({"results": [], "total": 0}), selector), + Err(LiveError::Unavailable) + ); + assert_eq!( + validate_opencrvs_search(&json!({"results": [{}, {}, {}], "total": 3}), selector), + Err(LiveError::ExcessDisclosure) + ); + } +} diff --git a/crates/registry-evidence/tests/relay_shaped_source.rs b/crates/registry-evidence/tests/relay_shaped_source.rs new file mode 100644 index 000000000..2df7e8ae5 --- /dev/null +++ b/crates/registry-evidence/tests/relay_shaped_source.rs @@ -0,0 +1,581 @@ +//! Evidence-over-Relay composition: one full signed Evidence assertion is +//! evaluated over a mock HTTP source whose wire shape mirrors a Registry +//! Relay protected read API, authenticated with OAuth client credentials. +//! +//! The mock mirrors the Relay wire shape by hand: the templated protected +//! read path and the minimal single-record JSON response body of +//! `GET /v1/datasets/{dataset_id}/entities/{entity}/records/{id}` in +//! `crates/registry-relay/openapi/registry-relay.openapi.json`. Evidence +//! proves the composition without importing or depending on any Relay code, +//! per the Evidence product boundary rules, so no Relay crate, type, or +//! fixture appears here and the record content stays synthetic and +//! domain-neutral. + +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::bundle::Bundle; +use registry_evidence::config::{PreparationChannelPolicy, PreparationLimits, SourceConfig}; +use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; +use registry_evidence::model::{LookupResult, PublicValue, SelectorValue, SubjectBinding}; +use registry_evidence::rhai_runtime::{ + RequestPartRequirement, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, + MAXIMUM_ARRAY_ITEMS, MAXIMUM_JSON_BODY_DEPTH, MAXIMUM_QUERY_NAME_BYTES, MAXIMUM_QUERY_PAIRS, + MAXIMUM_QUERY_VALUE_BYTES, MAXIMUM_REQUEST_PARTS_BYTES, MAXIMUM_STRING_BYTES, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ResolvedSourceSelector, SourceExecutor}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use serde_json::json; +use tempfile::TempDir; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use zeroize::Zeroizing; + +/// The Relay-shaped protected read for one synthetic record: the templated +/// `/v1/datasets/{dataset_id}/entities/{entity}/records/{id}` path with +/// domain-neutral dataset and entity segments and the subject's record key. +const RECORD_PATH: &str = "/v1/datasets/synthetic-units/entities/unit-record/records/REC-0001"; +/// Relay requires a `Data-Purpose` header on entity record reads; the +/// deployment pins it as a reviewed fixed header. +const DATA_PURPOSE: &str = "https://relying.invalid/purpose/fixture-routing"; +/// Raw record material from the mirrored Relay response body. None of it may +/// reach the signed assertion payload. +const RAW_FIELD_NAME_CANARY: &str = "area_geometry"; +const RAW_FIELD_VALUE_CANARY: &str = "SYNTHETIC-AREA-GEOMETRY-CANARY"; +const RECORD_KEY: &str = "REC-0001"; +const RAW_REGION_CODE: &str = "R-101"; + +const AUDIENCE: &str = "https://relying.invalid/residence-procedure"; +const BINDING_KEY: &[u8] = b"relay-composition-binding-key-32-bytes-minimum"; +const REQUIREMENT: &str = "urn:example:fixture:requirement:residence-region:v1"; + +/// The reviewed bounded request preparation: the Relay read is a completely +/// fixed request, so both dynamic channels stay empty. +const PREPARE_SCRIPT: &str = "fn prepare(selectors, parameters) { #{query: [], body: ()} }"; + +/// The reviewed extraction over the Rust-projected response: only the +/// projected `region` field is visible here, and it becomes the one declared +/// fact for the residence-region acceptance derivation. +const EXTRACT_SCRIPT: &str = r#" +fn extract(source_response, parameters) { + let region_code = get_path(source_response, "/region"); + if is_missing(region_code) { return #{outcome: "no_match"}; } + #{outcome: "match", facts: #{official_residence_code: region_code}} +} +"#; + +/// An Evidence source declared the way a deployment bundle would declare it: +/// oauth2-client-credentials against the mock token endpoint and a fixed GET +/// against the Relay-shaped record path. +fn relay_shaped_source(base_url: &str, token_endpoint: &str) -> SourceConfig { + serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": base_url, + "posture": "field-projected", + "authentication": { + "kind": "oauth2-client-credentials", + "tokenEndpoint": token_endpoint, + "clientIdRef": "secret:file/relay-client-id", + "clientSecretRef": "secret:file/relay-client-secret", + "scope": "registry.read", + "credentialPlacement": "form-body", + "maximumCacheSeconds": 60 + }, + "request": { + "method": "GET", + "pathTemplate": "/v1/datasets/synthetic-units/entities/unit-record/records/{record}", + "pathBindings": { + "record": { + "role": "subject", + "profile": "residence-record-v1", + "field": "record_reference" + } + }, + "fixedHeaders": [ + {"name": "Accept", "value": "application/json"}, + {"name": "Data-Purpose", "value": DATA_PURPOSE} + ], + "selectorInputs": [{ + "role": "subject", + "alternatives": [ + {"profile": "residence-record-v1", "fields": ["record_reference"]} + ] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": {"query": "forbidden", "jsonBody": "forbidden"}, + "projection": ["/region"], + "redirects": "deny", + "timeoutMilliseconds": 1000, + "maximumResponseBytes": 65536, + "concurrencyLimit": 4 + }, + "responseSchema": "schemas/response.schema.yaml", + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("Relay-shaped source config deserializes") +} + +fn resolver(entries: &[(&str, &str)]) -> (TempDir, Arc) { + let root = tempfile::tempdir().expect("temporary secret root"); + for (name, value) in entries { + let path = root.path().join(name); + fs::write(&path, value).expect("write synthetic secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("protect secret"); + } + let resolver = SecretResolver::new([SecretProvider::File], root.path()) + .map(Arc::new) + .expect("resolver builds"); + (root, resolver) +} + +fn request_limits(config: &PreparationLimits) -> RequestPartsLimits { + fn channel(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn configured(value: Option, fallback: usize) -> usize { + value + .map(|value| usize::try_from(value).expect("configured limit fits usize")) + .unwrap_or(fallback) + } + + RequestPartsLimits::new( + channel(config.query), + channel(config.json_body), + RequestPartsBounds { + maximum_query_pairs: configured(config.maximum_query_pairs, MAXIMUM_QUERY_PAIRS), + maximum_query_name_bytes: configured( + config.maximum_query_name_bytes, + MAXIMUM_QUERY_NAME_BYTES, + ), + maximum_query_value_bytes: configured( + config.maximum_query_value_bytes, + MAXIMUM_QUERY_VALUE_BYTES, + ), + maximum_json_depth: configured(config.maximum_json_depth, MAXIMUM_JSON_BODY_DEPTH), + maximum_collection_items: configured( + config.maximum_collection_items, + MAXIMUM_ARRAY_ITEMS, + ), + maximum_string_bytes: configured(config.maximum_string_bytes, MAXIMUM_STRING_BYTES), + maximum_normalized_bytes: configured( + config.maximum_normalized_bytes, + MAXIMUM_REQUEST_PARTS_BYTES, + ), + }, + ) + .expect("fixture preparation limits satisfy the production ABI") +} + +fn encoded_parameters(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn contains_parameter(parameters: &[(String, String)], name: &str, value: &str) -> bool { + parameters + .iter() + .any(|(actual_name, actual_value)| actual_name == name && actual_value == value) +} + +fn copy_fixture_tree(source: &Path, target: &Path) { + fs::create_dir(target).expect("fixture directory is copied"); + for entry in fs::read_dir(source).expect("fixture directory is readable") { + let entry = entry.expect("fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture entry type is readable") + .is_dir() + { + copy_fixture_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +fn make_fixture_bundle_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("fixture bundle is readable") { + let entry = entry.expect("fixture bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("fixture bundle entry type is readable") + .is_dir() + { + make_fixture_bundle_read_only(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o444)) + .expect("fixture bundle file becomes read-only"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("fixture bundle directory becomes read-only"); +} + +async fn fixture_signer() -> EvidenceSigner { + const KEY_ID: &str = "relay-composition-evidence-key"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .expect("fixture signer initializes") +} + +#[tokio::test] +async fn a_relay_shaped_protected_read_backs_a_full_signed_minimum_disclosure_assertion() { + // A mock OAuth token endpoint stands in for the Relay deployment's + // authorization server: it only answers a client-credentials grant and + // issues one fresh bearer token. + let token_server = MockServer::start().await; + let records_server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=client_credentials")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "registry.read" + }))) + .expect(1) + .mount(&token_server) + .await; + + // The record endpoint mirrors the Relay wire shape by hand (hardcoded + // JSON, no Relay code): the single-record body follows the OpenAPI entity + // example shape of `id`, one codelist field, and one extra raw field. + // Only a request carrying the exact issued bearer, the pinned Accept + // header, and Relay's required Data-Purpose header is answered. + Mock::given(method("GET")) + .and(path(RECORD_PATH)) + .and(header("authorization", format!("Bearer {access_token}"))) + .and(header("accept", "application/json")) + .and(header("data-purpose", DATA_PURPOSE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": RECORD_KEY, + "region": RAW_REGION_CODE, + "area_geometry": RAW_FIELD_VALUE_CANARY + }))) + .with_priority(1) + .expect(1) + .mount(&records_server) + .await; + // Any request that misses the exact bearer is rejected the way a Relay + // deployment rejects it, and must never happen. + Mock::given(method("GET")) + .and(path(RECORD_PATH)) + .respond_with(ResponseTemplate::new(401).set_body_raw( + r#"{"type":"about:blank","title":"Unauthorized","status":401}"#, + "application/problem+json", + )) + .with_priority(10) + .expect(0) + .mount(&records_server) + .await; + + // Deployment-shaped inputs: file-provider secrets and the declared source. + let (_secret_root, secrets) = resolver(&[ + ("relay-client-id", client_id.as_str()), + ("relay-client-secret", client_secret.as_str()), + ]); + let source = relay_shaped_source( + &records_server.uri(), + &format!("{}/oauth/token", token_server.uri()), + ); + + // Bounded request preparation through the production Rhai runtime. + let runtime = RhaiRuntime::new(); + let preparation = runtime + .compile_preparation(PREPARE_SCRIPT) + .expect("preparation script compiles"); + let extraction = runtime + .compile_extraction(EXTRACT_SCRIPT) + .expect("extraction script compiles"); + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .expect("adapter parameters serialize"); + let script_selectors = json!({ + "subject": { + "profile": "residence-record-v1", + "values": {"record_reference": RECORD_KEY} + } + }); + let prepared = runtime + .prepare( + &preparation, + &script_selectors, + ¶meters, + &request_limits(&source.request.preparation_limits), + ) + .expect("fixed Relay read preparation succeeds"); + + // Production transport materialization pins the exact Relay-shaped read. + let transport_selectors = vec![ResolvedSourceSelector { + role: "subject".into(), + profile: "residence-record-v1".into(), + values: BTreeMap::from([( + "record_reference".into(), + SelectorValue::String(RECORD_KEY.into()), + )]), + }]; + let executor = SourceExecutor::new(&source, secrets).expect("Relay-shaped source compiles"); + let materialized = executor + .materialize_request(&transport_selectors, &prepared) + .expect("Relay-shaped request materializes"); + assert_eq!(materialized.path(), RECORD_PATH); + assert_eq!(materialized.query(), None); + assert_eq!(materialized.body(), None); + + // One end-to-end source execution: token acquisition, the authenticated + // record read, and the Rust projection boundary. + let projected = executor + .execute(&transport_selectors, &prepared) + .await + .expect("Relay-shaped source read succeeds"); + assert_eq!(projected, json!({"region": RAW_REGION_CODE})); + let projected_text = serde_json::to_string(&projected).expect("projected response serializes"); + for stripped in [RAW_FIELD_NAME_CANARY, RAW_FIELD_VALUE_CANARY, RECORD_KEY] { + assert!( + !projected_text.contains(stripped), + "projection let raw record material past the source boundary" + ); + } + let response_schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["region"], + "properties": {"region": {"type": "string", "minLength": 1, "maxLength": 32}} + })) + .expect("response schema compiles"); + assert!( + response_schema.is_valid(&projected), + "projected Relay-shaped response is outside the declared response schema" + ); + + // Reviewed extraction produces exactly the one declared fact. + let fact_schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["official_residence_code"], + "properties": { + "official_residence_code": {"type": "string", "minLength": 1, "maxLength": 32} + } + })) + .expect("fact schema compiles"); + let facts = match runtime + .extract(&extraction, &projected, ¶meters, &fact_schema) + .expect("Relay-shaped response extracts") + { + LookupResult::Match(facts) => facts, + _ => panic!("Relay-shaped match returned a non-match outcome"), + }; + assert_eq!( + serde_json::to_value(&facts).expect("facts serialize"), + json!({"official_residence_code": RAW_REGION_CODE}) + ); + + // The immutable residence-region acceptance bundle finishes the full + // path: real derivation, output gate, Evidence construction, signing, + // and verification against the deployment's public JWKS. + let acceptance_copy = tempfile::tempdir().expect("temporary acceptance bundle root"); + let acceptance_root = acceptance_copy.path().join("residence-region"); + copy_fixture_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/residence-region"), + &acceptance_root, + ); + make_fixture_bundle_read_only(&acceptance_root); + let kernel = OfflineKernel::compile(Arc::new( + Bundle::load(&acceptance_root).expect("immutable residence acceptance bundle loads"), + )) + .expect("residence acceptance kernel compiles"); + let observed_at = "2026-08-02T00:00:00Z" + .parse() + .expect("fixed observation time parses"); + let values = kernel + .derive_and_validate( + REQUIREMENT, + &facts, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) + .expect("residence derivation and immutable output gate succeed"); + assert_eq!(values.as_slice().len(), 1); + assert_eq!( + values.as_slice()[0].provides_value_for, + "urn:example:fixture:concept:residence-region" + ); + assert_eq!( + values.as_slice()[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + let evidence = kernel + .construct_evidence( + REQUIREMENT, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01J4BRXQ0ZZZZZZZZZZZZZZZZZ", + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: "fixture-routing", + audience: AUDIENCE, + issued_at: observed_at, + observed_at, + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("residence Evidence constructs"); + let signer = fixture_signer().await; + let jws = signer + .sign_json(&evidence) + .await + .expect("residence Evidence signs"); + + // (d) The assertion payload carries only the derived answer: no raw + // record field name or value from the mirrored Relay response body. + let payload = String::from_utf8( + URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("flattened JWS payload decodes"), + ) + .expect("assertion payload is UTF-8 JSON"); + assert!( + payload.contains("REGION-NORTH"), + "the derived controlled code is disclosed" + ); + for canary in [ + RAW_FIELD_NAME_CANARY, + RAW_FIELD_VALUE_CANARY, + RECORD_KEY, + RAW_REGION_CODE, + ] { + assert!( + !payload.contains(canary), + "raw Relay record material reached the assertion payload: {canary}" + ); + } + + // (c) The response is a signed flattened JWS that verifies against the + // deployment's public JWKS under the exact relying policy. + let jwks = jwks_document(signer.public_jwk(), []).expect("deployment JWKS publishes"); + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + observed_at, + Duration::from_secs(0), + ); + policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); + policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); + policy.requirement = REQUIREMENT.to_owned(); + policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); + policy.purpose = "fixture-routing".to_owned(); + policy.audience = AUDIENCE.to_owned(); + policy.configuration_revision = kernel.bundle().revision().to_owned(); + let verified = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("signed Evidence verifies against the deployment JWKS"); + assert_eq!(verified.supported_values.len(), 1); + assert_eq!( + verified.supported_values[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + + // (a) The token endpoint was called exactly once, with the closed + // client-credentials form and no credential in the URL. + let token_requests = token_server + .received_requests() + .await + .expect("token request journal"); + assert_eq!(token_requests.len(), 1, "exact OAuth bootstrap count"); + assert!( + token_requests[0].url.query().is_none(), + "token URL carries no query" + ); + let form = encoded_parameters(&token_requests[0].body); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "registry.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "token request body is the exact reviewed client-credentials shape" + ); + + // (b) The records endpoint saw exactly one read carrying the issued + // bearer and the pinned reviewed headers. + let record_requests = records_server + .received_requests() + .await + .expect("record request journal"); + assert_eq!(record_requests.len(), 1, "exact evidence-data count"); + let record_request = &record_requests[0]; + assert_eq!(record_request.method.as_str(), "GET"); + assert_eq!(record_request.url.path(), RECORD_PATH); + assert!(record_request.url.query().is_none()); + assert!(record_request.body.is_empty()); + assert_eq!( + record_request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {access_token}").as_str()), + "the record read carried the issued bearer" + ); + assert_eq!( + record_request + .headers + .get("data-purpose") + .and_then(|value| value.to_str().ok()), + Some(DATA_PURPOSE) + ); +} diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs new file mode 100644 index 000000000..16831dc86 --- /dev/null +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -0,0 +1,449 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SecurityMatrix { + contract: String, + status: String, + review_rule: String, + invariants: Vec, + cross_cutting: serde_norway::Mapping, + fixture_index: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SecurityRow { + id: String, + rule: String, + threat: String, + enforcement: String, + negative_test: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Traceability { + contract: String, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceEntry { + id: String, + tests: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TestReference { + file: String, + name: String, +} + +/// The SD-JWT VC profile. Only the members this checker binds to code and to +/// the traceability index are modelled; the rest of the frozen profile is +/// narrative owned by review. +#[derive(Deserialize)] +struct SdJwtVcProfile { + contract: String, + status: String, + response: ProfileResponse, + protected_header: ProfileProtectedHeader, + negative_tests: Vec, +} + +#[derive(Deserialize)] +struct ProfileResponse { + media_type: String, +} + +#[derive(Deserialize)] +struct ProfileProtectedHeader { + typ: ProfileConst, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileConst { + #[serde(rename = "const")] + constant: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcceptanceTraceability { + contract: String, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcceptanceEntry { + id: String, + summary: String, + tests: Vec, + /// Names the residual gap when executable tests only partially prove a row. + #[serde(default)] + note: Option, +} + +/// The conformance coverage index. Only the fields consumed by this checker are +/// modelled; the index carries further frozen sections owned by other tests. +#[derive(Deserialize)] +struct CoverageIndex { + categories: Vec, + acceptance_definitions: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CoverageDefinition { + definition: String, + bundle: String, + cases: String, + selector: String, + posture: String, + supported_values: Vec, + coverage: BTreeMap, +} + +#[derive(Deserialize)] +struct CasesFixture { + cases: Vec, +} + +#[derive(Deserialize)] +struct CaseEntry { + id: String, + /// A companion bundle name is how an anti-reconstruction case is addressed + /// by the coverage index, because the case itself is a bundle rejection. + #[serde(default)] + companion_bundle: Option, +} + +#[test] +fn every_named_security_negative_is_bound_to_an_executable_test() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let matrix: SecurityMatrix = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-invariant-matrix.yaml")) + .expect("security matrix reads"), + ) + .expect("security matrix parses"); + let traceability: Traceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-test-traceability.yaml")) + .expect("traceability reads"), + ) + .expect("traceability parses"); + assert_eq!( + traceability.contract, + "registry.evidence.security-test-traceability/v1" + ); + assert_eq!(matrix.contract, "registry.evidence.security-invariants/v1"); + assert_eq!(matrix.status, "frozen"); + assert!(matrix.review_rule.contains("named negative test")); + assert_eq!( + matrix.fixture_index, + "../fixtures/conformance/coverage-matrix.yaml" + ); + + let mut required = matrix + .invariants + .into_iter() + .map(|row| { + assert!(row.id.starts_with("V1-I")); + assert!(!row.rule.is_empty()); + assert!(!row.threat.is_empty()); + assert!(!row.enforcement.is_empty()); + row.negative_test + }) + .collect::>(); + for (_, value) in matrix.cross_cutting { + let row = value.as_mapping().expect("cross-cutting row is a mapping"); + let id = row + .get(serde_norway::Value::String("negative_test".to_owned())) + .and_then(serde_norway::Value::as_str) + .expect("cross-cutting row names a negative test"); + assert!(required.insert(id.to_owned()), "duplicate matrix id {id}"); + } + + let mut mapped = BTreeSet::new(); + for entry in traceability.entries { + assert!( + mapped.insert(entry.id.clone()), + "duplicate mapping {}", + entry.id + ); + assert!( + !entry.tests.is_empty(), + "{} has no executable test", + entry.id + ); + for test in &entry.tests { + assert_reference_is_an_executable_test(&root, &entry.id, test); + } + } + assert_eq!(mapped, required, "security negative-test mapping drifted"); +} + +/// The SD-JWT VC profile is a frozen response-format contract, so every +/// negative it names must resolve in the same security traceability index the +/// checker above proves executable. The profile therefore cannot claim a +/// guarantee no test enforces, and the response format cannot drift away from +/// the media type and JWT type the runtime actually emits. +#[test] +fn every_sd_jwt_vc_profile_negative_is_bound_to_a_mapped_security_negative() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let profile: SdJwtVcProfile = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/sd-jwt-vc-profile.yaml")) + .expect("sd-jwt-vc profile reads"), + ) + .expect("sd-jwt-vc profile parses"); + assert_eq!(profile.contract, "registry.evidence.sd-jwt-vc-profile/v1"); + assert_eq!(profile.status, "frozen"); + assert_eq!( + profile.response.media_type, + registry_evidence::EVIDENCE_SD_JWT_VC_MEDIA_TYPE + ); + assert_eq!( + profile.protected_header.typ.constant, + registry_evidence::EVIDENCE_SD_JWT_VC_TYP + ); + + let traceability: Traceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-test-traceability.yaml")) + .expect("traceability reads"), + ) + .expect("traceability parses"); + let mapped = traceability + .entries + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + + assert!( + !profile.negative_tests.is_empty(), + "the profile names no negative test" + ); + let mut named = BTreeSet::new(); + for id in &profile.negative_tests { + assert!(named.insert(id.clone()), "the profile repeats {id}"); + assert!( + mapped.contains(id), + "profile negative {id} is not mapped to an executable test" + ); + } +} + +/// Prove that one mapped reference still names a real Rust test item, so a +/// renamed, moved, or deleted test fails the traceability checker. +fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { + assert!( + test.file.starts_with("crates/registry-evidence/") + && test.file.ends_with(".rs") + && !test.file.contains(".."), + "{entry_id} has an unsafe source reference" + ); + let source = fs::read_to_string(root.join(&test.file)) + .unwrap_or_else(|_| panic!("{entry_id} source file is missing")); + let signature = format!("fn {}(", test.name); + assert!( + source.contains(&signature), + "{entry_id} points to missing Rust test {}", + test.name + ); + let item_start = source + .find(&signature) + .expect("test signature was just found"); + let prefix = &source[..item_start]; + let attribute_window = &prefix[prefix.len().saturating_sub(160)..]; + // The parenthesized form is a test item too. A concurrency invariant can + // only be proven by a test that actually runs on several threads, so + // `#[tokio::test(flavor = "multi_thread", ...)]` has to be traceable. + assert!( + attribute_window.contains("#[test]") + || attribute_window.contains("#[tokio::test]") + || attribute_window.contains("#[tokio::test("), + "{entry_id} reference {} is not a test item", + test.name + ); +} + +#[test] +fn every_acceptance_row_is_bound_to_an_executable_test() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let traceability: AcceptanceTraceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/acceptance-test-traceability.yaml")) + .expect("acceptance traceability reads"), + ) + .expect("acceptance traceability parses"); + assert_eq!( + traceability.contract, + "registry.evidence.acceptance-test-traceability/v1" + ); + + let expected = (1..=62) + .map(|row| format!("acceptance-row-{row:02}")) + .collect::>(); + let mapped = traceability + .entries + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + assert_eq!( + mapped, expected, + "acceptance row mapping is not the 62 required rows in order" + ); + + for entry in &traceability.entries { + assert!( + !entry.summary.trim().is_empty(), + "{} has no summary", + entry.id + ); + if let Some(note) = &entry.note { + assert!( + !note.trim().is_empty(), + "{} has an empty residual-gap note", + entry.id + ); + } + assert!( + !entry.tests.is_empty(), + "{} has no executable test", + entry.id + ); + let mut referenced = BTreeSet::new(); + for test in &entry.tests { + assert!( + referenced.insert((test.file.as_str(), test.name.as_str())), + "{} repeats the reference {}", + entry.id, + test.name + ); + assert_reference_is_an_executable_test(&root, &entry.id, test); + } + } + + assert_coverage_index_resolves(&root); +} + +/// The conformance coverage index is an input to acceptance traceability: every +/// acceptance definition must still point at real bundle and case artifacts, +/// and every case named by a coverage category must still exist in them. +fn assert_coverage_index_resolves(root: &Path) { + let fixtures = root.join("products/evidence/fixtures"); + let index: CoverageIndex = serde_norway::from_slice( + &fs::read(fixtures.join("conformance/coverage-matrix.yaml")).expect("coverage index reads"), + ) + .expect("coverage index parses"); + let categories = index + .categories + .iter() + .cloned() + .collect::>(); + assert_eq!( + categories.len(), + index.categories.len(), + "coverage index repeats a category" + ); + assert!( + !index.acceptance_definitions.is_empty(), + "coverage index names no acceptance definition" + ); + + let mut seen = BTreeSet::new(); + for definition in &index.acceptance_definitions { + assert!( + seen.insert(definition.definition.clone()), + "coverage index repeats definition {}", + definition.definition + ); + assert!( + !definition.selector.is_empty() + && !definition.posture.is_empty() + && !definition.supported_values.is_empty(), + "{} has an incomplete coverage declaration", + definition.definition + ); + assert!( + fixture_path(&fixtures, &definition.definition, &definition.bundle).is_file(), + "{} names a missing bundle {}", + definition.definition, + definition.bundle + ); + let cases_path = fixture_path(&fixtures, &definition.definition, &definition.cases); + let cases: CasesFixture = + serde_norway::from_slice(&fs::read(&cases_path).unwrap_or_else(|_| { + panic!("{} names a missing case fixture", definition.definition) + })) + .unwrap_or_else(|_| panic!("{} case fixture parses", definition.definition)); + + let mut addressable = BTreeSet::new(); + for case in &cases.cases { + addressable.insert(case.id.clone()); + if let Some(companion) = &case.companion_bundle { + addressable.insert(companion.clone()); + } + } + assert_eq!( + definition.coverage.keys().cloned().collect::>(), + categories, + "{} does not cover exactly the declared categories", + definition.definition + ); + for (category, named) in &definition.coverage { + let names = match named { + serde_norway::Value::String(one) => vec![one.as_str()], + serde_norway::Value::Sequence(many) => many + .iter() + .map(|value| { + value.as_str().unwrap_or_else(|| { + panic!( + "{}/{category} names a non-string case", + definition.definition + ) + }) + }) + .collect(), + _ => panic!( + "{}/{category} is neither one case nor a case list", + definition.definition + ), + }; + assert!( + !names.is_empty(), + "{}/{category} names no case", + definition.definition + ); + for name in names { + assert!( + addressable.contains(name), + "{}/{category} names {name}, which is absent from {}", + definition.definition, + cases_path.display() + ); + } + } + } +} + +/// Resolve one coverage-index path, which is written relative to the +/// `conformance` directory, without allowing it to escape the fixture tree. +fn fixture_path(fixtures: &Path, definition: &str, relative: &str) -> std::path::PathBuf { + let inside = relative + .strip_prefix("../") + .unwrap_or_else(|| panic!("{definition} path {relative} is not fixture-relative")); + assert!( + inside.ends_with(".yaml") && !inside.contains("..") && !inside.starts_with('/'), + "{definition} path {relative} is unsafe" + ); + fixtures.join(inside) +} diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs new file mode 100644 index 000000000..9df5c22d2 --- /dev/null +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -0,0 +1,1654 @@ +//! Full-path conformance for the frozen Version 1 selector matrix. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_evidence::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind as AuditAuthorityKind, + EvidenceAuditEvent, EvidenceAuditLog, ResponseProtection, +}; +use registry_evidence::auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}; +use registry_evidence::bundle::{Bundle, BundleError, DeploymentInputs}; +use registry_evidence::config::{AuthorityKind, SelectorInput}; +use registry_evidence::kernel::{ + EvidenceConstruction, KernelOutcome, OfflineKernel, ValueProjection, +}; +use registry_evidence::model::{ + Evidence, EvidenceRequest, FlattenedJws, RequestedSelector, RequestedSubject, SelectorValue, + SubjectBinding, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::selector::{ + match_entitlement, resolve_selectors, AuthorizationError, ResolvedAuthorization, + ResolvedSelectorValue, +}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ResolvedSourceSelector, SourceExecutor}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{sign, LocalJwkSigner, PrivateJwk, SigningProvider}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-auth-key"}"#; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-evidence-key"}"#; +const TOKEN_ISSUER: &str = "https://identity.invalid"; +const TOKEN_AUDIENCE: &str = "selector-conformance"; +const EVIDENCE_AUDIENCE: &str = "urn:example:fixture:audience:requester-a"; +const PURPOSE: &str = "fixture-procedure"; +const SOURCE_TOKEN: &str = "selector-source-token-canary"; +const BINDING_KEY: &[u8] = b"selector-binding-secret-canary-32-bytes-minimum"; +const AUDIT_KEY: &[u8] = b"selector-audit-secret-canary-32-bytes-minimum"; + +const CLASSIFICATION: &str = "urn:example:fixture:requirement:classification:v1"; +const PROPERTY: &str = "urn:example:fixture:requirement:property:v1"; +const PROPERTY_WITH_EVENT: &str = "urn:example:fixture:requirement:property-with-event:v1"; +const RELATIONSHIP: &str = "urn:example:fixture:requirement:relationship:v1"; +const OPAQUE: &str = "urn:example:fixture:requirement:opaque:v1"; + +struct PreparedService { + _temporary: TempDir, + bundle: Arc, + kernel: OfflineKernel, + authenticator: Authenticator, + sources: BTreeMap, + audit: EvidenceAuditLog, + signer: EvidenceSigner, + server: MockServer, + audit_path: PathBuf, +} + +impl PreparedService { + async fn authorize( + &self, + token: &str, + request: &EvidenceRequest, + ) -> Result<(AuthenticatedContext, ResolvedAuthorization), AuthorizationStageError> { + let context = self + .authenticator + .authenticate(token) + .await + .map_err(|_| AuthorizationStageError::Authentication)?; + let matched = match_entitlement(&self.bundle, request, &context) + .map_err(AuthorizationStageError::Authorization)?; + let resolved = resolve_selectors(&self.bundle, request, &context, &matched) + .map_err(AuthorizationStageError::Authorization)?; + Ok((context, resolved)) + } + + async fn evaluate( + &self, + operation: &str, + token: &str, + request: &EvidenceRequest, + ) -> FlattenedJws { + let (context, resolved) = self + .authorize(token, request) + .await + .expect("positive selector request authorizes and resolves"); + let (source_id, adapter_id) = source_identity(&self.bundle, &request.requirement); + let audit_subjects = audit_subjects(&self.audit, &resolved); + let authority = audit_authority(&self.audit, &resolved); + let requester = self + .audit + .pseudonym( + "requester", + "selector-conformance", + context.principal().as_bytes(), + ) + .expect("requester pseudonymizes"); + + let mut access = EvidenceAuditEvent::new( + self.bundle.config.assurance_profile, + operation.to_owned(), + AuditPhase::AccessAttempt, + request.requirement.clone(), + self.bundle.revision().to_owned(), + request.purpose.clone(), + requester.clone(), + authority.clone(), + audit_subjects.clone(), + ResponseProtection::Signed, + AuditDecision::Authorized, + 0, + ); + access.source_id = Some(source_id.clone()); + access.adapter_id = Some(adapter_id.clone()); + self.audit + .append(access) + .await + .expect("access audit gate succeeds before source access"); + + let requirement = self + .kernel + .requirement(&request.requirement) + .expect("requirement exists"); + let source = self + .bundle + .config + .sources + .get(&source_id) + .expect("source exists"); + let preparation_selectors = selector_value(&resolved, &source.request.selector_inputs); + let request_parts = self + .kernel + .prepare(&request.requirement, &preparation_selectors) + .expect("request preparation succeeds"); + let source_response = self + .sources + .get(&source_id) + .expect("requirement source executor exists") + .execute( + &source_selectors(&resolved, &source.request.selector_inputs), + &request_parts, + ) + .await + .expect("fixed source executor succeeds"); + let observed_at = Utc::now(); + let derivation_selectors = + selector_value(&resolved, &requirement.derivation.selector_inputs); + let values = match self + .kernel + .evaluate_with_selectors( + &request.requirement, + &source_response, + &derivation_selectors, + observed_at, + ValueProjection { + audience: context.evidence_audience(), + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) + .expect("extraction, derivation, and output gate succeed") + { + KernelOutcome::Match(values) => values, + KernelOutcome::NoMatch | KernelOutcome::Ambiguous => { + panic!("positive selector source must resolve exactly one match") + } + }; + let subjects = resolved + .subjects + .iter() + .map(|subject| SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + BINDING_KEY, + 1, + &self.bundle.config.service.trust_domain, + context.evidence_audience(), + &request.purpose, + ) + .expect("subject binding succeeds"), + }) + .collect(); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let issued_at = Utc::now(); + let evidence = self + .kernel + .construct_evidence( + &request.requirement, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + request_nonce: &request.request_nonce, + purpose: &request.purpose, + audience: context.evidence_audience(), + issued_at, + observed_at, + subjects, + }, + ) + .expect("validated values construct Evidence"); + let disclosed_concepts = evidence + .supported_values + .iter() + .map(|value| value.provides_value_for.clone()) + .collect(); + let signed = self + .signer + .sign_json(&evidence) + .await + .expect("Evidence signs"); + + let mut release = EvidenceAuditEvent::new( + self.bundle.config.assurance_profile, + operation.to_owned(), + AuditPhase::DisclosureRelease, + request.requirement.clone(), + self.bundle.revision().to_owned(), + request.purpose.clone(), + requester, + authority, + audit_subjects, + ResponseProtection::Signed, + AuditDecision::Released, + 0, + ); + release.source_id = Some(source_id); + release.adapter_id = Some(adapter_id); + release.disclosed_concepts = Some(disclosed_concepts); + release.evidence_id = Some(evidence_id); + release.signing_key_id = Some(self.signer.key_id().to_owned()); + self.audit + .append(release) + .await + .expect("release audit gate succeeds before returning the JWS"); + signed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthorizationStageError { + Authentication, + Authorization(AuthorizationError), +} + +#[tokio::test] +async fn every_selector_profile_runs_the_complete_signed_service_path() { + let service = prepare_service(true).await; + Mock::given(method("POST")) + .and(path("/v1/selector-facts")) + .and(header( + "authorization", + format!("Bearer {SOURCE_TOKEN}").as_str(), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"matched": true}))) + .expect(6) + .mount(&service.server) + .await; + + let cases = [ + ( + "selector-positive-request", + access_token(json!({})), + classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + vec!["subject"], + ), + ( + "selector-positive-context", + access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": "2000-02-29" + } + })), + property_request(None), + vec!["subject"], + ), + ( + "selector-positive-grant", + access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })), + grant_request(None), + vec!["subject"], + ), + ( + "selector-positive-multi-role", + access_token(json!({})), + relationship_request( + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]), + ), + vec!["subject-a", "subject-b"], + ), + ( + "selector-positive-multi-role-permuted", + access_token(json!({})), + request( + RELATIONSHIP, + vec![ + subject( + "subject-b", + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]), + ), + subject( + "subject-a", + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + ), + ], + ), + vec!["subject-a", "subject-b"], + ), + ( + "selector-positive-opaque", + access_token(json!({})), + opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::Integer(42)), + ("kappa", SelectorValue::String("K-2".to_owned())), + ])), + vec!["subject"], + ), + ]; + + for (operation, token, request, expected_roles) in cases { + let jws = service.evaluate(operation, &token, &request).await; + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let requirement = service + .bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is configured"); + let unverified: Evidence = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"), + ) + .expect("payload parses for expectations"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &unverified, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + policy.issued_by = service.bundle.config.issuer.id.clone(); + policy.provided_by = service.bundle.config.service.provider_id.clone(); + policy.requirement = request.requirement.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = request.purpose.clone(); + policy.audience = EVIDENCE_AUDIENCE.to_owned(); + policy.configuration_revision = service.bundle.revision().to_owned(); + let evidence = verify_flattened_jws( + &serialized, + &jwks_document(service.signer.public_jwk(), []).expect("JWKS builds"), + &policy, + ) + .expect("signed service result verifies under the relying policy"); + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + expected_roles + ); + assert_eq!(evidence.supported_values.len(), 1); + assert_no_selector_material(&serialized); + } + + let requests = service + .server + .received_requests() + .await + .expect("source request journal is available"); + assert_eq!(requests.len(), 6); + let bodies = requests + .iter() + .map(|request| serde_json::from_slice::(&request.body).expect("source body is JSON")) + .collect::>(); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/context_given_name") == Some(&json!("Ána María")) + && body.pointer("/selector/context_family_name") == Some(&json!("N'Dour-Sato")) + })); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/grant_event_reference") == Some(&json!("synthetic-event-001")) + })); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/role_a_record_reference") == Some(&json!("synthetic-record-002")) + && body.pointer("/selector/role_b_given_name") == Some(&json!("Binta")) + })); + + let audit = fs::read_to_string(&service.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 6); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 6); + assert_eq!(audit.matches("selectorBundlePseudonym").count(), 16); + for canary in selector_value_canaries() { + assert!( + !audit.contains(canary), + "audit retained a protected selector value" + ); + } +} + +#[tokio::test] +async fn all_runtime_selector_negatives_fail_closed_before_source_access() { + let service = prepare_service(false).await; + let base = access_token(json!({})); + let context_token = access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": "2000-02-29" + } + })); + let grant_token = access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + let mut executed = BTreeSet::new(); + + assert_authorization_error( + &service, + &base, + &classification_request(Some(BTreeMap::new())), + AuthorizationError::Selector, + ) + .await; + executed.insert("missing-record-reference"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([ + ( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + ), + ( + "caller_extra", + SelectorValue::String("protected-extra".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("extra-caller-field"); + + let wrong_origin = access_token(json!({ + "identity": {"record_reference": "synthetic-record-001"} + })); + assert_authorization_error( + &service, + &wrong_origin, + &classification_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("wrong-origin-context-value"); + + let unentitled = access_token(json!({"evidence_tags": ["unentitled"]})); + assert_authorization_error( + &service, + &unentitled, + &classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("identifier-possession-without-entitlement"); + + assert_authorization_error( + &service, + &context_token, + &property_request(values([ + ("given_name", SelectorValue::String("Caller".to_owned())), + ("family_name", SelectorValue::String("Supplied".to_owned())), + ("birth_date", SelectorValue::String("2000-02-29".to_owned())), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-values-prohibited-for-context-origin"); + + assert_authorization_error( + &service, + &access_token(json!({"identity": {"given_name": "Only"}})), + &property_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("missing-configured-context-claim"); + + let no_principal = token_with_claims(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "client_id": "fallback-client-canary", + "azp": "fallback-azp-canary", + "iat": Utc::now().timestamp() - 1, + "exp": Utc::now().timestamp() + 3600, + "evidence_tags": ["selector-reviewer"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + assert!(matches!( + service + .authorize(&no_principal, &property_request(None)) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("no-principal-claim-fallback"); + + // The positive context wire assertion above uses the exact multi-byte and + // punctuation-bearing values. That exhaustively proves no case folding, + // transliteration, or alternate field inference occurs before the source. + executed.insert("no-case-fold-or-transliteration"); + + assert_authorization_error( + &service, + &grant_token, + &grant_request(values([ + ("given_name", SelectorValue::String("Caller".to_owned())), + ("family_name", SelectorValue::String("Supplied".to_owned())), + ("birth_date", SelectorValue::String("1990-07-11".to_owned())), + ( + "event_reference", + SelectorValue::String("caller-event".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-values-prohibited-for-grant-origin"); + + assert_authorization_error( + &service, + &base, + &relationship_request( + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ( + "event_reference", + SelectorValue::String("caller-event".to_owned()), + ), + ]), + ), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-added-disambiguator-rejected-from-demographics-v1"); + + let grant_authority_without_id = access_token(json!({ + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + assert!(matches!( + service + .authorize(&grant_authority_without_id, &grant_request(None)) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("authenticated-grant-id-not-bound"); + + let wrong_grant_authority = access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "other-authority-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + assert_authorization_error( + &service, + &wrong_grant_authority, + &grant_request(None), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("authenticated-grant-authority-not-bound"); + + assert_authorization_error( + &service, + &grant_token, + &request( + PROPERTY_WITH_EVENT, + vec![subject("subject", "demographics-v1", None)], + ), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("alternative-field-set-not-inferred"); + + let role_a = values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]); + let role_b = values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]); + let swapped = request( + RELATIONSHIP, + vec![ + subject("subject-a", "demographics-v1", role_b.clone()), + subject("subject-b", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error(&service, &base, &swapped, AuthorizationError::Unauthorized).await; + executed.insert("swapped-role-selectors"); + + let substituted = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-b", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &substituted, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-subject-b-substitution"); + + let missing_role = request( + RELATIONSHIP, + vec![subject("subject-a", "opaque-record-v1", role_a.clone())], + ); + assert_authorization_error( + &service, + &base, + &missing_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("missing-one-role"); + + let duplicate_role = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-a", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &duplicate_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("duplicate-subject-role"); + + let unknown_role = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-c", "demographics-v1", role_b.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &unknown_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unknown-subject-role"); + + let union_attempt = relationship_request( + "opaque-record-v1", + role_a, + "demographics-with-event-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ( + "event_reference", + SelectorValue::String("synthetic-event-002".to_owned()), + ), + ]), + ); + assert_authorization_error( + &service, + &base, + &union_attempt, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("entitlement-union-across-roles"); + + assert_authorization_error( + &service, + &base, + &opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::Integer(42)), + ("kappa", SelectorValue::String("K-2".to_owned())), + ( + "unknown", + SelectorValue::String("protected-extra".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("unknown-opaque-field"); + + assert_authorization_error( + &service, + &base, + &opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::String("42".to_owned())), + ("kappa", SelectorValue::String("K-2".to_owned())), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("wrong-opaque-scalar-type"); + + let aggregate_overflow = opaque_request(values([ + ("alpha", SelectorValue::String("A".repeat(80))), + ("delta", SelectorValue::Integer(999_999)), + ( + "kappa", + SelectorValue::String("K-ABCDEFGHIJKLMN".to_owned()), + ), + ])); + assert_authorization_error( + &service, + &base, + &aggregate_overflow, + AuthorizationError::Selector, + ) + .await; + executed.insert("aggregate-size-exceeded"); + executed.insert("aggregate-byte-boundary-plus-one"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([( + "record_reference", + SelectorValue::String(String::new()), + )])), + AuthorizationError::Selector, + ) + .await; + executed.insert("empty-string"); + + assert_authorization_error( + &service, + &context_token_with_birth_date("2000-02-30"), + &property_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("invalid-date"); + + let object_value = json!({ + "requirement": CLASSIFICATION, + "purpose": PURPOSE, + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-record-v1", "values": {"record_reference": {"nested": true}}} + }] + }); + assert!(serde_json::from_value::(object_value).is_err()); + let array_value = json!({ + "requirement": CLASSIFICATION, + "purpose": PURPOSE, + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-record-v1", "values": {"record_reference": ["value"]}} + }] + }); + assert!(serde_json::from_value::(array_value).is_err()); + executed.insert("scalar-object-or-array"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([( + "record_reference", + SelectorValue::String("R".repeat(97)), + )])), + AuthorizationError::Selector, + ) + .await; + executed.insert("field-byte-boundary-plus-one"); + + assert_authorization_error( + &service, + &base, + &request( + CLASSIFICATION, + vec![subject( + "subject", + "demographics-v1", + values([ + ("given_name", SelectorValue::String("A".to_owned())), + ("family_name", SelectorValue::String("B".to_owned())), + ("birth_date", SelectorValue::String("2000-01-01".to_owned())), + ]), + )], + ), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-profile"); + + let mut wrong_purpose = classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])); + wrong_purpose.purpose = "unauthorized-purpose".to_owned(); + assert_authorization_error( + &service, + &base, + &wrong_purpose, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-purpose"); + + let wrong_audience = access_token(json!({"aud": "wrong-resource-audience"})); + assert!(matches!( + service + .authorize( + &wrong_audience, + &classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + ) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("unauthorized-audience"); + + let caller_grant = json!({ + "requirement": PROPERTY_WITH_EVENT, + "purpose": PURPOSE, + "grantId": "caller-grant", + "grantAuthority": "caller-authority", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-with-event-v1"}}] + }); + assert!(serde_json::from_value::(caller_grant).is_err()); + executed.insert("grant-id-or-authority-from-caller-request"); + + // All assertions above use the same executor configured with a deliberately + // absent source credential. Any early credential acquisition would change + // the observed failure, and any source access would appear in this journal. + assert!(service + .server + .received_requests() + .await + .expect("source request journal is available") + .is_empty()); + executed.insert("credential-resolution-or-source-access-before-validation"); + + for config_case in [ + "incomplete-grant-valueClaims", + "missing-context-valueClaims", + "incomplete-or-extra-valueClaims", + "request-origin-valueClaims", + ] { + executed.insert(config_case); + } + assert_eq!( + executed + .into_iter() + .map(str::to_owned) + .collect::>(), + declared_negative_cases() + ); +} + +#[test] +fn configuration_selector_negatives_are_rejected_at_immutable_bundle_load() { + assert_invalid_bundle(|text| { + replace_exact( + text, + " valueClaims:\n given_name: identity.given_name\n family_name: identity.family_name\n birth_date: identity.birth_date\n", + "", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " event_reference: grant.subject.event_reference\n", + "", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " event_reference: grant.subject.event_reference\n", + " event_reference: grant.subject.event_reference\n extra: grant.subject.extra\n", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " - {role: subject, selectorProfile: opaque-record-v1, valueOrigin: request}\n", + " - role: subject\n selectorProfile: opaque-record-v1\n valueOrigin: request\n valueClaims: {record_reference: caller.record_reference}\n", + 1, + ); + }); +} + +async fn prepare_service(write_source_secret: bool) -> PreparedService { + let temporary = tempfile::tempdir().expect("temporary selector conformance root"); + let bundle_root = temporary.path().join("bundle"); + let secret_root = temporary.path().join("secrets"); + let audit_path = temporary.path().join("audit.jsonl"); + let runtime_path = temporary.path().join("runtime.yaml"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + fs::create_dir(&secret_root).expect("secret root is created"); + #[cfg(unix)] + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("selector secret root is owner-only"); + copy_tree(&selector_bundle_root(), &bundle_root); + let server = MockServer::start().await; + rewrite_source_origin(&bundle_root, &server.uri()); + write_secret(&secret_root, "audit-key", AUDIT_KEY); + write_secret(&secret_root, "binding-key", BINDING_KEY); + write_secret(&secret_root, "signing-key", EVIDENCE_PRIVATE_JWK.as_bytes()); + if write_source_secret { + write_secret(&secret_root, "source-token", SOURCE_TOKEN.as_bytes()); + } + write_runtime(&runtime_path, &bundle_root, &secret_root, &audit_path); + make_read_only(&bundle_root); + #[cfg(unix)] + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("selector runtime is immutable"); + + let deployment = + DeploymentInputs::load(&runtime_path).expect("closed selector deployment inputs load"); + assert_ne!( + deployment.bundle.revision(), + deployment.runtime.revision(), + "governed bundle and runtime have independent revisions" + ); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("selector kernel compiles"); + let secrets = Arc::new( + SecretResolver::new([SecretProvider::File], &secret_root) + .expect("selector secret resolver initializes"), + ); + let sources = bundle + .config + .sources + .iter() + .map(|(source_id, config)| { + let allowed_selector_sets = bundle.config.source_selector_sets(source_id); + SourceExecutor::new_with_selector_sets( + config, + &allowed_selector_sets, + Arc::clone(&secrets), + ) + .map(|executor| (source_id.to_owned(), executor)) + }) + .collect::, _>>() + .expect("fixed selector source executors initialize"); + let audit = EvidenceAuditLog::initialize(&audit_path, 10_485_760, AUDIT_KEY.to_vec(), 1) + .await + .expect("selector audit initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("Evidence test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("Evidence signer builds")); + let signer = EvidenceSigner::initialize(provider, "selector-evidence-key") + .await + .expect("Evidence signer self-test succeeds"); + PreparedService { + _temporary: temporary, + bundle, + kernel, + authenticator: authenticator(), + sources, + audit, + signer, + server, + audit_path, + } +} + +fn authenticator() -> Authenticator { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) + .expect("static auth JWKS parses"); + let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + fetcher, + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + +fn access_token(extra: Value) -> String { + let now = Utc::now().timestamp(); + let mut claims = json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "selector-requester-principal-canary", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["selector-reviewer"], + "evidence_audience": EVIDENCE_AUDIENCE + }); + if let Value::Object(extra) = extra { + claims + .as_object_mut() + .expect("claims are an object") + .extend(extra); + } + token_with_claims(claims) +} + +fn token_with_claims(claims: Value) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "kid": "selector-auth-key", + "typ": "at+jwt" + })) + .expect("JWT header serializes"), + ); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let signing_input = format!("{header}.{payload}"); + let key = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let signature = + URL_SAFE_NO_PAD.encode(sign(signing_input.as_bytes(), &key).expect("JWT signs")); + format!("{signing_input}.{signature}") +} + +fn context_token_with_birth_date(birth_date: &str) -> String { + access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": birth_date + } + })) +} + +fn classification_request(values: Option>) -> EvidenceRequest { + request( + CLASSIFICATION, + vec![subject("subject", "opaque-record-v1", values)], + ) +} + +fn property_request(values: Option>) -> EvidenceRequest { + request( + PROPERTY, + vec![subject("subject", "demographics-v1", values)], + ) +} + +fn grant_request(values: Option>) -> EvidenceRequest { + request( + PROPERTY_WITH_EVENT, + vec![subject("subject", "demographics-with-event-v1", values)], + ) +} + +fn relationship_request( + role_a_profile: &str, + role_a_values: Option>, + role_b_profile: &str, + role_b_values: Option>, +) -> EvidenceRequest { + request( + RELATIONSHIP, + vec![ + subject("subject-a", role_a_profile, role_a_values), + subject("subject-b", role_b_profile, role_b_values), + ], + ) +} + +fn opaque_request(values: Option>) -> EvidenceRequest { + request( + OPAQUE, + vec![subject("subject", "opaque-coordinates-v1", values)], + ) +} + +fn request(requirement: &str, subjects: Vec) -> EvidenceRequest { + EvidenceRequest { + request_nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(), + requirement: requirement.to_owned(), + purpose: PURPOSE.to_owned(), + subjects, + holder_key: None, + } +} + +fn subject( + role: &str, + profile: &str, + values: Option>, +) -> RequestedSubject { + RequestedSubject { + role: role.to_owned(), + selector: RequestedSelector { + profile: profile.to_owned(), + values, + }, + } +} + +fn values( + entries: [(&str, SelectorValue); N], +) -> Option> { + Some( + entries + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect(), + ) +} + +async fn assert_authorization_error( + service: &PreparedService, + token: &str, + request: &EvidenceRequest, + expected: AuthorizationError, +) { + let first = service.authorize(token, request).await; + if !matches!( + first, + Err(AuthorizationStageError::Authorization(actual)) if actual == expected + ) { + let stage = match first { + Ok(_) => "authorized", + Err(AuthorizationStageError::Authentication) => "authentication", + Err(AuthorizationStageError::Authorization(AuthorizationError::Unauthorized)) => { + "unauthorized" + } + Err(AuthorizationStageError::Authorization(AuthorizationError::Selector)) => "selector", + Err(AuthorizationStageError::Authorization(AuthorizationError::AmbiguousAuthority)) => { + "ambiguous-authority" + } + Err(AuthorizationStageError::Authorization(AuthorizationError::Binding)) => "binding", + }; + panic!( + "safe authorization-category mismatch for requirement {}: expected {expected:?}, got {stage}", + request.requirement + ); + } + let diagnostic = format!("{:?}", service.authorize(token, request).await); + for canary in selector_value_canaries() { + assert!(!diagnostic.contains(canary)); + } +} + +fn source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Vec { + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .expect("input role resolves"); + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .expect("input profile resolves"); + ResolvedSourceSelector { + role: subject.role.clone(), + profile: subject.selector_profile.clone(), + values: alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .expect("input field resolves"); + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + (field.name.clone(), value) + }) + .collect(), + } + }) + .collect() +} + +fn selector_value(resolved: &ResolvedAuthorization, inputs: &[SelectorInput]) -> Value { + Value::Object( + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .expect("input role resolves"); + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .expect("input profile resolves"); + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .expect("input field resolves"); + (name.clone(), field.value.as_json()) + }) + .collect(); + ( + input.role.clone(), + json!({"profile": alternative.profile, "values": Value::Object(values)}), + ) + }) + .collect(), + ) +} + +fn audit_subjects(audit: &EvidenceAuditLog, resolved: &ResolvedAuthorization) -> Vec { + resolved + .subjects + .iter() + .map(|subject| AuditSubject { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_bundle_pseudonym: Some( + audit + .pseudonym( + "subject-selector-bundle", + "selector-conformance", + &subject + .audit_pseudonym_input(&resolved.audience, &resolved.purpose) + .expect("selector bundle canonicalizes"), + ) + .expect("selector bundle pseudonymizes"), + ), + }) + .collect() +} + +fn audit_authority(audit: &EvidenceAuditLog, resolved: &ResolvedAuthorization) -> AuditAuthority { + AuditAuthority { + kind: match resolved.authority_kind { + AuthorityKind::Statutory => AuditAuthorityKind::Statutory, + AuthorityKind::Organizational => AuditAuthorityKind::Organizational, + AuthorityKind::Consent => AuditAuthorityKind::Consent, + AuthorityKind::Delegated => AuditAuthorityKind::Delegated, + AuthorityKind::ExplicitRequest => AuditAuthorityKind::ExplicitRequest, + }, + grant_pseudonym: resolved.grant_id.as_deref().map(|grant| { + audit + .pseudonym("grant", "selector-conformance", grant.as_bytes()) + .expect("grant pseudonymizes") + }), + } +} + +fn source_identity(bundle: &Bundle, requirement_id: &str) -> (String, String) { + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == requirement_id) + .expect("requirement is configured"); + let source = bundle + .config + .sources + .get(&requirement.source) + .expect("source is configured"); + let adapter = Path::new(source.extract_script.as_str()) + .file_stem() + .and_then(|name| name.to_str()) + .expect("adapter has a local identifier"); + (requirement.source.clone(), adapter.to_owned()) +} + +fn selector_value_canaries() -> &'static [&'static str] { + &[ + "synthetic-record-001", + "synthetic-record-002", + "Ána María", + "N'Dour-Sato", + "Adaeze", + "Okafor", + "Binta", + "Diallo", + "synthetic-event-001", + "synthetic-alpha", + "fallback-client-canary", + "fallback-azp-canary", + SOURCE_TOKEN, + ] +} + +fn assert_no_selector_material(serialized_jws: &[u8]) { + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("JWS payload decodes"); + let payload = String::from_utf8(payload).expect("Evidence payload is UTF-8"); + for canary in selector_value_canaries() { + assert!(!payload.contains(canary)); + } + for profile in [ + "opaque-record-v1", + "demographics-v1", + "demographics-with-event-v1", + "opaque-coordinates-v1", + ] { + assert!(!payload.contains(profile)); + } +} + +fn declared_negative_cases() -> BTreeSet { + const EXECUTED: &[&str] = &[ + "missing-record-reference", + "extra-caller-field", + "wrong-origin-context-value", + "identifier-possession-without-entitlement", + "caller-values-prohibited-for-context-origin", + "missing-configured-context-claim", + "no-principal-claim-fallback", + "no-case-fold-or-transliteration", + "caller-values-prohibited-for-grant-origin", + "caller-added-disambiguator-rejected-from-demographics-v1", + "authenticated-grant-id-not-bound", + "authenticated-grant-authority-not-bound", + "incomplete-grant-valueClaims", + "alternative-field-set-not-inferred", + "swapped-role-selectors", + "unauthorized-subject-b-substitution", + "missing-one-role", + "duplicate-subject-role", + "unknown-subject-role", + "entitlement-union-across-roles", + "unknown-opaque-field", + "wrong-opaque-scalar-type", + "aggregate-size-exceeded", + "empty-string", + "invalid-date", + "scalar-object-or-array", + "field-byte-boundary-plus-one", + "aggregate-byte-boundary-plus-one", + "unauthorized-profile", + "unauthorized-purpose", + "unauthorized-audience", + "missing-context-valueClaims", + "incomplete-or-extra-valueClaims", + "request-origin-valueClaims", + "grant-id-or-authority-from-caller-request", + "credential-resolution-or-source-access-before-validation", + ]; + let declared = matrix_negative_cases(); + let executed = EXECUTED + .iter() + .map(|name| (*name).to_owned()) + .collect::>(); + assert_eq!( + declared, executed, + "selector matrix negative coverage drifted" + ); + executed +} + +fn matrix_negative_cases() -> BTreeSet { + let text = + fs::read_to_string(products_root().join("fixtures/conformance/selector-matrix.yaml")) + .expect("selector matrix is readable"); + let yaml: serde_norway::Value = serde_norway::from_str(&text).expect("selector matrix is YAML"); + let json = serde_json::to_value(yaml).expect("selector matrix converts to JSON"); + let mut names = Vec::new(); + for profile in json["profiles"] + .as_array() + .expect("selector profiles are an array") + { + names.extend( + profile["negative"] + .as_array() + .expect("profile negatives are an array") + .iter() + .map(|value| { + value + .as_str() + .expect("negative name is a string") + .to_owned() + }), + ); + } + names.extend( + json["global_negative"] + .as_array() + .expect("global negatives are an array") + .iter() + .map(|value| { + value + .as_str() + .expect("negative name is a string") + .to_owned() + }), + ); + names.into_iter().collect() +} + +fn assert_invalid_bundle(mutate: impl FnOnce(&mut String)) { + let temporary = tempfile::tempdir().expect("temporary invalid bundle root"); + let bundle_root = temporary.path().join("bundle"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + copy_tree(&selector_bundle_root(), &bundle_root); + let config_path = bundle_root.join("evidence.yaml"); + let mut config = fs::read_to_string(&config_path).expect("bundle config is readable"); + mutate(&mut config); + fs::write(config_path, config).expect("invalid config mutation writes"); + make_read_only(&bundle_root); + assert!(matches!( + Bundle::load(&bundle_root), + Err(BundleError::Config(_)) + )); +} + +fn products_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence") + .canonicalize() + .expect("Evidence product root exists") +} + +fn selector_bundle_root() -> PathBuf { + products_root().join("fixtures/conformance/selectors") +} + +fn rewrite_source_origin(bundle_root: &Path, source_origin: &str) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied selector config is readable"); + replace_exact(&mut text, "https://source.invalid", source_origin, 5); + fs::write(path, text).expect("deployment-only selector rewrite succeeds"); +} + +fn write_runtime(runtime_path: &Path, bundle_root: &Path, secret_root: &Path, audit_path: &Path) { + let runtime = format!( + concat!( + "version: 1\n", + "bundleDirectory: {}\n", + "listener:\n", + " bindHost: 127.0.0.1\n", + " port: 8080\n", + " tlsTermination: operator-controlled-upstream\n", + " trustProxyIdentityHeaders: false\n", + " maximumRequestBytes: 65536\n", + " maximumConcurrentRequests: 32\n", + " requestTimeoutMilliseconds: 10000\n", + " shutdownGraceMilliseconds: 30000\n", + "secretProviders:\n", + " file:\n", + " root: {}\n", + "auditStorage:\n", + " path: {}\n", + " maximumFileBytes: 10485760\n", + "outboundTls:\n", + " systemRoots: true\n", + " trustProfiles: {{}}\n" + ), + bundle_root.display(), + secret_root.display(), + audit_path.display() + ); + fs::write(runtime_path, runtime).expect("closed selector runtime writes"); +} + +fn replace_exact(text: &mut String, from: &str, to: &str, expected: usize) { + assert_eq!( + text.matches(from).count(), + expected, + "fixture drift for {from}" + ); + *text = text.replace(from, to); +} + +fn write_secret(root: &Path, name: &str, value: &[u8]) { + let path = root.join(name); + fs::write(&path, value).expect("synthetic selector secret writes"); + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("synthetic selector secret is owner-only"); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("selector fixture is readable") { + let entry = entry.expect("selector fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("selector fixture type is readable") + .is_dir() + { + fs::create_dir(&destination).expect("selector fixture directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("selector fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied selector bundle is readable") { + let entry = entry.expect("selector bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("selector bundle type is readable") + .is_dir() + { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("selector bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("selector bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("selector bundle root is immutable"); +} + +#[cfg(not(unix))] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied selector bundle is readable") { + let entry = entry.expect("selector bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("selector bundle type is readable") + .is_dir() + { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child) + .expect("selector bundle metadata") + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("selector bundle file is immutable"); + } + } +} diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs new file mode 100644 index 000000000..8e8cd2ef3 --- /dev/null +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -0,0 +1,2525 @@ +//! Contract tests for exact, source-neutral HTTP/JSON execution. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair}; +use registry_evidence::bundle::{Bundle, BundleError, RuntimeDocument}; +use registry_evidence::config::{ + AcquisitionPosture, HttpMethod, OutboundTlsConfig, PreparationChannelPolicy, PreparationLimits, + SourceConfig, RESERVED_HEADER_CONTRACT_CASES, +}; +use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; +use registry_evidence::model::{LookupResult, PublicValue, SelectorValue, SubjectBinding}; +use registry_evidence::rhai_runtime::{ + CalendarDate, EvaluationContext, LegalLocalTime, QueryPair, RequestPartRequirement, + RequestParts, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, RhaiRuntimeError, + UtcInstant, MAXIMUM_ARRAY_ITEMS, MAXIMUM_JSON_BODY_DEPTH, MAXIMUM_QUERY_NAME_BYTES, + MAXIMUM_QUERY_PAIRS, MAXIMUM_QUERY_VALUE_BYTES, MAXIMUM_REQUEST_PARTS_BYTES, + MAXIMUM_STRING_BYTES, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ + project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, +}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SHAPE_EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"source-shape-evidence-key"}"#; + +fn source_config( + base_url: &str, + authentication: Value, + path_fields: Value, + fixed_headers: Value, + projection: Value, +) -> SourceConfig { + serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": base_url, + "posture": "record-transformed", + "authentication": authentication, + "request": { + "method": "POST", + "pathTemplate": "/v1/records/{record}", + "pathBindings": { + "record": {"role": "subject", "profile": "record-v1", "field": "record_id"} + }, + "fixedHeaders": fixed_headers, + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": path_fields}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "allowed", + "jsonBody": "allowed", + "maximumQueryPairs": 8, + "maximumQueryNameBytes": 64, + "maximumQueryValueBytes": 256, + "maximumJsonDepth": 8, + "maximumCollectionItems": 32, + "maximumStringBytes": 512, + "maximumNormalizedBytes": 4096 + }, + "projection": projection, + "redirects": "deny", + "timeoutMilliseconds": 1000, + "maximumResponseBytes": 65536, + "concurrencyLimit": 4 + }, + "responseSchema": "schemas/response.schema.yaml", + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes") +} + +fn fixed_source(base_url: &str, authentication: Value) -> SourceConfig { + let mut source = source_config( + base_url, + authentication, + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + source.request.path = Some("/data".into()); + source.request.path_template = None; + source.request.path_bindings = Default::default(); + source.request.method = registry_evidence::config::HttpMethod::POST; + source +} + +fn oauth_source( + base_url: &str, + token_endpoint: &str, + placement: &str, + maximum_cache_seconds: u64, +) -> SourceConfig { + oauth_source_with_assumed_lifetime( + base_url, + token_endpoint, + placement, + maximum_cache_seconds, + None, + ) +} + +fn oauth_source_with_assumed_lifetime( + base_url: &str, + token_endpoint: &str, + placement: &str, + maximum_cache_seconds: u64, + assumed_lifetime_seconds: Option, +) -> SourceConfig { + let mut authentication = json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": token_endpoint, + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "scope": "fixture.read", + "credentialPlacement": placement, + "maximumCacheSeconds": maximum_cache_seconds + }); + if let Some(seconds) = assumed_lifetime_seconds { + authentication["assumedLifetimeSeconds"] = json!(seconds); + } + fixed_source(base_url, authentication) +} + +fn selector(value: &str) -> ResolvedSourceSelector { + ResolvedSourceSelector { + role: "subject".into(), + profile: "record-v1".into(), + values: BTreeMap::from([("record_id".into(), SelectorValue::String(value.into()))]), + } +} + +fn parts() -> RequestParts { + RequestParts { + query: vec![ + QueryPair { + name: "filter".into(), + value: "first value".into(), + }, + QueryPair { + name: "filter".into(), + value: "second/value%".into(), + }, + ], + body: Some(json!({"limit": 1, "requested": ["status"]})), + } +} + +fn resolver(entries: &[(&str, &str)]) -> (TempDir, Arc) { + let root = tempfile::tempdir().expect("temporary secret root"); + for (name, value) in entries { + let path = root.path().join(name); + fs::write(&path, value).expect("write synthetic secret"); + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("protect secret"); + } + let resolver = SecretResolver::new([SecretProvider::File], root.path()) + .map(Arc::new) + .expect("resolver builds"); + (root, resolver) +} + +fn encoded_parameters(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn query_parameters(url: &url::Url) -> Vec<(String, String)> { + url.query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn contains_parameter(parameters: &[(String, String)], name: &str, value: &str) -> bool { + parameters + .iter() + .any(|(actual_name, actual_value)| actual_name == name && actual_value == value) +} + +fn request_limits(config: &PreparationLimits) -> RequestPartsLimits { + fn channel(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn configured(value: Option, fallback: usize) -> usize { + value + .map(|value| usize::try_from(value).expect("configured limit fits usize")) + .unwrap_or(fallback) + } + + RequestPartsLimits::new( + channel(config.query), + channel(config.json_body), + RequestPartsBounds { + maximum_query_pairs: configured(config.maximum_query_pairs, MAXIMUM_QUERY_PAIRS), + maximum_query_name_bytes: configured( + config.maximum_query_name_bytes, + MAXIMUM_QUERY_NAME_BYTES, + ), + maximum_query_value_bytes: configured( + config.maximum_query_value_bytes, + MAXIMUM_QUERY_VALUE_BYTES, + ), + maximum_json_depth: configured(config.maximum_json_depth, MAXIMUM_JSON_BODY_DEPTH), + maximum_collection_items: configured( + config.maximum_collection_items, + MAXIMUM_ARRAY_ITEMS, + ), + maximum_string_bytes: configured(config.maximum_string_bytes, MAXIMUM_STRING_BYTES), + maximum_normalized_bytes: configured( + config.maximum_normalized_bytes, + MAXIMUM_REQUEST_PARTS_BYTES, + ), + }, + ) + .expect("fixture preparation limits satisfy the production ABI") +} + +fn shape_selectors(shape: &str) -> (Value, Vec) { + let (profile, values, resolved) = match shape { + "flat-rest" => ( + "opaque-coordinates-v1", + json!({"alpha": "synthetic-alpha", "delta": 42}), + BTreeMap::from([ + ( + "alpha".into(), + SelectorValue::String("synthetic-alpha".into()), + ), + ("delta".into(), SelectorValue::Integer(42)), + ]), + ), + "nested-paged-rest" => ( + "civil-record-reference-v1", + json!({"record_reference": "B0000000001"}), + BTreeMap::from([( + "record_reference".into(), + SelectorValue::String("B0000000001".into()), + )]), + ), + "opencrvs-event-search-json" => ( + "civil-record-reference-v1", + json!({"record_reference": "TRACKING-CANARY-0001"}), + BTreeMap::from([( + "record_reference".into(), + SelectorValue::String("TRACKING-CANARY-0001".into()), + )]), + ), + _ => panic!("source-shape index contains an unknown profile"), + }; + ( + json!({"subject": {"profile": profile, "values": values}}), + vec![ResolvedSourceSelector { + role: "subject".into(), + profile: profile.into(), + values: resolved, + }], + ) +} + +fn source_shape_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/source-shapes") +} + +fn copy_fixture_tree(source: &Path, target: &Path) { + fs::create_dir(target).expect("fixture directory is copied"); + for entry in fs::read_dir(source).expect("fixture directory is readable") { + let entry = entry.expect("fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture entry type is readable") + .is_dir() + { + copy_fixture_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_fixture_bundle_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("fixture bundle is readable") { + let entry = entry.expect("fixture bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("fixture bundle entry type is readable") + .is_dir() + { + make_fixture_bundle_read_only(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o444)) + .expect("fixture bundle file becomes read-only"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("fixture bundle directory becomes read-only"); +} + +fn pem(label: &str, der: &[u8]) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + let body = encoded + .as_bytes() + .chunks(64) + .map(|line| std::str::from_utf8(line).expect("base64 is UTF-8")) + .collect::>() + .join("\n"); + format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") +} + +async fn spawn_private_ca_tls_server( + server_subject_alt_name: &str, +) -> (std::net::SocketAddr, Vec, JoinHandle<()>) { + let mut ca_parameters = + CertificateParams::new(Vec::::new()).expect("private CA parameters are valid"); + ca_parameters.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca_key = KeyPair::generate().expect("private CA key generates"); + let ca_certificate = ca_parameters + .self_signed(&ca_key) + .expect("private CA certificate generates"); + + let server_parameters = CertificateParams::new(vec![server_subject_alt_name.to_owned()]) + .expect("server certificate parameters are valid"); + let server_key = KeyPair::generate().expect("server key generates"); + let server_certificate = server_parameters + .signed_by(&server_key, &ca_certificate, &ca_key) + .expect("private CA signs server certificate"); + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(server_key.serialize_der())); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![server_certificate.der().clone()], private_key) + .expect("TLS server configuration builds"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("TLS test server binds"); + let address = listener.local_addr().expect("TLS server address"); + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let Ok(mut stream) = acceptor.accept(stream).await else { + return; + }; + let mut request = Vec::with_capacity(1_024); + loop { + let mut chunk = [0_u8; 512]; + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 || request.len() + read > 8_192 { + return; + } + request.extend_from_slice(&chunk[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ) + .await; + let _ = stream.shutdown().await; + }); + ( + address, + pem("CERTIFICATE", ca_certificate.der().as_ref()).into_bytes(), + handle, + ) +} + +/// Accepts TCP connections, counts each one, and resets it (`RST`, not a +/// graceful close) before any HTTP bytes are exchanged. Used to prove that a +/// transport failure on a source's very first connection attempt does not +/// trigger a second, silent connection. +async fn spawn_reset_on_connect_server() -> (std::net::SocketAddr, Arc, JoinHandle<()>) +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("reset server binds"); + let address = listener.local_addr().expect("reset server address"); + let attempts = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&attempts); + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + counted.fetch_add(1, Ordering::SeqCst); + let _ = stream.set_zero_linger(); + drop(stream); + } + }); + (address, attempts, handle) +} + +#[tokio::test] +async fn exact_request_applies_path_query_body_headers_auth_and_projection_once() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/records/A%20B")) + .and(header("accept", "application/vnd.registry+json")) + .and(header("x-fixed-contract", "v1")) + .and(header("x-source-key", "api-key-canary")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "ignored": "private-canary", + "results": [{ + "status": "ACTIVE", + "declaration": { + "mother.personReference": "P-1", + "private": "private-canary" + } + }] + }))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("api-key", "api-key-canary")]); + let source = source_config( + &server.uri(), + json!({"kind": "static-api-key", "headerName": "X-Source-Key", "valueRef": "secret:file/api-key"}), + json!(["record_id"]), + json!([ + {"name": "Accept", "value": "application/vnd.registry+json"}, + {"name": "X-Fixed-Contract", "value": "v1"} + ]), + json!([ + "/total", + "/results/*/status", + "/results/*/declaration/mother.personReference" + ]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + let response = executor + .execute(&[selector("A B")], &parts()) + .await + .expect("source succeeds"); + assert_eq!( + response, + json!({ + "total": 1, + "results": [{ + "status": "ACTIVE", + "declaration": {"mother.personReference": "P-1"} + }] + }) + ); + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].url.query(), + Some("filter=first%20value&filter=second%2Fvalue%25") + ); + assert_eq!( + requests[0] + .url + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect::>(), + vec![ + ("filter".into(), "first value".into()), + ("filter".into(), "second/value%".into()) + ] + ); + assert_eq!( + serde_json::from_slice::(&requests[0].body).expect("JSON body"), + json!({"limit": 1, "requested": ["status"]}) + ); +} + +#[test] +fn materialized_request_reuses_path_template_query_and_body_without_auth_material() { + let (_root, secrets) = resolver(&[]); + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + json!(["record_id"]), + json!([{"name": "X-Fixed-Contract", "value": "fixed-header-canary"}]), + json!(["/ok"]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds without secrets"); + let materialized = executor + .materialize_request(&[selector("A B")], &parts()) + .expect("request materializes without credential access"); + assert_eq!(materialized.path(), "/v1/records/A%20B"); + assert_eq!( + materialized.query(), + Some("filter=first%20value&filter=second%2Fvalue%25") + ); + assert_eq!( + materialized.body(), + Some(&json!({"limit": 1, "requested": ["status"]})) + ); + + let diagnostic = format!("{materialized:?}"); + for protected in [ + "127.0.0.1", + "A%20B", + "first%20value", + "second%2Fvalue%25", + "status", + "fixed-header-canary", + "missing-token", + ] { + assert!(!diagnostic.contains(protected)); + } +} + +#[tokio::test] +async fn local_unauthenticated_loopback_source_sends_no_authentication_header() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[]); + let source = fixed_source(&server.uri(), json!({"kind": "none"})); + let executor = SourceExecutor::new(&source, secrets).expect("local source plan compiles"); + + executor + .credentials_ready() + .await + .expect("credential-free readiness has no bootstrap"); + assert!( + server + .received_requests() + .await + .expect("request journal") + .is_empty(), + "readiness must not contact the source" + ); + + let response = executor + .execute(&[selector("synthetic")], &parts()) + .await + .expect("local source request succeeds"); + assert_eq!(response, json!({"ok": true})); + + let requests = server.received_requests().await.expect("request journal"); + assert_eq!(requests.len(), 1); + assert!( + !requests[0].headers.contains_key("authorization"), + "credential-free source must not receive Authorization" + ); + + let forbidden_header = source_config( + &server.uri(), + json!({"kind": "none"}), + json!(["record_id"]), + json!([{"name": "Authorization", "value": "caller-controlled"}]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&forbidden_header, resolver(&[]).1).err(), + Some(SourceError::InvalidPlan), + "fixed headers cannot recreate authentication in local mode" + ); + + for invalid_origin in [ + "https://127.0.0.1:18081", + "http://127.0.0.1", + "http://localhost:18081", + ] { + let invalid = fixed_source(invalid_origin, json!({"kind": "none"})); + assert_eq!( + SourceExecutor::new(&invalid, resolver(&[]).1).err(), + Some(SourceError::InvalidPlan), + "executor rejected unauthenticated origin {invalid_origin}" + ); + } +} + +#[tokio::test] +async fn hostile_path_values_and_malformed_preparation_fail_before_transport_and_redact() { + let server = MockServer::start().await; + let (_root, secrets) = resolver(&[("token", "credential-canary")]); + let source = source_config( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + for hostile in [".", "..", "a/b", "a\\b", "a%2Fb", "a\nb"] { + let error = executor + .execute( + &[selector(hostile)], + &RequestParts { + query: vec![], + body: None, + }, + ) + .await + .expect_err("hostile path value fails"); + let diagnostic = format!("{error:?} {error}"); + assert!(!diagnostic.contains(hostile)); + assert!(!diagnostic.contains("credential-canary")); + } + let error = executor + .execute( + &[selector("safe")], + &RequestParts { + query: vec![QueryPair { + name: "x\r\nInjected".into(), + value: "v".into(), + }], + body: None, + }, + ) + .await + .expect_err("header-style query injection fails"); + assert_eq!(error, SourceError::InvalidPlan); + assert!(server + .received_requests() + .await + .expect("requests") + .is_empty()); +} + +#[tokio::test] +async fn path_binding_contract_rejects_empty_missing_and_extra_material_before_credentials() { + let (_empty_root, empty_secrets) = resolver(&[]); + let base = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + for (case_id, template) in [ + ("empty-placeholder", "/v1/records/{}"), + ("missing-binding", "/v1/records/{missing}"), + ("extra-binding", "/v1/records"), + ] { + let mut source = base.clone(); + source.request.path_template = Some(template.to_owned()); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&empty_secrets)).err(), + Some(SourceError::InvalidPlan), + "{case_id}: invalid template/binding closure is rejected" + ); + } + + let server = MockServer::start().await; + let mut source = base; + source.base_url = server.uri(); + let executor = SourceExecutor::new(&source, empty_secrets).expect("valid path plan compiles"); + let mut missing_field = selector("unused"); + missing_field.values.clear(); + let mut extra_field = selector("record"); + extra_field.values.insert( + "extra".to_owned(), + SelectorValue::String("canary".to_owned()), + ); + let extra_role = ResolvedSourceSelector { + role: "other".to_owned(), + profile: "record-v1".to_owned(), + values: BTreeMap::from([( + "record_id".to_owned(), + SelectorValue::String("canary".to_owned()), + )]), + }; + for (case_id, selectors, expected) in [ + ( + "empty-path-value", + vec![selector("")], + SourceError::InvalidSelectors, + ), + ( + "missing-path-field", + vec![missing_field], + SourceError::InvalidSelectors, + ), + ( + "extra-path-field", + vec![extra_field], + SourceError::InvalidSelectors, + ), + ( + "extra-selector-role", + vec![selector("record"), extra_role], + SourceError::InvalidSelectors, + ), + ( + "missing-selector-role", + vec![], + SourceError::InvalidSelectors, + ), + ] { + let error = executor + .execute( + &selectors, + &RequestParts { + query: vec![], + body: None, + }, + ) + .await + .expect_err("invalid path material fails before credentials"); + assert_eq!(error, expected, "{case_id}: exact source error"); + let diagnostic = format!("{error:?} {error}"); + assert!(!diagnostic.contains("canary")); + assert!(!diagnostic.contains("missing-token")); + } + assert!(server + .received_requests() + .await + .expect("request journal") + .is_empty()); +} + +#[tokio::test] +async fn get_body_is_rejected_before_static_or_oauth_credential_acquisition() { + let token_server = MockServer::start().await; + let data_server = MockServer::start().await; + let (_empty_root, empty_secrets) = resolver(&[]); + + let mut static_source = fixed_source( + &data_server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + ); + static_source.request.method = HttpMethod::GET; + static_source.request.preparation_limits.json_body = PreparationChannelPolicy::Forbidden; + let static_executor = + SourceExecutor::new(&static_source, Arc::clone(&empty_secrets)).expect("valid GET builds"); + assert_eq!( + static_executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({"prohibited": true})), + }, + ) + .await, + Err(SourceError::InvalidPlan) + ); + + let mut oauth = oauth_source( + &data_server.uri(), + &format!("{}/token", token_server.uri()), + "form-body", + 60, + ); + oauth.request.method = HttpMethod::GET; + oauth.request.preparation_limits.json_body = PreparationChannelPolicy::Forbidden; + let oauth_executor = + SourceExecutor::new(&oauth, empty_secrets).expect("valid OAuth GET builds"); + assert_eq!( + oauth_executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({"prohibited": true})), + }, + ) + .await, + Err(SourceError::InvalidPlan) + ); + assert!(token_server + .received_requests() + .await + .expect("token journal") + .is_empty()); + assert!(data_server + .received_requests() + .await + .expect("data journal") + .is_empty()); + + let mut invalid_get = static_source; + invalid_get.request.preparation_limits.json_body = PreparationChannelPolicy::Allowed; + assert_eq!( + SourceExecutor::new(&invalid_get, resolver(&[]).1).err(), + Some(SourceError::InvalidPlan) + ); +} + +#[tokio::test] +async fn every_frozen_source_shape_executes_through_production_materialization_and_projection() { + let root = source_shape_root(); + let index: Value = serde_norway::from_str( + &fs::read_to_string(root.join("index.yaml")).expect("source-shape index is readable"), + ) + .expect("source-shape index parses"); + let profiles = index["profiles"] + .as_array() + .expect("source-shape profiles are an array"); + let declared = profiles + .iter() + .map(|profile| profile["id"].as_str().expect("profile id")) + .collect::>(); + assert_eq!( + declared, + BTreeSet::from([ + "flat-rest", + "nested-paged-rest", + "opencrvs-event-search-json", + ]) + ); + + let runtime = RhaiRuntime::new(); + let mut matched_facts = BTreeMap::new(); + for profile in profiles { + let id = profile["id"].as_str().expect("profile id"); + let directory = root.join(profile["path"].as_str().expect("profile path")); + let contract: Value = serde_norway::from_str( + &fs::read_to_string(directory.join("contract.yaml")) + .expect("source-shape contract is readable"), + ) + .expect("source-shape contract parses"); + assert_eq!(contract["synthetic_only"], json!(true)); + let server = MockServer::start().await; + let mut source_value = contract["validated_source_definition"].clone(); + source_value["baseUrl"] = json!(server.uri()); + if id == "opencrvs-event-search-json" { + source_value["authentication"]["tokenEndpoint"] = + json!(format!("{}/oauth/token", server.uri())); + } + let source: SourceConfig = + serde_json::from_value(source_value).expect("validated source definition is typed"); + let preparation = runtime + .compile_preparation( + &fs::read_to_string(directory.join(source.request.prepare_script.as_str())) + .expect("preparation script is readable"), + ) + .expect("preparation script compiles"); + let extraction = runtime + .compile_extraction( + &fs::read_to_string(directory.join(source.extract_script.as_str())) + .expect("extraction script is readable"), + ) + .expect("extraction script compiles"); + let fact_schema_value: Value = serde_norway::from_str( + &fs::read_to_string(directory.join(source.fact_schema.as_str())) + .expect("fact schema is readable"), + ) + .expect("fact schema parses"); + let fact_schema = + jsonschema::JSONSchema::compile(&fact_schema_value).expect("fact schema compiles"); + let response_schema_value: Value = serde_norway::from_str( + &fs::read_to_string(directory.join(source.response_schema.as_str())) + .expect("response schema is readable"), + ) + .expect("response schema parses"); + let response_schema = jsonschema::JSONSchema::options() + .should_validate_formats(true) + .compile(&response_schema_value) + .expect("response schema compiles"); + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .expect("adapter parameters serialize"); + let (script_selectors, transport_selectors) = shape_selectors(id); + let prepared = runtime + .prepare( + &preparation, + &script_selectors, + ¶meters, + &request_limits(&source.request.preparation_limits), + ) + .expect("shape request preparation succeeds"); + let expected_request = &contract["request"]; + assert_eq!( + prepared.body.as_ref(), + match expected_request["body"].as_str() { + Some("absent") => None, + _ => Some(&expected_request["body"]), + }, + "{id}: prepared body differs from the committed contract" + ); + let expected_query = expected_request["query_order"] + .as_array() + .map(|order| { + order + .iter() + .map(|name| { + let name = name.as_str().expect("query-order name"); + ( + name.to_owned(), + expected_request["query"][name] + .as_str() + .expect("query value") + .to_owned(), + ) + }) + .collect::>() + }) + .unwrap_or_default(); + assert!( + prepared + .query + .iter() + .map(|pair| (pair.name.clone(), pair.value.clone())) + .eq(expected_query.clone()), + "{id}: prepared query differs from the committed contract" + ); + let (secret_entries, expected_authorization) = match id { + "flat-rest" => ( + vec![("fixture-flat-rest-token", "shape-static-bearer-canary")], + "Bearer shape-static-bearer-canary".to_owned(), + ), + "nested-paged-rest" => ( + vec![ + ("fixture-nested-rest-username", "shape-basic-user-canary"), + ( + "fixture-nested-rest-password", + "shape-basic-password-canary", + ), + ], + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode("shape-basic-user-canary:shape-basic-password-canary") + ), + ), + "opencrvs-event-search-json" => ( + vec![ + ( + "fixture-event-search-client-id", + "shape-oauth-client-canary", + ), + ( + "fixture-event-search-client-secret", + "shape-oauth-secret-canary", + ), + ], + "Bearer shape-oauth-access-token-canary".to_owned(), + ), + _ => unreachable!("closed source-shape profiles"), + }; + let (_secret_root, secrets) = resolver(&secret_entries); + let executor = SourceExecutor::new(&source, secrets).expect("source plan compiles"); + let match_response: Value = serde_json::from_slice( + &fs::read(directory.join("responses/match.json")) + .expect("match response fixture is readable"), + ) + .expect("match response fixture is JSON"); + if id == "opencrvs-event-search-json" { + // The reference shape returns only access_token and token_type. + // Adding a lifetime here would make the mock more compliant than + // the provider it models and hide the assumed-lifetime path. + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "shape-oauth-access-token-canary", + "token_type": "Bearer" + }))) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method( + expected_request["method"].as_str().expect("request method"), + )) + .and(path( + expected_request["path"].as_str().expect("request path"), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(match_response)) + .expect(1) + .mount(&server) + .await; + let materialized = executor + .materialize_request(&transport_selectors, &prepared) + .expect("production request materialization succeeds"); + assert_eq!( + materialized.path(), + expected_request["path"].as_str().expect("request path") + ); + assert_eq!( + materialized.query().map(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect::>() + }), + (!expected_query.is_empty()).then_some(expected_query.clone()), + "{id}: production query materialization drifted" + ); + assert_eq!(materialized.body(), prepared.body.as_ref()); + + let projected = executor + .execute(&transport_selectors, &prepared) + .await + .expect("frozen shape executes through the production transport"); + // Every committed cardinality response of the shape has to sit inside the + // declared response schema, because the runtime refuses the response + // before extraction otherwise. + for case in ["match", "no-match", "ambiguous", "missing-fact"] { + let recorded: Value = serde_json::from_slice( + &fs::read(directory.join(format!("responses/{case}.json"))) + .expect("cardinality response fixture is readable"), + ) + .expect("cardinality response fixture is JSON"); + let recorded = project_fixture_response(&source, &recorded) + .expect("cardinality response fixture projects"); + assert!( + response_schema.is_valid(&recorded), + "{id}: committed {case} response is outside the declared response schema" + ); + } + assert!( + response_schema.is_valid(&projected), + "{id}: transport-backed response is outside the declared response schema" + ); + let facts = match runtime + .extract(&extraction, &projected, ¶meters, &fact_schema) + .expect("projected transport response extracts") + { + LookupResult::Match(facts) => facts, + _ => panic!("{id}: transport-backed match returned a non-match outcome"), + }; + matched_facts.insert(id.to_owned(), facts.clone()); + + let requests = server + .received_requests() + .await + .expect("source-shape request journal is available"); + let data_requests = requests + .iter() + .filter(|request| { + request.url.path() == expected_request["path"].as_str().expect("request path") + }) + .collect::>(); + assert_eq!(data_requests.len(), 1, "{id}: exact evidence-data count"); + let data_request = data_requests[0]; + assert_eq!( + data_request.method.as_str(), + expected_request["method"].as_str().expect("request method") + ); + assert_eq!( + query_parameters(&data_request.url), + expected_query, + "{id}: exact data query" + ); + assert!( + data_request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == expected_authorization), + "{id}: exact evidence-data authentication" + ); + for (name, value) in expected_request["headers"] + .as_object() + .expect("reviewed request headers are an object") + { + if name == "authorization" { + continue; + } + assert!( + data_request + .headers + .get(name) + .and_then(|actual| actual.to_str().ok()) + .is_some_and(|actual| actual == value.as_str().expect("header value")), + "{id}: exact reviewed header {name}" + ); + } + match prepared.body.as_ref() { + Some(expected) => assert_eq!( + serde_json::from_slice::(&data_request.body) + .expect("evidence-data body is JSON"), + *expected, + "{id}: exact evidence-data body" + ), + None => assert!(data_request.body.is_empty(), "{id}: body remains absent"), + } + if id == "opencrvs-event-search-json" { + let token_requests = requests + .iter() + .filter(|request| request.url.path() == "/oauth/token") + .collect::>(); + assert_eq!(token_requests.len(), 1, "exact OAuth bootstrap count"); + // The reference shape accepts the client credentials in the token + // request body, so no credential may appear in the token URL. + assert!( + query_parameters(&token_requests[0].url).is_empty(), + "OAuth bootstrap URL carries no query" + ); + let form = encoded_parameters(&token_requests[0].body); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", "shape-oauth-client-canary") + && contains_parameter(&form, "client_secret", "shape-oauth-secret-canary"), + "OAuth bootstrap body is the exact reviewed shape" + ); + assert!(token_requests[0].headers.get("authorization").is_none()); + assert_eq!( + token_requests[0] + .headers + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("application/x-www-form-urlencoded") + ); + assert_eq!( + token_requests[0] + .headers + .get("accept") + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!(requests.len(), 2); + } else { + assert_eq!(requests.len(), 1); + } + + let mut response_files = fs::read_dir(directory.join("responses")) + .expect("response fixture directory is readable") + .map(|entry| entry.expect("response fixture entry").path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json")) + .collect::>(); + response_files.sort(); + let names = response_files + .iter() + .map(|path| { + path.file_name() + .and_then(|value| value.to_str()) + .expect("response fixture name") + }) + .collect::>(); + for required in [ + "ambiguous.json", + "error-envelope.json", + "match.json", + "missing-fact.json", + "no-match.json", + ] { + assert!( + names.contains(required), + "{id}: required outcome {required} is absent" + ); + } + + for response_path in response_files { + let name = response_path + .file_name() + .and_then(|value| value.to_str()) + .expect("response fixture name"); + let response: Value = serde_json::from_slice( + &fs::read(&response_path).expect("response fixture is readable"), + ) + .expect("response fixture is JSON"); + let projected = project_fixture_response(&source, &response); + if name == "error-envelope.json" { + assert_eq!(projected, Err(SourceError::ErrorEnvelope)); + continue; + } + let projected = projected.expect("response projection succeeds"); + let lookup = runtime.extract(&extraction, &projected, ¶meters, &fact_schema); + match name { + "match.json" => match lookup.expect("match extraction succeeds") { + LookupResult::Match(direct_facts) => assert_eq!( + direct_facts, facts, + "{id}: direct fixture and real transport extraction drifted" + ), + _ => panic!("{id}: match fixture returned a non-match outcome"), + }, + "no-match.json" => assert!(matches!(lookup, Ok(LookupResult::NoMatch))), + "ambiguous.json" => assert!(matches!(lookup, Ok(LookupResult::Ambiguous))), + "missing-fact.json" => { + assert_eq!(lookup, Err(RhaiRuntimeError::FactSchema)); + } + "inconsistent-cardinality.json" => { + assert_eq!(lookup, Err(RhaiRuntimeError::SourceProtocol)); + } + _ => panic!("{id}: unclassified response fixture {name}"), + } + } + } + + let acceptance_copy = tempfile::tempdir().expect("temporary acceptance bundle root"); + let acceptance_root = acceptance_copy.path().join("residence-region"); + copy_fixture_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/residence-region"), + &acceptance_root, + ); + make_fixture_bundle_read_only(&acceptance_root); + let kernel = OfflineKernel::compile(Arc::new( + Bundle::load(&acceptance_root).expect("immutable residence acceptance bundle loads"), + )) + .expect("residence acceptance kernel compiles"); + let requirement = "urn:example:fixture:requirement:residence-region:v1"; + let observed_at = "2026-08-02T00:00:00Z" + .parse() + .expect("fixed observation time parses"); + let private = PrivateJwk::parse(SHAPE_EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signing provider builds")); + let signer = EvidenceSigner::initialize(provider, "source-shape-evidence-key") + .await + .expect("test signer passes its self-test"); + let jwks = + jwks_document(signer.public_jwk(), std::iter::empty()).expect("test public key publishes"); + + for shape in ["flat-rest", "nested-paged-rest"] { + let values = kernel + .derive_and_validate( + requirement, + matched_facts.get(shape).expect("shape match facts exist"), + observed_at, + ValueProjection { + audience: "https://relying.invalid/residence-procedure", + binding_key: b"source-shape-binding-key-32-bytes-minimum", + binding_key_version: 1, + }, + ) + .expect("real residence derivation and immutable output gate succeed"); + assert_eq!(values.as_slice().len(), 1); + assert_eq!( + values.as_slice()[0].provides_value_for, + "urn:example:fixture:concept:residence-region" + ); + assert_eq!( + values.as_slice()[0].value, + PublicValue::String("REGION-NORTH".to_owned()), + "{shape}: source-shape swap changed the governed controlled code" + ); + let evidence = kernel + .construct_evidence( + requirement, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01J4BRXQ0ZZZZZZZZZZZZZZZZZ", + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: "fixture-routing", + audience: "https://relying.invalid/residence-procedure", + issued_at: observed_at, + observed_at, + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("real residence Evidence constructs"); + let jws = signer + .sign_json(&evidence) + .await + .expect("real residence Evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + observed_at, + Duration::from_secs(0), + ); + policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); + policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); + policy.requirement = requirement.to_owned(); + policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); + policy.purpose = "fixture-routing".to_owned(); + policy.audience = "https://relying.invalid/residence-procedure".to_owned(); + policy.configuration_revision = kernel.bundle().revision().to_owned(); + let verified = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("signed residence Evidence verifies under the exact relying policy"); + assert_eq!( + verified.supported_values[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + } +} + +#[tokio::test] +async fn every_acquisition_posture_fixture_executes_with_one_bounded_request() { + let fixture: Value = serde_norway::from_str(include_str!( + "../../../products/evidence/fixtures/conformance/acquisition-postures.yaml" + )) + .expect("acquisition-posture fixture parses"); + let cases = fixture["cases"] + .as_array() + .expect("acquisition-posture cases are an array"); + let runtime = RhaiRuntime::new(); + let preparation = runtime + .compile_preparation("fn prepare(selectors, parameters) { #{query: [], body: #{}} }") + .expect("common posture preparation compiles"); + let mut executed = BTreeSet::new(); + + for case in cases { + let posture_name = case["posture"].as_str().expect("posture name"); + executed.insert(posture_name.to_owned()); + let (posture, derived_fact, expected_claim, expected_negative) = match posture_name { + "source-derived" => ( + AcquisitionPosture::SourceDerived, + "final_code", + "acquisition and disclosure minimization", + "source-returns-undeclared-field", + ), + "field-projected" => ( + AcquisitionPosture::FieldProjected, + "fact_b", + "strong acquisition and disclosure minimization", + "fixed-projection-expanded", + ), + "record-transformed" => ( + AcquisitionPosture::RecordTransformed, + "fact_a", + "disclosure minimization only", + "full-lifecycle-minimization-overclaim", + ), + _ => panic!("unknown acquisition posture fixture case"), + }; + assert_eq!(case["expected_claim"], json!(expected_claim)); + assert_eq!(case["negative"], json!(expected_negative)); + let declared_facts = case["declared_facts"] + .as_array() + .expect("declared facts are an array") + .iter() + .map(|value| value.as_str().expect("declared fact").to_owned()) + .collect::>(); + let mut response = case["source_response"].clone(); + response["result"]["undeclared_source_canary"] = json!("never-project-this-value"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "synthetic-posture-token")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.posture = posture; + source.request.projection = std::iter::once("/total".to_owned()) + .chain(declared_facts.iter().map(|fact| format!("/result/{fact}"))) + .collect(); + let prepared = runtime + .prepare( + &preparation, + &json!({}), + &json!({}), + &request_limits(&source.request.preparation_limits), + ) + .expect("common posture preparation succeeds"); + let projected = SourceExecutor::new(&source, secrets) + .expect("posture source compiles") + .execute(&[selector("record")], &prepared) + .await + .expect("posture source request succeeds"); + assert!(!serde_json::to_string(&projected) + .expect("projected response serializes") + .contains("never-project-this-value")); + + let facts_body = declared_facts + .iter() + .map(|fact| format!("{fact}: source_response[\"result\"][\"{fact}\"]")) + .collect::>() + .join(","); + let extraction = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ #{{outcome: \"match\", facts: #{{{facts_body}}}}} }}" + )) + .expect("posture extraction compiles"); + let properties = declared_facts + .iter() + .map(|fact| (fact.clone(), json!({}))) + .collect::>(); + let schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": declared_facts, + "properties": properties + })) + .expect("posture fact schema compiles"); + let facts = match runtime + .extract(&extraction, &projected, &json!({}), &schema) + .expect("posture extraction succeeds") + { + LookupResult::Match(facts) => facts, + _ => panic!("posture extraction returned a non-match outcome"), + }; + let derivation = runtime + .compile_derivation(&format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{concept_id: \"posture-result\", value: facts[\"{derived_fact}\"]}}] }}" + )) + .expect("posture derivation compiles"); + let derived = runtime + .derive( + &derivation, + &facts, + &json!({}), + EvaluationContext::new( + UtcInstant::parse("2026-08-02T00:00:00Z").expect("instant"), + CalendarDate::parse("2026-08-02").expect("date"), + LegalLocalTime::parse("07:00:00+07:00").expect("local time"), + &json!({}), + BTreeMap::new(), + ) + .expect("evaluation context"), + ) + .expect("posture derivation succeeds"); + assert!(derived.len() == 1 && derived[0].concept_id == "posture-result"); + assert_eq!( + server + .received_requests() + .await + .expect("request journal") + .len(), + 1 + ); + } + + assert_eq!( + executed, + BTreeSet::from([ + "field-projected".to_owned(), + "record-transformed".to_owned(), + "source-derived".to_owned(), + ]) + ); +} + +#[tokio::test] +async fn basic_bearer_and_static_api_key_headers_are_exact_and_failures_are_redacted() { + let cases = [ + ( + json!({"kind": "basic", "usernameRef": "secret:file/user", "passwordRef": "secret:file/password"}), + vec![("user", "basic-user"), ("password", "basic-password")], + "authorization", + format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + "basic-user:basic-password" + ) + ), + ), + ( + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + vec![("token", "bearer-token")], + "authorization", + "Bearer bearer-token".into(), + ), + ( + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + vec![("key", "static-key")], + "x-api-key", + "static-key".into(), + ), + ]; + for (authentication, entries, header_name, header_value) in cases { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .and(header(header_name, header_value.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&entries); + let source = fixed_source(&server.uri(), authentication); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("authenticated request succeeds"); + } +} + +async fn assert_oauth_success_matrix_case(placement: &str, maximum_cache_seconds: u64) { + let server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + let expected_token_requests: usize = if maximum_cache_seconds == 0 { 2 } else { 1 }; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read" + }))) + .expect(expected_token_requests as u64) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", client_id.as_str()), + ("oauth-client-secret", client_secret.as_str()), + ]); + let source = oauth_source( + &server.uri(), + &format!("{}/token", server.uri()), + placement, + maximum_cache_seconds, + ); + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + for _ in 0..2 { + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("OAuth-authenticated source request succeeds"); + } + + let requests = server.received_requests().await.expect("request journal"); + let token_requests = requests + .iter() + .filter(|request| request.url.path() == "/token") + .collect::>(); + let data_requests = requests + .iter() + .filter(|request| request.url.path() == "/data") + .collect::>(); + assert!( + token_requests.len() == expected_token_requests, + "unexpected OAuth token request count" + ); + assert!( + data_requests.len() == 2, + "unexpected evidence-data request count" + ); + assert!(data_requests.iter().all(|request| { + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == format!("Bearer {access_token}")) + })); + + for request in token_requests { + let query = query_parameters(&request.url); + let form = encoded_parameters(&request.body); + match placement { + "basic-header" => { + let expected = format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{client_id}:{client_secret}") + ) + ); + assert!(query.is_empty(), "Basic placement added token query fields"); + assert!( + form.len() == 2 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read"), + "Basic placement token form is not exact" + ); + assert!(request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == expected)); + } + "form-body" => { + assert!(query.is_empty(), "form placement added token query fields"); + assert!(request.headers.get("authorization").is_none()); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "form placement token body is not exact" + ); + } + _ => panic!("unknown non-secret test placement"), + } + } +} + +#[tokio::test] +async fn oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded() { + for placement in ["basic-header", "form-body"] { + assert_oauth_success_matrix_case(placement, 60).await; + assert_oauth_success_matrix_case(placement, 0).await; + } +} + +/// A provider that omits `expires_in` still gets a bounded cache, and the +/// configured maximum still wins over the assumed lifetime. +#[tokio::test] +async fn oauth_assumed_lifetime_caches_an_omitted_provider_lifetime_and_stays_clamped() { + for (maximum_cache_seconds, expected_token_requests) in [(60_u64, 1_u64), (0, 2)] { + let server = MockServer::start().await; + let access_token = format!("access-token-{}", ulid::Ulid::new()); + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer" + }))) + .expect(expected_token_requests) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", "assumed-lifetime-client-id"), + ("oauth-client-secret", "assumed-lifetime-client-secret"), + ]); + let source = oauth_source_with_assumed_lifetime( + &server.uri(), + &format!("{}/token", server.uri()), + "form-body", + maximum_cache_seconds, + Some(120), + ); + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + for _ in 0..2 { + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("assumed lifetime authorizes the request"); + } + } +} + +#[tokio::test] +async fn oauth_credential_redaction_fixture_fails_closed_without_data_requests() { + let fixture: Value = serde_norway::from_str(include_str!( + "../../../products/evidence/fixtures/conformance/oauth-credential-redaction.yaml" + )) + .expect("OAuth redaction fixture parses"); + let declared = fixture["cases"] + .as_array() + .expect("fixture cases are an array") + .iter() + .map(|case| { + case["id"] + .as_str() + .expect("fixture case has an id") + .to_owned() + }) + .collect::>(); + let expected = BTreeSet::from([ + "malformed-token-json".to_owned(), + "token-400".to_owned(), + "token-401".to_owned(), + "token-response-duplicate-access-token".to_owned(), + "token-response-extension-members".to_owned(), + "token-response-oversized".to_owned(), + "token-response-wrong-access-token-field".to_owned(), + "token-response-wrong-lifetime".to_owned(), + "token-response-wrong-media-type".to_owned(), + "token-response-wrong-scope".to_owned(), + "token-response-omitted-lifetime".to_owned(), + "token-response-omitted-lifetime-with-assumed-lifetime".to_owned(), + "token-response-wrong-token-type".to_owned(), + "token-success".to_owned(), + "transport-connection-failure".to_owned(), + "transport-timeout".to_owned(), + ]); + assert_eq!( + declared, expected, + "OAuth fixture and executable matrix drifted" + ); + + for case_id in declared { + let server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + let response = match case_id.as_str() { + "transport-connection-failure" => None, + "token-success" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read" + }))), + "token-400" => Some( + ResponseTemplate::new(400).set_body_string(client_secret.clone()), + ), + "token-401" => Some( + ResponseTemplate::new(401).set_body_string(client_secret.clone()), + ), + "malformed-token-json" => { + Some(ResponseTemplate::new(200).set_body_raw("{invalid", "application/json")) + } + "token-response-oversized" => Some(ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"access_token\":\"{}\",\"token_type\":\"Bearer\",\"expires_in\":120}}", + "x".repeat(9_000) + ), + "application/json", + )), + // The members a deployed authorization server actually adds, plus + // one carrying the client-secret canary. Ignored members must not + // become credentials and must not surface anywhere. + "token-response-extension-members" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read", + "refresh_expires_in": 0, + "not-before-policy": 0, + "session_state": "b2c1d0f3-0000-4000-8000-000000000000", + "ext_expires_in": 120, + "unexpected": client_secret.clone() + }))), + // An extension member must not be able to arrive as a second + // access_token, which is what makes ignoring the unread ones safe. + "token-response-duplicate-access-token" => Some(ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"access_token\":\"{access_token}\",\"token_type\":\"Bearer\",\"expires_in\":120,\"access_token\":\"{client_secret}\"}}" + ), + "application/json", + )), + "token-response-wrong-access-token-field" => { + Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": 7, + "token_type": "Bearer", + "expires_in": 120 + }))) + } + "token-response-wrong-token-type" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "MAC", + "expires_in": 120 + }))), + "token-response-wrong-scope" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "other.scope" + }))), + "token-response-wrong-lifetime" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 0 + }))), + // The minimum RFC 6749 section 5.1 response. Accepted only when the + // bundle states the lifetime to assume. + "token-response-omitted-lifetime" + | "token-response-omitted-lifetime-with-assumed-lifetime" => { + Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer" + }))) + } + "token-response-wrong-media-type" => Some(ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"access_token\":\"{access_token}\",\"token_type\":\"Bearer\",\"expires_in\":120}}" + ), + "text/plain", + )), + "transport-timeout" => Some(ResponseTemplate::new(200) + .set_delay(Duration::from_millis(100)) + .set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120 + }))), + _ => panic!("fixture contains an unknown case id: {case_id}"), + }; + if let Some(response) = response { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", client_id.as_str()), + ("oauth-client-secret", client_secret.as_str()), + ]); + let token_endpoint = if case_id == "transport-connection-failure" { + "http://127.0.0.1:0/token".to_owned() + } else { + format!("{}/token", server.uri()) + }; + let assumed_lifetime_seconds = + (case_id == "token-response-omitted-lifetime-with-assumed-lifetime").then_some(120); + let mut source = oauth_source_with_assumed_lifetime( + &server.uri(), + &token_endpoint, + "form-body", + 0, + assumed_lifetime_seconds, + ); + if case_id == "transport-timeout" { + source.request.timeout_milliseconds = 20; + } + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + let result = executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + let expects_success = matches!( + case_id.as_str(), + "token-success" + | "token-response-extension-members" + | "token-response-omitted-lifetime-with-assumed-lifetime" + ); + if expects_success { + assert!(result.is_ok(), "success fixture case {case_id} failed"); + } else { + let expected_error = match case_id.as_str() { + "transport-timeout" => SourceError::Timeout, + "transport-connection-failure" => SourceError::Transport, + _ => SourceError::Credential, + }; + assert_eq!(result, Err(expected_error)); + let diagnostic = result + .expect_err("failure fixture case returns an error") + .to_string(); + assert!(!diagnostic.contains(&client_id)); + assert!(!diagnostic.contains(&client_secret)); + assert!(!diagnostic.contains(&access_token)); + assert!(!diagnostic.contains("/token?")); + } + + let requests = server.received_requests().await.expect("request journal"); + let data_count = requests + .iter() + .filter(|request| request.url.path() == "/data") + .count(); + if expects_success { + assert!( + data_count == 1, + "successful token did not authorize one data request" + ); + // The credential the source presents is the access token and only + // the access token. For the extension-members case this is what + // proves an ignored member did not become one. + let authorization = requests + .iter() + .find(|request| request.url.path() == "/data") + .expect("the data request was journaled") + .headers + .get("authorization") + .expect("the data request carried a credential") + .to_str() + .expect("the authorization header is text") + .to_owned(); + assert_eq!(authorization, format!("Bearer {access_token}")); + } else { + assert!( + data_count == 0, + "token failure reached the evidence-data source" + ); + } + let token_request = requests + .iter() + .find(|request| request.url.path() == "/token"); + if case_id == "transport-connection-failure" { + assert!(token_request.is_none()); + continue; + } + let token_request = token_request.expect("token request was journaled"); + // No placement may put a credential in the token URL, so the redaction + // surface is the request body and the response, never the URL. + assert!( + query_parameters(&token_request.url).is_empty(), + "token URL carried a query" + ); + let form = encoded_parameters(&token_request.body); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "form placement did not deliver the exact closed credential request" + ); + } +} + +#[tokio::test] +async fn projection_missing_leaf_is_omitted_but_bad_intermediate_stops_before_extraction() { + for (response, expected) in [ + (json!({"results": [{}]}), Ok(json!({"results": [{}]}))), + (json!({}), Err(SourceError::ProjectionViolation)), + ( + json!({"results": {}}), + Err(SourceError::ProjectionViolation), + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "token")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.request.projection = vec!["/results/*/optional".into()]; + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + assert_eq!( + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})) + } + ) + .await, + expected + ); + } +} + +#[tokio::test] +async fn source_executor_failure_matrix_is_exact_single_request_and_value_free() { + let cases = [ + ("http-401", SourceError::Status(SourceStatus::Unauthorized)), + ("http-403", SourceError::Status(SourceStatus::Forbidden)), + ("http-429", SourceError::Status(SourceStatus::RateLimited)), + ("http-500", SourceError::Status(SourceStatus::ServerError)), + ("redirect", SourceError::Redirect), + ("timeout", SourceError::Timeout), + ("invalid-json", SourceError::InvalidJson), + ("wrong-media-type", SourceError::WrongMediaType), + ( + "raw-oversized-before-projection", + SourceError::ResponseTooLarge, + ), + ]; + + for (case_id, expected_error) in cases { + let server = MockServer::start().await; + let response = match case_id { + "http-401" => ResponseTemplate::new(401) + .set_body_string("source-response-canary credential-canary"), + "http-403" => ResponseTemplate::new(403) + .set_body_string("source-response-canary credential-canary"), + "http-429" => ResponseTemplate::new(429) + .insert_header("retry-after", "60") + .set_body_string("source-response-canary credential-canary"), + "http-500" => ResponseTemplate::new(500) + .set_body_string("source-response-canary credential-canary"), + "redirect" => { + ResponseTemplate::new(302).insert_header("location", "/redirect-target-canary") + } + "timeout" => ResponseTemplate::new(200) + .set_delay(Duration::from_millis(100)) + .set_body_json(json!({"ok": true})), + "invalid-json" => ResponseTemplate::new(200) + .set_body_raw("{source-response-canary", "application/json"), + "wrong-media-type" => { + ResponseTemplate::new(200).set_body_raw("source-response-canary", "text/plain") + } + "raw-oversized-before-projection" => ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"ok\":true,\"ignored\":\"source-response-canary{}\"}}", + "x".repeat(256) + ), + "application/json", + ), + _ => unreachable!("closed source failure cases"), + }; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "credential-canary")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + if case_id == "timeout" { + source.request.timeout_milliseconds = 20; + } + if case_id == "raw-oversized-before-projection" { + source.request.maximum_response_bytes = 64; + } + let error = SourceExecutor::new(&source, secrets) + .expect("failure-matrix source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect_err("failure-matrix case cannot succeed"); + assert_eq!(error, expected_error, "{case_id}: exact source error"); + let diagnostic = format!("{error:?} {error}"); + for canary in [ + "source-response-canary", + "credential-canary", + "redirect-target-canary", + ] { + assert!( + !diagnostic.contains(canary), + "{case_id}: diagnostics remain value-free" + ); + } + let requests = server.received_requests().await.expect("request journal"); + assert_eq!(requests.len(), 1, "{case_id}: exactly one request"); + assert_eq!(requests[0].url.path(), "/data"); + } +} + +#[test] +fn forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation() { + let (_root, secrets) = resolver(&[("key", "secret")]); + for header_name in RESERVED_HEADER_CONTRACT_CASES { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([{"name": header_name, "value": "forbidden"}]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "reserved fixed header {header_name} is rejected" + ); + } + let duplicate = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([ + {"name": "X-Reviewed-Header", "value": "one"}, + {"name": "x-reviewed-header", "value": "two"} + ]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&duplicate, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "fixed header names are unique case-insensitively" + ); + let authentication_collision = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([{"name": "x-api-key", "value": "fixed"}]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&authentication_collision, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "fixed and authentication headers cannot collide" + ); + for api_key_header in RESERVED_HEADER_CONTRACT_CASES { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": api_key_header, "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "reserved authentication header {api_key_header} is rejected" + ); + } + for projection in [ + json!(["/a", "/a/b"]), + json!(["/a/0"]), + json!(["/a/*/x", "/a/b"]), + ] { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + projection, + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan) + ); + } +} + +/// Every allowed selector set must carry the roles the path template binds. +/// +/// The sets are not written by an operator. They are derived from authority +/// grants, one per grant, filtered to the roles the source declares. So a grant +/// that authorizes this requirement over a role the template does not bind +/// yields a set with no value for the placeholder. `materialize_url` needs one +/// for every placeholder, so that set fails every request it ever serves, while +/// startup and readiness both pass because some other grant covers the role. +/// Refuse the plan instead, at the point the mismatch is visible. +#[test] +fn an_allowed_selector_set_that_cannot_fill_the_path_template_is_refused() { + let (_root, secrets) = resolver(&[("key", "api-key-value")]); + let mut source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + // The template binds `subject`; `parent` is declared but never bound. + source.request.selector_inputs = serde_json::from_value(json!([ + {"role": "subject", "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}]}, + {"role": "parent", "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}]} + ])) + .expect("selector inputs deserialize"); + + let complete = vec![vec![ + ("subject".to_owned(), "record-v1".to_owned()), + ("parent".to_owned(), "record-v1".to_owned()), + ]]; + SourceExecutor::new_with_selector_sets(&source, &complete, Arc::clone(&secrets)) + .expect("a set carrying every bound role compiles"); + + let subject_only = vec![vec![("subject".to_owned(), "record-v1".to_owned())]]; + SourceExecutor::new_with_selector_sets(&source, &subject_only, Arc::clone(&secrets)) + .expect("a set carrying only the bound role compiles"); + + // Legal-parent-relationship shape: one authority path over the parent, one + // source path template bound to the child. The parent-only path is the one + // that would fail every request. + let parent_only = vec![vec![("parent".to_owned(), "record-v1".to_owned())]]; + assert_eq!( + SourceExecutor::new_with_selector_sets(&source, &parent_only, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "a set omitting the bound role has no value for the placeholder" + ); + + let mixed = vec![ + vec![ + ("subject".to_owned(), "record-v1".to_owned()), + ("parent".to_owned(), "record-v1".to_owned()), + ], + vec![("parent".to_owned(), "record-v1".to_owned())], + ]; + assert_eq!( + SourceExecutor::new_with_selector_sets(&source, &mixed, secrets).err(), + Some(SourceError::InvalidPlan), + "one complete set must not excuse an incomplete one" + ); +} + +#[tokio::test] +async fn private_ca_tls_handshake_succeeds_and_hostname_mismatch_fails() { + let (address, ca_pem, server) = spawn_private_ca_tls_server("127.0.0.1").await; + let directory = tempfile::tempdir().expect("temporary TLS directory"); + let configured_path = directory.path().join("private-ca.pem"); + fs::write(&configured_path, &ca_pem).expect("write configured private CA"); + let tls: OutboundTlsConfig = serde_json::from_value(json!({ + "systemRoots": true, + "trustProfiles": {"private-pki": {"caBundleFile": configured_path}} + })) + .expect("TLS config deserializes"); + let mut source = fixed_source( + &format!("https://127.0.0.1:{}", address.port()), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.tls_trust_profile = Some("private-pki".into()); + let (_root, secrets) = resolver(&[("token", "token")]); + let mut captured = BTreeMap::from([("private-pki".into(), ca_pem)]); + let executor = SourceExecutor::new_with_selector_sets_and_tls( + &source, + &[vec![("subject".into(), "record-v1".into())]], + &tls, + &captured, + Arc::clone(&secrets), + ) + .expect("private CA source compiles"); + fs::write(&configured_path, b"changed-after-capture").expect("mutate configured file"); + captured.insert("private-pki".into(), b"changed-after-compile".to_vec()); + executor + .credentials_ready() + .await + .expect("captured TLS source remains credential-ready without reopening CA files"); + assert_eq!( + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await, + Ok(json!({"ok": true})) + ); + server.await.expect("trusted TLS server task completes"); + + let (mismatch_address, mismatch_ca, mismatch_server) = + spawn_private_ca_tls_server("localhost").await; + let mut mismatch_source = source; + mismatch_source.base_url = format!("https://127.0.0.1:{}", mismatch_address.port()); + let mismatch_captured = BTreeMap::from([("private-pki".into(), mismatch_ca)]); + let mismatch = SourceExecutor::new_with_selector_sets_and_tls( + &mismatch_source, + &[vec![("subject".into(), "record-v1".into())]], + &tls, + &mismatch_captured, + secrets, + ) + .expect("hostname-mismatch source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + assert_eq!(mismatch, Err(SourceError::Transport)); + mismatch_server + .await + .expect("mismatched TLS server task completes"); +} + +#[tokio::test] +async fn a_reset_transport_failure_yields_exactly_one_connection_attempt() { + let (address, attempts, server) = spawn_reset_on_connect_server().await; + let source = fixed_source( + &format!("http://127.0.0.1:{}", address.port()), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + let (_root, secrets) = resolver(&[("token", "token")]); + let result = SourceExecutor::new(&source, secrets) + .expect("reset-transport source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + assert_eq!(result, Err(SourceError::Transport)); + // Give the listener a moment to observe a second connection attempt, if + // one were made, before asserting the final count. + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "a reset transport failure must not be retried" + ); + server.abort(); +} + +#[test] +fn private_ca_plan_rejects_unbound_missing_and_malformed_captures() { + let directory = tempfile::tempdir().expect("temporary TLS directory"); + let configured_path = directory.path().join("private-ca.pem"); + let tls: OutboundTlsConfig = serde_json::from_value(json!({ + "systemRoots": true, + "trustProfiles": {"private-pki": {"caBundleFile": configured_path}} + })) + .expect("TLS config deserializes"); + let mut source = fixed_source( + "https://127.0.0.1:443", + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.tls_trust_profile = Some("private-pki".into()); + let (_root, secrets) = resolver(&[("token", "token")]); + let allowed = [vec![("subject".into(), "record-v1".into())]]; + let no_bindings = OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }; + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &no_bindings, + &BTreeMap::new(), + Arc::clone(&secrets), + ) + .err(), + Some(SourceError::InvalidPlan) + ); + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &tls, + &BTreeMap::new(), + Arc::clone(&secrets), + ) + .err(), + Some(SourceError::InvalidPlan) + ); + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &tls, + &BTreeMap::from([("private-pki".into(), b"not-a-certificate".to_vec())]), + secrets, + ) + .err(), + Some(SourceError::InvalidPlan) + ); +} + +#[cfg(unix)] +#[test] +fn runtime_ca_capture_rejects_symlink_malformed_and_mutable_files() { + use std::os::unix::fs::symlink; + + enum CaCase { + Symlink, + Malformed, + Mutable, + } + for case in [CaCase::Symlink, CaCase::Malformed, CaCase::Mutable] { + let directory = tempfile::tempdir().expect("temporary runtime directory"); + let secret_root = directory.path().join("secrets"); + fs::create_dir(&secret_root).expect("create secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("protect secret root"); + let ca_path = directory.path().join("private-ca.pem"); + match case { + CaCase::Symlink => { + let target = directory.path().join("private-ca-target.pem"); + fs::write( + &target, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write CA target"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o444)) + .expect("protect CA target"); + symlink(target, &ca_path).expect("create CA symlink"); + } + CaCase::Malformed => { + fs::write(&ca_path, b"not-a-certificate").expect("write malformed CA"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)) + .expect("protect malformed CA"); + } + CaCase::Mutable => { + fs::write( + &ca_path, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write mutable CA"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o600)) + .expect("leave CA mutable"); + } + } + let runtime_path = directory.path().join("runtime.yaml"); + fs::write( + &runtime_path, + format!( + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n private-pki: {{caBundleFile: {}}}\n", + secret_root.display(), + ca_path.display() + ), + ) + .expect("write runtime configuration"); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("protect runtime configuration"); + let error = RuntimeDocument::load(&runtime_path).expect_err("unsafe CA capture fails"); + match case { + CaCase::Symlink => assert_eq!(error, BundleError::InvalidPath), + CaCase::Malformed => assert!(matches!(error, BundleError::InvalidArtifact(_))), + // The CA bundle sits outside the bundle directory, so the refusal + // has to name it. Re-freezing the bundle would not touch it. + CaCase::Mutable => assert_eq!( + error.artifact_fault().map(|fault| fault.fault().cause()), + Some("the TLS CA bundle the runtime file names is writable") + ), + } + } +} + +#[test] +fn ambient_proxy_variables_are_ignored_in_an_isolated_process() { + if std::env::var_os("EVIDENCE_PROXY_CHILD").is_some() { + return; + } + let status = Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg("ambient_proxy_child") + .arg("--nocapture") + .env("EVIDENCE_PROXY_CHILD", "1") + .env("HTTP_PROXY", "http://127.0.0.1:1") + .env("HTTPS_PROXY", "http://127.0.0.1:1") + .env("ALL_PROXY", "http://127.0.0.1:1") + .env("NO_PROXY", "") + .status() + .expect("spawn isolated proxy test"); + assert!(status.success()); +} + +#[tokio::test] +async fn ambient_proxy_child() { + if std::env::var_os("EVIDENCE_PROXY_CHILD").is_none() { + return; + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "synthetic-proxy-test-access-token", + "token_type": "Bearer", + "expires_in": 60, + "scope": "fixture.read" + }))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[ + ("token", "token"), + ("oauth-client-id", "synthetic-proxy-client"), + ("oauth-client-secret", "synthetic-proxy-secret"), + ]); + let source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + SourceExecutor::new(&source, Arc::clone(&secrets)) + .expect("executor builds") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("ambient proxy is ignored"); + let oauth = oauth_source( + &server.uri(), + &format!("{}/token", server.uri()), + "basic-header", + 0, + ); + SourceExecutor::new(&oauth, secrets) + .expect("OAuth executor builds") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("ambient proxy is ignored for token and evidence-data requests"); +} diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml new file mode 100644 index 000000000..077379995 --- /dev/null +++ b/crates/registry-evidencectl/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "registry-evidencectl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Evidence adopter tooling: key generation, OpenAPI authoring, fixture runs." +repository.workspace = true +publish = false + +[[bin]] +name = "evidencectl" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +chrono.workspace = true +clap.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +inquire.workspace = true +registry-platform-crypto.workspace = true +rhai.workspace = true +rustix.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +signal-hook.workspace = true +ureq.workspace = true +url.workspace = true +zeroize.workspace = true +tempfile.workspace = true diff --git a/crates/registry-evidencectl/install.sh b/crates/registry-evidencectl/install.sh new file mode 100644 index 000000000..4ffb41f4b --- /dev/null +++ b/crates/registry-evidencectl/install.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="registrystack/registry-stack" +binaries=(evidence evidencectl mint) +# Release packaging replaces this empty value with the asset's canonical tag. +default_version="" +script_name="${BASH_SOURCE[0]:-}" +script_name="${script_name##*/}" +filename_version="" +if [[ "$script_name" =~ ^evidencectl-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))-install\.sh$ ]]; then + filename_version="${BASH_REMATCH[1]}" +fi +if [ -n "$default_version" ] && + [ -n "$filename_version" ] && + [ "$default_version" != "$filename_version" ]; then + echo "Refusing an installer whose embedded release does not match its filename." >&2 + exit 1 +fi +default_version="${default_version:-$filename_version}" +version="${EVIDENCECTL_VERSION:-$default_version}" +if [ -n "$default_version" ] && + [ -n "${EVIDENCECTL_VERSION:-}" ] && + [ "$EVIDENCECTL_VERSION" != "$default_version" ]; then + echo "Refusing a release override that does not match the released installer asset." >&2 + exit 1 +fi +install_dir="${EVIDENCECTL_INSTALL_DIR:-$HOME/.local/bin}" +asset_dir="${EVIDENCECTL_ASSET_DIR:-}" + +usage() { + cat </release/VERIFY.md + +Environment: + EVIDENCECTL_VERSION Release tag to install. A released installer + embeds its tag and refuses a different override. + EVIDENCECTL_INSTALL_DIR Install directory. Defaults to ~/.local/bin. + EVIDENCECTL_ASSET_DIR Read already-downloaded release assets from this + directory instead of downloading them. +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "evidencectl installer needs '$1'." >&2 + exit 1 + fi +} + +if [ -z "$version" ]; then + echo "No release is pinned for this installer copy." >&2 + echo "Evidence binaries ship with releases that include them; set" >&2 + echo "EVIDENCECTL_VERSION to a pinned vMAJOR.MINOR.PATCH tag or run the" >&2 + echo "versioned evidencectl--install.sh asset from a release." >&2 + exit 1 +fi +if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Refusing non-canonical release tag '$version'." >&2 + echo "Set EVIDENCECTL_VERSION to a pinned vMAJOR.MINOR.PATCH tag." >&2 + exit 1 +fi + +need uname +if [ -z "$asset_dir" ]; then + need curl +fi + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os/$arch" in +Linux/x86_64 | Linux/amd64) + os_label="linux" + arch_label="amd64" + ;; +Linux/arm64 | Linux/aarch64) + os_label="linux" + arch_label="arm64" + ;; +Darwin/arm64 | Darwin/aarch64) + os_label="macos" + arch_label="arm64" + ;; +*) + printf 'No prebuilt Evidence toolset asset is published for %s/%s.\n' "$os" "$arch" >&2 + printf 'Supported platforms: Linux amd64, Linux arm64, and macOS arm64.\n' >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' "$repo" "$version" >&2 + exit 1 + ;; +esac + +base_url="https://github.com/${repo}/releases/download/${version}" +verify_url="https://github.com/${repo}/blob/${version}/release/VERIFY.md" +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t evidencectl)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +download() { + local src="$1" + local dest="$2" + if [ -n "$asset_dir" ]; then + local name="${src##*/}" + if [ ! -f "$asset_dir/$name" ]; then + return 1 + fi + cp "$asset_dir/$name" "$dest" + else + curl -fsSL "$src" -o "$dest" 2>/dev/null + fi +} + +if [ -n "$asset_dir" ]; then + printf 'Installing verified local Evidence toolset %s assets for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +else + printf 'Downloading the Evidence toolset %s for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +fi + +for binary in "${binaries[@]}"; do + asset="${binary}-${version}-${os_label}-${arch_label}" + if ! download "$base_url/$asset" "$tmpdir/$asset"; then + printf 'Could not read the published %s %s binary for %s/%s.\n' \ + "$binary" "$version" "$os_label" "$arch_label" >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + fi +done + +if ! download "$base_url/SHA256SUMS" "$tmpdir/SHA256SUMS"; then + echo "Could not download SHA256SUMS for checksum verification." >&2 + exit 1 +fi + +sha256_file() { + local path="$1" + local result + if command -v shasum >/dev/null 2>&1; then + result="$(shasum -a 256 "$path")" + elif command -v sha256sum >/dev/null 2>&1; then + result="$(sha256sum "$path")" + else + echo "evidencectl installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 + exit 1 + fi + printf '%s\n' "${result%% *}" +} + +verify_asset() { + local name="$1" + local expected_hash actual_hash + expected_hash="$(awk -v asset="$name" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" + if [ -z "$expected_hash" ]; then + echo "SHA256SUMS has no entry for $name" >&2 + exit 1 + fi + actual_hash="$(sha256_file "$tmpdir/$name")" + if [ "$actual_hash" != "$expected_hash" ]; then + echo "Checksum verification failed for $name" >&2 + echo "Expected: $expected_hash" >&2 + echo "Actual: $actual_hash" >&2 + exit 1 + fi +} + +for binary in "${binaries[@]}"; do + verify_asset "${binary}-${version}-${os_label}-${arch_label}" +done +printf 'Integrity checks passed: %s binaries matched SHA256SUMS.\n' "${#binaries[@]}" +cat <&2 + ;; +esac diff --git a/crates/registry-evidencectl/src/access.rs b/crates/registry-evidencectl/src/access.rs new file mode 100644 index 000000000..505b77e73 --- /dev/null +++ b/crates/registry-evidencectl/src/access.rs @@ -0,0 +1,925 @@ +//! Local access-policy and client authoring. +//! +//! Governed, reviewable policy and public client membership live under +//! `access/`. The only private client artifact is the locally generated key +//! under `.evidence/clients//private.jwk`. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Read as _, Write as _}, + os::unix::fs::{DirBuilderExt as _, MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{bail, Context as _, Result}; +use clap::{Args, Subcommand}; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::{authoring, dev, keygen}; + +const ACCESS_DIRECTORY: &str = "access"; +const POLICIES_DIRECTORY: &str = "policies"; +const CLIENTS_DIRECTORY: &str = "clients"; +const PRIVATE_STATE_DIRECTORY: &str = ".evidence"; +const PRIVATE_KEY_FILENAME: &str = "private.jwk"; +const MAX_DOCUMENT_BYTES: u64 = 64 * 1024; +const MAX_POLICIES: usize = 128; +const MAX_QUESTIONS: usize = 128; +const MAX_CLIENTS: usize = 4_096; +const MAX_POLICIES_PER_CLIENT: usize = 32; +const PUBLIC_DIRECTORY_MODE: u32 = 0o755; +const PUBLIC_FILE_MODE: u32 = 0o644; +const PRIVATE_DIRECTORY_MODE: u32 = 0o700; +const PRIVATE_FILE_MODE: u32 = 0o600; + +#[derive(Debug, Subcommand)] +pub enum AccessCommand { + /// Define which authored questions a policy may request. + #[command(subcommand)] + Policy(PolicyCommand), + /// Register and revoke local Evidence clients. + #[command(subcommand)] + Client(ClientCommand), +} + +#[derive(Debug, Subcommand)] +pub enum PolicyCommand { + /// Add one governed access policy. + Add(PolicyAddArgs), + /// List governed access policies. + List(ProjectArgs), +} + +#[derive(Debug, Subcommand)] +pub enum ClientCommand { + /// Add one local client and generate its private key. + Add(ClientAddArgs), + /// List local clients and their policy membership. + List(ProjectArgs), + /// Revoke one local client. + Revoke(ClientRevokeArgs), +} + +#[derive(Debug, Args)] +pub struct ProjectArgs { + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Debug, Args)] +pub struct PolicyAddArgs { + /// Lowercase policy identifier. + policy: String, + /// Authored question granted by this policy. Repeat for more than one. + #[arg(long, required = true)] + question: Vec, + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Debug, Args)] +pub struct ClientAddArgs { + /// Lowercase client identifier. + client: String, + /// Access policy assigned to this client. Repeat for more than one. + #[arg(long, required = true)] + policy: Vec, + /// Generate an owner-only Ed25519 key for local client authentication. + #[arg(long, required = true)] + generate_local_key: bool, + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Debug, Args)] +pub struct ClientRevokeArgs { + /// Lowercase client identifier. + client: String, + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AccessPolicyDocument { + version: u8, + id: String, + questions: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ClientStatus { + Active, + Revoked, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClientDocument { + version: u8, + client_id: String, + status: ClientStatus, + policies: Vec, + principal: String, + evidence_audience: String, + keys: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ActiveClient { + pub(crate) client_id: String, + pub(crate) private_key_path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ActiveClientRegistration { + pub(crate) client_id: String, + pub(crate) registration: Value, +} + +pub fn run(command: AccessCommand) -> Result { + match command { + AccessCommand::Policy(PolicyCommand::Add(args)) => add_policy(&args), + AccessCommand::Policy(PolicyCommand::List(args)) => list_policies(&args.project), + AccessCommand::Client(ClientCommand::Add(args)) => add_client(&args), + AccessCommand::Client(ClientCommand::List(args)) => list_clients(&args.project), + AccessCommand::Client(ClientCommand::Revoke(args)) => revoke_client(&args), + } +} + +fn add_policy(args: &PolicyAddArgs) -> Result { + let project = canonical_project(&args.project)?; + validate_identifier(&args.policy, "policy")?; + let questions = sorted_unique(&args.question, "questions", MAX_QUESTIONS)?; + for question in &questions { + validate_identifier(question, "question")?; + } + let _lifecycle = dev::lock_project_lifecycle(&project)?; + if dev::try_load_ready_state(&project)?.is_some() { + bail!("stop the local development session before changing access policies"); + } + for question in &questions { + validate_authored_question(&project, question)?; + } + + let directory = project.join(ACCESS_DIRECTORY).join(POLICIES_DIRECTORY); + ensure_public_directory(&project.join(ACCESS_DIRECTORY))?; + ensure_public_directory(&directory)?; + if load_policy_documents_if_present(&project)?.len() >= MAX_POLICIES { + bail!("a project may define at most {MAX_POLICIES} access policies"); + } + let path = directory.join(format!("{}.yaml", args.policy)); + let document = AccessPolicyDocument { + version: 1, + id: args.policy.clone(), + questions, + }; + write_new_yaml_atomic(&path, &document, PUBLIC_FILE_MODE)?; + println!( + "Added access policy {} for {}.", + document.id, + document.questions.join(", ") + ); + Ok(ExitCode::SUCCESS) +} + +fn list_policies(project: &Path) -> Result { + let project = canonical_project(project)?; + let policies = load_policy_documents_if_present(&project)?; + if policies.is_empty() { + println!("No access policies configured."); + return Ok(ExitCode::SUCCESS); + } + println!("POLICY\tQUESTIONS"); + for policy in policies.values() { + println!("{}\t{}", policy.id, policy.questions.join(", ")); + } + Ok(ExitCode::SUCCESS) +} + +fn add_client(args: &ClientAddArgs) -> Result { + let project = canonical_project(&args.project)?; + validate_identifier(&args.client, "client")?; + if !args.generate_local_key { + bail!("local client creation requires --generate-local-key"); + } + let policy_ids = sorted_unique(&args.policy, "policies", MAX_POLICIES_PER_CLIENT)?; + for policy_id in &policy_ids { + validate_identifier(policy_id, "policy")?; + } + let _lifecycle = dev::lock_project_lifecycle(&project)?; + let live = prepare_live_context(&project)?; + let policies = load_policy_documents(&project)?; + validate_client_policies(&policy_ids, &policies)?; + let existing_clients = load_client_documents_if_present(&project)?; + for existing in existing_clients.values() { + validate_client_policies(&existing.policies, &policies)?; + } + if existing_clients.len() >= MAX_CLIENTS { + bail!("a project may define at most {MAX_CLIENTS} clients"); + } + + let public_directory = project.join(ACCESS_DIRECTORY).join(CLIENTS_DIRECTORY); + let private_directory = project + .join(PRIVATE_STATE_DIRECTORY) + .join(CLIENTS_DIRECTORY); + ensure_public_directory(&project.join(ACCESS_DIRECTORY))?; + ensure_public_directory(&public_directory)?; + ensure_private_directory(&project.join(PRIVATE_STATE_DIRECTORY))?; + ensure_private_directory(&private_directory)?; + + let public_path = public_directory.join(format!("{}.yaml", args.client)); + let private_client_path = private_directory.join(&args.client); + reject_existing(&public_path)?; + reject_existing(&private_client_path)?; + + let staging = tempfile::Builder::new() + .prefix(".client-stage-") + .tempdir_in(&private_directory) + .context("creating private client staging directory")?; + fs::set_permissions( + staging.path(), + fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE), + )?; + let public_key_path = staging.path().join("public.jwk"); + let (_, generated_public) = keygen::generate_dev_keypair( + staging.path(), + &format!("local-client-{}-key-1", args.client), + PRIVATE_KEY_FILENAME, + "public.jwk", + )?; + debug_assert_eq!(generated_public, public_key_path); + let public_key = read_public_jwk(&public_key_path)?; + fs::remove_file(&public_key_path).context("removing staged public-key copy")?; + + let document = ClientDocument { + version: 1, + client_id: args.client.clone(), + status: ClientStatus::Active, + policies: policy_ids, + principal: format!("urn:registrystack:evidence:local:client:{}", args.client), + evidence_audience: format!("urn:registrystack:evidence:local:client:{}", args.client), + keys: vec![public_key], + }; + + // Publish the private directory first. The public document is the marker + // that makes the client discoverable, and an unexpected public collision + // rolls the newly published private directory back. + fs::rename(staging.path(), &private_client_path) + .context("publishing private local client key")?; + if let Err(error) = write_new_yaml_atomic(&public_path, &document, PUBLIC_FILE_MODE) { + let _ = fs::remove_dir_all(&private_client_path); + return Err(error); + } + + let reload_requested = + if let Err(error) = synchronize_live_client(&project, &document, live.as_ref()) { + return Err( + error.context("client was saved, but the running local session was not reloaded") + ); + } else { + live.is_some() + }; + println!( + "Added client {} with {}.", + document.client_id, + joined_policies(&document.policies) + ); + if reload_requested { + println!("Registry Mint reload requested."); + } + Ok(ExitCode::SUCCESS) +} + +fn list_clients(project: &Path) -> Result { + let project = canonical_project(project)?; + let policies = load_policy_documents_if_present(&project)?; + let clients = load_client_documents_if_present(&project)?; + if clients.is_empty() { + println!("No clients configured."); + return Ok(ExitCode::SUCCESS); + } + println!("CLIENT\tSTATUS\tPOLICIES"); + for client in clients.values() { + validate_client_policies(&client.policies, &policies)?; + let status = match client.status { + ClientStatus::Active => "active", + ClientStatus::Revoked => "revoked", + }; + println!( + "{}\t{}\t{}", + client.client_id, + status, + client.policies.join(", ") + ); + } + Ok(ExitCode::SUCCESS) +} + +fn revoke_client(args: &ClientRevokeArgs) -> Result { + let project = canonical_project(&args.project)?; + validate_identifier(&args.client, "client")?; + let _lifecycle = dev::lock_project_lifecycle(&project)?; + let live = prepare_live_context(&project)?; + let policies = load_policy_documents(&project)?; + let path = client_document_path(&project, &args.client); + let mut document = read_client_document(&path)?; + validate_client_policies(&document.policies, &policies)?; + if document.status == ClientStatus::Revoked { + bail!("client {} is already revoked", args.client); + } + if let Some(context) = &live { + validate_path_mode( + &context + .generated_directory + .join(format!("{}.yaml", document.client_id)), + false, + PRIVATE_FILE_MODE, + )?; + } + document.status = ClientStatus::Revoked; + replace_yaml_atomic(&path, &document, PUBLIC_FILE_MODE)?; + let reload_requested = + if let Err(error) = synchronize_live_revocation(&project, &document, live.as_ref()) { + return Err( + error.context("client was revoked, but the running local session was not reloaded") + ); + } else { + live.is_some() + }; + println!("Revoked client {}.", document.client_id); + if reload_requested { + println!("Registry Mint reload requested."); + } + Ok(ExitCode::SUCCESS) +} + +/// Resolve one client against the exact access-policy generation currently +/// active in local development. +pub(crate) fn resolve_ready_client( + project: &Path, + client_id: &str, + policy_tags: &BTreeMap, +) -> Result { + validate_identifier(client_id, "client")?; + let registration = load_active_clients(project, policy_tags)? + .into_iter() + .find(|registration| registration.client_id == client_id) + .ok_or_else(|| anyhow::anyhow!("unknown or revoked active client {client_id}"))?; + let project = canonical_project(project)?; + let document = read_client_document(&client_document_path(&project, client_id))?; + let private_key_path = validate_private_client_key(&project, &document)?; + Ok(ActiveClient { + client_id: registration.client_id, + private_key_path, + }) +} + +/// Load active editable clients as exact Mint registration documents. +pub(crate) fn load_active_clients( + project: &Path, + policy_tags: &BTreeMap, +) -> Result> { + let project = canonical_project(project)?; + let policies = load_policy_documents_if_present(&project)?; + if policy_tags.len() != policies.len() { + bail!("compiled access policies do not match the editable project policies"); + } + for policy in policies.values() { + let expected = authoring::access_policy_requester_tag(&policy.id, &policy.questions)?; + if policy_tags.get(&policy.id) != Some(&expected) { + bail!( + "editable access policy {} differs from the active generation", + policy.id + ); + } + } + let clients = load_client_documents_if_present(&project)?; + let mut registrations = Vec::new(); + for document in clients.values() { + validate_client_policies(&document.policies, &policies)?; + if document.status == ClientStatus::Revoked { + continue; + } + let requester_tags = document + .policies + .iter() + .map(|id| { + policy_tags + .get(id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("client names an unknown compiled policy")) + }) + .collect::>>()?; + registrations.push(ActiveClientRegistration { + client_id: document.client_id.clone(), + registration: mint_registration(document, requester_tags), + }); + } + Ok(registrations) +} + +fn mint_registration(document: &ClientDocument, requester_tags: Vec) -> Value { + json!({ + "clientId": document.client_id, + "principal": document.principal, + "evidenceAudience": document.evidence_audience, + "requesterTags": requester_tags, + "keys": document.keys, + }) +} + +#[derive(Clone, Debug)] +struct LiveContext { + policy_tags: BTreeMap, + generated_directory: PathBuf, +} + +fn prepare_live_context(project: &Path) -> Result> { + let Some(ready) = dev::try_load_ready_state(project)? else { + return Ok(None); + }; + if ready.access_policies.is_empty() { + bail!( + "the running local session uses the implicit tutorial caller; stop and restart it after defining access policies" + ); + } + let policy_tags = ready + .access_policies + .into_iter() + .map(|policy| (policy.id, policy.requester_tag)) + .collect::>(); + // Validate the complete editable registry and exact policy generation + // before a mutation publishes anything. + load_active_clients(project, &policy_tags)?; + let generated_directory = project.join(".evidence/dev/generated/clients"); + validate_path_mode(&generated_directory, true, PRIVATE_DIRECTORY_MODE)?; + Ok(Some(LiveContext { + policy_tags, + generated_directory, + })) +} + +fn synchronize_live_client( + project: &Path, + document: &ClientDocument, + live: Option<&LiveContext>, +) -> Result<()> { + let Some(live) = live else { + return Ok(()); + }; + let registration = load_active_clients(project, &live.policy_tags)? + .into_iter() + .find(|registration| registration.client_id == document.client_id) + .ok_or_else(|| anyhow::anyhow!("new client is not active in the editable registry"))?; + let generated_path = live + .generated_directory + .join(format!("{}.yaml", document.client_id)); + write_new_yaml_atomic( + &generated_path, + ®istration.registration, + PRIVATE_FILE_MODE, + )?; + dev::request_mint_reload(project) +} + +fn synchronize_live_revocation( + project: &Path, + document: &ClientDocument, + live: Option<&LiveContext>, +) -> Result<()> { + let Some(live) = live else { + return Ok(()); + }; + // The remaining registry must still be a valid all-or-nothing snapshot. + load_active_clients(project, &live.policy_tags)?; + let generated_path = live + .generated_directory + .join(format!("{}.yaml", document.client_id)); + validate_path_mode(&generated_path, false, PRIVATE_FILE_MODE)?; + fs::remove_file(&generated_path) + .with_context(|| format!("removing revoked registration {}", generated_path.display()))?; + sync_directory(&live.generated_directory)?; + dev::request_mint_reload(project) +} + +fn validate_client_policies( + policy_ids: &[String], + policies: &BTreeMap, +) -> Result<()> { + if policy_ids.is_empty() || policy_ids.len() > MAX_POLICIES_PER_CLIENT { + bail!("a client must have 1..={MAX_POLICIES_PER_CLIENT} policies"); + } + let mut covered_questions = BTreeMap::<&str, &str>::new(); + for policy_id in policy_ids { + validate_identifier(policy_id, "policy")?; + let policy = policies + .get(policy_id) + .ok_or_else(|| anyhow::anyhow!("unknown access policy {policy_id}"))?; + for question in &policy.questions { + if let Some(existing) = covered_questions.insert(question, policy_id) { + bail!( + "policies {existing} and {policy_id} grant the same authored entitlement for question {question}" + ); + } + } + } + Ok(()) +} + +fn load_policy_documents(project: &Path) -> Result> { + let policies = load_policy_documents_if_present(project)?; + if policies.is_empty() { + bail!("no access policies are configured"); + } + Ok(policies) +} + +fn load_policy_documents_if_present( + project: &Path, +) -> Result> { + validate_optional_access_root(project)?; + let directory = project.join(ACCESS_DIRECTORY).join(POLICIES_DIRECTORY); + let paths = yaml_paths_if_present(&directory, MAX_POLICIES, "access policies")?; + let mut policies = BTreeMap::new(); + for path in paths { + let mut document: AccessPolicyDocument = read_yaml(&path, PUBLIC_FILE_MODE)?; + if document.version != 1 { + bail!("access policy version must be 1"); + } + validate_identifier(&document.id, "policy")?; + validate_filename_id(&path, &document.id, "access policy")?; + document.questions = + canonical_sorted_unique(&document.questions, "questions", MAX_QUESTIONS)?; + for question in &document.questions { + validate_identifier(question, "question")?; + validate_authored_question(project, question)?; + } + if policies.insert(document.id.clone(), document).is_some() { + bail!("access policy ids must be unique"); + } + } + Ok(policies) +} + +fn load_client_documents_if_present(project: &Path) -> Result> { + validate_optional_access_root(project)?; + let directory = project.join(ACCESS_DIRECTORY).join(CLIENTS_DIRECTORY); + let paths = yaml_paths_if_present(&directory, MAX_CLIENTS, "clients")?; + let mut clients = BTreeMap::new(); + for path in paths { + let document = read_client_document(&path)?; + if clients + .insert(document.client_id.clone(), document) + .is_some() + { + bail!("client ids must be unique"); + } + } + Ok(clients) +} + +fn read_client_document(path: &Path) -> Result { + let mut document: ClientDocument = read_yaml(path, PUBLIC_FILE_MODE)?; + if document.version != 1 { + bail!("client document version must be 1"); + } + validate_identifier(&document.client_id, "client")?; + validate_filename_id(path, &document.client_id, "client")?; + document.policies = + canonical_sorted_unique(&document.policies, "policies", MAX_POLICIES_PER_CLIENT)?; + if document.principal != local_client_uri(&document.client_id) + || document.evidence_audience != local_client_uri(&document.client_id) + { + bail!("local client principal and evidence audience must match its client id"); + } + if document.keys.len() != 1 { + bail!("a local client must contain exactly one public key"); + } + for key in &document.keys { + if key.get("d").is_some() { + bail!("client documents must never contain private key material"); + } + let text = serde_json::to_string(key).context("rendering client public key")?; + PublicJwk::parse(&text).context("client public JWK is invalid")?; + } + Ok(document) +} + +fn validate_private_client_key(project: &Path, document: &ClientDocument) -> Result { + let private_root = project.join(PRIVATE_STATE_DIRECTORY); + let clients_root = private_root.join(CLIENTS_DIRECTORY); + let directory = clients_root.join(&document.client_id); + validate_path_mode(&private_root, true, PRIVATE_DIRECTORY_MODE)?; + validate_path_mode(&clients_root, true, PRIVATE_DIRECTORY_MODE)?; + validate_path_mode(&directory, true, PRIVATE_DIRECTORY_MODE)?; + let path = directory.join(PRIVATE_KEY_FILENAME); + let text = read_bounded_file(&path, MAX_DOCUMENT_BYTES, Some(PRIVATE_FILE_MODE))?; + let private = PrivateJwk::parse(&text).context("local client private JWK is invalid")?; + let registered_text = serde_json::to_string(&document.keys[0]) + .context("rendering registered client public JWK")?; + let registered = + PublicJwk::parse(®istered_text).context("registered client public JWK is invalid")?; + if private + .public() + .jkt() + .context("deriving private-key thumbprint")? + != registered + .jkt() + .context("deriving registered-key thumbprint")? + { + bail!("local client private key does not match its registered public key"); + } + Ok(path) +} + +fn read_public_jwk(path: &Path) -> Result { + let text = read_bounded_file(path, MAX_DOCUMENT_BYTES, Some(PRIVATE_FILE_MODE))?; + PublicJwk::parse(&text).context("generated public JWK is invalid")?; + let value: Value = serde_json::from_str(&text).context("parsing generated public JWK")?; + if value.get("d").is_some() { + bail!("generated public JWK unexpectedly contains private material"); + } + Ok(value) +} + +fn validate_authored_question(project: &Path, question_id: &str) -> Result<()> { + let directory = project.join("questions"); + validate_visible_directory(&directory, "questions")?; + let path = directory.join(format!("{question_id}.yaml")); + let value: Value = read_yaml_any_mode(&path)?; + if value.get("id").and_then(Value::as_str) != Some(question_id) { + bail!("question id must match its questions/.yaml filename"); + } + Ok(()) +} + +fn yaml_paths_if_present(directory: &Path, maximum: usize, label: &str) -> Result> { + match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).with_context(|| format!("inspecting {label}")), + }; + let parent = directory + .parent() + .context("access directory has no parent")?; + validate_path_mode(parent, true, PUBLIC_DIRECTORY_MODE)?; + validate_path_mode(directory, true, PUBLIC_DIRECTORY_MODE)?; + let mut paths = fs::read_dir(directory) + .with_context(|| format!("reading {label}"))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + paths.sort(); + if paths.len() > maximum { + bail!("too many {label}; maximum is {maximum}"); + } + if paths + .iter() + .any(|path| path.extension().and_then(|value| value.to_str()) != Some("yaml")) + { + bail!("{label} may contain only .yaml files"); + } + Ok(paths) +} + +fn validate_optional_access_root(project: &Path) -> Result<()> { + let path = project.join(ACCESS_DIRECTORY); + match fs::symlink_metadata(&path) { + Ok(_) => validate_path_mode(&path, true, PUBLIC_DIRECTORY_MODE), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn validate_visible_directory(path: &Path, label: &str) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspecting {label} directory {}", path.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o022 != 0 + { + bail!("{label} must be held in a plain owner-controlled directory"); + } + Ok(()) +} + +fn read_yaml Deserialize<'de>>(path: &Path, mode: u32) -> Result { + let text = read_bounded_file(path, MAX_DOCUMENT_BYTES, Some(mode))?; + serde_norway::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +fn read_yaml_any_mode(path: &Path) -> Result { + let text = read_bounded_file(path, MAX_DOCUMENT_BYTES, None)?; + serde_norway::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +fn read_bounded_file(path: &Path, maximum: u64, required_mode: Option) -> Result { + let descriptor = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .with_context(|| format!("opening {} without following symlinks", path.display()))?; + let file = File::from(descriptor); + let metadata = file + .metadata() + .with_context(|| format!("inspecting open file {}", path.display()))?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.len() > maximum + || required_mode.is_some_and(|mode| metadata.permissions().mode() & 0o7777 != mode) + { + bail!( + "{} is not a bounded owner-controlled regular file", + path.display() + ); + } + let mut text = String::new(); + file.take(maximum + 1) + .read_to_string(&mut text) + .with_context(|| format!("reading {}", path.display()))?; + if text.len() as u64 > maximum { + bail!("{} is too large", path.display()); + } + Ok(text) +} + +fn write_new_yaml_atomic(path: &Path, value: &T, mode: u32) -> Result<()> { + reject_existing(path)?; + let bytes = serde_norway::to_string(value).context("rendering access document")?; + let parent = path.parent().context("access document has no parent")?; + let mut temporary = tempfile::Builder::new() + .prefix(".access-write-") + .tempfile_in(parent) + .context("creating temporary access document")?; + temporary + .as_file_mut() + .set_permissions(fs::Permissions::from_mode(mode))?; + temporary.write_all(bytes.as_bytes())?; + temporary.as_file_mut().sync_all()?; + temporary + .persist_noclobber(path) + .map_err(|error| error.error) + .with_context(|| format!("publishing {} without overwrite", path.display()))?; + sync_directory(parent)?; + Ok(()) +} + +fn replace_yaml_atomic(path: &Path, value: &T, mode: u32) -> Result<()> { + validate_path_mode(path, false, mode)?; + let bytes = serde_norway::to_string(value).context("rendering access document")?; + let parent = path.parent().context("access document has no parent")?; + let mut temporary = tempfile::Builder::new() + .prefix(".access-write-") + .tempfile_in(parent) + .context("creating temporary access document")?; + temporary + .as_file_mut() + .set_permissions(fs::Permissions::from_mode(mode))?; + temporary.write_all(bytes.as_bytes())?; + temporary.as_file_mut().sync_all()?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("atomically replacing {}", path.display()))?; + sync_directory(parent)?; + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path) + .with_context(|| format!("opening directory {}", path.display()))? + .sync_all() + .with_context(|| format!("syncing directory {}", path.display())) +} + +fn ensure_public_directory(path: &Path) -> Result<()> { + ensure_directory(path, PUBLIC_DIRECTORY_MODE) +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + ensure_directory(path, PRIVATE_DIRECTORY_MODE) +} + +fn ensure_directory(path: &Path, mode: u32) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => validate_path_mode(path, true, mode), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::DirBuilder::new() + .mode(mode) + .create(path) + .with_context(|| format!("creating {}", path.display()))?; + validate_path_mode(path, true, mode) + } + Err(error) => Err(error.into()), + } +} + +fn validate_path_mode(path: &Path, directory: bool, mode: u32) -> Result<()> { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; + if metadata.file_type().is_symlink() + || if directory { + !metadata.is_dir() + } else { + !metadata.is_file() + } + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != mode + { + bail!( + "{} must be a plain owner-controlled {} with mode {mode:04o}", + path.display(), + if directory { "directory" } else { "file" } + ); + } + Ok(()) +} + +fn reject_existing(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => bail!("refusing to overwrite existing {}", path.display()), + Err(error) => Err(error.into()), + } +} + +fn validate_filename_id(path: &Path, id: &str, label: &str) -> Result<()> { + if path.file_stem().and_then(|value| value.to_str()) != Some(id) { + bail!("{label} id must match its .yaml filename"); + } + Ok(()) +} + +fn sorted_unique(values: &[String], label: &str, maximum: usize) -> Result> { + if values.is_empty() || values.len() > maximum { + bail!("{label} must contain 1..={maximum} values"); + } + let sorted = values.iter().cloned().collect::>(); + if sorted.len() != values.len() { + bail!("{label} must be unique"); + } + Ok(sorted.into_iter().collect()) +} + +fn canonical_sorted_unique(values: &[String], label: &str, maximum: usize) -> Result> { + let sorted = sorted_unique(values, label, maximum)?; + if sorted != values { + bail!("{label} must be sorted in canonical order"); + } + Ok(sorted) +} + +fn validate_identifier(value: &str, label: &str) -> Result<()> { + let bytes = value.as_bytes(); + if !matches!(bytes.first(), Some(b'a'..=b'z')) + || bytes.len() > 64 + || bytes[1..].iter().any(|byte| { + !(byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-')) + }) + { + bail!("{label} id must be a lowercase local identifier (maximum 64 bytes)"); + } + Ok(()) +} + +fn canonical_project(path: &Path) -> Result { + let project = fs::canonicalize(path) + .with_context(|| format!("resolving project root {}", path.display()))?; + let metadata = fs::symlink_metadata(&project)?; + if !metadata.is_dir() { + bail!("project root must be a directory"); + } + Ok(project) +} + +fn client_document_path(project: &Path, client_id: &str) -> PathBuf { + project + .join(ACCESS_DIRECTORY) + .join(CLIENTS_DIRECTORY) + .join(format!("{client_id}.yaml")) +} + +fn local_client_uri(client_id: &str) -> String { + format!("urn:registrystack:evidence:local:client:{client_id}") +} + +fn joined_policies(policies: &[String]) -> String { + if policies.len() == 1 { + format!("policy {}", policies[0]) + } else { + format!("policies {}", policies.join(", ")) + } +} diff --git a/crates/registry-evidencectl/src/audit_view.rs b/crates/registry-evidencectl/src/audit_view.rs new file mode 100644 index 000000000..178ba0a1f --- /dev/null +++ b/crates/registry-evidencectl/src/audit_view.rs @@ -0,0 +1,317 @@ +//! Minimized local audit presentation delegated to the Evidence core. + +use std::{ + io::{Read as _, Write as _}, + path::{Path, PathBuf}, + process::{Command, ExitCode, Stdio}, +}; + +use anyhow::{anyhow, Result}; +use chrono::DateTime; +use clap::{ArgGroup, Args, Subcommand}; +use serde::{Deserialize, Deserializer}; + +use crate::dev; + +const CORE_VIEW_SCHEMA: &str = "registry.evidence.local-audit-operation/v1"; +const MAX_CORE_OUTPUT_BYTES: usize = 256 * 1024; +const AUDIT_FAILED: &str = "local audit inspection failed"; + +#[derive(Debug, Subcommand)] +pub enum AuditCommand { + /// Show a minimized view of stopped local audit history. + Show(ShowArgs), +} + +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("view") + .required(true) + .multiple(false) + .args(["last_operation"]) +))] +pub struct ShowArgs { + /// Show the last verified local operation after the service has stopped. + #[arg(long)] + last_operation: bool, + + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, + + #[arg(long, hide = true)] + evidence_bin: Option, +} + +pub fn run(command: AuditCommand) -> Result { + match command { + AuditCommand::Show(args) => show(args), + } +} + +fn show(args: ShowArgs) -> Result { + if !args.last_operation { + return Err(failed()); + } + let stopped = dev::load_stopped_state(&args.project).map_err(|_| failed())?; + let evidence = dev::resolve_tool_binary( + "evidence", + args.evidence_bin.as_deref(), + "EVIDENCECTL_TEST_EVIDENCE_BIN", + ) + .map_err(|_| failed())?; + let output = inspect_core(&evidence, &stopped.runtime_path)?; + let view: CoreAuditOperation = serde_json::from_slice(&output).map_err(|_| failed())?; + let rendered = render(&view, &stopped.questions)?; + + std::io::stdout() + .lock() + .write_all(rendered.as_bytes()) + .map_err(|_| failed())?; + Ok(ExitCode::SUCCESS) +} + +/// Read no more than the closed core output bound and retain nothing from a +/// failed child. Stderr is never inherited because it may contain protected +/// audit or deployment detail from a substituted binary. +fn inspect_core(evidence: &Path, runtime: &Path) -> Result> { + let mut child = Command::new(evidence) + .arg("--runtime") + .arg(runtime) + .arg("local-audit-last-operation") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| failed())?; + let mut bytes = Vec::with_capacity(MAX_CORE_OUTPUT_BYTES.min(8192)); + let read = child + .stdout + .take() + .ok_or_else(failed)? + .take((MAX_CORE_OUTPUT_BYTES + 1) as u64) + .read_to_end(&mut bytes); + if read.is_err() || bytes.len() > MAX_CORE_OUTPUT_BYTES { + let _ = child.kill(); + let _ = child.wait(); + return Err(failed()); + } + let status = child.wait().map_err(|_| failed())?; + if !status.success() { + return Err(failed()); + } + Ok(bytes) +} + +fn render(view: &CoreAuditOperation, questions: &[dev::ReadyQuestionState]) -> Result { + let access = view.events.first().ok_or_else(failed)?; + let question = questions + .iter() + .find(|question| { + question.requirement_uri == access.requirement && question.purpose == access.purpose + }) + .ok_or_else(failed)?; + if view.schema != CORE_VIEW_SCHEMA + || !valid_operation(&view.operation) + || !valid_alias(&question.alias) + || question.concepts.is_empty() + || question.concepts.len() > 16 + || question.concepts.iter().any(|concept| { + !valid_alias(&concept.alias) + || !valid_uri(&concept.uri) + || !matches!( + concept.form.as_str(), + "boolean" + | "controlled-category" + | "bounded-integer" + | "reviewed-structured-value" + ) + }) + || !valid_purpose(&question.purpose) + || !(1..=2).contains(&view.events.len()) + { + return Err(failed()); + } + + validate_common(access, question)?; + if access.phase != Phase::AccessAttempt + || access.decision != Decision::Authorized + || access.disclosed_concepts != Presence::Absent + || access.evidence_id != Presence::Absent + { + return Err(failed()); + } + + let mut rendered = format!( + "ACCESS AUTHORIZED {} {} requester={}\n", + question.alias, question.purpose, access.requester_pseudonym + ); + if view.events.len() == 1 { + return Ok(rendered); + } + + let release = &view.events[1]; + validate_common(release, question)?; + if release.phase != Phase::DisclosureRelease + || release.decision != Decision::Released + || release.requirement != access.requirement + || release.purpose != access.purpose + || release.requester_pseudonym != access.requester_pseudonym + || release.response_protection != access.response_protection + || parse_time(&release.occurred_at)? < parse_time(&access.occurred_at)? + || release.disclosed_concepts + != Presence::Present( + question + .concepts + .iter() + .map(|concept| concept.uri.clone()) + .collect(), + ) + || !matches!( + &release.evidence_id, + Presence::Present(value) if valid_uri(value) + ) + { + return Err(failed()); + } + rendered.push_str(&format!( + "DISCLOSURE RELEASED {}\n", + question + .concepts + .iter() + .map(|concept| concept.alias.as_str()) + .collect::>() + .join(", ") + )); + Ok(rendered) +} + +fn validate_common(event: &CoreAuditEvent, question: &dev::ReadyQuestionState) -> Result<()> { + if event.requirement != question.requirement_uri + || event.purpose != question.purpose + || !matches!( + event.response_protection, + ResponseProtection::Signed | ResponseProtection::SdJwtVc + ) + || !valid_pseudonym(&event.requester_pseudonym) + { + return Err(failed()); + } + parse_time(&event.occurred_at)?; + Ok(()) +} + +fn parse_time(value: &str) -> Result> { + if value.len() > 64 || value.chars().any(char::is_control) { + return Err(failed()); + } + DateTime::parse_from_rfc3339(value).map_err(|_| failed()) +} + +fn valid_operation(value: &str) -> bool { + (16..=128).contains(&value.len()) && !value.chars().any(char::is_control) +} + +fn valid_alias(value: &str) -> bool { + valid_local_name(value, 128, false) +} + +fn valid_purpose(value: &str) -> bool { + valid_local_name(value, 128, true) +} + +fn valid_local_name(value: &str, maximum: usize, colon: bool) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= maximum + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + || (colon && byte == b':') + }) +} + +fn valid_pseudonym(value: &str) -> bool { + let Some(rest) = value.strip_prefix("hmac-sha256:v") else { + return false; + }; + let Some((version, digest)) = rest.split_once(':') else { + return false; + }; + !version.is_empty() + && !version.starts_with('0') + && version.bytes().all(|byte| byte.is_ascii_digit()) + && digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_uri(value: &str) -> bool { + !value.is_empty() && value.len() <= 512 && url::Url::parse(value).is_ok() +} + +fn failed() -> anyhow::Error { + anyhow!(AUDIT_FAILED) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CoreAuditOperation { + schema: String, + operation: String, + events: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CoreAuditEvent { + occurred_at: String, + phase: Phase, + decision: Decision, + requirement: String, + purpose: String, + requester_pseudonym: String, + response_protection: ResponseProtection, + #[serde(default, deserialize_with = "deserialize_presence")] + disclosed_concepts: Presence>, + #[serde(default, deserialize_with = "deserialize_presence")] + evidence_id: Presence, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum Phase { + AccessAttempt, + DisclosureRelease, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum Decision { + Authorized, + Released, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum ResponseProtection { + Signed, + SdJwtVc, +} + +#[derive(Debug, Default, Eq, PartialEq)] +enum Presence { + #[default] + Absent, + Present(T), +} + +fn deserialize_presence<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Presence::Present) +} diff --git a/crates/registry-evidencectl/src/authoring.rs b/crates/registry-evidencectl/src/authoring.rs new file mode 100644 index 000000000..ccfeb4b06 --- /dev/null +++ b/crates/registry-evidencectl/src/authoring.rs @@ -0,0 +1,5105 @@ +//! Compile the deliberately narrow local tutorial authoring shape into the +//! runtime's canonical deployment inputs. +//! +//! This module is an internal seam for `dev`. It does not expose another CLI +//! surface and it delegates the final semantic decision to `evidence check`. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::Read as _, + os::unix::fs::{DirBuilderExt as _, MetadataExt as _, PermissionsExt as _}, + path::{Component, Path, PathBuf}, + process::Command, +}; + +use anyhow::{anyhow, bail, Context as _, Result}; +use registry_platform_crypto::{canonicalize_json, domain_separated_sha256}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use url::{Host, Url}; + +use crate::suggest::{ + narrow, + openapi::Spec, + types::{BoundKind, BoundValues, OperationKey}, +}; + +const OPENAPI_FILE: &str = "source.openapi.yaml"; +const QUESTIONS_DIRECTORY: &str = "questions"; +const SOURCES_DIRECTORY: &str = "sources"; +const SELECTORS_DIRECTORY: &str = "selectors"; +const DERIVATIONS_DIRECTORY: &str = "derivations"; +const SCHEMAS_DIRECTORY: &str = "schemas"; +const FIXTURES_DIRECTORY: &str = "fixtures"; +const SECRETS_DIRECTORY: &str = "secrets"; +const ACCESS_DIRECTORY: &str = "access"; +const ACCESS_POLICIES_DIRECTORY: &str = "policies"; +const LOCAL_URI_PREFIX: &str = "urn:registrystack:evidence:local:"; +const LOCAL_AUDIENCE: &str = "registry-evidence-local"; +const SIGNING_KEY_ID: &str = "local-signing-key-1"; +const AUTHORITY_PROFILE_ID: &str = "local-caller"; +const LOCAL_CALLER_EVIDENCE_AUDIENCE: &str = "urn:registrystack:evidence:local:caller"; +const MAX_OPENAPI_BYTES: u64 = 16 * 1024 * 1024; +const MAX_QUESTION_BYTES: u64 = 64 * 1024; +const MAX_ACCESS_POLICY_BYTES: u64 = 64 * 1024; +const MAX_DERIVATION_BYTES: u64 = 64 * 1024; +const MAX_SOURCE_ARTIFACT_BYTES: u64 = 1024 * 1024; +const MAX_QUESTIONS: usize = 128; +const MAX_CONCEPTS: usize = 16; +const MAX_CATEGORIES: usize = 32; +const MAX_CATEGORY_BYTES: usize = 64; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CompiledConceptForm { + Boolean, + ControlledCategory, + BoundedInteger, + Structured, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct CompiledConcept { + pub(crate) concept_alias: String, + pub(crate) concept_uri: String, + pub(crate) concept_form: CompiledConceptForm, +} + +/// Closed metadata consumed later by `dev` and request preparation. It stays +/// in memory here; this compiler does not create a second public artifact. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct CompiledQuestion { + pub(crate) question_alias: String, + pub(crate) requirement_uri: String, + pub(crate) purpose: String, + pub(crate) subjects: Vec, + pub(crate) concepts: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct CompiledSubject { + pub(crate) role: String, + pub(crate) selector_profile: String, + pub(crate) selector_field: String, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct CompiledProject { + pub(crate) runtime_path: PathBuf, + pub(crate) questions: Vec, + pub(crate) local_audience: String, + pub(crate) requester_tag: String, + pub(crate) caller_evidence_audience: String, + pub(crate) access_policies: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CompiledAccessPolicy { + pub(crate) id: String, + pub(crate) requester_tag: String, + pub(crate) questions: Vec, +} + +#[derive(Debug)] +pub(crate) struct CompiledProductionProject { + pub(crate) bundle_path: PathBuf, + pub(crate) fixture_paths: Vec, + pub(crate) bundle: Value, +} + +enum CompileProfile { + Local(LocalServicePorts), + Production(Value), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct LocalServicePorts { + pub(crate) evidence: u16, + pub(crate) mint: u16, +} + +impl LocalServicePorts { + pub(crate) fn new(evidence: u16, mint: u16) -> Result { + if evidence == 0 || mint == 0 { + bail!("local service ports must be non-zero"); + } + if evidence == mint { + bail!("Evidence and Mint must use different local ports"); + } + Ok(Self { evidence, mint }) + } + + pub(crate) fn mint_origin(self) -> String { + format!("http://127.0.0.1:{}", self.mint) + } +} + +impl Default for LocalServicePorts { + fn default() -> Self { + Self { + evidence: 8080, + mint: 8081, + } + } +} + +/// Compile the authored questions into one unpublished local generation, then +/// ask the real Evidence binary to check the complete result. +/// +/// `staging_root` must be an existing, empty, owner-only directory. The caller +/// owns generation publication and process supervision. +#[cfg(test)] +pub(crate) fn compile_local_project( + project_root: &Path, + staging_root: &Path, + evidence_bin: &Path, +) -> Result { + compile_local_project_with_ports( + project_root, + staging_root, + evidence_bin, + LocalServicePorts::default(), + ) +} + +pub(crate) fn compile_local_project_with_ports( + project_root: &Path, + staging_root: &Path, + evidence_bin: &Path, + ports: LocalServicePorts, +) -> Result { + LocalServicePorts::new(ports.evidence, ports.mint)?; + let project_root = validate_project_root(project_root)?; + validate_private_empty_staging(staging_root)?; + validate_evidence_binary(evidence_bin)?; + + // Resolve the complete plan before writing anything. Unsupported or + // ambiguous authoring inputs therefore leave the staging root empty. + let inputs = read_inputs(&project_root, true)?; + let plan = compile_plan(inputs, CompileProfile::Local(ports))?; + let compilation = write_plan(&project_root, staging_root, &plan, ports)?; + + if let Err(error) = check_with_evidence(evidence_bin, &compilation.runtime_path) { + // A rejected unpublished generation should remain removable by its + // owner. No path outside the caller-supplied staging root is changed. + let _ = set_bundle_modes(&staging_root.join("bundle"), 0o700, 0o600); + let _ = fs::set_permissions(&compilation.runtime_path, fs::Permissions::from_mode(0o600)); + return Err(error); + } + + Ok(compilation) +} + +/// Compile one complete production bundle into an unpublished private staging +/// directory. The caller owns temporary runtime validation and publication. +pub(crate) fn compile_production_project( + project_root: &Path, + staging_root: &Path, + governed_bundle: Value, +) -> Result { + validate_plain_path_components(project_root, "production project")?; + let project_root = validate_project_root(project_root)?; + validate_private_empty_staging(staging_root)?; + let inputs = read_inputs(&project_root, false)?; + validate_production_inputs(&project_root, &inputs)?; + let plan = compile_plan(inputs, CompileProfile::Production(governed_bundle))?; + reject_local_production_values(&plan.bundle)?; + validate_production_sources(&plan.bundle)?; + let bundle_path = write_bundle(&project_root, staging_root, &plan)?; + let fixture_paths = plan + .questions + .iter() + .map(|question| { + question + .fixture_artifact + .clone() + .expect("production questions were validated") + }) + .collect(); + Ok(CompiledProductionProject { + bundle_path, + fixture_paths, + bundle: plan.bundle, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Question { + id: String, + question: String, + purpose: String, + #[serde(default)] + subject: Option, + #[serde(default)] + subjects: Vec, + source: QuestionSource, + answers: Vec, + derivation: String, + disclosure: QuestionDisclosure, + #[serde(default)] + governance: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct QuestionGovernance { + requirement: String, + kind: RequirementKind, + reference_frameworks: Vec, + evidence_type: String, + validity_seconds: u64, + observation_timezone: String, + fixtures: String, + disclosure_families: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum RequirementKind { + Criterion, + InformationRequirement, + Constraint, +} + +impl RequirementKind { + fn as_str(self) -> &'static str { + match self { + Self::Criterion => "criterion", + Self::InformationRequirement => "information-requirement", + Self::Constraint => "constraint", + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuestionSubject { + role: String, + selector: String, + #[serde(default)] + profile: Option, + #[serde(default)] + derivation: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuestionSource { + #[serde(rename = "ref")] + source_ref: Option, + #[serde(default)] + operation: Option, + #[serde(default)] + facts: Vec, + #[serde(rename = "collectionBounds", default)] + collection_bounds: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuestionFact { + name: String, + path: String, + combine: FactCombination, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum FactCombination { + ExactlyOne, + Collect, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuestionAnswer { + concept: String, + #[serde(default)] + id: Option, + #[serde(rename = "type")] + answer_type: AnswerType, + #[serde(default)] + values: Vec, + minimum: Option, + maximum: Option, + schema: Option, + #[serde(rename = "maximumSerializedBytes")] + maximum_serialized_bytes: Option, + #[serde(rename = "sdJwtVc")] + sd_jwt_vc: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum AnswerType { + Boolean, + ControlledCategory, + BoundedInteger, + ReviewedStructuredValue, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct QuestionSdJwtVc { + claim: String, + disclosure: QuestionSdJwtVcDisclosure, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum QuestionSdJwtVcDisclosure { + TopLevel, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuestionDisclosure { + allow: Vec, +} + +struct Inputs { + openapi: Value, + selectors: BTreeMap, + sources: BTreeMap, + schemas: BTreeMap, + questions: Vec, + access_policies: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AccessPolicyDocument { + version: u8, + id: String, + questions: Vec, +} + +#[derive(Clone)] +struct AuthoredAccessPolicy { + id: String, + requester_tag: String, + questions: Vec, +} + +struct AuthoredQuestion { + question: Question, + derivation: String, +} + +struct CompilePlan { + questions: Vec, + access_policies: Vec, + bundle: Value, +} + +struct QuestionPlan { + question_id: String, + source_artifact_id: String, + authored_source_artifacts: Option>, + derivation_artifact: String, + fixture_artifact: Option, + purpose: String, + requirement_uri: String, + concepts: Vec, + subjects: Vec, + source_id: String, + source_value: Value, + grant: Value, + requirement: Value, + response_schema: Value, + fact_schema: Value, + adapter_parameters_schema: Value, + prepare_script: String, + extract_script: String, + derivation_script: String, +} + +struct SubjectPlan { + role: String, + selector_field: String, + selector_profile: String, + selector_profile_value: Value, + derivation: bool, +} + +struct ConceptPlan { + concept_alias: String, + concept_uri: String, + concept_form: CompiledConceptForm, + constraints: Value, + codelist: Option<(String, Value)>, + schema: Option<(String, Value)>, + sd_jwt_vc: Option, +} + +struct CompiledFacts { + response_schema: Value, + fact_schema: Value, + extract_script: String, +} + +struct BundleRequirement<'a> { + requirement_uri: String, + kind: &'static str, + concepts: &'a [ConceptPlan], +} + +struct Operation<'a> { + method: &'a str, + path: &'a str, + path_item: &'a Map, + operation: &'a Map, +} + +fn validate_project_root(project_root: &Path) -> Result { + let metadata = fs::symlink_metadata(project_root) + .with_context(|| format!("inspecting project root {}", project_root.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "project root {} must be a plain directory", + project_root.display() + ); + } + fs::canonicalize(project_root) + .with_context(|| format!("resolving project root {}", project_root.display())) +} + +fn validate_plain_path_components(path: &Path, description: &str) -> Result<()> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let components = absolute.components().collect::>(); + let mut current = PathBuf::new(); + for component in components { + match component { + Component::RootDir => current.push(Path::new("/")), + Component::Normal(value) => current.push(value), + Component::CurDir => continue, + Component::ParentDir | Component::Prefix(_) => { + bail!("{description} must not contain path traversal") + } + } + let metadata = + fs::symlink_metadata(¤t).with_context(|| format!("inspecting {description}"))?; + if metadata.file_type().is_symlink() { + bail!("{description} must not traverse symbolic links"); + } + if !metadata.is_dir() { + bail!("{description} must be a plain directory"); + } + } + Ok(()) +} + +fn validate_private_empty_staging(staging_root: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(staging_root) + .with_context(|| format!("inspecting staging root {}", staging_root.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o700 + { + bail!( + "local compilation staging root {} must be a plain owner-only directory (mode 0700)", + staging_root.display() + ); + } + let mut entries = fs::read_dir(staging_root) + .with_context(|| format!("reading staging root {}", staging_root.display()))?; + if entries.next().transpose()?.is_some() { + bail!("local compilation staging root must be empty"); + } + Ok(()) +} + +fn validate_evidence_binary(path: &Path) -> Result<()> { + let metadata = fs::metadata(path) + .with_context(|| format!("inspecting evidence binary {}", path.display()))?; + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + bail!( + "evidence binary {} is not an executable file", + path.display() + ); + } + Ok(()) +} + +fn read_inputs(project_root: &Path, require_local_secrets: bool) -> Result { + let openapi_text = read_regular_file( + &project_root.join(OPENAPI_FILE), + MAX_OPENAPI_BYTES, + "retained OpenAPI document", + )?; + let openapi: Value = serde_norway::from_slice(&openapi_text) + .context("parsing retained OpenAPI document as YAML or JSON")?; + validate_openapi_version(&openapi)?; + + let selectors = read_named_objects(project_root, SELECTORS_DIRECTORY, "selector profile")?; + let sources = read_named_objects(project_root, SOURCES_DIRECTORY, "source")?; + let schemas = read_named_objects(project_root, SCHEMAS_DIRECTORY, "schema")?; + let mut questions = Vec::new(); + let mut question_ids = BTreeSet::new(); + let mut derivation_paths = BTreeSet::new(); + for question_path in question_paths(project_root)? { + let question_bytes = read_regular_file(&question_path, MAX_QUESTION_BYTES, "question")?; + let question: Question = serde_norway::from_slice(&question_bytes) + .with_context(|| format!("parsing question {}", question_path.display()))?; + validate_question(&question)?; + if question_path.file_stem().and_then(|value| value.to_str()) != Some(&question.id) { + bail!("question id must match its questions/.yaml filename"); + } + if !question_ids.insert(question.id.clone()) { + bail!("question ids must be unique"); + } + if !derivation_paths.insert(question.derivation.clone()) { + bail!("each question must name its own derivation file"); + } + + let derivation_path = project_relative_derivation(project_root, &question.derivation)?; + let derivation_bytes = read_regular_file( + &derivation_path, + MAX_DERIVATION_BYTES, + "authored derivation", + )?; + let derivation = + String::from_utf8(derivation_bytes).context("authored derivation must be UTF-8")?; + validate_authored_answer(&derivation)?; + questions.push(AuthoredQuestion { + question, + derivation, + }); + } + let access_policies = if require_local_secrets { + read_access_policies(project_root, &question_ids)? + } else { + Vec::new() + }; + + if require_local_secrets { + let secrets = project_root.join(SECRETS_DIRECTORY); + let secrets_metadata = fs::symlink_metadata(&secrets) + .with_context(|| format!("inspecting local secret directory {}", secrets.display()))?; + if secrets_metadata.file_type().is_symlink() + || !secrets_metadata.is_dir() + || secrets_metadata.uid() != rustix::process::geteuid().as_raw() + || secrets_metadata.permissions().mode() & 0o7777 != 0o700 + { + bail!("local secret directory must be a plain owner-only directory (mode 0700)"); + } + } + + Ok(Inputs { + openapi, + selectors, + sources, + schemas, + questions, + access_policies, + }) +} + +fn validate_production_inputs(project_root: &Path, inputs: &Inputs) -> Result<()> { + for authored in &inputs.questions { + let question = &authored.question; + let governance = question + .governance + .as_ref() + .ok_or_else(|| anyhow!("every production question requires governance"))?; + if question.answers.iter().any(|answer| answer.id.is_none()) { + bail!("every production answer requires one stable concept id"); + } + for uri in std::iter::once(governance.requirement.as_str()) + .chain(governance.reference_frameworks.iter().map(String::as_str)) + .chain(std::iter::once(governance.evidence_type.as_str())) + .chain(governance.disclosure_families.iter().map(String::as_str)) + .chain( + question + .answers + .iter() + .filter_map(|answer| answer.id.as_deref()), + ) + { + if uri.starts_with(LOCAL_URI_PREFIX) { + bail!("production governance must not use disposable local identifiers"); + } + } + let fixture = project_relative_fixture(project_root, &governance.fixtures)?; + let _ = read_regular_file(&fixture, MAX_SOURCE_ARTIFACT_BYTES, "production fixture")?; + } + Ok(()) +} + +fn project_relative_fixture(project_root: &Path, value: &str) -> Result { + let relative = Path::new(value); + let components = relative.components().collect::>(); + if components.len() != 2 + || components.first() != Some(&Component::Normal(FIXTURES_DIRECTORY.as_ref())) + || !matches!(components.get(1), Some(Component::Normal(_))) + || relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("yaml") + { + bail!("governance fixtures must be project-relative fixtures/.yaml files"); + } + let directory = project_root.join(FIXTURES_DIRECTORY); + let metadata = fs::symlink_metadata(&directory) + .with_context(|| format!("inspecting fixture directory {}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("fixtures must be held in a plain directory"); + } + Ok(project_root.join(relative)) +} + +fn reject_local_production_values(bundle: &Value) -> Result<()> { + match bundle { + Value::String(value) if value.starts_with(LOCAL_URI_PREFIX) => { + bail!("the production bundle contains a disposable local identifier") + } + Value::Array(values) => { + for value in values { + reject_local_production_values(value)?; + } + } + Value::Object(values) => { + for value in values.values() { + reject_local_production_values(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_production_sources(bundle: &Value) -> Result<()> { + let sources = bundle + .get("sources") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("the production bundle has no sources object"))?; + for source in sources.values() { + let https = source + .get("baseUrl") + .and_then(Value::as_str) + .is_some_and(|value| value.starts_with("https://")); + let authenticated = source + .pointer("/authentication/kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind != "none" && kind != "review-required"); + if !https || !authenticated { + bail!("every production source must use authenticated HTTPS"); + } + } + Ok(()) +} + +fn read_access_policies( + project_root: &Path, + question_ids: &BTreeSet, +) -> Result> { + let access_root = project_root.join(ACCESS_DIRECTORY); + let access_metadata = match fs::symlink_metadata(&access_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error) + .with_context(|| format!("inspecting access directory {}", access_root.display())) + } + }; + if access_metadata.file_type().is_symlink() || !access_metadata.is_dir() { + bail!("access must be held in a plain project directory"); + } + let directory = access_root.join(ACCESS_POLICIES_DIRECTORY); + let metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if access_root.join("clients").exists() { + bail!("client access configuration requires at least one access policy"); + } + return Ok(Vec::new()); + } + Err(error) => { + return Err(error).with_context(|| { + format!("inspecting access policy directory {}", directory.display()) + }) + } + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("access policies must be held in a plain access/policies directory"); + } + let mut paths = fs::read_dir(&directory) + .with_context(|| format!("reading access policy directory {}", directory.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + paths.sort(); + if !(1..=MAX_QUESTIONS).contains(&paths.len()) { + bail!("explicit access configuration requires 1..={MAX_QUESTIONS} access policies"); + } + + let mut ids = BTreeSet::new(); + let mut policies = Vec::with_capacity(paths.len()); + for path in paths { + if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { + bail!("access-policies may contain only .yaml files"); + } + let bytes = read_regular_file(&path, MAX_ACCESS_POLICY_BYTES, "access policy")?; + let policy: AccessPolicyDocument = serde_norway::from_slice(&bytes) + .with_context(|| format!("parsing access policy {}", path.display()))?; + if policy.version != 1 { + bail!("access policy version must be 1"); + } + if !valid_local_identifier(&policy.id) { + bail!("access policy id must be a lowercase local identifier"); + } + if path.file_stem().and_then(|value| value.to_str()) != Some(&policy.id) { + bail!("access policy id must match its access/policies/.yaml filename"); + } + if !ids.insert(policy.id.clone()) { + bail!("access policy ids must be unique"); + } + if !(1..=MAX_QUESTIONS).contains(&policy.questions.len()) { + bail!("an access policy must name 1..={MAX_QUESTIONS} questions"); + } + if !policy.questions.windows(2).all(|pair| pair[0] < pair[1]) { + bail!("access policy questions must be sorted and unique"); + } + if policy + .questions + .iter() + .any(|question| !question_ids.contains(question)) + { + bail!("access policy names a question that does not exist in this project"); + } + let questions = policy.questions; + let requester_tag = access_policy_requester_tag(&policy.id, &questions)?; + policies.push(AuthoredAccessPolicy { + id: policy.id, + requester_tag, + questions, + }); + } + Ok(policies) +} + +pub(crate) fn access_policy_requester_tag(id: &str, questions: &[String]) -> Result { + if !valid_local_identifier(id) || questions.is_empty() || questions.len() > MAX_QUESTIONS { + bail!("access policy is outside the closed local profile"); + } + if !questions.windows(2).all(|pair| pair[0] < pair[1]) + || questions + .iter() + .any(|question| !valid_local_identifier(question)) + { + bail!("access policy questions must be unique lowercase local identifiers"); + } + let canonical = canonicalize_json(&json!({ + "version": 1, + "id": id, + "questions": questions, + })) + .context("canonicalizing access policy")?; + let digest = domain_separated_sha256(b"registry-evidencectl-access-policy-v1\0", &canonical); + let mut tag = String::from("policy-v1-"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut tag, "{byte:02x}").expect("writing to a string cannot fail"); + } + Ok(tag) +} + +fn read_named_objects( + project_root: &Path, + directory_name: &str, + description: &str, +) -> Result> { + let directory = project_root.join(directory_name); + let metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(error) => { + return Err(error).with_context(|| { + format!("inspecting {description} directory {}", directory.display()) + }) + } + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("{directory_name} must be held in a plain directory"); + } + let mut paths = fs::read_dir(&directory) + .with_context(|| format!("reading {description} directory {}", directory.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + paths.sort(); + + let mut objects = BTreeMap::new(); + for path in paths { + if path.extension().and_then(|extension| extension.to_str()) != Some("yaml") { + bail!("{directory_name} may contain only .yaml files"); + } + let bytes = read_regular_file(&path, MAX_SOURCE_ARTIFACT_BYTES, description)?; + let value: Value = serde_norway::from_slice(&bytes) + .with_context(|| format!("parsing {description} {}", path.display()))?; + if !value.is_object() { + bail!("{description} must be a YAML object"); + } + let id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| anyhow!("{description} file name is not valid UTF-8"))?; + if !valid_local_identifier(id) { + bail!("{description} file name must be a lowercase local identifier"); + } + objects.insert(id.to_owned(), value); + } + Ok(objects) +} + +/// Parse the authored program as Rhai and reserve `derive` exclusively for +/// the generated binding wrapper. Function discovery comes from the AST, so +/// strings, comments, and whitespace cannot masquerade as entry points. +fn validate_authored_answer(source: &str) -> Result<()> { + let ast = rhai::Engine::new() + .compile(source) + .map_err(|_| anyhow!("authored derivation does not compile as Rhai"))?; + let mut names = BTreeSet::new(); + let mut answers = 0; + for function in ast.iter_functions() { + if !names.insert(function.name) { + bail!("authored derivation function names must be unique"); + } + if function.name == "derive" { + bail!("the `derive` entry point is reserved for the generated concept binding"); + } + if function.name == "answer" { + if function.params.len() != 3 { + bail!("authored derivation must declare answer(facts, selectors, context)"); + } + answers += 1; + } + } + if answers != 1 { + bail!("authored derivation must declare exactly one answer(facts, selectors, context)"); + } + Ok(()) +} + +fn read_regular_file(path: &Path, maximum_bytes: u64, description: &str) -> Result> { + use rustix::fs::{Mode, OFlags}; + + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("opening {description} {}", path.display()))?; + let mut file = File::from(descriptor); + let metadata = file + .metadata() + .with_context(|| format!("inspecting {description} {}", path.display()))?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > maximum_bytes { + bail!( + "{description} {} is not a bounded plain file", + path.display() + ); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(maximum_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .with_context(|| format!("reading {description} {}", path.display()))?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > maximum_bytes { + bail!("{description} exceeds its byte limit"); + } + Ok(bytes) +} + +fn question_paths(project_root: &Path) -> Result> { + let directory = project_root.join(QUESTIONS_DIRECTORY); + let metadata = fs::symlink_metadata(&directory) + .with_context(|| format!("inspecting question directory {}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("questions must be held in a plain directory"); + } + let mut paths = fs::read_dir(&directory) + .with_context(|| format!("reading question directory {}", directory.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + paths.sort(); + if paths.is_empty() || paths.len() > MAX_QUESTIONS { + bail!("local authoring requires 1..={MAX_QUESTIONS} questions/*.yaml files"); + } + if paths + .iter() + .any(|path| path.extension().and_then(|value| value.to_str()) != Some("yaml")) + { + bail!("questions must contain only questions/*.yaml files"); + } + Ok(paths) +} + +fn project_relative_derivation(project_root: &Path, value: &str) -> Result { + let relative = Path::new(value); + let components = relative.components().collect::>(); + if components.len() != 2 + || components.first() != Some(&Component::Normal(DERIVATIONS_DIRECTORY.as_ref())) + || !matches!(components.get(1), Some(Component::Normal(_))) + || relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("rhai") + { + bail!("derivation must be a project-relative derivations/.rhai file"); + } + let directory = project_root.join(DERIVATIONS_DIRECTORY); + let metadata = fs::symlink_metadata(&directory) + .with_context(|| format!("inspecting derivation directory {}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("derivations must be held in a plain directory"); + } + Ok(project_root.join(relative)) +} + +fn validate_question(question: &Question) -> Result<()> { + for (label, value) in [ + ("id", question.id.as_str()), + ("purpose", question.purpose.as_str()), + ] { + if !valid_local_identifier(value) { + bail!("question {label} must be a lowercase local identifier"); + } + } + let subjects = question_subjects(question)?; + let mut roles = BTreeSet::new(); + for subject in &subjects { + if !valid_local_identifier(&subject.role) + || !valid_local_identifier(&subject.selector) + || subject + .profile + .as_deref() + .is_some_and(|profile| !valid_local_identifier(profile)) + { + bail!("question subjects must use lowercase local role, selector, and profile identifiers"); + } + if !roles.insert(subject.role.as_str()) { + bail!("question subject roles must be unique"); + } + } + if question.question.is_empty() + || question.question.len() > 512 + || question.question.chars().any(char::is_control) + { + bail!("question text must be a non-empty bounded line of text"); + } + if !(1..=MAX_CONCEPTS).contains(&question.answers.len()) { + bail!("answers must contain 1..={MAX_CONCEPTS} governed concepts"); + } + let mut concepts = BTreeSet::new(); + let mut sd_jwt_claims = BTreeSet::new(); + for answer in &question.answers { + if !valid_local_identifier(&answer.concept) { + bail!("answer concept must be a lowercase local identifier"); + } + if !concepts.insert(answer.concept.as_str()) { + bail!("answer concepts must be unique"); + } + validate_answer(answer)?; + if let Some(projection) = &answer.sd_jwt_vc { + if !sd_jwt_claims.insert(projection.claim.as_str()) { + bail!("sdJwtVc.claim names must be unique within a question"); + } + } + } + match (&question.source.source_ref, &question.source.operation) { + (Some(source_ref), None) => { + if !valid_local_identifier(source_ref) + || !question.source.facts.is_empty() + || !question.source.collection_bounds.is_empty() + { + bail!("a source reference must contain only one valid ref"); + } + } + (None, Some(operation)) => { + if question.source.facts.is_empty() || question.source.facts.len() > 16 { + bail!("source.facts must contain 1..=16 authored fact selections"); + } + let mut names = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for fact in &question.source.facts { + if !valid_field_name(&fact.name) || !names.insert(fact.name.as_str()) { + bail!("source fact names must be unique lowercase local identifiers"); + } + if fact.path.is_empty() + || fact.path.len() > 256 + || !fact.path.starts_with('/') + || fact.path.chars().any(char::is_control) + || !paths.insert(fact.path.as_str()) + { + bail!("source fact paths must be unique bounded extended JSON Pointers"); + } + let repeated = fact.path.split('/').any(|segment| segment == "*"); + match (repeated, fact.combine) { + (false, FactCombination::ExactlyOne) | (true, FactCombination::Collect) => {} + (false, FactCombination::Collect) => bail!( + "source fact `{}` uses `collect` but its path visits no collection", + fact.name + ), + (true, FactCombination::ExactlyOne) => bail!( + "source fact `{}` visits a collection and must explicitly use `combine: collect`", + fact.name + ), + } + } + if question.source.collection_bounds.len() > 16 + || question + .source + .collection_bounds + .iter() + .any(|(pointer, maximum)| { + pointer.is_empty() + || pointer.len() > 256 + || !pointer.starts_with('/') + || pointer.chars().any(char::is_control) + || !(1..=256).contains(maximum) + }) + { + bail!("source.collectionBounds must contain bounded array pointers with values in 1..=256"); + } + if operation.is_empty() + || operation.len() > 256 + || operation.chars().any(char::is_control) + { + bail!("source.operation must name one bounded OpenAPI operationId"); + } + } + _ => bail!("source must declare either ref or operation with facts"), + } + let allowed = question + .disclosure + .allow + .iter() + .map(String::as_str) + .collect::>(); + if allowed != concepts || allowed.len() != question.disclosure.allow.len() { + bail!("disclosure.allow must contain exactly the declared answer concepts"); + } + Ok(()) +} + +fn question_subjects(question: &Question) -> Result> { + match (&question.subject, question.subjects.as_slice()) { + (Some(subject), []) => Ok(vec![subject]), + (None, subjects) if (1..=8).contains(&subjects.len()) => Ok(subjects.iter().collect()), + (Some(_), _) => bail!("question must declare either subject or subjects, not both"), + (None, _) => bail!("question must declare 1..=8 subjects"), + } +} + +fn validate_answer(answer: &QuestionAnswer) -> Result<()> { + match answer.answer_type { + AnswerType::Boolean => { + if !answer.values.is_empty() + || answer.minimum.is_some() + || answer.maximum.is_some() + || answer.schema.is_some() + || answer.maximum_serialized_bytes.is_some() + || answer.sd_jwt_vc.is_some() + { + bail!("a boolean answer must not declare values or numeric bounds"); + } + } + AnswerType::ControlledCategory => { + if answer.minimum.is_some() + || answer.maximum.is_some() + || answer.schema.is_some() + || answer.maximum_serialized_bytes.is_some() + || answer.sd_jwt_vc.is_some() + { + bail!("a controlled-category answer must not declare numeric bounds"); + } + if !(2..=MAX_CATEGORIES).contains(&answer.values.len()) + || answer.values.iter().collect::>().len() != answer.values.len() + || answer.values.iter().any(|value| { + value.is_empty() + || value.len() > MAX_CATEGORY_BYTES + || value.chars().any(char::is_control) + }) + { + bail!( + "a controlled-category answer needs 2..={MAX_CATEGORIES} unique bounded values" + ); + } + } + AnswerType::BoundedInteger => { + if !answer.values.is_empty() + || answer.schema.is_some() + || answer.maximum_serialized_bytes.is_some() + || answer.sd_jwt_vc.is_some() + { + bail!("a bounded-integer answer must not declare category values"); + } + let (Some(minimum), Some(maximum)) = (answer.minimum, answer.maximum) else { + bail!("a bounded-integer answer requires minimum and maximum"); + }; + const JSON_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + if !(-JSON_SAFE_INTEGER..=JSON_SAFE_INTEGER).contains(&minimum) + || !(-JSON_SAFE_INTEGER..=JSON_SAFE_INTEGER).contains(&maximum) + || minimum > maximum + { + bail!("a bounded-integer answer needs consistent JSON-safe bounds"); + } + } + AnswerType::ReviewedStructuredValue => { + if !answer.values.is_empty() || answer.minimum.is_some() || answer.maximum.is_some() { + bail!("a reviewed structured answer must not declare scalar constraints"); + } + let schema = answer + .schema + .as_deref() + .ok_or_else(|| anyhow!("a reviewed structured answer requires schema"))?; + validate_answer_schema_path(schema)?; + if !matches!(answer.maximum_serialized_bytes, Some(1..=65_536)) { + bail!("a reviewed structured answer requires maximumSerializedBytes in 1..=65536"); + } + if let Some(projection) = &answer.sd_jwt_vc { + validate_sd_jwt_claim_name(&projection.claim)?; + } + } + } + Ok(()) +} + +fn validate_answer_schema_path(value: &str) -> Result<()> { + let path = Path::new(value); + let components = path.components().collect::>(); + if components.len() != 2 + || components.first() != Some(&Component::Normal(SCHEMAS_DIRECTORY.as_ref())) + || !matches!(components.get(1), Some(Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("yaml") + { + bail!("answer schema must be one schemas/.yaml file"); + } + Ok(()) +} + +fn validate_sd_jwt_claim_name(value: &str) -> Result<()> { + const RESERVED: [&str; 24] = [ + "iss", + "sub", + "aud", + "iat", + "nbf", + "exp", + "vct", + "id", + "jti", + "_sd", + "_sd_alg", + "cnf", + "status", + "issuedBy", + "providedBy", + "supportsRequirement", + "purpose", + "audience", + "assuranceProfile", + "observedAt", + "configurationRevision", + "requestNonce", + "subjects", + "structuredValues", + ]; + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 64 + || !matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z')) + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + || RESERVED.contains(&value) + { + bail!("sdJwtVc.claim must be a bounded JSON claim name"); + } + Ok(()) +} + +fn valid_local_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 64 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn valid_field_name(value: &str) -> bool { + valid_local_identifier(value) +} + +fn validate_openapi_version(document: &Value) -> Result<()> { + let version = document + .get("openapi") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("retained document has no OpenAPI version"))?; + if !(version.starts_with("3.0.") || version.starts_with("3.1.")) { + bail!("only OpenAPI 3.0.x and 3.1.x are supported"); + } + Ok(()) +} + +fn compile_plan(inputs: Inputs, profile: CompileProfile) -> Result { + let has_inline_source = inputs + .questions + .iter() + .any(|authored| authored.question.source.source_ref.is_none()); + let (spec, base_url) = if has_inline_source { + reject_unsupported_keys( + inputs + .openapi + .as_object() + .ok_or_else(|| anyhow!("retained OpenAPI document must be an object"))?, + &["openapi", "info", "servers", "paths", "components"], + "OpenAPI document", + )?; + if inputs.openapi.get("security").is_some() { + bail!("the local tutorial source must omit top-level OpenAPI security"); + } + ( + Some(Spec::from_value( + inputs.openapi.clone(), + "retained OpenAPI document", + )?), + Some(exact_loopback_server(&inputs.openapi)?), + ) + } else { + (None, None) + }; + let mut questions = Vec::with_capacity(inputs.questions.len()); + for authored in inputs.questions { + questions.push(compile_question_plan( + &inputs.openapi, + spec.as_ref(), + base_url.as_deref(), + &inputs.selectors, + &inputs.sources, + &inputs.schemas, + authored, + )?); + } + let access_policies = inputs.access_policies; + let bundle = match profile { + CompileProfile::Local(ports) => render_local_bundle(&questions, &access_policies, ports), + CompileProfile::Production(governance) => render_production_bundle(&questions, governance)?, + }; + Ok(CompilePlan { + questions, + access_policies, + bundle, + }) +} + +fn compile_question_plan( + openapi: &Value, + spec: Option<&Spec>, + base_url: Option<&str>, + selectors: &BTreeMap, + sources: &BTreeMap, + schemas: &BTreeMap, + authored: AuthoredQuestion, +) -> Result { + let question = &authored.question; + if question.source.source_ref.is_some() { + return compile_referenced_question(selectors, sources, schemas, authored); + } + let authored_subjects = question_subjects(question)?; + if authored_subjects + .iter() + .any(|subject| subject.profile.is_some()) + { + bail!("an OpenAPI question derives its local selector profiles"); + } + let operation_id = question + .source + .operation + .as_deref() + .expect("inline source was validated"); + let operation = unique_operation(openapi, operation_id)?; + if operation.operation.get("security").is_some() { + bail!("the local tutorial operation must omit OpenAPI security"); + } + if operation.operation.get("requestBody").is_some() { + bail!("the local tutorial GET operation must not declare a request body"); + } + if operation.operation.get("servers").is_some() || operation.path_item.get("servers").is_some() + { + bail!("the local tutorial operation must use the document's one server"); + } + reject_unsupported_keys( + operation.path_item, + &["get", "parameters"], + "selected OpenAPI path item", + )?; + reject_unsupported_keys( + operation.operation, + &["operationId", "parameters", "responses"], + "selected OpenAPI operation", + )?; + + exact_path_selectors( + &operation, + &authored_subjects + .iter() + .map(|subject| subject.selector.as_str()) + .collect::>(), + )?; + let compiled_facts = compile_facts( + spec.expect("inline source needs parsed OpenAPI"), + &operation, + &question.source, + )?; + let requirement_uri = question + .governance + .as_ref() + .map(|governance| governance.requirement.clone()) + .unwrap_or_else(|| local_uri(&format!("requirement:{}", question.id))); + let concepts = question + .answers + .iter() + .map(|answer| compile_concept(&question.id, answer, schemas)) + .collect::>>()?; + let requirement_kind = question + .governance + .as_ref() + .map(|governance| governance.kind.as_str()) + .unwrap_or_else(|| { + if concepts.len() == 1 && concepts[0].concept_form == CompiledConceptForm::Boolean { + "criterion" + } else { + "information-requirement" + } + }); + + let response_schema = compiled_facts.response_schema; + let fact_schema = compiled_facts.fact_schema; + let adapter_parameters_schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["operationId"], + "properties": { + "operationId": {"type": "string", "const": operation_id} + } + }); + + let prepare_script = + "fn prepare(selectors, parameters) {\n #{query: [], body: ()}\n}\n".to_owned(); + let extract_script = compiled_facts.extract_script; + let derivation_script = render_derivation(&authored.derivation, &concepts); + let subjects = authored_subjects + .iter() + .map(|authored_subject| { + let selector_profile = local_subject_selector_profile_id( + &question.id, + &authored_subject.role, + authored_subjects.len(), + ); + SubjectPlan { + role: authored_subject.role.clone(), + selector_field: authored_subject.selector.clone(), + selector_profile, + selector_profile_value: json!({ + "maximumAggregateBytes": 200, + "fields": { + authored_subject.selector.clone(): { + "type": "string", + "minimumBytes": 1, + "maximumBytes": 200, + } + }, + }), + derivation: authored_subject.derivation, + } + }) + .collect::>(); + let source_id = local_source_id(&question.id); + let (source_value, grant, requirement) = render_question_bundle_parts( + question, + base_url.expect("inline source needs local base URL"), + operation.path, + &subjects, + &source_id, + &BundleRequirement { + requirement_uri: requirement_uri.clone(), + kind: requirement_kind, + concepts: &concepts, + }, + ); + Ok(QuestionPlan { + question_id: question.id.clone(), + source_artifact_id: question.id.clone(), + authored_source_artifacts: None, + derivation_artifact: question.derivation.clone(), + fixture_artifact: question + .governance + .as_ref() + .map(|governance| governance.fixtures.clone()), + purpose: question.purpose.clone(), + requirement_uri, + concepts, + subjects, + source_id, + source_value, + grant, + requirement, + response_schema, + fact_schema, + adapter_parameters_schema, + prepare_script, + extract_script, + derivation_script, + }) +} + +fn compile_referenced_question( + selectors: &BTreeMap, + sources: &BTreeMap, + schemas: &BTreeMap, + authored: AuthoredQuestion, +) -> Result { + let question = &authored.question; + let source_id = question + .source + .source_ref + .as_deref() + .expect("referenced source was validated"); + let source_value = sources + .get(source_id) + .ok_or_else(|| { + anyhow!("question source ref `{source_id}` has no sources/{source_id}.yaml") + })? + .clone(); + let subjects = compile_referenced_subjects(question, &source_value, selectors)?; + + let requirement_uri = question + .governance + .as_ref() + .map(|governance| governance.requirement.clone()) + .unwrap_or_else(|| local_uri(&format!("requirement:{}", question.id))); + let concepts = question + .answers + .iter() + .map(|answer| compile_concept(&question.id, answer, schemas)) + .collect::>>()?; + let requirement_kind = question + .governance + .as_ref() + .map(|governance| governance.kind.as_str()) + .unwrap_or_else(|| { + if concepts.len() == 1 && concepts[0].concept_form == CompiledConceptForm::Boolean { + "criterion" + } else { + "information-requirement" + } + }); + let (grant, requirement) = render_governance_parts( + question, + &subjects, + source_id, + &BundleRequirement { + requirement_uri: requirement_uri.clone(), + kind: requirement_kind, + concepts: &concepts, + }, + ); + let derivation_script = render_derivation(&authored.derivation, &concepts); + + Ok(QuestionPlan { + question_id: question.id.clone(), + source_artifact_id: source_id.to_owned(), + authored_source_artifacts: Some(referenced_source_artifacts(&source_value)?), + derivation_artifact: question.derivation.clone(), + fixture_artifact: question + .governance + .as_ref() + .map(|governance| governance.fixtures.clone()), + purpose: question.purpose.clone(), + requirement_uri, + concepts, + subjects, + source_id: source_id.to_owned(), + source_value, + grant, + requirement, + response_schema: Value::Null, + fact_schema: Value::Null, + adapter_parameters_schema: Value::Null, + prepare_script: String::new(), + extract_script: String::new(), + derivation_script, + }) +} + +fn compile_referenced_subjects( + question: &Question, + source: &Value, + selectors: &BTreeMap, +) -> Result> { + let authored = question_subjects(question)?; + let mut compiled = Vec::with_capacity(authored.len()); + for subject in authored { + let selector_profile = match &subject.profile { + Some(profile) => profile.clone(), + None => referenced_selector_profile(source, &subject.role, &subject.selector)?, + }; + let selector_profile_value = selectors + .get(&selector_profile) + .ok_or_else(|| { + anyhow!("referenced source question uses missing selectors/{selector_profile}.yaml") + })? + .clone(); + let selector_fields = selector_profile_value + .get("fields") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("selector profile `{selector_profile}` has no fields object"))?; + if !selector_fields.contains_key(&subject.selector) { + bail!( + "selector profile `{selector_profile}` does not declare the question subject field" + ); + } + let used_by_source = + source_uses_subject(source, &subject.role, &selector_profile, &subject.selector)?; + if !used_by_source && !subject.derivation { + bail!("every question subject must be used by the source or declared for derivation"); + } + compiled.push(SubjectPlan { + role: subject.role.clone(), + selector_field: subject.selector.clone(), + selector_profile, + selector_profile_value, + derivation: subject.derivation, + }); + } + + let inputs = source + .pointer("/request/selectorInputs") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("referenced source request must declare selectorInputs"))?; + for input in inputs { + let role = input + .get("role") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("source selector input has no role"))?; + let matches = compiled + .iter() + .filter(|subject| { + subject.role == role + && source_uses_subject( + source, + role, + &subject.selector_profile, + &subject.selector_field, + ) + .unwrap_or(false) + }) + .count(); + if matches != 1 { + bail!("question subjects must select exactly one alternative for every source role"); + } + } + Ok(compiled) +} + +fn source_uses_subject(source: &Value, role: &str, profile: &str, field: &str) -> Result { + let inputs = source + .pointer("/request/selectorInputs") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("referenced source request must declare selectorInputs"))?; + Ok(inputs.iter().any(|input| { + input.get("role").and_then(Value::as_str) == Some(role) + && input + .get("alternatives") + .and_then(Value::as_array) + .is_some_and(|alternatives| { + alternatives.iter().any(|alternative| { + alternative.get("profile").and_then(Value::as_str) == Some(profile) + && alternative + .get("fields") + .and_then(Value::as_array) + .is_some_and(|fields| { + fields.len() == 1 && fields[0].as_str() == Some(field) + }) + }) + }) + })) +} + +fn referenced_selector_profile(source: &Value, role: &str, field: &str) -> Result { + let inputs = source + .pointer("/request/selectorInputs") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("referenced source request must declare selectorInputs"))?; + let mut matches = Vec::new(); + for input in inputs { + if input.get("role").and_then(Value::as_str) != Some(role) { + continue; + } + let alternatives = input + .get("alternatives") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("source selector alternatives must be an array"))?; + for alternative in alternatives { + let fields = alternative + .get("fields") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("source selector fields must be an array"))?; + if fields.iter().any(|value| value.as_str() == Some(field)) { + matches.push( + alternative + .get("profile") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("source selector alternative has no profile"))? + .to_owned(), + ); + } + } + } + if matches.len() != 1 { + bail!("question subject must match exactly one referenced source selector alternative"); + } + Ok(matches.pop().expect("one selector profile")) +} + +fn referenced_source_artifacts(source: &Value) -> Result> { + [ + "/request/prepareScript", + "/request/adapterParametersSchema", + "/responseSchema", + "/extractScript", + "/factSchema", + ] + .iter() + .map(|pointer| { + let path = source + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("referenced source is missing `{pointer}`"))?; + validate_bundle_relative_artifact(path)?; + Ok(path.to_owned()) + }) + .collect() +} + +fn validate_bundle_relative_artifact(value: &str) -> Result<()> { + let path = Path::new(value); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.components().count() != 2 + || !matches!( + path.components().next(), + Some(Component::Normal(directory)) + if directory == "adapters" || directory == "schemas" + ) + { + bail!("referenced source artifacts must be adapters/ or schemas/"); + } + Ok(()) +} + +fn compile_concept( + question_id: &str, + answer: &QuestionAnswer, + schemas: &BTreeMap, +) -> Result { + let concept_uri = answer + .id + .clone() + .unwrap_or_else(|| local_uri(&format!("concept:{question_id}:{}", answer.concept))); + Ok(match answer.answer_type { + AnswerType::Boolean => ConceptPlan { + concept_alias: answer.concept.clone(), + concept_uri, + concept_form: CompiledConceptForm::Boolean, + constraints: json!({}), + codelist: None, + schema: None, + sd_jwt_vc: None, + }, + AnswerType::ControlledCategory => { + let scheme = answer.id.as_ref().map_or_else( + || local_uri(&format!("category-scheme:{question_id}:{}", answer.concept)), + // Version 1 requires a distinct category-scheme identifier, + // while the compact production question contract authors + // only the stable concept identifier. This deterministic + // suffix does not invent a requirement, framework, Evidence + // Type, concept, or disclosure-family URI. + |identifier| format!("{identifier}:categories"), + ); + let path = format!("codelists/{question_id}-{}.yaml", answer.concept); + let maximum_bytes = answer + .values + .iter() + .map(String::len) + .max() + .expect("controlled categories were validated"); + ConceptPlan { + concept_alias: answer.concept.clone(), + concept_uri, + concept_form: CompiledConceptForm::ControlledCategory, + constraints: json!({ + "categoryScheme": scheme, + "schemeVersion": "1", + "maximumBytes": maximum_bytes, + "codelist": path, + }), + codelist: Some(( + path, + json!({ + "id": scheme, + "version": "1", + "codes": answer.values, + }), + )), + schema: None, + sd_jwt_vc: None, + } + } + AnswerType::BoundedInteger => ConceptPlan { + concept_alias: answer.concept.clone(), + concept_uri, + concept_form: CompiledConceptForm::BoundedInteger, + constraints: json!({ + "minimum": answer.minimum.expect("bounded integer was validated"), + "maximum": answer.maximum.expect("bounded integer was validated"), + }), + codelist: None, + schema: None, + sd_jwt_vc: None, + }, + AnswerType::ReviewedStructuredValue => { + let path = answer + .schema + .as_deref() + .expect("structured answer was validated"); + let key = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| anyhow!("answer schema filename is not valid UTF-8"))?; + let schema = schemas + .get(key) + .cloned() + .ok_or_else(|| anyhow!("answer schema `{path}` does not exist"))?; + let schema_id = schema + .get("$id") + .and_then(Value::as_str) + .filter(|value| url::Url::parse(value).is_ok()) + .ok_or_else(|| anyhow!("answer schema `{path}` requires an absolute `$id`"))? + .to_owned(); + ConceptPlan { + concept_alias: answer.concept.clone(), + concept_uri, + concept_form: CompiledConceptForm::Structured, + constraints: json!({ + "schema": schema_id, + "maximumSerializedBytes": answer + .maximum_serialized_bytes + .expect("structured answer was validated"), + }), + codelist: None, + schema: Some((path.to_owned(), schema)), + sd_jwt_vc: answer.sd_jwt_vc.as_ref().map(|projection| { + json!({ + "claim": projection.claim, + "disclosure": match projection.disclosure { + QuestionSdJwtVcDisclosure::TopLevel => "top-level", + }, + }) + }), + } + } + }) +} + +fn exact_loopback_server(document: &Value) -> Result { + let servers = document + .get("servers") + .and_then(Value::as_array) + .filter(|servers| servers.len() == 1) + .ok_or_else(|| anyhow!("OpenAPI must declare exactly one local server"))?; + let server = servers[0] + .as_object() + .filter(|server| server.len() == 1) + .ok_or_else(|| anyhow!("the local OpenAPI server must contain only its fixed URL"))?; + let value = server + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("the local OpenAPI server URL is missing"))?; + let url = Url::parse(value).context("parsing the local OpenAPI server URL")?; + let port = url + .port() + .filter(|port| *port != 0) + .ok_or_else(|| anyhow!("the local OpenAPI server needs an explicit non-zero port"))?; + if url.scheme() != "http" + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + bail!("the local OpenAPI server must be one canonical HTTP loopback origin"); + } + let canonical = match url.host() { + Some(Host::Ipv4(address)) if address.is_loopback() => { + format!("http://{address}:{port}") + } + Some(Host::Ipv6(address)) if address == std::net::Ipv6Addr::LOCALHOST => { + format!("http://[{address}]:{port}") + } + _ => bail!("the local OpenAPI server must use a numeric loopback address"), + }; + if canonical != value { + bail!("the local OpenAPI server must use its exact canonical origin spelling"); + } + Ok(canonical) +} + +fn unique_operation<'a>(document: &'a Value, operation_id: &str) -> Result> { + const METHODS: [&str; 8] = [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", + ]; + let paths = document + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("OpenAPI paths must be an object"))?; + let mut matches = Vec::new(); + for (path, item) in paths { + let item = item + .as_object() + .ok_or_else(|| anyhow!("OpenAPI path item `{path}` must be an object"))?; + if item.contains_key("$ref") { + bail!("OpenAPI path-item references are outside the local tutorial subset"); + } + for method in METHODS { + let Some(operation) = item.get(method) else { + continue; + }; + let operation = operation + .as_object() + .ok_or_else(|| anyhow!("OpenAPI operation `{method} {path}` must be an object"))?; + if operation.get("operationId").and_then(Value::as_str) == Some(operation_id) { + matches.push(Operation { + method, + path, + path_item: item, + operation, + }); + } + } + } + if matches.len() != 1 { + bail!("source.operation must resolve to exactly one OpenAPI operationId"); + } + let operation = matches.pop().expect("one operation"); + if operation.method != "get" { + bail!("the local tutorial source supports only one resolved GET operationId"); + } + Ok(operation) +} + +fn exact_path_selectors(operation: &Operation<'_>, expected: &[&str]) -> Result<()> { + let mut parameters = Vec::new(); + for owner in [operation.path_item, operation.operation] { + if let Some(values) = owner.get("parameters") { + let values = values + .as_array() + .ok_or_else(|| anyhow!("OpenAPI parameters must be an array"))?; + parameters.extend(values); + } + } + if parameters.len() != expected.len() { + bail!("the local tutorial operation must declare exactly one path selector per subject"); + } + let expected = expected.iter().copied().collect::>(); + if expected.len() != parameters.len() { + bail!("each OpenAPI question subject must use a distinct path selector"); + } + let mut actual = BTreeSet::new(); + for parameter in parameters { + let parameter = parameter + .as_object() + .ok_or_else(|| anyhow!("the path selector must be an object"))?; + reject_unsupported_keys( + parameter, + &["name", "in", "required", "schema"], + "path selector", + )?; + let parameter_schema = parameter + .get("schema") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("the path selector schema must be an object"))?; + reject_unsupported_keys(parameter_schema, &["type"], "path selector schema")?; + let name = parameter + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("the path selector must have a name"))?; + if parameter.contains_key("$ref") + || parameter.get("in").and_then(Value::as_str) != Some("path") + || parameter.get("required").and_then(Value::as_bool) != Some(true) + || parameter_schema.get("type").and_then(Value::as_str) != Some("string") + || !actual.insert(name) + { + bail!("question selectors must equal the operation's required string path parameters"); + } + } + if actual != expected { + bail!("question selectors must equal the operation's required string path parameters"); + } + if !operation.path.starts_with('/') + || operation.path.starts_with("//") + || operation.path.contains(['?', '#', '\\']) + || operation + .path + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + || expected.iter().any(|name| { + let placeholder = format!("{{{name}}}"); + operation + .path + .split('/') + .filter(|segment| *segment == placeholder) + .count() + != 1 + }) + || operation.path.matches('{').count() != expected.len() + || operation.path.matches('}').count() != expected.len() + { + bail!("each path selector must occupy exactly one complete path segment"); + } + Ok(()) +} + +fn compile_facts( + spec: &Spec, + operation: &Operation<'_>, + source: &QuestionSource, +) -> Result { + let operation_key = OperationKey { + method: operation.method.to_ascii_uppercase(), + path: operation.path.to_owned(), + }; + let resolved = spec.response_schema(&operation_key, "200", "application/json")?; + for fact in &source.facts { + validate_selected_schema_path(&resolved.schema.0, &fact.path)?; + } + let (candidate_leaves, _) = crate::suggest::flatten::candidate_leaves(&resolved.schema); + let offered = candidate_leaves + .iter() + .map(|leaf| leaf.pointer.as_str()) + .collect::>(); + for fact in &source.facts { + if !offered.contains(fact.path.as_str()) { + bail!( + "source fact `{}` path `{}` is not a selectable scalar leaf in the 200 application/json response", + fact.name, + fact.path + ); + } + } + + let projection = source + .facts + .iter() + .map(|fact| fact.path.clone()) + .collect::>(); + let used_collections = source + .facts + .iter() + .flat_map(|fact| collection_pointers(&fact.path)) + .collect::>(); + let declared_collections = source + .collection_bounds + .keys() + .cloned() + .collect::>(); + if used_collections != declared_collections { + let missing = used_collections + .difference(&declared_collections) + .cloned() + .collect::>(); + let unused = declared_collections + .difference(&used_collections) + .cloned() + .collect::>(); + bail!( + "source.collectionBounds must exactly name every selected collection (missing: {}; unused: {})", + display_list(&missing), + display_list(&unused) + ); + } + + let plan = narrow::plan_advisories( + &resolved.schema, + &projection, + &crate::suggest::types::Observations::default(), + )?; + if let Some(advisory) = plan.advisories.first() { + bail!( + "selected source schema needs adopter review: {}", + advisory.message() + ); + } + let mut resolutions = BTreeMap::new(); + for need in &plan.needs { + match need.kind { + BoundKind::ArrayMaxItems => { + let maximum = source.collection_bounds.get(&need.pointer).ok_or_else(|| { + anyhow!( + "selected collection `{}` is unbounded; declare it in source.collectionBounds", + need.pointer + ) + })?; + resolutions.insert( + (need.pointer.clone(), BoundKind::ArrayMaxItems), + BoundValues::MaxItems(*maximum), + ); + } + BoundKind::IntegerRange | BoundKind::StringLength => bail!( + "selected fact schema at `{}` is unbounded; add its closed bounds to the retained OpenAPI document", + need.pointer + ), + } + } + let mut response_schema = narrow::apply(&resolved.schema, &projection, &resolutions)?.schema; + close_selected_response(&mut response_schema, &projection, &source.collection_bounds)?; + + let mut fact_properties = Map::new(); + for fact in &source.facts { + let leaf = schema_at_extended_pointer(&response_schema, &fact.path)?.clone(); + let property = match fact.combine { + FactCombination::ExactlyOne => leaf, + FactCombination::Collect => { + let maximum = collection_pointers(&fact.path) + .iter() + .try_fold(1_u64, |product, pointer| { + product.checked_mul(source.collection_bounds[pointer]) + }) + .ok_or_else(|| { + anyhow!("source fact `{}` collection bound overflows", fact.name) + })?; + if maximum > 256 { + bail!( + "source fact `{}` can collect {maximum} values; reduce collection bounds so the product is at most 256", + fact.name + ); + } + json!({ + "type": "array", + "minItems": 1, + "maxItems": maximum, + "items": leaf, + }) + } + }; + fact_properties.insert(fact.name.clone(), property); + } + let required = source + .facts + .iter() + .map(|fact| Value::String(fact.name.clone())) + .collect::>(); + let fact_schema = json!({ + "type": "object", + "additionalProperties": false, + "required": required, + "properties": fact_properties, + }); + + Ok(CompiledFacts { + response_schema, + fact_schema, + extract_script: render_fact_extraction(&source.facts), + }) +} + +fn validate_selected_schema_path(schema: &Value, pointer: &str) -> Result<()> { + let segments = parse_extended_pointer(pointer)?; + validate_selected_schema_node(schema, &segments, "") +} + +fn validate_selected_schema_node( + node: &Value, + segments: &[ExtendedSegment], + pointer: &str, +) -> Result<()> { + let object = node + .as_object() + .ok_or_else(|| anyhow!("selected OpenAPI schema node at `{pointer}` is not an object"))?; + let primary_type = match object.get("type") { + Some(Value::String(value)) => value.as_str(), + Some(Value::Array(values)) => values + .iter() + .filter_map(Value::as_str) + .find(|value| *value != "null") + .ok_or_else(|| { + anyhow!("selected OpenAPI schema node at `{pointer}` has no value type") + })?, + _ => bail!("selected OpenAPI schema node at `{pointer}` has no closed type"), + }; + let mut allowed = vec![ + "type", + "description", + "title", + "deprecated", + "readOnly", + "writeOnly", + "example", + "examples", + ]; + allowed.extend(match primary_type { + "object" => &["properties", "required", "additionalProperties"][..], + "array" => &["items", "minItems", "maxItems", "uniqueItems", "const"][..], + "string" => &["format", "minLength", "maxLength", "enum", "const"][..], + "integer" => &["minimum", "maximum", "enum", "const"][..], + "boolean" => &["enum", "const"][..], + other => { + bail!("selected OpenAPI schema node at `{pointer}` has unsupported type `{other}`") + } + }); + reject_unsupported_keys( + object, + &allowed, + &format!("selected OpenAPI schema at `{pointer}`"), + )?; + + let Some((segment, rest)) = segments.split_first() else { + return Ok(()); + }; + match (primary_type, segment) { + ("object", ExtendedSegment::Key(key)) => { + let child = object + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(key)) + .ok_or_else(|| { + anyhow!("selected OpenAPI schema at `{pointer}` has no member `{key}`") + })?; + validate_selected_schema_node( + child, + rest, + &format!("{pointer}/{}", escape_pointer_segment(key)), + ) + } + ("array", ExtendedSegment::Wildcard) => { + let child = object + .get("items") + .ok_or_else(|| anyhow!("selected OpenAPI array at `{pointer}` has no items"))?; + validate_selected_schema_node(child, rest, &format!("{pointer}/*")) + } + ("object", ExtendedSegment::Wildcard) => { + bail!("selected fact path uses `*` at object `{pointer}`") + } + ("array", ExtendedSegment::Key(_)) => { + bail!("selected fact path must use `*` at array `{pointer}`") + } + _ => bail!("selected fact path continues past scalar `{pointer}`"), + } +} + +fn collection_pointers(pointer: &str) -> Vec { + let mut parts = Vec::new(); + let mut collections = Vec::new(); + for segment in pointer.split('/').skip(1) { + if segment == "*" { + collections.push(format!("/{}", parts.join("/"))); + } + parts.push(segment); + } + collections +} + +fn display_list(values: &[String]) -> String { + if values.is_empty() { + "none".to_owned() + } else { + values.join(", ") + } +} + +fn close_selected_response( + schema: &mut Value, + selections: &[String], + collection_bounds: &BTreeMap, +) -> Result<()> { + for selection in selections { + let segments = parse_extended_pointer(selection)?; + close_selected_path(schema, &segments, "", collection_bounds)?; + } + Ok(()) +} + +#[derive(Clone, Debug)] +enum ExtendedSegment { + Key(String), + Wildcard, +} + +fn parse_extended_pointer(pointer: &str) -> Result> { + if pointer.is_empty() || !pointer.starts_with('/') { + bail!("source fact path must be a non-empty extended JSON Pointer"); + } + pointer + .split('/') + .skip(1) + .map(|segment| { + if segment == "*" { + Ok(ExtendedSegment::Wildcard) + } else { + decode_pointer_segment(segment).map(ExtendedSegment::Key) + } + }) + .collect() +} + +fn decode_pointer_segment(segment: &str) -> Result { + let mut decoded = String::new(); + let mut characters = segment.chars(); + while let Some(character) = characters.next() { + if character != '~' { + decoded.push(character); + continue; + } + match characters.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => bail!("source fact path contains an invalid JSON Pointer escape"), + } + } + Ok(decoded) +} + +fn close_selected_path( + node: &mut Value, + segments: &[ExtendedSegment], + pointer: &str, + collection_bounds: &BTreeMap, +) -> Result<()> { + make_non_nullable(node); + let Some((segment, rest)) = segments.split_first() else { + return Ok(()); + }; + let object = node.as_object_mut().ok_or_else(|| { + anyhow!("selected response path crosses a non-schema node at `{pointer}`") + })?; + match segment { + ExtendedSegment::Key(key) => { + let required = object + .get_mut("required") + .and_then(Value::as_array_mut) + .ok_or_else(|| { + anyhow!("selected response object at `{pointer}` has no required list") + })?; + if !required.iter().any(|value| value.as_str() == Some(key)) { + required.push(Value::String(key.clone())); + } + let properties = object + .get_mut("properties") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + anyhow!("selected response path expects an object at `{pointer}`") + })?; + let child = properties + .get_mut(key) + .ok_or_else(|| anyhow!("selected response path does not declare `{key}`"))?; + let child_pointer = format!("{pointer}/{}", escape_pointer_segment(key)); + close_selected_path(child, rest, &child_pointer, collection_bounds) + } + ExtendedSegment::Wildcard => { + let maximum = collection_bounds.get(pointer).ok_or_else(|| { + anyhow!("selected response collection `{pointer}` has no authored bound") + })?; + let minimum = object.get("minItems").and_then(Value::as_u64).unwrap_or(0); + if minimum > *maximum { + bail!( + "source.collectionBounds sets `{pointer}` to {maximum}, below its declared minItems {minimum}" + ); + } + object.insert("minItems".to_owned(), Value::from(minimum.max(1))); + object.insert("maxItems".to_owned(), Value::from(*maximum)); + let items = object.get_mut("items").ok_or_else(|| { + anyhow!("selected response collection `{pointer}` has no items schema") + })?; + close_selected_path(items, rest, &format!("{pointer}/*"), collection_bounds) + } + } +} + +fn make_non_nullable(node: &mut Value) { + let Some(object) = node.as_object_mut() else { + return; + }; + let Some(Value::Array(types)) = object.get("type") else { + return; + }; + if types.len() == 2 && types.iter().any(|value| value.as_str() == Some("null")) { + if let Some(value_type) = types.iter().find(|value| value.as_str() != Some("null")) { + object.insert("type".to_owned(), value_type.clone()); + } + } +} + +fn schema_at_extended_pointer<'a>(schema: &'a Value, pointer: &str) -> Result<&'a Value> { + let mut node = schema; + for segment in parse_extended_pointer(pointer)? { + node = match segment { + ExtendedSegment::Key(key) => node + .get("properties") + .and_then(|properties| properties.get(&key)) + .ok_or_else(|| anyhow!("generated response schema lost selected member `{key}`"))?, + ExtendedSegment::Wildcard => node.get("items").ok_or_else(|| { + anyhow!("generated response schema lost selected collection items") + })?, + }; + } + Ok(node) +} + +fn render_fact_extraction(facts: &[QuestionFact]) -> String { + let mut rendered = + String::from("fn extract(source_response, parameters) {\n let facts = #{};\n"); + for (index, fact) in facts.iter().enumerate() { + let name = json_string(&fact.name); + match fact.combine { + FactCombination::ExactlyOne => rendered.push_str(&format!( + " facts[{name}] = required(get_path(source_response, {}), \"source_fact_missing\");\n", + json_string(&fact.path) + )), + FactCombination::Collect => { + rendered.push_str(&format!(" let collected_{index} = [];\n")); + render_collection_walk(&mut rendered, index, &fact.path); + rendered.push_str(&format!(" facts[{name}] = collected_{index};\n")); + } + } + } + rendered.push_str(" #{outcome: \"match\", facts: facts}\n}\n"); + rendered +} + +fn render_collection_walk(rendered: &mut String, fact_index: usize, pointer: &str) { + let segments = pointer.split('/').skip(1).collect::>(); + let wildcard_count = segments.iter().filter(|segment| **segment == "*").count(); + let mut cursor = "source_response".to_owned(); + let mut start = 0; + let mut depth = 0; + for (position, segment) in segments.iter().enumerate() { + if *segment != "*" { + continue; + } + let relative = format!("/{}", segments[start..position].join("/")); + let items = format!("items_{fact_index}_{depth}"); + let item = format!("item_{fact_index}_{depth}"); + let indent = " ".repeat(depth + 1); + let collection = if relative == "/" { + cursor.clone() + } else { + format!("get_path({cursor}, {})", json_string(&relative)) + }; + rendered.push_str(&format!( + "{indent}let {items} = required({collection}, \"source_collection_missing\");\n" + )); + rendered.push_str(&format!("{indent}for {item} in {items} {{\n")); + cursor = item; + start = position + 1; + depth += 1; + } + debug_assert_eq!(depth, wildcard_count); + let tail = segments[start..].join("/"); + let indent = " ".repeat(depth + 1); + let value = if tail.is_empty() { + cursor + } else { + format!("get_path({cursor}, {})", json_string(&format!("/{tail}"))) + }; + rendered.push_str(&format!( + "{indent}collected_{fact_index}.push(required({value}, \"source_fact_missing\"));\n" + )); + for closing_depth in (0..depth).rev() { + rendered.push_str(&format!("{}}}\n", " ".repeat(closing_depth + 1))); + } +} + +fn reject_unsupported_keys( + object: &Map, + allowed: &[&str], + description: &str, +) -> Result<()> { + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + bail!("{description} contains unsupported key `{key}`"); + } + Ok(()) +} + +fn render_derivation(authored: &str, concepts: &[ConceptPlan]) -> String { + let mut rendered = authored.trim_end().to_owned(); + rendered.push_str("\n\n"); + rendered.push_str("fn derive(facts, selectors, evaluation_context) {\n"); + rendered.push_str(" let governed_answers = answer(facts, selectors, evaluation_context);\n"); + rendered.push_str(" [\n"); + for (index, concept) in concepts.iter().enumerate() { + rendered.push_str(" #{\n"); + rendered.push_str(&format!( + " concept_id: {},\n", + json_string(&concept.concept_uri) + )); + rendered.push_str(&format!( + " value: governed_answers[{}]\n", + json_string(&concept.concept_alias) + )); + rendered.push_str(" }"); + if index + 1 != concepts.len() { + rendered.push(','); + } + rendered.push('\n'); + } + rendered.push_str(" ]\n}\n"); + rendered +} + +fn render_question_bundle_parts( + question: &Question, + base_url: &str, + path_template: &str, + subjects: &[SubjectPlan], + source_id: &str, + requirement: &BundleRequirement, +) -> (Value, Value, Value) { + let path_bindings = Value::Object(Map::from_iter(subjects.iter().map(|subject| { + ( + subject.selector_field.clone(), + json!({ + "role": subject.role, + "profile": subject.selector_profile, + "field": subject.selector_field, + }), + ) + }))); + let selector_inputs = subjects + .iter() + .map(|subject| { + json!({ + "role": subject.role, + "alternatives": [{ + "profile": subject.selector_profile, + "fields": [subject.selector_field], + }], + }) + }) + .collect::>(); + let projection = question + .source + .facts + .iter() + .map(|fact| Value::String(fact.path.clone())) + .collect::>(); + + let source_value = json!({ + "transport": "http-json", + "baseUrl": base_url, + "posture": "field-projected", + "authentication": {"kind": "none"}, + "request": { + "method": "GET", + "pathTemplate": path_template, + "pathBindings": path_bindings, + "fixedHeaders": [{"name": "Accept", "value": "application/json"}], + "selectorInputs": selector_inputs, + "prepareScript": format!("adapters/{}-source-prepare.rhai", question.id), + "adapterParameters": {"operationId": question.source.operation.as_deref().expect("inline source")}, + "adapterParametersSchema": format!( + "schemas/{}-source-adapter-parameters.schema.yaml", + question.id + ), + "preparationLimits": { + "query": "allowed", + "jsonBody": "forbidden", + "maximumNormalizedBytes": 4096, + }, + "projection": projection, + "redirects": "deny", + "timeoutMilliseconds": 3000, + "maximumResponseBytes": 65536, + "concurrencyLimit": 8, + }, + "responseSchema": format!("schemas/{}-source-response.schema.yaml", question.id), + "extractScript": format!("adapters/{}-source-extract.rhai", question.id), + "factSchema": format!("schemas/{}-source-facts.schema.yaml", question.id), + }); + let (grant, requirement_value) = + render_governance_parts(question, subjects, source_id, requirement); + (source_value, grant, requirement_value) +} + +fn render_governance_parts( + question: &Question, + subjects: &[SubjectPlan], + source_id: &str, + requirement: &BundleRequirement<'_>, +) -> (Value, Value) { + let (reference_frameworks, evidence_type, observation_timezone, validity_seconds, families) = + match &question.governance { + Some(governance) => ( + governance.reference_frameworks.clone(), + governance.evidence_type.clone(), + governance.observation_timezone.clone(), + governance.validity_seconds, + governance.disclosure_families.clone(), + ), + None => ( + vec![local_uri(&format!("framework:{}", question.id))], + local_uri(&format!("evidence-type:{}", question.id)), + "UTC".to_owned(), + 300, + vec![local_uri(&format!("disclosure-family:{}", question.id))], + ), + }; + let grant_subjects = subjects + .iter() + .map(|subject| { + json!({ + "role": subject.role, + "selectorProfile": subject.selector_profile, + "valueOrigin": "request", + }) + }) + .collect::>(); + let response_formats = if requirement + .concepts + .iter() + .any(|concept| concept.sd_jwt_vc.is_some()) + { + json!(["signed-jws", "sd-jwt-vc"]) + } else { + json!(["signed-jws"]) + }; + let grant = json!({ + "requirement": requirement.requirement_uri, + "purpose": question.purpose, + "audienceFrom": "authenticated-requester", + "responseFormats": response_formats, + "subjects": grant_subjects, + }); + let concepts = requirement + .concepts + .iter() + .map(|concept| { + let mut rendered = json!({ + "id": concept.concept_uri, + "form": match concept.concept_form { + CompiledConceptForm::Boolean => "boolean", + CompiledConceptForm::ControlledCategory => "controlled-category", + CompiledConceptForm::BoundedInteger => "bounded-integer", + CompiledConceptForm::Structured => "reviewed-structured-value", + }, + "required": true, + "constraints": concept.constraints, + }); + if let Some(projection) = &concept.sd_jwt_vc { + rendered["sdJwtVc"] = projection.clone(); + } + rendered + }) + .collect::>(); + let subject_roles = subjects + .iter() + .map(|subject| { + json!({ + "role": subject.role, + "cardinality": "one", + "selectorProfiles": [subject.selector_profile], + }) + }) + .collect::>(); + let selector_inputs = subjects + .iter() + .filter(|subject| subject.derivation) + .map(|subject| { + json!({ + "role": subject.role, + "alternatives": [{ + "profile": subject.selector_profile, + "fields": [subject.selector_field], + }], + }) + }) + .collect::>(); + let mut derivation = Map::from_iter([ + ("script".to_owned(), json!(question.derivation)), + ("parameters".to_owned(), json!({})), + ]); + if !selector_inputs.is_empty() { + derivation.insert("selectorInputs".to_owned(), Value::Array(selector_inputs)); + } + let mut requirement_value = json!({ + "id": requirement.requirement_uri, + "kind": requirement.kind, + "source": source_id, + "purposes": [question.purpose], + "subjectRoles": subject_roles, + "referenceFrameworks": reference_frameworks, + "evidenceType": evidence_type, + "observationTimezone": observation_timezone, + "validitySeconds": validity_seconds, + "derivation": derivation, + "concepts": concepts, + "disclosureGuard": {"families": families}, + "existenceDisclosure": "collapse-unresolved", + }); + if let Some(governance) = &question.governance { + requirement_value["fixtures"] = Value::String(governance.fixtures.clone()); + } + (grant, requirement_value) +} + +fn render_local_bundle( + questions: &[QuestionPlan], + access_policies: &[AuthoredAccessPolicy], + ports: LocalServicePorts, +) -> Value { + let mint_origin = ports.mint_origin(); + let selector_profiles = questions + .iter() + .flat_map(|question| &question.subjects) + .map(|subject| { + ( + subject.selector_profile.clone(), + subject.selector_profile_value.clone(), + ) + }) + .collect::>(); + let sources = questions + .iter() + .map(|question| (question.source_id.clone(), question.source_value.clone())) + .collect::>(); + let authority_profiles = if access_policies.is_empty() { + let grants = questions + .iter() + .map(|question| question.grant.clone()) + .collect::>(); + Map::from_iter([( + AUTHORITY_PROFILE_ID.to_owned(), + json!({ + "kind": "explicit-request", + "requesterTags": [AUTHORITY_PROFILE_ID], + "grants": grants, + }), + )]) + } else { + access_policies + .iter() + .map(|policy| { + let grants = policy + .questions + .iter() + .map(|question_id| { + questions + .iter() + .find(|question| question.question_id == *question_id) + .expect("access policy questions were validated") + .grant + .clone() + }) + .collect::>(); + ( + policy.requester_tag.clone(), + json!({ + "kind": "explicit-request", + "requesterTags": [policy.requester_tag], + "grants": grants, + }), + ) + }) + .collect::>() + }; + let requirements = questions + .iter() + .map(|question| question.requirement.clone()) + .collect::>(); + let response_formats = if questions + .iter() + .flat_map(|question| &question.concepts) + .any(|concept| concept.sd_jwt_vc.is_some()) + { + json!(["signed-jws", "sd-jwt-vc"]) + } else { + json!(["signed-jws"]) + }; + json!({ + "version": 1, + "assuranceProfile": "local", + "service": { + "providerId": local_uri("provider"), + "trustDomain": local_uri("trust-domain"), + }, + "issuer": {"id": local_uri("issuer")}, + "authentication": { + "kind": "oidc-access-token", + "issuer": mint_origin, + "audiences": [LOCAL_AUDIENCE], + "tokenTypes": ["at+jwt"], + "algorithms": ["EdDSA"], + "jwksUri": format!("{mint_origin}/.well-known/jwks.json"), + "principalClaim": "sub", + "requesterTagsClaim": "evidence_tags", + "evidenceAudienceClaim": "evidence_audience", + "grantIdClaim": "evidence_grant_id", + "grantAuthorityClaim": "evidence_authority", + }, + "audit": { + "format": "keyed-jsonl", + "hashSecretRef": "secret:file/audit-hmac-key", + "hashKeyVersion": 1, + "failClosed": true, + }, + "subjectBinding": { + "secretRef": "secret:file/subject-binding-hmac-key", + "keyVersion": 1, + }, + "rateLimits": { + "requestsPerPrincipalPerMinute": 60, + "burstPerPrincipal": 10, + "failedSelectorAttemptsPerPrincipalAuthorityPerMinute": 10, + }, + "signing": { + "format": "flattened-jws-json", + "algorithm": "EdDSA", + "activeKeyId": SIGNING_KEY_ID, + "activeKeyRef": "secret:file/signing-ed25519-private-jwk", + "retiredPublicJwkFiles": [], + "jwksPath": "/.well-known/evidence/jwks.json", + "maximumAssertionValiditySeconds": 300, + "verifierClockSkewSeconds": 30, + }, + "responseFormats": response_formats, + "selectorProfiles": selector_profiles, + "sources": sources, + "authorityProfiles": authority_profiles, + "requirements": requirements, + }) +} + +fn render_production_bundle(questions: &[QuestionPlan], mut governance: Value) -> Result { + let object = governance + .as_object_mut() + .ok_or_else(|| anyhow!("production governance must be an object"))?; + object.insert( + "selectorProfiles".to_owned(), + Value::Object(Map::from_iter( + questions + .iter() + .flat_map(|question| &question.subjects) + .map(|subject| { + ( + subject.selector_profile.clone(), + subject.selector_profile_value.clone(), + ) + }), + )), + ); + object.insert( + "sources".to_owned(), + Value::Object(Map::from_iter(questions.iter().map(|question| { + (question.source_id.clone(), question.source_value.clone()) + }))), + ); + object.insert( + "requirements".to_owned(), + Value::Array( + questions + .iter() + .map(|question| question.requirement.clone()) + .collect(), + ), + ); + Ok(governance) +} + +fn write_plan( + project_root: &Path, + staging_root: &Path, + plan: &CompilePlan, + ports: LocalServicePorts, +) -> Result { + write_bundle(project_root, staging_root, plan)?; + create_private_directory(&staging_root.join("audit"))?; + + let canonical_staging = fs::canonicalize(staging_root) + .with_context(|| format!("resolving staging root {}", staging_root.display()))?; + let secret_root = fs::canonicalize(project_root.join(SECRETS_DIRECTORY)) + .context("resolving local secret directory")?; + let runtime = json!({ + "version": 1, + "bundleDirectory": canonical_staging.join("bundle").to_string_lossy(), + "listener": { + "bindHost": "127.0.0.1", + "port": ports.evidence, + "tlsTermination": "operator-controlled-upstream", + "trustProxyIdentityHeaders": false, + "maximumRequestBytes": 65536, + "maximumConcurrentRequests": 64, + "requestTimeoutMilliseconds": 10000, + "shutdownGraceMilliseconds": 30000, + }, + "secretProviders": {"file": {"root": secret_root.to_string_lossy()}}, + "auditStorage": { + "path": canonical_staging.join("audit/evidence.jsonl").to_string_lossy(), + "maximumFileBytes": 1073741824_u64, + }, + "outboundTls": {"systemRoots": true, "trustProfiles": {}}, + }); + let runtime_path = staging_root.join("runtime.yaml"); + write_private_file(&runtime_path, &yaml_bytes(&runtime)?)?; + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o400)) + .with_context(|| format!("sealing {}", runtime_path.display()))?; + + let questions = plan + .questions + .iter() + .map(|question| CompiledQuestion { + question_alias: question.question_id.clone(), + requirement_uri: question.requirement_uri.clone(), + purpose: question.purpose.clone(), + subjects: question + .subjects + .iter() + .map(|subject| CompiledSubject { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_field: subject.selector_field.clone(), + }) + .collect(), + concepts: question + .concepts + .iter() + .map(|concept| CompiledConcept { + concept_alias: concept.concept_alias.clone(), + concept_uri: concept.concept_uri.clone(), + concept_form: concept.concept_form, + }) + .collect(), + }) + .collect(); + Ok(CompiledProject { + runtime_path, + questions, + local_audience: LOCAL_AUDIENCE.to_owned(), + requester_tag: AUTHORITY_PROFILE_ID.to_owned(), + caller_evidence_audience: LOCAL_CALLER_EVIDENCE_AUDIENCE.to_owned(), + access_policies: plan + .access_policies + .iter() + .map(|policy| CompiledAccessPolicy { + id: policy.id.clone(), + requester_tag: policy.requester_tag.clone(), + questions: policy.questions.clone(), + }) + .collect(), + }) +} + +fn write_bundle(project_root: &Path, staging_root: &Path, plan: &CompilePlan) -> Result { + let bundle = staging_root.join("bundle"); + create_private_directory(&bundle)?; + for directory in ["adapters", "derivations", "schemas"] { + create_private_directory(&bundle.join(directory))?; + } + if plan + .questions + .iter() + .flat_map(|question| &question.concepts) + .any(|concept| concept.codelist.is_some()) + { + create_private_directory(&bundle.join("codelists"))?; + } + write_private_file(&bundle.join("evidence.yaml"), &yaml_bytes(&plan.bundle)?)?; + let mut written_sources = BTreeSet::new(); + let mut written_paths = BTreeSet::from(["evidence.yaml".to_owned()]); + for question in &plan.questions { + if written_sources.insert(question.source_artifact_id.clone()) { + if let Some(artifacts) = &question.authored_source_artifacts { + for artifact in artifacts { + let bytes = read_project_artifact( + project_root, + artifact, + MAX_SOURCE_ARTIFACT_BYTES, + "referenced source artifact", + )?; + if !written_paths.insert(artifact.clone()) { + continue; + } + write_private_file(&bundle.join(artifact), &bytes)?; + } + } else { + write_private_file( + &bundle.join(format!( + "adapters/{}-source-prepare.rhai", + question.question_id + )), + question.prepare_script.as_bytes(), + )?; + write_private_file( + &bundle.join(format!( + "adapters/{}-source-extract.rhai", + question.question_id + )), + question.extract_script.as_bytes(), + )?; + write_private_file( + &bundle.join(format!( + "schemas/{}-source-response.schema.yaml", + question.question_id + )), + &yaml_bytes(&question.response_schema)?, + )?; + write_private_file( + &bundle.join(format!( + "schemas/{}-source-facts.schema.yaml", + question.question_id + )), + &yaml_bytes(&question.fact_schema)?, + )?; + write_private_file( + &bundle.join(format!( + "schemas/{}-source-adapter-parameters.schema.yaml", + question.question_id + )), + &yaml_bytes(&question.adapter_parameters_schema)?, + )?; + } + } + write_private_file( + &bundle.join(&question.derivation_artifact), + question.derivation_script.as_bytes(), + )?; + written_paths.insert(question.derivation_artifact.clone()); + for (path, codelist) in question + .concepts + .iter() + .filter_map(|concept| concept.codelist.as_ref()) + { + if !written_paths.insert(path.clone()) { + continue; + } + write_private_file(&bundle.join(path), &yaml_bytes(codelist)?)?; + } + for (path, schema) in question + .concepts + .iter() + .filter_map(|concept| concept.schema.as_ref()) + { + if written_paths.insert(path.clone()) { + write_private_file(&bundle.join(path), &yaml_bytes(schema)?)?; + } + } + if let Some(path) = &question.fixture_artifact { + if written_paths.insert(path.clone()) { + let bytes = read_project_artifact( + project_root, + path, + MAX_SOURCE_ARTIFACT_BYTES, + "production fixture", + )?; + ensure_generated_parent(&bundle, path)?; + write_private_file(&bundle.join(path), &bytes)?; + } + } + } + for path in auxiliary_artifacts(&plan.bundle)? { + if written_paths.insert(path.clone()) { + let bytes = read_project_artifact( + project_root, + &path, + MAX_SOURCE_ARTIFACT_BYTES, + "referenced bundle artifact", + )?; + ensure_generated_parent(&bundle, &path)?; + write_private_file(&bundle.join(path), &bytes)?; + } + } + set_bundle_modes(&bundle, 0o500, 0o400)?; + Ok(bundle) +} + +fn read_project_artifact( + project_root: &Path, + relative: &str, + maximum_bytes: u64, + description: &str, +) -> Result> { + let parent = project_root + .join(relative) + .parent() + .ok_or_else(|| anyhow!("{description} has no project directory"))? + .to_path_buf(); + let metadata = fs::symlink_metadata(&parent) + .with_context(|| format!("inspecting {description} directory"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("{description} must be held in a plain project directory"); + } + read_regular_file(&project_root.join(relative), maximum_bytes, description) +} + +fn ensure_generated_parent(bundle: &Path, relative: &str) -> Result<()> { + let parent = bundle + .join(relative) + .parent() + .ok_or_else(|| anyhow!("generated artifact has no parent"))? + .to_path_buf(); + if !parent.exists() { + fs::create_dir_all(&parent) + .with_context(|| format!("creating generated directory {}", parent.display()))?; + } + Ok(()) +} + +fn auxiliary_artifacts(bundle: &Value) -> Result> { + let mut paths = BTreeSet::new(); + if let Some(profiles) = bundle.get("selectorProfiles").and_then(Value::as_object) { + for profile in profiles.values() { + if let Some(fields) = profile.get("fields").and_then(Value::as_object) { + for field in fields.values() { + if let Some(path) = field.get("codelist").and_then(Value::as_str) { + validate_auxiliary_artifact(path, "codelists", ".yaml")?; + paths.insert(path.to_owned()); + } + } + } + } + } + if let Some(public_keys) = bundle + .pointer("/signing/retiredPublicJwkFiles") + .and_then(Value::as_array) + { + for value in public_keys { + let path = value + .as_str() + .ok_or_else(|| anyhow!("retired public key paths must be strings"))?; + validate_auxiliary_artifact(path, "public-keys", ".jwk.json")?; + paths.insert(path.to_owned()); + } + } + Ok(paths.into_iter().collect()) +} + +fn validate_auxiliary_artifact(value: &str, directory: &str, suffix: &str) -> Result<()> { + let path = Path::new(value); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.components().next() != Some(Component::Normal(directory.as_ref())) + || path.components().count() != 2 + || !value.ends_with(suffix) + { + bail!("referenced bundle artifacts must remain in their allowed project directory"); + } + Ok(()) +} + +fn create_private_directory(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(path) + .with_context(|| format!("creating private directory {}", path.display())) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> { + use std::{fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _}; + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", path.display()))?; + file.sync_all() + .with_context(|| format!("persisting {}", path.display())) +} + +fn set_bundle_modes(root: &Path, directory_mode: u32, file_mode: u32) -> Result<()> { + for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() { + set_bundle_modes(&path, directory_mode, file_mode)?; + fs::set_permissions(&path, fs::Permissions::from_mode(directory_mode))?; + } else if metadata.is_file() { + fs::set_permissions(&path, fs::Permissions::from_mode(file_mode))?; + } else { + bail!("unexpected generated bundle entry"); + } + } + fs::set_permissions(root, fs::Permissions::from_mode(directory_mode))?; + Ok(()) +} + +fn check_with_evidence(evidence_bin: &Path, runtime_path: &Path) -> Result<()> { + let output = Command::new(evidence_bin) + .arg("--runtime") + .arg(runtime_path) + .arg("check") + .env_remove("REGISTRY_EVIDENCE_RUNTIME") + .output() + .with_context(|| format!("running {} check", evidence_bin.display()))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let diagnostic = stderr.trim(); + if diagnostic.is_empty() { + bail!("Evidence rejected the compiled local generation"); + } + bail!("Evidence rejected the compiled local generation: {diagnostic}") +} + +fn yaml_bytes(value: &Value) -> Result> { + let mut text = serde_norway::to_string(value).context("serializing generated YAML")?; + if !text.ends_with('\n') { + text.push('\n'); + } + Ok(text.into_bytes()) +} + +fn local_uri(suffix: &str) -> String { + format!("{LOCAL_URI_PREFIX}{suffix}") +} + +fn local_selector_profile_id(question_id: &str) -> String { + format!("local-subject-{question_id}-v1") +} + +fn local_subject_selector_profile_id( + question_id: &str, + role: &str, + subject_count: usize, +) -> String { + if subject_count == 1 { + local_selector_profile_id(question_id) + } else { + format!("local-subject-{question_id}-{role}-v1") + } +} + +fn local_source_id(question_id: &str) -> String { + format!("local-source-{question_id}") +} + +fn json_string(value: &str) -> String { + serde_json::to_string(value).expect("strings serialize") +} + +fn escape_pointer_segment(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::Write as _, + os::unix::fs::{symlink, OpenOptionsExt as _}, + }; + + const OPENAPI: &str = r#"openapi: 3.1.0 +info: {title: Tutorial registry, version: 1.0.0} +servers: [{url: 'http://127.0.0.1:8000'}] +paths: + /people/{person_id}: + get: + operationId: getPerson + parameters: + - name: person_id + in: path + required: true + schema: {type: string} + responses: + '200': + description: A person + content: + application/json: + schema: + type: object + required: [person_id, name, date_of_birth] + properties: + person_id: {type: string} + name: {type: string} + date_of_birth: {type: string, format: date} +"#; + + const QUESTION: &str = r#"id: adult-status +question: Is the person at least 18 years old? +purpose: age-check +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +answers: + - concept: is_adult + type: boolean +derivation: derivations/adult-status.rhai +disclosure: + allow: [is_adult] +"#; + + const RELATIONSHIP_OPENAPI: &str = r#"openapi: 3.1.0 +info: {title: Tutorial family registry, version: 1.0.0} +servers: [{url: 'http://127.0.0.1:8000'}] +paths: + /children/{child_id}/candidate-parents/{candidate_id}: + get: + operationId: getParentRelationship + parameters: + - name: child_id + in: path + required: true + schema: {type: string} + - name: candidate_id + in: path + required: true + schema: {type: string} + responses: + '200': + description: A governed relationship decision + content: + application/json: + schema: + type: object + required: [relationship_confirmed] + properties: + relationship_confirmed: {type: boolean} +"#; + + const RELATIONSHIP_QUESTION: &str = r#"id: parent-relationship +question: Is the candidate registered as a parent of the child? +purpose: relationship-check +subjects: + - role: child + selector: child_id + - role: candidate-parent + selector: candidate_id +source: + operation: getParentRelationship + facts: + - name: relationship_confirmed + path: /relationship_confirmed + combine: exactly-one + collectionBounds: {} +answers: + - concept: relationship_confirmed + type: boolean +derivation: derivations/parent-relationship.rhai +disclosure: + allow: [relationship_confirmed] +"#; + + const RELATIONSHIP_ANSWER: &str = r#"fn answer(facts, selectors, context) { + #{relationship_confirmed: required(facts.relationship_confirmed, "relationship_missing")} +} +"#; + + const ANSWER: &str = r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + let adult_on = add_calendar_years(born, 18); + #{is_adult: compare_dates(context.legal_local_date, adult_on) >= 0} +} +"#; + + const AGE_BRACKET_QUESTION: &str = r#"id: age-bracket +question: Which age bracket does this person belong to? +purpose: service-path-selection +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +answers: + - concept: age_bracket + type: controlled-category + values: [under-18, 18-to-24, 25-to-64, 65-or-older] +derivation: derivations/age-bracket.rhai +disclosure: + allow: [age_bracket] +"#; + + const AGE_BRACKET_ANSWER: &str = r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + if compare_dates(context.legal_local_date, add_calendar_years(born, 18)) < 0 { + #{age_bracket: "under-18"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 25)) < 0 { + #{age_bracket: "18-to-24"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 65)) < 0 { + #{age_bracket: "25-to-64"} + } else { + #{age_bracket: "65-or-older"} + } +} +"#; + + const IMMUNIZATION_QUESTION: &str = r#"id: immunization-summary +question: Is the immunization schedule complete, and how many doses are recorded? +purpose: care-coordination +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: dose_count + path: /dose_count + combine: exactly-one + collectionBounds: {} +answers: + - concept: schedule_complete + type: boolean + - concept: dose_count + type: bounded-integer + minimum: 0 + maximum: 20 +derivation: derivations/immunization-summary.rhai +disclosure: + allow: [schedule_complete, dose_count] +"#; + + const IMMUNIZATION_ANSWER: &str = r#"fn answer(facts, selectors, context) { + let dose_count = required(facts.dose_count, "dose_count_missing"); + #{schedule_complete: dose_count >= 3, dose_count: dose_count} +} +"#; + + const MULTI_EVENT_OPENAPI: &str = r#"openapi: 3.1.0 +info: {title: Sanitized tracker API, version: 1.0.0} +servers: [{url: 'http://127.0.0.1:8000'}] +paths: + /records/{record_id}/events: + get: + operationId: listRecordEvents + parameters: + - name: record_id + in: path + required: true + schema: {type: string} + responses: + '200': + description: Bounded event history + content: + application/json: + schema: + $ref: '#/components/schemas/EventPage' +components: + schemas: + EventPage: + type: object + additionalProperties: false + properties: + events: + type: array + items: + $ref: '#/components/schemas/Event' + Event: + type: object + additionalProperties: false + properties: + event: {type: string, minLength: 1, maxLength: 64} + status: {type: string, minLength: 1, maxLength: 32} + occurredAt: {type: string, format: date-time} +"#; + + const MULTI_EVENT_QUESTION: &str = r#"id: event-history +question: Did the bounded event history satisfy the reviewed rule? +purpose: history-review +subject: + role: record + selector: record_id +source: + operation: listRecordEvents + facts: + - name: event_statuses + path: /events/*/status + combine: collect + - name: event_times + path: /events/*/occurredAt + combine: collect + collectionBounds: + /events: 4 +answers: + - concept: history_satisfies_rule + type: boolean +derivation: derivations/event-history.rhai +disclosure: + allow: [history_satisfies_rule] +"#; + + const MULTI_EVENT_ANSWER: &str = r#"fn answer(facts, selectors, context) { + #{history_satisfies_rule: len(required(facts.event_statuses, "event_statuses_missing")) > 0} +} +"#; + + const MULTI_EVENT_RESPONSE: &str = r#"{ + "events": [ + {"event": "evt-001", "status": "completed", "occurredAt": "2026-07-01T08:00:00Z"}, + {"event": "evt-002", "status": "cancelled", "occurredAt": "2026-07-02T09:00:00Z"} + ] +}"#; + + const BIRTH_CERTIFICATE_QUESTION: &str = r#"id: birth-certificate +question: What birth details are recorded for this person? +purpose: birth-record-review +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: name + path: /name + combine: exactly-one + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +answers: + - concept: birth_certificate + type: reviewed-structured-value + schema: schemas/birth-certificate.yaml + maximumSerializedBytes: 2048 + sdJwtVc: + claim: birthCertificate + disclosure: top-level +derivation: derivations/birth-certificate.rhai +disclosure: + allow: [birth_certificate] +"#; + + const BIRTH_CERTIFICATE_ANSWER: &str = r#"fn answer(facts, selectors, context) { + #{birth_certificate: #{ + form: "reviewed-structured-value", + schema: "urn:example:schema:birth-certificate:v1", + fields: #{ + givenName: required(facts.name, "name_missing"), + dateOfBirth: required(facts.date_of_birth, "date_of_birth_missing") + } + }} +} +"#; + + const BIRTH_CERTIFICATE_SCHEMA: &str = r#"$schema: https://json-schema.org/draft/2020-12/schema +$id: urn:example:schema:birth-certificate:v1 +type: object +additionalProperties: false +required: [givenName, dateOfBirth] +properties: + givenName: {type: string, minLength: 1, maxLength: 200} + dateOfBirth: {type: string, format: date} +"#; + + #[test] + fn compiles_only_the_canonical_private_local_generation() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("local compilation succeeds"); + + assert_eq!(compiled.runtime_path, fixture.staging.join("runtime.yaml")); + assert_eq!(compiled.questions.len(), 1); + let question = &compiled.questions[0]; + assert_eq!(question.question_alias, "adult-status"); + assert_eq!( + question.requirement_uri, + "urn:registrystack:evidence:local:requirement:adult-status" + ); + assert_eq!( + question.concepts[0].concept_uri, + "urn:registrystack:evidence:local:concept:adult-status:is_adult" + ); + assert_eq!(question.purpose, "age-check"); + assert_eq!(question.subjects[0].role, "person"); + assert_eq!( + question.subjects[0].selector_profile, + local_selector_profile_id("adult-status") + ); + assert_eq!(question.subjects[0].selector_field, "person_id"); + assert_eq!(question.concepts[0].concept_alias, "is_adult"); + assert_eq!( + question.concepts[0].concept_form, + CompiledConceptForm::Boolean + ); + assert_eq!(compiled.local_audience, LOCAL_AUDIENCE); + assert_eq!(compiled.requester_tag, AUTHORITY_PROFILE_ID); + assert!(compiled.access_policies.is_empty()); + assert_eq!( + compiled.caller_evidence_audience, + LOCAL_CALLER_EVIDENCE_AUDIENCE + ); + assert_eq!( + tree(&fixture.staging), + vec![ + "audit/", + "bundle/", + "bundle/adapters/", + "bundle/adapters/adult-status-source-extract.rhai", + "bundle/adapters/adult-status-source-prepare.rhai", + "bundle/derivations/", + "bundle/derivations/adult-status.rhai", + "bundle/evidence.yaml", + "bundle/schemas/", + "bundle/schemas/adult-status-source-adapter-parameters.schema.yaml", + "bundle/schemas/adult-status-source-facts.schema.yaml", + "bundle/schemas/adult-status-source-response.schema.yaml", + "runtime.yaml", + ] + ); + assert_mode(&fixture.staging, 0o700); + assert_mode(&fixture.staging.join("audit"), 0o700); + assert_mode(&fixture.staging.join("bundle"), 0o500); + assert_mode(&compiled.runtime_path, 0o400); + + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + assert_eq!(bundle["assuranceProfile"], "local"); + let source_id = local_source_id("adult-status"); + let selector_profile = local_selector_profile_id("adult-status"); + assert_eq!( + bundle["sources"][&source_id]["authentication"], + json!({"kind": "none"}) + ); + assert_eq!( + bundle["sources"][&source_id]["request"]["pathBindings"]["person_id"], + json!({"role": "person", "profile": selector_profile, "field": "person_id"}) + ); + assert!(bundle["requirements"][0].get("fixtures").is_none()); + assert_eq!( + bundle["requirements"][0]["concepts"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let derivation = + fs::read_to_string(fixture.staging.join("bundle/derivations/adult-status.rhai")) + .expect("derivation reads"); + assert!(derivation.contains("fn answer(facts, selectors, context)")); + assert!(derivation.contains( + "concept_id: \"urn:registrystack:evidence:local:concept:adult-status:is_adult\"" + )); + assert!(derivation + .contains("let governed_answers = answer(facts, selectors, evaluation_context)")); + assert!(derivation.contains("value: governed_answers[\"is_adult\"]")); + assert!(!derivation.contains("concept_id: \"is_adult\"")); + } + + #[test] + fn compiles_a_structured_value_into_independent_sd_jwt_vc_fields() { + let openapi = OPENAPI.replace( + " name: {type: string}", + " name: {type: string, minLength: 1, maxLength: 200}", + ); + let fixture = Fixture::new( + &openapi, + BIRTH_CERTIFICATE_QUESTION, + BIRTH_CERTIFICATE_ANSWER, + true, + ); + fs::create_dir(fixture.project.join("schemas")).expect("schemas"); + fs::write( + fixture.project.join("schemas/birth-certificate.yaml"), + BIRTH_CERTIFICATE_SCHEMA, + ) + .expect("birth certificate schema"); + + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("structured value compiles"); + + assert_eq!( + compiled.questions[0].concepts[0].concept_form, + CompiledConceptForm::Structured + ); + assert!(fixture + .staging + .join("bundle/schemas/birth-certificate.yaml") + .is_file()); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + assert_eq!( + bundle["responseFormats"], + json!(["signed-jws", "sd-jwt-vc"]) + ); + assert_eq!( + bundle["authorityProfiles"][AUTHORITY_PROFILE_ID]["grants"][0]["responseFormats"], + json!(["signed-jws", "sd-jwt-vc"]) + ); + let concept = &bundle["requirements"][0]["concepts"][0]; + assert_eq!(concept["form"], "reviewed-structured-value"); + assert_eq!( + concept["constraints"], + json!({ + "schema": "urn:example:schema:birth-certificate:v1", + "maximumSerializedBytes": 2048, + }) + ); + assert_eq!( + concept["sdJwtVc"], + json!({"claim": "birthCertificate", "disclosure": "top-level"}) + ); + } + + #[test] + fn inline_openapi_question_compiles_multiple_role_bound_subjects() { + let fixture = Fixture::new( + RELATIONSHIP_OPENAPI, + RELATIONSHIP_QUESTION, + RELATIONSHIP_ANSWER, + true, + ); + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("multi-subject local compilation succeeds"); + + let question = &compiled.questions[0]; + assert_eq!(question.subjects.len(), 2); + assert_eq!(question.subjects[0].role, "child"); + assert_eq!(question.subjects[0].selector_field, "child_id"); + assert_eq!(question.subjects[1].role, "candidate-parent"); + assert_eq!(question.subjects[1].selector_field, "candidate_id"); + + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + let source = &bundle["sources"][local_source_id("parent-relationship")]; + assert_eq!( + source["request"]["pathBindings"]["child_id"]["role"], + "child" + ); + assert_eq!( + source["request"]["pathBindings"]["candidate_id"]["role"], + "candidate-parent" + ); + assert_eq!( + source["request"]["selectorInputs"] + .as_array() + .expect("selector inputs") + .len(), + 2 + ); + assert_eq!( + bundle["requirements"][0]["subjectRoles"] + .as_array() + .expect("subject roles") + .len(), + 2 + ); + assert_eq!( + bundle["authorityProfiles"][AUTHORITY_PROFILE_ID]["grants"][0]["subjects"] + .as_array() + .expect("grant subjects") + .len(), + 2 + ); + + for invalid_question in [ + RELATIONSHIP_QUESTION.replace( + " - role: candidate-parent\n selector: candidate_id\n", + "", + ), + RELATIONSHIP_QUESTION.replace("role: candidate-parent", "role: child"), + RELATIONSHIP_QUESTION.replace("selector: candidate_id", "selector: person_id"), + ] { + let fixture = Fixture::new( + RELATIONSHIP_OPENAPI, + &invalid_question, + RELATIONSHIP_ANSWER, + true, + ); + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("incomplete or ambiguous role binding is rejected"); + assert!(fixture.staging_is_empty()); + } + } + + #[test] + fn compiles_one_closed_controlled_category() { + let fixture = Fixture::new(OPENAPI, AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER, true); + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("controlled category compiles"); + + assert_eq!( + compiled.questions[0].concepts[0].concept_form, + CompiledConceptForm::ControlledCategory + ); + let codelist: Value = serde_norway::from_slice( + &fs::read( + fixture + .staging + .join("bundle/codelists/age-bracket-age_bracket.yaml"), + ) + .expect("codelist reads"), + ) + .expect("codelist parses"); + assert_eq!( + codelist["codes"], + json!(["under-18", "18-to-24", "25-to-64", "65-or-older"]) + ); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + assert_eq!(bundle["requirements"][0]["kind"], "information-requirement"); + assert_eq!( + bundle["requirements"][0]["concepts"][0]["form"], + "controlled-category" + ); + } + + #[test] + fn production_controlled_category_keeps_the_stable_concept_and_distinct_scheme_ids() { + let answer: QuestionAnswer = serde_norway::from_str( + r#"concept: age_bracket +id: urn:authority:concept:age-bracket:v1 +type: controlled-category +values: [under-18, adult] +"#, + ) + .expect("production answer parses"); + let concept = compile_concept("age-bracket", &answer, &BTreeMap::new()) + .expect("controlled category compiles"); + + assert_eq!(concept.concept_uri, "urn:authority:concept:age-bracket:v1"); + assert_eq!( + concept.constraints["categoryScheme"], + "urn:authority:concept:age-bracket:v1:categories" + ); + assert_ne!( + concept.constraints["categoryScheme"], + concept.concept_uri.as_str(), + "the runtime-required category scheme remains distinct from the governed concept" + ); + let (_, codelist) = concept.codelist.expect("controlled category codelist"); + assert_eq!( + codelist["id"], + "urn:authority:concept:age-bracket:v1:categories" + ); + } + + #[test] + fn production_compiler_ignores_local_access_and_handles_all_neutral_question_shapes() { + fn referenced(question: &str, source: &str) -> String { + let start = question.find("source:\n").expect("source section"); + let end = start + + question[start..] + .find("answers:\n") + .expect("answers section"); + format!( + "{}source:\n ref: {source}\n{}", + &question[..start], + &question[end..] + ) + } + + fn governed(mut question: String, question_id: &str, answers: &[(&str, &str)]) -> String { + for (alias, identifier) in answers { + question = question.replace( + &format!(" - concept: {alias}\n"), + &format!(" - concept: {alias}\n id: {identifier}\n"), + ); + } + question.push_str(&format!( + r#"governance: + requirement: urn:authority:requirement:{question_id}:v1 + kind: information-requirement + referenceFrameworks: [urn:authority:framework:neutral:v1] + evidenceType: urn:authority:evidence-type:{question_id}:v1 + validitySeconds: 300 + observationTimezone: UTC + fixtures: fixtures/{question_id}.yaml + disclosureFamilies: [urn:authority:disclosure-family:{question_id}:v1] +"#, + )); + question + } + + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + for directory in ["sources", "selectors", "adapters", "schemas", "fixtures"] { + fs::create_dir(fixture.project.join(directory)) + .expect("production authoring directory"); + } + for (profile, field) in [ + ("person-reference-v1", "person_id"), + ("child-reference-v1", "child_id"), + ("candidate-reference-v1", "candidate_id"), + ] { + fs::write( + fixture.project.join(format!("selectors/{profile}.yaml")), + format!( + "maximumAggregateBytes: 64\nfields:\n {field}:\n type: string\n minimumBytes: 1\n maximumBytes: 64\n" + ), + ) + .expect("selector profile"); + } + fs::write( + fixture.project.join("sources/people.yaml"), + r#"transport: http-json +baseUrl: https://records.example.test +posture: field-projected +authentication: {kind: static-bearer, tokenRef: 'secret:file/records-token'} +request: + method: GET + pathTemplate: /people/{person_id} + pathBindings: + person_id: {role: person, profile: person-reference-v1, field: person_id} + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: person + alternatives: + - {profile: person-reference-v1, fields: [person_id]} + prepareScript: adapters/source-prepare.rhai + adapterParameters: {} + adapterParametersSchema: schemas/source-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: forbidden, maximumNormalizedBytes: 4096} + projection: [/value] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/source-response.schema.yaml +extractScript: adapters/source-extract.rhai +factSchema: schemas/source-facts.schema.yaml +"#, + ) + .expect("people source"); + fs::write( + fixture.project.join("sources/relationships.yaml"), + r#"transport: http-json +baseUrl: https://relationships.example.test +posture: field-projected +authentication: {kind: static-bearer, tokenRef: 'secret:file/relationships-token'} +request: + method: GET + pathTemplate: /children/{child_id}/candidates/{candidate_id} + pathBindings: + child_id: {role: child, profile: child-reference-v1, field: child_id} + candidate_id: {role: candidate-parent, profile: candidate-reference-v1, field: candidate_id} + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: child + alternatives: + - {profile: child-reference-v1, fields: [child_id]} + - role: candidate-parent + alternatives: + - {profile: candidate-reference-v1, fields: [candidate_id]} + prepareScript: adapters/source-prepare.rhai + adapterParameters: {} + adapterParametersSchema: schemas/source-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: forbidden, maximumNormalizedBytes: 4096} + projection: [/value] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/source-response.schema.yaml +extractScript: adapters/source-extract.rhai +factSchema: schemas/source-facts.schema.yaml +"#, + ) + .expect("relationship source"); + for (path, contents) in [ + ( + "adapters/source-prepare.rhai", + "fn prepare(selectors, parameters) { #{query: [], body: ()} }\n", + ), + ( + "adapters/source-extract.rhai", + "fn extract(response, parameters) { #{outcome: \"match\", facts: response} }\n", + ), + ( + "schemas/source-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ( + "schemas/source-response.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ( + "schemas/source-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ] { + fs::write(fixture.project.join(path), contents).expect("source artifact"); + } + + let questions = [ + ( + governed( + referenced(QUESTION, "people"), + "adult-status", + &[("is_adult", "urn:authority:concept:is-adult:v1")], + ), + ANSWER, + ), + ( + governed( + referenced(AGE_BRACKET_QUESTION, "people"), + "age-bracket", + &[("age_bracket", "urn:authority:concept:age-bracket:v1")], + ), + AGE_BRACKET_ANSWER, + ), + ( + governed( + referenced(IMMUNIZATION_QUESTION, "people"), + "immunization-summary", + &[ + ( + "schedule_complete", + "urn:authority:concept:schedule-complete:v1", + ), + ("dose_count", "urn:authority:concept:dose-count:v1"), + ], + ), + IMMUNIZATION_ANSWER, + ), + ( + governed( + referenced(RELATIONSHIP_QUESTION, "relationships"), + "parent-relationship", + &[( + "relationship_confirmed", + "urn:authority:concept:relationship-confirmed:v1", + )], + ), + RELATIONSHIP_ANSWER, + ), + ]; + for (question, derivation) in questions { + fixture.add_question(&question, derivation); + let parsed: Question = serde_norway::from_str(&question).expect("governed question"); + fs::write( + fixture.project.join(format!("fixtures/{}.yaml", parsed.id)), + "version: 1\ncases: []\n", + ) + .expect("governed fixture"); + } + + symlink( + fixture.project.join("questions"), + fixture.project.join(ACCESS_DIRECTORY), + ) + .expect("malformed local access link"); + + let target = json!({ + "version": 1, + "assuranceProfile": "production", + "service": {"providerId": "urn:authority:provider", "trustDomain": "urn:authority:trust"}, + "issuer": {"id": "urn:authority:issuer"}, + "authentication": {}, + "audit": {}, + "subjectBinding": {}, + "rateLimits": {}, + "signing": {}, + "authorityProfiles": {"authority": {"kind": "explicit-request"}}, + }); + let project = fs::canonicalize(&fixture.project).expect("canonical production project"); + let compiled = compile_production_project(&project, &fixture.staging, target) + .expect("all neutral shapes compile through production"); + let bundle = compiled.bundle; + let requirements = bundle["requirements"].as_array().expect("requirements"); + + assert_eq!(requirements.len(), 4); + assert_eq!( + requirements + .iter() + .map(|requirement| requirement["id"].as_str().expect("requirement id")) + .collect::>(), + [ + "urn:authority:requirement:adult-status:v1", + "urn:authority:requirement:age-bracket:v1", + "urn:authority:requirement:immunization-summary:v1", + "urn:authority:requirement:parent-relationship:v1", + ] + ); + assert_eq!(requirements[0]["concepts"][0]["form"], "boolean"); + assert_eq!( + requirements[1]["concepts"][0]["form"], + "controlled-category" + ); + assert_eq!(requirements[2]["concepts"].as_array().unwrap().len(), 2); + assert_eq!(requirements[2]["concepts"][1]["form"], "bounded-integer"); + assert_eq!(requirements[3]["subjectRoles"].as_array().unwrap().len(), 2); + assert_eq!(compiled.fixture_paths.len(), 4); + assert!(!serde_json::to_string(&bundle) + .expect("bundle JSON") + .contains(LOCAL_URI_PREFIX)); + } + + #[test] + fn local_compiler_uses_exact_optional_governance_but_keeps_local_assurance() { + let question = QUESTION.replace( + " - concept: is_adult\n", + " - concept: is_adult\n id: urn:authority:concept:is-adult:v1\n", + ) + r#"governance: + requirement: urn:authority:requirement:adult-status:v1 + kind: criterion + referenceFrameworks: [urn:authority:framework:adult-status:v1] + evidenceType: urn:authority:evidence-type:adult-status:v1 + validitySeconds: 900 + observationTimezone: Asia/Bangkok + fixtures: fixtures/adult-status.yaml + disclosureFamilies: [urn:authority:disclosure-family:adult-status:v1] +"#; + let fixture = Fixture::new(OPENAPI, &question, ANSWER, true); + fs::create_dir(fixture.project.join("fixtures")).expect("fixtures"); + fs::write( + fixture.project.join("fixtures/adult-status.yaml"), + "version: 1\ncases: []\n", + ) + .expect("fixture"); + + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("governed local compilation"); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("local bundle"), + ) + .expect("local bundle YAML"); + let requirement = &bundle["requirements"][0]; + + assert_eq!(bundle["assuranceProfile"], "local"); + assert_eq!(bundle["authentication"]["issuer"], "http://127.0.0.1:8081"); + assert_eq!( + bundle["signing"]["activeKeyRef"], + "secret:file/signing-ed25519-private-jwk" + ); + assert_eq!( + requirement["id"], + "urn:authority:requirement:adult-status:v1" + ); + assert_eq!(requirement["validitySeconds"], 900); + assert_eq!(requirement["observationTimezone"], "Asia/Bangkok"); + assert_eq!( + requirement["concepts"][0]["id"], + "urn:authority:concept:is-adult:v1" + ); + assert!(fixture + .staging + .join("bundle/fixtures/adult-status.yaml") + .is_file()); + } + + #[test] + fn copied_project_artifacts_reject_symlinked_parent_directories() { + let temporary = tempfile::tempdir().expect("tempdir"); + let project = temporary.path().join("project"); + let outside = temporary.path().join("outside"); + fs::create_dir(&project).expect("project"); + fs::create_dir(&outside).expect("outside"); + + for (directory, file) in [ + ("adapters", "prepare.rhai"), + ("schemas", "facts.schema.yaml"), + ("codelists", "categories.yaml"), + ("public-keys", "retired.jwk.json"), + ] { + fs::write(outside.join(file), b"outside\n").expect("outside artifact"); + symlink(&outside, project.join(directory)).expect("artifact directory symlink"); + assert!( + read_project_artifact( + &project, + &format!("{directory}/{file}"), + 1024, + "copied artifact", + ) + .is_err(), + "{directory} symlink must not be followed" + ); + fs::remove_file(project.join(directory)).expect("remove test symlink"); + fs::remove_file(outside.join(file)).expect("remove outside artifact"); + } + } + + #[test] + fn compiles_boolean_and_bounded_integer_into_one_signed_assertion_bundle() { + let openapi = OPENAPI + .replace( + "required: [person_id, name, date_of_birth]", + "required: [person_id, name, date_of_birth, dose_count]", + ) + .replace( + " date_of_birth: {type: string, format: date}", + " date_of_birth: {type: string, format: date}\n dose_count: {type: integer, minimum: 0, maximum: 20}", + ); + let fixture = Fixture::new(&openapi, IMMUNIZATION_QUESTION, IMMUNIZATION_ANSWER, true); + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("multiple governed answers compile"); + + assert_eq!( + compiled.questions[0] + .concepts + .iter() + .map(|concept| (concept.concept_alias.as_str(), concept.concept_form)) + .collect::>(), + [ + ("schedule_complete", CompiledConceptForm::Boolean), + ("dose_count", CompiledConceptForm::BoundedInteger), + ] + ); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + let requirement = &bundle["requirements"][0]; + assert_eq!(requirement["kind"], "information-requirement"); + assert_eq!(requirement["concepts"].as_array().unwrap().len(), 2); + assert_eq!(requirement["concepts"][0]["form"], "boolean"); + assert_eq!(requirement["concepts"][1]["form"], "bounded-integer"); + assert_eq!( + requirement["concepts"][1]["constraints"], + json!({"minimum": 0, "maximum": 20}) + ); + let derivation = fs::read_to_string( + fixture + .staging + .join("bundle/derivations/immunization-summary.rhai"), + ) + .expect("derivation reads"); + assert!(derivation.contains("value: governed_answers[\"schedule_complete\"]")); + assert!(derivation.contains("value: governed_answers[\"dose_count\"]")); + assert!(derivation.contains( + "urn:registrystack:evidence:local:concept:immunization-summary:schedule_complete" + )); + assert!(derivation + .contains("urn:registrystack:evidence:local:concept:immunization-summary:dose_count")); + } + + #[test] + fn compiles_every_authored_question_into_one_generation() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fixture.add_question(AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER); + + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("multiple questions compile"); + + assert_eq!( + compiled + .questions + .iter() + .map(|question| question.question_alias.as_str()) + .collect::>(), + ["adult-status", "age-bracket"] + ); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + assert_eq!(bundle["selectorProfiles"].as_object().unwrap().len(), 2); + assert_eq!(bundle["sources"].as_object().unwrap().len(), 2); + assert_eq!( + bundle["authorityProfiles"][AUTHORITY_PROFILE_ID]["grants"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!(bundle["requirements"].as_array().unwrap().len(), 2); + assert!(fixture + .staging + .join("bundle/derivations/adult-status.rhai") + .is_file()); + assert!(fixture + .staging + .join("bundle/derivations/age-bracket.rhai") + .is_file()); + } + + #[test] + fn explicit_access_policies_replace_the_implicit_caller_profile() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fixture.add_question(AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER); + fixture.add_access_policy("age-checks", &["adult-status", "age-bracket"]); + fixture.add_access_policy("service-routing", &["age-bracket"]); + + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("explicit access policies compile"); + + assert_eq!( + compiled + .access_policies + .iter() + .map(|policy| (policy.id.as_str(), policy.questions.as_slice())) + .collect::>(), + [ + ( + "age-checks", + ["adult-status".to_owned(), "age-bracket".to_owned()].as_slice() + ), + ("service-routing", ["age-bracket".to_owned()].as_slice()), + ] + ); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle reads"), + ) + .expect("bundle parses"); + let profiles = bundle["authorityProfiles"] + .as_object() + .expect("authority profiles"); + assert_eq!(profiles.len(), 2); + assert!(!profiles.contains_key(AUTHORITY_PROFILE_ID)); + for policy in &compiled.access_policies { + let profile = &profiles[&policy.requester_tag]; + assert_eq!(profile["kind"], "explicit-request"); + assert_eq!(profile["requesterTags"], json!([policy.requester_tag])); + let grants = profile["grants"].as_array().unwrap(); + assert_eq!(grants.len(), policy.questions.len()); + for (grant, question) in grants.iter().zip(&policy.questions) { + assert_eq!( + grant["requirement"], + local_uri(&format!("requirement:{question}")) + ); + } + } + } + + #[test] + fn access_policy_tags_are_stable_and_revision_bound() { + let first = access_policy_requester_tag("age-checks", &["adult-status".to_owned()]) + .expect("first tag"); + let same = access_policy_requester_tag("age-checks", &["adult-status".to_owned()]) + .expect("same tag"); + let changed = access_policy_requester_tag( + "age-checks", + &["adult-status".to_owned(), "age-bracket".to_owned()], + ) + .expect("changed tag"); + + assert_eq!(first, same); + assert_ne!(first, changed); + assert!(first.starts_with("policy-v1-")); + assert_eq!(first.len(), "policy-v1-".len() + 64); + } + + #[test] + fn explicit_access_policies_reject_unknown_questions_before_writing() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fixture.add_access_policy("unknown-access", &["missing-question"]); + + let error = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unknown question must fail"); + + assert!(error.to_string().contains("does not exist")); + assert!(fixture.staging_is_empty()); + } + + #[test] + fn explicit_access_directory_cannot_escape_through_a_symlink() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + let outside = fixture.project.parent().unwrap().join("outside-access"); + fs::create_dir(&outside).expect("outside access"); + symlink(&outside, fixture.project.join("access")).expect("access symlink"); + + let error = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("access symlink must fail"); + + assert!(error.to_string().contains("plain project directory")); + assert!(fixture.staging_is_empty()); + } + + #[test] + fn referenced_v1_source_and_selector_are_reused_by_questions() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + for directory in ["sources", "selectors", "adapters", "schemas"] { + fs::create_dir(fixture.project.join(directory)).expect("authoring directory"); + } + fs::write( + fixture.project.join("selectors/person-reference-v1.yaml"), + "maximumAggregateBytes: 200\nfields:\n person_id:\n type: string\n minimumBytes: 1\n maximumBytes: 200\n", + ) + .expect("selector"); + fs::write( + fixture.project.join("sources/people.yaml"), + r#"transport: http-json +baseUrl: https://records.example.test +posture: field-projected +authentication: + kind: basic + usernameRef: secret:file/records-username + passwordRef: secret:file/records-password +request: + method: GET + pathTemplate: /people/{person_id} + pathBindings: + person_id: {role: person, profile: person-reference-v1, field: person_id} + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: person + alternatives: + - {profile: person-reference-v1, fields: [person_id]} + prepareScript: adapters/people-prepare.rhai + adapterParameters: {} + adapterParametersSchema: schemas/people-parameters.schema.yaml + preparationLimits: {query: allowed, jsonBody: forbidden, maximumNormalizedBytes: 4096} + projection: [/date_of_birth] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/people-response.schema.yaml +extractScript: adapters/people-extract.rhai +factSchema: schemas/people-facts.schema.yaml +"#, + ) + .expect("source"); + for (path, contents) in [ + ( + "adapters/people-prepare.rhai", + "fn prepare(s, p) { #{query: [], body: ()} }\n", + ), + ( + "adapters/people-extract.rhai", + "fn extract(r, p) { #{outcome: \"match\", facts: r} }\n", + ), + ( + "schemas/people-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ( + "schemas/people-response.schema.yaml", + "type: object\nadditionalProperties: false\nproperties: {}\n", + ), + ( + "schemas/people-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ] { + fs::write(fixture.project.join(path), contents).expect("source artifact"); + } + let inline = r#"source: + operation: getPerson + facts: + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +"#; + let referenced = QUESTION.replace(inline, "source:\n ref: people\n"); + fs::write( + fixture.project.join("questions/adult-status.yaml"), + &referenced, + ) + .expect("referenced question"); + let copied = referenced + .replace("id: adult-status", "id: adult-status-copy") + .replace( + "derivations/adult-status.rhai", + "derivations/adult-status-copy.rhai", + ); + fixture.add_question(&copied, ANSWER); + + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("referenced source compiles"); + assert_eq!(compiled.questions.len(), 2); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle"), + ) + .expect("bundle yaml"); + assert_eq!(bundle["sources"].as_object().expect("sources").len(), 1); + assert_eq!( + bundle["selectorProfiles"] + .as_object() + .expect("selectors") + .len(), + 1 + ); + assert_eq!( + bundle["sources"]["people"]["authentication"]["usernameRef"], + "secret:file/records-username" + ); + assert!(fixture + .staging + .join("bundle/adapters/people-extract.rhai") + .is_file()); + } + + #[test] + fn referenced_question_compiles_multiple_role_bound_subjects() { + let question = r#"id: relationship-check +question: Does the governed relationship hold? +purpose: relationship-review +subjects: + - role: child + selector: child_reference + profile: child-reference-v1 + derivation: true + - role: candidate + selector: person_reference + profile: person-reference-v1 + derivation: true +source: + ref: family-record +answers: + - concept: relationship_confirmed + type: boolean +derivation: derivations/relationship-check.rhai +disclosure: + allow: [relationship_confirmed] +"#; + let answer = r#"fn answer(facts, selectors, context) { + let child = required(selectors["child"], "child_missing"); + let candidate = required(selectors["candidate"], "candidate_missing"); + #{relationship_confirmed: child["values"]["child_reference"] != candidate["values"]["person_reference"]} +} +"#; + let fixture = Fixture::new(OPENAPI, question, answer, true); + for directory in ["sources", "selectors", "adapters", "schemas"] { + fs::create_dir(fixture.project.join(directory)).expect("authoring directory"); + } + for (name, field) in [ + ("child-reference-v1", "child_reference"), + ("person-reference-v1", "person_reference"), + ] { + fs::write( + fixture.project.join(format!("selectors/{name}.yaml")), + format!( + "maximumAggregateBytes: 200\nfields:\n {field}:\n type: string\n minimumBytes: 1\n maximumBytes: 200\n" + ), + ) + .expect("selector"); + } + fs::write( + fixture.project.join("sources/family-record.yaml"), + r#"transport: http-json +baseUrl: https://records.example.test +posture: field-projected +authentication: {kind: static-bearer, tokenRef: 'secret:file/records-token'} +request: + method: GET + pathTemplate: /children/{child_reference}/relationships + pathBindings: + child_reference: {role: child, profile: child-reference-v1, field: child_reference} + selectorInputs: + - role: child + alternatives: + - {profile: child-reference-v1, fields: [child_reference]} + prepareScript: adapters/family-prepare.rhai + adapterParameters: {} + adapterParametersSchema: schemas/family-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: forbidden, maximumNormalizedBytes: 4096} + projection: [/relationship_complete] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/family-response.schema.yaml +extractScript: adapters/family-extract.rhai +factSchema: schemas/family-facts.schema.yaml +"#, + ) + .expect("source"); + for (path, contents) in [ + ( + "adapters/family-prepare.rhai", + "fn prepare(s, p) { #{query: [], body: ()} }\n", + ), + ( + "adapters/family-extract.rhai", + "fn extract(r, p) { #{outcome: \"match\", facts: r} }\n", + ), + ( + "schemas/family-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: []\nproperties: {}\n", + ), + ( + "schemas/family-response.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [relationship_complete]\nproperties:\n relationship_complete: {type: boolean}\n", + ), + ( + "schemas/family-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [relationship_complete]\nproperties:\n relationship_complete: {type: boolean}\n", + ), + ] { + fs::write(fixture.project.join(path), contents).expect("source artifact"); + } + + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("multi-subject question compiles"); + assert_eq!( + compiled.questions[0] + .subjects + .iter() + .map(|subject| (subject.role.as_str(), subject.selector_profile.as_str())) + .collect::>(), + [ + ("child", "child-reference-v1"), + ("candidate", "person-reference-v1"), + ] + ); + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.staging.join("bundle/evidence.yaml")).expect("bundle"), + ) + .expect("bundle YAML"); + assert_eq!(bundle["selectorProfiles"].as_object().unwrap().len(), 2); + let grant = &bundle["authorityProfiles"][AUTHORITY_PROFILE_ID]["grants"][0]; + assert_eq!(grant["subjects"].as_array().unwrap().len(), 2); + let requirement = &bundle["requirements"][0]; + assert_eq!(requirement["subjectRoles"].as_array().unwrap().len(), 2); + assert_eq!( + requirement["derivation"]["selectorInputs"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + bundle["sources"]["family-record"]["request"]["selectorInputs"] + .as_array() + .unwrap() + .len(), + 1, + "the derivation-only candidate never widens the provider request" + ); + } + + #[test] + fn compiles_nested_multi_event_leaves_without_reducing_to_the_first_event() { + let fixture = Fixture::new( + MULTI_EVENT_OPENAPI, + MULTI_EVENT_QUESTION, + MULTI_EVENT_ANSWER, + true, + ); + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("nested repeated facts compile"); + + let response: Value = serde_norway::from_slice( + &fs::read( + fixture + .staging + .join("bundle/schemas/event-history-source-response.schema.yaml"), + ) + .expect("response schema"), + ) + .expect("response schema parses"); + assert_eq!(response["additionalProperties"], false); + assert_eq!(response["required"], json!(["events"])); + assert_eq!(response["properties"]["events"]["minItems"], 1); + assert_eq!(response["properties"]["events"]["maxItems"], 4); + assert_eq!( + response["properties"]["events"]["items"]["required"], + json!(["status", "occurredAt"]) + ); + + let facts: Value = serde_norway::from_slice( + &fs::read( + fixture + .staging + .join("bundle/schemas/event-history-source-facts.schema.yaml"), + ) + .expect("fact schema"), + ) + .expect("fact schema parses"); + assert_eq!(facts["additionalProperties"], false); + assert_eq!(facts["properties"]["event_statuses"]["minItems"], 1); + assert_eq!(facts["properties"]["event_statuses"]["maxItems"], 4); + + let extract = fs::read_to_string( + fixture + .staging + .join("bundle/adapters/event-history-source-extract.rhai"), + ) + .expect("extract script"); + assert!(extract.contains("for item_0_0 in items_0_0"), "{extract}"); + assert!(extract.contains("collected_0.push"), "{extract}"); + assert!(extract.contains("for item_1_0 in items_1_0"), "{extract}"); + assert!(!extract.contains("/events/0"), "{extract}"); + + let sample: Value = serde_json::from_str(MULTI_EVENT_RESPONSE).expect("sanitized sample"); + assert_eq!(sample["events"].as_array().expect("events").len(), 2); + assert_ne!(sample["events"][0]["status"], sample["events"][1]["status"]); + } + + #[test] + fn repeated_fact_selection_requires_an_explicit_closed_combination_rule_and_bounds() { + let cases = [ + MULTI_EVENT_QUESTION.replace("combine: collect", "combine: exactly-one"), + MULTI_EVENT_QUESTION.replace( + " collectionBounds:\n /events: 4", + " collectionBounds: {}", + ), + MULTI_EVENT_QUESTION.replace(" /events: 4", " /events: 257"), + MULTI_EVENT_QUESTION.replace(" /events: 4", " /events: 4\n /unused: 2"), + ]; + for question in cases { + let fixture = Fixture::new(MULTI_EVENT_OPENAPI, &question, MULTI_EVENT_ANSWER, true); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unsafe repeated fact selection is rejected"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn selected_scalar_leaf_must_have_a_reviewed_closed_bound() { + let unbounded = MULTI_EVENT_OPENAPI.replace( + "status: {type: string, minLength: 1, maxLength: 32}", + "status: {type: string}", + ); + let fixture = Fixture::new(&unbounded, MULTI_EVENT_QUESTION, MULTI_EVENT_ANSWER, true); + let error = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unbounded selected leaf is rejected"); + assert!(error.to_string().contains("unbounded"), "{error:#}"); + assert!(fixture.staging_is_empty()); + } + + #[test] + fn collection_extraction_visits_nested_arrays_and_scalar_array_items() { + let extract = render_fact_extraction(&[QuestionFact { + name: "observations".to_owned(), + path: "/events/*/groups/*/*".to_owned(), + combine: FactCombination::Collect, + }]); + assert!(extract.contains("get_path(source_response, \"/events\")")); + assert!(extract.contains("get_path(item_0_0, \"/groups\")")); + assert!(extract.contains("let items_0_2 = required(item_0_1")); + assert!(extract.contains("collected_0.push(required(item_0_2")); + assert!(!extract.contains("/0")); + } + + #[test] + fn question_is_closed_and_disclosure_cannot_be_widened() { + for mutation in [ + QUESTION.replace(" allow: [is_adult]", " allow: []"), + QUESTION.replace(" allow: [is_adult]", " allow: [is_adult, another_answer]"), + QUESTION.replace(" allow: [is_adult]", " allow: [is_adult, is_adult]"), + QUESTION.replace(" path: /date_of_birth", " path: /missing"), + format!("{QUESTION}unknown: true\n"), + ] { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fs::write( + fixture.project.join("questions/adult-status.yaml"), + mutation, + ) + .expect("mutated question"); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("widened question is rejected"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn multi_answer_constraints_and_exact_disclosure_fail_closed() { + let openapi = OPENAPI + .replace( + "required: [person_id, name, date_of_birth]", + "required: [person_id, name, date_of_birth, dose_count]", + ) + .replace( + " date_of_birth: {type: string, format: date}", + " date_of_birth: {type: string, format: date}\n dose_count: {type: integer, minimum: 0, maximum: 20}", + ); + for question in [ + IMMUNIZATION_QUESTION.replace(" minimum: 0\n", ""), + IMMUNIZATION_QUESTION.replace(" maximum: 20", " maximum: -1"), + IMMUNIZATION_QUESTION.replace( + " allow: [schedule_complete, dose_count]", + " allow: [schedule_complete, undeclared]", + ), + IMMUNIZATION_QUESTION + .replace(" - concept: dose_count", " - concept: schedule_complete"), + IMMUNIZATION_QUESTION.replace( + " type: boolean\n - concept: dose_count", + " type: boolean\n minimum: 0\n - concept: dose_count", + ), + ] { + let fixture = Fixture::new(&openapi, &question, IMMUNIZATION_ANSWER, true); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("invalid governed answers are rejected"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn authored_code_cannot_replace_or_bypass_the_generated_concept_binding() { + let authored = r#"fn answer(facts, selectors, context) { + [#{concept_id: "urn:attacker:extra", value: true}] +} +"#; + let fixture = Fixture::new(OPENAPI, QUESTION, authored, true); + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("authored function stays behind the generated binding"); + + let derivation = + fs::read_to_string(fixture.staging.join("bundle/derivations/adult-status.rhai")) + .expect("rejected generated derivation remains inspectable"); + let wrapper = derivation.rsplit_once("fn derive").unwrap().1; + assert!(wrapper.contains("urn:registrystack:evidence:local:concept:adult-status:is_adult")); + assert!(!wrapper.contains("urn:attacker")); + assert!( + wrapper.contains("let governed_answers = answer(facts, selectors, evaluation_context)") + ); + assert!(wrapper.contains("value: governed_answers[\"is_adult\"]")); + + for rejected in [ + "fn helper(facts, selectors, context) { true }", + "fn answer(facts) { true }", + "fn answer(facts, selectors, context) { true } fn derive(facts, selectors, context) { [] }", + "fn answer(facts, selectors, context) { true } fn answer(facts) { false }", + ] { + let fixture = Fixture::new(OPENAPI, QUESTION, rejected, true); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("authored entry-point bypass is rejected"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn ambiguous_or_nonlocal_openapi_fails_before_staging() { + let cases = [ + OPENAPI.replace("127.0.0.1", "example.test"), + OPENAPI.replace( + "servers: [{url: 'http://127.0.0.1:8000'}]", + "servers: [{url: 'http://127.0.0.1:8000'}, {url: 'http://127.0.0.1:8001'}]", + ), + OPENAPI.replace("paths:", "security: []\npaths:"), + OPENAPI.replace(" get:", " post:"), + OPENAPI.replace(" responses:", " security: []\n responses:"), + OPENAPI.replace(" '200':", " default:"), + OPENAPI.replace( + " date_of_birth: {type: string, format: date}", + " date_of_birth: {type: object}", + ), + ]; + for openapi in cases { + let fixture = Fixture::new(&openapi, QUESTION, ANSWER, true); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unsupported OpenAPI is rejected"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn unsupported_openapi_constraints_fail_before_staging() { + let cases = [ + OPENAPI.replace( + "date_of_birth: {type: string, format: date}", + "date_of_birth: {type: string, maxLength: 32, pattern: '^2000-'}", + ), + OPENAPI.replace( + "date_of_birth: {type: string, format: date}", + "date_of_birth: {type: integer, exclusiveMinimum: 0}", + ), + OPENAPI.replace( + "date_of_birth: {type: string, format: date}", + "date_of_birth: {type: string, format: date, x-extra: true}", + ), + OPENAPI.replace( + " type: object\n required:", + " type: object\n minProperties: 1\n required:", + ), + ]; + for openapi in cases { + let fixture = Fixture::new(&openapi, QUESTION, ANSWER, true); + let error = + compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unpreserved OpenAPI constraint is rejected"); + assert!(error.to_string().contains("unsupported key"), "{error:#}"); + assert!(fixture.staging_is_empty(), "{error:#}"); + } + } + + #[test] + fn punctuated_selector_and_fact_names_are_safely_quoted() { + let (openapi, question, answer) = punctuated_inputs(); + let fixture = Fixture::new(&openapi, &question, &answer, true); + let compiled = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect("punctuated names compile"); + + assert_eq!( + compiled.questions[0].subjects[0].selector_field, + "person-id.v1" + ); + let extract = fs::read_to_string( + fixture + .staging + .join("bundle/adapters/adult-status-source-extract.rhai"), + ) + .expect("extract script reads"); + assert!(extract.contains( + "facts[\"date-of.birth\"] = required(get_path(source_response, \"/date-of.birth\")" + )); + } + + #[test] + fn input_files_and_private_staging_are_fail_closed() { + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fs::set_permissions(&fixture.staging, fs::Permissions::from_mode(0o755)) + .expect("change staging mode"); + let error = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("public staging rejected"); + assert!(error.to_string().contains("0700")); + + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fs::write( + fixture.project.join("questions/notes.txt"), + b"not a question", + ) + .expect("write unexpected entry"); + let error = compile_local_project(&fixture.project, &fixture.staging, &fixture.evidence) + .expect_err("unexpected question entry rejected"); + assert!(error.to_string().contains("only questions/*.yaml")); + assert!(fixture.staging_is_empty()); + } + + #[test] + #[ignore = "run after building the sibling evidence binary with local source authentication"] + fn real_evidence_loader_accepts_the_compiled_tutorial_generation() { + let evidence = std::env::var_os("EVIDENCE_BIN") + .map(PathBuf::from) + .expect("set EVIDENCE_BIN to the built evidence binary"); + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts generated inputs"); + + let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); + fixture.add_question(AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER); + fixture.add_access_policy("age-checks", &["adult-status"]); + fixture.add_access_policy("service-routing", &["age-bracket"]); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts explicit access profiles"); + + let fixture = Fixture::new(OPENAPI, AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER, true); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts the controlled category"); + + let openapi = OPENAPI + .replace( + "required: [person_id, name, date_of_birth]", + "required: [person_id, name, date_of_birth, dose_count]", + ) + .replace( + " date_of_birth: {type: string, format: date}", + " date_of_birth: {type: string, format: date}\n dose_count: {type: integer, minimum: 0, maximum: 20}", + ); + let fixture = Fixture::new(&openapi, IMMUNIZATION_QUESTION, IMMUNIZATION_ANSWER, true); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts multiple governed answers"); + + let (openapi, question, answer) = punctuated_inputs(); + let fixture = Fixture::new(&openapi, &question, &answer, true); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts safely quoted punctuated names"); + + let fixture = Fixture::new( + MULTI_EVENT_OPENAPI, + MULTI_EVENT_QUESTION, + MULTI_EVENT_ANSWER, + true, + ); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts nested repeated fact extraction"); + + let fixture = Fixture::new( + RELATIONSHIP_OPENAPI, + RELATIONSHIP_QUESTION, + RELATIONSHIP_ANSWER, + true, + ); + crate::keygen::generate_scaffold_key_material( + &fixture.project.join("secrets"), + SIGNING_KEY_ID, + ) + .expect("generate local keys"); + compile_local_project(&fixture.project, &fixture.staging, &evidence) + .expect("real Evidence loader accepts multiple role-bound subjects"); + } + + fn punctuated_inputs() -> (String, String, String) { + let openapi = OPENAPI + .replace("person_id", "person-id.v1") + .replace("date_of_birth", "date-of.birth"); + let question = QUESTION + .replace("person_id", "person-id.v1") + .replace("date_of_birth", "date-of.birth"); + let answer = ANSWER.replace("facts.date_of_birth", "facts[\"date-of.birth\"]"); + (openapi, question, answer) + } + + struct Fixture { + _root: tempfile::TempDir, + project: PathBuf, + staging: PathBuf, + evidence: PathBuf, + } + + impl Fixture { + fn new(openapi: &str, question: &str, derivation: &str, check_succeeds: bool) -> Self { + let root = tempfile::tempdir().expect("tempdir"); + let project = root.path().join("project"); + fs::create_dir(&project).expect("project"); + fs::create_dir(project.join("questions")).expect("questions"); + fs::create_dir(project.join("derivations")).expect("derivations"); + let mut secrets = fs::DirBuilder::new(); + secrets.mode(0o700); + secrets.create(project.join("secrets")).expect("secrets"); + fs::write(project.join(OPENAPI_FILE), openapi).expect("OpenAPI"); + + let staging = root.path().join("staging"); + let mut private = fs::DirBuilder::new(); + private.mode(0o700); + private.create(&staging).expect("staging"); + + let evidence = root.path().join("evidence-stub"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&evidence) + .expect("stub"); + let script = if check_succeeds { + "#!/bin/sh\ntest \"$1\" = --runtime && test \"$3\" = check\n" + } else { + "#!/bin/sh\necho 'script rejected' >&2\nexit 1\n" + }; + file.write_all(script.as_bytes()).expect("write stub"); + + let fixture = Self { + _root: root, + project, + staging, + evidence, + }; + fixture.add_question(question, derivation); + fixture + } + + fn add_question(&self, question: &str, derivation: &str) { + let parsed: Question = serde_norway::from_str(question).expect("question parses"); + fs::write( + self.project + .join("questions") + .join(format!("{}.yaml", parsed.id)), + question, + ) + .expect("question"); + fs::write(self.project.join(&parsed.derivation), derivation).expect("derivation"); + } + + fn add_access_policy(&self, id: &str, questions: &[&str]) { + fs::create_dir_all(self.project.join("access/policies")).expect("policy directory"); + let policy = json!({"version": 1, "id": id, "questions": questions}); + fs::write( + self.project + .join("access/policies") + .join(format!("{id}.yaml")), + serde_norway::to_string(&policy).expect("policy YAML"), + ) + .expect("policy"); + } + + fn staging_is_empty(&self) -> bool { + fs::read_dir(&self.staging) + .expect("read staging") + .next() + .is_none() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if self.staging.join("bundle").is_dir() { + let _ = set_bundle_modes(&self.staging.join("bundle"), 0o700, 0o600); + } + if self.staging.join("runtime.yaml").is_file() { + let _ = fs::set_permissions( + self.staging.join("runtime.yaml"), + fs::Permissions::from_mode(0o600), + ); + } + } + } + + fn tree(root: &Path) -> Vec { + fn visit(root: &Path, current: &Path, output: &mut Vec) { + let mut entries = fs::read_dir(current) + .expect("read directory") + .collect::>>() + .expect("entries"); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let relative = path.strip_prefix(root).unwrap().to_string_lossy(); + if path.is_dir() { + output.push(format!("{relative}/")); + visit(root, &path, output); + } else { + output.push(relative.into_owned()); + } + } + } + let mut output = Vec::new(); + visit(root, root, &mut output); + output + } + + fn assert_mode(path: &Path, expected: u32) { + let actual = fs::metadata(path).unwrap().permissions().mode() & 0o7777; + assert_eq!(actual, expected, "mode of {}", path.display()); + } +} diff --git a/crates/registry-evidencectl/src/build.rs b/crates/registry-evidencectl/src/build.rs new file mode 100644 index 000000000..691981fad --- /dev/null +++ b/crates/registry-evidencectl/src/build.rs @@ -0,0 +1,817 @@ +//! Compile an editable Evidence authoring project into one closed production +//! candidate. Production secrets and target-host paths remain operator-owned. + +use std::{ + collections::BTreeSet, + fs::{self, File, OpenOptions}, + io::{Read as _, Seek as _, Write as _}, + os::unix::fs::{ + DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _, + }, + path::{Component, Path, PathBuf}, + process::{Command, ExitCode, ExitStatus, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; + +use anyhow::{anyhow, bail, Context as _, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::Args; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use crate::{authoring, fixtures, keygen}; + +const MAX_TARGET_BYTES: u64 = 1024 * 1024; +const MAX_EVIDENCE_STDOUT_BYTES: u64 = 1024 * 1024; +const SECRET_PREFIX: &str = "secret:file/"; +const VALIDATION_CA: &[u8] = br#"-----BEGIN CERTIFICATE----- +MIIDMzCCAhugAwIBAgIULquGuNJ2HotUWgpEcRBAdsEtTkUwDQYJKoZIhvcNAQEL +BQAwKTEnMCUGA1UEAwweZXZpZGVuY2VjdGwtdmFsaWRhdGlvbi5pbnZhbGlkMB4X +DTI2MDgwNDE1MjIxMloXDTM2MDgwMTE1MjIxMlowKTEnMCUGA1UEAwweZXZpZGVu +Y2VjdGwtdmFsaWRhdGlvbi5pbnZhbGlkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqaK57iK2Xspf35AsdY0lCOkUgGRFP7cheDnl855jeW1izSt9ZbBZ +BO9TbUo2J5WnNApOIQFi/57kxX/9HUaTHxaQXsFRgLolYCU5CSWuAI5JMDP0OH+H +xni8AJ1j/cOFovhg/eqRAatF97tBu5Wxh6ghl1eDmZOVeboM/OHns4hauxi6zkdC +oq0ZF7XAQTM7WYbmSewfXcaY5Px4YtyuDJoTVBzsVkp9X3OposyicAXT/5BqPqjC +2jCnM9/PsO9ZpzSZTzeYn06QRtED3hCruCc3isMlWr5lE/KMvMvm9Q+q7+VfariD +qL2UuK4hCRcvTzcbW3s67x3DsohcbuA/OQIDAQABo1MwUTAdBgNVHQ4EFgQUAHgZ +2TkaFqS4edYq+6zlsG6aBDwwHwYDVR0jBBgwFoAUAHgZ2TkaFqS4edYq+6zlsG6a +BDwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAeOvtXp0JMcQw +ouUNvQGlPvu2bcfjsEfvzKOyzjRKmgf4RZYXdFTbV+TkRWjUHjkKjkGE8T18bnBs +3bLuzx0/UJw0b5BxTVSevUgmjnSDqK8XBS8ZyBomcB9MQ+MwPO4ssTDPsZCqOLao +GlhP5e68cbZwmC2YYtgu/bPRSMtlYzTp6wQv2voDlSPZgCUlzfTU67yKsS0dnQaV +wObsZ58XF4WVjuNtyoxtqToUtnrdCP9HUG/I5QiD54IFlVx2dqeWhLa/oyMeAxiR +R1YU60RrYIjPIGEnL+L1WuwoOEu8x09ly2/9wuIWhQPNgVMTCzjwnt8XdVuNecD6 +MRmJRtyidQ== +-----END CERTIFICATE----- +"#; + +#[derive(Debug, Args)] +pub struct BuildArgs { + /// Editable Evidence authoring project; defaults to the current directory. + #[arg(long, default_value = ".")] + pub project: PathBuf, + + /// Explicit production target containing governance.yaml and runtime.yaml. + #[arg(long)] + pub target: PathBuf, + + /// New candidate directory to create. It must not already exist. + #[arg(long)] + pub output: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TargetGovernance { + version: u32, + assurance_profile: String, + service: Value, + issuer: Value, + authentication: Value, + audit: Value, + subject_binding: Value, + rate_limits: Value, + signing: Value, + #[serde(default)] + response_formats: Option, + authority_profiles: Value, +} + +impl TargetGovernance { + fn into_bundle(self) -> Result { + if self.version != 1 { + bail!("production governance version must be 1"); + } + if self.assurance_profile != "production" { + bail!("evidencectl build requires assuranceProfile: production"); + } + if self + .authority_profiles + .as_object() + .is_none_or(Map::is_empty) + { + bail!("production governance requires at least one authority profile"); + } + let mut object = Map::from_iter([ + ("version".to_owned(), json!(self.version)), + ( + "assuranceProfile".to_owned(), + Value::String(self.assurance_profile), + ), + ("service".to_owned(), self.service), + ("issuer".to_owned(), self.issuer), + ("authentication".to_owned(), self.authentication), + ("audit".to_owned(), self.audit), + ("subjectBinding".to_owned(), self.subject_binding), + ("rateLimits".to_owned(), self.rate_limits), + ("signing".to_owned(), self.signing), + ("authorityProfiles".to_owned(), self.authority_profiles), + ]); + if let Some(response_formats) = self.response_formats { + object.insert("responseFormats".to_owned(), response_formats); + } + Ok(Value::Object(object)) + } +} + +pub fn run(args: BuildArgs) -> Result { + let interruption = BuildInterruption::install()?; + run_inner(args, &interruption) +} + +fn run_inner(args: BuildArgs, interruption: &BuildInterruption) -> Result { + interruption.check()?; + reject_existing_output(&args.output)?; + let project = plain_directory(&args.project, "production project")?; + let output_parent = plain_parent(&args.output)?; + let candidate = output_parent.join( + args.output + .file_name() + .ok_or_else(|| anyhow!("candidate output must name one new directory"))?, + ); + if candidate.starts_with(&project) { + bail!("candidate output must remain outside the editable project"); + } + let target = plain_directory(&args.target, "production target")?; + let governance_bytes = read_plain_file( + &target.join("governance.yaml"), + MAX_TARGET_BYTES, + "production governance", + )?; + let target_runtime = read_plain_file( + &target.join("runtime.yaml"), + MAX_TARGET_BYTES, + "production runtime", + )?; + let governance: TargetGovernance = serde_norway::from_slice(&governance_bytes) + .context("production governance is not the closed Version 1 target shape")?; + let governed_bundle = governance.into_bundle()?; + let evidence_bin = fixtures::resolve_evidence_binary(None)?; + + interruption.check()?; + let staging = tempfile::Builder::new() + .prefix(".evidencectl-build-") + .tempdir_in(&output_parent) + .with_context(|| format!("staging the candidate in {}", output_parent.display()))?; + fs::set_permissions(staging.path(), fs::Permissions::from_mode(0o700)) + .context("setting private production candidate staging permissions")?; + let result = prepare_candidate( + &project, + staging.path(), + &target_runtime, + governed_bundle, + &evidence_bin, + &output_parent, + interruption, + ); + let (revision, secret_references) = match result { + Ok(result) => result, + Err(error) => { + close_candidate_staging(staging)?; + return Err(error); + } + }; + if let Err(error) = interruption.check() { + close_candidate_staging(staging)?; + return Err(error); + } + publish(staging, &args.output)?; + + println!("Bundle revision: {revision}"); + println!("Candidate: {}", args.output.display()); + for reference in secret_references { + println!("Provision {SECRET_PREFIX}{reference}"); + } + println!( + "Target runtime paths and production secret material remain unverified until `evidencectl doctor --project {}` and the target-host Evidence check.", + args.output.display() + ); + Ok(ExitCode::SUCCESS) +} + +struct BuildInterruption { + requested: Arc, + registrations: Vec, +} + +impl BuildInterruption { + fn install() -> Result { + let mut guard = Self { + requested: Arc::new(AtomicBool::new(false)), + registrations: Vec::new(), + }; + for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { + let registration = signal_hook::flag::register(signal, Arc::clone(&guard.requested)) + .context("installing the production-build signal handler")?; + guard.registrations.push(registration); + } + Ok(guard) + } + + fn check(&self) -> Result<()> { + if self.requested.load(Ordering::Relaxed) { + bail!("production build interrupted"); + } + Ok(()) + } +} + +impl Drop for BuildInterruption { + fn drop(&mut self) { + for registration in self.registrations.drain(..) { + signal_hook::low_level::unregister(registration); + } + } +} + +fn prepare_candidate( + project: &Path, + staging_root: &Path, + target_runtime: &[u8], + governed_bundle: Value, + evidence_bin: &Path, + temporary_parent: &Path, + interruption: &BuildInterruption, +) -> Result<(String, Vec)> { + let compiled = authoring::compile_production_project(project, staging_root, governed_bundle)?; + interruption.check()?; + reject_review_markers(&compiled.bundle_path)?; + reject_review_markers_in_bytes(target_runtime, "production runtime")?; + let runtime_path = staging_root.join("runtime.yaml"); + write_new_file(&runtime_path, target_runtime, 0o600)?; + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o400)) + .context("sealing the copied production runtime")?; + + let secret_references = secret_references(&compiled.bundle)?; + let validation = tempfile::Builder::new() + .prefix(".evidencectl-build-validation-") + .tempdir_in(temporary_parent) + .context("creating private production validation state")?; + fs::set_permissions(validation.path(), fs::Permissions::from_mode(0o700)) + .context("setting private production validation permissions")?; + let validation_result = (|| -> Result { + let validation_runtime = prepare_validation_runtime( + validation.path(), + &compiled.bundle_path, + &compiled.bundle, + &secret_references, + )?; + interruption.check()?; + let revision = run_check(evidence_bin, &validation_runtime, interruption)?; + for fixture in &compiled.fixture_paths { + interruption.check()?; + run_fixture(evidence_bin, &validation_runtime, fixture, interruption)?; + } + interruption.check()?; + Ok(revision) + })(); + validation + .close() + .context("removing private production validation staging")?; + let revision = validation_result?; + Ok((revision, secret_references)) +} + +fn prepare_validation_runtime( + root: &Path, + bundle: &Path, + config: &Value, + secret_references: &[String], +) -> Result { + let secret_root = root.join("secrets"); + let active_ref = config + .pointer("/signing/activeKeyRef") + .and_then(Value::as_str) + .and_then(|value| value.strip_prefix(SECRET_PREFIX)) + .ok_or_else(|| anyhow!("production signing must use one logical file secret reference"))?; + let active_key_id = config + .pointer("/signing/activeKeyId") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("production signing must declare one active key id"))?; + keygen::generate_dev_keypair( + &secret_root, + active_key_id, + active_ref, + ".validation-public.jwk.json", + )?; + for reference in secret_references { + if reference == active_ref { + continue; + } + let mut entropy = [0_u8; 32]; + getrandom::fill(&mut entropy).context("generating temporary validation material")?; + let encoded = URL_SAFE_NO_PAD.encode(entropy); + write_new_file(&secret_root.join(reference), encoded.as_bytes(), 0o600)?; + } + + let ca_root = root.join("ca"); + create_private_directory(&ca_root)?; + let mut trust_profiles = Map::new(); + for profile in tls_trust_profiles(config)? { + let path = ca_root.join(format!("{profile}.pem")); + write_new_file(&path, VALIDATION_CA, 0o400)?; + trust_profiles.insert(profile, json!({"caBundleFile": path.to_string_lossy()})); + } + let audit = root.join("audit"); + create_private_directory(&audit)?; + let runtime = json!({ + "version": 1, + "bundleDirectory": fs::canonicalize(bundle)?.to_string_lossy(), + "listener": { + "bindHost": "127.0.0.1", + "port": 1, + "tlsTermination": "operator-controlled-upstream", + "trustProxyIdentityHeaders": false, + "maximumRequestBytes": 65536, + "maximumConcurrentRequests": 1, + "requestTimeoutMilliseconds": 10000, + "shutdownGraceMilliseconds": 30000, + }, + "secretProviders": {"file": {"root": fs::canonicalize(&secret_root)?.to_string_lossy()}}, + "auditStorage": { + "path": audit.join("evidence.jsonl").to_string_lossy(), + "maximumFileBytes": 1048576, + }, + "outboundTls": {"systemRoots": true, "trustProfiles": trust_profiles}, + }); + let path = root.join("runtime.yaml"); + let mut bytes = serde_norway::to_string(&runtime)?.into_bytes(); + if !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } + write_new_file(&path, &bytes, 0o400)?; + Ok(path) +} + +fn tls_trust_profiles(config: &Value) -> Result> { + let mut profiles = BTreeSet::new(); + if let Some(sources) = config.get("sources").and_then(Value::as_object) { + for source in sources.values() { + if let Some(profile) = source.get("tlsTrustProfile").and_then(Value::as_str) { + if !valid_local_identifier(profile) { + bail!("production TLS trust profile identifier is invalid"); + } + profiles.insert(profile.to_owned()); + } + } + } + Ok(profiles.into_iter().collect()) +} + +fn run_check( + evidence_bin: &Path, + runtime: &Path, + interruption: &BuildInterruption, +) -> Result { + let mut command = Command::new(evidence_bin); + command + .arg("--runtime") + .arg(runtime) + .arg("check") + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + let output = run_evidence(command, interruption, true)?; + if !output.status.success() { + return runtime_failure("Evidence rejected the generated production bundle"); + } + parse_bundle_revision(&String::from_utf8_lossy(&output.stdout)) +} + +fn run_fixture( + evidence_bin: &Path, + runtime: &Path, + fixture: &str, + interruption: &BuildInterruption, +) -> Result<()> { + let mut command = Command::new(evidence_bin); + command + .arg("--runtime") + .arg(runtime) + .arg("evaluate") + .arg("--fixture") + .arg(fixture) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + let output = run_evidence(command, interruption, false)?; + if output.status.success() { + return Ok(()); + } + runtime_failure("Evidence rejected a production fixture") +} + +struct EvidenceOutput { + status: ExitStatus, + stdout: Vec, +} + +fn run_evidence( + mut command: Command, + interruption: &BuildInterruption, + capture_stdout: bool, +) -> Result { + interruption.check()?; + let mut stdout = capture_stdout + .then(tempfile::tempfile) + .transpose() + .context("creating private Evidence output capture")?; + command.stdin(Stdio::null()).stderr(Stdio::null()); + if let Some(file) = &stdout { + command.stdout(Stdio::from(file.try_clone()?)); + } else { + command.stdout(Stdio::null()); + } + let mut child = command + .spawn() + .context("starting the Evidence production validation")?; + let status = loop { + if interruption.check().is_err() { + terminate_validation_child(&mut child); + return Err(anyhow!("production build interrupted")); + } + if stdout.as_ref().is_some_and(|file| { + file.metadata() + .is_ok_and(|metadata| metadata.len() > MAX_EVIDENCE_STDOUT_BYTES) + }) { + terminate_validation_child(&mut child); + bail!("Evidence production validation output exceeded its byte limit"); + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(error) => { + terminate_validation_child(&mut child); + return Err(error).context("waiting for Evidence production validation"); + } + } + }; + interruption.check()?; + + let mut captured = Vec::new(); + if let Some(file) = stdout.as_mut() { + file.rewind()?; + file.take(MAX_EVIDENCE_STDOUT_BYTES + 1) + .read_to_end(&mut captured)?; + if captured.len() as u64 > MAX_EVIDENCE_STDOUT_BYTES { + bail!("Evidence production validation output exceeded its byte limit"); + } + } + Ok(EvidenceOutput { + status, + stdout: captured, + }) +} + +fn terminate_validation_child(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn runtime_failure(message: &str) -> Result { + // Evidence diagnostics are intentionally not relayed here. A validation + // process may have opened operator-authored configuration, and build + // failures must remain value-free even if that subprocess is replaced. + bail!("{message}") +} + +fn parse_bundle_revision(stdout: &str) -> Result { + let revision = stdout + .lines() + .find_map(|line| line.strip_prefix("Evidence deployment ")) + .and_then(|line| line.split_whitespace().next()) + .filter(|value| { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .ok_or_else(|| anyhow!("Evidence check returned no bundle revision"))?; + Ok(revision.to_owned()) +} + +fn secret_references(value: &Value) -> Result> { + let mut references = BTreeSet::new(); + collect_secret_references(value, &mut references)?; + Ok(references.into_iter().collect()) +} + +fn collect_secret_references(value: &Value, references: &mut BTreeSet) -> Result<()> { + match value { + Value::String(value) => { + if let Some(reference) = value.strip_prefix(SECRET_PREFIX) { + if !valid_secret_name(reference) { + bail!("production logical file secret reference has invalid syntax"); + } + references.insert(reference.to_owned()); + } + } + Value::Array(values) => { + for value in values { + collect_secret_references(value, references)?; + } + } + Value::Object(values) => { + for value in values.values() { + collect_secret_references(value, references)?; + } + } + _ => {} + } + Ok(()) +} + +fn valid_secret_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn valid_local_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn reject_review_markers(bundle: &Path) -> Result<()> { + for path in bundle_files(bundle)? { + let bytes = fs::read(&path).context("reading one generated bundle artifact")?; + reject_review_markers_in_bytes(&bytes, "production bundle")?; + } + Ok(()) +} + +fn reject_review_markers_in_bytes(bytes: &[u8], description: &str) -> Result<()> { + if [ + b"TODO(evidencectl)".as_slice(), + b"review-required", + b"placeholder_fact", + ] + .iter() + .any(|marker| bytes.windows(marker.len()).any(|window| window == *marker)) + { + bail!("the {description} contains an unresolved authoring review marker"); + } + Ok(()) +} + +fn bundle_files(root: &Path) -> Result> { + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(path) = pending.pop() { + for entry in fs::read_dir(&path).context("walking the generated bundle")? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + bail!("the generated bundle contains a symbolic link"); + } + if metadata.is_dir() { + pending.push(path); + } else if metadata.is_file() { + files.push(path); + } else { + bail!("the generated bundle contains an unsupported entry"); + } + } + } + files.sort(); + Ok(files) +} + +fn reject_existing_output(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => bail!("output already exists; evidencectl build never overwrites a candidate"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("inspecting the candidate output path"), + } +} + +fn plain_parent(path: &Path) -> Result { + if !matches!(path.components().next_back(), Some(Component::Normal(_))) { + bail!("candidate output must name one new directory"); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + validate_plain_components(parent, "candidate parent", true)?; + fs::canonicalize(parent).context("resolving the candidate parent") +} + +fn plain_directory(path: &Path, description: &str) -> Result { + validate_plain_components(path, description, true)?; + fs::canonicalize(path).with_context(|| format!("resolving {description} directory")) +} + +fn validate_plain_components( + path: &Path, + description: &str, + final_is_directory: bool, +) -> Result<()> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let components = absolute.components().collect::>(); + let mut current = PathBuf::new(); + for (index, component) in components.iter().enumerate() { + match component { + Component::RootDir => current.push(Path::new("/")), + Component::Normal(value) => current.push(value), + Component::CurDir => continue, + Component::ParentDir | Component::Prefix(_) => { + bail!("{description} must not contain path traversal") + } + } + let metadata = + fs::symlink_metadata(¤t).with_context(|| format!("inspecting {description}"))?; + if metadata.file_type().is_symlink() { + bail!("{description} must not traverse symbolic links"); + } + let is_final = index + 1 == components.len(); + if (!is_final || final_is_directory) && !metadata.is_dir() { + bail!("{description} must be an existing plain directory"); + } + } + Ok(()) +} + +fn read_plain_file(path: &Path, maximum: u64, description: &str) -> Result> { + use rustix::fs::{Mode, OFlags}; + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("opening {description}"))?; + let mut file = File::from(descriptor); + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > maximum { + bail!("{description} must be a bounded regular file"); + } + let mut bytes = Vec::new(); + std::io::Read::by_ref(&mut file) + .take(maximum + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > maximum { + bail!("{description} exceeds its byte limit"); + } + Ok(bytes) +} + +fn create_private_directory(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(path) + .with_context(|| format!("creating {}", path.display())) +} + +fn write_new_file(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(contents)?; + file.sync_all()?; + Ok(()) +} + +fn make_tree_removable(root: &Path) -> Result<()> { + for entry in fs::read_dir(root)? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + bail!("private staging contains a symbolic link"); + } + if metadata.is_dir() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + make_tree_removable(&path)?; + } else if metadata.is_file() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } else { + bail!("private staging contains an unsupported entry"); + } + } + fs::set_permissions(root, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn close_candidate_staging(staging: tempfile::TempDir) -> Result<()> { + make_tree_removable(staging.path())?; + staging + .close() + .context("removing private production candidate staging") +} + +fn publish(staging: tempfile::TempDir, output: &Path) -> Result<()> { + let staged = staging.keep(); + if let Err(error) = rename_noreplace(&staged, output) { + let _ = make_tree_removable(&staged); + let _ = fs::remove_dir_all(&staged); + return Err(error).context("publishing the production candidate without replacement"); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace candidate publication is unsupported on this platform", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn production_governance_is_closed_at_the_target_boundary() { + let unknown = serde_norway::from_str::( + r#"version: 1 +assuranceProfile: production +service: {} +issuer: {} +authentication: {} +audit: {} +subjectBinding: {} +rateLimits: {} +signing: {} +authorityProfiles: {} +requirements: [] +"#, + ); + assert!(unknown.is_err()); + } + + #[test] + fn revision_and_secret_reference_parsing_are_closed() { + let revision = format!("Evidence deployment sha256:{}\n", "a".repeat(64)); + assert_eq!( + parse_bundle_revision(&revision).expect("revision"), + format!("sha256:{}", "a".repeat(64)) + ); + assert!(parse_bundle_revision("Evidence deployment sha256:not-a-digest\n").is_err()); + + let names = secret_references(&json!({ + "z": "secret:file/source-token", + "a": ["secret:file/audit-key", "secret:file/source-token"] + })) + .expect("references"); + assert_eq!(names, ["audit-key", "source-token"]); + assert!(secret_references(&json!({"key": "secret:file/../escape"})).is_err()); + assert!(secret_references(&json!({"key": "secret:file/nested/escape"})).is_err()); + assert!(tls_trust_profiles(&json!({ + "sources": {"source": {"tlsTrustProfile": "../../escape"}} + })) + .is_err()); + } + + #[test] + fn target_and_candidate_paths_reject_ancestor_symlinks() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = fs::canonicalize(temporary.path()).expect("canonical tempdir"); + let actual = root.join("actual"); + fs::create_dir(&actual).expect("actual directory"); + let link = root.join("link"); + symlink(&actual, &link).expect("ancestor symlink"); + + assert!(plain_directory(&link, "production target").is_err()); + assert!(plain_parent(&link.join("candidate")).is_err()); + } + + #[test] + fn review_markers_are_rejected_without_repeating_authored_values() { + for marker in ["TODO(evidencectl)", "review-required", "placeholder_fact"] { + let error = reject_review_markers_in_bytes(marker.as_bytes(), "production runtime") + .expect_err("review marker rejected") + .to_string(); + assert!(!error.contains(marker)); + } + } +} diff --git a/crates/registry-evidencectl/src/dev.rs b/crates/registry-evidencectl/src/dev.rs new file mode 100644 index 000000000..442cffd4f --- /dev/null +++ b/crates/registry-evidencectl/src/dev.rs @@ -0,0 +1,2308 @@ +//! Minimal private lifecycle for local Evidence tutorials. +//! +//! The final `.evidence/dev` directory is compiled in place because the +//! runtime contains absolute paths. A resident supervisor owns both service +//! children and is the only process allowed to stop them. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, Metadata, OpenOptions}, + io::{Read as _, Write as _}, + os::unix::{ + fs::{ + symlink, DirBuilderExt as _, FileTypeExt as _, MetadataExt as _, OpenOptionsExt as _, + PermissionsExt as _, + }, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + process::{Child, Command, ExitCode, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, bail, Context as _, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use crate::{ + access, + authoring::{ + access_policy_requester_tag, compile_local_project_with_ports, CompiledAccessPolicy, + CompiledConceptForm, CompiledProject, CompiledQuestion, LocalServicePorts, + }, + keygen, +}; + +const STATE_SCHEMA: &str = "registry.evidencectl.dev-state/v5"; +const CONTROL_SOCKET_NAME: &str = "control.sock"; +const CALLER_ID: &str = "local-tutorial-caller"; +const LOCAL_ACCESS_TOKEN_AUDIENCE: &str = "registry-evidence-local"; +const LOCAL_CALLER_EVIDENCE_AUDIENCE: &str = "urn:registrystack:evidence:local:caller"; +const LOCAL_REQUESTER_TAG: &str = "local-caller"; +const MINT_KEY_ID: &str = "local-mint-signing-key-1"; +const CALLER_KEY_ID: &str = "local-tutorial-caller-key-1"; +const MINT_AUDIT_KEY_FILENAME: &str = "mint-audit-hmac-key"; +const PRIVATE_DIR_MODE: u32 = 0o700; +const PRIVATE_FILE_MODE: u32 = 0o600; +const MAX_STATE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_HTTP_BODY_BYTES: u64 = 64 * 1024; +const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 45; +const SHUTDOWN_TIMEOUT_SECONDS: u64 = 35; + +#[derive(Debug, Args)] +pub struct DevArgs { + #[command(subcommand)] + action: Option, + + /// Return after Mint and Evidence are ready on loopback. + #[arg(long)] + detach: bool, + + /// Loopback port for the local Evidence service. + #[arg(long, default_value_t = 8080)] + evidence_port: u16, + + /// Loopback port for the local Mint service. + #[arg(long, default_value_t = 8081)] + mint_port: u16, + + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, + + #[arg(long, hide = true)] + evidence_bin: Option, + + #[arg(long, hide = true)] + mint_bin: Option, + + #[arg( + long, + default_value_t = DEFAULT_READY_TIMEOUT_SECONDS, + value_parser = clap::value_parser!(u64).range(1..=120), + hide = true + )] + ready_timeout_seconds: u64, +} + +#[derive(Debug, Subcommand)] +enum DevAction { + /// Stop the active local Mint and Evidence pair. + Stop(StopArgs), + /// Remove one completed stopped local generation. + Clean(CleanArgs), +} + +#[derive(Debug, Args)] +struct StopArgs { + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Debug, Args)] +struct CleanArgs { + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, +} + +#[derive(Debug, Args)] +pub struct SupervisorArgs { + #[arg(long)] + dev_root: PathBuf, + #[arg(long)] + evidence_bin: PathBuf, + #[arg(long)] + mint_bin: PathBuf, + #[arg(long)] + ready_timeout_seconds: u64, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum DevStatus { + Starting, + Ready, + Stopping, + Stopped, + Failed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum FailureKind { + MintStart, + MintReadiness, + EvidenceStart, + EvidenceReadiness, + ChildExited, + Supervisor, + SupervisorSignal, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DevState { + schema: String, + status: DevStatus, + project: PathBuf, + runtime_path: PathBuf, + evidence_origin: String, + mint_origin: String, + token_url: String, + access_token_audience: String, + caller: Option, + access_policies: Vec, + questions: Vec, + failure: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CallerState { + client_id: String, + private_key_path: PathBuf, + assertion_audience: String, + evidence_audience: String, + requester_tag: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AccessPolicyState { + id: String, + requester_tag: String, + questions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct QuestionState { + alias: String, + requirement_uri: String, + purpose: String, + subjects: Vec, + concepts: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SubjectState { + role: String, + selector_profile: String, + selector_field: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ConceptState { + alias: String, + uri: String, + form: String, +} + +/// Validated active-session inputs for native request preparation. +/// +/// Consumers use this seam instead of parsing private state themselves. Every +/// path is absolute and revalidated against the one ready `.evidence/dev` +/// session before it is returned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadyDevState { + pub(crate) project: PathBuf, + pub(crate) runtime_path: PathBuf, + pub(crate) evidence_origin: String, + pub(crate) mint_origin: String, + pub(crate) token_url: String, + pub(crate) access_token_audience: String, + pub(crate) caller: Option, + pub(crate) access_policies: Vec, + pub(crate) questions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadyCallerState { + pub(crate) client_id: String, + pub(crate) private_key_path: PathBuf, + pub(crate) assertion_audience: String, + pub(crate) evidence_audience: String, + pub(crate) requester_tag: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadyAccessPolicy { + pub(crate) id: String, + pub(crate) requester_tag: String, + pub(crate) questions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadyQuestionState { + pub(crate) alias: String, + pub(crate) requirement_uri: String, + pub(crate) purpose: String, + pub(crate) subjects: Vec, + pub(crate) concepts: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadySubjectState { + pub(crate) role: String, + pub(crate) selector_profile: String, + pub(crate) selector_field: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReadyConceptState { + pub(crate) alias: String, + pub(crate) uri: String, + pub(crate) form: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StoppedDevState { + pub(crate) runtime_path: PathBuf, + pub(crate) questions: Vec, +} + +pub(crate) struct LifecycleLock { + _file: File, +} + +#[derive(Default)] +struct OwnedChildren { + evidence: Option, + mint: Option, +} + +impl OwnedChildren { + fn stop(&mut self) { + stop_children(self.evidence.as_mut(), self.mint.as_mut()); + self.evidence = None; + self.mint = None; + } +} + +impl Drop for OwnedChildren { + fn drop(&mut self) { + self.stop(); + } +} + +pub fn run(args: DevArgs) -> Result { + match args.action { + Some(DevAction::Stop(stop)) => { + if args.detach { + bail!("`dev stop` does not accept `--detach`"); + } + stop_dev(&stop.project) + } + Some(DevAction::Clean(clean)) => { + if args.detach { + bail!("`dev clean` does not accept `--detach`"); + } + clean_dev(&clean.project) + } + None => { + if !args.detach { + bail!("the local development lifecycle requires `evidencectl dev --detach`"); + } + let ports = LocalServicePorts::new(args.evidence_port, args.mint_port)?; + start_detached( + &args.project, + args.evidence_bin.as_deref(), + args.mint_bin.as_deref(), + args.ready_timeout_seconds, + ports, + ) + } + } +} + +pub fn run_supervisor(args: SupervisorArgs) -> Result { + let dev_root = args.dev_root.clone(); + let terminate = Arc::new(AtomicBool::new(false)); + let result = (|| { + // SIGKILL cannot be observed and is intentionally not part of this + // tutorial lifecycle. Catchable operator signals use owned cleanup. + for signal in [ + signal_hook::consts::SIGTERM, + signal_hook::consts::SIGHUP, + signal_hook::consts::SIGINT, + ] { + signal_hook::flag::register(signal, Arc::clone(&terminate)) + .context("failed to install the local supervisor signal handler")?; + } + injected_supervisor_failure("before-setsid")?; + rustix::process::setsid().context("failed to detach the local supervisor session")?; + publish_test_supervisor_pid()?; + supervise(args, &terminate) + })(); + if let Err(error) = result { + let _ = publish_supervisor_failure(&dev_root, FailureKind::Supervisor); + return Err(error); + } + Ok(ExitCode::SUCCESS) +} + +#[allow(dead_code)] // Crate-private handoff for the immediately following request slice. +pub(crate) fn load_ready_state(project: &Path) -> Result { + let project = canonical_project(project)?; + let generated_root = existing_private_generated_root(&project)?; + let dev_root = generated_root.join("dev"); + validate_private_directory(&dev_root)?; + let state = read_state(&dev_root.join("state.json"))?; + if state.status != DevStatus::Ready || state.failure.is_some() { + bail!("the local development state is not the closed ready session"); + } + validate_closed_state(&state, &project, &dev_root)?; + require_owned_regular_file(&state.runtime_path, 0o400)?; + if let Some(caller) = &state.caller { + require_owned_regular_file(&caller.private_key_path, PRIVATE_FILE_MODE)?; + } + validate_control_socket(&dev_root.join("control.sock"))?; + Ok(ReadyDevState { + project, + runtime_path: state.runtime_path, + evidence_origin: state.evidence_origin, + mint_origin: state.mint_origin, + token_url: state.token_url, + access_token_audience: state.access_token_audience, + caller: state.caller.map(|caller| ReadyCallerState { + client_id: caller.client_id, + private_key_path: caller.private_key_path, + assertion_audience: caller.assertion_audience, + evidence_audience: caller.evidence_audience, + requester_tag: caller.requester_tag, + }), + access_policies: state + .access_policies + .into_iter() + .map(|policy| ReadyAccessPolicy { + id: policy.id, + requester_tag: policy.requester_tag, + questions: policy.questions, + }) + .collect(), + questions: state.questions.into_iter().map(ready_question).collect(), + }) +} + +#[allow(dead_code)] // Consumed by the access-management CLI slice. +pub(crate) fn try_load_ready_state(project: &Path) -> Result> { + let project = canonical_project(project)?; + let generated_root = project.join(".evidence"); + match fs::symlink_metadata(&generated_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Ok(_) => validate_private_directory(&generated_root)?, + Err(error) => return Err(error.into()), + } + let dev_root = generated_root.join("dev"); + match fs::symlink_metadata(&dev_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Ok(_) => validate_private_directory(&dev_root)?, + Err(error) => return Err(error.into()), + } + let state = read_state(&dev_root.join("state.json"))?; + match state.status { + DevStatus::Ready if state.failure.is_none() => load_ready_state(&project).map(Some), + DevStatus::Stopped if state.caller.is_none() && state.failure.is_none() => { + load_stopped_state(&project)?; + Ok(None) + } + _ => bail!("local development state exists but is neither ready nor cleanly stopped"), + } +} + +#[allow(dead_code)] // Crate-private handoff for the immediately following audit slice. +pub(crate) fn load_stopped_state(project: &Path) -> Result { + let project = canonical_project(project)?; + let generated_root = existing_private_generated_root(&project)?; + let dev_root = generated_root.join("dev"); + validate_private_directory(&dev_root)?; + let state = read_state(&dev_root.join("state.json"))?; + if state.status != DevStatus::Stopped || state.caller.is_some() || state.failure.is_some() { + bail!("the local development state is not the closed stopped session"); + } + validate_closed_state(&state, &project, &dev_root)?; + require_owned_regular_file(&state.runtime_path, 0o400)?; + match fs::symlink_metadata(dev_root.join("control.sock")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => bail!("stopped local state still has a control path"), + Err(error) => return Err(error.into()), + } + Ok(StoppedDevState { + runtime_path: state.runtime_path, + questions: state.questions.into_iter().map(ready_question).collect(), + }) +} + +/// Ask the private local supervisor to make Mint reload its complete client +/// registry. This confirms only that SIGHUP was delivered. The next token +/// request remains the functional proof that Mint accepted the new registry. +#[allow(dead_code)] // Consumed by the access-management CLI slice. +pub(crate) fn request_mint_reload(project: &Path) -> Result<()> { + let project = canonical_project(project)?; + let generated_root = existing_private_generated_root(&project)?; + let dev_root = generated_root.join("dev"); + validate_private_directory(&dev_root)?; + let state = read_state(&dev_root.join("state.json"))?; + if state.status != DevStatus::Ready || state.failure.is_some() { + bail!("the local development state is not ready for a Mint reload"); + } + validate_closed_state(&state, &project, &dev_root)?; + let socket = dev_root.join(CONTROL_SOCKET_NAME); + validate_control_socket(&socket)?; + send_control_request(&socket, b"reload-mint\n", b"reload-requested\n") + .context("the local supervisor did not accept the Mint reload request") +} + +fn send_control_request(socket: &Path, request: &[u8], expected: &[u8]) -> Result<()> { + let parent = socket + .parent() + .ok_or_else(|| anyhow!("local control socket has no parent"))?; + let bridge = tempfile::Builder::new() + .prefix("ec-") + .tempdir() + .context("creating a short private control path")?; + fs::set_permissions(bridge.path(), fs::Permissions::from_mode(PRIVATE_DIR_MODE))?; + let link = bridge.path().join("d"); + symlink(parent, &link).context("creating a short private control path")?; + let mut stream = UnixStream::connect(link.join(CONTROL_SOCKET_NAME)) + .context("the local supervisor is unavailable")?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.write_all(request)?; + stream.shutdown(std::net::Shutdown::Write)?; + let mut response = Vec::new(); + (&mut stream).take(64).read_to_end(&mut response)?; + if response != expected { + bail!("the local supervisor returned an unexpected control response"); + } + Ok(()) +} + +fn validate_closed_state(state: &DevState, project: &Path, dev_root: &Path) -> Result<()> { + let evidence_port = local_origin_port(&state.evidence_origin); + let mint_port = local_origin_port(&state.mint_origin); + let origins_are_closed = matches!((evidence_port, mint_port), (Some(evidence), Some(mint)) if evidence != mint) + && state.token_url == format!("{}/token", state.mint_origin); + let questions_are_closed = !state.questions.is_empty() + && state.questions.len() <= 128 + && state.questions.iter().all(valid_question_state) + && state + .questions + .iter() + .map(|question| question.alias.as_str()) + .collect::>() + .len() + == state.questions.len(); + let question_aliases = state + .questions + .iter() + .map(|question| question.alias.as_str()) + .collect::>(); + let access_policies_are_closed = state.access_policies.len() <= 128 + && state + .access_policies + .windows(2) + .all(|pair| pair[0].id < pair[1].id) + && state + .access_policies + .iter() + .all(|policy| valid_access_policy_state(policy, &question_aliases)) + && state + .access_policies + .iter() + .map(|policy| policy.requester_tag.as_str()) + .collect::>() + .len() + == state.access_policies.len() + && state_matches_sealed_bundle(&state.questions, &state.access_policies, dev_root) + .unwrap_or(false); + let caller_is_closed = match state.status { + DevStatus::Starting | DevStatus::Ready => { + state.access_policies.is_empty() == state.caller.is_some() + } + DevStatus::Stopped => state.caller.is_none(), + DevStatus::Stopping | DevStatus::Failed => true, + } && state + .caller + .as_ref() + .is_none_or(|caller| validate_closed_caller(caller, dev_root, &state.token_url).is_ok()); + if state.project != project + || state.runtime_path != dev_root.join("runtime.yaml") + || !origins_are_closed + || state.access_token_audience != LOCAL_ACCESS_TOKEN_AUDIENCE + || !questions_are_closed + || !access_policies_are_closed + || !caller_is_closed + { + bail!("the local development state contains values outside the closed lifecycle profile"); + } + Ok(()) +} + +fn valid_access_policy_state( + policy: &AccessPolicyState, + question_aliases: &BTreeSet<&str>, +) -> bool { + valid_local_identifier(&policy.id) + && !policy.questions.is_empty() + && policy.questions.len() <= 128 + && policy.questions.windows(2).all(|pair| pair[0] < pair[1]) + && policy + .questions + .iter() + .all(|question| question_aliases.contains(question.as_str())) + && access_policy_requester_tag(&policy.id, &policy.questions) + .is_ok_and(|tag| tag == policy.requester_tag) +} + +fn valid_question_state(question: &QuestionState) -> bool { + let identifiers_are_closed = [question.alias.as_str(), question.purpose.as_str()] + .into_iter() + .all(valid_local_identifier); + let concepts_are_closed = !question.concepts.is_empty() + && question.concepts.len() <= 16 + && question.concepts.iter().all(valid_concept_state) + && question + .concepts + .iter() + .map(|concept| concept.alias.as_str()) + .collect::>() + .len() + == question.concepts.len(); + let subject_count = question.subjects.len(); + let subjects_are_closed = !question.subjects.is_empty() + && question.subjects.len() <= 8 + && question.subjects.iter().all(|subject| { + valid_local_identifier(&subject.role) + && valid_local_identifier(&subject.selector_profile) + && valid_local_identifier(&subject.selector_field) + && (!subject.selector_profile.starts_with("local-subject-") + || subject.selector_profile + == if subject_count == 1 { + format!("local-subject-{}-v1", question.alias) + } else { + format!("local-subject-{}-{}-v1", question.alias, subject.role) + }) + }) + && question + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>() + .len() + == question.subjects.len(); + identifiers_are_closed + && valid_uri(&question.requirement_uri) + && subjects_are_closed + && concepts_are_closed +} + +fn valid_concept_state(concept: &ConceptState) -> bool { + valid_local_identifier(&concept.alias) + && valid_uri(&concept.uri) + && matches!( + concept.form.as_str(), + "boolean" | "controlled-category" | "bounded-integer" | "reviewed-structured-value" + ) +} + +fn valid_uri(value: &str) -> bool { + value.len() <= 512 && url::Url::parse(value).is_ok() +} + +fn state_matches_sealed_bundle( + questions: &[QuestionState], + access_policies: &[AccessPolicyState], + dev_root: &Path, +) -> Result { + let path = dev_root.join("bundle/evidence.yaml"); + require_owned_regular_file(&path, 0o400)?; + let bytes = fs::read(&path).context("failed to read the sealed local bundle")?; + if bytes.len() > 1024 * 1024 { + return Ok(false); + } + let bundle: Value = + serde_norway::from_slice(&bytes).context("the sealed local bundle is invalid")?; + let requirements = bundle + .get("requirements") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("the sealed local bundle has no requirements"))?; + if requirements.len() != questions.len() { + return Ok(false); + } + let selector_profiles = bundle + .get("selectorProfiles") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("the sealed local bundle has no selector profiles"))?; + for question in questions { + let Some(requirement) = requirements.iter().find(|requirement| { + requirement.get("id").and_then(Value::as_str) == Some(question.requirement_uri.as_str()) + }) else { + return Ok(false); + }; + if !requirement + .get("purposes") + .and_then(Value::as_array) + .is_some_and(|purposes| *purposes == [Value::String(question.purpose.clone())]) + { + return Ok(false); + } + let concepts = requirement + .get("concepts") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("a sealed local requirement has no concepts"))?; + if concepts.len() != question.concepts.len() + || question.concepts.iter().any(|concept| { + !concepts.iter().any(|configured| { + configured.get("id").and_then(Value::as_str) == Some(concept.uri.as_str()) + && configured.get("form").and_then(Value::as_str) + == Some(concept.form.as_str()) + }) + }) + { + return Ok(false); + } + let roles = requirement + .get("subjectRoles") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("a sealed local requirement has no subject roles"))?; + if roles.len() != question.subjects.len() + || question.subjects.iter().any(|subject| { + !roles.iter().any(|role| { + role.get("role").and_then(Value::as_str) == Some(subject.role.as_str()) + && role + .get("selectorProfiles") + .and_then(Value::as_array) + .is_some_and(|profiles| { + profiles.iter().any(|profile| { + profile.as_str() == Some(subject.selector_profile.as_str()) + }) + }) + }) || !selector_profiles + .get(&subject.selector_profile) + .and_then(|profile| profile.get("fields")) + .and_then(Value::as_object) + .is_some_and(|fields| fields.contains_key(&subject.selector_field)) + }) + { + return Ok(false); + } + } + let authority_profiles = bundle + .get("authorityProfiles") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("the sealed local bundle has no authority profiles"))?; + if access_policies.is_empty() { + let Some(profile) = authority_profiles.get(LOCAL_REQUESTER_TAG) else { + return Ok(false); + }; + return Ok(authority_profiles.len() == 1 + && authority_profile_matches( + profile, + LOCAL_REQUESTER_TAG, + &questions.iter().collect::>(), + )); + } + if authority_profiles.len() != access_policies.len() { + return Ok(false); + } + for policy in access_policies { + let Some(profile) = authority_profiles.get(&policy.requester_tag) else { + return Ok(false); + }; + let governed_questions = policy + .questions + .iter() + .filter_map(|alias| questions.iter().find(|question| question.alias == *alias)) + .collect::>(); + if governed_questions.len() != policy.questions.len() + || !authority_profile_matches(profile, &policy.requester_tag, &governed_questions) + { + return Ok(false); + } + } + Ok(true) +} + +fn authority_profile_matches( + profile: &Value, + requester_tag: &str, + questions: &[&QuestionState], +) -> bool { + if profile.get("kind").and_then(Value::as_str) != Some("explicit-request") + || profile + .get("requesterTags") + .and_then(Value::as_array) + .is_none_or(|tags| tags.as_slice() != [Value::String(requester_tag.to_owned())]) + { + return false; + } + profile + .get("grants") + .and_then(Value::as_array) + .is_some_and(|grants| { + grants.len() == questions.len() + && grants + .iter() + .zip(questions) + .all(|(grant, question)| grant_matches_question(grant, question)) + }) +} + +fn grant_matches_question(grant: &Value, question: &QuestionState) -> bool { + if grant.get("requirement").and_then(Value::as_str) != Some(question.requirement_uri.as_str()) + || grant.get("purpose").and_then(Value::as_str) != Some(question.purpose.as_str()) + || grant.get("audienceFrom").and_then(Value::as_str) != Some("authenticated-requester") + { + return false; + } + grant + .get("subjects") + .and_then(Value::as_array) + .is_some_and(|subjects| { + subjects.len() == question.subjects.len() + && subjects + .iter() + .zip(&question.subjects) + .all(|(configured, expected)| { + configured.get("role").and_then(Value::as_str) + == Some(expected.role.as_str()) + && configured.get("selectorProfile").and_then(Value::as_str) + == Some(expected.selector_profile.as_str()) + && configured.get("valueOrigin").and_then(Value::as_str) + == Some("request") + }) + }) +} + +fn validate_closed_caller(caller: &CallerState, dev_root: &Path, token_url: &str) -> Result<()> { + if caller.client_id != CALLER_ID + || caller.private_key_path != dev_root.join("generated/keys/caller-private.jwk") + || caller.assertion_audience != token_url + || caller.evidence_audience != LOCAL_CALLER_EVIDENCE_AUDIENCE + || caller.requester_tag != LOCAL_REQUESTER_TAG + { + bail!("the local development caller is outside the closed lifecycle profile"); + } + Ok(()) +} + +fn local_origin_port(origin: &str) -> Option { + let port = origin.strip_prefix("http://127.0.0.1:")?.parse().ok()?; + (port != 0 && origin == format!("http://127.0.0.1:{port}")).then_some(port) +} + +fn local_origin(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +fn valid_local_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 64 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn start_detached( + project: &Path, + evidence_override: Option<&Path>, + mint_override: Option<&Path>, + ready_timeout_seconds: u64, + ports: LocalServicePorts, +) -> Result { + let project = canonical_project(project)?; + let generated_root = ensure_private_generated_root(&project)?; + let _lifecycle = lock_lifecycle(&generated_root)?; + let dev_root = generated_root.join("dev"); + + match fs::symlink_metadata(&dev_root) { + Ok(metadata) => { + validate_private_directory_metadata(&dev_root, &metadata)?; + remove_completed_dev_root(&project, &dev_root)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("failed to inspect local development state"), + } + + create_private_directory(&dev_root)?; + let result = prepare_and_start( + &project, + &dev_root, + evidence_override, + mint_override, + ready_timeout_seconds, + ports, + ); + if let Err(error) = result { + if let Err(cleanup) = cleanup_new_dev_root(&dev_root) { + return Err(error.context(format!( + "failed to roll back the incomplete local session: {cleanup:#}" + ))); + } + return Err(error); + } + result +} + +fn remove_completed_dev_root(project: &Path, dev_root: &Path) -> Result<()> { + let state = read_state(&dev_root.join("state.json"))?; + if state.status != DevStatus::Stopped || state.caller.is_some() || state.failure.is_some() { + bail!("local development state already exists and is not a completed stopped session"); + } + validate_closed_state(&state, project, dev_root)?; + require_owned_regular_file(&state.runtime_path, 0o400)?; + match fs::symlink_metadata(dev_root.join(CONTROL_SOCKET_NAME)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => bail!("stopped local state still has a control path"), + Err(error) => return Err(error.into()), + } + make_tree_removable(dev_root)?; + fs::remove_dir_all(dev_root).context("failed to replace the completed local session") +} + +fn clean_dev(project: &Path) -> Result { + let project = canonical_project(project)?; + let generated_root = existing_private_generated_root(&project)?; + let _lifecycle = lock_lifecycle(&generated_root)?; + let dev_root = generated_root.join("dev"); + validate_private_directory(&dev_root)?; + remove_completed_dev_root(&project, &dev_root)?; + println!("Removed stopped local Evidence state"); + Ok(ExitCode::SUCCESS) +} + +fn prepare_and_start( + project: &Path, + dev_root: &Path, + evidence_override: Option<&Path>, + mint_override: Option<&Path>, + ready_timeout_seconds: u64, + ports: LocalServicePorts, +) -> Result { + let evidence_bin = canonical_tool_binary(resolve_tool_binary( + "evidence", + evidence_override, + "EVIDENCECTL_TEST_EVIDENCE_BIN", + )?)?; + let mint_bin = canonical_tool_binary(resolve_tool_binary( + "mint", + mint_override, + "EVIDENCECTL_TEST_MINT_BIN", + )?)?; + let compiled = compile_local_project_with_ports(project, dev_root, &evidence_bin, ports)?; + let evidence_origin = local_origin(ports.evidence); + let mint_origin = local_origin(ports.mint); + let token_url = format!("{mint_origin}/token"); + + let generated = dev_root.join("generated"); + let keys = generated.join("keys"); + let clients = generated.join("clients"); + let mint_audit = generated.join("audit"); + let logs = dev_root.join("logs"); + for directory in [&generated, &keys, &clients, &mint_audit, &logs] { + create_private_directory(directory)?; + } + + let (mint_private, _) = keygen::generate_dev_keypair( + &keys, + MINT_KEY_ID, + "mint-private.jwk", + "mint-public.jwk.json", + )?; + let mint_audit_key = keys.join(MINT_AUDIT_KEY_FILENAME); + generate_mint_audit_key(&mint_audit_key)?; + let mint_config = mint_config(&compiled, &mint_private, &mint_audit_key, ports); + let mint_config_path = generated.join("mint.yaml"); + write_private_yaml(&mint_config_path, &mint_config)?; + let caller = if compiled.access_policies.is_empty() { + let (caller_private, caller_public) = keygen::generate_dev_keypair( + &keys, + CALLER_KEY_ID, + "caller-private.jwk", + "caller-public.jwk.json", + )?; + let caller_public = read_owner_json(&caller_public, 16 * 1024)?; + write_private_yaml( + &clients.join("caller.yaml"), + &local_caller_registration(&compiled, caller_public), + )?; + Some(CallerState { + client_id: CALLER_ID.to_owned(), + private_key_path: caller_private, + assertion_audience: token_url.clone(), + evidence_audience: compiled.caller_evidence_audience.clone(), + requester_tag: compiled.requester_tag.clone(), + }) + } else { + let policy_tags = compiled + .access_policies + .iter() + .map(|policy| (policy.id.clone(), policy.requester_tag.clone())) + .collect::>(); + let registrations = access::load_active_clients(project, &policy_tags)?; + if registrations.is_empty() { + bail!("explicit access policies require at least one active client"); + } + for registration in registrations { + write_private_yaml( + &clients.join(format!("{}.yaml", registration.client_id)), + ®istration.registration, + )?; + } + None + }; + run_check(&mint_bin, &["check", "--config"], &mint_config_path, "Mint")?; + + let state = DevState { + schema: STATE_SCHEMA.to_owned(), + status: DevStatus::Starting, + project: project.to_path_buf(), + runtime_path: compiled.runtime_path.clone(), + evidence_origin: evidence_origin.clone(), + mint_origin: mint_origin.clone(), + token_url: token_url.clone(), + access_token_audience: compiled.local_audience.clone(), + caller, + access_policies: compiled + .access_policies + .iter() + .map(AccessPolicyState::from) + .collect(), + questions: compiled.questions.iter().map(QuestionState::from).collect(), + failure: None, + }; + write_new_state(&dev_root.join("state.json"), &state)?; + + let supervisor_log = create_private_file(&logs.join("supervisor.log"))?; + let supervisor_error = supervisor_log.try_clone()?; + let executable = supervisor_executable()?; + let mut supervisor = match Command::new(executable) + .arg("__dev-supervisor") + .arg("--dev-root") + .arg(dev_root) + .arg("--evidence-bin") + .arg(&evidence_bin) + .arg("--mint-bin") + .arg(&mint_bin) + .arg("--ready-timeout-seconds") + .arg(ready_timeout_seconds.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::from(supervisor_log)) + .stderr(Stdio::from(supervisor_error)) + .spawn() + { + Ok(supervisor) => supervisor, + Err(error) => { + publish_supervisor_failure(dev_root, FailureKind::Supervisor)?; + return Err(error).context("failed to start the local supervisor"); + } + }; + + if let Err(error) = wait_for_supervisor_ready(dev_root, &mut supervisor, ready_timeout_seconds) + { + abort_start(&mut supervisor)?; + publish_supervisor_failure(dev_root, FailureKind::Supervisor)?; + return Err(error); + } + println!("Evidence ready at {evidence_origin}"); + println!("Mint ready at {mint_origin}"); + Ok(ExitCode::SUCCESS) +} + +fn stop_dev(project: &Path) -> Result { + let project = canonical_project(project)?; + let generated_root = existing_private_generated_root(&project)?; + let _lifecycle = lock_lifecycle(&generated_root)?; + let dev_root = generated_root.join("dev"); + validate_private_directory(&dev_root)?; + let state = read_state(&dev_root.join("state.json"))?; + if state.project != project || !matches!(state.status, DevStatus::Starting | DevStatus::Ready) { + bail!("local development state is not an active session"); + } + let socket = dev_root.join(CONTROL_SOCKET_NAME); + validate_control_socket(&socket)?; + std::env::set_current_dir(&dev_root) + .context("failed to enter the private local development directory")?; + let mut stream = UnixStream::connect(CONTROL_SOCKET_NAME) + .context("the recorded local supervisor is unavailable; refusing PID-based recovery")?; + stream.set_read_timeout(Some(Duration::from_secs(SHUTDOWN_TIMEOUT_SECONDS + 5)))?; + stream.write_all(b"stop\n")?; + stream.shutdown(std::net::Shutdown::Write)?; + let mut response = Vec::new(); + (&mut stream).take(64).read_to_end(&mut response)?; + if response != b"stopped\n" { + bail!("the local supervisor did not confirm a clean stop"); + } + let stopped = read_state(&dev_root.join("state.json"))?; + if stopped.status != DevStatus::Stopped || stopped.caller.is_some() { + bail!("the local supervisor did not publish the closed stopped state"); + } + println!("Local Evidence stopped"); + Ok(ExitCode::SUCCESS) +} + +fn supervisor_executable() -> Result { + #[cfg(debug_assertions)] + if let Some(path) = std::env::var_os("EVIDENCECTL_TEST_SUPERVISOR_BIN") { + return Ok(PathBuf::from(path)); + } + std::env::current_exe().context("failed to resolve evidencectl") +} + +fn injected_supervisor_failure(stage: &str) -> Result<()> { + #[cfg(debug_assertions)] + if std::env::var("EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE").as_deref() == Ok(stage) { + bail!("injected local supervisor failure at {stage}"); + } + let _ = stage; + Ok(()) +} + +fn publish_test_supervisor_pid() -> Result<()> { + #[cfg(debug_assertions)] + if let Some(path) = std::env::var_os("EVIDENCECTL_TEST_SUPERVISOR_PID_FILE") { + let path = PathBuf::from(path); + let mut file = create_private_file(&path)?; + writeln!(file, "{}", std::process::id())?; + file.sync_all()?; + } + Ok(()) +} + +fn publish_test_service_pid(name: &str, child: &Child) -> Result<()> { + #[cfg(debug_assertions)] + if let Some(directory) = std::env::var_os("EVIDENCECTL_TEST_SERVICE_PID_DIRECTORY") { + let directory = PathBuf::from(directory); + validate_private_directory(&directory)?; + let mut file = create_private_file(&directory.join(format!("{name}.pid")))?; + writeln!(file, "{}", child.id())?; + file.sync_all()?; + } + let _ = (name, child); + Ok(()) +} + +fn publish_supervisor_failure(dev_root: &Path, kind: FailureKind) -> Result<()> { + let state_path = dev_root.join("state.json"); + let mut state = read_state(&state_path)?; + if !matches!(state.status, DevStatus::Failed | DevStatus::Stopped) { + state.status = DevStatus::Failed; + state.failure = Some(kind); + replace_state(&state_path, &state)?; + } + let socket = dev_root.join(CONTROL_SOCKET_NAME); + match fs::symlink_metadata(&socket) { + Ok(_) => remove_control_socket(&socket), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { + ensure_supervisor_active(terminate)?; + validate_private_directory(&args.dev_root)?; + let dev_root = fs::canonicalize(&args.dev_root)?; + let state_path = dev_root.join("state.json"); + let mut state = read_state(&state_path)?; + if state.status != DevStatus::Starting || state.runtime_path != dev_root.join("runtime.yaml") { + bail!("supervisor state is not a fresh compiled local session"); + } + let project = dev_root + .parent() + .and_then(Path::parent) + .ok_or_else(|| anyhow!("local development root is outside a project"))?; + validate_closed_state(&state, project, &dev_root)?; + + std::env::set_current_dir(&dev_root) + .context("failed to enter the private local development directory")?; + let socket_path = dev_root.join(CONTROL_SOCKET_NAME); + injected_supervisor_failure("before-socket")?; + let listener = UnixListener::bind(CONTROL_SOCKET_NAME) + .context("failed to bind the private local control socket")?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(PRIVATE_FILE_MODE))?; + validate_control_socket(&socket_path)?; + listener.set_nonblocking(true)?; + injected_supervisor_failure("after-socket")?; + + let mut children = OwnedChildren::default(); + children.mint = Some( + match spawn_service( + &args.mint_bin, + &["serve", "--config"], + &dev_root.join("generated/mint.yaml"), + &dev_root.join("logs/mint.log"), + ) { + Ok(child) => child, + Err(error) => { + eprintln!("Mint start failed before child ownership: {error:#}"); + return fail_before_evidence(&state_path, &mut state, FailureKind::MintStart); + } + }, + ); + publish_test_service_pid( + "mint", + children.mint.as_ref().expect("Mint child was assigned"), + )?; + if wait_for_http( + &format!("{}/.well-known/jwks.json", state.mint_origin), + children.mint.as_mut().expect("Mint child was assigned"), + HttpProof::MintKey(MINT_KEY_ID), + args.ready_timeout_seconds, + terminate, + ) + .is_err() + { + eprintln!("Mint did not reach its fixed local JWKS readiness proof"); + return fail_before_evidence(&state_path, &mut state, FailureKind::MintReadiness); + } + + children.evidence = Some( + match spawn_evidence( + &args.evidence_bin, + &state.runtime_path, + &dev_root.join("logs/evidence.log"), + ) { + Ok(child) => child, + Err(error) => { + eprintln!("Evidence start failed: {error:#}"); + return fail_before_evidence(&state_path, &mut state, FailureKind::EvidenceStart); + } + }, + ); + publish_test_service_pid( + "evidence", + children + .evidence + .as_ref() + .expect("Evidence child was assigned"), + )?; + if wait_for_http( + &format!("{}/ready", state.evidence_origin), + children + .evidence + .as_mut() + .expect("Evidence child was assigned"), + HttpProof::EvidenceReady, + args.ready_timeout_seconds, + terminate, + ) + .is_err() + { + eprintln!("Evidence did not reach its fixed local readiness proof"); + return fail_before_evidence(&state_path, &mut state, FailureKind::EvidenceReadiness); + } + + state.status = DevStatus::Ready; + injected_supervisor_failure("before-ready-state")?; + replace_state(&state_path, &state)?; + let outcome = supervisor_loop( + &listener, + children + .evidence + .as_mut() + .expect("Evidence child was assigned"), + children.mint.as_mut().expect("Mint child was assigned"), + terminate, + ) + .unwrap_or(SupervisorOutcome::Failed(FailureKind::Supervisor)); + state.status = DevStatus::Stopping; + let stopping_state = replace_state(&state_path, &state); + children.stop(); + stopping_state?; + + match outcome { + SupervisorOutcome::Stop(mut stream) => { + remove_control_socket(&socket_path)?; + remove_private_tree(&dev_root.join("generated"))?; + remove_private_tree(&dev_root.join("logs"))?; + state.status = DevStatus::Stopped; + state.caller = None; + state.failure = None; + replace_state(&state_path, &state)?; + stream.write_all(b"stopped\n")?; + Ok(()) + } + SupervisorOutcome::Failed(kind) => { + remove_control_socket(&socket_path)?; + state.status = DevStatus::Failed; + state.failure = Some(kind); + replace_state(&state_path, &state) + } + } +} + +fn fail_before_evidence(state_path: &Path, state: &mut DevState, kind: FailureKind) -> Result<()> { + state.status = DevStatus::Failed; + state.failure = Some(kind); + replace_state(state_path, state) +} + +enum SupervisorOutcome { + Stop(UnixStream), + Failed(FailureKind), +} + +fn supervisor_loop( + listener: &UnixListener, + evidence: &mut Child, + mint: &mut Child, + terminate: &AtomicBool, +) -> Result { + loop { + if terminate.load(Ordering::Relaxed) { + return Ok(SupervisorOutcome::Failed(FailureKind::SupervisorSignal)); + } + if evidence.try_wait()?.is_some() || mint.try_wait()?.is_some() { + return Ok(SupervisorOutcome::Failed(FailureKind::ChildExited)); + } + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + (&mut stream).take(16).read_to_end(&mut request)?; + if request == b"stop\n" { + return Ok(SupervisorOutcome::Stop(stream)); + } + if request == b"reload-mint\n" { + signal_child_with(mint, rustix::process::Signal::HUP)?; + stream.write_all(b"reload-requested\n")?; + continue; + } + let _ = stream.write_all(b"invalid\n"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(50)); + } + Err(_) => return Ok(SupervisorOutcome::Failed(FailureKind::Supervisor)), + } + } +} + +fn spawn_service(binary: &Path, prefix: &[&str], value: &Path, log: &Path) -> Result { + let stdout = create_private_file(log)?; + let stderr = stdout.try_clone()?; + Command::new(binary) + .args(prefix) + .arg(value) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .with_context(|| format!("failed to start {}", binary.display())) +} + +fn spawn_evidence(binary: &Path, runtime: &Path, log: &Path) -> Result { + let stdout = create_private_file(log)?; + let stderr = stdout.try_clone()?; + Command::new(binary) + .arg("--runtime") + .arg(runtime) + .arg("serve") + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .with_context(|| format!("failed to start {}", binary.display())) +} + +enum HttpProof<'a> { + MintKey(&'a str), + EvidenceReady, +} + +fn wait_for_http( + url: &str, + child: &mut Child, + proof: HttpProof<'_>, + seconds: u64, + terminate: &AtomicBool, +) -> Result<()> { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_millis(250)) + .timeout_read(Duration::from_secs(2)) + .timeout_write(Duration::from_millis(500)) + .redirects(0) + .build(); + let deadline = Instant::now() + Duration::from_secs(seconds); + while Instant::now() < deadline { + ensure_supervisor_active(terminate)?; + if child.try_wait()?.is_some() { + bail!("service exited before readiness"); + } + if let Ok(response) = agent.get(url).call() { + let mut bytes = Vec::new(); + response + .into_reader() + .take(MAX_HTTP_BODY_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 <= MAX_HTTP_BODY_BYTES { + let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let matches = match proof { + HttpProof::MintKey(kid) => value["keys"] + .as_array() + .is_some_and(|keys| keys.iter().any(|key| key["kid"] == kid)), + HttpProof::EvidenceReady => value == json!({"status": "ready"}), + }; + if matches && child.try_wait()?.is_none() { + return Ok(()); + } + } + } + thread::sleep(Duration::from_millis(100)); + } + bail!("service readiness timed out") +} + +fn ensure_supervisor_active(terminate: &AtomicBool) -> Result<()> { + if terminate.load(Ordering::Relaxed) { + bail!("local supervisor received a termination signal"); + } + Ok(()) +} + +fn stop_children(evidence: Option<&mut Child>, mint: Option<&mut Child>) { + let mut evidence = evidence; + let mut mint = mint; + if let Some(child) = evidence.as_deref_mut() { + let _ = signal_child(child); + } + if let Some(child) = mint.as_deref_mut() { + let _ = signal_child(child); + } + let deadline = Instant::now() + Duration::from_secs(SHUTDOWN_TIMEOUT_SECONDS); + loop { + let evidence_done = evidence + .as_deref_mut() + .is_none_or(|child| child.try_wait().ok().flatten().is_some()); + let mint_done = mint + .as_deref_mut() + .is_none_or(|child| child.try_wait().ok().flatten().is_some()); + if evidence_done && mint_done { + return; + } + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(50)); + } + for child in [evidence, mint].into_iter().flatten() { + // A child that ignores TERM is killed only after the bounded graceful + // deadline. Child::kill is SIGKILL on Unix and cannot run child cleanup. + let _ = child.kill(); + let _ = child.wait(); + } +} + +fn signal_child(child: &Child) -> Result<()> { + signal_child_with(child, rustix::process::Signal::TERM) +} + +fn signal_child_with(child: &Child, signal: rustix::process::Signal) -> Result<()> { + let raw = i32::try_from(child.id()).context("child identifier is not a process id")?; + let pid = + rustix::process::Pid::from_raw(raw).ok_or_else(|| anyhow!("invalid child process"))?; + rustix::process::kill_process(pid, signal)?; + Ok(()) +} + +fn wait_for_supervisor_ready(dev_root: &Path, child: &mut Child, seconds: u64) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(seconds + 5); + loop { + let state = read_state(&dev_root.join("state.json"))?; + match state.status { + DevStatus::Ready => return Ok(()), + DevStatus::Failed => { + let _ = child.wait(); + let diagnostic = read_owner_file(&dev_root.join("logs/supervisor.log"), 4096) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + bail!( + "local services failed during startup ({:?}){}{}", + state.failure, + if diagnostic.is_empty() { "" } else { ": " }, + diagnostic.trim() + ); + } + DevStatus::Starting if Instant::now() < deadline => {} + _ => bail!("local supervisor did not publish readiness"), + } + if child.try_wait()?.is_some() { + bail!("local supervisor exited before readiness"); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn abort_start(supervisor: &mut Child) -> Result<()> { + if supervisor.try_wait()?.is_some() { + return Ok(()); + } + signal_child(supervisor).context("failed to terminate the local supervisor after startup")?; + // SIGKILL is deliberately outside this tutorial lifecycle because it + // cannot run the supervisor's child cleanup. TERM is bounded by the + // supervisor's own child shutdown deadline, and the creating command + // waits for that cleanup instead of abandoning the owner process. + supervisor.wait()?; + Ok(()) +} + +fn mint_config( + compiled: &CompiledProject, + mint_private: &Path, + mint_audit_key: &Path, + ports: LocalServicePorts, +) -> Value { + let mint_origin = ports.mint_origin(); + let token_url = format!("{mint_origin}/token"); + json!({ + "version": 1, + "validationMode": "supervised-local-development", + "issuer": mint_origin, + "listener": { + "address": "127.0.0.1", + "port": ports.mint, + "maximumRequestBytes": 16384, + "requestTimeoutMilliseconds": 5000, + }, + "signing": { + "algorithm": "EdDSA", + "activeKeyId": MINT_KEY_ID, + "activeKeyFile": mint_private, + "retiredPublicJwkFiles": [], + "jwksPath": "/.well-known/jwks.json", + }, + "audit": { + "path": "audit/mint.jsonl", + // Mint rotates a sealed segment at this threshold. A local + // tutorial session never reaches it, and the value matches the + // documented deployment example. + "maximumFileBytes": 1_073_741_824u64, + "hashKeyFile": mint_audit_key, + "hashKeyVersion": 1, + }, + "accessTokens": { + "audiences": [compiled.local_audience], + "lifetimeSeconds": 300, + "claims": { + "principal": "sub", + "requesterTags": "evidence_tags", + "evidenceAudience": "evidence_audience", + "grantId": "evidence_grant_id", + "grantAuthority": "evidence_authority", + }, + }, + "clientAssertion": { + "audience": token_url, + "maximumLifetimeSeconds": 120, + "algorithms": ["EdDSA"], + "replayCacheEntries": 256, + }, + "clients": {"directory": "clients"}, + }) +} + +fn generate_mint_audit_key(path: &Path) -> Result<()> { + let mut entropy = Zeroizing::new([0_u8; 32]); + getrandom::fill(entropy.as_mut_slice()) + .context("failed to generate local Mint audit key material")?; + let key = Zeroizing::new(URL_SAFE_NO_PAD.encode(entropy.as_slice())); + let mut file = create_private_file(path)?; + file.write_all(key.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +fn local_caller_registration(compiled: &CompiledProject, caller_public: Value) -> Value { + json!({ + "clientId": CALLER_ID, + "principal": "urn:registrystack:evidence:local:caller", + "evidenceAudience": compiled.caller_evidence_audience, + "requesterTags": [compiled.requester_tag], + "keys": [caller_public], + }) +} + +impl From<&CompiledAccessPolicy> for AccessPolicyState { + fn from(compiled: &CompiledAccessPolicy) -> Self { + Self { + id: compiled.id.clone(), + requester_tag: compiled.requester_tag.clone(), + questions: compiled.questions.clone(), + } + } +} + +impl From<&CompiledQuestion> for QuestionState { + fn from(compiled: &CompiledQuestion) -> Self { + Self { + alias: compiled.question_alias.clone(), + requirement_uri: compiled.requirement_uri.clone(), + purpose: compiled.purpose.clone(), + subjects: compiled + .subjects + .iter() + .map(|subject| SubjectState { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_field: subject.selector_field.clone(), + }) + .collect(), + concepts: compiled + .concepts + .iter() + .map(|concept| ConceptState { + alias: concept.concept_alias.clone(), + uri: concept.concept_uri.clone(), + form: match concept.concept_form { + CompiledConceptForm::Boolean => "boolean".to_owned(), + CompiledConceptForm::ControlledCategory => "controlled-category".to_owned(), + CompiledConceptForm::BoundedInteger => "bounded-integer".to_owned(), + CompiledConceptForm::Structured => "reviewed-structured-value".to_owned(), + }, + }) + .collect(), + } + } +} + +fn run_check(binary: &Path, prefix: &[&str], config: &Path, name: &str) -> Result<()> { + let status = Command::new(binary) + .args(prefix) + .arg(config) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("failed to run {name} check"))?; + if !status.success() { + bail!("{name} rejected the generated local configuration"); + } + Ok(()) +} + +#[allow(dead_code)] // Shared by the immediately following request and audit slices. +pub(crate) fn resolve_tool_binary( + name: &str, + explicit: Option<&Path>, + test_env: &str, +) -> Result { + if let Some(path) = explicit { + return Ok(path.to_path_buf()); + } + let current = std::env::current_exe().context("failed to resolve evidencectl")?; + if let Some(sibling) = current.parent().map(|parent| parent.join(name)) { + if sibling.is_file() { + return Ok(sibling); + } + } + if let Some(path) = std::env::var_os(test_env) { + return Ok(PathBuf::from(path)); + } + Ok(PathBuf::from(name)) +} + +fn canonical_tool_binary(path: PathBuf) -> Result { + fs::canonicalize(&path) + .with_context(|| format!("failed to resolve tool binary {}", path.display())) +} + +fn canonical_project(path: &Path) -> Result { + let canonical = fs::canonicalize(path) + .with_context(|| format!("project {} is unavailable", path.display()))?; + let metadata = fs::symlink_metadata(&canonical)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + bail!("project is not a real directory"); + } + require_owner(&metadata, "project directory")?; + Ok(canonical) +} + +fn ensure_private_generated_root(project: &Path) -> Result { + let root = project.join(".evidence"); + match fs::symlink_metadata(&root) { + Ok(metadata) => validate_private_directory_metadata(&root, &metadata)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_private_directory(&root)?; + } + Err(error) => return Err(error.into()), + } + Ok(root) +} + +fn existing_private_generated_root(project: &Path) -> Result { + let root = project.join(".evidence"); + validate_private_directory(&root)?; + Ok(root) +} + +fn create_private_directory(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.mode(PRIVATE_DIR_MODE); + builder + .create(path) + .with_context(|| format!("failed to create private directory {}", path.display()))?; + validate_private_directory(path) +} + +fn validate_private_directory(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect private directory {}", path.display()))?; + validate_private_directory_metadata(path, &metadata) +} + +fn validate_private_directory_metadata(path: &Path, metadata: &Metadata) -> Result<()> { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("private state {} is not a real directory", path.display()); + } + require_owner(metadata, "private directory")?; + if metadata.mode() & 0o777 != PRIVATE_DIR_MODE { + bail!("private state {} must have mode 0700", path.display()); + } + Ok(()) +} + +fn lock_lifecycle(root: &Path) -> Result { + let path = root.join("lifecycle.lock"); + let file = match create_private_rw_file(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => open_owner_rw(&path)?, + Err(error) => return Err(error.into()), + }; + require_owner_file(&path)?; + rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) + .context("another local lifecycle operation is already active")?; + Ok(LifecycleLock { _file: file }) +} + +#[allow(dead_code)] // Consumed by the access-management CLI slice. +pub(crate) fn lock_project_lifecycle(project: &Path) -> Result { + let project = canonical_project(project)?; + let generated_root = ensure_private_generated_root(&project)?; + lock_lifecycle(&generated_root) +} + +fn create_private_rw_file(path: &Path) -> std::io::Result { + OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(PRIVATE_FILE_MODE) + .open(path) +} + +fn open_owner_rw(path: &Path) -> Result { + require_owner_file(path)?; + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::RDWR | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + let file = File::from(fd); + require_owner_metadata(&file.metadata()?, "lifecycle lock")?; + Ok(file) +} + +fn require_owner_file(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.nlink() != 1 { + bail!("owner-only state is not a single-link regular file"); + } + require_owner_metadata(&metadata, "owner-only state")?; + Ok(metadata) +} + +#[allow(dead_code)] +fn require_owned_regular_file(path: &Path, mode: u32) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.nlink() != 1 { + bail!("ready session path is not a single-link regular file"); + } + require_owner(&metadata, "ready session file")?; + if metadata.mode() & 0o777 != mode { + bail!("ready session file has the wrong private mode"); + } + Ok(()) +} + +fn require_owner_metadata(metadata: &Metadata, label: &str) -> Result<()> { + require_owner(metadata, label)?; + if metadata.mode() & 0o777 != PRIVATE_FILE_MODE { + bail!("{label} must have mode 0600"); + } + Ok(()) +} + +fn require_owner(metadata: &Metadata, label: &str) -> Result<()> { + if metadata.uid() != rustix::process::getuid().as_raw() { + bail!("{label} is not owned by the current user"); + } + Ok(()) +} + +fn create_private_file(path: &Path) -> Result { + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(PRIVATE_FILE_MODE) + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + require_owner_file(path)?; + Ok(file) +} + +fn write_private_yaml(path: &Path, value: &Value) -> Result<()> { + let mut text = serde_norway::to_string(value)?; + if !text.ends_with('\n') { + text.push('\n'); + } + let mut file = create_private_file(path)?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +fn read_owner_json(path: &Path, maximum: u64) -> Result { + let bytes = read_owner_file(path, maximum)?; + serde_json::from_slice(&bytes).context("owner-only JSON is invalid") +} + +fn read_owner_file(path: &Path, maximum: u64) -> Result> { + let before = require_owner_file(path)?; + if before.len() > maximum { + bail!("owner-only state exceeds its size bound"); + } + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + )?; + let mut file = File::from(fd); + let opened = file.metadata()?; + if before.dev() != opened.dev() || before.ino() != opened.ino() { + bail!("owner-only state changed while opening"); + } + let mut bytes = Vec::new(); + (&mut file).take(maximum + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > maximum { + bail!("owner-only state exceeds its size bound"); + } + Ok(bytes) +} + +fn write_new_state(path: &Path, state: &DevState) -> Result<()> { + let bytes = serde_json::to_vec(state)?; + let mut file = create_private_file(path)?; + file.write_all(&bytes)?; + file.sync_all()?; + Ok(()) +} + +fn replace_state(path: &Path, state: &DevState) -> Result<()> { + require_owner_file(path)?; + let parent = path + .parent() + .ok_or_else(|| anyhow!("state has no parent"))?; + validate_private_directory(parent)?; + let mut random = [0_u8; 9]; + getrandom::fill(&mut random)?; + let temporary = parent.join(format!(".state-{}", URL_SAFE_NO_PAD.encode(random))); + write_new_state(&temporary, state)?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn read_state(path: &Path) -> Result { + let bytes = read_owner_file(path, MAX_STATE_BYTES)?; + let state: DevState = serde_json::from_slice(&bytes).context("local state is invalid")?; + if state.schema != STATE_SCHEMA { + bail!("local state schema is unsupported"); + } + Ok(state) +} + +fn validate_control_socket(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { + bail!("local control path is not a Unix socket"); + } + require_owner_metadata(&metadata, "local control socket") +} + +fn remove_control_socket(path: &Path) -> Result<()> { + validate_control_socket(path)?; + fs::remove_file(path)?; + Ok(()) +} + +fn remove_private_tree(path: &Path) -> Result<()> { + validate_private_directory(path)?; + fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display())) +} + +fn cleanup_new_dev_root(dev_root: &Path) -> Result<()> { + validate_private_directory(dev_root)?; + let socket = dev_root.join("control.sock"); + match fs::symlink_metadata(&socket) { + Ok(_) => remove_control_socket(&socket)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + make_tree_removable(dev_root)?; + fs::remove_dir_all(dev_root).context("failed to clean incomplete local development state") +} + +fn make_tree_removable(root: &Path) -> Result<()> { + for entry in fs::read_dir(root)? { + let path = entry?.path(); + let metadata = fs::symlink_metadata(&path)?; + require_owner(&metadata, "incomplete local state")?; + if metadata.file_type().is_symlink() { + bail!("incomplete local state contains a symlink"); + } + if metadata.is_dir() { + make_tree_removable(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_DIR_MODE))?; + } else if metadata.is_file() { + fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_FILE_MODE))?; + } else { + bail!("incomplete local state contains an unexpected entry"); + } + } + fs::set_permissions(root, fs::Permissions::from_mode(PRIVATE_DIR_MODE))?; + Ok(()) +} + +fn ready_question(question: QuestionState) -> ReadyQuestionState { + ReadyQuestionState { + alias: question.alias, + requirement_uri: question.requirement_uri, + purpose: question.purpose, + subjects: question + .subjects + .into_iter() + .map(|subject| ReadySubjectState { + role: subject.role, + selector_profile: subject.selector_profile, + selector_field: subject.selector_field, + }) + .collect(), + concepts: question + .concepts + .into_iter() + .map(|concept| ReadyConceptState { + alias: concept.alias, + uri: concept.uri, + form: concept.form, + }) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compiled(runtime: &Path) -> CompiledProject { + CompiledProject { + runtime_path: runtime.to_path_buf(), + questions: vec![CompiledQuestion { + question_alias: "adult-status".to_owned(), + requirement_uri: "urn:registrystack:evidence:local:requirement:adult-status" + .to_owned(), + purpose: "age-check".to_owned(), + subjects: vec![crate::authoring::CompiledSubject { + role: "person".to_owned(), + selector_profile: "local-subject-adult-status-v1".to_owned(), + selector_field: "person_id".to_owned(), + }], + concepts: vec![crate::authoring::CompiledConcept { + concept_alias: "is_adult".to_owned(), + concept_uri: "urn:registrystack:evidence:local:concept:adult-status:is_adult" + .to_owned(), + concept_form: CompiledConceptForm::Boolean, + }], + }], + local_audience: "registry-evidence-local".to_owned(), + requester_tag: "local-caller".to_owned(), + caller_evidence_audience: LOCAL_CALLER_EVIDENCE_AUDIENCE.to_owned(), + access_policies: Vec::new(), + } + } + + #[test] + fn mint_documents_are_closed_and_derive_authority_from_the_compiler() { + let compiled = compiled(Path::new("/private/runtime.yaml")); + let config = mint_config( + &compiled, + Path::new("/private/mint-private.jwk"), + Path::new("/private/mint-audit-hmac-key"), + LocalServicePorts::default(), + ); + let caller = local_caller_registration( + &compiled, + json!({"kty":"OKP","crv":"Ed25519","kid":"caller","alg":"EdDSA","x":"public"}), + ); + assert_eq!(config["validationMode"], "supervised-local-development"); + assert_eq!(config["issuer"], "http://127.0.0.1:8081"); + assert_eq!( + config["listener"], + json!({ + "address": "127.0.0.1", "port": 8081, + "maximumRequestBytes": 16384, "requestTimeoutMilliseconds": 5000 + }) + ); + assert_eq!( + config["accessTokens"]["audiences"], + json!([compiled.local_audience]) + ); + assert_eq!( + config["audit"], + json!({ + "path": "audit/mint.jsonl", + "maximumFileBytes": 1_073_741_824u64, + "hashKeyFile": "/private/mint-audit-hmac-key", + "hashKeyVersion": 1, + }) + ); + assert_eq!(caller["requesterTags"], json!([compiled.requester_tag])); + assert_eq!( + caller["evidenceAudience"], + compiled.caller_evidence_audience + ); + assert!(caller.to_string().find("private").is_none()); + } + + #[test] + fn structured_concepts_use_the_runtime_value_form_in_lifecycle_state() { + let mut compiled = compiled(Path::new("/private/runtime.yaml")); + compiled.questions[0].concepts[0].concept_form = CompiledConceptForm::Structured; + + let state = QuestionState::from(&compiled.questions[0]); + + assert_eq!(state.concepts[0].form, "reviewed-structured-value"); + assert!(valid_question_state(&state)); + } + + #[test] + fn private_state_rejects_public_directories_files_and_symlinks() { + let root = tempfile::tempdir().expect("tempdir"); + let public = root.path().join("public"); + fs::create_dir(&public).expect("directory"); + fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).expect("mode"); + assert!(validate_private_directory(&public).is_err()); + + let private = root.path().join("private"); + create_private_directory(&private).expect("private directory"); + let state = private.join("state.json"); + fs::write(&state, b"{}").expect("state"); + fs::set_permissions(&state, fs::Permissions::from_mode(0o644)).expect("mode"); + assert!(read_state(&state).is_err()); + + let link = root.path().join("link"); + symlink(&private, &link).expect("symlink"); + assert!(validate_private_directory(&link).is_err()); + } + + #[test] + fn ready_and_stopped_handoffs_validate_the_closed_lifecycle_state() { + let temporary = tempfile::tempdir().expect("tempdir"); + let project = temporary.path().join("project"); + fs::create_dir(&project).expect("project"); + let project = fs::canonicalize(project).expect("canonical project"); + let generated = project.join(".evidence"); + create_private_directory(&generated).expect("generated root"); + let dev = generated.join("dev"); + create_private_directory(&dev).expect("dev root"); + create_private_directory(&dev.join("generated")).expect("generated"); + create_private_directory(&dev.join("generated/keys")).expect("keys"); + + let runtime = dev.join("runtime.yaml"); + drop(create_private_file(&runtime).expect("runtime")); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o400)).expect("seal runtime"); + let caller_key = dev.join("generated/keys/caller-private.jwk"); + drop(create_private_file(&caller_key).expect("caller key")); + let socket = dev.join("control.sock"); + let listener = UnixListener::bind(&socket).expect("control socket"); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).expect("socket mode"); + + let compiled = compiled(&runtime); + let bundle = dev.join("bundle"); + create_private_directory(&bundle).expect("bundle"); + let bundle_path = bundle.join("evidence.yaml"); + fs::write( + &bundle_path, + br#"selectorProfiles: + local-subject-adult-status-v1: + fields: + person_id: {type: string} +authorityProfiles: + local-caller: + kind: explicit-request + requesterTags: [local-caller] + grants: + - requirement: urn:registrystack:evidence:local:requirement:adult-status + purpose: age-check + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: person + selectorProfile: local-subject-adult-status-v1 + valueOrigin: request +requirements: + - id: urn:registrystack:evidence:local:requirement:adult-status + purposes: [age-check] + subjectRoles: + - role: person + selectorProfiles: [local-subject-adult-status-v1] + concepts: + - id: urn:registrystack:evidence:local:concept:adult-status:is_adult + form: boolean +"#, + ) + .expect("bundle config"); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o400)) + .expect("seal bundle config"); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o500)).expect("seal bundle"); + let mut state = DevState { + schema: STATE_SCHEMA.to_owned(), + status: DevStatus::Ready, + project: project.clone(), + runtime_path: runtime.clone(), + evidence_origin: local_origin(8080), + mint_origin: local_origin(8081), + token_url: format!("{}/token", local_origin(8081)), + access_token_audience: compiled.local_audience.clone(), + caller: Some(CallerState { + client_id: CALLER_ID.to_owned(), + private_key_path: caller_key, + assertion_audience: format!("{}/token", local_origin(8081)), + evidence_audience: compiled.caller_evidence_audience.clone(), + requester_tag: compiled.requester_tag.clone(), + }), + access_policies: Vec::new(), + questions: compiled.questions.iter().map(QuestionState::from).collect(), + failure: None, + }; + write_new_state(&dev.join("state.json"), &state).expect("ready state"); + let ready = load_ready_state(&project).expect("ready handoff"); + assert_eq!(ready.runtime_path, runtime); + assert_eq!(ready.questions[0].alias, "adult-status"); + assert!(ready.caller.is_some()); + assert!(ready.access_policies.is_empty()); + assert!( + clean_dev(&project).is_err(), + "active state is never removed" + ); + assert!(dev.is_dir(), "refused cleanup preserves active state"); + + let valid_ready = state.clone(); + state.access_token_audience = "tampered-audience".to_owned(); + replace_state(&dev.join("state.json"), &state).expect("tampered state"); + assert!(load_ready_state(&project).is_err()); + state = valid_ready.clone(); + state.questions[0].requirement_uri = "urn:tampered".to_owned(); + replace_state(&dev.join("state.json"), &state).expect("tampered canonical value"); + assert!(load_ready_state(&project).is_err()); + state = valid_ready.clone(); + state.questions[0].concepts[0].uri = "urn:tampered".to_owned(); + replace_state(&dev.join("state.json"), &state).expect("tampered concept"); + assert!(load_ready_state(&project).is_err()); + state = valid_ready; + replace_state(&dev.join("state.json"), &state).expect("restore ready state"); + + let policy_questions = vec!["adult-status".to_owned()]; + let policy_tag = + access_policy_requester_tag("age-checks", &policy_questions).expect("policy tag"); + let mut explicit_bundle: Value = + serde_norway::from_slice(&fs::read(&bundle_path).expect("read implicit bundle")) + .expect("parse implicit bundle"); + explicit_bundle["authorityProfiles"] = Value::Object(serde_json::Map::from_iter([( + policy_tag.clone(), + json!({ + "kind": "explicit-request", + "requesterTags": [policy_tag], + "grants": [{ + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "audienceFrom": "authenticated-requester", + "responseFormats": ["signed-jws"], + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-adult-status-v1", + "valueOrigin": "request", + }], + }], + }), + )])); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(PRIVATE_FILE_MODE)) + .expect("unseal bundle config for test update"); + fs::write( + &bundle_path, + serde_norway::to_string(&explicit_bundle).expect("explicit bundle YAML"), + ) + .expect("write explicit bundle"); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o400)) + .expect("reseal bundle config"); + state.caller = None; + state.access_policies = vec![AccessPolicyState { + id: "age-checks".to_owned(), + requester_tag: policy_tag.clone(), + questions: policy_questions, + }]; + replace_state(&dev.join("state.json"), &state).expect("explicit policy state"); + let explicit = load_ready_state(&project).expect("explicit ready handoff"); + assert!(explicit.caller.is_none()); + assert_eq!(explicit.access_policies[0].requester_tag, policy_tag); + assert!(try_load_ready_state(&project) + .expect("optional ready handoff") + .is_some()); + let valid_explicit = state.clone(); + state.access_policies[0].id = "other-age-checks".to_owned(); + state.access_policies[0].requester_tag = access_policy_requester_tag( + &state.access_policies[0].id, + &state.access_policies[0].questions, + ) + .expect("internally valid but unsealed policy tag"); + replace_state(&dev.join("state.json"), &state).expect("unsealed policy state"); + assert!(load_ready_state(&project).is_err()); + state = valid_explicit.clone(); + state.access_policies[0].requester_tag = "policy-v1-tampered".to_owned(); + replace_state(&dev.join("state.json"), &state).expect("tampered policy state"); + assert!(load_ready_state(&project).is_err()); + state = valid_explicit; + replace_state(&dev.join("state.json"), &state).expect("restore explicit state"); + + drop(listener); + remove_control_socket(&socket).expect("remove socket"); + remove_private_tree(&dev.join("generated")).expect("remove generated"); + state.status = DevStatus::Stopped; + state.caller = None; + replace_state(&dev.join("state.json"), &state).expect("stopped state"); + let stopped = load_stopped_state(&project).expect("stopped handoff"); + assert!(try_load_ready_state(&project) + .expect("stopped optional handoff") + .is_none()); + assert_eq!(stopped.runtime_path, runtime); + assert_eq!(stopped.questions[0].concepts[0].alias, "is_adult"); + + clean_dev(&project).expect("clean stopped session"); + assert!(!dev.exists()); + } + + #[test] + fn lifecycle_lock_is_nonblocking_and_owner_only() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path().join("generated"); + create_private_directory(&root).expect("root"); + let _held = lock_lifecycle(&root).expect("first lock"); + assert!(lock_lifecycle(&root).is_err(), "second operation must fail"); + require_owner_file(&root.join("lifecycle.lock")).expect("private sentinel"); + } + + #[test] + fn supervisor_reload_control_signals_only_mint_and_keeps_serving() { + let temporary = tempfile::tempdir().expect("tempdir"); + let socket = temporary.path().join("control.sock"); + let listener = UnixListener::bind(&socket).expect("control listener"); + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + + let mint_script = temporary.path().join("mint-child"); + let mint_ready = temporary.path().join("mint-ready"); + fs::write( + &mint_script, + "#!/bin/sh\ntrap ':' HUP\nprintf ready > \"$MINT_READY\"\nwhile :; do sleep 1; done\n", + ) + .expect("mint script"); + fs::set_permissions(&mint_script, fs::Permissions::from_mode(0o700)).expect("script mode"); + let mut mint = Command::new(&mint_script) + .env("MINT_READY", &mint_ready) + .spawn() + .expect("mint child"); + let mut evidence = Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("evidence child"); + let deadline = Instant::now() + Duration::from_secs(10); + while !mint_ready.is_file() { + if let Some(status) = mint.try_wait().expect("mint child status") { + panic!("mint child exited before installing its HUP handler: {status}"); + } + assert!( + Instant::now() < deadline, + "mint child did not install its HUP handler within 10 seconds" + ); + thread::sleep(Duration::from_millis(10)); + } + + let client_socket = socket.clone(); + let client = thread::spawn(move || { + send_control_request(&client_socket, b"reload-mint\n", b"reload-requested\n") + .expect("reload response"); + send_control_request(&client_socket, b"stop\n", b"stopped\n").expect("stop response"); + }); + let outcome = supervisor_loop(&listener, &mut evidence, &mut mint, &AtomicBool::new(false)) + .expect("supervisor loop"); + match outcome { + SupervisorOutcome::Stop(mut stream) => { + stream.write_all(b"stopped\n").expect("stop confirmation"); + } + SupervisorOutcome::Failed(kind) => panic!("unexpected supervisor failure: {kind:?}"), + } + client.join().expect("control client"); + assert!(evidence.try_wait().expect("evidence status").is_none()); + assert!(mint.try_wait().expect("mint status").is_none()); + signal_child(&evidence).expect("stop evidence"); + signal_child(&mint).expect("stop mint"); + evidence.wait().expect("wait evidence"); + mint.wait().expect("wait mint"); + } + + #[test] + fn control_requests_bridge_paths_longer_than_sockaddr_un() { + let temporary = tempfile::tempdir().expect("tempdir"); + let short = temporary.path().join("short"); + fs::create_dir(&short).expect("short directory"); + let socket = short.join(CONTROL_SOCKET_NAME); + let listener = UnixListener::bind(&socket).expect("listener"); + + let mut long_parent = temporary.path().join("long"); + fs::create_dir(&long_parent).expect("long root"); + for _ in 0..6 { + long_parent = long_parent.join("component-with-a-deliberately-long-name"); + fs::create_dir(&long_parent).expect("long component"); + } + let alias = long_parent.join("target"); + symlink(&short, &alias).expect("short target alias"); + let long_socket = alias.join(CONTROL_SOCKET_NAME); + assert!(long_socket.as_os_str().as_encoded_bytes().len() > 104); + assert!(UnixStream::connect(&long_socket).is_err()); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + (&mut stream) + .take(16) + .read_to_end(&mut request) + .expect("request"); + assert_eq!(request, b"reload-mint\n"); + stream.write_all(b"reload-requested\n").expect("response"); + }); + send_control_request(&long_socket, b"reload-mint\n", b"reload-requested\n") + .expect("long control path"); + server.join().expect("server"); + } +} diff --git a/crates/registry-evidencectl/src/doctor.rs b/crates/registry-evidencectl/src/doctor.rs new file mode 100644 index 000000000..d568d0c39 --- /dev/null +++ b/crates/registry-evidencectl/src/doctor.rs @@ -0,0 +1,753 @@ +//! Deployment-project mode walk. +//! +//! Evidence refuses, at startup, any deployment artifact whose permissions or +//! ownership are wrong: a bundle it could write to, a secret readable past its +//! owner, an audit chain another user could edit. Each refusal is correct and +//! each names one artifact, so an operator who has just run `chmod -R` over a +//! project discovers them one restart at a time. +//! +//! This walks the whole project and reports every artifact that would be +//! refused, in one pass, without starting anything. It re-states the runtime's +//! rules rather than deciding anything of its own: it makes no Evidence +//! semantic decision, needs no `evidence` binary, and is advisory. What the +//! runtime accepts at startup remains the only authority. +//! +//! Two caveats belong to the operator rather than to the code. Ownership is +//! compared against the user running this check, which is not necessarily the +//! user the service runs as. And a project on a read-only mount satisfies the +//! immutability rule whatever its modes say, which this mirrors. +//! +//! An explicitly paired Mint configuration adds a separate, mechanical check +//! of the access-token fields both products share. It does not discover Mint, +//! validate either product, read a client registry or key, or make an +//! authorization decision. The product `check` commands remain authoritative. + +use std::{ + collections::BTreeSet, + fs::{self, Metadata}, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; +use serde::{Deserialize, Serialize}; +use serde_norway::Value as YamlValue; + +/// How a bundle names a secret the file provider resolves. +const SECRET_REFERENCE_PREFIX: &str = "secret:file/"; + +/// The JWT `typ` Registry Mint writes on access tokens. +const MINT_ACCESS_TOKEN_TYPE: &str = "at+jwt"; + +/// Registry Mint's default public-key route when `signing.jwksPath` is omitted. +const DEFAULT_MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; + +#[derive(Debug, Args)] +pub struct DoctorArgs { + /// Deployment project directory containing runtime.yaml and bundle/. + #[arg(long)] + pub project: PathBuf, + + /// Mechanically compare this Mint configuration with Evidence authentication. + #[arg(long, value_name = "PATH")] + pub mint_config: Option, + + /// Emit one machine-readable JSON report on standard output. + #[arg(long)] + pub json: bool, +} + +/// One artifact this walk refuses, and why. +#[derive(Debug, Serialize)] +struct Finding { + path: String, + problem: String, +} + +/// One group of artifacts governed by a single runtime rule. +#[derive(Debug, Serialize)] +struct Check { + name: &'static str, + passed: bool, + inspected: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + findings: Vec, +} + +#[derive(Debug, Serialize)] +struct DoctorReport { + checks: Vec, + passed: bool, + /// Artifacts this walk looked at, summed. A reader mistakes the check + /// count for coverage otherwise: six checks say nothing about whether the + /// bundle beneath them held four files or four hundred. + inspected: usize, +} + +pub fn run(args: DoctorArgs) -> Result { + let project = args.project.as_path(); + let runtime_path = project.join("runtime.yaml"); + if !runtime_path.is_file() { + bail!( + "runtime configuration not found at {} (expected a deployment project directory containing runtime.yaml)", + runtime_path.display() + ); + } + let runtime = read_yaml(&runtime_path)?; + let bundle_directory = resolve_bundle_directory(&runtime, &runtime_path, project)?; + let bundle_config_path = bundle_directory.join("evidence.yaml"); + let bundle = read_yaml(&bundle_config_path)?; + + let mut checks = vec![ + check_runtime_file(project, &runtime_path), + check_bundle(project, &bundle_directory), + ]; + checks.extend(check_secrets(project, &runtime, &runtime_path, &bundle)); + checks.push(check_audit(project, &runtime, &runtime_path)); + if let Some(mint_config_path) = args.mint_config.as_deref() { + checks.push(check_mint_compatibility( + project, + &bundle_config_path, + &bundle, + mint_config_path, + )); + } + + let passed = checks.iter().all(|check| check.passed); + let inspected = checks.iter().map(|check| check.inspected).sum(); + let report = DoctorReport { + checks, + passed, + inspected, + }; + + if args.json { + print_diagnostics(&report, true); + let encoded = serde_json::to_string(&report).context("failed to encode the JSON report")?; + println!("{encoded}"); + } else { + print_diagnostics(&report, false); + } + + Ok(if passed { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +/// The runtime file itself: a deployment input the service refuses to start +/// from if it could write to it. +fn check_runtime_file(project: &Path, runtime_path: &Path) -> Check { + let mut run = CheckRun::new("runtime file", project); + let read_only_mount = mount_is_read_only(runtime_path); + require_immutable(&mut run, runtime_path, read_only_mount); + run.finish() +} + +/// The bundle directory and everything beneath it, under the same rule. +fn check_bundle(project: &Path, bundle_directory: &Path) -> Check { + let mut run = CheckRun::new("bundle", project); + let read_only_mount = mount_is_read_only(bundle_directory); + walk_immutable(&mut run, bundle_directory, read_only_mount); + run.finish() +} + +/// The secret root and every secret the bundle names. +/// +/// The secrets are discovered from the bundle's own `secret:file/` references +/// rather than by listing the secret directory. The difference matters: the +/// public half of a signing key is written into that directory at mode 0644 by +/// design, and a directory walk would report a project the runtime accepts. +fn check_secrets( + project: &Path, + runtime: &YamlValue, + runtime_path: &Path, + bundle: &YamlValue, +) -> Vec { + let references = secret_references(bundle); + let root = runtime + .get("secretProviders") + .and_then(|providers| providers.get("file")) + .and_then(|file| file.get("root")) + .and_then(YamlValue::as_str); + + let Some(root) = root else { + if references.is_empty() { + return Vec::new(); + } + let mut run = CheckRun::new("secrets", project); + run.refuse( + runtime_path, + format!( + "names no file secret provider, and the bundle references {} secret(s) through one", + references.len() + ), + ); + return vec![run.finish()]; + }; + let root = resolve_against(runtime_path, project, Path::new(root)); + + let mut root_run = CheckRun::new("secret root", project); + if let Some(metadata) = root_run.stat(&root) { + if metadata.file_type().is_symlink() { + root_run.refuse(&root, "is a symbolic link; the runtime requires a directory reached without traversing one".to_owned()); + } else if !metadata.is_dir() { + root_run.refuse(&root, "is not a directory".to_owned()); + } else if metadata.permissions().mode() & 0o077 != 0 { + root_run.refuse(&root, group_or_other(&metadata, 0o700)); + } + } + + let mut secret_run = CheckRun::new("secrets", project); + for name in references { + let path = root.join(&name); + let Some(metadata) = secret_run.stat(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + secret_run.refuse( + &path, + "is not a regular file reached without traversing a symbolic link".to_owned(), + ); + continue; + } + // Secrets are the one artifact the runtime pins to an exact mode + // rather than to a bound, so report the exact mode back. + let mode = metadata.permissions().mode() & 0o7777; + if mode != 0o600 { + secret_run.refuse( + &path, + format!("has mode {mode:04o}; the runtime requires exactly 0600 (chmod 600)"), + ); + } + require_sole_owner(&mut secret_run, &path, &metadata); + } + + vec![root_run.finish(), secret_run.finish()] +} + +/// The audit chain and its lock companion, when they exist. Absence is not a +/// finding: the service creates both on first write. +fn check_audit(project: &Path, runtime: &YamlValue, runtime_path: &Path) -> Check { + let mut run = CheckRun::new("audit", project); + let Some(path) = runtime + .get("auditStorage") + .and_then(|storage| storage.get("path")) + .and_then(YamlValue::as_str) + else { + return run.finish(); + }; + let path = resolve_against(runtime_path, project, Path::new(path)); + let lock = lock_companion(&path); + for candidate in [path, lock] { + if !candidate.exists() { + continue; + } + let Some(metadata) = run.stat(&candidate) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + run.refuse(&candidate, "is not a regular file".to_owned()); + continue; + } + if metadata.permissions().mode() & 0o077 != 0 { + run.refuse(&candidate, group_or_other(&metadata, 0o600)); + } + require_sole_owner(&mut run, &candidate, &metadata); + } + run.finish() +} + +/// The Evidence fields whose values must agree with a paired Mint deployment. +/// +/// This deliberately projects only the protocol binding. The rest of the +/// bundle is governed by `evidence check`, and accepting it here would turn +/// adopter tooling into a second implementation of Evidence configuration. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EvidenceAuthenticationCompatibility { + issuer: String, + audiences: Vec, + token_types: Vec, + algorithms: Vec, + jwks_uri: String, + principal_claim: String, + requester_tags_claim: String, + evidence_audience_claim: String, + grant_id_claim: String, + grant_authority_claim: String, + actor_claim: Option, +} + +/// The corresponding Mint projection. Mint's own `mint check` owns every +/// other field, including key files, clients and client assertions. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MintCompatibilityDocument { + issuer: String, + signing: MintSigningCompatibility, + access_tokens: MintAccessTokenCompatibility, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MintSigningCompatibility { + algorithm: String, + #[serde(default = "default_mint_jwks_path")] + jwks_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MintAccessTokenCompatibility { + audiences: Vec, + claims: MintClaimCompatibility, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MintClaimCompatibility { + #[serde(default = "default_principal_claim")] + principal: String, + requester_tags: String, + evidence_audience: String, + grant_id: String, + grant_authority: String, + actor: Option, +} + +fn default_mint_jwks_path() -> String { + DEFAULT_MINT_JWKS_PATH.to_owned() +} + +fn default_principal_claim() -> String { + "sub".to_owned() +} + +fn check_mint_compatibility( + project: &Path, + bundle_config_path: &Path, + bundle: &YamlValue, + mint_config_path: &Path, +) -> Check { + let mut run = CheckRun::new("mint compatibility", project); + run.inspected += 1; + let evidence: Option = bundle + .get("authentication") + .cloned() + .and_then(|authentication| serde_norway::from_value(authentication).ok()); + if evidence.is_none() { + run.refuse( + bundle_config_path, + "authentication paired-Mint compatibility fields are missing or invalid".to_owned(), + ); + } + let mint = read_mint_compatibility(&mut run, mint_config_path); + + let (Some(evidence), Some(mint)) = (evidence, mint) else { + return run.finish(); + }; + + if evidence.issuer != mint.issuer { + run.refuse( + bundle_config_path, + "authentication.issuer does not match the paired Mint issuer".to_owned(), + ); + } + + // This is the route Mint publishes in its metadata. It is concatenation, + // not URL joining: path-bearing issuers are part of Mint's contract. + let mint_jwks_uri = format!("{}{}", mint.issuer, mint.signing.jwks_path); + if evidence.jwks_uri != mint_jwks_uri { + run.refuse( + bundle_config_path, + "authentication.jwksUri does not match the paired Mint JWKS endpoint".to_owned(), + ); + } + + if !same_string_set(&evidence.audiences, &mint.access_tokens.audiences) { + run.refuse( + bundle_config_path, + "authentication.audiences do not match the paired Mint access-token audiences" + .to_owned(), + ); + } + if !evidence.algorithms.contains(&mint.signing.algorithm) { + run.refuse( + bundle_config_path, + "authentication.algorithms does not admit the paired Mint access-token signing algorithm" + .to_owned(), + ); + } + if !evidence + .token_types + .iter() + .any(|token_type| token_type == MINT_ACCESS_TOKEN_TYPE) + { + run.refuse( + bundle_config_path, + "authentication.tokenTypes does not admit Mint at+jwt access tokens".to_owned(), + ); + } + + compare_claim_name( + &mut run, + bundle_config_path, + "principalClaim", + &evidence.principal_claim, + &mint.access_tokens.claims.principal, + ); + compare_claim_name( + &mut run, + bundle_config_path, + "requesterTagsClaim", + &evidence.requester_tags_claim, + &mint.access_tokens.claims.requester_tags, + ); + compare_claim_name( + &mut run, + bundle_config_path, + "evidenceAudienceClaim", + &evidence.evidence_audience_claim, + &mint.access_tokens.claims.evidence_audience, + ); + compare_claim_name( + &mut run, + bundle_config_path, + "grantIdClaim", + &evidence.grant_id_claim, + &mint.access_tokens.claims.grant_id, + ); + compare_claim_name( + &mut run, + bundle_config_path, + "grantAuthorityClaim", + &evidence.grant_authority_claim, + &mint.access_tokens.claims.grant_authority, + ); + if evidence.actor_claim != mint.access_tokens.claims.actor { + run.refuse( + bundle_config_path, + "authentication.actorClaim does not match accessTokens.claims.actor".to_owned(), + ); + } + + run.finish() +} + +fn read_mint_compatibility( + run: &mut CheckRun<'_>, + mint_config_path: &Path, +) -> Option { + run.inspected += 1; + let bytes = match fs::read(mint_config_path) { + Ok(bytes) => bytes, + Err(_) => { + run.refuse( + mint_config_path, + "paired Mint configuration cannot be read".to_owned(), + ); + return None; + } + }; + match serde_norway::from_slice(&bytes) { + Ok(document) => Some(document), + Err(_) => { + run.refuse( + mint_config_path, + "paired Mint compatibility fields are missing or invalid".to_owned(), + ); + None + } + } +} + +fn same_string_set(left: &[String], right: &[String]) -> bool { + left.iter().collect::>() == right.iter().collect::>() +} + +fn compare_claim_name( + run: &mut CheckRun<'_>, + bundle_config_path: &Path, + evidence_field: &str, + evidence_claim: &str, + mint_claim: &str, +) { + if evidence_claim != mint_claim { + run.refuse( + bundle_config_path, + format!( + "authentication.{evidence_field} does not match its paired Mint access-token claim name" + ), + ); + } +} + +/// One check under construction: the artifacts it looked at, and the reasons it +/// refused any of them. +struct CheckRun<'a> { + name: &'static str, + project: &'a Path, + inspected: usize, + findings: Vec, +} + +impl<'a> CheckRun<'a> { + fn new(name: &'static str, project: &'a Path) -> Self { + Self { + name, + project, + inspected: 0, + findings: Vec::new(), + } + } + + /// Read one artifact's own metadata, counting it as inspected. Symbolic + /// links are not followed: every rule here is about the named entry. + fn stat(&mut self, path: &Path) -> Option { + self.inspected += 1; + match fs::symlink_metadata(path) { + Ok(metadata) => Some(metadata), + Err(error) => { + self.refuse(path, format!("cannot be read: {error}")); + None + } + } + } + + fn refuse(&mut self, path: &Path, problem: String) { + self.findings.push(Finding { + path: self.display(path), + problem, + }); + } + + /// Project-relative where possible, so a report reads as a list of things + /// to fix rather than a column of temporary directory prefixes. + fn display(&self, path: &Path) -> String { + path.strip_prefix(self.project) + .unwrap_or(path) + .display() + .to_string() + } + + fn finish(self) -> Check { + Check { + name: self.name, + passed: self.findings.is_empty(), + inspected: self.inspected, + findings: self.findings, + } + } +} + +/// No write bits for anyone: what the runtime requires of a deployment input. +fn require_immutable(run: &mut CheckRun, path: &Path, read_only_mount: bool) { + let Some(metadata) = run.stat(path) else { + return; + }; + refuse_unless_immutable(run, path, &metadata, read_only_mount); +} + +/// The same rule over a directory tree, entry by entry. +fn walk_immutable(run: &mut CheckRun, path: &Path, read_only_mount: bool) { + let Some(metadata) = run.stat(path) else { + return; + }; + if metadata.file_type().is_symlink() { + run.refuse( + path, + "is a symbolic link; the runtime refuses one anywhere in a bundle".to_owned(), + ); + return; + } + refuse_unless_immutable(run, path, &metadata, read_only_mount); + if !metadata.is_dir() { + return; + } + match fs::read_dir(path) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => walk_immutable(run, &entry.path(), read_only_mount), + Err(error) => run.refuse(path, format!("cannot be listed: {error}")), + } + } + } + Err(error) => run.refuse(path, format!("cannot be listed: {error}")), + } +} + +fn refuse_unless_immutable( + run: &mut CheckRun, + path: &Path, + metadata: &Metadata, + read_only_mount: bool, +) { + let mode = metadata.permissions().mode() & 0o7777; + if !read_only_mount && mode & 0o222 != 0 { + run.refuse( + path, + format!("has mode {mode:04o}; the runtime requires no write bits (chmod a-w)"), + ); + } +} + +/// Owned by this user and reachable under one name only. A second hard link is +/// a second name for the same bytes, which survives a permission change on the +/// first. +fn require_sole_owner(run: &mut CheckRun, path: &Path, metadata: &Metadata) { + let euid = rustix::process::geteuid().as_raw(); + if metadata.uid() != euid { + run.refuse( + path, + format!( + "is owned by uid {}, not by the user running this check (uid {euid}); the runtime requires the user it runs as", + metadata.uid() + ), + ); + } + if metadata.nlink() != 1 { + run.refuse( + path, + format!( + "has {} hard links; the runtime requires exactly one", + metadata.nlink() + ), + ); + } +} + +fn group_or_other(metadata: &Metadata, required: u32) -> String { + let mode = metadata.permissions().mode() & 0o7777; + format!( + "has mode {mode:04o}; the runtime requires no group or other access (chmod {required:o})" + ) +} + +/// The audit sink's lock companion, `.lock`. +fn lock_companion(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".lock"); + PathBuf::from(name) +} + +/// Whether the filesystem carrying `path` is mounted read-only, in which case +/// the runtime accepts write bits it would otherwise refuse. An unreadable +/// mount is treated as writable, which reports rather than hides. +fn mount_is_read_only(path: &Path) -> bool { + rustix::fs::statvfs(path).is_ok_and(|status| { + status + .f_flag + .contains(rustix::fs::StatVfsMountFlags::RDONLY) + }) +} + +/// Resolve a configured path: absolute as written, relative against the +/// configuration file's own directory. +fn resolve_against(config_path: &Path, project: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + config_path.parent().unwrap_or(project).join(path) + } +} + +/// Resolve the bundle directory a project's `runtime.yaml` names, on the same +/// terms `evidencectl fixtures run` does. +fn resolve_bundle_directory( + runtime: &YamlValue, + runtime_path: &Path, + project: &Path, +) -> Result { + match runtime.get("bundleDirectory") { + Some(value) => { + let value = value.as_str().ok_or_else(|| { + anyhow!( + "bundleDirectory in {} is not a string", + runtime_path.display() + ) + })?; + Ok(resolve_against(runtime_path, project, Path::new(value))) + } + None => Ok(project.join("bundle")), + } +} + +/// Every `secret:file/` a bundle names, wherever in the document it sits. +/// +/// This is discovery, not validation: the reference is found by its own prefix +/// rather than by the field carrying it, so a secret a future field names is +/// checked without this walk learning that field. `evidence check` is left to +/// reject a bundle that is otherwise malformed. +fn secret_references(bundle: &YamlValue) -> Vec { + let mut names = Vec::new(); + collect_secret_references(bundle, &mut names); + names +} + +fn collect_secret_references(value: &YamlValue, names: &mut Vec) { + match value { + YamlValue::String(text) => { + if let Some(name) = text.strip_prefix(SECRET_REFERENCE_PREFIX) { + let name = name.to_owned(); + if !names.contains(&name) { + names.push(name); + } + } + } + YamlValue::Sequence(items) => { + for item in items { + collect_secret_references(item, names); + } + } + YamlValue::Mapping(entries) => { + for (_, entry) in entries { + collect_secret_references(entry, names); + } + } + _ => {} + } +} + +fn read_yaml(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + serde_norway::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display())) +} + +/// Print one line per check, every finding beneath it, and a summary line. +/// +/// In JSON mode this goes to stderr, keeping stdout reserved for the single +/// JSON document; in human mode it is the entire report and goes to stdout. +/// Findings are never elided: a walk that reports some of what is broken sends +/// an operator back for a second restart, which is what this exists to avoid. +fn print_diagnostics(report: &DoctorReport, to_stderr: bool) { + let mut lines = Vec::new(); + for check in &report.checks { + let status = if check.passed { "PASS" } else { "FAIL" }; + lines.push(format!( + "{status}: {} ({} inspected)", + check.name, check.inspected + )); + for finding in &check.findings { + lines.push(format!(" {}: {}", finding.path, finding.problem)); + } + } + let passed = report.checks.iter().filter(|check| check.passed).count(); + let failed = report.checks.len() - passed; + lines.push(format!( + "{passed} passed, {failed} failed ({} artifacts inspected)", + report.inspected + )); + + for line in lines { + if to_stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } +} diff --git a/crates/registry-evidencectl/src/fixtures.rs b/crates/registry-evidencectl/src/fixtures.rs new file mode 100644 index 000000000..8e5ddf60f --- /dev/null +++ b/crates/registry-evidencectl/src/fixtures.rs @@ -0,0 +1,383 @@ +//! Fixture-run driver. Shells out to the `evidence` binary for every +//! semantic decision and only aggregates results. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, ExitCode}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Subcommand}; +use serde::Serialize; +use serde_norway::Value as YamlValue; + +#[derive(Debug, Subcommand)] +pub enum FixturesCommand { + /// Run `evidence check` and every bundle fixture through `evidence evaluate`. + Run(RunArgs), +} + +#[derive(Debug, Args)] +pub struct RunArgs { + /// Deployment project directory containing runtime.yaml and bundle/. + #[arg(long)] + pub project: PathBuf, + + /// Path to the evidence binary; defaults to `evidence` on PATH. + #[arg(long)] + pub evidence_bin: Option, + + /// Emit one machine-readable JSON report on standard output. + #[arg(long)] + pub json: bool, +} + +/// The result of one `evidence` invocation: whether it exited zero, when it did +/// not its captured stderr for the operator to read, and, for a fixture run, +/// how many cases that fixture evaluated. +struct StepOutcome { + passed: bool, + stderr: Option, + evaluated_cases: Option, +} + +#[derive(Debug, Serialize)] +struct CheckReport { + passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + stderr: Option, +} + +#[derive(Debug, Serialize)] +struct FixtureReport { + path: String, + passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + stderr: Option, + /// Absent when the fixture failed, or when `evidence` reported no count. + #[serde(skip_serializing_if = "Option::is_none")] + evaluated_cases: Option, +} + +#[derive(Debug, Serialize)] +struct RunReport { + check: CheckReport, + fixtures: Vec, + passed: bool, + /// The cases every fixture in this run evaluated, summed. + /// + /// The step counts above measure artifacts, which a reader mistakes for + /// coverage: a project with four fixture files reports the same `5 passed` + /// whether those files hold four cases or forty. + evaluated_cases: usize, +} + +pub fn run(command: FixturesCommand) -> Result { + match command { + FixturesCommand::Run(args) => run_fixtures(args), + } +} + +fn run_fixtures(args: RunArgs) -> Result { + let runtime_path = args.project.join("runtime.yaml"); + if !runtime_path.is_file() { + bail!( + "runtime configuration not found at {} (expected a deployment project directory containing runtime.yaml)", + runtime_path.display() + ); + } + let bundle_directory = resolve_bundle_directory(&runtime_path, &args.project)?; + let bundle_config_path = bundle_directory.join("evidence.yaml"); + let fixture_paths = discover_fixtures(&bundle_config_path)?; + let evidence_bin = resolve_evidence_binary(args.evidence_bin.as_deref())?; + + let check_outcome = run_evidence_step(&evidence_bin, &runtime_path, &["check"]); + let check_passed = check_outcome.passed; + + // A broken bundle makes per-fixture results meaningless, so a failing + // check short-circuits before any fixture is evaluated. + let mut fixtures = Vec::new(); + if check_passed { + for fixture_path in &fixture_paths { + let outcome = run_evidence_step( + &evidence_bin, + &runtime_path, + &["evaluate", "--fixture", fixture_path], + ); + fixtures.push(FixtureReport { + path: fixture_path.clone(), + passed: outcome.passed, + stderr: outcome.stderr, + evaluated_cases: outcome.evaluated_cases, + }); + } + } + + let overall_passed = check_passed && fixtures.iter().all(|fixture| fixture.passed); + let evaluated_cases = fixtures + .iter() + .filter_map(|fixture| fixture.evaluated_cases) + .sum(); + let report = RunReport { + check: CheckReport { + passed: check_passed, + stderr: check_outcome.stderr, + }, + fixtures, + passed: overall_passed, + evaluated_cases, + }; + + if args.json { + print_diagnostics(&report, true); + let encoded = serde_json::to_string(&report).context("failed to encode the JSON report")?; + println!("{encoded}"); + } else { + print_diagnostics(&report, false); + } + + Ok(if overall_passed { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +/// Resolve the bundle directory a project's `runtime.yaml` names. A relative +/// `bundleDirectory` is resolved against the runtime file's own directory, an +/// absolute one is used as-is, and `/bundle` is the default only when +/// the key is absent. This is discovery, not validation: `evidence check` is +/// left to reject a runtime configuration that is otherwise malformed. +fn resolve_bundle_directory(runtime_path: &Path, project: &Path) -> Result { + let bytes = fs::read(runtime_path).with_context(|| { + format!( + "failed to read runtime configuration at {}", + runtime_path.display() + ) + })?; + let document: YamlValue = serde_norway::from_slice(&bytes).with_context(|| { + format!( + "failed to parse runtime configuration at {}", + runtime_path.display() + ) + })?; + match document.get("bundleDirectory") { + Some(value) => { + let value = value.as_str().ok_or_else(|| { + anyhow!( + "bundleDirectory in {} is not a string", + runtime_path.display() + ) + })?; + let path = Path::new(value); + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + let base = runtime_path.parent().unwrap_or(project); + Ok(base.join(path)) + } + } + None => Ok(project.join("bundle")), + } +} + +/// Enumerate the bundle-relative fixture paths a project's requirements +/// reference. This is discovery, not validation: unknown fields anywhere in +/// the document are tolerated, and `evidence check` is left to reject a +/// bundle that is otherwise malformed. +fn discover_fixtures(bundle_config_path: &Path) -> Result> { + let bytes = fs::read(bundle_config_path).with_context(|| { + format!( + "failed to read bundle configuration at {}", + bundle_config_path.display() + ) + })?; + let document: YamlValue = serde_norway::from_slice(&bytes).with_context(|| { + format!( + "failed to parse bundle configuration at {}", + bundle_config_path.display() + ) + })?; + let requirements = document + .get("requirements") + .and_then(YamlValue::as_sequence) + .ok_or_else(|| { + anyhow!( + "bundle configuration at {} has no requirements list", + bundle_config_path.display() + ) + })?; + + let mut fixture_paths: Vec = Vec::new(); + for requirement in requirements { + let fixture_path = requirement + .get("fixtures") + .and_then(YamlValue::as_str) + .ok_or_else(|| { + anyhow!( + "a requirement in {} has no fixtures path", + bundle_config_path.display() + ) + })?; + if !fixture_paths + .iter() + .any(|existing| existing == fixture_path) + { + fixture_paths.push(fixture_path.to_owned()); + } + } + Ok(fixture_paths) +} + +/// Resolve the `evidence` binary: an explicit `--evidence-bin`, else +/// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. +/// Crate-visible so `suggest::emit` resolves the runtime binary the same way. +pub(crate) fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + if !path.is_file() { + bail!("evidence binary not found at {}", path.display()); + } + return Ok(path.to_path_buf()); + } + if let Ok(env_path) = env::var("EVIDENCE_BIN") { + let path = PathBuf::from(&env_path); + if !path.is_file() { + bail!( + "evidence binary not found at {} (from EVIDENCE_BIN)", + path.display() + ); + } + return Ok(path); + } + find_on_path("evidence").ok_or_else(|| { + anyhow!( + "evidence binary not found: pass --evidence-bin, set EVIDENCE_BIN, or add `evidence` to PATH" + ) + }) +} + +fn find_on_path(name: &str) -> Option { + let path_var = env::var_os("PATH")?; + env::split_paths(&path_var).find_map(|dir| { + let candidate = dir.join(name); + is_candidate_executable(&candidate).then_some(candidate) + }) +} + +/// A regular file, and, on unix, one with at least one executable bit set. A +/// non-executable file on `PATH` is skipped so resolution falls through to +/// the clearer "not found" error instead of a spawn failure later. +fn is_candidate_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Run one `evidence --runtime ` invocation. +/// +/// Standard output and standard error are captured rather than inherited so +/// steps never interleave, and any failure to even spawn the process is +/// treated the same as a nonzero exit: the step failed. +fn run_evidence_step(evidence_bin: &Path, runtime_path: &Path, args: &[&str]) -> StepOutcome { + let mut command = Command::new(evidence_bin); + command.arg("--runtime").arg(runtime_path).args(args); + match command.output() { + Ok(output) if output.status.success() => StepOutcome { + passed: true, + stderr: None, + evaluated_cases: evaluated_cases(&String::from_utf8_lossy(&output.stdout)), + }, + Ok(output) => StepOutcome { + passed: false, + stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()), + evaluated_cases: None, + }, + Err(error) => StepOutcome { + passed: false, + stderr: Some(format!("failed to run {}: {error}", evidence_bin.display())), + evaluated_cases: None, + }, + } +} + +/// Read the case count out of `Evidence fixture passed (N evaluated cases)`. +/// +/// This driver makes no semantic decision, so the count is `evidence`'s own +/// figure or nothing at all. An unrecognized line leaves it absent rather than +/// guessed, which keeps a total that is short honest instead of wrong. +fn evaluated_cases(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + line.trim() + .strip_prefix("Evidence fixture passed (")? + .strip_suffix(" evaluated cases)")? + .parse() + .ok() + }) +} + +/// Print one line per step and a summary line. +/// +/// In JSON mode this goes to stderr, keeping stdout reserved for the single +/// JSON document; in human mode it is the entire report and goes to stdout. +fn print_diagnostics(report: &RunReport, to_stderr: bool) { + let mut lines = Vec::new(); + lines.push(step_line("check", report.check.passed)); + if !report.check.passed { + lines.extend(indented(report.check.stderr.as_deref())); + } + for fixture in &report.fixtures { + let mut line = step_line(&fixture.path, fixture.passed); + if let Some(cases) = fixture.evaluated_cases { + line.push_str(&format!(" ({cases} cases)")); + } + lines.push(line); + if !fixture.passed { + lines.extend(indented(fixture.stderr.as_deref())); + } + } + let passed_count = + usize::from(report.check.passed) + report.fixtures.iter().filter(|f| f.passed).count(); + let failed_count = + usize::from(!report.check.passed) + report.fixtures.iter().filter(|f| !f.passed).count(); + // The step counts stay, because they are what the exit code is made of. + // The case total is beside them because it is the number a reader is + // actually looking for: how much of the deployment this run exercised. + lines.push(format!( + "{passed_count} passed, {failed_count} failed ({} cases evaluated)", + report.evaluated_cases + )); + + for line in lines { + if to_stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } +} + +fn step_line(name: &str, passed: bool) -> String { + let status = if passed { "PASS" } else { "FAIL" }; + format!("{status}: {name}") +} + +fn indented(stderr: Option<&str>) -> Vec { + let text = stderr.unwrap_or_default(); + if text.trim().is_empty() { + return vec![" (no output captured)".to_owned()]; + } + text.lines().map(|line| format!(" {line}")).collect() +} diff --git a/crates/registry-evidencectl/src/jwks.rs b/crates/registry-evidencectl/src/jwks.rs new file mode 100644 index 000000000..4efa9cd42 --- /dev/null +++ b/crates/registry-evidencectl/src/jwks.rs @@ -0,0 +1,114 @@ +//! Public JWKS assembly from public JWK files. Inputs containing private +//! material are rejected outright. + +use std::{ + collections::HashMap, + fs::{self, OpenOptions}, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::PathBuf, + process::ExitCode, +}; + +use anyhow::{bail, Context, Result}; +use clap::Args; +use registry_platform_crypto::{canonicalize_json, PublicJwk}; + +const OUTPUT_FILE_MODE: u32 = 0o644; + +#[derive(Debug, Args)] +pub struct JwksArgs { + /// Output JWKS document path. + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, + + /// Public JWK files to include, in order. + #[arg(required = true)] + pub public_jwk_files: Vec, +} + +pub fn run(args: JwksArgs) -> Result { + if args.out.exists() && !args.force { + bail!( + "refusing to overwrite existing output without --force: {}", + args.out.display() + ); + } + + let mut entries = Vec::new(); + // canonical bytes per kid already accepted, so a repeated kid can be + // recognized as either an identical duplicate or a genuine conflict. + let mut seen_by_kid: HashMap> = HashMap::new(); + + for path in &args.public_jwk_files { + let contents = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + // Validates the JWK and hard-rejects any private member (including a + // "d" value); the error never carries the file's contents. + let public = PublicJwk::parse(&contents) + .with_context(|| format!("{} is not a valid public JWK", path.display()))?; + let kid = public + .kid + .clone() + .ok_or_else(|| anyhow::anyhow!("{} is missing a \"kid\" member", path.display()))?; + + let value: serde_json::Value = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse {}", path.display()))?; + let canonical = canonicalize_json(&value) + .with_context(|| format!("failed to canonicalize {}", path.display()))?; + + match seen_by_kid.get(&kid) { + Some(existing) if existing == &canonical => continue, + Some(_) => bail!( + "conflicting public JWKs share kid \"{kid}\" (last seen at {})", + path.display() + ), + None => { + seen_by_kid.insert(kid, canonical); + entries.push(value); + } + } + } + + let mut document = serde_json::to_string_pretty(&serde_json::json!({ "keys": entries })) + .context("failed to render the JWKS document")?; + document.push('\n'); + + write_owner_file(&args.out, document.as_bytes(), args.force)?; + + println!("wrote {}", args.out.display()); + + Ok(ExitCode::SUCCESS) +} + +/// Writes `contents` to `path` at `OUTPUT_FILE_MODE`, set atomically at file +/// creation. A force overwrite first removes anything already at `path`, +/// including a symlink, so the create that follows always creates a fresh +/// file and `OUTPUT_FILE_MODE` is always the one `O_CREAT` applies, never a +/// later chmod that could instead land on whatever a symlink now points at. +fn write_owner_file(path: &std::path::Path, contents: &[u8], force: bool) -> Result<()> { + if force { + match fs::symlink_metadata(path) { + Ok(_) => fs::remove_file(path) + .with_context(|| format!("failed to remove existing {}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())) + } + } + } + let mut options = OpenOptions::new(); + options.write(true).mode(OUTPUT_FILE_MODE).create_new(true); + let mut file = options + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to persist {}", path.display()))?; + Ok(()) +} diff --git a/crates/registry-evidencectl/src/keygen.rs b/crates/registry-evidencectl/src/keygen.rs new file mode 100644 index 000000000..7d43add6f --- /dev/null +++ b/crates/registry-evidencectl/src/keygen.rs @@ -0,0 +1,499 @@ +//! Key material generation. Private material is written as owner-only files +//! and never reaches standard output. + +use std::{ + fs::{self, OpenOptions}, + io::Write as _, + os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::{Args, Subcommand}; +use ed25519_dalek::SigningKey; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use zeroize::Zeroizing; + +#[derive(Debug, Subcommand)] +pub enum KeygenCommand { + /// Ed25519 signing keypair as private and public JWK files. + Signing(SigningArgs), + /// One random raw secret file, 32 bytes (audit or subject-binding HMAC). + /// + /// This is HMAC key material, not a credential a source will accept: the + /// bytes are arbitrary and an HTTP header value rejects most of them. Use + /// `keygen token` for a bearer token. + Secret(SecretArgs), + /// One random bearer token file, printable and header-safe. + Token(TokenArgs), + /// Ed25519 holder keypair for SD-JWT VC confirmation binding. + Holder(HolderArgs), +} + +#[derive(Debug, Args)] +pub struct SigningArgs { + /// Secret directory receiving the private JWK file (created 0700). + #[arg(long)] + pub out_dir: PathBuf, + + /// Key identifier; defaults to the RFC 7638 JWK thumbprint. + #[arg(long)] + pub kid: Option, + + /// Public JWK output path; defaults to a file inside the secret directory. + #[arg(long)] + pub public_out: Option, + + /// Overwrite existing output files. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Args)] +pub struct SecretArgs { + /// Output file for the raw secret (written 0600). + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Args)] +pub struct TokenArgs { + /// Output file for the bearer token (written 0600). + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Args)] +pub struct HolderArgs { + /// Directory receiving the holder private JWK file (created 0700). + #[arg(long)] + pub out_dir: PathBuf, + + /// Key identifier; defaults to the RFC 7638 JWK thumbprint. + #[arg(long)] + pub kid: Option, + + /// Public JWK output path; defaults to a file inside the secret directory. + #[arg(long)] + pub public_out: Option, + + /// Overwrite existing output files. + #[arg(long)] + pub force: bool, +} + +/// Filename for the private signing JWK, fixed to match the reference +/// deployment project's secret-mount layout. +const SIGNING_PRIVATE_FILENAME: &str = "signing-ed25519-private-jwk"; +const SIGNING_PUBLIC_FILENAME: &str = "signing-ed25519-public.jwk.json"; +const HOLDER_PRIVATE_FILENAME: &str = "holder-ed25519-private-jwk"; +const HOLDER_PUBLIC_FILENAME: &str = "holder-ed25519-public.jwk.json"; +const AUDIT_HMAC_FILENAME: &str = "audit-hmac-key"; +const SUBJECT_BINDING_HMAC_FILENAME: &str = "subject-binding-hmac-key"; + +const PRIVATE_FILE_MODE: u32 = 0o600; +const PUBLIC_FILE_MODE: u32 = 0o644; +const PRIVATE_DIR_MODE: u32 = 0o700; + +/// Exactly 32 raw bytes: one HMAC secret (audit or subject-binding). +const SECRET_FILE_BYTES: usize = 32; + +/// How much randomness a generated bearer token carries, before encoding. +const TOKEN_ENTROPY_BYTES: usize = 32; + +pub fn run(command: KeygenCommand) -> Result { + match command { + KeygenCommand::Signing(args) => run_keypair( + &args.out_dir, + args.kid.as_deref(), + args.public_out.as_deref(), + args.force, + SIGNING_PRIVATE_FILENAME, + SIGNING_PUBLIC_FILENAME, + ), + KeygenCommand::Secret(args) => run_secret(&args), + KeygenCommand::Token(args) => run_token(&args), + KeygenCommand::Holder(args) => run_keypair( + &args.out_dir, + args.kid.as_deref(), + args.public_out.as_deref(), + args.force, + HOLDER_PRIVATE_FILENAME, + HOLDER_PUBLIC_FILENAME, + ), + } +} + +/// Generate the four files needed during local Evidence authoring. +/// +/// The caller supplies a newly staged, unpublished project. This function +/// never accepts force and never reports paths, so a collision fails closed +/// and no private material reaches standard output. Publication of the staged +/// project makes the complete batch visible at once. +pub(crate) fn generate_scaffold_key_material(out_dir: &Path, kid: &str) -> Result<()> { + ensure_private_dir(out_dir)?; + run_keypair_impl( + out_dir, + Some(kid), + None, + false, + SIGNING_PRIVATE_FILENAME, + SIGNING_PUBLIC_FILENAME, + false, + PUBLIC_FILE_MODE, + )?; + for filename in [AUDIT_HMAC_FILENAME, SUBJECT_BINDING_HMAC_FILENAME] { + run_secret_impl( + &SecretArgs { + out: out_dir.join(filename), + force: false, + }, + false, + )?; + } + Ok(()) +} + +/// Generate one private development keypair without reporting key material or +/// paths. Both halves remain owner-only because the pair lives in ephemeral +/// private supervisor state rather than in a public JWKS artifact. +pub(crate) fn generate_dev_keypair( + out_dir: &Path, + kid: &str, + private_filename: &str, + public_filename: &str, +) -> Result<(PathBuf, PathBuf)> { + ensure_private_dir(out_dir)?; + run_keypair_impl( + out_dir, + Some(kid), + None, + false, + private_filename, + public_filename, + false, + PRIVATE_FILE_MODE, + )?; + Ok(( + out_dir.join(private_filename), + out_dir.join(public_filename), + )) +} + +fn run_keypair( + out_dir: &Path, + kid: Option<&str>, + public_out: Option<&Path>, + force: bool, + private_filename: &str, + public_filename: &str, +) -> Result { + run_keypair_impl( + out_dir, + kid, + public_out, + force, + private_filename, + public_filename, + true, + PUBLIC_FILE_MODE, + ) +} + +#[allow(clippy::too_many_arguments)] +fn run_keypair_impl( + out_dir: &Path, + kid: Option<&str>, + public_out: Option<&Path>, + force: bool, + private_filename: &str, + public_filename: &str, + report: bool, + public_file_mode: u32, +) -> Result { + if let Some(kid) = kid { + if kid.trim().is_empty() { + bail!("--kid must not be empty or whitespace-only"); + } + } + + let private_path = out_dir.join(private_filename); + let public_path = public_out + .map(Path::to_path_buf) + .unwrap_or_else(|| out_dir.join(public_filename)); + + // Every target path is known up front, so the whole batch can be checked + // for collisions before anything is written. + reject_existing(&[&private_path, &public_path], force)?; + + let mut secret = Zeroizing::new([0_u8; 32]); + getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + let signing_key = SigningKey::from_bytes(&secret); + let x = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()); + let d = Zeroizing::new(URL_SAFE_NO_PAD.encode(secret.as_slice())); + + let kid = match kid { + Some(kid) => kid.to_string(), + None => default_kid(&x)?, + }; + + let private_json = Zeroizing::new( + serde_json::to_string_pretty(&serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + // `json!` copies `d` into a `serde_json::Value::String` whose heap + // buffer this crate does not zeroize, unlike every other copy of + // the secret above. Accepted: serde_json owns the escaping here, + // and the copy is short-lived, but it is not wiped. + "d": d.as_str(), + "x": x, + "alg": "EdDSA", + "kid": kid, + })) + .context("failed to render the private JWK")?, + ); + let public_json = serde_json::to_string_pretty(&serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": x, + "alg": "EdDSA", + "kid": kid, + "use": "sig", + })) + .context("failed to render the public JWK")?; + + // Self-check: a key this tool cannot parse back is not fit to ship. + PrivateJwk::parse(&private_json).context("generated private JWK failed validation")?; + PublicJwk::parse(&public_json).context("generated public JWK failed validation")?; + + ensure_private_dir(out_dir)?; + ensure_parent_dir(&public_path)?; + write_owner_file( + &private_path, + private_json.as_bytes(), + PRIVATE_FILE_MODE, + force, + )?; + write_owner_file( + &public_path, + public_json.as_bytes(), + public_file_mode, + force, + )?; + + if report { + println!("wrote {}", private_path.display()); + println!("wrote {}", public_path.display()); + println!("kid: {kid}"); + } + + Ok(ExitCode::SUCCESS) +} + +fn run_secret(args: &SecretArgs) -> Result { + run_secret_impl(args, true) +} + +fn run_secret_impl(args: &SecretArgs, report: bool) -> Result { + reject_existing(&[&args.out], args.force)?; + + let secret = generate_secret()?; + + if let Some(parent) = args + .out + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + ensure_secret_parent_dir(parent)?; + } + write_owner_file(&args.out, secret.as_slice(), PRIVATE_FILE_MODE, args.force)?; + + if report { + println!("wrote {}", args.out.display()); + } + + Ok(ExitCode::SUCCESS) +} + +/// Write one bearer token, for a source that has no token of its own to issue. +/// +/// A real source issues its own credential and this command has no business +/// inventing one. It exists for the stand-in source a project is stood up +/// against first, where the alternative is `keygen secret`: the obvious +/// neighbour, and the wrong tool, because its raw bytes reach an HTTP header +/// that rejects most of them. +fn run_token(args: &TokenArgs) -> Result { + reject_existing(&[&args.out], args.force)?; + + let mut entropy = Zeroizing::new([0_u8; TOKEN_ENTROPY_BYTES]); + getrandom::fill(entropy.as_mut_slice()).context("failed to generate random key material")?; + // base64url without padding: every character is unreserved in a header + // value and never NUL, so no draw has to be rejected. Written without a + // trailing newline, because the runtime reads the file whole and would + // carry one into the header. + let token = Zeroizing::new(URL_SAFE_NO_PAD.encode(entropy.as_slice())); + + if let Some(parent) = args + .out + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + ensure_secret_parent_dir(parent)?; + } + write_owner_file(&args.out, token.as_bytes(), PRIVATE_FILE_MODE, args.force)?; + + println!("wrote {}", args.out.display()); + + Ok(ExitCode::SUCCESS) +} + +/// Draws `SECRET_FILE_BYTES` uniformly at random, rejecting any draw that +/// contains a NUL byte. +/// +/// The Evidence runtime refuses a file-provided secret containing NUL, which a +/// uniform 32-byte draw carries about 11.8% of the time. Rejection sampling +/// keeps the value uniform over the accepted set (255^32, or 255.8 bits) and +/// keeps a scaffolded project working the first time, instead of failing at +/// `evidence serve` long after `evidence check` passed. +fn generate_secret() -> Result> { + let mut secret = Zeroizing::new([0_u8; SECRET_FILE_BYTES]); + loop { + getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + if !secret.contains(&0) { + return Ok(secret); + } + } +} + +/// Default kid: the RFC 7638 thumbprint of the public key. +fn default_kid(x: &str) -> Result { + let public = PublicJwk { + kty: "OKP".to_string(), + kid: None, + alg: None, + crv: Some("Ed25519".to_string()), + x: Some(x.to_string()), + y: None, + n: None, + e: None, + }; + public.jkt().context("failed to compute the JWK thumbprint") +} + +/// Refuses to proceed if any target path already exists, unless `force` is +/// set. Checked for every path before any file is written so a batch either +/// completes in full or leaves nothing behind. +fn reject_existing(paths: &[&Path], force: bool) -> Result<()> { + if force { + return Ok(()); + } + let existing: Vec = paths + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if existing.is_empty() { + return Ok(()); + } + bail!( + "refusing to overwrite existing output without --force: {}", + existing.join(", ") + ); +} + +/// Creates the parent directory of `path` if it does not already exist. Used +/// for public output paths, which carry no confidentiality requirement of +/// their own, so no particular mode is imposed. +fn ensure_parent_dir(path: &Path) -> Result<()> { + let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + else { + return Ok(()); + }; + if parent.exists() { + return Ok(()); + } + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display())) +} + +/// Creates `dir` as mode 0700 if missing, or normalizes its mode to 0700 if +/// it already exists. Used for `--out-dir`: the caller named `dir` itself as +/// the secret directory, so an existing directory found there is brought +/// under this tool's ownership either way. +fn ensure_private_dir(dir: &Path) -> Result<()> { + ensure_private_dir_impl(dir, true) +} + +/// Creates `dir` as mode 0700 if missing; leaves its mode untouched if it +/// already exists. Used for `--out`'s parent directory, which is derived from +/// the output path rather than named by the caller as a secret directory, so +/// this tool does not re-chmod a directory it did not create. +fn ensure_secret_parent_dir(dir: &Path) -> Result<()> { + ensure_private_dir_impl(dir, false) +} + +/// Shared implementation: a symlink or non-directory at `dir` is always +/// rejected. A missing `dir` is always created at mode 0700. Whether an +/// already-existing `dir` has its mode normalized to 0700 is left to the +/// caller via `normalize_existing`. +fn ensure_private_dir_impl(dir: &Path, normalize_existing: bool) -> Result<()> { + if dir.exists() { + let metadata = fs::symlink_metadata(dir) + .with_context(|| format!("failed to inspect {}", dir.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("{} exists and is not a plain directory", dir.display()); + } + if !normalize_existing { + return Ok(()); + } + } else { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + builder.mode(PRIVATE_DIR_MODE); + builder + .create(dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + } + fs::set_permissions(dir, fs::Permissions::from_mode(PRIVATE_DIR_MODE)) + .with_context(|| format!("failed to set permissions on {}", dir.display())) +} + +/// Writes `contents` to `path` with `mode`, set atomically at file creation +/// so there is no window where the file is readable with the wrong +/// permissions. A force overwrite first removes anything already at `path`, +/// including a symlink, so the create that follows always creates a fresh +/// file and `mode` is always the one `O_CREAT` applies, never a later chmod +/// that could instead land on whatever a symlink now points at. +fn write_owner_file(path: &Path, contents: &[u8], mode: u32, force: bool) -> Result<()> { + if force { + match fs::symlink_metadata(path) { + Ok(_) => fs::remove_file(path) + .with_context(|| format!("failed to remove existing {}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())) + } + } + } + let mut options = OpenOptions::new(); + options.write(true).mode(mode).create_new(true); + let mut file = options + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to persist {}", path.display()))?; + Ok(()) +} diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs new file mode 100644 index 000000000..ace835211 --- /dev/null +++ b/crates/registry-evidencectl/src/main.rs @@ -0,0 +1,95 @@ +//! Evidence adopter tooling: key generation, OpenAPI-assisted authoring, and +//! fixture runs. Companion to the frozen `evidence` runtime CLI; it never +//! implements Evidence semantics itself and shells out to the runtime binary +//! for them. + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +mod access; +mod audit_view; +mod authoring; +mod build; +mod dev; +mod doctor; +mod fixtures; +mod jwks; +mod keygen; +mod request; +mod scaffold; +mod suggest; +mod verify; + +#[derive(Debug, Parser)] +#[command( + name = "evidencectl", + version, + about = "Evidence adopter tooling: keys, OpenAPI authoring, fixture runs" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Manage local caller access policies and clients. + #[command(subcommand)] + Access(access::AccessCommand), + /// Generate Evidence deployment key material as owner-only files. + #[command(subcommand)] + Keygen(keygen::KeygenCommand), + /// Assemble a public JWKS document from public JWK files. + Jwks(jwks::JwksArgs), + /// Start an editable Evidence authoring project from OpenAPI. + New(scaffold::NewArgs), + /// Compile an editable project into a reviewed production candidate. + Build(build::BuildArgs), + /// Drive the evidence binary across a project's bundle fixtures. + #[command(subcommand)] + Fixtures(fixtures::FixturesCommand), + /// Work with a project's sources, starting from their own API documents. + #[command(subcommand)] + Source(suggest::SourceCommand), + /// Report every project artifact whose mode or owner the runtime refuses. + Doctor(doctor::DoctorArgs), + /// Run the private local Mint and Evidence pair for an authored project. + Dev(dev::DevArgs), + /// Prepare a closed request for the active local project. + #[command(subcommand)] + Request(request::RequestCommand), + /// Verify one retained Evidence response offline. + Verify(verify::VerifyArgs), + /// Inspect stopped local audit history. + #[command(subcommand)] + Audit(audit_view::AuditCommand), + #[command(name = "__dev-supervisor", hide = true)] + DevSupervisor(dev::SupervisorArgs), +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let result = match cli.command { + Command::Access(command) => access::run(command), + Command::Keygen(command) => keygen::run(command), + Command::Jwks(args) => jwks::run(args), + Command::New(args) => scaffold::run(args), + Command::Build(args) => build::run(args), + Command::Fixtures(command) => fixtures::run(command), + Command::Source(command) => suggest::run(command), + Command::Doctor(args) => doctor::run(args), + Command::Dev(args) => dev::run(args), + Command::Request(command) => request::run(command), + Command::Verify(args) => verify::run(args), + Command::Audit(command) => audit_view::run(command), + Command::DevSupervisor(args) => dev::run_supervisor(args), + }; + match result { + Ok(code) => code, + Err(error) => { + eprintln!("evidencectl: {error:#}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/registry-evidencectl/src/request.rs b/crates/registry-evidencectl/src/request.rs new file mode 100644 index 000000000..cf436b2f0 --- /dev/null +++ b/crates/registry-evidencectl/src/request.rs @@ -0,0 +1,581 @@ +//! Closed request preparation for the first local tutorial. +//! +//! This module assembles only the request described by validated ready state. +//! Mint owns authentication and Evidence owns authorization and verification +//! context semantics. + +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::{Read as _, Write as _}, + os::unix::fs::{DirBuilderExt as _, MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::{Command, ExitCode, Stdio}, +}; + +use anyhow::{anyhow, bail, Context as _, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::{Args, Subcommand, ValueEnum}; +use registry_platform_crypto::canonicalize_json; +use serde_json::{json, Map, Value}; +use zeroize::{Zeroize as _, Zeroizing}; + +use crate::{ + access, + dev::{self, ReadyDevState}, +}; + +const PRIVATE_DIRECTORY_MODE: u32 = 0o700; +const PRIVATE_FILE_MODE: u32 = 0o600; +const MAX_SELECTOR_VALUE_BYTES: usize = 200; +const MAX_TOKEN_BYTES: usize = 64 * 1024; +const MAX_CONTEXT_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Subcommand)] +pub enum RequestCommand { + /// Prepare the request, authorization header, and verification context. + Prepare(PrepareArgs), +} + +#[derive(Debug, Args)] +pub struct PrepareArgs { + /// Question defined by the active local project. + question: String, + + /// Exact purpose declared by the question. + #[arg(long)] + purpose: String, + + /// Subject selector. Repeat role:field=value for a multi-subject question. + #[arg(long, required = true)] + subject: Vec, + + /// Safe name for this retained request. + #[arg(long)] + name: String, + + /// Registered local application used to request authorization. + #[arg(long)] + client: Option, + + /// Response format to request and verify. + #[arg(long, value_enum, default_value_t = PreparedResponseFormat::SignedJws)] + format: PreparedResponseFormat, + + /// Project root. Defaults to the current directory. + #[arg(long, default_value = ".", hide = true)] + project: PathBuf, + + #[arg(long, hide = true)] + evidence_bin: Option, + + #[arg(long, hide = true)] + mint_bin: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum PreparedResponseFormat { + SignedJws, + SdJwtVc, +} + +impl PreparedResponseFormat { + fn as_str(self) -> &'static str { + match self { + Self::SignedJws => "signed-jws", + Self::SdJwtVc => "sd-jwt-vc", + } + } +} + +pub fn run(command: RequestCommand) -> Result { + match command { + RequestCommand::Prepare(args) => prepare(args), + } +} + +fn prepare(args: PrepareArgs) -> Result { + validate_request_name(&args.name)?; + let ready = dev::load_ready_state(&args.project)?; + let (question, subjects) = validate_closed_inputs(&ready, &args)?; + let client = resolve_request_client(&ready, args.client.as_deref())?; + let evidence = dev::resolve_tool_binary( + "evidence", + args.evidence_bin.as_deref(), + "EVIDENCECTL_TEST_EVIDENCE_BIN", + )?; + let mint = dev::resolve_tool_binary( + "mint", + args.mint_bin.as_deref(), + "EVIDENCECTL_TEST_MINT_BIN", + )?; + + let requests_root = ensure_requests_root(&ready.project)?; + let destination = requests_root.join(&args.name); + require_absent(&destination)?; + let mut staging = StagingDirectory::create(&requests_root)?; + + let request = closed_request(question, &subjects)?; + let request_path = staging.path().join("request.json"); + write_private_bytes(&request_path, &request)?; + + let token = obtain_token( + &mint, + &ready.token_url, + &client.client_id, + &client.private_key_path, + &client.assertion_audience, + )?; + let context_path = staging.path().join("verification.json"); + prepare_context( + &evidence, + &ready.runtime_path, + &request_path, + &context_path, + &token, + args.format, + )?; + let authorization_path = staging.path().join("authorization.curl"); + write_authorization(&authorization_path, &token)?; + drop(token); + + validate_private_directory(&requests_root)?; + staging.publish(&destination)?; + + let relative = Path::new(".evidence/requests").join(&args.name); + println!( + "Prepared request: {}", + relative.join("request.json").display() + ); + println!( + "Prepared verification context: {}", + relative.join("verification.json").display() + ); + println!( + "Prepared authorization: {}", + relative.join("authorization.curl").display() + ); + Ok(ExitCode::SUCCESS) +} + +struct RequestClient { + client_id: String, + private_key_path: PathBuf, + assertion_audience: String, +} + +fn resolve_request_client(ready: &ReadyDevState, client_id: Option<&str>) -> Result { + match client_id { + Some(client_id) => { + if ready.access_policies.is_empty() { + bail!("--client requires an active generation with explicit access policies"); + } + let policy_tags = ready + .access_policies + .iter() + .map(|policy| (policy.id.clone(), policy.requester_tag.clone())) + .collect::>(); + let client = access::resolve_ready_client(&ready.project, client_id, &policy_tags)?; + Ok(RequestClient { + client_id: client.client_id, + private_key_path: client.private_key_path, + assertion_audience: ready.token_url.clone(), + }) + } + None => { + let caller = ready.caller.as_ref().ok_or_else(|| { + anyhow!("the active project requires a registered client selected with --client") + })?; + Ok(RequestClient { + client_id: caller.client_id.clone(), + private_key_path: caller.private_key_path.clone(), + assertion_audience: caller.assertion_audience.clone(), + }) + } + } +} + +fn validate_closed_inputs<'a, 'b>( + ready: &'a ReadyDevState, + args: &'b PrepareArgs, +) -> Result<( + &'a dev::ReadyQuestionState, + Vec<(&'a dev::ReadySubjectState, &'b str)>, +)> { + let question = ready + .questions + .iter() + .find(|question| question.alias == args.question) + .ok_or_else(|| anyhow!("question does not match the active local project"))?; + if args.purpose != question.purpose { + bail!("purpose does not match the active local tutorial question"); + } + if args.subject.len() != question.subjects.len() { + bail!("subject inputs must match the question's complete role set"); + } + let mut values = BTreeMap::new(); + for input in &args.subject { + let (binding, value) = input + .split_once('=') + .filter(|(_, value)| !value.contains('=')) + .ok_or_else(|| anyhow!("subject must be one field=value or role:field=value pair"))?; + let (role, field) = match binding.split_once(':') { + Some((role, field)) if !role.contains(':') && !field.contains(':') => (role, field), + None if question.subjects.len() == 1 => (question.subjects[0].role.as_str(), binding), + _ => bail!("multi-subject inputs must use role:field=value"), + }; + let subject = question + .subjects + .iter() + .find(|subject| subject.role == role) + .ok_or_else(|| anyhow!("subject role does not match the active local question"))?; + if field != subject.selector_field || values.insert(role, value).is_some() { + bail!("subject inputs must contain each declared role and selector exactly once"); + } + if value.is_empty() + || value.len() > MAX_SELECTOR_VALUE_BYTES + || value.chars().any(char::is_control) + { + bail!("subject value must be non-empty, bounded, and contain no control characters"); + } + } + let subjects = question + .subjects + .iter() + .map(|subject| { + values + .get(subject.role.as_str()) + .copied() + .map(|value| (subject, value)) + .ok_or_else(|| anyhow!("subject inputs do not cover the complete role set")) + }) + .collect::>>()?; + Ok((question, subjects)) +} + +fn validate_request_name(name: &str) -> Result<()> { + let bytes = name.as_bytes(); + if !matches!(bytes.first(), Some(b'a'..=b'z')) + || bytes.len() > 64 + || bytes[1..].iter().any(|byte| { + !(byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-')) + }) + { + bail!("request name must be a safe lowercase local name"); + } + Ok(()) +} + +fn closed_request( + question: &dev::ReadyQuestionState, + subjects: &[(&dev::ReadySubjectState, &str)], +) -> Result> { + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).context("failed to generate a request nonce")?; + let nonce = URL_SAFE_NO_PAD.encode(random); + random.zeroize(); + let subjects = subjects + .iter() + .map(|(subject, value)| { + let selector_values = Value::Object(Map::from_iter([( + subject.selector_field.clone(), + Value::String((*value).to_owned()), + )])); + json!({ + "role": subject.role, + "selector": { + "profile": subject.selector_profile, + "values": selector_values, + } + }) + }) + .collect::>(); + let request = json!({ + "requestNonce": nonce, + "requirement": question.requirement_uri, + "purpose": question.purpose, + "subjects": subjects, + }); + canonicalize_json(&request).context("failed to serialize the closed Evidence request") +} + +fn obtain_token( + mint: &Path, + token_url: &str, + client_id: &str, + private_key_path: &Path, + assertion_audience: &str, +) -> Result> { + let mut child = Command::new(mint) + .arg("token") + .arg("--url") + .arg(token_url) + .arg("--client-id") + .arg(client_id) + .arg("--key") + .arg(private_key_path) + .arg("--audience") + .arg(assertion_audience) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("failed to invoke Mint")?; + let mut stdout = Zeroizing::new(Vec::with_capacity(MAX_TOKEN_BYTES + 2)); + let read_result = child + .stdout + .take() + .ok_or_else(|| anyhow!("failed to open Mint token output"))? + .take((MAX_TOKEN_BYTES + 3) as u64) + .read_to_end(&mut stdout); + if read_result.is_err() || stdout.len() > MAX_TOKEN_BYTES + 2 { + let _ = child.kill(); + let _ = child.wait(); + bail!("Registry Mint refused a token for client {client_id}"); + } + let status = child.wait().context("failed to wait for Mint")?; + if !status.success() { + bail!("Registry Mint refused a token for client {client_id}"); + } + if std::str::from_utf8(&stdout).is_err() { + bail!("Registry Mint refused a token for client {client_id}"); + } + let mut token = Zeroizing::new( + String::from_utf8(std::mem::take(&mut stdout)).expect("Mint output was validated as UTF-8"), + ); + if token.ends_with('\n') { + token.pop(); + if token.ends_with('\r') { + token.pop(); + } + } + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))) + { + bail!("Registry Mint refused a token for client {client_id}"); + } + Ok(token) +} + +fn prepare_context( + evidence: &Path, + runtime: &Path, + request: &Path, + context: &Path, + token: &str, + response_format: PreparedResponseFormat, +) -> Result<()> { + let context_file = create_private_file(context)?; + let mut child = Command::new(evidence) + .arg("--runtime") + .arg(runtime) + .arg("prepare-local-verification-context") + .arg("--request") + .arg(request) + .arg("--response-format") + .arg(response_format.as_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::from(context_file.try_clone()?)) + .stderr(Stdio::null()) + .spawn() + .context("failed to invoke Evidence context preparation")?; + let write_result = child + .stdin + .take() + .ok_or_else(|| anyhow!("failed to open Evidence authorization input"))? + .write_all(token.as_bytes()); + if write_result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + bail!("Evidence context preparation failed"); + } + let status = child.wait().context("failed to wait for Evidence")?; + if !status.success() { + bail!("Evidence context preparation failed"); + } + context_file.sync_all()?; + validate_private_file(context, &context_file, 1, MAX_CONTEXT_BYTES)?; + Ok(()) +} + +fn write_authorization(path: &Path, token: &str) -> Result<()> { + let mut contents = Zeroizing::new(String::with_capacity(token.len() + 36)); + contents.push_str("header = \"Authorization: Bearer "); + contents.push_str(token); + contents.push_str("\"\n"); + write_private_bytes(path, contents.as_bytes()) +} + +fn ensure_requests_root(project: &Path) -> Result { + let generated = project.join(".evidence"); + validate_private_directory(&generated)?; + let requests = generated.join("requests"); + match fs::symlink_metadata(&requests) { + Ok(_) => validate_private_directory(&requests)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_private_directory(&requests)?; + } + Err(error) => return Err(error.into()), + } + Ok(requests) +} + +fn require_absent(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => bail!("request name already exists; refusing to replace it"), + Err(error) => Err(error.into()), + } +} + +struct StagingDirectory { + path: Option, +} + +impl StagingDirectory { + fn create(parent: &Path) -> Result { + for _ in 0..8 { + let mut random = [0_u8; 12]; + getrandom::fill(&mut random)?; + let name = format!(".prepare-{}", URL_SAFE_NO_PAD.encode(random)); + random.zeroize(); + let path = parent.join(name); + match create_private_directory(&path) { + Ok(()) => return Ok(Self { path: Some(path) }), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) => { + } + Err(error) => return Err(error), + } + } + bail!("failed to allocate private request staging") + } + + fn path(&self) -> &Path { + self.path.as_deref().expect("staging remains active") + } + + fn publish(&mut self, destination: &Path) -> Result<()> { + let path = self.path(); + rename_noreplace(path, destination) + .with_context(|| format!("failed to publish request `{}`", destination.display()))?; + self.path = None; + Ok(()) + } +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + let Some(path) = self.path.take() else { + return; + }; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + let _ = fs::remove_file(path); + } + Ok(metadata) if metadata.is_dir() => { + let _ = fs::remove_dir_all(path); + } + _ => {} + } + } +} + +fn create_private_directory(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.mode(PRIVATE_DIRECTORY_MODE); + builder + .create(path) + .with_context(|| format!("failed to create private directory {}", path.display()))?; + validate_private_directory(path) +} + +fn validate_private_directory(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect private directory {}", path.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != rustix::process::getuid().as_raw() + || metadata.permissions().mode() & 0o777 != PRIVATE_DIRECTORY_MODE + { + bail!( + "private directory {} must be owner-only and unsymlinked", + path.display() + ); + } + Ok(()) +} + +fn create_private_file(path: &Path) -> Result { + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::CLOEXEC + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::NONBLOCK, + rustix::fs::Mode::from_bits_truncate(PRIVATE_FILE_MODE as rustix::fs::RawMode), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("failed to create private file {}", path.display()))?; + let file = File::from(fd); + validate_private_file(path, &file, 0, u64::MAX)?; + Ok(file) +} + +fn write_private_bytes(path: &Path, contents: &[u8]) -> Result<()> { + let mut file = create_private_file(path)?; + file.write_all(contents)?; + file.sync_all()?; + validate_private_file(path, &file, contents.len() as u64, contents.len() as u64) +} + +fn validate_private_file( + path: &Path, + opened: &File, + minimum_bytes: u64, + maximum_bytes: u64, +) -> Result<()> { + let path_metadata = fs::symlink_metadata(path)?; + let open_metadata = opened.metadata()?; + if path_metadata.file_type().is_symlink() + || !path_metadata.is_file() + || path_metadata.nlink() != 1 + || path_metadata.uid() != rustix::process::getuid().as_raw() + || path_metadata.permissions().mode() & 0o777 != PRIVATE_FILE_MODE + || path_metadata.dev() != open_metadata.dev() + || path_metadata.ino() != open_metadata.ino() + || !(minimum_bytes..=maximum_bytes).contains(&open_metadata.len()) + { + bail!("private request artifact failed its file-safety checks"); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace request publication is unsupported", + )) +} diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs new file mode 100644 index 000000000..2f131522d --- /dev/null +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -0,0 +1,213 @@ +//! Minimal OpenAPI-assisted Evidence project authoring. +//! +//! `new` retains the API description for a later question-authoring step. It +//! does not select an operation or invent Evidence semantics, source policy, +//! runtime configuration, or acceptance cases. + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{bail, Context as _}; +use clap::{Args, ValueEnum}; + +use crate::{keygen, suggest}; + +const RETAINED_OPENAPI_FILE: &str = "source.openapi.yaml"; +const SIGNING_KEY_ID: &str = "local-signing-key-1"; + +#[derive(Clone, Debug, ValueEnum)] +pub enum AuthoringProfile { + /// Development-only authoring with no deployment assurance claim. + Local, +} + +#[derive(Debug, Args)] +pub struct NewArgs { + /// New directory to create for the editable authoring project. + pub directory: PathBuf, + + /// OpenAPI 3.0 or 3.1 document: a local path or an HTTPS URL. + #[arg(long)] + pub openapi: Option, + + /// Explicit development profile for OpenAPI-assisted authoring. + #[arg(long, value_enum, requires = "openapi")] + pub profile: Option, + + /// Generate disposable, unbound local signing and HMAC material. + #[arg(long, requires = "openapi")] + pub generate_keys: bool, +} + +pub fn run(args: NewArgs) -> anyhow::Result { + let openapi = args.openapi.as_ref().context( + "`evidencectl new` starts from an API description; pass --openapi ", + )?; + if args.profile.is_none() { + bail!("OpenAPI authoring requires the explicit development profile `--profile local`"); + } + + validate_new_destination(&args.directory)?; + let parent = destination_parent(&args.directory)?; + let source = suggest::fetch::spec_source(openapi)?; + let (_, document) = suggest::openapi::Spec::open_retained(&source)?; + + let staging = tempfile::Builder::new() + .prefix(".evidencectl-new-") + .tempdir_in(parent) + .with_context(|| format!("staging the project in {}", parent.display()))?; + let staged_root = staging.path(); + + write_new_file( + &staged_root.join(".gitignore"), + b"secrets/\n.evidence/\n", + 0o644, + )?; + write_new_file( + &staged_root.join(RETAINED_OPENAPI_FILE), + document.as_bytes(), + 0o644, + )?; + for directory in [ + "selectors", + "sources", + "adapters", + "schemas", + "questions", + "derivations", + "fixtures", + ] { + fs::create_dir(staged_root.join(directory)) + .with_context(|| format!("creating the empty {directory} directory"))?; + } + + if args.generate_keys { + keygen::generate_scaffold_key_material(&staged_root.join("secrets"), SIGNING_KEY_ID) + .context("generating unbound local authoring key material")?; + } + + fs::set_permissions(staged_root, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("setting permissions on {}", staged_root.display()))?; + publish(staging, &args.directory)?; + + println!( + "Created an editable OpenAPI authoring project in {}", + args.directory.display() + ); + println!( + " OpenAPI: {} (retained exactly for question authoring)", + args.directory.join(RETAINED_OPENAPI_FILE).display() + ); + println!( + " selectors: {}", + args.directory.join("selectors").display() + ); + println!(" sources: {}", args.directory.join("sources").display()); + println!( + " questions: {}", + args.directory.join("questions").display() + ); + println!( + " derivations: {}", + args.directory.join("derivations").display() + ); + println!(" fixtures: {}", args.directory.join("fixtures").display()); + if args.generate_keys { + println!( + " keys: {} (owner-only, disposable, and unbound)", + args.directory.join("secrets").display() + ); + } + println!( + "Next: run `evidencectl source suggest --project {}` to draft one editable source.", + args.directory.display() + ); + println!("No question, fixture case, runtime, target, or deployment bundle was generated."); + Ok(ExitCode::SUCCESS) +} + +fn validate_new_destination(path: &Path) -> anyhow::Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => bail!( + "refusing to replace existing project path {}; choose a new directory", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("inspecting project path {}", path.display())) + } + } +} + +fn destination_parent(path: &Path) -> anyhow::Result<&Path> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let metadata = fs::symlink_metadata(parent) + .with_context(|| format!("inspecting project parent {}", parent.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "project parent {} must be an existing plain directory", + parent.display() + ); + } + Ok(parent) +} + +fn write_new_file(path: &Path, contents: &[u8], mode: u32) -> anyhow::Result<()> { + use std::{fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _}; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {}", parent.display()))?; + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("writing {}", path.display()))?; + file.sync_all() + .with_context(|| format!("persisting {}", path.display())) +} + +fn publish(staging: tempfile::TempDir, destination: &Path) -> anyhow::Result<()> { + let staged = staging.keep(); + if let Err(error) = rename_noreplace(&staged, destination) { + let _ = fs::remove_dir_all(&staged); + return Err(error).with_context(|| { + format!( + "publishing the project without replacing {}", + destination.display() + ) + }); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace project publication is unsupported on this platform", + )) +} diff --git a/crates/registry-evidencectl/src/suggest/emit.rs b/crates/registry-evidencectl/src/suggest/emit.rs new file mode 100644 index 000000000..75e820140 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/emit.rs @@ -0,0 +1,1378 @@ +//! Draft Evidence source artifacts from a narrowed response schema, write +//! them into a deployment project, and verify them against the `evidence` +//! binary. +//! +//! This stage never re-derives anything the earlier pipeline stages already +//! decided: it renders `NarrowOutcome` and the confirmed selection into +//! files, a pasteable source block, and a report. A bound the pipeline could +//! not resolve is never invented here either — it stays an explicit +//! `# TODO(evidencectl):` comment, so `evidence check` keeps rejecting the +//! draft until a human resolves it. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{bail, Context, Result}; +use serde_json::Value; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, DraftArtifacts, DraftFile, NarrowOutcome, OperationKey, + Provenance, SpecSource, SuggestedBound, +}; + +/// Everything the emit stage needs to draft artifacts for one source. Built +/// from the outputs of the earlier pipeline stages (`openapi`, `flatten`, +/// `sample`, `narrow`); this stage does not consult the OpenAPI document, the +/// sample, or the narrowing heuristics again. +#[derive(Debug, Clone)] +pub struct EmitInputs { + /// Source identifier the caller chose. Names every emitted file and + /// becomes the `sources.` key. + pub source_id: String, + pub operation: OperationKey, + /// Response status code the schema was read from. + pub status: String, + /// Response media type the schema was read from; also the `Accept` + /// header value in the drafted source block. + pub media_type: String, + /// An origin and path prefix derived from the OpenAPI `servers` list, when + /// one could be. The origin is emitted only as a commented review + /// suggestion. See [`split_server_url`]. + pub base_url_suggestion: Option, + /// Selected projection pointers (extended form), in presentation order. + pub selection: Vec, + pub narrowed: NarrowOutcome, + /// Every bound the closed subset required for this selection, each + /// carrying its suggestion and provenance when the pipeline could derive + /// one. Used to annotate resolved bounds with a `# derived from ...` + /// comment; a need still unresolved is already covered by + /// `narrowed.unresolved` and gets a TODO comment instead. + pub needs: Vec, + /// Where the OpenAPI document was read from, echoed back in + /// `equivalent_command`. + pub openapi: SpecSource, + /// The sample file path, if one was used, echoed back in + /// `equivalent_command`. + pub sample_path: Option, + /// The deployment project the draft was (or would be) written into, + /// echoed back in `equivalent_command`. + pub project: Option, +} + +/// The outcome of running `evidence check` against a written draft. +/// +/// Bundle-stage failures and secret/runtime-stage failures are distinguished +/// by the runtime's own fixed stderr messages: bundle rejection prints +/// "deployment ..." text, while secret/runtime initialization failure prints +/// "runtime ... initialization failed" — which means the bundle itself was +/// already accepted, only local secret material is missing. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub enum CheckClassification { + /// `evidence check` passed outright. + BundleAccepted, + /// The bundle was rejected; `stderr` is the runtime's captured message. + BundleRejected { stderr: String }, + /// The bundle was accepted, but local secret material has not been + /// provisioned yet (expected for a freshly drafted project). + SecretsUnprovisioned, +} + +/// An OpenAPI server URL split into the two places the runtime keeps its +/// parts: `baseUrl` is validated as an origin, so any path the server URL +/// carried belongs on the request path instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerSuggestion { + /// Scheme, host and optional port, with no trailing slash. + pub base_url: String, + /// The path the server URL carried, without a trailing slash, or the + /// empty string when it carried none. + pub path_prefix: String, +} + +/// Splits an OpenAPI server URL into an origin and a path prefix. +/// +/// Returns `None` for anything that does not name one fixed origin: a URL with +/// `{variables}`, a relative URL, or one carrying a query or fragment. The +/// caller leaves the origin absent in that case. The origin is not otherwise +/// validated here because it remains a review suggestion, not source policy. +pub fn split_server_url(url: &str) -> Option { + if url.contains(['{', '}', '?', '#', ' ']) { + return None; + } + let (scheme, rest) = url.split_once("://")?; + if scheme.is_empty() + || !scheme.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) + { + return None; + } + let (authority, path) = match rest.find('/') { + Some(position) => (&rest[..position], &rest[position..]), + None => (rest, ""), + }; + if authority.is_empty() || authority.contains('@') { + return None; + } + let path_prefix = path.trim_end_matches('/'); + Some(ServerSuggestion { + base_url: format!("{scheme}://{authority}"), + path_prefix: path_prefix.to_owned(), + }) +} + +/// The get_path pointer byte-length ceiling the runtime enforces. +const GET_PATH_MAX_BYTES: usize = 256; +/// The get_path pointer segment-count ceiling the runtime enforces. +const GET_PATH_MAX_SEGMENTS: usize = 16; + +/// The two methods an Evidence fixed request admits. +const ADMITTED_METHODS: [&str; 2] = ["GET", "POST"]; + +/// Draft the response schema, extract-script skeleton, facts-schema stub, +/// pasteable source block, human report, and equivalent command for one +/// source, from already-decided inputs. +pub fn draft(inputs: &EmitInputs) -> Result { + let method = request_method(&inputs.operation.method)?; + + let mut get_paths = Vec::with_capacity(inputs.selection.len()); + for pointer in &inputs.selection { + let derived = get_path_pointer(pointer).with_context(|| { + format!( + "preparing the extract script for source `{}`", + inputs.source_id + ) + })?; + get_paths.push((pointer.clone(), derived)); + } + + let files = vec![ + DraftFile { + bundle_relative_path: format!("adapters/{}-prepare.rhai", inputs.source_id), + contents: render_prepare_script(), + }, + DraftFile { + bundle_relative_path: format!("schemas/{}-parameters.schema.yaml", inputs.source_id), + contents: render_parameters_schema(), + }, + DraftFile { + bundle_relative_path: format!("schemas/{}-response.schema.yaml", inputs.source_id), + contents: render_response_schema(inputs), + }, + DraftFile { + bundle_relative_path: format!("adapters/{}-extract.rhai", inputs.source_id), + contents: render_extract_script(inputs, &get_paths), + }, + DraftFile { + bundle_relative_path: format!("schemas/{}-facts.schema.yaml", inputs.source_id), + contents: render_facts_schema(&inputs.source_id), + }, + ]; + + Ok(DraftArtifacts { + source_id: inputs.source_id.clone(), + files, + authoring_source: render_authoring_source(inputs), + source_block: render_source_block(inputs, method), + report: render_report(inputs), + equivalent_command: render_equivalent_command(inputs), + }) +} + +fn render_prepare_script() -> String { + r#"fn prepare(selectors, parameters) { + // TODO(evidencectl): bind only reviewed selector fields and fixed parameters. + #{query: [], body: ()} +} +"# + .to_owned() +} + +fn render_parameters_schema() -> String { + r#"type: object +additionalProperties: false +required: [] +properties: {} +"# + .to_owned() +} + +/// Checks the operation's method against the runtime's fixed-request method +/// enumeration, which holds two members. Anything else is refused by name +/// rather than drafted into a source the runtime would reject. +pub fn request_method(method: &str) -> Result<&'static str> { + let upper = method.to_ascii_uppercase(); + ADMITTED_METHODS + .into_iter() + .find(|admitted| *admitted == upper) + .ok_or_else(|| { + anyhow::anyhow!( + "an Evidence fixed request declares method GET or POST; `{method}` is outside \ + that enumeration, so no source can call this operation" + ) + }) +} + +/// The request path the source declares: the OpenAPI operation path with the +/// server URL's path prefix, if any, in front of it. +fn request_path(inputs: &EmitInputs) -> String { + let prefix = inputs + .base_url_suggestion + .as_ref() + .map(|server| server.path_prefix.as_str()) + .unwrap_or_default(); + format!("{prefix}{}", inputs.operation.path) +} + +/// Write every draft file under `/bundle/`, creating parent +/// directories as needed. Refuses to overwrite any existing file: if any +/// target already exists, nothing is written and the error lists every +/// collision so the caller can resolve them all at once. +#[allow(dead_code)] +pub fn write_into_project(project: &Path, files: &[DraftFile]) -> Result> { + let bundle_directory = project.join("bundle"); + let targets: Vec = files + .iter() + .map(|file| bundle_directory.join(&file.bundle_relative_path)) + .collect(); + + let collisions: Vec = targets + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if !collisions.is_empty() { + bail!( + "refusing to overwrite existing file(s): {}", + collisions.join(", ") + ); + } + + for (file, target) in files.iter().zip(&targets) { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating the directory {}", parent.display()))?; + } + fs::write(target, &file.contents) + .with_context(|| format!("writing {}", target.display()))?; + } + Ok(targets) +} + +/// Write one complete editable source draft into an authoring project. +/// +/// The authoring project owns this layout. The deployment-shaped print-only +/// draft remains available without `--project`, but local `dev` consumes this +/// source directory directly after its explicit review fields are completed. +pub fn write_into_authoring_project( + project: &Path, + artifacts: &DraftArtifacts, +) -> Result> { + for directory in ["sources", "adapters", "schemas"] { + let path = project.join(directory); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("inspecting authoring directory {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "authoring directory {} must be a plain directory created by `evidencectl new`", + path.display() + ); + } + } + let source_path = project + .join("sources") + .join(format!("{}.yaml", artifacts.source_id)); + let mut targets = vec![source_path]; + targets.extend( + artifacts + .files + .iter() + .map(|file| project.join(&file.bundle_relative_path)), + ); + + let collisions = targets + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect::>(); + if !collisions.is_empty() { + bail!( + "refusing to overwrite existing file(s): {}", + collisions.join(", ") + ); + } + let mut contents = vec![artifacts.authoring_source.as_bytes()]; + contents.extend(artifacts.files.iter().map(|file| file.contents.as_bytes())); + let mut written = Vec::new(); + for (target, contents) in targets.iter().zip(contents) { + if let Err(error) = write_new_authoring_file(target, contents) { + for path in &written { + let _ = fs::remove_file(path); + } + return Err(error); + } + written.push(target.clone()); + } + Ok(targets) +} + +fn write_new_authoring_file(path: &Path, contents: &[u8]) -> Result<()> { + use std::{fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _}; + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("writing {}", path.display()))?; + file.sync_all() + .with_context(|| format!("persisting {}", path.display())) +} + +/// Run `evidence --runtime /runtime.yaml check` and classify the +/// result. `evidence_bin` resolves the same way as the other `evidencectl` +/// subcommands that shell out to the runtime binary: an explicit path, else +/// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. +#[allow(dead_code)] +pub fn verify(project: &Path, evidence_bin: Option<&Path>) -> Result { + let evidence_bin = crate::fixtures::resolve_evidence_binary(evidence_bin) + .context("resolving the evidence binary")?; + let runtime_path = project.join("runtime.yaml"); + + let output = Command::new(&evidence_bin) + .arg("--runtime") + .arg(&runtime_path) + .arg("check") + .output() + .with_context(|| format!("failed to run {}", evidence_bin.display()))?; + + if output.status.success() { + return Ok(CheckClassification::BundleAccepted); + } + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if is_secrets_unprovisioned(&stderr) { + return Ok(CheckClassification::SecretsUnprovisioned); + } + Ok(CheckClassification::BundleRejected { stderr }) +} + +/// The runtime initialization stages that fail only because local secret or +/// key material has not been provisioned yet. Reaching any of them means the +/// bundle itself was already accepted. +/// +/// The list is exact rather than a `runtime ... initialization failed` shape, +/// because the runtime reports bundle, source and rate-limit failures through +/// the same shape. Matching the shape would report a draft the runtime refused +/// as a success. +#[allow(dead_code)] +const SECRET_STAGE_MESSAGES: [&str; 3] = [ + "evidence: runtime secret initialization failed", + "evidence: runtime audit initialization failed", + "evidence: runtime signing initialization failed", +]; + +/// True when `stderr` names one of [`SECRET_STAGE_MESSAGES`]. +/// +/// The comparison is a prefix so a stage message that grows a trailing reason +/// still classifies; the stage name itself is still matched in full. +#[allow(dead_code)] +fn is_secrets_unprovisioned(stderr: &str) -> bool { + let trimmed = stderr.trim(); + SECRET_STAGE_MESSAGES + .iter() + .any(|message| trimmed.starts_with(message)) +} + +/// Derive a plain RFC 6901 `get_path` pointer from an extended projection +/// pointer by substituting `0` for every `*` wildcard segment, then enforce +/// the runtime's byte-length and segment-count ceilings. +fn get_path_pointer(extended_pointer: &str) -> Result { + let derived = extended_pointer + .split('/') + .map(|segment| if segment == "*" { "0" } else { segment }) + .collect::>() + .join("/"); + + if derived.len() > GET_PATH_MAX_BYTES { + bail!( + "selection pointer {extended_pointer} produces a get_path pointer of {} bytes, exceeding the {GET_PATH_MAX_BYTES}-byte ceiling", + derived.len() + ); + } + let segment_count = derived.split('/').filter(|s| !s.is_empty()).count(); + if segment_count > GET_PATH_MAX_SEGMENTS { + bail!( + "selection pointer {extended_pointer} produces a get_path pointer of {segment_count} segments, exceeding the {GET_PATH_MAX_SEGMENTS}-segment ceiling" + ); + } + Ok(derived) +} + +fn escape_pointer_segment(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} + +/// How a derived bound is described wherever it is reported: in a schema +/// comment, in the report, and in the auto-acceptance notes a flag-driven run +/// prints. One phrasing keeps those three consistent. +/// +/// [`Provenance::Operator`] has no label here, because a value the operator +/// typed was not derived from anything: it is reported separately, and calling +/// it a derivation would misattribute a human decision to the tool. +pub(super) fn provenance_label(provenance: &Provenance) -> Option<&'static str> { + match provenance { + Provenance::Spec => Some("the OpenAPI schema"), + Provenance::Format => Some("its declared format"), + Provenance::Sample => Some("the sample response (widened)"), + Provenance::PageSize => Some("a page-size parameter in the spec"), + Provenance::SubsetCeiling => { + Some("the subset ceiling, because the document states a larger bound") + } + Provenance::Operator => None, + } +} + +/// The comment written above a bound the operator chose themselves. +const OPERATOR_CHOICE_COMMENT: &str = "# chosen at the prompt"; + +/// The caution shown wherever a bound the tool guessed is announced. +/// +/// Only a sampled integer earns one. A page of results says how long that page +/// was, but an integer field is usually a counter, and the highest value one +/// response happened to carry is no statement about how high it can climb; +/// under-sizing that ceiling rejects real responses later. Every other +/// derivation reads a stated bound rather than guessing at one. +pub(super) fn review_note(kind: &BoundKind, provenance: &Provenance) -> Option<&'static str> { + match (kind, provenance) { + (BoundKind::IntegerRange, Provenance::Sample) => { + Some("a counter usually needs a more generous ceiling than one response shows") + } + _ => None, + } +} + +/// Re-attributes to the operator every bound whose accepted value differs from +/// what the pipeline suggested, so the draft's comments and the report describe +/// what actually happened. +/// +/// A suggestion adopted unchanged keeps its real provenance: the operator +/// confirming a derivation does not make the derivation theirs. A need the +/// operator answered where nothing was suggested, and one whose suggestion they +/// edited, both become [`Provenance::Operator`] carrying the accepted value. +pub fn attribute_operator_edits( + needs: &mut [BoundNeed], + resolutions: &BTreeMap<(String, BoundKind), BoundValues>, +) { + for need in needs.iter_mut() { + let key = (need.pointer.clone(), need.kind.clone()); + let Some(accepted) = resolutions.get(&key) else { + continue; + }; + if need + .suggestion + .as_ref() + .is_some_and(|suggestion| &suggestion.values == accepted) + { + continue; + } + need.suggestion = Some(SuggestedBound { + values: accepted.clone(), + provenance: Provenance::Operator, + }); + } +} + +/// Tracks which (pointer, kind) bound needs are still unresolved (get a TODO +/// comment) versus resolved with known provenance (get a "derived from" +/// comment), so the schema renderer can annotate each node as it recurses. +struct SchemaAnnotations { + unresolved: BTreeSet<(String, BoundKind)>, + provenance: BTreeMap<(String, BoundKind), Provenance>, +} + +impl SchemaAnnotations { + fn new(narrowed: &NarrowOutcome, needs: &[BoundNeed]) -> Self { + let unresolved: BTreeSet<(String, BoundKind)> = narrowed + .unresolved + .iter() + .map(|need| (need.pointer.clone(), need.kind.clone())) + .collect(); + + let mut provenance = BTreeMap::new(); + for need in needs { + let key = (need.pointer.clone(), need.kind.clone()); + if unresolved.contains(&key) { + continue; + } + if let Some(suggestion) = &need.suggestion { + provenance.insert(key, suggestion.provenance.clone()); + } + } + + Self { + unresolved, + provenance, + } + } + + fn comment_for(&self, pointer: &str, kind: BoundKind) -> Option { + let key = (pointer.to_owned(), kind.clone()); + if self.unresolved.contains(&key) { + return Some(format!( + "# TODO(evidencectl): {} needs {}", + display_pointer(pointer), + kind.label() + )); + } + self.provenance.get(&key).map(|provenance| { + provenance_label(provenance).map_or_else( + || OPERATOR_CHOICE_COMMENT.to_owned(), + |label| format!("# derived from {label}"), + ) + }) + } +} + +/// Renders a pointer for a message, naming the root rather than printing an +/// empty string. Matches the wording `narrow` and `flatten` already use. +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(response root)" + } else { + pointer + } +} + +fn push_line(out: &mut String, indent: usize, text: &str) { + for _ in 0..indent { + out.push_str(" "); + } + out.push_str(text); + out.push('\n'); +} + +fn yaml_scalar_string(value: &str) -> String { + quote_if(value, needs_yaml_quoting(value)) +} + +/// A scalar written inside a flow sequence. There `,`, `[`, `]`, `{` and `}` +/// are significant wherever they appear, not only at the start of the scalar: +/// an unquoted `pending, review` would read as two members. +fn yaml_flow_scalar_string(value: &str) -> String { + let quoted = needs_yaml_quoting(value) || value.contains([',', '[', ']', '{', '}']); + quote_if(value, quoted) +} + +fn quote_if(value: &str, quoted: bool) -> String { + if quoted { + format!("\"{}\"", escape_double_quoted(value)) + } else { + value.to_owned() + } +} + +/// Escapes `value` for the interior of a double-quoted YAML or Rhai string. +/// +/// One function serves both because the two grammars agree on every escape +/// used here: `\\`, `\"`, `\n`, `\r`, `\t`, and `\xNN` for the remaining +/// control characters, all of which are below `U+00A0` and so fit two hex +/// digits. +/// +/// Every value passed here originates in the OpenAPI document: property names, +/// media types, enum members. A property name may legally contain a quote, a +/// backslash or a control character, and none of those may reach the output +/// raw. A raw newline folds a YAML scalar (turning the rest of the value into +/// a sibling mapping entry) and terminates a Rhai literal; a raw quote ends +/// either one early. +fn escape_double_quoted(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\\' => escaped.push_str(r"\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str(r"\n"), + '\r' => escaped.push_str(r"\r"), + '\t' => escaped.push_str(r"\t"), + control if control.is_control() => { + escaped.push_str(&format!(r"\x{:02x}", control as u32)); + } + other => escaped.push(other), + } + } + escaped +} + +/// Renders `value` as a complete double-quoted Rhai string literal. +fn rhai_string_literal(value: &str) -> String { + format!("\"{}\"", escape_double_quoted(value)) +} + +fn needs_yaml_quoting(value: &str) -> bool { + if value.is_empty() { + return true; + } + if matches!( + value, + "true" | "false" | "null" | "~" | "yes" | "no" | "Yes" | "No" | "TRUE" | "FALSE" | "NULL" + ) { + return true; + } + if value.trim() != value { + return true; + } + if value.parse::().is_ok() { + return true; + } + let first = value.chars().next().expect("checked non-empty above"); + if matches!( + first, + '-' | '?' + | ':' + | ',' + | '[' + | ']' + | '{' + | '}' + | '#' + | '&' + | '*' + | '!' + | '|' + | '>' + | '\'' + | '"' + | '%' + | '@' + | '`' + ) { + return true; + } + // A bare colon or hash is fine in a plain scalar (e.g. `https://...`); it + // is only ambiguous with a mapping key or a comment when followed by + // whitespace, or when a colon ends the scalar entirely. + if value.contains(": ") || value.ends_with(':') || value.contains(" #") { + return true; + } + // Any control character, not just a newline: a plain scalar carrying one + // either folds or is rejected outright, and the quoted form escapes it. + value.chars().any(char::is_control) +} + +fn render_key(key: &str) -> String { + yaml_scalar_string(key) +} + +fn render_scalar(value: &Value) -> String { + render_scalar_with(value, yaml_scalar_string) +} + +fn render_flow_scalar(value: &Value) -> String { + render_scalar_with(value, yaml_flow_scalar_string) +} + +fn render_scalar_with(value: &Value, quote: fn(&str) -> String) -> String { + match value { + Value::String(s) => quote(s), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_owned(), + other => other.to_string(), + } +} + +fn render_flow_list(items: &[Value]) -> String { + let rendered: Vec = items.iter().map(render_flow_scalar).collect(); + format!("[{}]", rendered.join(", ")) +} + +/// A `const` value, which the closed subset admits as a scalar or as a +/// sequence (an array node may be pinned to one exact list). +fn render_const_value(value: &Value) -> String { + match value { + Value::Array(items) => render_flow_list(items), + other => render_scalar(other), + } +} + +fn render_type(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => render_flow_list(items), + other => render_scalar(other), + } +} + +fn primary_type_name(object: &serde_json::Map) -> Option<&str> { + match object.get("type") { + Some(Value::String(s)) => Some(s.as_str()), + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .find(|s| *s != "null"), + _ => None, + } +} + +fn bound_kind_of(object: &serde_json::Map) -> Option { + match primary_type_name(object) { + Some("array") => Some(BoundKind::ArrayMaxItems), + Some("integer") => Some(BoundKind::IntegerRange), + Some("string") => Some(BoundKind::StringLength), + _ => None, + } +} + +fn render_response_schema(inputs: &EmitInputs) -> String { + let annotations = SchemaAnnotations::new(&inputs.narrowed, &inputs.needs); + let mut out = String::new(); + push_line( + &mut out, + 0, + &format!( + "# Closed schema for the projected response of {}. The runtime checks it", + inputs.source_id + ), + ); + push_line( + &mut out, + 0, + "# before the extract script runs, so the script maps a response whose shape", + ); + push_line( + &mut out, + 0, + "# it can rely on and never re-checks presence or type by hand.", + ); + push_line(&mut out, 0, "#"); + push_line( + &mut out, + 0, + "# Generated by `evidencectl source suggest`. Every TODO below blocks", + ); + push_line( + &mut out, + 0, + "# `evidence check` until it is resolved by hand; every \"derived from\" comment", + ); + push_line( + &mut out, + 0, + "# states where a bound came from so it can be reviewed rather than trusted", + ); + push_line(&mut out, 0, "# blindly."); + // A response body that is itself an array needs a `maxItems` like any other + // array, but the root node has no property line to hang the comment on: + // `render_schema_node` annotates children only. It is annotated here or + // nowhere. + if let Some(kind) = inputs.narrowed.schema.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for("", kind) { + push_line(&mut out, 0, &comment); + } + } + render_schema_node(&inputs.narrowed.schema, "", 0, &annotations, &mut out); + out +} + +fn render_schema_node( + node: &Value, + pointer: &str, + indent: usize, + annotations: &SchemaAnnotations, + out: &mut String, +) { + let Some(object) = node.as_object() else { + return; + }; + if let Some(type_value) = object.get("type") { + push_line(out, indent, &format!("type: {}", render_type(type_value))); + } + match primary_type_name(object) { + Some("object") => { + if let Some(additional) = object.get("additionalProperties") { + push_line( + out, + indent, + &format!("additionalProperties: {}", render_scalar(additional)), + ); + } + if let Some(required) = object.get("required").and_then(Value::as_array) { + push_line( + out, + indent, + &format!("required: {}", render_flow_list(required)), + ); + } + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + push_line(out, indent, "properties:"); + for (key, child) in properties { + let child_pointer = format!("{pointer}/{}", escape_pointer_segment(key)); + if let Some(kind) = child.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for(&child_pointer, kind) { + push_line(out, indent + 1, &comment); + } + } + push_line(out, indent + 1, &format!("{}:", render_key(key))); + render_schema_node(child, &child_pointer, indent + 2, annotations, out); + } + } + } + Some("array") => { + if let Some(min_items) = object.get("minItems") { + push_line( + out, + indent, + &format!("minItems: {}", render_scalar(min_items)), + ); + } + if let Some(max_items) = object.get("maxItems") { + push_line( + out, + indent, + &format!("maxItems: {}", render_scalar(max_items)), + ); + } + if object.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + push_line(out, indent, "uniqueItems: true"); + } + if let Some(const_value) = object.get("const") { + push_line( + out, + indent, + &format!("const: {}", render_const_value(const_value)), + ); + } + if let Some(items) = object.get("items") { + // An array of scalars never reaches the object-properties loop + // below, so a bound demanded of the items node is annotated + // here or nowhere. + let child_pointer = format!("{pointer}/*"); + if let Some(kind) = items.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for(&child_pointer, kind) { + push_line(out, indent, &comment); + } + } + push_line(out, indent, "items:"); + render_schema_node(items, &child_pointer, indent + 1, annotations, out); + } + } + _ => { + for key in ["minimum", "maximum", "minLength", "maxLength"] { + if let Some(value) = object.get(key) { + push_line(out, indent, &format!("{key}: {}", render_scalar(value))); + } + } + if let Some(format_value) = object.get("format") { + push_line( + out, + indent, + &format!("format: {}", render_scalar(format_value)), + ); + } + if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { + push_line( + out, + indent, + &format!("enum: {}", render_flow_list(enum_values)), + ); + } + if let Some(const_value) = object.get("const") { + push_line( + out, + indent, + &format!("const: {}", render_const_value(const_value)), + ); + } + } + } +} + +fn render_facts_schema(source_id: &str) -> String { + let mut out = String::new(); + push_line( + &mut out, + 0, + "# Closed schema for the facts extraction may hand to the derivation for", + ); + push_line( + &mut out, + 0, + &format!("# {source_id}. Extraction output that does not match exactly is"), + ); + push_line(&mut out, 0, "# rejected before any derivation runs."); + push_line(&mut out, 0, "#"); + push_line( + &mut out, + 0, + "# TODO(evidencectl): replace placeholder_fact with the real fact(s) this", + ); + push_line( + &mut out, + 0, + "# source's extract script produces, and give each one real bounds.", + ); + push_line(&mut out, 0, "type: object"); + push_line(&mut out, 0, "additionalProperties: false"); + push_line(&mut out, 0, "required: [placeholder_fact]"); + push_line(&mut out, 0, "properties:"); + push_line( + &mut out, + 1, + "# TODO(evidencectl): rename and bound this placeholder fact.", + ); + push_line(&mut out, 1, "placeholder_fact:"); + push_line(&mut out, 2, "type: string"); + push_line(&mut out, 2, "minLength: 1"); + push_line(&mut out, 2, "maxLength: 256"); + out +} + +/// The commented loop sketch shown beside a selection that crosses an array, +/// as `(indentation, code)` pairs. +/// +/// Every construct in it is one the runtime's Rhai engine actually registers: +/// an array is reached by its own pointer with `get_path`, guarded with +/// `is_missing`, and iterated with `for`. The engine disables ranges, exposes +/// `len` as a property rather than a method, and defines no `string + integer` +/// operator, so a sketch built from any of those would not run if pasted. +/// +/// Each array the pointer crosses is named by its own pointer relative to the +/// element that contains it, so a nested array is reached from the outer +/// element rather than from the response root. +fn loop_sketch(extended_pointer: &str) -> Vec<(usize, String)> { + let parts: Vec<&str> = extended_pointer.split("/*").collect(); + let Some((remainder, arrays)) = parts.split_last() else { + return Vec::new(); + }; + if arrays.is_empty() { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut indent = 4; + for (depth, array_pointer) in arrays.iter().enumerate() { + let level = depth + 1; + let container = if depth == 0 { + "source_response".to_owned() + } else { + format!("element_{depth}") + }; + lines.push(( + indent, + format!("let items_{level} = get_path({container}, \"{array_pointer}\");"), + )); + lines.push((indent, format!("if !is_missing(items_{level}) {{"))); + indent += 4; + lines.push((indent, format!("for element_{level} in items_{level} {{"))); + indent += 4; + } + + let innermost = arrays.len(); + if remainder.is_empty() { + lines.push(( + indent, + format!("// element_{innermost} is the value at {extended_pointer}"), + )); + } else { + lines.push(( + indent, + format!("let value = get_path(element_{innermost}, \"{remainder}\");"), + )); + lines.push((indent, "// ...".to_owned())); + } + for _ in arrays { + indent -= 4; + lines.push((indent, "}".to_owned())); + indent -= 4; + lines.push((indent, "}".to_owned())); + } + lines +} + +fn render_extract_script(inputs: &EmitInputs, get_paths: &[(String, String)]) -> String { + let mut out = String::new(); + push_line( + &mut out, + 0, + "// Fact extraction skeleton generated by `evidencectl source suggest` for", + ); + push_line( + &mut out, + 0, + &format!("// {} {}.", inputs.operation.method, inputs.operation.path), + ); + push_line( + &mut out, + 0, + "// The response schema has already rejected anything outside its declared", + ); + push_line( + &mut out, + 0, + "// shape, so get_path below never needs a presence or type check beyond", + ); + push_line( + &mut out, + 0, + "// is_missing. What remains is deciding how the selected leaves relate, which", + ); + push_line( + &mut out, + 0, + "// cardinality outcome they mean, and which facts the derivation needs.", + ); + push_line(&mut out, 0, "//"); + push_line( + &mut out, + 0, + "// TODO(evidencectl): decide how this response distinguishes zero, one, and", + ); + push_line( + &mut out, + 0, + "// multiple matches, and return the matching outcome instead of the", + ); + push_line(&mut out, 0, "// unconditional match below."); + push_line(&mut out, 0, "fn extract(source_response, parameters) {"); + + for (index, (extended_pointer, get_path_pointer)) in get_paths.iter().enumerate() { + let variable = format!("leaf_{}", index + 1); + push_line( + &mut out, + 1, + &format!( + "let {variable} = get_path(source_response, {});", + rhai_string_literal(get_path_pointer) + ), + ); + push_line(&mut out, 1, &format!("if is_missing({variable}) {{")); + push_line( + &mut out, + 2, + &format!("// TODO(evidencectl): decide what an absent {extended_pointer} means here."), + ); + push_line(&mut out, 1, "}"); + if extended_pointer.contains('*') { + push_line( + &mut out, + 1, + &format!("// TODO(evidencectl): {extended_pointer} is under an array; the index 0"), + ); + push_line( + &mut out, + 1, + "// pointer above only reaches the first element. Iterate every element once", + ); + push_line( + &mut out, + 1, + "// the cardinality check above is written, for example:", + ); + for (sketch_indent, code) in loop_sketch(extended_pointer) { + let padding = " ".repeat(sketch_indent); + push_line(&mut out, 1, &format!("// {padding}{code}")); + } + } + out.push('\n'); + } + + push_line(&mut out, 1, "#{outcome: \"match\", facts: #{}}"); + out.push_str("}\n"); + out +} + +/// Render only facts established mechanically by the selected OpenAPI +/// operation. Governed source policy remains absent rather than receiving a +/// plausible-looking default. +fn render_source_block(inputs: &EmitInputs, method: &str) -> String { + let mut out = String::new(); + push_line(&mut out, 0, "sources:"); + push_line(&mut out, 1, &format!("{}:", render_key(&inputs.source_id))); + push_line(&mut out, 2, "transport: http-json"); + match &inputs.base_url_suggestion { + Some(server) => { + push_line( + &mut out, + 2, + "# Review the OpenAPI server origin before adding it to source policy:", + ); + push_line( + &mut out, + 2, + &format!("# baseUrl: {}", yaml_scalar_string(&server.base_url)), + ); + } + None => push_line( + &mut out, + 2, + "# Add a reviewed fixed baseUrl; the OpenAPI document gives no fixed origin.", + ), + } + push_line(&mut out, 2, "request:"); + push_line(&mut out, 3, &format!("method: {method}")); + let path = request_path(inputs); + if path.contains(['{', '}']) { + push_line( + &mut out, + 3, + &format!("pathTemplate: {}", yaml_scalar_string(&path)), + ); + } else { + push_line(&mut out, 3, &format!("path: {}", yaml_scalar_string(&path))); + } + push_line(&mut out, 3, "fixedHeaders:"); + push_line(&mut out, 4, "- name: Accept"); + push_line( + &mut out, + 5, + &format!("value: {}", yaml_scalar_string(&inputs.media_type)), + ); + let selection_values: Vec = inputs + .selection + .iter() + .cloned() + .map(Value::String) + .collect(); + push_line( + &mut out, + 3, + &format!("projection: {}", render_flow_list(&selection_values)), + ); + push_line( + &mut out, + 2, + &format!( + "responseSchema: schemas/{}-response.schema.yaml", + inputs.source_id + ), + ); + push_line( + &mut out, + 2, + &format!("extractScript: adapters/{}-extract.rhai", inputs.source_id), + ); + push_line( + &mut out, + 2, + &format!("factSchema: schemas/{}-facts.schema.yaml", inputs.source_id), + ); + out +} + +/// Render the existing V1 source object without a surrounding `sources` map. +/// This keeps the authoring artifact identical to the runtime contract. +fn render_authoring_source(inputs: &EmitInputs) -> String { + let mechanical = render_source_block( + inputs, + request_method(&inputs.operation.method).expect("validated method"), + ); + let mut out = String::new(); + for line in mechanical + .lines() + .skip(2) + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + { + if line == "request:" { + push_line( + &mut out, + 0, + "# Replace review-required with source-derived, field-projected, or record-transformed.", + ); + push_line(&mut out, 0, "posture: review-required"); + push_line( + &mut out, + 0, + "# Choose an authentication kind and logical secret:file references.", + ); + push_line(&mut out, 0, "authentication: {kind: review-required}"); + } + push_line(&mut out, 0, line); + if line.starts_with(" projection:") { + push_line( + &mut out, + 1, + "# Declare selectorInputs and any complete-segment pathBindings before running dev.", + ); + push_line(&mut out, 1, "selectorInputs: []"); + push_line( + &mut out, + 1, + &format!("prepareScript: adapters/{}-prepare.rhai", inputs.source_id), + ); + push_line(&mut out, 1, "adapterParameters: {}"); + push_line( + &mut out, + 1, + &format!( + "adapterParametersSchema: schemas/{}-parameters.schema.yaml", + inputs.source_id + ), + ); + push_line(&mut out, 1, "preparationLimits:"); + push_line(&mut out, 2, "query: allowed"); + push_line(&mut out, 2, "jsonBody: forbidden"); + push_line(&mut out, 2, "maximumNormalizedBytes: 4096"); + push_line(&mut out, 1, "redirects: deny"); + push_line(&mut out, 1, "timeoutMilliseconds: 3000"); + push_line(&mut out, 1, "maximumResponseBytes: 65536"); + push_line(&mut out, 1, "concurrencyLimit: 8"); + } + } + out +} + +fn render_report(inputs: &EmitInputs) -> String { + let mut out = String::new(); + out.push_str(&format!( + "evidencectl source suggest: draft for source `{}` ({} {})\n\n", + inputs.source_id, inputs.operation.method, inputs.operation.path + )); + + let unresolved_keys: BTreeSet<(String, BoundKind)> = inputs + .narrowed + .unresolved + .iter() + .map(|need| (need.pointer.clone(), need.kind.clone())) + .collect(); + + let mut derived_lines = Vec::new(); + let mut chosen_lines = Vec::new(); + for need in &inputs.needs { + let key = (need.pointer.clone(), need.kind.clone()); + if unresolved_keys.contains(&key) { + continue; + } + let Some(suggestion) = &need.suggestion else { + continue; + }; + let note = review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")); + match provenance_label(&suggestion.provenance) { + Some(label) => derived_lines.push(format!( + " - {} ({}): derived from {label}{note}", + need.pointer, + need.kind.label() + )), + None => chosen_lines.push(format!(" - {} ({})", need.pointer, need.kind.label())), + } + } + if derived_lines.is_empty() { + out.push_str("Derived automatically: none.\n\n"); + } else { + out.push_str("Derived automatically:\n"); + for line in &derived_lines { + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + if !chosen_lines.is_empty() { + out.push_str("Chosen at the prompt:\n"); + for line in &chosen_lines { + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + + out.push_str(&format!( + "Still needs your input ({}):\n", + match inputs.narrowed.unresolved.len() { + 0 => "the source block below".to_owned(), + 1 => "1 schema bound, plus the source block below".to_owned(), + count => format!("{count} schema bounds, plus the source block below"), + } + )); + for need in &inputs.narrowed.unresolved { + out.push_str(&format!( + " - TODO(evidencectl): {} needs {}\n", + display_pointer(&need.pointer), + need.kind.label() + )); + } + out.push_str(" - source origin, posture, authentication, selector bindings, preparation,\n"); + out.push_str(" and request limits are intentionally absent from the mechanical draft.\n\n"); + + out.push_str("Next steps:\n"); + out.push_str(&format!( + " 1. Resolve every TODO(evidencectl) comment in schemas/{}-response.schema.yaml,\n adapters/{}-extract.rhai, and schemas/{}-facts.schema.yaml.\n", + inputs.source_id, inputs.source_id, inputs.source_id + )); + out.push_str(" 2. Review the source block and add the governed source decisions it omits.\n"); + out.push_str(" 3. Merge it into a separately authored Evidence project.\n"); + out +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Quotes one argument of the reproduce command for a POSIX shell. +/// +/// The reproduce line is meant to be pasted, and a projection pointer carries +/// `*`: an interactive zsh expands it, and aborts the whole command when it +/// matches nothing. Single quotes are used because they suppress every +/// expansion; the only character they cannot carry is the single quote itself, +/// which is spliced in as `'\''`. +/// +/// The decision to quote is an allowlist rather than a list of characters +/// known to be special. A denylist has to be complete to be correct, and the +/// cost of an omission is not a cosmetic one: an unquoted `$` expands a +/// parameter and an unquoted backtick runs a command, so a pointer through a +/// property named `$id` would silently reproduce a different run. +fn shell_quote(value: &str) -> String { + const SHELL_SAFE: [char; 6] = ['_', '.', '/', ':', '@', '-']; + let safe = !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || SHELL_SAFE.contains(&character)); + if safe { + return value.to_owned(); + } + format!("'{}'", value.replace('\'', r"'\''")) +} + +fn render_equivalent_command(inputs: &EmitInputs) -> String { + let mut parts: Vec = vec![ + "evidencectl".to_owned(), + "source".to_owned(), + "suggest".to_owned(), + ]; + + if inputs.project.is_none() { + parts.push("--openapi".to_owned()); + parts.push(shell_quote(&inputs.openapi.display())); + } + parts.push("--operation".to_owned()); + parts.push(shell_quote(&format!( + "{} {}", + inputs.operation.method, inputs.operation.path + ))); + parts.push("--status".to_owned()); + parts.push(shell_quote(&inputs.status)); + parts.push("--media-type".to_owned()); + parts.push(shell_quote(&inputs.media_type)); + if let Some(sample) = &inputs.sample_path { + parts.push("--sample".to_owned()); + parts.push(shell_quote(&path_display(sample))); + } + parts.push("--source-id".to_owned()); + parts.push(inputs.source_id.clone()); + if let Some(project) = &inputs.project { + parts.push("--project".to_owned()); + parts.push(shell_quote(&path_display(project))); + } + for pointer in &inputs.selection { + parts.push("--select".to_owned()); + parts.push(shell_quote(pointer)); + } + + parts.join(" ") +} diff --git a/crates/registry-evidencectl/src/suggest/fetch.rs b/crates/registry-evidencectl/src/suggest/fetch.rs new file mode 100644 index 000000000..a050d7130 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/fetch.rs @@ -0,0 +1,169 @@ +//! Reads an OpenAPI document that is published at a URL rather than sitting +//! on disk. +//! +//! Registry APIs publish their description at a well-known URL far more often +//! than they ship it as a file, and making the operator fetch it by hand adds +//! a step that only invites a stale copy. Fetching it here is read-only and +//! touches nothing the runtime will later run: the draft is still reviewed by +//! a human and still carries a `TODO` wherever a bound could not be derived. +//! +//! What a URL does change is trust. The description decides which leaves the +//! operator is offered, and the projection they pick from it is the +//! data-minimization boundary of the finished source, so a tampered document +//! could quietly widen what a deployment reads. The rule applied here is +//! therefore the one the runtime already enforces for the source URLs it will +//! itself call: plain `http` only to a numeric loopback host, `https` +//! everywhere else, and never any credential in the authority. Two tools +//! reading the same rule is one rule to review. +//! +//! A new project retains the validated response exactly; `source suggest` +//! interprets it later, when an adopter is ready to select fields. Nothing is +//! written outside the authoring project the operator names, and no request +//! carries authentication. A description behind a token is fetched by the +//! operator with their own client and passed as a file. + +use std::{io::Read, path::PathBuf, time::Duration}; + +use anyhow::{bail, Context, Result}; +use url::{Host, Url}; + +use super::types::SpecSource; + +/// How long a connection may take to establish, and how long the whole +/// exchange may take. A description is a single small document from a server +/// the operator chose; a request still running after this is a wedged host, +/// and failing beats hanging a terminal indefinitely. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Redirects are followed because a published description is very often a +/// stable URL pointing at a versioned one. The count is bounded so a +/// redirect loop fails rather than spins. +const MAX_REDIRECTS: u32 = 5; + +/// Decides what a `--openapi` argument names, and rejects a URL this tool +/// will not fetch before anything is read or any question is asked. +pub fn spec_source(value: &str) -> Result { + if looks_like_url(value) { + return Ok(SpecSource::Url(check_url(value)?)); + } + Ok(SpecSource::File(PathBuf::from(value))) +} + +/// Whether `value` names a location to fetch rather than a file to open. +/// +/// The test is for a URL scheme at the front, not for `://` anywhere in the +/// string, so a local path that merely contains those characters still opens +/// as a path. Any scheme is detected, not only the two that are permitted: +/// `ftp://spec.yaml` is a URL the operator meant as a URL, and telling them +/// the scheme is not permitted is more use than reporting that no such file +/// exists. +pub fn looks_like_url(value: &str) -> bool { + let Some((scheme, rest)) = value.split_once("://") else { + return false; + }; + !scheme.is_empty() + && !rest.is_empty() + && scheme.starts_with(|first: char| first.is_ascii_alphabetic()) + && scheme + .chars() + .all(|character| character.is_ascii_alphanumeric() || "+-.".contains(character)) +} + +/// Parses `value` and accepts it only if it is a URL this tool will fetch. +/// +/// Applied to the URL the operator passed and again to the URL the response +/// actually came from, so a document that arrived over a hop the operator +/// would not have permitted is refused rather than drafted from. The redirect +/// has already been followed by the time the second check runs; what it +/// prevents is reading a description whose last hop was open to tampering. +/// Checking the final URL is enough for that: an intermediate hop can only be +/// introduced by a server whose own response was already protected by the +/// scheme of the hop before it. +pub fn check_url(value: &str) -> Result { + let url = Url::parse(value).with_context(|| format!("parsing `{value}` as a URL"))?; + + // Reported without quoting the URL back: the thing that makes it + // unacceptable is the credential inside it, and echoing it to a terminal + // or a scrollback buffer is exactly what should not happen to a secret. + if !url.username().is_empty() || url.password().is_some() { + bail!( + "the OpenAPI URL carries credentials in its authority; pass a URL without them, and \ + fetch a description that needs authentication with your own client and pass the file" + ); + } + // A query is routinely where signed URLs and API keys live, and a + // fragment is client-side state rather than part of the fetched resource. + // Reject both without echoing either value into terminal output. + if url.query().is_some() || url.fragment().is_some() { + bail!( + "the OpenAPI URL carries a query or fragment; pass a stable URL without either, or \ + fetch the description with your own client and pass the local file" + ); + } + + match url.scheme() { + "https" => {} + "http" => match url.host() { + Some(Host::Ipv4(address)) if address.is_loopback() => {} + Some(Host::Ipv6(address)) if address.is_loopback() => {} + _ => bail!( + "`{url}` is plain http to a host that is not loopback; a description read in the \ + clear can be tampered with, and it decides what the drafted projection reads. \ + Use https, or a numeric loopback host such as `http://127.0.0.1:8080/...` for a \ + local server" + ), + }, + scheme => bail!("`{scheme}` URLs are not fetched; pass an https URL, or a local file path"), + } + Ok(url) +} + +/// Fetches `url` and returns the document text, refusing a body past +/// `max_bytes`. +/// +/// The limit is enforced while reading rather than from `Content-Length`, so +/// a response that never declares a length cannot talk this into buffering +/// whatever the server feels like sending. +pub fn get(url: &Url, max_bytes: u64) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .redirects(MAX_REDIRECTS) + .user_agent(concat!("evidencectl/", env!("CARGO_PKG_VERSION"))) + .build(); + + let response = match agent + .get(url.as_str()) + .set( + "Accept", + "application/yaml, application/json, text/yaml, */*", + ) + .call() + { + Ok(response) => response, + Err(ureq::Error::Status(status, _)) => bail!( + "fetching {url} returned HTTP {status}; a description behind authentication has to be \ + fetched with your own client and passed as a file" + ), + Err(error) => return Err(error).with_context(|| format!("fetching {url}")), + }; + + let landed = response.get_url().to_owned(); + if landed != url.as_str() { + check_url(&landed) + .with_context(|| format!("{url} redirected to a URL that is not read"))?; + } + + let mut body = Vec::new(); + response + .into_reader() + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut body) + .with_context(|| format!("reading the response body of {url}"))?; + if body.len() as u64 > max_bytes { + bail!("the document at {url} exceeds the {max_bytes} byte limit"); + } + + String::from_utf8(body).with_context(|| format!("decoding the document at {url} as UTF-8")) +} diff --git a/crates/registry-evidencectl/src/suggest/flatten.rs b/crates/registry-evidencectl/src/suggest/flatten.rs new file mode 100644 index 000000000..699864e4a --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/flatten.rs @@ -0,0 +1,221 @@ +//! Flattens a fully-resolved response schema into candidate projection +//! leaves. +//! +//! Every leaf pointer is in the extended projection form ADAPTER-API.md +//! defines: RFC 6901 segments (`~0`/`~1` escaped) for object members and the +//! reserved segment `*` for "every array element". Constructs the closed +//! schema subset cannot express as a selectable leaf (a real union via +//! `oneOf`/`anyOf`, an `allOf` merging more than one schema, an +//! `additionalProperties` schema, an untyped node, a cut `$ref` cycle, a +//! pointer past the depth limit) are skipped with a warning rather than +//! failing the whole spec: one exotic node should not block drafting from the +//! rest of the operation. + +use serde_json::Value; + +use super::types::{CandidateLeaf, ResolvedSchema, RECURSIVE_REF_KEY}; + +/// The extended-pointer form shares `get_path`'s 16-segment ceiling (see +/// `primitive-library.yaml`), so a projection pointer this stage produces +/// never needs truncating again once `*` is substituted for a numeric index. +const MAX_POINTER_SEGMENTS: usize = 16; + +/// Flattens `schema` into its selectable leaves plus warnings for any +/// skipped, unsupported node. A leaf's `pointer` selects one scalar value +/// (or, through a `*` segment, every occurrence of one scalar value inside +/// an array); containers (objects, arrays) are never themselves leaves. +pub fn candidate_leaves(schema: &ResolvedSchema) -> (Vec, Vec) { + let mut leaves = Vec::new(); + let mut warnings = Vec::new(); + walk(&schema.0, String::new(), 0, &mut leaves, &mut warnings); + (leaves, warnings) +} + +fn walk( + node: &Value, + pointer: String, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + let Some(object) = node.as_object() else { + warnings.push(format!( + "schema at `{}` is not an object node; skipped", + display_pointer(&pointer) + )); + return; + }; + + if let Some(reference) = object.get(RECURSIVE_REF_KEY).and_then(Value::as_str) { + warnings.push(format!( + "`{}` repeats the $ref cycle `{reference}`; a schema with no end cannot be \ + projected, so the repeat is skipped", + display_pointer(&pointer) + )); + return; + } + + if object.contains_key("oneOf") || object.contains_key("anyOf") { + warnings.push(format!( + "unsupported oneOf/anyOf union at `{}`; skipped", + display_pointer(&pointer) + )); + return; + } + if let Some(members) = object.get("allOf").and_then(Value::as_array) { + // A single-member allOf is the common code-generation idiom for + // attaching shared metadata via $ref (already inlined by this + // point); it is equivalent to its one member. Anything wider is a + // real merge this stage does not attempt to compute. + return if members.len() == 1 { + walk(&members[0], pointer, depth, leaves, warnings) + } else { + warnings.push(format!( + "unsupported allOf with {} members at `{}`; skipped", + members.len(), + display_pointer(&pointer) + )); + }; + } + + let Some((base_type, nullable)) = resolve_type(object) else { + warnings.push(format!( + "schema at `{}` has no single supported type (missing `type`, or a multi-type union beyond `[T, \"null\"]`); skipped", + display_pointer(&pointer) + )); + return; + }; + + match base_type.as_str() { + "object" => walk_object(object, &pointer, depth, leaves, warnings), + "array" => walk_array(object, &pointer, depth, leaves, warnings), + scalar => leaves.push(CandidateLeaf { + pointer, + type_label: type_label(scalar, object), + nullable, + description: object + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + }), + } +} + +fn walk_object( + object: &serde_json::Map, + pointer: &str, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + if object + .get("additionalProperties") + .is_some_and(Value::is_object) + { + warnings.push(format!( + "unsupported additionalProperties schema at `{}`; unnamed extra members cannot be named as projection pointers", + display_pointer(pointer) + )); + } + if object.contains_key("patternProperties") { + warnings.push(format!( + "unsupported patternProperties at `{}`; skipped", + display_pointer(pointer) + )); + } + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + warnings.push(format!( + "object at `{}` has no declared properties; nothing to select", + display_pointer(pointer) + )); + return; + }; + if depth >= MAX_POINTER_SEGMENTS { + warnings.push(format!( + "pointer depth limit ({MAX_POINTER_SEGMENTS} segments) reached at `{}`; not descending further", + display_pointer(pointer) + )); + return; + } + for (member_name, member_schema) in properties { + let child_pointer = format!("{pointer}/{}", escape_pointer_segment(member_name)); + walk(member_schema, child_pointer, depth + 1, leaves, warnings); + } +} + +fn walk_array( + object: &serde_json::Map, + pointer: &str, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + let Some(items) = object.get("items") else { + warnings.push(format!( + "array at `{}` has no `items` schema; skipped", + display_pointer(pointer) + )); + return; + }; + if depth >= MAX_POINTER_SEGMENTS { + warnings.push(format!( + "pointer depth limit ({MAX_POINTER_SEGMENTS} segments) reached at `{}`; not descending further", + display_pointer(pointer) + )); + return; + } + walk(items, format!("{pointer}/*"), depth + 1, leaves, warnings); +} + +/// Resolves a schema node's JSON Schema `type` to `(base type, nullable)`. +/// Accepts a bare string type, or the closed subset's only admitted union: +/// an array pairing exactly one non-`null` type with `"null"`. Any other +/// shape (missing `type`, or a genuine multi-type union) is not represented +/// and yields `None`. +fn resolve_type(object: &serde_json::Map) -> Option<(String, bool)> { + match object.get("type") { + Some(Value::String(type_name)) => Some((type_name.clone(), false)), + Some(Value::Array(type_names)) => { + let mut non_null = Vec::new(); + let mut has_null = false; + for entry in type_names { + match entry.as_str()? { + "null" => has_null = true, + other => non_null.push(other), + } + } + if non_null.len() == 1 { + Some((non_null[0].to_string(), has_null)) + } else { + None + } + } + _ => None, + } +} + +/// A human label for a leaf's type: `string (date)` when a string carries a +/// `format`, otherwise the bare JSON Schema type name. +fn type_label(base_type: &str, object: &serde_json::Map) -> String { + if base_type == "string" { + if let Some(format) = object.get("format").and_then(Value::as_str) { + return format!("string ({format})"); + } + } + base_type.to_string() +} + +/// Escapes an object member name into one RFC 6901 pointer segment: `~` and +/// `/` are escaped in that order (escaping `/` first would double-escape a +/// `~` produced by escaping a literal `~`). +fn escape_pointer_segment(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(root)" + } else { + pointer + } +} diff --git a/crates/registry-evidencectl/src/suggest/interactive.rs b/crates/registry-evidencectl/src/suggest/interactive.rs new file mode 100644 index 000000000..a82dd62a5 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/interactive.rs @@ -0,0 +1,291 @@ +//! The interactive front-end of `evidencectl source suggest`. +//! +//! Every prompt the tool can raise lives in this module, so the pipeline in +//! `mod.rs` and the stages beneath it stay promptless and deterministic: the +//! front-end only turns the pipeline's own findings into questions and turns +//! the answers back into the same values a fully-flagged run would have +//! supplied. Nothing here derives a bound, validates a schema, or writes a +//! file. +//! +//! A prompt is only ever raised when [`is_interactive`] holds, which requires +//! both standard input and standard output to be terminals. A run that is +//! piped, redirected, or driven by CI therefore fails with a message naming +//! the flags it needs rather than blocking on a question nobody can answer. + +use std::{collections::BTreeMap, io::IsTerminal}; + +use anyhow::{anyhow, Result}; +use inquire::{ + error::InquireError, + validator::{MinLengthValidator, Validation}, + Confirm, CustomUserError, MultiSelect, Select, Text, +}; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, CandidateLeaf, DraftFile, OperationKey, OperationSummary, + Provenance, +}; + +/// The longest leaf description shown beside a candidate pointer. A schema +/// description can run to paragraphs; the list stays readable instead. +const DESCRIPTION_BUDGET: usize = 60; + +/// True when both standard input and standard output are terminals, which is +/// what an inquire prompt needs to draw itself and read an answer. +pub fn is_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +/// Ask which operation the source calls. +pub fn choose_operation(operations: &[OperationSummary]) -> Result { + let labels: Vec = operations.iter().map(operation_label).collect(); + let chosen = Select::new("Which operation does this source call?", labels) + .with_help_message("type to filter, arrows to move, enter to select") + .raw_prompt() + .map_err(prompt_error)?; + Ok(operations[chosen.index].key.clone()) +} + +/// Ask which response leaves the projection allowlist should carry. +/// +/// Nothing is preselected: the projection is the data-minimization boundary, +/// so every field is chosen deliberately. At least one leaf is required, +/// because a source with an empty projection reads nothing. +pub fn choose_leaves(leaves: &[CandidateLeaf]) -> Result> { + let labels: Vec = leaves.iter().map(leaf_label).collect(); + let chosen = MultiSelect::new("Which response fields does this source need?", labels) + .with_validator(MinLengthValidator::new(1)) + .with_help_message( + "space to toggle, enter to confirm; select the smallest set the derivation needs", + ) + .raw_prompt() + .map_err(prompt_error)?; + Ok(chosen + .into_iter() + .map(|option| leaves[option.index].pointer.clone()) + .collect()) +} + +/// Ask for every bound the closed subset demands and the specification does +/// not state. +/// +/// A derived suggestion is offered as an editable value with its provenance in +/// the help line, never adopted silently. Clearing the value skips the +/// decision, which leaves an explicit TODO in the draft that `evidence check` +/// rejects until a human resolves it. +pub fn resolve_bounds(needs: &[BoundNeed]) -> Result> { + let mut resolutions = BTreeMap::new(); + for need in needs { + if let Some(values) = ask_bound(need)? { + resolutions.insert((need.pointer.clone(), need.kind.clone()), values); + } + } + Ok(resolutions) +} + +fn ask_bound(need: &BoundNeed) -> Result> { + let message = format!("{} for {}", need.kind.label(), need.pointer); + let help = match &need.suggestion { + Some(suggestion) => format!( + "suggested from {}{}; edit it, or clear the line to leave a TODO for review", + provenance_phrase(&suggestion.provenance), + super::emit::review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")) + ), + None => format!( + "nothing in the specification or the sample implies one; enter {} or leave it empty to leave a TODO", + input_shape(&need.kind) + ), + }; + let initial = need + .suggestion + .as_ref() + .map(|suggestion| render_bound(&suggestion.values)); + + let kind = need.kind.clone(); + let mut prompt = Text::new(&message).with_help_message(&help).with_validator( + move |input: &str| -> Result { + Ok(match parse_bound(&kind, input) { + Ok(_) => Validation::Valid, + Err(message) => Validation::Invalid(message.into()), + }) + }, + ); + if let Some(initial) = &initial { + prompt = prompt.with_initial_value(initial); + } + + let answer = prompt.prompt().map_err(prompt_error)?; + parse_bound(&need.kind, &answer).map_err(|message| anyhow!("{message}")) +} + +/// Ask for the source identifier, offering `default_id` as an editable value. +pub fn choose_source_id(default_id: &str) -> Result { + let answer = Text::new("Identifier for this source?") + .with_help_message("names the generated files and the `sources.` key") + .with_initial_value(default_id) + .with_validator(|input: &str| -> Result { + Ok(match super::validate_source_id(input) { + Ok(_) => Validation::Valid, + Err(error) => Validation::Invalid(format!("{error}").into()), + }) + }) + .prompt() + .map_err(prompt_error)?; + super::validate_source_id(&answer) +} + +/// Show the files a draft would create and ask whether to write them. +pub fn confirm_write(project_display: &str, files: &[DraftFile]) -> Result { + eprintln!("These files will be written into {project_display}:"); + for file in files { + eprintln!(" bundle/{}", file.bundle_relative_path); + } + Confirm::new("Write them?") + .with_default(true) + .with_help_message("nothing existing is ever overwritten; a collision stops the write") + .prompt() + .map_err(prompt_error) +} + +/// `METHOD /path — summary`, the one-line form an operation is chosen by. +fn operation_label(operation: &OperationSummary) -> String { + let mut label = format!("{} {}", operation.key.method, operation.key.path); + if let Some(summary) = &operation.summary { + label.push_str(" — "); + label.push_str(&truncate(summary)); + } + label +} + +/// `pointer type (nullable) — description`, the one-line form a leaf is +/// chosen by. +fn leaf_label(leaf: &CandidateLeaf) -> String { + let mut label = format!("{} {}", leaf.pointer, leaf.type_label); + if leaf.nullable { + label.push_str(" (nullable)"); + } + if let Some(description) = &leaf.description { + label.push_str(" — "); + label.push_str(&truncate(description)); + } + label +} + +/// One line of `text`, cut to the description budget on a character boundary. +fn truncate(text: &str) -> String { + let single_line = text.split('\n').next().unwrap_or(text).trim(); + if single_line.chars().count() <= DESCRIPTION_BUDGET { + return single_line.to_owned(); + } + let kept: String = single_line.chars().take(DESCRIPTION_BUDGET).collect(); + format!("{kept}…") +} + +fn provenance_phrase(provenance: &Provenance) -> &'static str { + match provenance { + Provenance::Spec => "the OpenAPI schema", + Provenance::Format => "the declared format", + Provenance::Sample => "the sample response, widened", + Provenance::PageSize => "a page-size parameter in the spec", + Provenance::SubsetCeiling => "the subset ceiling; the document states a larger bound", + Provenance::Operator => "your own answer", + } +} + +/// The text form of a bound value, which is also the editable initial value +/// of its prompt. +fn render_bound(values: &BoundValues) -> String { + match values { + BoundValues::MaxItems(maximum) => maximum.to_string(), + BoundValues::IntegerRange { minimum, maximum } => format!("{minimum} {maximum}"), + BoundValues::StringLength { + min_length, + max_length, + } => format!("{min_length} {max_length}"), + } +} + +/// What the operator is asked to type for a bound with no suggestion. +fn input_shape(kind: &BoundKind) -> &'static str { + match kind { + BoundKind::ArrayMaxItems => "a maximum item count", + BoundKind::IntegerRange => "a minimum and a maximum, separated by a space", + BoundKind::StringLength => "a minimum and a maximum length, separated by a space", + } +} + +/// Parses one bound answer. An empty answer is a deliberate skip, not an +/// error: it leaves the bound unresolved and the draft rejected until a human +/// supplies it. The message in `Err` is shown inline by the prompt validator, +/// so the operator can correct the answer without restarting. +fn parse_bound(kind: &BoundKind, input: &str) -> std::result::Result, String> { + let fields: Vec<&str> = input + .split([' ', ',', '\t']) + .filter(|field| !field.is_empty()) + .collect(); + if fields.is_empty() { + return Ok(None); + } + match kind { + BoundKind::ArrayMaxItems => { + let [maximum] = fields[..] else { + return Err("enter one maximum item count".to_owned()); + }; + let maximum = parse_unsigned(maximum)?; + Ok(Some(BoundValues::MaxItems(maximum))) + } + BoundKind::IntegerRange => { + let [minimum, maximum] = fields[..] else { + return Err("enter a minimum and a maximum, separated by a space".to_owned()); + }; + let minimum = parse_signed(minimum)?; + let maximum = parse_signed(maximum)?; + if minimum > maximum { + return Err("the minimum must not exceed the maximum".to_owned()); + } + Ok(Some(BoundValues::IntegerRange { minimum, maximum })) + } + BoundKind::StringLength => { + let [minimum, maximum] = fields[..] else { + return Err("enter a minimum and a maximum length, separated by a space".to_owned()); + }; + let min_length = parse_unsigned(minimum)?; + let max_length = parse_unsigned(maximum)?; + if min_length > max_length { + return Err("the minimum length must not exceed the maximum length".to_owned()); + } + Ok(Some(BoundValues::StringLength { + min_length, + max_length, + })) + } + } +} + +fn parse_unsigned(field: &str) -> std::result::Result { + field + .parse::() + .map_err(|_| format!("`{field}` is not a whole number of zero or more")) +} + +fn parse_signed(field: &str) -> std::result::Result { + field + .parse::() + .map_err(|_| format!("`{field}` is not a whole number")) +} + +/// Turns an inquire failure into an actionable error. Cancelling a prompt is +/// an ordinary outcome of an interactive session, not a defect, so it reports +/// what did not happen rather than a library error. +fn prompt_error(error: InquireError) -> anyhow::Error { + match error { + InquireError::OperationCanceled | InquireError::OperationInterrupted => { + anyhow!("cancelled at a prompt; nothing was written") + } + InquireError::NotTTY => { + anyhow!("this run has no terminal to prompt on; pass --operation and --select instead") + } + other => anyhow::Error::new(other).context("reading a prompt answer"), + } +} diff --git a/crates/registry-evidencectl/src/suggest/mod.rs b/crates/registry-evidencectl/src/suggest/mod.rs new file mode 100644 index 000000000..6eb5706f3 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/mod.rs @@ -0,0 +1,636 @@ +//! `evidencectl source suggest`: turn one OpenAPI operation into draft +//! Evidence source artifacts. +//! +//! The tool derives what the specification can state, asks (or accepts flags) +//! for what it cannot, and never invents a bound: every unresolved decision +//! is emitted as an explicit TODO that `evidence check` rejects, so the +//! runtime stays the only validator of the closed schema subset. The +//! interactive mode is a thin front-end over the same deterministic pipeline +//! and ends by printing the equivalent fully-flagged command. + +pub mod emit; +pub mod fetch; +pub mod flatten; +pub mod interactive; +pub mod narrow; +pub mod openapi; +pub mod sample; +pub mod types; + +use std::{collections::BTreeMap, path::Path, process::ExitCode}; + +use anyhow::{bail, Result}; +use clap::{Args, Subcommand}; + +use emit::EmitInputs; +use openapi::Spec; +use types::{ + BoundKind, BoundNeed, BoundValues, CandidateLeaf, Decisions, DraftArtifacts, Observations, + OperationKey, OperationSummary, Provenance, SuggestedBound, +}; + +#[derive(Debug, Subcommand)] +pub enum SourceCommand { + /// Suggest source configuration from an OpenAPI document. + Suggest(SuggestArgs), +} + +#[derive(Debug, Args)] +pub struct SuggestArgs { + /// OpenAPI 3.0 or 3.1 document for a print-only draft. With --project, + /// the retained source.openapi.yaml is used instead. + #[arg(long)] + pub openapi: Option, + + /// Operation as "METHOD /path/template"; interactive selection if absent. + #[arg(long)] + pub operation: Option, + + /// Response status code to read the schema from. + #[arg(long, default_value = "200")] + pub status: String, + + /// Response media type to read the schema from. + #[arg(long, default_value = "application/json")] + pub media_type: String, + + /// Projection pointer to select; repeat once per leaf. Interactive + /// selection if absent. + #[arg(long = "select")] + pub selection: Vec, + + /// Sample response JSON file used to suggest bounds. Read only; nothing + /// from it is copied into any artifact except derived bounds. + #[arg(long)] + pub sample: Option, + + /// Source identifier for the generated artifacts. + #[arg(long)] + pub source_id: Option, + + /// Deployment project to write the draft into; print-only if absent. + #[arg(long)] + pub project: Option, +} + +pub fn run(command: SourceCommand) -> Result { + match command { + SourceCommand::Suggest(args) => suggest(args), + } +} + +/// The largest `maxItems` the closed schema subset admits, and therefore the +/// ceiling any page-size-derived suggestion is clamped to. +const MAX_PROJECTED_ITEMS: i64 = 256; + +/// The identifier used when an operation's path yields no usable one. +const FALLBACK_SOURCE_ID: &str = "source-a"; + +/// Run one drafting pass. +/// +/// The pipeline is the same in both front-ends and runs in one order: +/// load the document, pick the operation, resolve its response schema, +/// flatten it into candidate leaves, take a selection, observe an optional +/// sample, plan the bounds the closed subset still demands, resolve those +/// bounds, then narrow, draft, and either write or print. Only the two +/// resolution steps differ: prompts when a terminal is driving the run, +/// announced auto-acceptance of the pipeline's own suggestions when flags +/// are. Both produce the same [`Decisions`], and the printed equivalent +/// command reproduces either run exactly, because every suggestion is +/// derived deterministically from the same inputs. +pub(crate) struct PreparedSuggestion { + pub artifacts: DraftArtifacts, + flag_driven: bool, +} + +/// Run the shared OpenAPI interpretation pipeline without writing output. +/// `source suggest` and `new --openapi` differ only in how they deliver this +/// prepared draft. +pub(crate) fn prepare(args: &SuggestArgs) -> Result { + let source = suggestion_openapi(args)?; + let spec = Spec::open(&source)?; + let operations = spec.operations(); + if operations.is_empty() { + bail!( + "{} declares no operation with a JSON response schema; there is nothing to draft from", + source.display() + ); + } + + let flag_driven = args.operation.is_some() && !args.selection.is_empty(); + // A run with no terminal to prompt on still gets told what it could have + // asked for, and each answer is only knowable once the one before it is + // settled: the operations come from the document, the leaves from the + // chosen operation's response schema. So the two refusals happen at the two + // points where the answer exists, not together at the top. + if args.operation.is_none() && !interactive::is_interactive() { + bail!( + "{}\n\nthis document declares:\n{}", + missing_flags_message(args), + list_operations(&operations) + ); + } + + let summary = match &args.operation { + Some(text) => find_operation(&parse_operation(text)?, &operations)?, + None => { + let key = interactive::choose_operation(&operations)?; + find_operation(&key, &operations)? + } + }; + if !summary + .json_responses + .iter() + .any(|(status, media_type)| *status == args.status && *media_type == args.media_type) + { + bail!( + "{} {} declares no `{}` `{}` response schema; it declares {}", + summary.key.method, + summary.key.path, + args.status, + args.media_type, + describe_responses(&summary.json_responses) + ); + } + let operation = summary.key.clone(); + + let resolved = spec.response_schema(&operation, &args.status, &args.media_type)?; + for note in &resolved.notes { + eprintln!("evidencectl: {note}"); + } + let schema = resolved.schema; + let (leaves, warnings) = flatten::candidate_leaves(&schema); + for warning in &warnings { + eprintln!("evidencectl: {warning}"); + } + if leaves.is_empty() { + bail!( + "the `{}` `{}` response schema of {} {} has no selectable leaf; \ + nothing above can be projected", + args.status, + args.media_type, + operation.method, + operation.path + ); + } + + let selection = if args.selection.is_empty() { + if !interactive::is_interactive() { + bail!( + "{}\n\nthis operation's `{}` `{}` response offers:\n{}", + missing_flags_message(args), + args.status, + args.media_type, + list_leaves(&leaves) + ); + } + interactive::choose_leaves(&leaves)? + } else { + check_selection(&args.selection, &leaves)?; + args.selection.clone() + }; + + let observations = match &args.sample { + Some(path) => sample::observe(&sample::load_sample(path)?, &selection)?, + None => Observations::default(), + }; + + let plan = narrow::plan_advisories(&schema, &selection, &observations)?; + for advisory in &plan.advisories { + eprintln!("evidencectl: {}", advisory.message()); + } + let mut needs = with_page_size_fallback(plan.needs, &spec, &operation)?; + + let resolutions = if flag_driven { + accept_suggestions(&needs) + } else { + interactive::resolve_bounds(&needs)? + }; + // An operator who typed over a suggestion owns that bound now, and the + // draft must not keep crediting the source the rejected number came from. + emit::attribute_operator_edits(&mut needs, &resolutions); + + let source_id = match &args.source_id { + Some(id) => validate_source_id(id)?, + None => { + let derived = default_source_id(&operation.path); + if flag_driven { + eprintln!("evidencectl: naming this source `{derived}`, from the operation path"); + derived + } else { + interactive::choose_source_id(&derived)? + } + } + }; + + let decisions = Decisions { + operation, + status: args.status.clone(), + media_type: args.media_type.clone(), + source_id, + selection, + resolutions, + }; + + let narrowed = narrow::apply(&schema, &decisions.selection, &decisions.resolutions)?; + let inputs = EmitInputs { + source_id: decisions.source_id.clone(), + operation: decisions.operation.clone(), + status: decisions.status.clone(), + media_type: decisions.media_type.clone(), + // Only the first server is a candidate: a later entry is usually a + // sandbox or a mirror, and quietly drafting against one because the + // primary carries template variables would point the source elsewhere. + base_url_suggestion: spec + .servers() + .into_iter() + .next() + .and_then(|url| emit::split_server_url(&url)), + selection: decisions.selection.clone(), + narrowed, + needs, + openapi: source.clone(), + sample_path: args.sample.clone(), + project: args.project.clone(), + }; + let artifacts = emit::draft(&inputs)?; + + Ok(PreparedSuggestion { + artifacts, + flag_driven, + }) +} + +fn suggestion_openapi(args: &SuggestArgs) -> Result { + match (&args.project, &args.openapi) { + (Some(project), None) => { + if !project.is_dir() { + bail!( + "authoring project directory {} not found; create it with `evidencectl new` first", + project.display() + ); + } + let retained = project.join("source.openapi.yaml"); + if !retained.is_file() { + bail!( + "authoring project {} has no retained source.openapi.yaml", + project.display() + ); + } + fetch::spec_source( + retained + .to_str() + .ok_or_else(|| anyhow::anyhow!("retained OpenAPI path is not valid UTF-8"))?, + ) + } + (Some(_), Some(_)) => bail!( + "--project uses its retained source.openapi.yaml; omit --openapi to avoid drafting from a different contract" + ), + (None, Some(openapi)) => fetch::spec_source(openapi), + (None, None) => bail!("pass --openapi , or pass --project "), + } +} + +fn suggest(args: SuggestArgs) -> Result { + let prepared = prepare(&args)?; + let artifacts = prepared.artifacts; + + let code = match &args.project { + Some(project) => deliver_into_project(project, &artifacts, prepared.flag_driven)?, + None => { + print_draft(&artifacts); + ExitCode::SUCCESS + } + }; + + println!("{}", artifacts.report); + println!("Reproduce this run with:"); + println!(" {}", artifacts.equivalent_command); + Ok(code) +} + +/// Write the draft into an OpenAPI authoring project, then report what happened. +/// +/// The write refuses to replace anything that already exists, so a repeated +/// run never silently discards an edited draft. Verification runs only when +/// the operator supplied a runtime binary to run it with. +fn deliver_into_project( + project: &Path, + artifacts: &DraftArtifacts, + flag_driven: bool, +) -> Result { + if !project.is_dir() { + bail!( + "authoring project directory {} not found; create one with `evidencectl new` first", + project.display() + ); + } + if !flag_driven + && !interactive::confirm_write(&project.display().to_string(), &artifacts.files)? + { + eprintln!("evidencectl: nothing was written; the draft is printed below instead."); + print_draft(artifacts); + return Ok(ExitCode::SUCCESS); + } + + let written = emit::write_into_authoring_project(project, artifacts)?; + for path in &written { + println!("wrote {}", path.display()); + } + println!( + "source draft: {}", + project + .join("sources") + .join(format!("{}.yaml", artifacts.source_id)) + .display() + ); + + println!( + "not verified: complete a question and run `evidencectl dev`; the local compiler \ + delegates validation to Evidence." + ); + Ok(ExitCode::SUCCESS) +} + +/// Print every drafted file, and the pasteable source block, to stdout. +fn print_draft(artifacts: &DraftArtifacts) { + for file in &artifacts.files { + print_block(&file.bundle_relative_path, &file.contents); + } + print_block( + "the block to paste under `sources:` in bundle/evidence.yaml", + &artifacts.source_block, + ); +} + +fn print_block(title: &str, body: &str) { + println!("--- {title} ---"); + print!("{body}"); + if !body.ends_with('\n') { + println!(); + } + println!(); +} + +/// Adopt every suggestion the pipeline derived, announcing each one with its +/// provenance so a flag-driven run is auditable from its own output. A need +/// with no suggestion stays unresolved: nothing here invents a bound. +fn accept_suggestions(needs: &[BoundNeed]) -> BTreeMap<(String, BoundKind), BoundValues> { + let mut resolutions = BTreeMap::new(); + for need in needs { + match &need.suggestion { + Some(suggestion) => { + let derivation = emit::provenance_label(&suggestion.provenance) + .map_or_else(String::new, |label| format!(", derived from {label}")); + let note = emit::review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")); + eprintln!( + "evidencectl: adopting {} for `{}`{derivation}{note}", + describe_bound(&suggestion.values), + narrow::display_pointer(&need.pointer), + ); + resolutions.insert( + (need.pointer.clone(), need.kind.clone()), + suggestion.values.clone(), + ); + } + None => eprintln!( + "evidencectl: nothing implies {} for `{}`; left as a TODO in the draft", + need.kind.label(), + narrow::display_pointer(&need.pointer) + ), + } + } + resolutions +} + +fn describe_bound(values: &BoundValues) -> String { + match values { + BoundValues::MaxItems(maximum) => format!("maxItems {maximum}"), + BoundValues::IntegerRange { minimum, maximum } => { + format!("minimum {minimum} and maximum {maximum}") + } + BoundValues::StringLength { + min_length, + max_length, + } => format!("minLength {min_length} and maxLength {max_length}"), + } +} + +/// Fill in the top-level collection's `maxItems` from the operation's +/// page-size parameters. +/// +/// The narrowing stage sees only the response schema, so it cannot know that a +/// query parameter caps how long a page can be. What that parameter bounds is +/// exactly one array: the collection the operation pages over. It says nothing +/// about an array nested inside a record, so only a need whose pointer crosses +/// no earlier wildcard is eligible; a nested array keeps whatever narrowing +/// derived, including nothing at all. +/// +/// For that one array the stated page size outranks a sample: a page of two +/// records is a fact about the request that was made, while the parameter's +/// maximum is the source's own statement about how long any page can be. A +/// bound taken from the response schema or from a format is stronger still and +/// is left alone. The largest declared maximum is used, because a smaller one +/// bounds only the requests that ask for it, and the result is clamped to the +/// subset ceiling. +fn with_page_size_fallback( + mut needs: Vec, + spec: &Spec, + operation: &OperationKey, +) -> Result> { + let eligible = |need: &BoundNeed| { + need.kind == BoundKind::ArrayMaxItems + && is_top_level_collection(&need.pointer) + && need.suggestion.as_ref().is_none_or(|suggestion| { + matches!( + suggestion.provenance, + Provenance::Sample | Provenance::PageSize + ) + }) + }; + if !needs.iter().any(eligible) { + return Ok(needs); + } + // The smallest advertised ceiling is the only one a response can actually + // reach: a server honouring both `pageSize` (max 50) and `limit` (max 200) + // never returns more than 50 items. Taking the largest would bound the + // array well above anything the operation can produce, which is the wrong + // direction for a minimum-disclosure bound. + let Some(maximum) = spec + .page_size_maximums(operation)? + .into_iter() + .filter(|maximum| *maximum > 0) + .min() + else { + return Ok(needs); + }; + let clamped = u64::try_from(maximum.min(MAX_PROJECTED_ITEMS)).unwrap_or(1); + // A page size above the subset ceiling is not what the draft ends up + // stating, so the parameter does not get the credit for what it does. + let provenance = if maximum <= MAX_PROJECTED_ITEMS { + Provenance::PageSize + } else { + Provenance::SubsetCeiling + }; + for need in needs.iter_mut().filter(|need| eligible(need)) { + need.suggestion = Some(SuggestedBound { + values: BoundValues::MaxItems(clamped), + provenance: provenance.clone(), + }); + } + Ok(needs) +} + +/// Whether `pointer` names an array the operation pages over directly, rather +/// than one reached by descending through another array's items. +fn is_top_level_collection(pointer: &str) -> bool { + !pointer.contains("/*") +} + +/// Parses `--operation`, which names one operation as `METHOD /path`. +/// +/// A method outside the runtime's fixed-request enumeration is refused here, +/// naming the two it admits, rather than later as an operation the document +/// "does not declare": the document may well declare it, and the reason it +/// cannot be drafted from is the runtime's, not the document's. +fn parse_operation(text: &str) -> Result { + let mut fields = text.split_whitespace(); + let (Some(method), Some(path), None) = (fields.next(), fields.next(), fields.next()) else { + bail!("--operation reads as \"METHOD /path\", for example \"GET /records\"; got `{text}`"); + }; + Ok(OperationKey { + method: emit::request_method(method)?.to_owned(), + path: path.to_owned(), + }) +} + +fn find_operation<'a>( + key: &OperationKey, + operations: &'a [OperationSummary], +) -> Result<&'a OperationSummary> { + operations + .iter() + .find(|operation| operation.key == *key) + .ok_or_else(|| { + anyhow::anyhow!( + "this document declares no `{} {}` with a JSON response schema; it declares:\n{}", + key.method, + key.path, + list_operations(operations) + ) + }) +} + +/// The document's operations, one `--operation` argument per line. +fn list_operations(operations: &[OperationSummary]) -> String { + operations + .iter() + .map(|operation| format!(" {} {}", operation.key.method, operation.key.path)) + .collect::>() + .join("\n") +} + +/// The response's selectable leaves, one `--select` argument per line. +fn list_leaves(leaves: &[CandidateLeaf]) -> String { + leaves + .iter() + .map(|leaf| format!(" {}", leaf.pointer)) + .collect::>() + .join("\n") +} + +fn describe_responses(responses: &[(String, String)]) -> String { + responses + .iter() + .map(|(status, media_type)| format!("`{status}` `{media_type}`")) + .collect::>() + .join(", ") +} + +/// Rejects a `--select` pointer that names nothing in this response schema, +/// before the narrowing stage reports the same pointer in schema terms. A +/// pointer may name a leaf or a container above one: selecting a container +/// projects its whole subtree. +fn check_selection(selection: &[String], leaves: &[CandidateLeaf]) -> Result<()> { + for pointer in selection { + let prefix = format!("{pointer}/"); + let known = leaves + .iter() + .any(|leaf| leaf.pointer == *pointer || leaf.pointer.starts_with(&prefix)); + if !known { + let available = leaves + .iter() + .map(|leaf| format!(" {}", leaf.pointer)) + .collect::>() + .join("\n"); + bail!("`--select {pointer}` names nothing in this response schema; it offers:\n{available}"); + } + } + Ok(()) +} + +/// Accepts an identifier that is safe as a file name, a YAML key, and a +/// bundle-relative path segment. +fn validate_source_id(candidate: &str) -> Result { + let acceptable = !candidate.is_empty() + && candidate.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + && !candidate.starts_with('-') + && !candidate.ends_with('-'); + if !acceptable { + bail!( + "`{candidate}` is not a usable source identifier: use lowercase letters, digits and \ + inner hyphens, for example `source-a`" + ); + } + Ok(candidate.to_owned()) +} + +/// Derives a source identifier from the last literal segment of the operation +/// path, so `/v1/records/{id}` suggests `records`. A path made only of +/// template parameters yields the neutral fallback. +fn default_source_id(path: &str) -> String { + let sanitized = path + .split('/') + .rfind(|segment| !segment.is_empty() && !segment.starts_with('{')) + .map(|segment| { + segment + .chars() + .map(|character| { + let lowered = character.to_ascii_lowercase(); + if lowered.is_ascii_lowercase() || lowered.is_ascii_digit() { + lowered + } else { + '-' + } + }) + .collect::() + }) + .unwrap_or_default(); + let trimmed = sanitized.trim_matches('-').to_owned(); + if trimmed.is_empty() { + FALLBACK_SOURCE_ID.to_owned() + } else { + trimmed + } +} + +/// The error a run gets when it has neither a terminal to ask on nor the +/// flags that answer the questions. +fn missing_flags_message(args: &SuggestArgs) -> String { + let mut missing: Vec<&str> = Vec::new(); + if args.operation.is_none() { + missing.push("--operation"); + } + if args.selection.is_empty() { + missing.push("--select"); + } + format!( + "this run has no terminal to prompt on, so it needs {} on the command line; \ + run it in a terminal to choose interactively, or pass the missing flags \ + (an interactive run prints the equivalent fully-flagged command)", + missing.join(" and ") + ) +} diff --git a/crates/registry-evidencectl/src/suggest/narrow.rs b/crates/registry-evidencectl/src/suggest/narrow.rs new file mode 100644 index 000000000..4d7a59db3 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/narrow.rs @@ -0,0 +1,1054 @@ +//! Narrowing a resolved response schema to the Evidence closed schema subset. +//! +//! The runtime validates a projected response against a bundle-relative +//! `responseSchema` written in the closed Version 1 subset: objects are closed +//! and declare bounded properties, arrays declare `maxItems` in `1..=256`, +//! integers declare both `minimum` and `maximum` or an enumeration or a +//! constant, strings declare `maxLength` or one of the two date formats or an +//! enumeration or a constant, and the only admitted union is the response-role +//! pair `[T, "null"]`. Two relaxations belong to the response role alone: +//! `required` may be a subset of the declared properties, because projection +//! drops a selected leaf the record did not carry, and a node may be null. +//! +//! This module does two things over that subset and nothing else: +//! +//! - [`plan_advisories`] enumerates the bounds the subset demands and the OpenAPI +//! document does not already state, each with the best suggestion the +//! inputs support and the provenance of that suggestion. +//! - [`apply`] prunes the schema to the projection selection and rewrites it +//! into the subset, inserting the bounds a human confirmed and omitting the +//! ones nobody did. +//! +//! The module never invents a bound. An unresolved bound is left out of the +//! emitted schema and reported in [`NarrowOutcome::unresolved`], so the draft +//! fails `evidence check` until an operator supplies it. The runtime stays the +//! only validator of the subset; this module only generates toward it. + +use std::collections::BTreeMap; + +use anyhow::{bail, Context as _, Result}; +use serde_json::{Map as JsonMap, Value}; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, NarrowOutcome, Observations, Observed, Provenance, + ResolvedSchema, SuggestedBound, RECURSIVE_REF_KEY, +}; + +/// The largest `maxItems` the closed subset admits. +const MAX_ITEMS_CEILING: u64 = 256; +/// The largest `maxLength` the closed subset admits. +const MAX_LENGTH_CEILING: u64 = 65_536; +/// The largest number of properties one closed object may declare. +const MAX_PROPERTIES: usize = 64; +/// The largest enumeration the closed subset admits. +const MAX_ENUM_MEMBERS: usize = 256; +/// The byte length of a canonical hyphenated UUID. +const UUID_LENGTH: u64 = 36; +/// The smallest string bound derived from a sample. Below this a bound says +/// more about the one response that was read than about the source. +const SAMPLE_STRING_FLOOR: u64 = 16; +/// The bucket a sampled array length is rounded up to. +const SAMPLE_ARRAY_BUCKET: u64 = 8; +/// The smallest integer maximum derived from a sample, for the same reason as +/// the string floor. +const SAMPLE_INTEGER_FLOOR: u64 = 10; + +/// Everything the narrowing stage can say about a schema before a human +/// decides anything: the bounds the subset still demands, and the findings +/// that are not bound decisions. +#[derive(Debug, Clone, Default)] +pub struct Plan { + /// Bounds the closed subset demands and the specification does not state, + /// in schema order. + pub needs: Vec, + /// Facts an operator should see that are not bound decisions. + pub advisories: Vec, +} + +/// One thing worth telling the operator about a node that is not a missing +/// bound. Advisories never change the emitted schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Advisory { + /// The extended projection pointer of the node it concerns. + pub pointer: String, + pub kind: AdvisoryKind, +} + +/// What an [`Advisory`] reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdvisoryKind { + /// The sample carried an explicit null where the specification does not + /// admit one. The emitted schema follows the specification, so the draft + /// rejects that response until the operator writes the node as the + /// response-role pair `[T, "null"]`. + NullOutsideSpec, + /// A string `format` outside the closed subset was dropped. The subset + /// admits `date` and `date-time` only; every other format survives, if at + /// all, as a length bound. + DroppedFormat(String), +} + +impl Advisory { + /// A one-line explanation, safe to print: it names the pointer and the + /// rule, never a sampled value. + pub fn message(&self) -> String { + match &self.kind { + AdvisoryKind::NullOutsideSpec => format!( + "`{}` was null in the sample but the specification does not admit null; \ + write the node as the pair [T, \"null\"] if the source really reports it", + display_pointer(&self.pointer) + ), + AdvisoryKind::DroppedFormat(format) => format!( + "`{}` declares format `{format}`, which the closed subset does not admit; \ + the subset admits `date` and `date-time` only", + display_pointer(&self.pointer) + ), + } + } +} + +/// Enumerates every bound the closed subset demands of the selected subtrees +/// and the specification does not already satisfy. +/// +/// Contract: +/// +/// - `selection` holds extended projection pointers (`~0`/`~1` escapes, `*` +/// for every array element). Duplicates, ancestor and descendant overlaps, +/// pointers absent from the schema, and numeric array indexes are errors: +/// bundle validation would reject the projection they describe, so they +/// fail here first. +/// - A bound the specification states inside the subset raises no need at all. +/// - Each need carries the best derivable suggestion, by precedence +/// `Spec` > `Format` > `Sample`. `Spec` appears when a stated bound is +/// outside the subset and can be clamped into it; `Format` only for `uuid`, +/// which is a fixed 36-byte string; `Sample` is widened by the policy +/// documented on [`widen_integer_range`], [`widen_string_length`] and +/// [`widen_array_items`]. A need with no derivable value carries none: this +/// module never invents a bound. +/// - Needs are returned in schema order. +/// +/// The returned [`Plan`] also carries the findings that are not bound +/// decisions: an explicit null the specification does not admit, and every +/// string format the closed subset made this stage drop. +pub fn plan_advisories( + schema: &ResolvedSchema, + selection: &[String], + observations: &Observations, +) -> Result { + let selected = build_selection(selection)?; + let mut narrowing = Narrowing { + observations, + resolutions: &[], + needs: Vec::new(), + advisories: Vec::new(), + }; + narrowing.narrow(&schema.0, &selected, "", true)?; + Ok(Plan { + needs: narrowing.needs, + advisories: narrowing.advisories, + }) +} + +/// Produces the closed-subset response schema for the selected subtrees. +/// +/// Contract: +/// +/// - The selection is validated exactly as in [`plan_advisories`]. +/// - The result is pruned to the selected subtrees and the containers needed +/// to reach them. Selecting a container keeps its whole subtree. +/// - Every object is closed with `additionalProperties: false` and declares +/// `required` as the members the specification marks required among the +/// members that were kept. A record reached through `*` therefore requires +/// nothing unless the specification guarantees that member of every record. +/// - Enumerations, constants, `uniqueItems`, in-subset bounds and the `date` +/// and `date-time` formats are carried through. Every other format is +/// dropped, because the subset admits no other. A `[T, "null"]` type pair is +/// preserved. +/// - A bound in `resolutions` is written into the schema after being checked +/// against the subset limits; a bound nobody resolved is omitted entirely +/// and returned in [`NarrowOutcome::unresolved`] in schema order. A +/// resolution matching no need is an error rather than a silent no-op. +/// - Arrays declare `minItems`, from the specification when it states one and +/// `0` otherwise: an empty page survives projection, and saying so +/// constrains nothing. +pub fn apply( + schema: &ResolvedSchema, + selection: &[String], + resolutions: &BTreeMap<(String, BoundKind), BoundValues>, +) -> Result { + let entries: Vec = resolutions + .iter() + .map(|(key, values)| (key.clone(), values.clone())) + .collect(); + apply_entries(schema, selection, &entries) +} + +/// [`apply`] over resolutions held as a slice rather than a map. +/// +/// [`apply`] takes the map form `Decisions` declares and delegates here; this +/// entry point takes the same pairs as a slice, for a caller holding them in +/// schema order rather than keyed. Order is not significant; duplicate keys +/// resolve to the first entry. +pub fn apply_entries( + schema: &ResolvedSchema, + selection: &[String], + resolutions: &[Resolution], +) -> Result { + let selected = build_selection(selection)?; + let observations = Observations::default(); + let mut narrowing = Narrowing { + observations: &observations, + resolutions, + needs: Vec::new(), + advisories: Vec::new(), + }; + let narrowed = narrowing.narrow(&schema.0, &selected, "", true)?; + for (pointer, kind) in resolutions.iter().map(|(key, _)| key) { + if !narrowing + .needs + .iter() + .any(|need| &need.pointer == pointer && &need.kind == kind) + { + bail!( + "the resolved {} for `{}` matches no bound this schema needs; \ + check the pointer against the selection", + kind.label(), + display_pointer(pointer) + ); + } + } + let unresolved = narrowing + .needs + .into_iter() + .filter(|need| resolution(resolutions, &need.pointer, &need.kind).is_none()) + .collect(); + Ok(NarrowOutcome { + schema: narrowed, + unresolved, + }) +} + +/// Rounds a sampled array length up to the next multiple of eight, with eight +/// as the floor, clamped to the subset ceiling of 256. +/// +/// One response is weak evidence of how long a page can be, so the bound is +/// deliberately looser than what was seen. It still has to stay inside the +/// subset, and an operator confirms it before it is written. +fn widen_array_items(observed: u64) -> u64 { + let bucketed = observed + .max(1) + .div_ceil(SAMPLE_ARRAY_BUCKET) + .saturating_mul(SAMPLE_ARRAY_BUCKET); + bucketed.clamp(SAMPLE_ARRAY_BUCKET, MAX_ITEMS_CEILING) +} + +/// Rounds a sampled string byte length up to the next power of two, with 16 as +/// the floor, clamped to the subset ceiling of 65,536. +fn widen_string_length(observed: u64) -> u64 { + let mut widened = SAMPLE_STRING_FLOOR; + while widened < observed && widened < MAX_LENGTH_CEILING { + widened = widened.saturating_mul(2); + } + widened.clamp(SAMPLE_STRING_FLOOR, MAX_LENGTH_CEILING) +} + +/// Widens a sampled integer range outward to round numbers. +/// +/// A non-negative observed minimum is kept at `0`: a count or a total that +/// never went below zero in one sample is not evidence of a floor above it. A +/// negative minimum is widened by half again of its magnitude, rounded away +/// from zero to the next number of the form `{1,2,5} x 10^k`. +/// +/// The maximum is treated far more generously, because a sampled integer is +/// usually a counter and one response says almost nothing about how high it +/// can climb: it becomes the next power of ten at or above twice what was +/// observed, with a floor of ten. Observing 12 therefore suggests 100 and +/// observing 60 suggests 1000. A ceiling that is merely snug around the sample +/// is the bound most likely to reject a legitimate response later, and the +/// operator confirms it before it is written. +fn widen_integer_range(min_observed: i64, max_observed: i64) -> (i64, i64) { + let minimum = if min_observed >= 0 { + 0 + } else { + let magnitude = min_observed.unsigned_abs(); + let widened = round_up_to_round_number(magnitude.saturating_add(magnitude / 2)); + i64::try_from(widened).map_or(i64::MIN, |widened| -widened) + }; + let target = if max_observed <= 0 { + 0 + } else { + max_observed.unsigned_abs().saturating_mul(2) + }; + let maximum = next_power_of_ten(target.max(SAMPLE_INTEGER_FLOOR)); + (minimum, i64::try_from(maximum).unwrap_or(i64::MAX)) +} + +/// The smallest power of ten that is at least `value`. +fn next_power_of_ten(value: u64) -> u64 { + let mut power: u64 = 1; + while power < value { + let Some(next) = power.checked_mul(10) else { + return u64::MAX; + }; + power = next; + } + power +} + +/// The smallest number of the form `{1,2,5} x 10^k` that is at least `value`. +fn round_up_to_round_number(value: u64) -> u64 { + if value <= 1 { + return 1; + } + let mut scale: u64 = 1; + loop { + for step in [1, 2, 5] { + let Some(candidate) = scale.checked_mul(step) else { + return u64::MAX; + }; + if candidate >= value { + return candidate; + } + } + let Some(next) = scale.checked_mul(10) else { + return u64::MAX; + }; + scale = next; + } +} + +/// One segment of an extended projection pointer. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + /// An ordinary object member, already unescaped. + Key(String), + /// The reserved segment `*`, visiting every element of an array. + Wildcard, +} + +/// The selection as a tree. A node either terminates a projection entry, in +/// which case its whole subtree is projected, or names the children selected +/// beneath it. +#[derive(Debug, Default)] +struct Selected { + /// The projection entry terminating here, kept for error messages. + terminal: Option, + children: Vec<(Segment, Selected)>, +} + +impl Selected { + fn child(&self, segment: &Segment) -> Option<&Selected> { + self.children + .iter() + .find_map(|(candidate, node)| (candidate == segment).then_some(node)) + } + + /// Any projection entry terminating at or beneath this node. + fn first_terminal(&self) -> Option<&str> { + if let Some(terminal) = &self.terminal { + return Some(terminal); + } + self.children + .iter() + .find_map(|(_, node)| node.first_terminal()) + } + + /// The entry to name in a message about this node. + fn blamed(&self) -> &str { + self.first_terminal().unwrap_or("(unknown entry)") + } +} + +/// Parses and validates the selection into a tree. +/// +/// Duplicate entries and ancestor/descendant overlaps are rejected here, +/// naming both entries, because bundle validation would reject the projection +/// they describe and a failure at authoring time is cheaper to read. +fn build_selection(selection: &[String]) -> Result { + if selection.is_empty() { + bail!("the projection selection is empty: select at least one response leaf"); + } + let mut root = Selected::default(); + for pointer in selection { + let segments = parse_pointer(pointer)?; + insert_selection(&mut root, &segments, pointer)?; + } + Ok(root) +} + +fn insert_selection(node: &mut Selected, segments: &[Segment], pointer: &str) -> Result<()> { + let Some((head, tail)) = segments.split_first() else { + if let Some(owner) = &node.terminal { + bail!("projection entry `{pointer}` duplicates `{owner}`"); + } + if let Some(descendant) = node.first_terminal() { + bail!( + "projection entries `{pointer}` and `{descendant}` overlap: \ + `{pointer}` already selects every leaf beneath it" + ); + } + node.terminal = Some(pointer.to_owned()); + return Ok(()); + }; + if let Some(owner) = &node.terminal { + bail!( + "projection entries `{owner}` and `{pointer}` overlap: \ + `{owner}` already selects every leaf beneath it" + ); + } + let index = match node + .children + .iter() + .position(|(segment, _)| segment == head) + { + Some(index) => index, + None => { + node.children.push((head.clone(), Selected::default())); + node.children.len() - 1 + } + }; + insert_selection(&mut node.children[index].1, tail, pointer) +} + +/// Splits an extended projection pointer into segments. +fn parse_pointer(pointer: &str) -> Result> { + if pointer.is_empty() { + bail!("a projection entry is empty: an entry names at least one segment, e.g. `/total`"); + } + let Some(body) = pointer.strip_prefix('/') else { + bail!("projection entry `{pointer}` must start with `/`"); + }; + body.split('/') + .map(|raw| parse_segment(raw, pointer)) + .collect() +} + +fn parse_segment(raw: &str, pointer: &str) -> Result { + if raw == "*" { + return Ok(Segment::Wildcard); + } + if raw.is_empty() { + bail!("projection entry `{pointer}` has an empty segment"); + } + let mut decoded = String::with_capacity(raw.len()); + let mut characters = raw.chars(); + while let Some(character) = characters.next() { + if character != '~' { + decoded.push(character); + continue; + } + match characters.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => bail!( + "projection entry `{pointer}` has an invalid escape: \ + RFC 6901 defines `~0` and `~1` only" + ), + } + } + Ok(Segment::Key(decoded)) +} + +/// Appends one already-decoded member name to a pointer, re-escaping it. +fn child_pointer(parent: &str, key: &str) -> String { + format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1")) +} + +/// Renders a pointer for a message, naming the root rather than printing an +/// empty string. +pub fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(response root)" + } else { + pointer + } +} + +/// One confirmed bound, keyed the way [`Decisions::resolutions`] keys it. +/// +/// [`Decisions::resolutions`]: super::types::Decisions::resolutions +pub type Resolution = ((String, BoundKind), BoundValues); + +/// Looks a resolution up. [`BoundKind`] carries no ordering, so resolutions +/// are scanned rather than searched; a selection is a handful of entries. +fn resolution<'a>( + resolutions: &'a [Resolution], + pointer: &str, + kind: &BoundKind, +) -> Option<&'a BoundValues> { + resolutions + .iter() + .find_map(|((candidate, candidate_kind), values)| { + (candidate == pointer && candidate_kind == kind).then_some(values) + }) +} + +/// One narrowing pass. It emits the pruned schema and collects, in schema +/// order, every bound the subset demands and every advisory. +struct Narrowing<'a> { + observations: &'a Observations, + resolutions: &'a [Resolution], + needs: Vec, + advisories: Vec, +} + +impl Narrowing<'_> { + fn observed(&self, pointer: &str) -> Option<&Observed> { + self.observations.by_pointer.get(pointer) + } + + /// Records a demanded bound and returns the resolution for it, when a + /// human supplied one. + fn demand( + &mut self, + pointer: &str, + kind: BoundKind, + suggestion: Option, + ) -> Option { + let resolved = resolution(self.resolutions, pointer, &kind).cloned(); + self.needs.push(BoundNeed { + pointer: pointer.to_owned(), + kind, + suggestion, + }); + resolved + } + + fn advise(&mut self, pointer: &str, kind: AdvisoryKind) { + self.advisories.push(Advisory { + pointer: pointer.to_owned(), + kind, + }); + } + + fn narrow( + &mut self, + schema: &Value, + selected: &Selected, + pointer: &str, + at_root: bool, + ) -> Result { + let node = schema.as_object().with_context(|| { + format!( + "the schema node at `{}` is not an object; every node in the closed subset is one", + display_pointer(pointer) + ) + })?; + // Reachable only by selecting a whole subtree that contains a cut + // recursion: the flattener never offers the cut node itself as a leaf. + if let Some(reference) = node.get(RECURSIVE_REF_KEY).and_then(Value::as_str) { + bail!( + "the node at `{}` repeats the $ref cycle `{reference}`; a schema with no end \ + cannot be projected, so select the members you need rather than the subtree \ + above it", + display_pointer(pointer) + ); + } + let Some((base, nullable)) = node_type(node, pointer)? else { + // A node with no type but a bounded const is admitted as it is. + reject_descent(selected, pointer)?; + let mut narrowed = JsonMap::new(); + carry(node, "const", &mut narrowed); + return Ok(Value::Object(narrowed)); + }; + if nullable && at_root { + bail!( + "the response schema root is a plain object in the closed subset; \ + the `[T, \"null\"]` pair is not admitted there" + ); + } + if !nullable && self.observed(pointer).is_some_and(|seen| seen.saw_null) { + self.advise(pointer, AdvisoryKind::NullOutsideSpec); + } + let mut narrowed = JsonMap::new(); + narrowed.insert( + "type".to_owned(), + if nullable { + Value::Array(vec![Value::from(base), Value::from("null")]) + } else { + Value::from(base) + }, + ); + match base { + "object" => self.narrow_object(node, selected, pointer, &mut narrowed)?, + "array" => self.narrow_array(node, selected, pointer, &mut narrowed)?, + "string" => { + reject_descent(selected, pointer)?; + self.narrow_string(node, pointer, &mut narrowed)?; + } + "integer" => { + reject_descent(selected, pointer)?; + self.narrow_integer(node, pointer, &mut narrowed)?; + } + "boolean" => { + reject_descent(selected, pointer)?; + carry(node, "enum", &mut narrowed); + carry(node, "const", &mut narrowed); + } + other => bail!( + "the node at `{}` has type `{other}`, which is outside the closed Version 1 \ + subset; that subset admits object, array, string, integer and boolean", + display_pointer(pointer) + ), + } + Ok(Value::Object(narrowed)) + } + + fn narrow_object( + &mut self, + node: &JsonMap, + selected: &Selected, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let properties = node + .get("properties") + .and_then(Value::as_object) + .with_context(|| { + format!( + "the object at `{}` declares no properties; the closed subset needs them", + display_pointer(pointer) + ) + })?; + let whole = selected.terminal.is_some(); + if !whole { + for (segment, child) in &selected.children { + let Segment::Key(key) = segment else { + bail!( + "projection entry `{}` uses `*` at `{}`, which is an object, not an array", + child.blamed(), + display_pointer(pointer) + ); + }; + if !properties.contains_key(key) { + bail!( + "projection entry `{}` names `{key}`, which the response schema does \ + not declare at `{}`", + child.blamed(), + display_pointer(pointer) + ); + } + } + } + let spec_required: Vec<&str> = node + .get("required") + .and_then(Value::as_array) + .map(|values| values.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let mut kept = JsonMap::new(); + let mut required = Vec::new(); + for (key, child_schema) in properties { + let child_selected = if whole { + selected + } else { + match selected.child(&Segment::Key(key.clone())) { + Some(child) => child, + None => continue, + } + }; + let child = child_pointer(pointer, key); + let narrowed_child = self.narrow(child_schema, child_selected, &child, false)?; + if spec_required.contains(&key.as_str()) { + required.push(Value::from(key.clone())); + } + kept.insert(key.clone(), narrowed_child); + } + if kept.is_empty() { + bail!( + "no member of the object at `{}` is selected; a closed object declares at \ + least one property", + display_pointer(pointer) + ); + } + if kept.len() > MAX_PROPERTIES { + bail!( + "the object at `{}` keeps {} members; the closed subset admits at most \ + {MAX_PROPERTIES}", + display_pointer(pointer), + kept.len() + ); + } + narrowed.insert("additionalProperties".to_owned(), Value::Bool(false)); + narrowed.insert("required".to_owned(), Value::Array(required)); + narrowed.insert("properties".to_owned(), Value::Object(kept)); + Ok(()) + } + + fn narrow_array( + &mut self, + node: &JsonMap, + selected: &Selected, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let items = node.get("items").with_context(|| { + format!( + "the array at `{}` does not close its item type; the closed subset needs `items`", + display_pointer(pointer) + ) + })?; + let child_selected = if selected.terminal.is_some() { + selected + } else { + match selected.child(&Segment::Wildcard) { + Some(child) => child, + None => bail!( + "projection entry `{}` addresses the array at `{}` by member name; \ + an array is visited with the reserved segment `*`, and numeric indexes \ + are not projection syntax", + selected.blamed(), + display_pointer(pointer) + ), + } + }; + let spec_minimum = node.get("minItems").and_then(Value::as_u64); + let spec_maximum = node.get("maxItems").and_then(Value::as_u64); + let stated_in_subset = spec_maximum.is_some_and(|maximum| { + (1..=MAX_ITEMS_CEILING).contains(&maximum) && maximum >= spec_minimum.unwrap_or(0) + }); + let maximum = if stated_in_subset { + spec_maximum + } else { + let suggestion = self.array_suggestion(pointer, spec_maximum, spec_minimum); + match self.demand(pointer, BoundKind::ArrayMaxItems, suggestion) { + Some(values) => Some(accept_max_items(&values, pointer)?), + None => None, + } + }; + let minimum = match (spec_minimum, maximum) { + (Some(minimum), Some(maximum)) => minimum.min(maximum), + (Some(minimum), None) => minimum, + (None, _) => 0, + }; + narrowed.insert("minItems".to_owned(), Value::from(minimum)); + if let Some(maximum) = maximum { + narrowed.insert("maxItems".to_owned(), Value::from(maximum)); + } + if node.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + narrowed.insert("uniqueItems".to_owned(), Value::Bool(true)); + } + carry(node, "const", narrowed); + let child = format!("{pointer}/*"); + let narrowed_items = self.narrow(items, child_selected, &child, false)?; + narrowed.insert("items".to_owned(), narrowed_items); + Ok(()) + } + + /// The best `maxItems` the inputs support: a stated bound clamped into the + /// subset, else a widened sample observation, else nothing. + /// + /// A stated bound above the ceiling is reported as the ceiling's, not the + /// document's. A specification writing `maxItems: 2147483647` is declining + /// to bound the array; carrying its name onto the 256 the draft ends up + /// with would read as a promise the source never made. + fn array_suggestion( + &self, + pointer: &str, + spec_maximum: Option, + spec_minimum: Option, + ) -> Option { + let floor = spec_minimum.unwrap_or(0).clamp(1, MAX_ITEMS_CEILING); + if let Some(stated) = spec_maximum { + let clamped = stated.clamp(floor, MAX_ITEMS_CEILING); + return Some(SuggestedBound { + values: BoundValues::MaxItems(clamped), + provenance: if clamped == stated { + Provenance::Spec + } else { + Provenance::SubsetCeiling + }, + }); + } + let observed = self.observed(pointer)?.max_array_items?; + Some(SuggestedBound { + values: BoundValues::MaxItems(widen_array_items(observed).max(floor)), + provenance: Provenance::Sample, + }) + } + + fn narrow_string( + &mut self, + node: &JsonMap, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let format = node.get("format").and_then(Value::as_str); + let dated = matches!(format, Some("date" | "date-time")); + if let Some(dropped) = format.filter(|_| !dated) { + self.advise(pointer, AdvisoryKind::DroppedFormat(dropped.to_owned())); + } + let spec_minimum = node.get("minLength").and_then(Value::as_u64); + let spec_maximum = node.get("maxLength").and_then(Value::as_u64); + let bounded = + spec_maximum.is_some_and(|maximum| (1..=MAX_LENGTH_CEILING).contains(&maximum)); + let enumerated = is_bounded_enum(node, Value::is_string); + let constant = node + .get("const") + .and_then(Value::as_str) + .is_some_and(|value| value.len() as u64 <= MAX_LENGTH_CEILING); + if dated { + if let Some(format) = format { + narrowed.insert("format".to_owned(), Value::from(format)); + } + } + if enumerated { + carry(node, "enum", narrowed); + } + if constant { + carry(node, "const", narrowed); + } + if bounded || dated || enumerated || constant { + if let Some((minimum, maximum)) = spec_minimum.zip(spec_maximum) { + if minimum > 0 && minimum <= maximum { + narrowed.insert("minLength".to_owned(), Value::from(minimum)); + } + } + if let Some(maximum) = spec_maximum.filter(|_| bounded) { + narrowed.insert("maxLength".to_owned(), Value::from(maximum)); + } + return Ok(()); + } + let suggestion = self.string_suggestion(pointer, format, spec_minimum, spec_maximum); + let Some(values) = self.demand(pointer, BoundKind::StringLength, suggestion) else { + return Ok(()); + }; + let (minimum, maximum) = accept_string_length(&values, pointer)?; + if minimum > 0 { + narrowed.insert("minLength".to_owned(), Value::from(minimum)); + } + narrowed.insert("maxLength".to_owned(), Value::from(maximum)); + Ok(()) + } + + /// The best string length the inputs support: a stated bound clamped into + /// the subset, else the fixed length a `uuid` format implies, else a + /// widened sample observation, else nothing. A stated bound above the + /// ceiling is attributed to the ceiling, for the reason + /// [`Self::array_suggestion`] gives. + fn string_suggestion( + &self, + pointer: &str, + format: Option<&str>, + spec_minimum: Option, + spec_maximum: Option, + ) -> Option { + let minimum = spec_minimum.unwrap_or(0); + if spec_maximum.is_some_and(|maximum| maximum > MAX_LENGTH_CEILING) { + return Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: minimum.min(MAX_LENGTH_CEILING), + max_length: MAX_LENGTH_CEILING, + }, + provenance: Provenance::SubsetCeiling, + }); + } + if format == Some("uuid") { + return Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: UUID_LENGTH, + max_length: UUID_LENGTH, + }, + provenance: Provenance::Format, + }); + } + let observed = self.observed(pointer)?.max_string_bytes?; + let maximum = widen_string_length(observed); + Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: minimum.min(maximum), + max_length: maximum, + }, + provenance: Provenance::Sample, + }) + } + + fn narrow_integer( + &mut self, + node: &JsonMap, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let spec_minimum = node.get("minimum").and_then(Value::as_i64); + let spec_maximum = node.get("maximum").and_then(Value::as_i64); + let stated = spec_minimum + .zip(spec_maximum) + .filter(|(minimum, maximum)| minimum <= maximum); + let enumerated = is_bounded_enum(node, |value| value.as_i64().is_some()); + let constant = node.get("const").and_then(Value::as_i64).is_some(); + if enumerated { + carry(node, "enum", narrowed); + } + if constant { + carry(node, "const", narrowed); + } + if let Some((minimum, maximum)) = stated { + narrowed.insert("minimum".to_owned(), Value::from(minimum)); + narrowed.insert("maximum".to_owned(), Value::from(maximum)); + return Ok(()); + } + if enumerated || constant { + return Ok(()); + } + let suggestion = self.integer_suggestion(pointer, spec_minimum, spec_maximum); + let Some(values) = self.demand(pointer, BoundKind::IntegerRange, suggestion) else { + return Ok(()); + }; + let (minimum, maximum) = accept_integer_range(&values, pointer)?; + narrowed.insert("minimum".to_owned(), Value::from(minimum)); + narrowed.insert("maximum".to_owned(), Value::from(maximum)); + Ok(()) + } + + /// The best integer range the inputs support. A stated end is kept as it + /// stands and only the missing end is derived from the sample; with no + /// sample and only one stated end there is no suggestion, because the + /// other end would have to be invented. + fn integer_suggestion( + &self, + pointer: &str, + spec_minimum: Option, + spec_maximum: Option, + ) -> Option { + let observed = self.observed(pointer); + let seen_minimum = observed.and_then(|seen| seen.min_integer); + let seen_maximum = observed.and_then(|seen| seen.max_integer); + let widened = seen_minimum + .or(seen_maximum) + .zip(seen_maximum.or(seen_minimum)) + .map(|(minimum, maximum)| widen_integer_range(minimum, maximum)); + let minimum = spec_minimum.or(widened.map(|(minimum, _)| minimum))?; + let maximum = spec_maximum.or(widened.map(|(_, maximum)| maximum))?; + if minimum > maximum { + return None; + } + Some(SuggestedBound { + values: BoundValues::IntegerRange { minimum, maximum }, + provenance: if spec_minimum.is_some() && spec_maximum.is_some() { + Provenance::Spec + } else { + Provenance::Sample + }, + }) + } +} + +/// Reads the one type a node declares, admitting the response-role pair +/// `[T, "null"]`. Returns `None` for a node that declares a bounded const +/// instead of a type. +fn node_type<'node>( + node: &'node JsonMap, + pointer: &str, +) -> Result> { + match node.get("type") { + None => { + if node.get("const").is_some() { + return Ok(None); + } + bail!( + "the node at `{}` declares no type; the closed subset needs one type \ + or one bounded const", + display_pointer(pointer) + ) + } + Some(Value::String(name)) => Ok(Some((name.as_str(), false))), + Some(Value::Array(members)) => match members.as_slice() { + [Value::String(name), Value::String(null)] if null == "null" && name != "null" => { + Ok(Some((name.as_str(), true))) + } + _ => bail!( + "the node at `{}` declares a type union the closed subset does not admit; \ + a response node may write `[T, \"null\"]` and nothing else", + display_pointer(pointer) + ), + }, + Some(_) => bail!( + "the node at `{}` declares a type that is neither a name nor a `[T, \"null\"]` pair", + display_pointer(pointer) + ), + } +} + +/// Fails when a projection entry descends past a leaf. +fn reject_descent(selected: &Selected, pointer: &str) -> Result<()> { + if selected.terminal.is_some() { + return Ok(()); + } + bail!( + "projection entry `{}` descends past the leaf at `{}`", + selected.blamed(), + display_pointer(pointer) + ) +} + +/// Copies one keyword through unchanged when the node declares it. +fn carry(node: &JsonMap, keyword: &str, narrowed: &mut JsonMap) { + if let Some(value) = node.get(keyword) { + narrowed.insert(keyword.to_owned(), value.clone()); + } +} + +/// True when the node declares an enumeration the closed subset admits. +fn is_bounded_enum(node: &JsonMap, member: fn(&Value) -> bool) -> bool { + node.get("enum") + .and_then(Value::as_array) + .is_some_and(|values| { + !values.is_empty() && values.len() <= MAX_ENUM_MEMBERS && values.iter().all(member) + }) +} + +/// Checks a resolved `maxItems` against the subset before it is written. +fn accept_max_items(values: &BoundValues, pointer: &str) -> Result { + let BoundValues::MaxItems(maximum) = values else { + bail!( + "the resolution for `{}` is not a maxItems value, but the array there needs one", + display_pointer(pointer) + ); + }; + if !(1..=MAX_ITEMS_CEILING).contains(maximum) { + bail!( + "the resolved maxItems {maximum} for `{}` is outside the closed subset range \ + 1..={MAX_ITEMS_CEILING}", + display_pointer(pointer) + ); + } + Ok(*maximum) +} + +/// Checks a resolved string length against the subset before it is written. +fn accept_string_length(values: &BoundValues, pointer: &str) -> Result<(u64, u64)> { + let BoundValues::StringLength { + min_length, + max_length, + } = values + else { + bail!( + "the resolution for `{}` is not a string length, but the string there needs one", + display_pointer(pointer) + ); + }; + if *max_length == 0 || *max_length > MAX_LENGTH_CEILING || min_length > max_length { + bail!( + "the resolved string length {min_length}..={max_length} for `{}` is outside the \ + closed subset range 1..={MAX_LENGTH_CEILING}", + display_pointer(pointer) + ); + } + Ok((*min_length, *max_length)) +} + +/// Checks a resolved integer range against the subset before it is written. +fn accept_integer_range(values: &BoundValues, pointer: &str) -> Result<(i64, i64)> { + let BoundValues::IntegerRange { minimum, maximum } = values else { + bail!( + "the resolution for `{}` is not an integer range, but the integer there needs one", + display_pointer(pointer) + ); + }; + if minimum > maximum { + bail!( + "the resolved integer range {minimum}..={maximum} for `{}` is empty", + display_pointer(pointer) + ); + } + Ok((*minimum, *maximum)) +} diff --git a/crates/registry-evidencectl/src/suggest/openapi.rs b/crates/registry-evidencectl/src/suggest/openapi.rs new file mode 100644 index 000000000..49eb500d7 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/openapi.rs @@ -0,0 +1,716 @@ +//! Loads an OpenAPI 3.0.x or 3.1.x document, from a file or from a URL (see +//! [`super::fetch`]), and resolves the pieces the `source suggest` pipeline +//! needs: operation listings and one operation's response schema with every +//! local `$ref` inlined. +//! +//! Only local `#/components/...` refs are followed, whichever way the +//! document arrived: one document is fetched, never a graph of them. An +//! external or remote `$ref` (anything not starting with `#/`) +//! is rejected with a clear error rather than silently truncated or ignored, +//! because a partially-resolved schema would let the closed-subset narrowing +//! stage draft against data the runtime cannot actually see. A `$ref` cycle +//! is different in kind: it is not missing information but an expansion with +//! no end, so the repeat is cut in place, marked, and reported, and the rest +//! of the operation stays draftable. +//! +//! Resolution also canonicalizes the two dialect spellings that describe +//! something the closed subset already admits: a two-member union against +//! `null` becomes the type pair `[T, "null"]`, and a node declaring +//! `properties` or `items` and no `type` is read as the type that keyword +//! belongs to. Neither adds a constraint the document does not state; each is +//! reported as a note so the reading stays the operator's to reject. + +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::Value; + +use super::fetch; +use super::types::{ + OperationKey, OperationSummary, ResolvedResponse, ResolvedSchema, SpecSource, RECURSIVE_REF_KEY, +}; + +/// Path Item Object keys this pipeline can draft a source from. +/// +/// OpenAPI allows eight methods, but an Evidence fixed request declares one of +/// two: the runtime's method enumeration is `GET` and `POST`. Offering any +/// other method would only produce a source the runtime rejects, so the +/// listing is filtered here rather than at the far end of the pipeline. +const OPERATION_METHODS: [&str; 2] = ["get", "post"]; + +/// OpenAPI documents larger than this are rejected before they are read, the +/// way `sample::load_sample` rejects an oversized sample. The largest published +/// registry API descriptions are a few megabytes; a document past this ceiling +/// is a mistaken path rather than a specification to draft from. +const MAX_DOCUMENT_BYTES: u64 = 16 * 1024 * 1024; + +/// Query parameter names that bound how many items one response carries, +/// compared against the parameter's name lowercased with `_`, `-` and `.` +/// removed. +/// +/// The list is deliberately closed. A name outside it yields no page-size +/// value at all, so the bound it would have set stays an unresolved +/// `TODO(evidencectl)` for the operator to answer, which is the outcome this +/// tool prefers over a bound it cannot justify. `page`, `pageNumber`, +/// `pageIndex`, `offset` and `start` are absent on purpose: they count pages +/// or positions, not items. +const PAGE_SIZE_NAMES: [&str; 8] = [ + "pagesize", + "perpage", + "size", + "limit", + "pagelimit", + "count", + "maxresults", + "maxrecords", +]; + +/// Whether `name` names a page-size query parameter, comparing the whole +/// normalized name against [`PAGE_SIZE_NAMES`]. +fn is_page_size_name(name: &str) -> bool { + let normalized: String = name + .chars() + .filter(|character| !matches!(character, '_' | '-' | '.')) + .flat_map(char::to_lowercase) + .collect(); + PAGE_SIZE_NAMES.contains(&normalized.as_str()) +} + +/// A loaded, dialect-checked OpenAPI document. +/// +/// `open` accepts OpenAPI 3.0.x and 3.1.x, in YAML or JSON, from a local file +/// or URL. It does not otherwise validate the document against the OpenAPI +/// meta-schema; malformed structure surfaces as an error from whichever +/// accessor first needs the missing or mistyped piece. +#[derive(Debug, Clone)] +pub struct Spec { + document: Value, +} + +impl Spec { + /// Reads and parses the OpenAPI document `source` names, from disk or + /// from the network. Accepts YAML or JSON (YAML is a superset for this + /// purpose, so both are parsed the same way) and requires a top-level + /// `openapi: 3.0.x` or `3.1.x` version string. + pub fn open(source: &SpecSource) -> Result { + Self::open_retained(source).map(|(spec, _)| spec) + } + + /// Read and validate a document once while retaining its exact UTF-8 text. + /// + /// `evidencectl new` stores this text for the later question-authoring + /// step. Returning it from the same read that produced `Spec` prevents a + /// file change or a second network response from making the retained + /// document differ from the one that was validated. + pub(crate) fn open_retained(source: &SpecSource) -> Result<(Spec, String)> { + let text = match source { + SpecSource::File(path) => read_local(path)?, + SpecSource::Url(url) => fetch::get(url, MAX_DOCUMENT_BYTES)?, + }; + let spec = Spec::parse(&text, &source.display())?; + Ok((spec, text)) + } + + /// Parses one already-read document, naming it `origin` in any error so + /// the message points at the file path or URL the operator passed rather + /// than at a buffer. + fn parse(text: &str, origin: &str) -> Result { + let document: Value = serde_norway::from_str(text) + .with_context(|| format!("parsing {origin} as YAML or JSON"))?; + Self::from_value(document, origin) + } + + /// Validate an already parsed, retained OpenAPI document. + /// + /// Local authoring reads its retained file once with owner and size checks, + /// then uses this constructor to share response reference resolution with + /// `source suggest` without reopening the file. + pub(crate) fn from_value(document: Value, origin: &str) -> Result { + let version = document + .get("openapi") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{origin} has no top-level `openapi` version string"))?; + if !(version.starts_with("3.0.") || version.starts_with("3.1.")) { + bail!( + "{origin} declares `openapi: {version}`; only OpenAPI 3.0.x and 3.1.x are supported" + ); + } + Ok(Spec { document }) + } + + /// Every path-and-method operation that carries at least one JSON + /// response schema (a response whose media type contains `json`, e.g. + /// `application/json` or `application/problem+json`, and that declares + /// a `schema`). Operations with no JSON response are omitted because + /// they have nothing this pipeline can draft from. + pub fn operations(&self) -> Vec { + let mut out = Vec::new(); + let Some(paths) = self.document.get("paths").and_then(Value::as_object) else { + return out; + }; + for (path, path_item_raw) in paths { + let Ok(path_item) = self.resolve_top_ref(path_item_raw, &mut Vec::new()) else { + continue; + }; + let Some(path_item) = path_item.as_object() else { + continue; + }; + for method in OPERATION_METHODS { + let Some(operation) = path_item.get(method) else { + continue; + }; + let json_responses = self.json_responses(operation); + if json_responses.is_empty() { + continue; + } + out.push(OperationSummary { + key: OperationKey { + method: method.to_ascii_uppercase(), + path: path.clone(), + }, + summary: operation + .get("summary") + .and_then(Value::as_str) + .map(str::to_string), + json_responses, + }); + } + } + out + } + + /// The `(status, media type)` pairs on `operation` whose media type + /// looks like JSON and which declare a response `schema`. + fn json_responses(&self, operation: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + let Some(responses) = operation.get("responses").and_then(Value::as_object) else { + return out; + }; + for (status, response_raw) in responses { + let Ok(response) = self.resolve_top_ref(response_raw, &mut Vec::new()) else { + continue; + }; + let Some(content) = response.get("content").and_then(Value::as_object) else { + continue; + }; + for (media_type, media_object) in content { + if media_type.to_ascii_lowercase().contains("json") + && media_object.get("schema").is_some() + { + out.push((status.clone(), media_type.clone())); + } + } + } + out + } + + /// The response schema for `key`'s `status`/`media_type` response, with + /// every local `$ref` inlined, the dialect normalized, and a note for each + /// reading the normalization had to make. + pub fn response_schema( + &self, + key: &OperationKey, + status: &str, + media_type: &str, + ) -> Result { + let operation = self.find_operation(key)?; + let responses = operation + .get("responses") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("{} {} declares no `responses`", key.method, key.path))?; + let response_raw = responses + .get(status) + .ok_or_else(|| anyhow!("{} {} has no `{status}` response", key.method, key.path))?; + let response = self + .resolve_top_ref(response_raw, &mut Vec::new()) + .with_context(|| { + format!( + "resolving the `{status}` response of {} {}", + key.method, key.path + ) + })?; + let content = response + .get("content") + .and_then(Value::as_object) + .ok_or_else(|| { + anyhow!( + "{} {} `{status}` response declares no `content`", + key.method, + key.path + ) + })?; + let media_object = content.get(media_type).ok_or_else(|| { + anyhow!( + "{} {} `{status}` response has no `{media_type}` content", + key.method, + key.path + ) + })?; + let schema = media_object.get("schema").ok_or_else(|| { + anyhow!( + "{} {} `{status}` `{media_type}` response declares no `schema`", + key.method, + key.path + ) + })?; + let mut notes = Vec::new(); + let resolved = self + .inline_schema(schema, "", &mut Vec::new(), &mut notes) + .with_context(|| { + format!( + "resolving the `{status}` `{media_type}` response schema of {} {}", + key.method, key.path + ) + })?; + Ok(ResolvedResponse { + schema: ResolvedSchema(resolved), + notes, + }) + } + + /// Base URLs from the document's top-level `servers` array, in document + /// order. Empty when the document declares none. + pub fn servers(&self) -> Vec { + self.document + .get("servers") + .and_then(Value::as_array) + .map(|servers| { + servers + .iter() + .filter_map(|server| server.get("url").and_then(Value::as_str)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + } + + /// Integer `maximum` values found on `key`'s query parameters (path-item + /// level and operation level) whose name is one of [`PAGE_SIZE_NAMES`]. + /// + /// This is a naming heuristic, not a semantic one: it does not attempt + /// to determine whether a matching parameter actually bounds page size, + /// and it looks only at the parameter's own `schema.maximum`. Later + /// pipeline stages decide whether and how to use the values as + /// `Provenance::PageSize`. + /// + /// The match is on the whole normalized name rather than a substring, + /// because a substring match cannot tell a page size from a page index: + /// `page` and `pageSize` both contain `page`, but only one of them bounds + /// how many items a response carries, and reading the other as an item + /// count would suggest a bound orders of magnitude too generous. + pub fn page_size_maximums(&self, key: &OperationKey) -> Result> { + let paths = self + .document + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("document has no `paths`"))?; + let path_item_raw = paths + .get(&key.path) + .ok_or_else(|| anyhow!("no path `{}` in the document", key.path))?; + let path_item = self.resolve_top_ref(path_item_raw, &mut Vec::new())?; + + let mut maximums = Vec::new(); + if let Some(parameters) = path_item.get("parameters").and_then(Value::as_array) { + self.collect_page_size_maximums(parameters, &mut maximums)?; + } + let operation = self.find_operation(key)?; + if let Some(parameters) = operation.get("parameters").and_then(Value::as_array) { + self.collect_page_size_maximums(parameters, &mut maximums)?; + } + Ok(maximums) + } + + fn collect_page_size_maximums(&self, parameters: &[Value], out: &mut Vec) -> Result<()> { + for parameter_raw in parameters { + let parameter = self.resolve_top_ref(parameter_raw, &mut Vec::new())?; + let Some(name) = parameter.get("name").and_then(Value::as_str) else { + continue; + }; + if parameter.get("in").and_then(Value::as_str) != Some("query") { + continue; + } + if !is_page_size_name(name) { + continue; + } + let Some(schema) = parameter.get("schema") else { + continue; + }; + let resolved = self + .inline_schema(schema, "", &mut Vec::new(), &mut Vec::new()) + .with_context(|| format!("resolving the schema of query parameter `{name}`"))?; + if let Some(maximum) = resolved.get("maximum").and_then(Value::as_i64) { + out.push(maximum); + } + } + Ok(()) + } + + /// Finds `key`'s Operation Object, resolving a path-item-level `$ref` if + /// present. + fn find_operation(&self, key: &OperationKey) -> Result<&Value> { + let paths = self + .document + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("document has no `paths`"))?; + let path_item_raw = paths + .get(&key.path) + .ok_or_else(|| anyhow!("no path `{}` in the document", key.path))?; + let path_item = self.resolve_top_ref(path_item_raw, &mut Vec::new())?; + let method = key.method.to_ascii_lowercase(); + path_item + .get(&method) + .ok_or_else(|| anyhow!("path `{}` has no `{}` operation", key.path, key.method)) + } + + /// Follows a chain of `$ref` at the top level of `node` (a Response, + /// Parameter, or Path Item Object, none of which nest further schema + /// keywords the way a Schema Object does) until a non-`$ref` object is + /// reached. Returns `node` unchanged when it carries no `$ref`. + fn resolve_top_ref<'a>( + &'a self, + node: &'a Value, + stack: &mut Vec, + ) -> Result<&'a Value> { + let Some(object) = node.as_object() else { + return Ok(node); + }; + let Some(reference) = object.get("$ref").and_then(Value::as_str) else { + return Ok(node); + }; + let pointer = local_ref_pointer(reference)?; + if stack.iter().any(|seen| seen == reference) { + bail!("$ref cycle detected at `{reference}`"); + } + let target = resolve_pointer(&self.document, pointer) + .with_context(|| format!("resolving $ref `{reference}`"))?; + stack.push(reference.to_string()); + let resolved = self.resolve_top_ref(target, stack)?; + stack.pop(); + Ok(resolved) + } + + /// Recursively inlines every local `$ref` inside a Schema Object and + /// normalizes the dialect. Per OpenAPI 3.0 semantics, a schema node + /// carrying `$ref` has any sibling keywords ignored; this function does the + /// same, uniformly, for simplicity. + /// + /// `pointer` locates `node` inside the response schema so a note can name + /// where it applies; it is the same extended projection form the flattener + /// produces, so the two read alike. + fn inline_schema( + &self, + node: &Value, + pointer: &str, + stack: &mut Vec, + notes: &mut Vec, + ) -> Result { + let Value::Object(object) = node else { + return Ok(node.clone()); + }; + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + let target_pointer = local_ref_pointer(reference)?; + if stack.iter().any(|seen| seen == reference) { + notes.push(format!( + "`{}` repeats the $ref cycle `{reference}`; the repeat is cut there, so \ + nothing below it can be projected", + display_pointer(pointer) + )); + return Ok(Value::Object( + [(RECURSIVE_REF_KEY.to_owned(), Value::from(reference))] + .into_iter() + .collect(), + )); + } + let target = resolve_pointer(&self.document, target_pointer) + .with_context(|| format!("resolving $ref `{reference}`"))? + .clone(); + stack.push(reference.to_string()); + let inlined = self.inline_schema(&target, pointer, stack, notes); + stack.pop(); + return inlined; + } + + let mut result = serde_json::Map::with_capacity(object.len()); + for (key, value) in object { + let inlined_value = match key.as_str() { + "properties" => match value.as_object() { + Some(members) => { + let mut properties = serde_json::Map::with_capacity(members.len()); + for (member_name, member_schema) in members { + let member_pointer = + format!("{pointer}/{}", escape_pointer_segment(member_name)); + properties.insert( + member_name.clone(), + self.inline_schema(member_schema, &member_pointer, stack, notes)?, + ); + } + Value::Object(properties) + } + None => value.clone(), + }, + "items" if value.is_object() => { + self.inline_schema(value, &format!("{pointer}/*"), stack, notes)? + } + "not" | "additionalProperties" if value.is_object() => { + self.inline_schema(value, pointer, stack, notes)? + } + "allOf" | "oneOf" | "anyOf" => { + if let Some(members) = value.as_array() { + let mut inlined_members = Vec::with_capacity(members.len()); + for member in members { + inlined_members + .push(self.inline_schema(member, pointer, stack, notes)?); + } + Value::Array(inlined_members) + } else { + value.clone() + } + } + _ => value.clone(), + }; + result.insert(key.clone(), inlined_value); + } + normalize_nullable(&mut result); + collapse_null_union(&mut result); + order_nullable_pair(&mut result); + infer_structural_type(&mut result, pointer, notes); + Ok(Value::Object(result)) + } +} + +/// Rewrites OpenAPI 3.0's `nullable: true` in place to the 3.1-style type +/// pair `[T, "null"]`, removing the `nullable` keyword. A `nullable: true` +/// with no `type` keyword to pair against is left unrepresented (the +/// `nullable` key is still removed): the closed subset this pipeline drafts +/// toward always requires an explicit type, so a later stage rejects the +/// node as untyped rather than this function guessing one. +fn normalize_nullable(object: &mut serde_json::Map) { + let Some(Value::Bool(true)) = object.remove("nullable") else { + return; + }; + match object.get_mut("type") { + Some(Value::String(type_name)) => { + let pair = Value::Array(vec![ + Value::String(type_name.clone()), + Value::String("null".to_string()), + ]); + object.insert("type".to_string(), pair); + } + Some(Value::Array(type_names)) + if !type_names + .iter() + .any(|entry| entry.as_str() == Some("null")) => + { + type_names.push(Value::String("null".to_string())); + } + _ => {} + } +} + +/// Rewrites a two-member `anyOf`/`oneOf` whose members are one typed schema +/// and the schema `{"type": "null"}` into that typed schema carrying the pair +/// `[T, "null"]`. +/// +/// This is the shape generators emit for an optional field, and it states +/// exactly what the closed subset's one admitted union states. Rewriting it +/// adds no constraint: the members are kept as they stand, and a keyword on +/// the union node itself (a `description`, say) is carried over only where the +/// kept member does not already state one, so nothing the document said is +/// overwritten. Any other union, including one against `null` whose other +/// member declares no type to pair against, is left for the flattening stage +/// to skip and warn about. +fn collapse_null_union(object: &mut serde_json::Map) { + let keyword = ["anyOf", "oneOf"] + .into_iter() + .find(|keyword| object.contains_key(*keyword)); + let Some(keyword) = keyword else { + return; + }; + let Some(members) = object.get(keyword).and_then(Value::as_array) else { + return; + }; + let [first, second] = members.as_slice() else { + return; + }; + let kept = match (is_null_schema(first), is_null_schema(second)) { + (true, false) => second, + (false, true) => first, + _ => return, + }; + let Some(kept) = kept.as_object() else { + return; + }; + let Some(paired) = nullable_type(kept.get("type")) else { + return; + }; + + let mut collapsed = kept.clone(); + collapsed.insert("type".to_owned(), paired); + for (key, value) in object.iter() { + if key == keyword || collapsed.contains_key(key) { + continue; + } + collapsed.insert(key.clone(), value.clone()); + } + *object = collapsed; +} + +/// Whether `node` is the schema that admits only `null`. +fn is_null_schema(node: &Value) -> bool { + node.get("type").and_then(Value::as_str) == Some("null") +} + +/// The `[T, "null"]` pair for an existing `type` keyword, or `None` when there +/// is no single non-`null` type to pair. +fn nullable_type(declared: Option<&Value>) -> Option { + let null = Value::from("null"); + match declared? { + Value::String(name) if name != "null" => { + Some(Value::Array(vec![Value::String(name.clone()), null])) + } + Value::Array(names) => { + let mut non_null = names + .iter() + .filter(|name| name.as_str() != Some("null")) + .cloned(); + let single = non_null.next()?; + non_null + .next() + .is_none() + .then(|| Value::Array(vec![single, null])) + } + _ => None, + } +} + +/// Writes a nullable type pair in the one order the closed subset admits. +/// A document spelling it `["null", T]` describes the same node, and the +/// spelling is not a reason to refuse it later. +fn order_nullable_pair(object: &mut serde_json::Map) { + let Some(Value::Array(names)) = object.get("type") else { + return; + }; + let [first, second] = names.as_slice() else { + return; + }; + if first.as_str() == Some("null") && second.as_str().is_some_and(|name| name != "null") { + let reordered = Value::Array(vec![second.clone(), first.clone()]); + object.insert("type".to_owned(), reordered); + } +} + +/// Reads the type of a node that declares a structural keyword and no `type`. +/// +/// `properties` describes members of an object and `items` describes elements +/// of an array; neither means anything on any other type, so the node is not +/// ambiguous and reading it costs nothing the document did not already say. +/// Several large registry APIs publish their collection wrappers exactly this +/// way, and refusing them yields no draft at all rather than a narrower one. +/// +/// The reading stops there. A node carrying no structural keyword is left +/// untyped for the flattening stage to skip: there would be nothing to read it +/// from, and guessing a scalar type is the kind of invention this tool does +/// not do. A node already carrying `type`, a bounded `const`/`enum`, or a +/// union keyword states its own shape and is left alone. +fn infer_structural_type( + object: &mut serde_json::Map, + pointer: &str, + notes: &mut Vec, +) { + let stated = ["type", "const", "enum", "allOf", "oneOf", "anyOf"] + .into_iter() + .any(|keyword| object.contains_key(keyword)); + if stated || object.contains_key(RECURSIVE_REF_KEY) { + return; + } + let (keyword, inferred) = if object.contains_key("properties") { + ("properties", "object") + } else if object.contains_key("items") { + ("items", "array") + } else { + return; + }; + object.insert("type".to_owned(), Value::from(inferred)); + notes.push(format!( + "`{}` declares no `type` but does declare `{keyword}`, so it is read as `{inferred}`", + display_pointer(pointer) + )); +} + +/// Reads a local document, refusing one past the size ceiling before any of +/// it is read into memory. +fn read_local(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("reading OpenAPI document metadata at {}", path.display()))?; + if metadata.len() > MAX_DOCUMENT_BYTES { + bail!( + "OpenAPI document at {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_DOCUMENT_BYTES + ); + } + std::fs::read_to_string(path) + .with_context(|| format!("reading OpenAPI document at {}", path.display())) +} + +/// Escapes an object member name into one RFC 6901 pointer segment, matching +/// the flattening stage so a note and a candidate leaf name the same node the +/// same way. +fn escape_pointer_segment(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(root)" + } else { + pointer + } +} + +/// Validates that `reference` is a local same-document ref (`#/...` or the +/// whole-document `#`) and returns its JSON Pointer (without the leading +/// `#`). Rejects anything else — a relative or absolute document reference, +/// a URL, or a bare non-pointer fragment — as external or remote. +fn local_ref_pointer(reference: &str) -> Result<&str> { + if reference == "#" { + return Ok(""); + } + if reference.starts_with("#/") { + // Strip only the leading `#`, keeping the `/` that `resolve_pointer` expects. + Ok(&reference[1..]) + } else { + Err(anyhow!( + "external or remote $ref `{reference}` is not supported; only local `#/...` refs are" + )) + } +} + +/// Resolves an RFC 6901 JSON Pointer (without its leading `#`, e.g. +/// `/components/schemas/Record`) against `document`. +fn resolve_pointer<'a>(document: &'a Value, pointer: &str) -> Result<&'a Value> { + let mut current = document; + if pointer.is_empty() { + return Ok(current); + } + for raw_segment in pointer.split('/').skip(1) { + let segment = raw_segment.replace("~1", "/").replace("~0", "~"); + current = match current { + Value::Object(map) => map + .get(&segment) + .ok_or_else(|| anyhow!("no member `{segment}` at this point in the document"))?, + Value::Array(items) => { + let index: usize = segment + .parse() + .map_err(|_| anyhow!("`{segment}` is not a valid array index"))?; + items + .get(index) + .ok_or_else(|| anyhow!("index {index} is out of bounds"))? + } + _ => bail!("cannot descend into a scalar value at `{segment}`"), + }; + } + Ok(current) +} diff --git a/crates/registry-evidencectl/src/suggest/sample.rs b/crates/registry-evidencectl/src/suggest/sample.rs new file mode 100644 index 000000000..05bc7fe01 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/sample.rs @@ -0,0 +1,171 @@ +//! Raw shape observation from a sample response. +//! +//! `observe` walks a sample JSON document against a selection of extended +//! projection pointers (the same syntax `flatten` produces and the runtime's +//! `request.projection` accepts: RFC 6901 segments with `~0`/`~1` escapes and +//! the reserved segment `*` visiting every array element) and records what +//! shape each selected leaf had: integer extremes, maximum string byte +//! length, maximum array length, and whether an explicit null was seen. This +//! is bookkeeping only; no widening policy and no subset validation happen +//! here. That is the narrow stage's job, using these observations as one +//! input among several (spec bounds and formats outrank a sample). +//! +//! Privacy invariant: no function in this module stores, logs, or returns a +//! sample string *value*. Only its byte length crosses into an `Observed`. +//! The same holds for every other JSON value: only lengths, integer +//! extremes, array lengths, and a null flag are retained. + +use std::{collections::BTreeMap, fs, path::Path}; + +use anyhow::{bail, Context, Result}; +use serde_json::Value; + +use super::types::{Observations, Observed}; + +/// Sample files larger than this are rejected outright: a sample is read +/// only to suggest bounds, never to carry bulk data through this tool. +const MAX_SAMPLE_BYTES: u64 = 4 * 1024 * 1024; + +/// Read and parse a sample response document from `path`. +/// +/// Rejects files over [`MAX_SAMPLE_BYTES`] with a clear message before +/// reading their contents, and rejects a file that is not valid JSON. The +/// parsed value is returned to the caller; this function does not log or +/// otherwise surface any of its contents. +pub fn load_sample(path: &Path) -> Result { + let metadata = fs::metadata(path) + .with_context(|| format!("failed to read sample file metadata at {}", path.display()))?; + if metadata.len() > MAX_SAMPLE_BYTES { + bail!( + "sample file at {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_SAMPLE_BYTES + ); + } + let bytes = fs::read(path) + .with_context(|| format!("failed to read sample file at {}", path.display()))?; + serde_json::from_slice(&bytes) + .with_context(|| format!("sample file at {} is not valid JSON", path.display())) +} + +/// Walk `sample` following each pointer in `selection`, recording raw shape +/// observations keyed by the pointer text exactly as given. +/// +/// A pointer whose path is absent from the sample (a missing key, or a +/// wildcard segment landing on a non-array) is simply left unobserved: that +/// is not an error, since a sample need not exercise every selected leaf. +/// A pointer that is not a well-formed extended JSON Pointer (empty, or not +/// starting with `/`) is a caller error and returns `Err`. +/// +/// Every array visited through a `*` segment is also recorded, keyed by the +/// array's own pointer (the selection prefix up to that segment), holding +/// the largest length seen across every occurrence of that array shape in +/// the sample. A selection pointer that itself resolves to an array (no +/// trailing `*`) is recorded the same way, keyed by the selection pointer. +pub fn observe(sample: &Value, selection: &[String]) -> Result { + let mut by_pointer: BTreeMap = BTreeMap::new(); + for pointer in selection { + observe_one(sample, pointer, &mut by_pointer)?; + } + Ok(Observations { by_pointer }) +} + +/// Parse one extended pointer and walk `root` with it, folding results into +/// `by_pointer`. +fn observe_one( + root: &Value, + pointer: &str, + by_pointer: &mut BTreeMap, +) -> Result<()> { + if pointer.is_empty() || !pointer.starts_with('/') { + bail!( + "projection pointer {pointer:?} must be a non-empty extended JSON Pointer starting with \"/\"" + ); + } + let tokens: Vec<&str> = pointer.split('/').skip(1).collect(); + walk(root, &tokens, String::new(), by_pointer); + Ok(()) +} + +/// Follow `tokens` from `value`, tracking the pointer text already consumed +/// in `prefix` so recorded map keys are built from the same escaped text the +/// caller supplied rather than a re-derived (and possibly different) +/// rendering. +fn walk( + value: &Value, + tokens: &[&str], + prefix: String, + by_pointer: &mut BTreeMap, +) { + match tokens.split_first() { + None => record(value, &prefix, by_pointer), + Some((&"*", rest)) => { + if let Value::Array(items) = value { + // `prefix` here is the pointer to the array itself: the + // selection text consumed so far, before this `*` segment. + record(value, &prefix, by_pointer); + let element_prefix = format!("{prefix}/*"); + for item in items { + walk(item, rest, element_prefix.clone(), by_pointer); + } + } + // A non-array at a `*` segment is an unobserved mismatch, not + // an error: schema/sample disagreement is validated elsewhere. + } + Some((token, rest)) => { + if let Value::Object(members) = value { + let key = unescape_segment(token); + if let Some(child) = members.get(&key) { + let child_prefix = format!("{prefix}/{token}"); + walk(child, rest, child_prefix, by_pointer); + } + } + // A missing key or non-object here is likewise left unobserved. + } + } +} + +/// Fold one visited JSON value into the observation entry at `pointer`, +/// widening extremes but never retaining the value itself. +fn record(value: &Value, pointer: &str, by_pointer: &mut BTreeMap) { + let entry = by_pointer.entry(pointer.to_owned()).or_default(); + match value { + Value::Null => entry.saw_null = true, + Value::Bool(_) | Value::Object(_) => {} + Value::Number(number) => { + if let Some(seen) = number.as_i64() { + entry.min_integer = + Some(entry.min_integer.map_or(seen, |current| current.min(seen))); + entry.max_integer = + Some(entry.max_integer.map_or(seen, |current| current.max(seen))); + } + } + Value::String(text) => { + let bytes = text.len() as u64; + entry.max_string_bytes = Some( + entry + .max_string_bytes + .map_or(bytes, |current| current.max(bytes)), + ); + } + Value::Array(items) => { + let count = items.len() as u64; + entry.max_array_items = Some( + entry + .max_array_items + .map_or(count, |current| current.max(count)), + ); + } + } +} + +/// Decode one RFC 6901 pointer segment: `~1` to `/`, then `~0` to `~`, in +/// that order, so a literal `~1` in a key (encoded `~01`) round-trips. +fn unescape_segment(raw: &str) -> String { + if raw.contains('~') { + raw.replace("~1", "/").replace("~0", "~") + } else { + raw.to_owned() + } +} diff --git a/crates/registry-evidencectl/src/suggest/types.rs b/crates/registry-evidencectl/src/suggest/types.rs new file mode 100644 index 000000000..96ad6b18a --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/types.rs @@ -0,0 +1,238 @@ +//! Interchange types for the source-suggest pipeline. +//! +//! The pipeline is: load and resolve an OpenAPI document, flatten one +//! operation's response schema into candidate leaves, collect a selection and +//! bound decisions (from flags or the interactive prompts), narrow to the +//! closed schema subset, then emit draft artifacts. Every stage communicates +//! through the types here so the interactive front-end and the flag-driven +//! front-end share one deterministic core. + +use std::{collections::BTreeMap, path::PathBuf}; + +/// Where the OpenAPI document is read from. +/// +/// The two cases stay distinguishable all the way to the reproduce line, so +/// the command printed at the end of a run names the same document the run +/// actually read. Deciding which case a `--openapi` argument is happens once, +/// before anything is read, so an unusable URL fails before the operator is +/// asked a single question. +#[derive(Debug, Clone)] +pub enum SpecSource { + File(PathBuf), + Url(url::Url), +} + +impl SpecSource { + /// The document as it should be named in a message or echoed back in the + /// reproduce command. + pub fn display(&self) -> String { + match self { + SpecSource::File(path) => path.to_string_lossy().into_owned(), + SpecSource::Url(url) => url.to_string(), + } + } +} + +/// One operation in the OpenAPI document: an uppercase HTTP method and the +/// literal path template, e.g. `GET` and `/records/{id}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationKey { + pub method: String, + pub path: String, +} + +/// One selectable operation, as listed to the user. +#[derive(Debug, Clone)] +pub struct OperationSummary { + pub key: OperationKey, + pub summary: Option, + /// (status code, media type) pairs carrying a JSON response schema. + pub json_responses: Vec<(String, String)>, +} + +/// A response schema with every local `$ref` inlined and the dialect +/// normalized: OpenAPI 3.0 `nullable: true` is rewritten to the 3.1 type +/// pair `[T, "null"]`, so downstream stages handle one form. +#[derive(Debug, Clone)] +pub struct ResolvedSchema(pub serde_json::Value); + +/// The marker left in place of a schema node that repeats a `$ref` already on +/// the resolution stack. Cutting the repeat bounds an otherwise infinite +/// expansion without discarding the rest of the operation; the marker declares +/// no type, so no stage can mistake it for something projectable, and the +/// flattener names the recursion it stands for. +pub const RECURSIVE_REF_KEY: &str = "x-evidencectl-recursive-ref"; + +/// A resolved response schema with the readings the resolver made on the way. +/// +/// A note records where the document was ambiguous or unrepresentable and what +/// was done about it: a cut recursion, or a type read from a structural +/// keyword. Every note is reported to the operator, because a reading the tool +/// made on their behalf is one they may need to disagree with. +#[derive(Debug, Clone)] +pub struct ResolvedResponse { + pub schema: ResolvedSchema, + pub notes: Vec, +} + +/// One selectable leaf of the resolved schema, presented to the user and +/// mapped one-to-one onto a projection allowlist entry. +/// +/// Pointers are in the extended projection form defined by ADAPTER-API.md: +/// RFC 6901 segments with `~0`/`~1` escapes, and the reserved segment `*` +/// visiting every element of an array (`/results/*/trackingId`). The emit +/// stage derives `get_path` pointers from these by substituting a numeric +/// index for `*`, because `get_path` is plain RFC 6901 and does not accept +/// `*`. +#[derive(Debug, Clone)] +pub struct CandidateLeaf { + /// Extended projection pointer into the projected tree. + pub pointer: String, + /// Human label for the leaf's type, e.g. `string (date)`, `integer`. + pub type_label: String, + /// True when the spec admits an explicit null for this leaf. + pub nullable: bool, + pub description: Option, +} + +/// What kind of bound the closed subset demands and the spec did not supply. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum BoundKind { + /// Arrays must declare `maxItems` between 1 and 256. + ArrayMaxItems, + /// Integers must carry both `minimum` and `maximum` (or enum/const). + IntegerRange, + /// Strings need `minLength`/`maxLength` (or format/enum/const). + StringLength, +} + +/// Where a suggested bound value came from. Shown to the user so a default +/// is confirmed with its provenance, never adopted blind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Provenance { + /// Stated in the OpenAPI document itself. + Spec, + /// Derived mechanically from a `format` the subset does not admit, + /// e.g. `uuid` becoming fixed length bounds. + Format, + /// Observed in the sample response and widened. + Sample, + /// Derived from a page-size parameter in the spec. + PageSize, + /// The closed subset's own ceiling, used because the document states a + /// bound above it. This is deliberately not [`Provenance::Spec`]: the + /// number in the draft is not the number the document states, and + /// crediting the document for it would tell a reviewer the source promised + /// something it never promised. + SubsetCeiling, + /// Chosen by the operator at the prompt, either where nothing could be + /// derived or in place of a suggestion they edited. Nothing the tool + /// derived carries this: it is the one provenance that is not a + /// derivation. + Operator, +} + +/// One concrete bound value with its provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SuggestedBound { + pub values: BoundValues, + pub provenance: Provenance, +} + +/// The value shape per bound kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoundValues { + MaxItems(u64), + IntegerRange { minimum: i64, maximum: i64 }, + StringLength { min_length: u64, max_length: u64 }, +} + +/// One decision the closed subset requires at `pointer`, with the best +/// suggestion the pipeline could derive, if any. +#[derive(Debug, Clone)] +pub struct BoundNeed { + pub pointer: String, + pub kind: BoundKind, + pub suggestion: Option, +} + +/// Raw observations from a sample response, keyed by pointer. Widening +/// policy belongs to the narrowing stage, not here. +#[derive(Debug, Clone, Default)] +pub struct Observations { + pub by_pointer: BTreeMap, +} + +/// What one leaf (or array) looked like in the sample. +#[derive(Debug, Clone, Default)] +pub struct Observed { + pub min_integer: Option, + pub max_integer: Option, + pub max_string_bytes: Option, + pub max_array_items: Option, + pub saw_null: bool, +} + +/// The complete set of decisions the deterministic core consumes. The +/// interactive front-end and the flag parser both produce exactly this. +#[derive(Debug, Clone)] +pub struct Decisions { + pub operation: OperationKey, + pub status: String, + pub media_type: String, + pub source_id: String, + /// Selected projection allowlist entries (extended-pointer form), in + /// presentation order. The pipeline must reject or normalize a + /// selection containing both an ancestor and its descendant, because + /// bundle validation fails overlapping projection paths. + pub selection: Vec, + /// Confirmed bound values, keyed by (pointer, kind). A need absent here + /// is emitted as an explicit TODO and the draft fails `evidence check` + /// until the operator supplies it: the tool never invents a bound. + pub resolutions: BTreeMap<(String, BoundKind), BoundValues>, +} + +impl BoundKind { + /// Stable key form for maps and reports. + pub fn label(&self) -> &'static str { + match self { + BoundKind::ArrayMaxItems => "maxItems", + BoundKind::IntegerRange => "integer bounds", + BoundKind::StringLength => "string length bounds", + } + } +} + +/// The narrowed response schema plus everything still owed by a human. +#[derive(Debug, Clone)] +pub struct NarrowOutcome { + /// The closed-subset schema as a YAML-ready value. Unresolved bounds are + /// omitted (never invented), so `evidence check` rejects the draft until + /// the operator fills them. + pub schema: serde_json::Value, + /// Bounds still unresolved, in schema order. + pub unresolved: Vec, +} + +/// One draft file to write, bundle-relative. +#[derive(Debug, Clone)] +pub struct DraftFile { + pub bundle_relative_path: String, + pub contents: String, +} + +/// Everything the emit stage produces for one source. +#[derive(Debug, Clone)] +pub struct DraftArtifacts { + pub source_id: String, + pub files: Vec, + /// Editable local-authoring source document written by `--project`. + pub authoring_source: String, + /// A deliberately incomplete `sources.` YAML block containing only + /// facts mechanically established by the OpenAPI selection. + pub source_block: String, + /// Human report: what was derived, from where, and what remains. + pub report: String, + /// The fully-flagged non-interactive invocation reproducing this run. + pub equivalent_command: String, +} diff --git a/crates/registry-evidencectl/src/verify.rs b/crates/registry-evidencectl/src/verify.rs new file mode 100644 index 000000000..43034fe71 --- /dev/null +++ b/crates/registry-evidencectl/src/verify.rs @@ -0,0 +1,208 @@ +//! Offline response verification delegated entirely to the Evidence core. + +use std::{ + fs::{self, File}, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + path::{Component, Path, PathBuf}, + process::{Command, ExitCode, Stdio}, +}; + +use anyhow::{bail, Context as _, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::Args; +use zeroize::Zeroize as _; + +use crate::dev; + +const PRIVATE_FILE_MODE: u32 = 0o600; +const MAX_VERIFIED_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Args)] +pub struct VerifyArgs { + /// Flattened JWS JSON response returned by Evidence. + response: PathBuf, + + /// Owner-only verification context retained before the response existed. + #[arg(long)] + context: PathBuf, + + /// New owner-only file for the exact verified Evidence payload. + #[arg(long)] + output: PathBuf, + + #[arg(long, hide = true)] + evidence_bin: Option, +} + +pub fn run(args: VerifyArgs) -> Result { + validate_output_path(&args.output)?; + let evidence = dev::resolve_tool_binary( + "evidence", + args.evidence_bin.as_deref(), + "EVIDENCECTL_TEST_EVIDENCE_BIN", + )?; + let mut staged = StagedOutput::create(&args.output)?; + let output_file = staged.file.try_clone()?; + let status = Command::new(evidence) + .arg("verify-local-response") + .arg("--context") + .arg(&args.context) + .arg("--response") + .arg(&args.response) + .stdin(Stdio::null()) + .stdout(Stdio::from(output_file)) + .stderr(Stdio::null()) + .status() + .context("failed to invoke Evidence response verification")?; + if !status.success() { + bail!("Evidence response verification failed"); + } + staged.file.sync_all()?; + validate_private_output(&staged.path, &staged.file)?; + staged.publish(&args.output)?; + println!("VERIFIED"); + Ok(ExitCode::SUCCESS) +} + +fn validate_output_path(path: &Path) -> Result<()> { + if path.file_name().is_none() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + bail!("verified output path must be normalized and name one new file"); + } + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => bail!("verified output already exists; refusing to replace it"), + Err(error) => return Err(error.into()), + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let metadata = fs::symlink_metadata(parent) + .with_context(|| format!("failed to inspect output directory {}", parent.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != rustix::process::getuid().as_raw() + { + bail!("verified output directory must be owned and unsymlinked"); + } + Ok(()) +} + +struct StagedOutput { + path: PathBuf, + file: File, + published: bool, +} + +impl StagedOutput { + fn create(output: &Path) -> Result { + let parent = output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + for _ in 0..8 { + let mut random = [0_u8; 12]; + getrandom::fill(&mut random)?; + let path = parent.join(format!(".verify-{}", URL_SAFE_NO_PAD.encode(random))); + random.zeroize(); + match create_private_file(&path) { + Ok(file) => { + return Ok(Self { + path, + file, + published: false, + }); + } + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) => { + } + Err(error) => return Err(error), + } + } + bail!("failed to allocate private verification output") + } + + fn publish(&mut self, output: &Path) -> Result<()> { + rename_noreplace(&self.path, output) + .context("failed to publish verified output without replacing an existing path")?; + self.published = true; + Ok(()) + } +} + +impl Drop for StagedOutput { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_file(&self.path); + } + } +} + +fn create_private_file(path: &Path) -> Result { + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::CLOEXEC + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::NONBLOCK, + rustix::fs::Mode::from_bits_truncate(PRIVATE_FILE_MODE as rustix::fs::RawMode), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("failed to create private output {}", path.display()))?; + let file = File::from(fd); + let metadata = file.metadata()?; + if !metadata.is_file() + || metadata.nlink() != 1 + || metadata.uid() != rustix::process::getuid().as_raw() + || metadata.permissions().mode() & 0o777 != PRIVATE_FILE_MODE + { + bail!("private verification output failed its file-safety checks"); + } + Ok(file) +} + +fn validate_private_output(path: &Path, opened: &File) -> Result<()> { + let path_metadata = fs::symlink_metadata(path)?; + let open_metadata = opened.metadata()?; + if path_metadata.file_type().is_symlink() + || !path_metadata.is_file() + || path_metadata.nlink() != 1 + || path_metadata.uid() != rustix::process::getuid().as_raw() + || path_metadata.permissions().mode() & 0o777 != PRIVATE_FILE_MODE + || path_metadata.dev() != open_metadata.dev() + || path_metadata.ino() != open_metadata.ino() + || open_metadata.len() == 0 + || open_metadata.len() > MAX_VERIFIED_BYTES + { + bail!("private verification output failed its file-safety checks"); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple")))] +fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace verification publication is unsupported", + )) +} diff --git a/crates/registry-evidencectl/tests/access.rs b/crates/registry-evidencectl/tests/access.rs new file mode 100644 index 000000000..f402187ee --- /dev/null +++ b/crates/registry-evidencectl/tests/access.rs @@ -0,0 +1,263 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::{symlink, PermissionsExt as _}, + path::Path, + process::{Command, Output}, +}; + +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use serde_json::Value; + +fn evidencectl(project: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .arg("--project") + .arg(project) + .output() + .expect("run evidencectl") +} + +fn write_question(project: &Path, id: &str) { + fs::create_dir_all(project.join("questions")).expect("questions directory"); + fs::write( + project.join("questions").join(format!("{id}.yaml")), + format!("id: {id}\n"), + ) + .expect("question"); +} + +fn add_policy(project: &Path, id: &str, questions: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidencectl")); + command.args(["access", "policy", "add", id]); + for question in questions { + command.args(["--question", question]); + } + command.arg("--project").arg(project); + command.output().expect("add policy") +} + +fn add_client(project: &Path, id: &str, policies: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidencectl")); + command.args(["access", "client", "add", id]); + for policy in policies { + command.args(["--policy", policy]); + } + command + .arg("--generate-local-key") + .arg("--project") + .arg(project); + command.output().expect("add client") +} + +fn success(output: &Output) -> String { + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()).expect("stdout utf8") +} + +fn mode(path: &Path) -> u32 { + fs::metadata(path).expect("metadata").permissions().mode() & 0o7777 +} + +#[test] +fn adds_reviewable_policy_and_public_client_while_isolating_private_key() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + + assert_eq!( + success(&add_policy(project, "age-checks", &["adult-status"])), + "Added access policy age-checks for adult-status.\n" + ); + assert_eq!( + success(&add_client(project, "age-checker", &["age-checks"])), + "Added client age-checker with policy age-checks.\n" + ); + + let policy_path = project.join("access/policies/age-checks.yaml"); + let client_path = project.join("access/clients/age-checker.yaml"); + let private_path = project.join(".evidence/clients/age-checker/private.jwk"); + assert_eq!(mode(&policy_path), 0o644); + assert_eq!(mode(&client_path), 0o644); + assert_eq!(mode(private_path.parent().unwrap()), 0o700); + assert_eq!(mode(&private_path), 0o600); + + let policy: Value = + serde_norway::from_slice(&fs::read(policy_path).expect("policy")).expect("policy yaml"); + assert_eq!(policy["version"], 1); + assert_eq!(policy["id"], "age-checks"); + assert_eq!(policy["questions"], serde_json::json!(["adult-status"])); + + let client: Value = + serde_norway::from_slice(&fs::read(client_path).expect("client")).expect("client yaml"); + assert_eq!(client["clientId"], "age-checker"); + assert_eq!(client["status"], "active"); + assert_eq!(client["policies"], serde_json::json!(["age-checks"])); + assert_eq!(client["keys"].as_array().unwrap().len(), 1); + assert!(client["keys"][0].get("d").is_none()); + let public_text = serde_json::to_string(&client["keys"][0]).expect("public json"); + PublicJwk::parse(&public_text).expect("public JWK"); + + let private_text = fs::read_to_string(private_path).expect("private key"); + let private = PrivateJwk::parse(&private_text).expect("private JWK"); + let private_value = private.d.clone().expect("private material"); + assert!( + !String::from_utf8_lossy(&add_client(project, "age-checker", &["age-checks"]).stdout) + .contains(&private_value) + ); + + let policies = success(&evidencectl(project, &["access", "policy", "list"])); + assert!(policies.contains("age-checks\tadult-status")); + let clients = success(&evidencectl(project, &["access", "client", "list"])); + assert!(clients.contains("age-checker\tactive\tage-checks")); +} + +#[test] +fn unknown_policy_fails_before_generating_or_publishing_client_state() { + let fixture = tempfile::tempdir().expect("tempdir"); + let output = add_client(fixture.path(), "unknown-client", &["missing-policy"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("no access policies are configured")); + assert!(!fixture.path().join("access/clients").exists()); + assert!(!fixture.path().join(".evidence/clients").exists()); +} + +#[test] +fn overlapping_policy_membership_is_rejected_before_key_generation() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + success(&add_policy(project, "first", &["adult-status"])); + success(&add_policy(project, "second", &["adult-status"])); + + let output = add_client(project, "ambiguous-client", &["first", "second"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("grant the same authored entitlement for question adult-status")); + assert!(!project + .join("access/clients/ambiguous-client.yaml") + .exists()); + assert!(!project.join(".evidence/clients/ambiguous-client").exists()); +} + +#[test] +fn unsafe_identifiers_and_unknown_questions_change_nothing() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + + let unsafe_id = add_policy(project, "../escape", &["adult-status"]); + assert!(!unsafe_id.status.success()); + assert!(!project.join("access").exists()); + + let unknown = add_policy(project, "missing-question", &["not-authored"]); + assert!(!unknown.status.success()); + assert!(String::from_utf8_lossy(&unknown.stderr).contains("not-authored.yaml")); + assert!(!project.join("access").exists()); +} + +#[test] +fn add_never_overwrites_existing_policy_or_client() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + success(&add_policy(project, "age-checks", &["adult-status"])); + let policy_before = fs::read(project.join("access/policies/age-checks.yaml")).unwrap(); + let duplicate_policy = add_policy(project, "age-checks", &["adult-status"]); + assert!(!duplicate_policy.status.success()); + assert_eq!( + fs::read(project.join("access/policies/age-checks.yaml")).unwrap(), + policy_before + ); + + success(&add_client(project, "age-checker", &["age-checks"])); + let private_before = + fs::read(project.join(".evidence/clients/age-checker/private.jwk")).unwrap(); + let duplicate_client = add_client(project, "age-checker", &["age-checks"]); + assert!(!duplicate_client.status.success()); + assert_eq!( + fs::read(project.join(".evidence/clients/age-checker/private.jwk")).unwrap(), + private_before + ); +} + +#[test] +fn revoke_updates_public_status_but_retains_private_key() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + success(&add_policy(project, "age-checks", &["adult-status"])); + success(&add_client(project, "age-checker", &["age-checks"])); + let private_path = project.join(".evidence/clients/age-checker/private.jwk"); + let private_before = fs::read(&private_path).expect("private key"); + + let output = evidencectl(project, &["access", "client", "revoke", "age-checker"]); + assert_eq!(success(&output), "Revoked client age-checker.\n"); + assert_eq!( + fs::read(&private_path).expect("retained private key"), + private_before + ); + let list = success(&evidencectl(project, &["access", "client", "list"])); + assert!(list.contains("age-checker\trevoked\tage-checks")); + + let duplicate = evidencectl(project, &["access", "client", "revoke", "age-checker"]); + assert!(!duplicate.status.success()); + assert!(String::from_utf8_lossy(&duplicate.stderr).contains("already revoked")); +} + +#[test] +fn unsafe_or_symlinked_access_directory_publishes_no_access_artifact() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + fs::create_dir(project.join("access")).expect("access directory"); + fs::set_permissions(project.join("access"), fs::Permissions::from_mode(0o777)) + .expect("unsafe access mode"); + let unsafe_mode = add_policy(project, "age-checks", &["adult-status"]); + assert!(!unsafe_mode.status.success()); + assert!(!project.join("access/policies").exists()); + + let symlink_fixture = tempfile::tempdir().expect("symlink fixture"); + let symlink_project = symlink_fixture.path().join("project"); + let outside = symlink_fixture.path().join("outside"); + fs::create_dir(&symlink_project).expect("project"); + fs::create_dir(&outside).expect("outside"); + write_question(&symlink_project, "adult-status"); + symlink(&outside, symlink_project.join("access")).expect("access symlink"); + let escaped = add_policy(&symlink_project, "age-checks", &["adult-status"]); + assert!(!escaped.status.success()); + assert_eq!(fs::read_dir(&outside).expect("outside").count(), 0); +} + +#[test] +fn public_clients_without_local_keys_can_be_added_alongside_and_revoked() { + let fixture = tempfile::tempdir().expect("tempdir"); + let project = fixture.path(); + write_question(project, "adult-status"); + success(&add_policy(project, "age-checks", &["adult-status"])); + success(&add_client(project, "governed-client", &["age-checks"])); + fs::remove_dir_all(project.join(".evidence/clients/governed-client")) + .expect("remove local-only key as in a fresh clone"); + + let local = add_client(project, "local-client", &["age-checks"]); + assert_eq!( + success(&local), + "Added client local-client with policy age-checks.\n" + ); + assert!(project + .join(".evidence/clients/local-client/private.jwk") + .is_file()); + + let revoke = evidencectl(project, &["access", "client", "revoke", "governed-client"]); + assert_eq!(success(&revoke), "Revoked client governed-client.\n"); + let governed: Value = serde_norway::from_slice( + &fs::read(project.join("access/clients/governed-client.yaml")).expect("governed client"), + ) + .expect("governed client yaml"); + assert_eq!(governed["status"], "revoked"); +} diff --git a/crates/registry-evidencectl/tests/audit_view.rs b/crates/registry-evidencectl/tests/audit_view.rs new file mode 100644 index 000000000..ba27c27c5 --- /dev/null +++ b/crates/registry-evidencectl/tests/audit_view.rs @@ -0,0 +1,567 @@ +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use serde_json::{json, Value}; + +const PSEUDONYM: &str = + "hmac-sha256:v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const FAILURE: &str = "evidencectl: local audit inspection failed\n"; + +#[test] +fn audit_help_is_nested_required_and_hides_test_seams() { + let audit = command() + .args(["audit", "--help"]) + .output() + .expect("audit help"); + assert_success(&audit); + let audit = String::from_utf8_lossy(&audit.stdout); + assert!( + audit.contains("show"), + "nested show command is absent: {audit}" + ); + + let show = command() + .args(["audit", "show", "--help"]) + .output() + .expect("show help"); + assert_success(&show); + let show = String::from_utf8_lossy(&show.stdout); + assert!( + show.contains("--last-operation"), + "selector is absent: {show}" + ); + for hidden in ["--project", "--evidence-bin"] { + assert!(!show.contains(hidden), "test seam leaked: {show}"); + } + + for arguments in [ + vec!["audit", "show"], + vec!["audit", "show", "--all"], + vec!["audit", "show", "--last-operation", "--last-operation"], + ] { + let output = command().args(arguments).output().expect("invalid CLI"); + assert!( + !output.status.success(), + "selector must be required/exclusive" + ); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn successful_view_delegates_to_stopped_core_and_prints_only_aliases() { + let fixture = Fixture::new(); + fixture.write_core_json(&successful_view()); + let output = fixture.show(); + assert_success(&output); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!( + "ACCESS AUTHORIZED adult-status age-check requester={PSEUDONYM}\n\ + DISCLOSURE RELEASED is_adult\n" + ) + ); + assert!(output.stderr.is_empty()); + + let arguments = + fs::read_to_string(fixture.evidence.with_extension("args")).expect("Evidence argv"); + assert_eq!( + arguments.lines().collect::>(), + [ + "--runtime", + fs::canonicalize(&fixture.root) + .expect("canonical project") + .join(".evidence/dev/runtime.yaml") + .to_str() + .expect("runtime path"), + "local-audit-last-operation", + ] + ); + let rendered = String::from_utf8_lossy(&output.stdout); + for forbidden in [ + "person-123", + "token-canary", + "operation", + "evidenceId", + "source", + "adapter", + "actor", + "grant", + "subject", + "citizen", + "accountability", + ] { + assert!(!rendered.contains(forbidden), "rendered {forbidden}"); + } +} + +#[test] +fn access_only_view_prints_authorized_without_claiming_release() { + let fixture = Fixture::new(); + let mut view = successful_view(); + view["events"].as_array_mut().expect("events").truncate(1); + fixture.write_core_json(&view); + let output = fixture.show(); + assert_success(&output); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("ACCESS AUTHORIZED adult-status age-check requester={PSEUDONYM}\n") + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("RELEASE")); +} + +#[test] +fn structured_sd_jwt_release_uses_the_same_minimized_audit_view() { + let fixture = Fixture::new(); + let state_path = fixture.root.join(".evidence/dev/state.json"); + let mut state: Value = + serde_json::from_slice(&fs::read(&state_path).expect("state")).expect("state JSON"); + state["questions"][1]["concepts"][0]["form"] = json!("reviewed-structured-value"); + fs::write( + &state_path, + serde_json::to_vec(&state).expect("state renders"), + ) + .expect("state writes"); + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o600)).expect("state mode"); + + let bundle_path = fixture.root.join(".evidence/dev/bundle/evidence.yaml"); + let mut bundle: Value = + serde_norway::from_slice(&fs::read(&bundle_path).expect("bundle")).expect("bundle YAML"); + bundle["requirements"][1]["concepts"][0]["form"] = json!("reviewed-structured-value"); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o600)) + .expect("unseal bundle for fixture update"); + fs::write( + &bundle_path, + serde_norway::to_string(&bundle).expect("bundle renders"), + ) + .expect("bundle writes"); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o400)).expect("bundle mode"); + + let mut view = successful_view(); + view["events"][0]["responseProtection"] = json!("sd-jwt-vc"); + view["events"][1]["responseProtection"] = json!("sd-jwt-vc"); + fixture.write_core_json(&view); + + let output = fixture.show(); + assert_success(&output); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!( + "ACCESS AUTHORIZED adult-status age-check requester={PSEUDONYM}\n\ + DISCLOSURE RELEASED is_adult\n" + ) + ); +} + +#[test] +fn multi_concept_release_requires_and_prints_the_exact_declared_list() { + let fixture = Fixture::new(); + let state_path = fixture.root.join(".evidence/dev/state.json"); + let mut state: Value = + serde_json::from_slice(&fs::read(&state_path).expect("state")).expect("state JSON"); + state["questions"][1]["concepts"] = json!([ + { + "alias": "is_adult", + "uri": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + }, + { + "alias": "age_years", + "uri": "urn:registrystack:evidence:local:concept:adult-status:age_years", + "form": "bounded-integer" + } + ]); + fs::write( + &state_path, + serde_json::to_vec(&state).expect("state renders"), + ) + .expect("state writes"); + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o600)).expect("state mode"); + + let bundle_path = fixture.root.join(".evidence/dev/bundle/evidence.yaml"); + let mut bundle: Value = + serde_norway::from_slice(&fs::read(&bundle_path).expect("bundle")).expect("bundle YAML"); + bundle["requirements"][1]["concepts"] = json!([ + { + "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + }, + { + "id": "urn:registrystack:evidence:local:concept:adult-status:age_years", + "form": "bounded-integer" + } + ]); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o600)) + .expect("open bundle for fixture update"); + fs::write( + &bundle_path, + serde_norway::to_string(&bundle).expect("bundle renders"), + ) + .expect("bundle writes"); + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o400)) + .expect("seal updated bundle"); + + let mut view = successful_view(); + view["events"][1]["disclosedConcepts"] = json!([ + "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "urn:registrystack:evidence:local:concept:adult-status:age_years" + ]); + fixture.write_core_json(&view); + let output = fixture.show(); + assert_success(&output); + assert!(String::from_utf8_lossy(&output.stdout) + .contains("DISCLOSURE RELEASED is_adult, age_years\n")); + + view["events"][1]["disclosedConcepts"] = + json!(["urn:registrystack:evidence:local:concept:adult-status:is_adult"]); + fixture.write_core_json(&view); + assert_closed_failure(&fixture.show(), "incomplete multi-concept release"); +} + +#[test] +fn closed_parser_alias_mapping_and_metadata_fail_without_partial_output() { + let fixture = Fixture::new(); + let base = successful_view(); + let mut cases = Vec::new(); + + let mut unknown_top = base.clone(); + unknown_top["rawSelector"] = json!("person-123"); + cases.push(("unknown top field", unknown_top)); + + let mut unknown_event = base.clone(); + unknown_event["events"][0]["sourceId"] = json!("source-canary"); + cases.push(("unknown event field", unknown_event)); + + let mut schema = base.clone(); + schema["schema"] = json!("registry.evidence.local-audit-operation/v2"); + cases.push(("schema", schema)); + + let mut requirement = base.clone(); + requirement["events"][0]["requirement"] = json!("urn:other:requirement"); + cases.push(("requirement alias", requirement)); + + let mut concept = base.clone(); + concept["events"][1]["disclosedConcepts"] = json!(["urn:other:concept"]); + cases.push(("concept alias", concept)); + + let mut requester = base.clone(); + requester["events"][1]["requesterPseudonym"] = + json!(format!("hmac-sha256:v1:{}", "b".repeat(64))); + cases.push(("requester coherence", requester)); + + let mut unsafe_requester = base.clone(); + unsafe_requester["events"][0]["requesterPseudonym"] = json!("person-123"); + cases.push(("unsafe pseudonym", unsafe_requester)); + + let mut protection = base.clone(); + protection["events"][0]["responseProtection"] = json!("unsigned"); + cases.push(("response protection", protection)); + + let mut phase = base.clone(); + phase["events"][1]["phase"] = json!("access-attempt"); + cases.push(("phase", phase)); + + let mut decision = base.clone(); + decision["events"][1]["decision"] = json!("authorized"); + cases.push(("decision", decision)); + + let mut explicit_null = base.clone(); + explicit_null["events"][0]["disclosedConcepts"] = Value::Null; + cases.push(("explicit null is not omission", explicit_null)); + + let mut reversed_time = base.clone(); + reversed_time["events"][1]["occurredAt"] = json!("2026-08-04T00:00:00.000Z"); + cases.push(("event time order", reversed_time)); + + let mut too_many = base.clone(); + let third = too_many["events"][1].clone(); + too_many["events"] + .as_array_mut() + .expect("events") + .push(third); + cases.push(("event bound", too_many)); + + for (label, value) in cases { + fixture.write_core_json(&value); + assert_closed_failure(&fixture.show(), label); + } + + fixture.write_core_bytes(b"{malformed person-123 token-canary"); + assert_closed_failure(&fixture.show(), "malformed JSON"); +} + +#[test] +fn core_failure_oversized_output_and_non_stopped_state_are_value_free() { + let fixture = Fixture::new(); + fixture.write_core_json(&successful_view()); + fs::write(fixture.evidence.with_extension("fail"), b"").expect("failure marker"); + assert_closed_failure(&fixture.show(), "core failure"); + fs::remove_file(fixture.evidence.with_extension("fail")).expect("remove marker"); + + fixture.write_core_bytes(&vec![b'x'; 256 * 1024 + 1]); + assert_closed_failure(&fixture.show(), "output bound"); + + fixture.write_core_json(&successful_view()); + let state_path = fixture.root.join(".evidence/dev/state.json"); + let mut state: Value = + serde_json::from_slice(&fs::read(&state_path).expect("state")).expect("state JSON"); + state["status"] = json!("ready"); + fs::write( + &state_path, + serde_json::to_vec(&state).expect("state renders"), + ) + .expect("state writes"); + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o600)).expect("state mode"); + assert_closed_failure(&fixture.show(), "running state"); +} + +fn successful_view() -> Value { + json!({ + "schema": "registry.evidence.local-audit-operation/v1", + "operation": "person-123-token-canary", + "events": [ + { + "occurredAt": "2026-08-04T00:00:01.000Z", + "phase": "access-attempt", + "decision": "authorized", + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "requesterPseudonym": PSEUDONYM, + "responseProtection": "signed" + }, + { + "occurredAt": "2026-08-04T00:00:02.000Z", + "phase": "disclosure-release", + "decision": "released", + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "requesterPseudonym": PSEUDONYM, + "responseProtection": "signed", + "disclosedConcepts": [ + "urn:registrystack:evidence:local:concept:adult-status:is_adult" + ], + "evidenceId": "urn:token-canary:evidence:person-123" + } + ] + }) +} + +struct Fixture { + _temporary: tempfile::TempDir, + root: PathBuf, + evidence: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temporary = tempfile::tempdir().expect("temporary directory"); + let root = temporary.path().join("project"); + private_directory(&root); + private_directory(&root.join(".evidence")); + private_directory(&root.join(".evidence/dev")); + private_directory(&root.join(".evidence/dev/bundle")); + private_file(&root.join(".evidence/dev/runtime.yaml"), b"runtime", 0o400); + let canonical = fs::canonicalize(&root).expect("canonical project"); + let state = json!({ + "schema": "registry.evidencectl.dev-state/v5", + "status": "stopped", + "project": canonical, + "runtimePath": canonical.join(".evidence/dev/runtime.yaml"), + "evidenceOrigin": "http://127.0.0.1:8080", + "mintOrigin": "http://127.0.0.1:8081", + "tokenUrl": "http://127.0.0.1:8081/token", + "accessTokenAudience": "registry-evidence-local", + "caller": null, + "accessPolicies": [], + "questions": [ + { + "alias": "age-bracket", + "requirementUri": "urn:registrystack:evidence:local:requirement:age-bracket", + "purpose": "service-path-selection", + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-age-bracket-v1", + "selectorField": "person_id" + }], + "concepts": [{ + "alias": "age_bracket", + "uri": "urn:registrystack:evidence:local:concept:age-bracket:age_bracket", + "form": "controlled-category" + }] + }, + { + "alias": "adult-status", + "requirementUri": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-adult-status-v1", + "selectorField": "person_id" + }], + "concepts": [{ + "alias": "is_adult", + "uri": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + }] + } + ], + "failure": null + }); + private_file( + &root.join(".evidence/dev/state.json"), + &serde_json::to_vec(&state).expect("state renders"), + 0o600, + ); + let bundle = json!({ + "selectorProfiles": { + "local-subject-age-bracket-v1": { + "fields": {"person_id": {"type": "string"}} + }, + "local-subject-adult-status-v1": { + "fields": {"person_id": {"type": "string"}} + } + }, + "authorityProfiles": { + "local-caller": { + "kind": "explicit-request", + "requesterTags": ["local-caller"], + "grants": [ + { + "requirement": "urn:registrystack:evidence:local:requirement:age-bracket", + "purpose": "service-path-selection", + "audienceFrom": "authenticated-requester", + "responseFormats": ["signed-jws"], + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-age-bracket-v1", + "valueOrigin": "request" + }] + }, + { + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "audienceFrom": "authenticated-requester", + "responseFormats": ["signed-jws"], + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-adult-status-v1", + "valueOrigin": "request" + }] + } + ] + } + }, + "requirements": [ + { + "id": "urn:registrystack:evidence:local:requirement:age-bracket", + "purposes": ["service-path-selection"], + "subjectRoles": [{ + "role": "person", + "selectorProfiles": ["local-subject-age-bracket-v1"] + }], + "concepts": [{ + "id": "urn:registrystack:evidence:local:concept:age-bracket:age_bracket", + "form": "controlled-category" + }] + }, + { + "id": "urn:registrystack:evidence:local:requirement:adult-status", + "purposes": ["age-check"], + "subjectRoles": [{ + "role": "person", + "selectorProfiles": ["local-subject-adult-status-v1"] + }], + "concepts": [{ + "id": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + }] + } + ] + }); + private_file( + &root.join(".evidence/dev/bundle/evidence.yaml"), + serde_norway::to_string(&bundle) + .expect("bundle renders") + .as_bytes(), + 0o400, + ); + + let evidence = temporary.path().join("evidence-stub"); + executable( + &evidence, + b"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\nif [ -f \"$0.fail\" ]; then\n printf 'person-123 token-canary source-canary\\n' >&2\n exit 41\nfi\ncat \"$0.output\"\n", + ); + Self { + _temporary: temporary, + root, + evidence, + } + } + + fn write_core_json(&self, value: &Value) { + self.write_core_bytes(&serde_json::to_vec(value).expect("core output renders")); + } + + fn write_core_bytes(&self, bytes: &[u8]) { + fs::write(self.evidence.with_extension("output"), bytes).expect("core output writes"); + } + + fn show(&self) -> Output { + command() + .current_dir(&self.root) + .args([ + "audit", + "show", + "--last-operation", + "--project", + ".", + "--evidence-bin", + ]) + .arg(&self.evidence) + .output() + .expect("audit show") + } +} + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn private_directory(path: &Path) { + fs::create_dir(path).expect("private directory"); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).expect("private mode"); +} + +fn private_file(path: &Path, contents: &[u8], mode: u32) { + fs::write(path, contents).expect("private file"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("private file mode"); +} + +fn executable(path: &Path, contents: &[u8]) { + private_file(path, contents, 0o700); +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_closed_failure(output: &Output, label: &str) { + assert!(!output.status.success(), "{label} unexpectedly succeeded"); + assert!(output.stdout.is_empty(), "{label} printed partial output"); + assert_eq!(String::from_utf8_lossy(&output.stderr), FAILURE, "{label}"); + for protected in ["person-123", "token-canary", "source-canary"] { + assert!( + !String::from_utf8_lossy(&output.stderr).contains(protected), + "{label} leaked {protected}" + ); + } +} diff --git a/crates/registry-evidencectl/tests/cli_surface.rs b/crates/registry-evidencectl/tests/cli_surface.rs new file mode 100644 index 000000000..ce9c6644a --- /dev/null +++ b/crates/registry-evidencectl/tests/cli_surface.rs @@ -0,0 +1,64 @@ +//! Every command module is reachable from the binary. +//! +//! The crate has only a `[[bin]]` target, so the other integration tests pull +//! command modules in with `#[path = "../src/..."] mod`. That compiles a module +//! whether or not `main.rs` declares it, which lets a command ship with a full +//! test suite and still be unreachable from `evidencectl`. This file drives the +//! real binary so the top-level surface cannot drift away from `src/`. + +use std::process::Command; + +/// The complete top-level subcommand set. Adding a command means adding it +/// here; the point of the list is that an omission fails rather than passes. +const TOP_LEVEL_COMMANDS: [&str; 11] = [ + "access", "keygen", "jwks", "new", "build", "fixtures", "source", "dev", "request", "verify", + "audit", +]; + +#[test] +fn every_top_level_command_is_listed_and_dispatchable() { + let help = evidencectl(&["--help"]); + for command in TOP_LEVEL_COMMANDS { + assert!( + help.contains(command), + "`evidencectl --help` does not list `{command}`:\n{help}" + ); + + // Listing is not dispatch: clap prints help for a declared variant even + // when the arm behind it is missing, so ask the command itself. + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args([command, "--help"]) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "`evidencectl {command} --help` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn an_undeclared_command_is_still_refused() { + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(["not-a-command", "--help"]) + .output() + .expect("run evidencectl"); + assert!( + !output.status.success(), + "an unknown subcommand must not succeed" + ); +} + +fn evidencectl(arguments: &[&str]) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "evidencectl {arguments:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} diff --git a/crates/registry-evidencectl/tests/dev_lifecycle.rs b/crates/registry-evidencectl/tests/dev_lifecycle.rs new file mode 100644 index 000000000..85635f57d --- /dev/null +++ b/crates/registry-evidencectl/tests/dev_lifecycle.rs @@ -0,0 +1,1148 @@ +//! Real first-tutorial lifecycle proof. +//! +//! The test is ignored in the ordinary package suite because it owns the +//! fixed tutorial ports. The grouped lifecycle gate builds the sibling Mint +//! and Evidence binaries, supplies their paths, and runs this test exactly. + +use std::{ + ffi::OsStr, + fs, + net::TcpListener, + os::unix::{ + ffi::OsStrExt as _, + fs::{symlink, MetadataExt as _, PermissionsExt as _}, + }, + path::{Path, PathBuf}, + process::{Child, Command, Output}, + thread, + time::{Duration, Instant}, +}; + +use serde_json::{json, Value}; + +const OPENAPI: &str = r#"openapi: 3.1.0 +info: {title: Tutorial registry, version: 1.0.0} +servers: [{url: 'http://127.0.0.1:8000'}] +paths: + /people/{person_id}: + get: + operationId: getPerson + parameters: + - name: person_id + in: path + required: true + schema: {type: string} + responses: + '200': + description: A person + content: + application/json: + schema: + type: object + required: [person_id, date_of_birth] + properties: + person_id: {type: string} + date_of_birth: {type: string, format: date} +"#; + +const QUESTION: &str = r#"id: adult-status +question: Is the person at least 18 years old? +purpose: age-check +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +answers: + - concept: is_adult + type: boolean +derivation: derivations/adult-status.rhai +disclosure: + allow: [is_adult] +"#; + +const DERIVATION: &str = r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + #{is_adult: compare_dates(context.legal_local_date, add_calendar_years(born, 18)) >= 0} +} +"#; + +const AGE_BRACKET_QUESTION: &str = r#"id: age-bracket +question: Which age bracket does this person belong to? +purpose: service-path-selection +subject: + role: person + selector: person_id +source: + operation: getPerson + facts: + - name: date_of_birth + path: /date_of_birth + combine: exactly-one + collectionBounds: {} +answers: + - concept: age_bracket + type: controlled-category + values: [under-18, 18-to-24, 25-to-64, 65-or-older] +derivation: derivations/age-bracket.rhai +disclosure: + allow: [age_bracket] +"#; + +const AGE_BRACKET_DERIVATION: &str = r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + if compare_dates(context.legal_local_date, add_calendar_years(born, 18)) < 0 { + #{age_bracket: "under-18"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 25)) < 0 { + #{age_bracket: "18-to-24"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 65)) < 0 { + #{age_bracket: "25-to-64"} + } else { + #{age_bracket: "65-or-older"} + } +} +"#; + +#[test] +#[ignore = "exact gate: owns fixed 127.0.0.1:8080 and :8081 tutorial ports"] +fn real_detached_lifecycle_is_ready_private_and_stops_only_owned_children() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let fixture = Project::new_long_path(); + assert!( + fixture + .root + .join(".evidence/dev/control.sock") + .as_os_str() + .as_bytes() + .len() + > 104, + "test path must exceed the common sockaddr_un.sun_path limit" + ); + fixture.generate_evidence_keys(); + + let mut unrelated = Command::new("/bin/sleep") + .arg("60") + .spawn() + .expect("start unrelated process"); + + let started = fixture.dev_start(&evidence, &mint); + assert_success(&started, "dev --detach"); + let stdout = String::from_utf8_lossy(&started.stdout); + assert_eq!( + stdout, + "Evidence ready at http://127.0.0.1:8080\nMint ready at http://127.0.0.1:8081\n" + ); + assert!(ready( + "http://127.0.0.1:8080/ready", + json!({"status":"ready"}) + )); + assert!(jwks_ready()); + + let duplicate = fixture.dev_start(&evidence, &mint); + assert!(!duplicate.status.success(), "duplicate start must fail"); + assert!(ready( + "http://127.0.0.1:8080/ready", + json!({"status":"ready"}) + )); + + let dev = fixture.root.join(".evidence/dev"); + assert_mode(&fixture.root.join(".evidence"), 0o700); + assert_mode(&dev, 0o700); + assert_mode(&dev.join("generated/audit"), 0o700); + for path in [ + "state.json", + "generated/mint.yaml", + "generated/clients/caller.yaml", + "generated/audit/mint.jsonl", + "generated/keys/mint-audit-hmac-key", + "generated/keys/mint-private.jwk", + "generated/keys/mint-public.jwk.json", + "generated/keys/caller-private.jwk", + "generated/keys/caller-public.jwk.json", + "logs/supervisor.log", + "logs/mint.log", + "logs/evidence.log", + ] { + assert_mode(&dev.join(path), 0o600); + } + + let state: Value = serde_json::from_slice(&fs::read(dev.join("state.json")).expect("state")) + .expect("state JSON"); + assert_eq!(state["schema"], "registry.evidencectl.dev-state/v5"); + assert_eq!(state["status"], "ready"); + assert_eq!(state["accessTokenAudience"], "registry-evidence-local"); + assert_eq!(state["caller"]["requesterTag"], "local-caller"); + assert_eq!(state["accessPolicies"], json!([])); + assert_eq!(state["questions"][0]["alias"], "adult-status"); + assert_eq!( + state["questions"][0]["requirementUri"], + "urn:registrystack:evidence:local:requirement:adult-status" + ); + let encoded_state = serde_json::to_string(&state).expect("state encodes"); + for prohibited in [ + "access_token", + "\"d\"", + "generations", + "receipt", + "template", + ] { + assert!( + !encoded_state.contains(prohibited), + "state contains {prohibited}" + ); + } + + assert_success( + &Command::new(&mint) + .args(["check", "--config"]) + .arg(dev.join("generated/mint.yaml")) + .output() + .expect("mint check"), + "mint check", + ); + assert_success( + &Command::new(&evidence) + .arg("--runtime") + .arg(dev.join("runtime.yaml")) + .arg("check") + .output() + .expect("evidence check"), + "evidence check", + ); + + let stopped = fixture.dev_stop(); + assert_success(&stopped, "dev stop"); + assert_eq!( + String::from_utf8_lossy(&stopped.stdout), + "Local Evidence stopped\n" + ); + wait_unavailable("127.0.0.1:8080"); + wait_unavailable("127.0.0.1:8081"); + assert!(unrelated.try_wait().expect("unrelated status").is_none()); + stop_child(&mut unrelated); + + let entries = sorted_names(&dev); + assert_eq!(entries, ["audit", "bundle", "runtime.yaml", "state.json"]); + let stopped_state: Value = + serde_json::from_slice(&fs::read(dev.join("state.json")).expect("stopped state")) + .expect("stopped state JSON"); + assert_eq!(stopped_state["status"], "stopped"); + assert!(stopped_state["caller"].is_null()); + assert!(dev.join("audit/evidence.jsonl").is_file()); + assert!(dev.join("runtime.yaml").is_file()); + assert_success(&fixture.dev_clean(), "dev clean"); + assert!(!dev.exists(), "clean removes the sealed stopped generation"); +} + +#[test] +#[ignore = "exact gate: starts real local Mint and Evidence services"] +fn configurable_ports_drive_every_generated_url_and_listener() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let fixture = Project::new(); + fixture.generate_evidence_keys(); + let (evidence_port, mint_port) = unused_port_pair(); + + let started = fixture.dev_start_on_ports(&evidence, &mint, evidence_port, mint_port); + assert_success(&started, "dev --detach on configured ports"); + assert_eq!( + String::from_utf8_lossy(&started.stdout), + format!( + "Evidence ready at http://127.0.0.1:{evidence_port}\nMint ready at http://127.0.0.1:{mint_port}\n" + ) + ); + assert!(ready( + &format!("http://127.0.0.1:{evidence_port}/ready"), + json!({"status":"ready"}) + )); + assert!(jwks_ready_at(mint_port)); + + let dev = fixture.root.join(".evidence/dev"); + let state: Value = serde_json::from_slice(&fs::read(dev.join("state.json")).unwrap()).unwrap(); + assert_eq!( + state["evidenceOrigin"], + format!("http://127.0.0.1:{evidence_port}") + ); + assert_eq!( + state["tokenUrl"], + format!("http://127.0.0.1:{mint_port}/token") + ); + let runtime: Value = serde_norway::from_slice(&fs::read(dev.join("runtime.yaml")).unwrap()) + .expect("runtime YAML"); + let mint_config: Value = + serde_norway::from_slice(&fs::read(dev.join("generated/mint.yaml")).unwrap()) + .expect("Mint YAML"); + assert_eq!(runtime["listener"]["port"], evidence_port); + assert_eq!(mint_config["listener"]["port"], mint_port); + assert_eq!(mint_config["audit"]["path"], "audit/mint.jsonl"); + assert_eq!(mint_config["audit"]["hashKeyVersion"], 1); + assert_eq!( + mint_config["clientAssertion"]["audience"], + format!("http://127.0.0.1:{mint_port}/token") + ); + + assert_success(&fixture.dev_stop(), "stop configured ports"); + wait_unavailable(&format!("127.0.0.1:{evidence_port}")); + wait_unavailable(&format!("127.0.0.1:{mint_port}")); + assert_success(&fixture.dev_clean(), "clean configured ports"); +} + +#[test] +#[ignore = "exact gate: starts real Mint and Evidence services"] +fn explicit_access_clients_reload_mint_without_restarting_services() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let fixture = Project::new(); + let source_probe = TcpListener::bind("127.0.0.1:0").expect("source call probe"); + source_probe + .set_nonblocking(true) + .expect("nonblocking source call probe"); + fixture.point_source_at( + source_probe + .local_addr() + .expect("source probe address") + .port(), + ); + fixture.add_age_bracket_question(); + fixture.generate_evidence_keys(); + assert_success( + &evidencectl() + .args([ + "access", + "policy", + "add", + "age-checks", + "--question", + "adult-status", + "--project", + ]) + .arg(&fixture.root) + .output() + .expect("add policy"), + "add policy", + ); + assert_success( + &evidencectl() + .args([ + "access", + "policy", + "add", + "service-routing", + "--question", + "age-bracket", + "--project", + ]) + .arg(&fixture.root) + .output() + .expect("add unassigned policy"), + "add policy for the ungranted question", + ); + assert_success( + &add_local_client(&fixture.root, "client-a", "age-checks"), + "add client A", + ); + let client_a_key = fixture.root.join(".evidence/clients/client-a/private.jwk"); + assert_mode(&client_a_key, 0o600); + let external_client_a_key = fixture + ._temporary + .path() + .join("external-client-a-private.jwk"); + fs::copy(&client_a_key, &external_client_a_key).expect("retain external client A key"); + fs::remove_dir_all(client_a_key.parent().unwrap()) + .expect("remove local-only client A key as in a fresh clone"); + assert!( + !client_a_key.exists(), + "the cloned project has only client A's public registration" + ); + + let pid_directory = fixture.root.join("service-pids"); + fs::create_dir(&pid_directory).expect("PID directory"); + fs::set_permissions(&pid_directory, fs::Permissions::from_mode(0o700)) + .expect("PID directory mode"); + let (evidence_port, mint_port) = unused_port_pair(); + let started = fixture + .dev_start_command(&evidence, &mint) + .args(["--evidence-port", &evidence_port.to_string()]) + .args(["--mint-port", &mint_port.to_string()]) + .env("EVIDENCECTL_TEST_SERVICE_PID_DIRECTORY", &pid_directory) + .output() + .expect("start explicit access generation"); + assert_success(&started, "start explicit access generation"); + let evidence_pid = read_pid(&pid_directory.join("evidence.pid")); + let mint_pid = read_pid(&pid_directory.join("mint.pid")); + let generated_clients = fixture.root.join(".evidence/dev/generated/clients"); + assert_mode(&fixture.root.join(".evidence/dev/generated/audit"), 0o700); + assert_mode( + &fixture + .root + .join(".evidence/dev/generated/keys/mint-audit-hmac-key"), + 0o600, + ); + assert_mode( + &fixture + .root + .join(".evidence/dev/generated/audit/mint.jsonl"), + 0o600, + ); + assert_eq!(sorted_names(&generated_clients), ["client-a.yaml"]); + + let added = add_local_client(&fixture.root, "client-b", "age-checks"); + assert_success(&added, "live add client B"); + assert!(String::from_utf8_lossy(&added.stdout).contains("Registry Mint reload requested.")); + assert_eq!( + sorted_names(&generated_clients), + ["client-a.yaml", "client-b.yaml"] + ); + assert_eq!(read_pid(&pid_directory.join("evidence.pid")), evidence_pid); + assert_eq!(read_pid(&pid_directory.join("mint.pid")), mint_pid); + assert!(process_is_alive(evidence_pid)); + assert!(process_is_alive(mint_pid)); + + let prepared = retry_until_success("newly added client B token request", || { + evidencectl() + .args([ + "request", + "prepare", + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + "--client", + "client-b", + "--name", + "client-b-live", + "--project", + ]) + .arg(&fixture.root) + .output() + .expect("prepare as client B") + }); + assert_success(&prepared, "newly added client B token request"); + + let client_b_key = fixture.root.join(".evidence/clients/client-b/private.jwk"); + let token = direct_mint_token(&mint, mint_port, "client-b", &client_b_key); + assert_success(&token, "direct token for client B"); + let token = String::from_utf8(token.stdout) + .expect("Mint token is UTF-8") + .trim() + .to_owned(); + let status = post_evidence( + evidence_port, + &token, + "urn:registrystack:evidence:local:requirement:age-bracket", + "service-path-selection", + "local-subject-age-bracket-v1", + ); + assert_eq!(status, 403, "an ungranted authored question is forbidden"); + assert_source_not_called(&source_probe); + + let revoked = evidencectl() + .args(["access", "client", "revoke", "client-a", "--project"]) + .arg(&fixture.root) + .output() + .expect("revoke client A"); + assert_success(&revoked, "live revoke client A"); + assert!(String::from_utf8_lossy(&revoked.stdout).contains("Registry Mint reload requested.")); + assert_eq!(sorted_names(&generated_clients), ["client-b.yaml"]); + assert_eq!(read_pid(&pid_directory.join("evidence.pid")), evidence_pid); + assert_eq!(read_pid(&pid_directory.join("mint.pid")), mint_pid); + assert!(process_is_alive(evidence_pid)); + assert!(process_is_alive(mint_pid)); + assert!( + !client_a_key.exists(), + "revocation does not require or recreate client A's local key" + ); + + let direct_refusal = retry_until_mint_refuses(|| { + direct_mint_token(&mint, mint_port, "client-a", &external_client_a_key) + }); + assert!(direct_refusal.stdout.is_empty()); + + let refused = evidencectl() + .args([ + "request", + "prepare", + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + "--client", + "client-a", + "--name", + "client-a-revoked", + "--project", + ]) + .arg(&fixture.root) + .output() + .expect("prepare as revoked client A"); + assert!( + !refused.status.success(), + "revoked client A must be refused" + ); + assert!(String::from_utf8_lossy(&refused.stderr) + .contains("unknown or revoked active client client-a")); + assert!(!fixture + .root + .join(".evidence/requests/client-a-revoked") + .exists()); + + let last_revoked = evidencectl() + .args(["access", "client", "revoke", "client-b", "--project"]) + .arg(&fixture.root) + .output() + .expect("revoke last client B"); + assert_success(&last_revoked, "live revoke last client B"); + assert!( + String::from_utf8_lossy(&last_revoked.stdout).contains("Registry Mint reload requested.") + ); + assert!(sorted_names(&generated_clients).is_empty()); + wait_mint_without_clients(mint_port); + assert!(process_is_alive(evidence_pid)); + assert!(process_is_alive(mint_pid)); + + assert_success(&fixture.dev_stop(), "stop explicit access generation"); + wait_unavailable(&format!("127.0.0.1:{evidence_port}")); + wait_unavailable(&format!("127.0.0.1:{mint_port}")); + assert_success(&fixture.dev_clean(), "clean explicit access generation"); +} + +#[test] +fn equal_local_ports_fail_before_creating_private_state() { + let fixture = Project::new(); + let output = evidencectl() + .args([ + "dev", + "--detach", + "--evidence-port", + "18080", + "--mint-port", + "18080", + "--project", + ]) + .arg(&fixture.root) + .output() + .expect("equal-port start"); + assert!(!output.status.success()); + assert!(!fixture.root.join(".evidence").exists()); +} + +#[test] +#[ignore = "exact gate: owns fixed 127.0.0.1:8081 tutorial port"] +fn mint_port_conflict_fails_without_starting_evidence_or_disturbing_the_listener() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let fixture = Project::new(); + fixture.generate_evidence_keys(); + let conflict = TcpListener::bind("127.0.0.1:8081").expect("reserve Mint port"); + + let output = fixture.dev_start(&evidence, &mint); + assert!(!output.status.success(), "port conflict must fail"); + assert!(conflict.local_addr().is_ok(), "unrelated listener survives"); + assert!( + TcpListener::bind("127.0.0.1:8080").is_ok(), + "Evidence was not orphaned" + ); + assert!( + !fixture.root.join(".evidence/dev").exists(), + "failed fresh state cleaned" + ); +} + +#[test] +#[ignore = "exact gate: starts real Mint on fixed 127.0.0.1:8081"] +fn evidence_child_failure_stops_mint_and_cleans_the_fresh_session() { + let mint = required_binary("MINT_BIN"); + let fixture = Project::new(); + fixture.generate_evidence_keys(); + let evidence = fixture.root.join("evidence-fails-on-serve"); + fs::write( + &evidence, + "#!/bin/sh\nif [ \"$3\" = check ]; then exit 0; fi\nexit 1\n", + ) + .expect("write Evidence test binary"); + fs::set_permissions(&evidence, fs::Permissions::from_mode(0o700)).expect("test binary mode"); + + let output = fixture.dev_start(&evidence, &mint); + assert!( + !output.status.success(), + "Evidence child failure must fail start" + ); + wait_unavailable("127.0.0.1:8080"); + wait_unavailable("127.0.0.1:8081"); + assert!( + !fixture.root.join(".evidence/dev").exists(), + "failed state cleaned" + ); +} + +#[test] +#[ignore = "exact gate: starts real services on fixed tutorial ports"] +fn ready_state_publication_failure_stops_children_and_allows_a_fresh_start() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let fixture = Project::new(); + fixture.generate_evidence_keys(); + + let failed = fixture.dev_start_with_env( + &evidence, + &mint, + "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", + OsStr::new("before-ready-state"), + ); + assert!( + !failed.status.success(), + "state publication fault must fail" + ); + wait_unavailable("127.0.0.1:8080"); + wait_unavailable("127.0.0.1:8081"); + assert!( + !fixture.root.join(".evidence/dev").exists(), + "failed fresh state cleaned" + ); + + let restarted = fixture.dev_start(&evidence, &mint); + assert_success(&restarted, "fresh start after rollback"); + assert_success(&fixture.dev_stop(), "stop fresh start"); +} + +#[test] +#[ignore = "exact gate: starts real services on fixed tutorial ports"] +fn catchable_supervisor_signals_stop_owned_children_and_publish_terminal_state() { + let evidence = required_binary("EVIDENCE_BIN"); + let mint = required_binary("MINT_BIN"); + let mut unrelated = Command::new("/bin/sleep") + .arg("60") + .spawn() + .expect("start unrelated process"); + + for signal in [ + rustix::process::Signal::TERM, + rustix::process::Signal::HUP, + rustix::process::Signal::INT, + ] { + let fixture = Project::new(); + fixture.generate_evidence_keys(); + let pid_file = fixture.root.join("supervisor.pid"); + let started = fixture.dev_start_with_env( + &evidence, + &mint, + "EVIDENCECTL_TEST_SUPERVISOR_PID_FILE", + pid_file.as_os_str(), + ); + assert_success(&started, "dev --detach before supervisor signal"); + + let pid: i32 = fs::read_to_string(&pid_file) + .expect("supervisor pid file") + .trim() + .parse() + .expect("supervisor pid"); + let pid = rustix::process::Pid::from_raw(pid).expect("positive supervisor pid"); + rustix::process::kill_process(pid, signal).expect("signal supervisor"); + + let dev = fixture.root.join(".evidence/dev"); + wait_for_failed_state(&dev); + wait_unavailable("127.0.0.1:8080"); + wait_unavailable("127.0.0.1:8081"); + assert!(!dev.join("control.sock").exists()); + assert!(unrelated.try_wait().expect("unrelated status").is_none()); + } + stop_child(&mut unrelated); +} + +#[test] +fn every_pre_socket_supervisor_failure_rolls_back_without_wedging_the_project() { + let fixture = Project::new(); + fixture.generate_evidence_keys(); + let check_only = fixture.root.join("check-only-tool"); + fs::write( + &check_only, + "#!/bin/sh\ncase \"$*\" in *check*) exit 0;; *) exit 1;; esac\n", + ) + .expect("write check-only tool"); + fs::set_permissions(&check_only, fs::Permissions::from_mode(0o700)).expect("tool mode"); + + let missing_supervisor = fixture.root.join("missing-supervisor"); + let failed = fixture.dev_start_with_env( + &check_only, + &check_only, + "EVIDENCECTL_TEST_SUPERVISOR_BIN", + missing_supervisor.as_os_str(), + ); + assert!(!failed.status.success(), "supervisor spawn fault must fail"); + assert!(!fixture.root.join(".evidence/dev").exists()); + + for stage in ["before-setsid", "before-socket", "after-socket"] { + let failed = fixture.dev_start_with_env( + &check_only, + &check_only, + "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", + OsStr::new(stage), + ); + assert!(!failed.status.success(), "{stage} fault must fail"); + assert!( + !fixture.root.join(".evidence/dev").exists(), + "{stage} rollback must permit the next fresh start" + ); + } +} + +#[test] +fn public_symlink_and_stale_state_fail_before_binary_or_process_access() { + let public = Project::new(); + fs::create_dir(public.root.join(".evidence")).expect("generated root"); + fs::set_permissions( + public.root.join(".evidence"), + fs::Permissions::from_mode(0o755), + ) + .expect("public mode"); + let output = evidencectl() + .args(["dev", "--detach", "--project"]) + .arg(&public.root) + .output() + .expect("public-state start"); + assert!(!output.status.success()); + assert!(!public.dev_clean().status.success()); + + let linked = Project::new(); + let target = linked.root.join("private-target"); + fs::create_dir(&target).expect("target"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).expect("target mode"); + symlink(&target, linked.root.join(".evidence")).expect("generated symlink"); + let output = evidencectl() + .args(["dev", "--detach", "--project"]) + .arg(&linked.root) + .output() + .expect("symlink-state start"); + assert!(!output.status.success()); + assert!(!linked.dev_clean().status.success()); + assert!(linked.root.join(".evidence").is_symlink()); + + let stale = Project::new(); + fs::create_dir(stale.root.join(".evidence")).expect("generated root"); + fs::set_permissions( + stale.root.join(".evidence"), + fs::Permissions::from_mode(0o700), + ) + .expect("generated mode"); + fs::create_dir(stale.root.join(".evidence/dev")).expect("stale dev"); + fs::set_permissions( + stale.root.join(".evidence/dev"), + fs::Permissions::from_mode(0o700), + ) + .expect("stale mode"); + fs::write(stale.root.join(".evidence/dev/unknown"), b"do not remove").expect("stale entry"); + let mut unrelated = Command::new("/bin/sleep") + .arg("60") + .spawn() + .expect("unrelated process"); + let output = evidencectl() + .args(["dev", "--detach", "--project"]) + .arg(&stale.root) + .output() + .expect("stale-state start"); + assert!(!output.status.success()); + assert!(!stale.dev_clean().status.success()); + assert!(stale.root.join(".evidence/dev/unknown").is_file()); + assert!(unrelated.try_wait().expect("unrelated status").is_none()); + stop_child(&mut unrelated); +} + +struct Project { + _temporary: tempfile::TempDir, + root: PathBuf, +} + +impl Project { + fn new() -> Self { + Self::at_relative_path(Path::new("tutorial")) + } + + fn new_long_path() -> Self { + Self::at_relative_path(Path::new( + "first-evidence-assertion-with-a-deliberately-long-project-directory/adult-status-with-a-long-adopter-project-name", + )) + } + + fn at_relative_path(relative: &Path) -> Self { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path().join(relative); + fs::create_dir_all(&root).expect("project"); + fs::create_dir(root.join("questions")).expect("questions"); + fs::create_dir(root.join("derivations")).expect("derivations"); + fs::write(root.join("source.openapi.yaml"), OPENAPI).expect("OpenAPI"); + fs::write(root.join("questions/adult-status.yaml"), QUESTION).expect("question"); + fs::write(root.join("derivations/adult-status.rhai"), DERIVATION).expect("derivation"); + Self { + _temporary: temporary, + root, + } + } + + fn generate_evidence_keys(&self) { + let secrets = self.root.join("secrets"); + assert_success( + &evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&secrets) + .args(["--kid", "local-signing-key-1"]) + .output() + .expect("signing key"), + "signing key", + ); + for name in ["audit-hmac-key", "subject-binding-hmac-key"] { + assert_success( + &evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(secrets.join(name)) + .output() + .expect("HMAC key"), + "HMAC key", + ); + } + } + + fn point_source_at(&self, port: u16) { + fs::write( + self.root.join("source.openapi.yaml"), + OPENAPI.replace("http://127.0.0.1:8000", &format!("http://127.0.0.1:{port}")), + ) + .expect("update source origin"); + } + + fn add_age_bracket_question(&self) { + fs::write( + self.root.join("questions/age-bracket.yaml"), + AGE_BRACKET_QUESTION, + ) + .expect("age-bracket question"); + fs::write( + self.root.join("derivations/age-bracket.rhai"), + AGE_BRACKET_DERIVATION, + ) + .expect("age-bracket derivation"); + } + + fn dev_start(&self, evidence: &Path, mint: &Path) -> Output { + self.dev_start_command(evidence, mint) + .output() + .expect("dev --detach") + } + + fn dev_start_on_ports( + &self, + evidence: &Path, + mint: &Path, + evidence_port: u16, + mint_port: u16, + ) -> Output { + self.dev_start_command(evidence, mint) + .args(["--evidence-port", &evidence_port.to_string()]) + .args(["--mint-port", &mint_port.to_string()]) + .output() + .expect("dev --detach on configured ports") + } + + fn dev_start_with_env( + &self, + evidence: &Path, + mint: &Path, + name: &str, + value: &OsStr, + ) -> Output { + self.dev_start_command(evidence, mint) + .env(name, value) + .output() + .expect("dev --detach with test fault") + } + + fn dev_start_command(&self, evidence: &Path, mint: &Path) -> Command { + let mut command = evidencectl(); + command + .args(["dev", "--detach", "--project"]) + .arg(&self.root) + .arg("--evidence-bin") + .arg(evidence) + .arg("--mint-bin") + .arg(mint) + .arg("--ready-timeout-seconds") + .arg("20"); + command + } + + fn dev_stop(&self) -> Output { + evidencectl() + .args(["dev", "stop", "--project"]) + .arg(&self.root) + .output() + .expect("dev stop") + } + + fn dev_clean(&self) -> Output { + evidencectl() + .args(["dev", "clean", "--project"]) + .arg(&self.root) + .output() + .expect("dev clean") + } +} + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn add_local_client(project: &Path, client: &str, policy: &str) -> Output { + evidencectl() + .args([ + "access", + "client", + "add", + client, + "--policy", + policy, + "--generate-local-key", + "--project", + ]) + .arg(project) + .output() + .expect("add local client") +} + +fn direct_mint_token(mint: &Path, port: u16, client: &str, key: &Path) -> Output { + let token_url = format!("http://127.0.0.1:{port}/token"); + Command::new(mint) + .arg("token") + .arg("--url") + .arg(&token_url) + .arg("--audience") + .arg(&token_url) + .arg("--client-id") + .arg(client) + .arg("--key") + .arg(key) + .output() + .expect("invoke Mint token client") +} + +fn post_evidence( + port: u16, + token: &str, + requirement: &str, + purpose: &str, + selector_profile: &str, +) -> u16 { + let body = json!({ + "requestNonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "requirement": requirement, + "purpose": purpose, + "subjects": [{ + "role": "person", + "selector": { + "profile": selector_profile, + "values": {"person_id": "person-123"}, + }, + }], + }); + let body = body.to_string(); + match ureq::post(&format!("http://127.0.0.1:{port}/v1/evidence")) + .set("Authorization", &format!("Bearer {token}")) + .set("Accept", "application/jose+json") + .set("Content-Type", "application/json") + .send_string(&body) + { + Ok(response) => response.status(), + Err(ureq::Error::Status(status, _)) => status, + Err(error) => panic!("Evidence request failed before an HTTP response: {error}"), + } +} + +fn assert_source_not_called(source_probe: &TcpListener) { + match source_probe.accept() { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Ok(_) => panic!("Evidence contacted the source for an ungranted question"), + Err(error) => panic!("source call probe failed: {error}"), + } +} + +fn retry_until_success(label: &str, mut operation: impl FnMut() -> Output) -> Output { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let output = operation(); + if output.status.success() { + return output; + } + if Instant::now() >= deadline { + panic!( + "{label} did not succeed after Mint reload\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn retry_until_mint_refuses(mut operation: impl FnMut() -> Output) -> Output { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let output = operation(); + let refused = !output.status.success() + && output.stdout.is_empty() + && String::from_utf8_lossy(&output.stderr).contains("invalid_client"); + if refused { + return output; + } + if Instant::now() >= deadline { + panic!( + "Mint did not refuse revoked client A after reload\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn read_pid(path: &Path) -> u32 { + fs::read_to_string(path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())) + .trim() + .parse() + .expect("numeric PID") +} + +fn process_is_alive(pid: u32) -> bool { + Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .status() + .is_ok_and(|status| status.success()) +} + +fn required_binary(name: &str) -> PathBuf { + std::env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("set {name} for the exact lifecycle gate")) +} + +fn assert_success(output: &Output, label: &str) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn ready(url: &str, expected: Value) -> bool { + ureq::get(url) + .call() + .ok() + .and_then(|response| serde_json::from_reader::<_, Value>(response.into_reader()).ok()) + == Some(expected) +} + +fn jwks_ready() -> bool { + jwks_ready_at(8081) +} + +fn jwks_ready_at(port: u16) -> bool { + ureq::get(&format!("http://127.0.0.1:{port}/.well-known/jwks.json")) + .call() + .ok() + .and_then(|response| serde_json::from_reader::<_, Value>(response.into_reader()).ok()) + .and_then(|value| value["keys"].as_array().cloned()) + .is_some_and(|keys| { + keys.iter() + .any(|key| key["kid"] == "local-mint-signing-key-1") + }) +} + +fn unused_port_pair() -> (u16, u16) { + let first = TcpListener::bind("127.0.0.1:0").expect("reserve first port"); + let second = TcpListener::bind("127.0.0.1:0").expect("reserve second port"); + let ports = ( + first.local_addr().expect("first address").port(), + second.local_addr().expect("second address").port(), + ); + drop((first, second)); + ports +} + +fn assert_mode(path: &Path, expected: u32) { + let metadata = fs::symlink_metadata(path) + .unwrap_or_else(|error| panic!("inspect {}: {error}", path.display())); + assert_eq!( + metadata.mode() & 0o777, + expected, + "mode of {}", + path.display() + ); + assert_eq!(metadata.uid(), rustix::process::getuid().as_raw()); +} + +fn sorted_names(root: &Path) -> Vec { + let mut names = fs::read_dir(root) + .expect("directory") + .map(|entry| { + entry + .expect("entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + names.sort(); + names +} + +fn wait_unavailable(address: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if TcpListener::bind(address).is_ok() { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("{address} remained occupied"); +} + +fn wait_mint_without_clients(port: u16) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if matches!( + ureq::get(&format!("http://127.0.0.1:{port}/ready")).call(), + Err(ureq::Error::Status(503, _)) + ) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("Mint did not publish its empty-registry readiness state"); +} + +fn wait_for_failed_state(dev: &Path) { + let deadline = Instant::now() + Duration::from_secs(45); + while Instant::now() < deadline { + if let Ok(bytes) = fs::read(dev.join("state.json")) { + if let Ok(state) = serde_json::from_slice::(&bytes) { + if state["status"] == "failed" && state["failure"] == "supervisor-signal" { + return; + } + } + } + thread::sleep(Duration::from_millis(50)); + } + panic!("supervisor did not publish terminal failed state"); +} + +fn stop_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs new file mode 100644 index 000000000..87b43ecbd --- /dev/null +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -0,0 +1,924 @@ +#![cfg(unix)] + +//! `evidencectl doctor` over a real deployment project. +//! +//! Every filesystem assertion here is about a mode or an owner the Evidence +//! runtime refuses at startup. The filesystem is intentionally assembled as a +//! doctor fixture because `evidencectl new` no longer invents a runnable +//! deployment. No `evidence` binary is involved anywhere in this file: +//! `doctor` is a filesystem walk, and an adopter who cannot yet start the +//! service is exactly the one who needs it. Nothing here prints key material. + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +const SIGNING_KID: &str = "doctor-signing-key-1"; +const SECRET_FILES: [&str; 2] = ["audit-hmac-key", "subject-binding-hmac-key"]; + +const MATCHING_MINT_CONFIG: &str = r#"version: 1 +issuer: https://identity.invalid +signing: + algorithm: EdDSA + jwksPath: /.well-known/jwks.json +accessTokens: + audiences: [evidence-scaffold] + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +"#; + +#[test] +fn doctor_passes_a_frozen_project_and_leaves_the_public_key_beside_it_alone() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + + // `keygen signing` writes the public half into the secret root at 0644 by + // design. The runtime never resolves it as a secret, so doctor must not + // report it. A walk of the secret directory would; a walk of the secret + // references the bundle actually names does not. + let public_key = project.join("secrets/signing-ed25519-public.jwk.json"); + assert_eq!( + mode_of(&public_key), + 0o644, + "the scaffolded public key is no longer world-readable, so this test proves nothing" + ); + + freeze(&project); + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + output.status.success(), + "doctor failed on a correctly provisioned project:\n{stdout}{}", + stderr_of(&output) + ); + assert!( + stdout.contains("0 failed"), + "unexpected doctor summary: {stdout}" + ); + assert!( + !stdout.contains("signing-ed25519-public.jwk.json"), + "doctor reported the public key that sits beside the private one: {stdout}" + ); +} + +#[test] +fn doctor_names_every_artifact_whose_mode_the_runtime_refuses() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + freeze(&project); + + // One artifact per rule the runtime enforces, each widened past it. A + // `chmod -R` an operator runs over a project produces exactly this state. + let refused = [ + "runtime.yaml", + "bundle/evidence.yaml", + "secrets", + "secrets/audit-hmac-key", + "audit/evidence.jsonl", + ]; + fs::write(project.join("audit/evidence.jsonl"), "").expect("stage an audit chain"); + for path in refused { + set_mode(&project.join(path), 0o755); + } + + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + !output.status.success(), + "doctor passed a project the runtime would refuse:\n{stdout}" + ); + for path in refused { + assert!( + stdout.contains(path), + "doctor did not report {path}:\n{stdout}" + ); + } +} + +#[test] +fn doctor_reports_a_secret_the_bundle_references_and_the_project_does_not_hold() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + + // Every secret but the source bearer token, which the README tells an + // adopter to obtain from the source system rather than generate. Forgetting + // it is the ordinary way a project reaches this state. + freeze(&project); + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + !output.status.success(), + "doctor passed a project missing a secret the bundle references:\n{stdout}" + ); + assert!( + stdout.contains("secrets/source-bearer-token"), + "doctor did not name the missing secret:\n{stdout}" + ); +} + +#[test] +fn doctor_json_puts_one_document_on_stdout_and_the_report_on_stderr() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + + freeze(&project); + let output = doctor(&project, &["--json"]); + unfreeze(&project); + + assert!( + output.status.success(), + "doctor --json failed on a correctly provisioned project:\n{}", + stderr_of(&output) + ); + let stdout = stdout_of(&output); + let lines: Vec<&str> = stdout.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines.len(), + 1, + "stdout must carry exactly one JSON document: {stdout}" + ); + let report: serde_json::Value = serde_json::from_str(lines[0]).expect("parse the JSON report"); + assert_eq!(report["passed"], serde_json::Value::Bool(true)); + let checks = report["checks"].as_array().expect("checks array"); + assert!( + checks.iter().all(|check| check["passed"] == true), + "a check failed in the JSON report: {stdout}" + ); + assert!( + stderr_of(&output).contains("0 failed"), + "the human report did not reach stderr in JSON mode" + ); +} + +#[test] +fn doctor_checks_only_an_explicit_external_mint_config() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let external_mint = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + write_mint(&external_mint, MATCHING_MINT_CONFIG); + + // A nested document must not be discovered. The explicit path is the only + // act that pairs Evidence with Mint. + write_mint( + &project.join("mint/mint.yaml"), + &MATCHING_MINT_CONFIG.replace("https://identity.invalid", "https://nested.invalid"), + ); + + freeze(&project); + let unpaired = doctor(&project, &[]); + let paired = doctor( + &project, + &[ + "--mint-config", + external_mint.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + assert!( + unpaired.status.success(), + "doctor discovered an unrequested Mint config:\n{}{}", + stdout_of(&unpaired), + stderr_of(&unpaired) + ); + assert!( + !stdout_of(&unpaired).contains("mint compatibility"), + "unpaired doctor reported a Mint check: {}", + stdout_of(&unpaired) + ); + assert!( + paired.status.success(), + "doctor rejected matching external Mint config:\n{}{}", + stdout_of(&paired), + stderr_of(&paired) + ); + assert!( + stdout_of(&paired).contains("PASS: mint compatibility"), + "paired doctor omitted its compatibility result: {}", + stdout_of(&paired) + ); +} + +#[test] +fn doctor_rejects_an_issuer_mismatch_without_printing_its_value() { + const SENTINEL: &str = "https://credential-token-selector-source.invalid"; + + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + write_mint( + &mint_config, + &MATCHING_MINT_CONFIG.replace("https://identity.invalid", SENTINEL), + ); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); + assert!( + !output.status.success(), + "doctor accepted an issuer mismatch: {diagnostics}" + ); + assert!( + diagnostics.contains("authentication.issuer"), + "issuer mismatch did not identify its field: {diagnostics}" + ); + assert!( + !diagnostics.contains(SENTINEL), + "issuer mismatch disclosed the configured value: {diagnostics}" + ); +} + +#[test] +fn doctor_reports_every_mint_field_mismatch_without_printing_values() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + + let cases = [ + ( + "jwksPath: /.well-known/jwks.json", + "jwksPath: /credential-token-selector-source-jwks", + "authentication.jwksUri", + "/credential-token-selector-source-jwks", + ), + ( + "audiences: [evidence-scaffold]", + "audiences: [credential-token-selector-source-audience]", + "authentication.audiences", + "credential-token-selector-source-audience", + ), + ( + "algorithm: EdDSA", + "algorithm: RS256", + "authentication.algorithms", + "RS256", + ), + ( + "principal: sub", + "principal: credential_token_selector_source_principal", + "authentication.principalClaim", + "credential_token_selector_source_principal", + ), + ( + "requesterTags: evidence_tags", + "requesterTags: credential_token_selector_source_tags", + "authentication.requesterTagsClaim", + "credential_token_selector_source_tags", + ), + ( + "evidenceAudience: evidence_audience", + "evidenceAudience: credential_token_selector_source_audience_claim", + "authentication.evidenceAudienceClaim", + "credential_token_selector_source_audience_claim", + ), + ( + "grantId: evidence_grant_id", + "grantId: credential_token_selector_source_grant_id", + "authentication.grantIdClaim", + "credential_token_selector_source_grant_id", + ), + ( + "grantAuthority: evidence_authority", + "grantAuthority: credential_token_selector_source_authority", + "authentication.grantAuthorityClaim", + "credential_token_selector_source_authority", + ), + ]; + + for (original, replacement, field, sentinel) in cases { + let mismatched = replace_once(MATCHING_MINT_CONFIG, original, replacement); + write_mint(&mint_config, &mismatched); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); + assert!( + !output.status.success(), + "doctor accepted mismatch in {field}: {diagnostics}" + ); + assert!( + diagnostics.contains(field), + "mismatch did not identify {field}: {diagnostics}" + ); + assert!( + !diagnostics.contains(sentinel), + "mismatch in {field} disclosed its configured value: {diagnostics}" + ); + } +} + +#[test] +fn doctor_requires_evidence_to_admit_the_mint_access_token_type() { + const SENTINEL: &str = "credential-token-selector-source-type"; + + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + rewrite_bundle( + &project, + "tokenTypes: [at+jwt]", + &format!("tokenTypes: [{SENTINEL}]"), + ); + write_mint(&mint_config, MATCHING_MINT_CONFIG); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); + assert!(!output.status.success(), "doctor accepted {diagnostics}"); + assert!( + diagnostics.contains("authentication.tokenTypes"), + "token-type mismatch did not identify its field: {diagnostics}" + ); + assert!( + !diagnostics.contains(SENTINEL), + "token-type mismatch disclosed its configured value: {diagnostics}" + ); +} + +#[test] +fn doctor_checks_every_actor_claim_presence_combination() { + let workspace = tempfile::tempdir().expect("tempdir"); + let mint_config = workspace.path().join("mint/mint.yaml"); + let cases = [ + (None, None, true), + (Some("shared_actor"), Some("shared_actor"), true), + (Some("evidence_actor"), None, false), + (None, Some("mint_actor"), false), + (Some("evidence_actor"), Some("mint_actor"), false), + ]; + + for (index, (evidence_actor, mint_actor, expected_pass)) in cases.into_iter().enumerate() { + let project = workspace.path().join(format!("project-{index}")); + provision(&project); + provision_bearer_token(&project); + if let Some(actor) = evidence_actor { + add_evidence_actor(&project, actor); + } + let mut mint = MATCHING_MINT_CONFIG.to_owned(); + if let Some(actor) = mint_actor { + mint.push_str(&format!(" actor: {actor}\n")); + } + write_mint(&mint_config, &mint); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); + assert_eq!( + output.status.success(), + expected_pass, + "unexpected actor compatibility result: {diagnostics}" + ); + if !expected_pass { + assert!( + diagnostics.contains("authentication.actorClaim"), + "actor mismatch did not identify its field: {diagnostics}" + ); + for value in [evidence_actor, mint_actor].into_iter().flatten() { + assert!( + !diagnostics.contains(value), + "actor mismatch disclosed its configured value: {diagnostics}" + ); + } + } + } +} + +#[test] +fn doctor_accepts_set_order_supersets_custom_jwks_and_matching_actor() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + + rewrite_bundle( + &project, + "issuer: https://identity.invalid", + "issuer: https://identity.invalid/", + ); + rewrite_bundle( + &project, + "audiences: [evidence-scaffold]", + "audiences: [secondary-audience, evidence-scaffold]", + ); + rewrite_bundle( + &project, + "tokenTypes: [at+jwt]", + "tokenTypes: [application/at+jwt, at+jwt]", + ); + rewrite_bundle( + &project, + "algorithms: [EdDSA]", + "algorithms: [RS256, EdDSA]", + ); + rewrite_bundle( + &project, + "jwksUri: https://identity.invalid/.well-known/jwks.json", + "jwksUri: https://identity.invalid//custom/jwks.json", + ); + add_evidence_actor(&project, "shared_actor"); + + let mut mint = MATCHING_MINT_CONFIG + .replace( + "issuer: https://identity.invalid", + "issuer: https://identity.invalid/", + ) + .replace( + "jwksPath: /.well-known/jwks.json", + "jwksPath: /custom/jwks.json", + ) + .replace( + "audiences: [evidence-scaffold]", + "audiences: [evidence-scaffold, secondary-audience]", + ); + mint.push_str(" actor: shared_actor\n"); + write_mint(&mint_config, &mint); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + assert!( + output.status.success(), + "doctor rejected mechanically compatible sets and supersets:\n{}{}", + stdout_of(&output), + stderr_of(&output) + ); +} + +#[test] +fn doctor_applies_mint_protocol_defaults() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + let mint = MATCHING_MINT_CONFIG + .replace(" jwksPath: /.well-known/jwks.json\n", "") + .replace(" principal: sub\n", ""); + write_mint(&mint_config, &mint); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + + assert!( + output.status.success(), + "doctor did not apply Mint's JWKS and principal defaults:\n{}{}", + stdout_of(&output), + stderr_of(&output) + ); +} + +#[test] +fn doctor_json_aggregates_mismatches_and_redacts_every_value() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + + let sentinels = [ + "credential-token-selector-source-audience", + "RS256", + "credential_token_selector_source_principal", + ]; + let mint = MATCHING_MINT_CONFIG + .replace( + "audiences: [evidence-scaffold]", + &format!("audiences: [{}]", sentinels[0]), + ) + .replace("algorithm: EdDSA", &format!("algorithm: {}", sentinels[1])) + .replace("principal: sub", &format!("principal: {}", sentinels[2])); + write_mint(&mint_config, &mint); + + freeze(&project); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + "--json", + ], + ); + unfreeze(&project); + + assert!(!output.status.success(), "doctor accepted three mismatches"); + let stdout = stdout_of(&output); + let report: serde_json::Value = serde_json::from_str(stdout.trim()).expect("doctor JSON"); + let check = report["checks"] + .as_array() + .expect("checks") + .iter() + .find(|check| check["name"] == "mint compatibility") + .expect("Mint compatibility check"); + assert_eq!( + check["findings"].as_array().expect("findings").len(), + 3, + "doctor must report every mechanical mismatch in one run: {stdout}" + ); + + let diagnostics = format!("{stdout}{}", stderr_of(&output)); + for field in [ + "authentication.audiences", + "authentication.algorithms", + "authentication.principalClaim", + ] { + assert!( + diagnostics.contains(field), + "aggregate diagnostics omitted {field}: {diagnostics}" + ); + } + for sentinel in sentinels { + assert!( + !diagnostics.contains(sentinel), + "JSON or human diagnostics disclosed {sentinel}: {diagnostics}" + ); + } +} + +#[test] +fn doctor_redacts_invalid_paired_documents() { + const SENTINEL: &str = "credential-token-selector-source-invalid-document"; + + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_config = workspace.path().join("mint/mint.yaml"); + provision(&project); + provision_bearer_token(&project); + write_mint(&mint_config, &format!("issuer: {SENTINEL}\nsigning: [\n")); + + freeze(&project); + let invalid_mint = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + let mint_diagnostics = format!("{}{}", stdout_of(&invalid_mint), stderr_of(&invalid_mint)); + assert!(!invalid_mint.status.success()); + assert!( + mint_diagnostics.contains("paired Mint compatibility fields are missing or invalid"), + "invalid Mint document lacked a stable diagnostic: {mint_diagnostics}" + ); + assert!( + !mint_diagnostics.contains(SENTINEL), + "Mint decoder error disclosed an authored value: {mint_diagnostics}" + ); + + write_mint(&mint_config, MATCHING_MINT_CONFIG); + rewrite_bundle( + &project, + "issuer: https://identity.invalid", + &format!("issuer: [{SENTINEL}]"), + ); + freeze(&project); + let invalid_evidence = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + unfreeze(&project); + let evidence_diagnostics = format!( + "{}{}", + stdout_of(&invalid_evidence), + stderr_of(&invalid_evidence) + ); + assert!(!invalid_evidence.status.success()); + assert!( + evidence_diagnostics + .contains("authentication paired-Mint compatibility fields are missing or invalid"), + "invalid Evidence binding lacked a stable diagnostic: {evidence_diagnostics}" + ); + assert!( + !evidence_diagnostics.contains(SENTINEL), + "Evidence decoder error disclosed an authored value: {evidence_diagnostics}" + ); +} + +#[test] +fn doctor_pairing_is_read_only_and_does_not_inspect_mint_authority_material() { + const SENTINEL: &str = "credential-token-selector-source-authority-material"; + + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + let mint_root = workspace.path().join("mint"); + let mint_config = mint_root.join("mint.yaml"); + provision(&project); + provision_bearer_token(&project); + let mut mint = MATCHING_MINT_CONFIG.replace( + " jwksPath: /.well-known/jwks.json", + &format!(" activeKeyFile: secrets/{SENTINEL}\n jwksPath: /.well-known/jwks.json"), + ); + mint.push_str("clients:\n directory: clients\n"); + write_mint(&mint_config, &mint); + fs::create_dir_all(mint_root.join("secrets")).expect("Mint secrets"); + fs::create_dir_all(mint_root.join("clients")).expect("Mint clients"); + fs::write(mint_root.join("secrets").join(SENTINEL), SENTINEL).expect("private key sentinel"); + fs::write(mint_root.join("clients/client.yaml"), SENTINEL).expect("client sentinel"); + + freeze(&project); + let before = tree_snapshot(workspace.path()); + let output = doctor( + &project, + &[ + "--mint-config", + mint_config.to_str().expect("Mint config path"), + ], + ); + let after = tree_snapshot(workspace.path()); + unfreeze(&project); + + assert!( + output.status.success(), + "doctor inspected unrelated Mint authority material:\n{}{}", + stdout_of(&output), + stderr_of(&output) + ); + assert!( + before == after, + "doctor changed a deployment artifact; snapshot contents are withheld" + ); + let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); + assert!( + !diagnostics.contains(SENTINEL), + "doctor disclosed Mint authority material: {diagnostics}" + ); +} + +#[test] +fn doctor_help_exposes_the_explicit_mint_config_option() { + let output = evidencectl(&["doctor", "--help"]); + assert!(output.status.success(), "doctor --help failed"); + assert!( + stdout_of(&output).contains("--mint-config "), + "doctor help omitted --mint-config: {}", + stdout_of(&output) + ); +} + +/// Assemble the smallest filesystem fixture that names every kind of artifact +/// doctor checks, then generate the private material through the public CLI. +fn provision(project: &Path) { + fs::create_dir_all(project.join("bundle")).expect("bundle directory"); + fs::create_dir_all(project.join("audit")).expect("audit directory"); + fs::write( + project.join("runtime.yaml"), + "bundleDirectory: bundle\nsecretProviders:\n file:\n root: secrets\nauditStorage:\n path: audit/evidence.jsonl\n", + ) + .expect("runtime fixture"); + fs::write( + project.join("bundle/evidence.yaml"), + r#"authentication: + kind: oidc-access-token + issuer: https://identity.invalid + audiences: [evidence-scaffold] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +signing: secret:file/signing-ed25519-private-jwk +audit: secret:file/audit-hmac-key +subjectBinding: secret:file/subject-binding-hmac-key +sourceToken: secret:file/source-bearer-token +"#, + ) + .expect("bundle fixture"); + + let secrets = project.join("secrets"); + run_ok(&[ + "keygen", + "signing", + "--out-dir", + secrets.to_str().expect("secret root"), + "--kid", + SIGNING_KID, + ]); + for name in SECRET_FILES { + let out = secrets.join(name); + run_ok(&["keygen", "secret", "--out", out.to_str().expect("secret")]); + } +} + +fn write_mint(path: &Path, document: &str) { + fs::create_dir_all(path.parent().expect("Mint parent")).expect("Mint directory"); + fs::write(path, document).expect("Mint configuration"); +} + +fn rewrite_bundle(project: &Path, original: &str, replacement: &str) { + let path = project.join("bundle/evidence.yaml"); + let document = fs::read_to_string(&path).expect("Evidence configuration"); + fs::write(&path, replace_once(&document, original, replacement)) + .expect("rewrite Evidence configuration"); +} + +fn add_evidence_actor(project: &Path, actor: &str) { + rewrite_bundle( + project, + " grantAuthorityClaim: evidence_authority", + &format!(" grantAuthorityClaim: evidence_authority\n actorClaim: {actor}"), + ); +} + +fn replace_once(document: &str, original: &str, replacement: &str) -> String { + assert_eq!( + document.matches(original).count(), + 1, + "fixture must contain exactly one {original:?}" + ); + document.replacen(original, replacement, 1) +} + +fn tree_snapshot(root: &Path) -> Vec<(String, u32, Vec)> { + let mut snapshot = Vec::new(); + collect_tree_snapshot(root, root, &mut snapshot); + snapshot +} + +fn collect_tree_snapshot(root: &Path, path: &Path, snapshot: &mut Vec<(String, u32, Vec)>) { + let metadata = fs::symlink_metadata(path).expect("snapshot metadata"); + let relative = path + .strip_prefix(root) + .expect("snapshot root") + .display() + .to_string(); + let mode = metadata.permissions().mode() & 0o7777; + if metadata.is_dir() { + snapshot.push((relative, mode, Vec::new())); + let mut entries: Vec<_> = fs::read_dir(path) + .expect("snapshot directory") + .map(|entry| entry.expect("snapshot entry").path()) + .collect(); + entries.sort(); + for entry in entries { + collect_tree_snapshot(root, &entry, snapshot); + } + } else { + snapshot.push((relative, mode, fs::read(path).expect("snapshot file"))); + } +} + +/// The one secret the scaffolded source needs and `provision` leaves out, so a +/// test can choose whether the project is complete. +fn provision_bearer_token(project: &Path) { + let out = project.join("secrets/source-bearer-token"); + run_ok(&["keygen", "token", "--out", out.to_str().expect("token")]); +} + +fn doctor(project: &Path, extra: &[&str]) -> Output { + let mut arguments = vec![ + "doctor", + "--project", + project.to_str().expect("project path"), + ]; + arguments.extend_from_slice(extra); + evidencectl(&arguments) +} + +fn run_ok(arguments: &[&str]) { + let output = evidencectl(arguments); + assert!( + output.status.success(), + "evidencectl {} failed: {}", + arguments[0], + stderr_of(&output) + ); +} + +fn evidencectl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +fn mode_of(path: &Path) -> u32 { + fs::symlink_metadata(path) + .expect("artifact metadata") + .permissions() + .mode() + & 0o7777 +} + +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("setting a mode"); +} + +/// The documented freeze: no write bits anywhere in the bundle, and a read-only +/// runtime file. Evidence refuses a deployment input it could write. +fn freeze(project: &Path) { + set_tree_mode(&project.join("bundle"), 0o555, 0o444); + set_mode(&project.join("runtime.yaml"), 0o444); +} + +/// Restore write permissions so the temporary directory can be removed. +fn unfreeze(project: &Path) { + set_tree_mode(&project.join("bundle"), 0o755, 0o644); + set_mode(&project.join("runtime.yaml"), 0o644); + set_mode(&project.join("secrets"), 0o700); +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + let metadata = fs::symlink_metadata(path).expect("tree entry"); + if metadata.is_dir() { + set_mode(path, 0o755); + for entry in fs::read_dir(path).expect("reading a directory") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + set_mode(path, directory_mode); + } else { + set_mode(path, file_mode); + } +} diff --git a/crates/registry-evidencectl/tests/fixtures.rs b/crates/registry-evidencectl/tests/fixtures.rs new file mode 100644 index 000000000..9f57d784b --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures.rs @@ -0,0 +1,542 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Build a minimal deployment project: `runtime.yaml` at the root and a +/// `bundle/evidence.yaml` whose requirements reference the given +/// bundle-relative fixture paths. The driver only ever parses this file to +/// enumerate fixtures, so its other fields are placeholders. +fn write_project(root: &Path, fixture_paths: &[&str]) -> PathBuf { + let project = root.join("project"); + fs::create_dir_all(project.join("bundle")).expect("create bundle dir"); + fs::write(project.join("runtime.yaml"), b"placeholder: true\n").expect("write runtime.yaml"); + + let mut requirements = String::new(); + for (index, fixture_path) in fixture_paths.iter().enumerate() { + requirements.push_str(&format!( + " - id: urn:example:fixture:requirement:{index}\n fixtures: {fixture_path}\n" + )); + } + let evidence_yaml = format!("version: 1\nrequirements:\n{requirements}"); + fs::write(project.join("bundle").join("evidence.yaml"), evidence_yaml) + .expect("write bundle evidence.yaml"); + project +} + +/// Write a stub `evidence` binary that: +/// - appends its argv (one argument per line, `===` between invocations) to +/// the file named by `$ARGV_LOG`; +/// - exits 1 with a fixed diagnostic on stderr when its step name equals +/// `$FAIL_STEP` (`check`, or `evaluate:`), and exits 0 +/// otherwise; +/// - prints the real `evidence evaluate` summary line, with `$CASES` cases, +/// when `$CASES` is set and the step is an evaluation. +fn write_stub_evidence(dir: &Path) -> PathBuf { + let path = dir.join("evidence"); + let script = r#"#!/bin/sh +set -eu + +for arg in "$@"; do + printf '%s\n' "$arg" >> "$ARGV_LOG" +done +printf '===\n' >> "$ARGV_LOG" + +fixture="" +prev="" +for arg in "$@"; do + if [ "$prev" = "--fixture" ]; then + fixture="$arg" + fi + prev="$arg" +done + +step="check" +for arg in "$@"; do + case "$arg" in + evaluate) step="evaluate" ;; + esac +done +if [ -n "$fixture" ]; then + step="evaluate:$fixture" +fi + +if [ "$step" = "${FAIL_STEP:-}" ]; then + printf 'stub failure for %s\n' "$step" >&2 + exit 1 +fi + +printf 'stub ok for %s\n' "$step" +if [ -n "$fixture" ] && [ -n "${CASES:-}" ]; then + printf 'Evidence fixture passed (%s evaluated cases)\n' "$CASES" +fi +exit 0 +"#; + fs::write(&path, script).expect("write stub evidence script"); + let mut permissions = fs::metadata(&path).expect("stat stub").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod stub"); + path +} + +/// Parse the argv log into one `Vec` per invocation, in call order. +fn read_argv_log(path: &Path) -> Vec> { + let contents = fs::read_to_string(path).unwrap_or_default(); + contents + .split("===\n") + .filter(|block| !block.is_empty()) + .map(|block| block.lines().map(str::to_owned).collect()) + .collect() +} + +#[test] +fn happy_path_runs_check_then_each_fixture_and_reports_pass() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: check"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/b.yaml"), "{stdout}"); + assert!(stdout.contains("3 passed, 0 failed"), "{stdout}"); + + let runtime_path = project.join("runtime.yaml"); + let runtime_path = runtime_path.to_str().expect("runtime path is utf8"); + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations, + vec![ + vec!["--runtime", runtime_path, "check"], + vec![ + "--runtime", + runtime_path, + "evaluate", + "--fixture", + "fixtures/a.yaml" + ], + vec![ + "--runtime", + runtime_path, + "evaluate", + "--fixture", + "fixtures/b.yaml" + ], + ], + "unexpected evidence invocations" + ); +} + +#[test] +fn check_failure_short_circuits_before_any_fixture_evaluation() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "check") + .output() + .expect("run evidencectl"); + + assert!( + !output.status.success(), + "expected nonzero exit on check failure" + ); + let stdout = stdout_of(&output); + assert!(stdout.contains("FAIL: check"), "{stdout}"); + assert!(stdout.contains("stub failure for check"), "{stdout}"); + assert!(stdout.contains("0 passed, 1 failed"), "{stdout}"); + assert!( + !stdout.contains("fixtures/a.yaml") && !stdout.contains("fixtures/b.yaml"), + "no fixture should have been reported: {stdout}" + ); + + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations.len(), + 1, + "evaluate must never be invoked once check fails: {invocations:?}" + ); +} + +#[test] +fn one_failing_fixture_is_reported_with_its_stderr_and_the_rest_still_run() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!( + !output.status.success(), + "expected nonzero exit on fixture failure" + ); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: check"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("FAIL: fixtures/b.yaml"), "{stdout}"); + assert!( + stdout.contains("stub failure for evaluate:fixtures/b.yaml"), + "failing step's stderr must be included: {stdout}" + ); + assert!(stdout.contains("2 passed, 1 failed"), "{stdout}"); + + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations.len(), + 3, + "every fixture must still run despite one failure: {invocations:?}" + ); +} + +#[test] +fn json_output_is_one_parseable_document_on_stdout_with_expected_pass_fail_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .arg("--json") + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stdout = stdout_of(&output); + let stdout_lines: Vec<&str> = stdout.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!( + stdout_lines.len(), + 1, + "stdout must carry exactly one JSON document: {stdout}" + ); + + let report: serde_json::Value = + serde_json::from_str(stdout_lines[0]).expect("parse JSON report"); + assert_eq!(report["passed"], serde_json::Value::Bool(false)); + assert_eq!(report["check"]["passed"], serde_json::Value::Bool(true)); + let fixtures = report["fixtures"].as_array().expect("fixtures array"); + assert_eq!(fixtures.len(), 2); + assert_eq!(fixtures[0]["path"], "fixtures/a.yaml"); + assert_eq!(fixtures[0]["passed"], serde_json::Value::Bool(true)); + assert_eq!(fixtures[1]["path"], "fixtures/b.yaml"); + assert_eq!(fixtures[1]["passed"], serde_json::Value::Bool(false)); + assert!(fixtures[1]["stderr"] + .as_str() + .expect("failing fixture carries stderr") + .contains("stub failure for evaluate:fixtures/b.yaml")); + + // Human diagnostics belong on stderr in JSON mode, not on stdout. + let stderr = stderr_of(&output); + assert!(stderr.contains("FAIL: fixtures/b.yaml"), "{stderr}"); +} + +/// The step counts measure artifacts, and a reader takes the summary line for +/// coverage. Two fixture files holding seven cases each is a fourteen-case run, +/// and reporting it as `3 passed` says nothing about how much was exercised. +#[test] +fn the_summary_totals_the_cases_each_fixture_evaluated() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("CASES", "7") + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!( + stdout.contains("PASS: fixtures/a.yaml (7 cases)"), + "{stdout}" + ); + assert!( + stdout.contains("3 passed, 0 failed (14 cases evaluated)"), + "{stdout}" + ); + + // A failing fixture evaluated nothing this run can count, so the total + // reports what actually ran rather than an estimate of what would have. + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .arg("--json") + .env("ARGV_LOG", &argv_log) + .env("CASES", "7") + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let report: serde_json::Value = + serde_json::from_str(stdout_of(&output).trim()).expect("parse JSON report"); + assert_eq!(report["evaluated_cases"], serde_json::json!(7)); + let fixtures = report["fixtures"].as_array().expect("fixtures array"); + assert_eq!(fixtures[0]["evaluated_cases"], serde_json::json!(7)); + assert!( + fixtures[1].get("evaluated_cases").is_none(), + "a failed fixture reports no count: {}", + fixtures[1] + ); +} + +/// An `evidence` that reports no count at all leaves the total short rather +/// than guessed. The driver makes no semantic decision of its own, and a +/// fabricated case count would be exactly that. +#[test] +fn an_unrecognized_summary_line_is_counted_as_nothing() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("CASES") + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!( + stdout.contains("2 passed, 0 failed (0 cases evaluated)"), + "{stdout}" + ); + assert!( + !stdout.contains("cases)\n") || !stdout.contains("PASS: fixtures/a.yaml ("), + "an uncounted fixture must not claim a count: {stdout}" + ); +} + +#[test] +fn missing_runtime_yaml_errors_clearly_without_invoking_evidence() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(&project).expect("create project dir"); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!(stderr.contains("runtime.yaml"), "{stderr}"); + assert!(!argv_log.exists(), "evidence must never be invoked"); +} + +#[test] +fn unresolvable_evidence_binary_errors_clearly() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let missing_bin = dir.path().join("nowhere").join("evidence"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&missing_bin) + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!(stderr.contains("evidence binary not found"), "{stderr}"); + assert!( + stderr.contains(missing_bin.to_str().expect("utf8 path")), + "{stderr}" + ); +} + +#[test] +fn fixtures_are_discovered_at_a_relative_bundle_directory_named_in_runtime_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(project.join("custom-bundle")).expect("create custom bundle dir"); + fs::write( + project.join("runtime.yaml"), + b"placeholder: true\nbundleDirectory: custom-bundle\n", + ) + .expect("write runtime.yaml"); + fs::write( + project.join("custom-bundle").join("evidence.yaml"), + "version: 1\nrequirements:\n - id: urn:example:fixture:requirement:0\n fixtures: fixtures/a.yaml\n", + ) + .expect("write bundle evidence.yaml"); + + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("2 passed, 0 failed"), "{stdout}"); +} + +#[test] +fn fixtures_are_discovered_at_an_absolute_bundle_directory_named_in_runtime_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(&project).expect("create project dir"); + let bundle_directory = dir.path().join("elsewhere").join("bundle"); + fs::create_dir_all(&bundle_directory).expect("create bundle dir"); + + let runtime_yaml = format!( + "placeholder: true\nbundleDirectory: {}\n", + bundle_directory.to_str().expect("bundle directory is utf8") + ); + fs::write(project.join("runtime.yaml"), runtime_yaml).expect("write runtime.yaml"); + fs::write( + bundle_directory.join("evidence.yaml"), + "version: 1\nrequirements:\n - id: urn:example:fixture:requirement:0\n fixtures: fixtures/a.yaml\n", + ) + .expect("write bundle evidence.yaml"); + + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("2 passed, 0 failed"), "{stdout}"); +} + +#[test] +fn a_non_executable_evidence_on_path_is_skipped_with_a_clear_resolution_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + + let path_dir = dir.path().join("path-entry"); + fs::create_dir(&path_dir).expect("create path entry dir"); + let candidate = path_dir.join("evidence"); + fs::write(&candidate, b"#!/bin/sh\nexit 0\n").expect("write candidate"); + let mut permissions = fs::metadata(&candidate) + .expect("stat candidate") + .permissions(); + permissions.set_mode(0o644); + fs::set_permissions(&candidate, permissions).expect("chmod candidate non-executable"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .env("PATH", &path_dir) + .env_remove("EVIDENCE_BIN") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("evidence binary not found"), + "a non-executable candidate on PATH must be skipped: {stderr}" + ); +} + +#[test] +fn evidence_bin_env_var_is_used_when_the_flag_is_omitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .env("EVIDENCE_BIN", &stub) + .env("ARGV_LOG", &argv_log) + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let invocations = read_argv_log(&argv_log); + assert_eq!(invocations.len(), 2, "check plus one fixture"); +} diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml new file mode 100644 index 000000000..53db2c96b --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: + title: Escaping + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + "tags/primary": + type: string + "tilde~name": + type: string diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml new file mode 100644 index 000000000..9d8844670 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: External Ref + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: 'external.yaml#/components/schemas/Record' diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml new file mode 100644 index 000000000..134ad8fe0 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml @@ -0,0 +1,49 @@ +openapi: 3.0.3 +info: + title: Implicit Types + version: "1.0.0" +paths: + # The collection-wrapper shape published by several large registry APIs: + # `properties` with no `type: object` beside it. + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + properties: + pager: + properties: + page: + type: integer + minimum: 1 + maximum: 1000 + records: + items: + properties: + id: + type: string + minLength: 11 + maxLength: 11 + maxItems: 50 + # A node carrying neither a type nor a structural keyword stays untyped: + # nothing here guesses at a scalar. + /opaque: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + known: + type: string + minLength: 1 + maxLength: 8 + anything: + description: no type, no properties, no items diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml new file mode 100644 index 000000000..02618aeb0 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml @@ -0,0 +1,57 @@ +openapi: 3.1.0 +info: + title: Nullable Unions + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + # The idiom generated by zod-to-openapi and friends: an + # optional scalar written as a two-member anyOf against + # `null` rather than as the 3.1 type pair. + note: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + # The same idiom spelled with `oneOf`, with the null member + # first, and carrying a description on the union node. + count: + description: how many were seen + oneOf: + - type: "null" + - type: integer + minimum: 0 + maximum: 99 + # A 3.1 type pair written null-first. + reversedPair: + type: ["null", string] + minLength: 1 + maxLength: 8 + # Nullable containers collapse the same way, so their members + # stay selectable. + parent: + anyOf: + - type: object + additionalProperties: false + properties: + id: + type: string + minLength: 1 + maxLength: 36 + - type: "null" + # A union of two real types is not a nullable spelling and is + # left alone for the flattener to skip. + either: + anyOf: + - type: string + - type: integer diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml new file mode 100644 index 000000000..d6119fc0d --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml @@ -0,0 +1,90 @@ +openapi: 3.0.3 +info: + title: Paging parameter shapes + version: "1.0.0" +paths: + # A page index and a page size side by side. The index bounds how many pages + # exist, not how many items a response carries, so it must not set an array + # bound; the size does. + /records: + get: + summary: Search records + parameters: + - name: page + in: query + schema: + type: integer + maximum: 10000 + - name: pageSize + in: query + schema: + type: integer + maximum: 50 + responses: + "200": + description: A page of records + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + # Two genuine size ceilings. The smaller one is the one a response can + # actually reach. + /events: + get: + summary: Search events + parameters: + - name: per_page + in: query + schema: + type: integer + maximum: 25 + - name: limit + in: query + schema: + type: integer + maximum: 200 + responses: + "200": + description: A page of events + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + # Names that merely contain a matching word without naming a page size. + /reports: + get: + summary: Search reports + parameters: + - name: pageToken + in: query + schema: + type: string + - name: fileSizeCeiling + in: query + schema: + type: integer + maximum: 9000 + - name: rateLimitBurst + in: query + schema: + type: integer + maximum: 8000 + responses: + "200": + description: A page of reports + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' +components: + schemas: + RecordSearchResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml new file mode 100644 index 000000000..7685c7870 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: + title: Example Records API + version: "1.0.0" +servers: + - url: https://records.example.test/api +paths: + /records: + get: + summary: Search records + parameters: + - name: pageSize + in: query + schema: + type: integer + maximum: 100 + responses: + "200": + description: A page of records + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + post: + summary: Create a record + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + delete: + summary: Delete every record + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Record' +components: + schemas: + RecordSearchResponse: + type: object + additionalProperties: false + required: [total, results] + properties: + total: + type: integer + recordedOn: + type: string + format: date-time + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/Record' + Record: + type: object + additionalProperties: false + properties: + trackingId: + type: string + status: + type: string + enum: [open, closed] + notes: + type: string + nullable: true diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json new file mode 100644 index 000000000..8eb16b074 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Example Records API", "version": "1.0.0" }, + "paths": { + "/records/{id}": { + "get": { + "summary": "Get one record", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "maximum": 50 } } + ], + "responses": { + "200": { + "description": "One record", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["trackingId", "status"], + "properties": { + "trackingId": { "type": "string" }, + "status": { "type": ["string", "null"] }, + "total": { "type": ["integer", "null"] } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { "type": "object", "additionalProperties": false, "properties": {} } + } + } + } + } + } + } + } +} diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml new file mode 100644 index 000000000..d78fd4bfd --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Recursive Tree + version: "1.0.0" +paths: + /nodes: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Node' +components: + schemas: + Node: + type: object + additionalProperties: false + properties: + id: + type: string + minLength: 1 + maxLength: 36 + label: + type: string + minLength: 1 + maxLength: 64 + children: + type: array + maxItems: 10 + items: + $ref: '#/components/schemas/Node' diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml new file mode 100644 index 000000000..7c314d754 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.3 +info: + title: Ref Cycle + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/A' +components: + schemas: + A: + type: object + additionalProperties: false + properties: + child: + $ref: '#/components/schemas/B' + B: + type: object + additionalProperties: false + properties: + parent: + $ref: '#/components/schemas/A' diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml new file mode 100644 index 000000000..1f66819a2 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.3 +info: + title: Unsupported Constructs + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + trackingId: + type: string + choice: + oneOf: + - type: string + - type: integer + wildcard: + type: object + properties: {} + additionalProperties: + type: string + simpleAllOf: + allOf: + - type: string + format: date + missingItems: + type: array + freeform: + type: object + multiType: + type: [string, integer] diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml new file mode 100644 index 000000000..daac38ccb --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml @@ -0,0 +1,10 @@ +openapi: "2.0" +info: + title: Swagger, Not OpenAPI 3 + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok diff --git a/crates/registry-evidencectl/tests/fixtures/samples/canary.json b/crates/registry-evidencectl/tests/fixtures/samples/canary.json new file mode 100644 index 000000000..98fc13833 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/canary.json @@ -0,0 +1,3 @@ +{ + "status": "CANARY-DO-NOT-LEAK-9f31" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/escaping.json b/crates/registry-evidencectl/tests/fixtures/samples/escaping.json new file mode 100644 index 000000000..fb1bee406 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/escaping.json @@ -0,0 +1,4 @@ +{ + "a/b": "slash-key", + "a~b": "tilde-key" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json b/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json new file mode 100644 index 000000000..e21018e69 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json @@ -0,0 +1,7 @@ +{ + "records": [ + { "priority": 3 }, + { "priority": -7 }, + { "priority": 12 } + ] +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json b/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json new file mode 100644 index 000000000..7d51b22d2 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json @@ -0,0 +1,15 @@ +{ + "total": 42, + "results": [ + { + "trackingId": "abc-123", + "status": "open", + "tags": ["a", "b", "c"] + }, + { + "trackingId": "def-456-longer", + "status": "closed", + "tags": ["x"] + } + ] +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json b/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json new file mode 100644 index 000000000..20fcd3862 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json @@ -0,0 +1,4 @@ +{ + "recordedOn": null, + "status": "active" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/unicode.json b/crates/registry-evidencectl/tests/fixtures/samples/unicode.json new file mode 100644 index 000000000..ea6ac7dc6 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/unicode.json @@ -0,0 +1,4 @@ +{ + "status": "héllo", + "note": "日本語" +} diff --git a/crates/registry-evidencectl/tests/install_script.rs b/crates/registry-evidencectl/tests/install_script.rs new file mode 100644 index 000000000..b2eabd827 --- /dev/null +++ b/crates/registry-evidencectl/tests/install_script.rs @@ -0,0 +1,603 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use tempfile::TempDir; + +const TEST_VERSION: &str = "v9.8.7"; +const BINARIES: [&str; 3] = ["evidence", "evidencectl", "mint"]; + +#[cfg(unix)] +#[test] +fn installer_refuses_to_run_without_a_pinned_release() { + let fixture = InstallerFixture::new(); + let output = fixture.command_without_version().output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("EVIDENCECTL_VERSION"), "stderr: {stderr}"); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn installer_rejects_noncanonical_release_tags() { + for tag in ["9.8.7", "v9.8", "latest", "v09.8.7", "v9.8.7-rc1"] { + let fixture = InstallerFixture::for_release(tag); + let output = fixture.run(); + assert!(!output.status.success(), "tag {tag} must be refused"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("non-canonical"), + "tag {tag} stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); + } +} + +#[cfg(unix)] +#[test] +fn installer_help_describes_the_toolset_and_verification_contract() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command_without_version(); + command.arg("--help"); + let output = command.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "evidence runtime", + "evidencectl adopter", + "mint token issuer", + "curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/evidencectl-install.sh | bash", + "SHA256SUMS", + "EVIDENCECTL_ASSET_DIR", + "release/VERIFY.md", + ] { + assert!(stdout.contains(expected), "help must mention {expected}"); + } +} + +#[cfg(unix)] +#[test] +fn versioned_installer_asset_selects_its_own_release_without_an_override() { + let fixture = InstallerFixture::new(); + let versioned = fixture + .temp_path() + .join(format!("evidencectl-{TEST_VERSION}-install.sh")); + fs::copy(installer_path(), &versioned).unwrap(); + let output = fixture.command_for(&versioned, false).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); +} + +#[cfg(unix)] +#[test] +fn released_installer_supports_the_conventional_curl_pipe() { + let fixture = InstallerFixture::new(); + let rendered = fixture.rendered_installer(); + let output = fixture.command_from_stdin(&rendered).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); +} + +#[cfg(unix)] +#[test] +fn released_installer_rejects_a_mismatched_release_override_from_stdin() { + let fixture = InstallerFixture::new(); + let rendered = fixture.rendered_installer(); + let mut command = fixture.command_from_stdin(&rendered); + command.env("EVIDENCECTL_VERSION", "v1.2.3"); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Refusing a release override"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn released_installer_rejects_a_filename_that_names_another_release() { + let fixture = InstallerFixture::new(); + let rendered = fixture.rendered_installer(); + let mismatched = fixture.temp_path().join("evidencectl-v1.2.3-install.sh"); + fs::rename(rendered, &mismatched).unwrap(); + let output = fixture.command_for(&mismatched, false).output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("embedded release does not match its filename"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn versioned_installer_asset_rejects_a_mismatched_release_override() { + let fixture = InstallerFixture::new(); + let versioned = fixture + .temp_path() + .join(format!("evidencectl-{TEST_VERSION}-install.sh")); + fs::copy(installer_path(), &versioned).unwrap(); + let mut command = fixture.command_for(&versioned, false); + command.env("EVIDENCECTL_VERSION", "v1.2.3"); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Refusing a release override"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn installer_checksum_verifies_and_installs_all_three_binaries() { + let fixture = InstallerFixture::new(); + let output = fixture.run(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Integrity checks passed"), + "stdout: {stdout}" + ); + assert!( + stdout.contains("Authenticity check not performed"), + "stdout: {stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn verified_local_asset_mode_installs_without_network_downloads() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.env("EVIDENCECTL_ASSET_DIR", fixture.release_dir()); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); + assert!( + !fixture.fake_curl_log().exists(), + "asset-dir mode must not download" + ); +} + +#[cfg(unix)] +#[test] +fn unsupported_platform_fails_before_download_without_a_partial_install() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.env("FAKE_UNAME_S", "SunOS"); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No prebuilt Evidence toolset asset"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); + assert!(!fixture.install_dir().exists(), "nothing may be installed"); +} + +#[cfg(unix)] +#[test] +fn missing_checksum_entry_refuses_the_whole_install() { + let fixture = InstallerFixture::new(); + fixture.rewrite_sums_without("evidencectl"); + let output = fixture.run(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("SHA256SUMS has no entry"), + "stderr: {stderr}" + ); + fixture.assert_nothing_installed(); +} + +#[cfg(unix)] +#[test] +fn checksum_failure_preserves_the_existing_toolset() { + let fixture = InstallerFixture::new(); + fixture.preinstall_previous_toolset(); + fixture.corrupt_release_asset("mint"); + let output = fixture.run(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Checksum verification failed"), + "stderr: {stderr}" + ); + fixture.assert_previous_toolset_intact(); +} + +#[cfg(unix)] +#[test] +fn partial_replacement_rolls_back_the_previous_toolset() { + let fixture = InstallerFixture::new(); + fixture.preinstall_previous_toolset(); + let output = fixture.run_with_second_mv_failure(); + assert!(!output.status.success()); + fixture.assert_previous_toolset_intact(); +} + +// macOS ships bash 3.2, so `/usr/bin/env bash` finds a shell without +// associative arrays, `mapfile`, or case conversion on any Mac that has no +// newer bash installed. The installer advertises macOS arm64, and the runners +// that execute this suite carry bash 5, so the portable-construct guard has to +// be a property of the source text rather than of the interpreter under test. +#[cfg(unix)] +#[test] +fn installer_avoids_shell_constructs_stock_macos_bash_cannot_parse() { + let source = fs::read_to_string(installer_path()).unwrap(); + for (construct, describe) in [ + ("declare -A", "associative arrays"), + ("local -A", "associative arrays"), + ("mapfile", "mapfile"), + ("readarray", "readarray"), + ("${!", "indirect or key expansion"), + (",,}", "lowercase expansion"), + ("^^}", "uppercase expansion"), + ] { + assert!( + !source.contains(construct), + "install.sh uses {describe} ('{construct}'), which bash 3.2 cannot parse" + ); + } +} + +#[cfg(unix)] +#[test] +fn installer_installs_under_stock_macos_bash() { + let Some(bash) = stock_macos_bash() else { + // Linux runners have no bash 3.2 to borrow. The construct guard above + // is what protects this path there. + return; + }; + let fixture = InstallerFixture::new(); + let output = reinterpret(fixture.command(), &bash).output().unwrap(); + assert!( + output.status.success(), + "bash 3.2 install failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); +} + +#[cfg(unix)] +#[test] +fn stock_macos_bash_rollback_restores_the_previous_toolset() { + let Some(bash) = stock_macos_bash() else { + return; + }; + let fixture = InstallerFixture::new(); + fixture.preinstall_previous_toolset(); + let output = reinterpret(fixture.mv_failure_command(), &bash) + .output() + .unwrap(); + assert!(!output.status.success()); + fixture.assert_previous_toolset_intact(); +} + +#[cfg(unix)] +struct InstallerFixture { + _temp: TempDir, + fake_bin: PathBuf, + release_dir: PathBuf, + install_dir: PathBuf, + version: String, +} + +#[cfg(unix)] +impl InstallerFixture { + fn new() -> Self { + Self::for_release(TEST_VERSION) + } + + fn for_release(version: &str) -> Self { + let temp = TempDir::new().unwrap(); + let fake_bin = temp.path().join("fake-bin"); + let release_dir = temp.path().join("release"); + let install_dir = temp.path().join("install"); + fs::create_dir_all(&fake_bin).unwrap(); + fs::create_dir_all(&release_dir).unwrap(); + write_executable( + &fake_bin.join("curl"), + r#"#!/usr/bin/env bash +set -euo pipefail +url="" +dest="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [[ -n "${FAKE_CURL_LOG:-}" ]]; then + printf '%s\n' "$url" >> "$FAKE_CURL_LOG" +fi +cp "${FAKE_RELEASE_DIR}/${url##*/}" "$dest" +"#, + ); + write_executable( + &fake_bin.join("uname"), + r#"#!/usr/bin/env bash +case "${1:-}" in + -s) printf '%s\n' "${FAKE_UNAME_S:-Linux}" ;; + -m) printf '%s\n' "${FAKE_UNAME_M:-x86_64}" ;; + *) exit 1 ;; +esac +"#, + ); + let fixture = Self { + _temp: temp, + fake_bin, + release_dir, + install_dir, + version: version.to_string(), + }; + fixture.write_release_assets(); + fixture + } + + fn write_release_assets(&self) { + let mut checksums = Vec::new(); + for binary in BINARIES { + let asset = self.asset_name(binary); + let path = self.release_dir.join(&asset); + fs::write(&path, format!("{binary} release binary\n")).unwrap(); + checksums.push(format!("{} {}\n", sha256(&path), asset)); + } + fs::write(self.release_dir.join("SHA256SUMS"), checksums.concat()).unwrap(); + } + + fn asset_name(&self, binary: &str) -> String { + format!("{binary}-{}-linux-amd64", self.version) + } + + fn rewrite_sums_without(&self, excluded: &str) { + let mut checksums = Vec::new(); + for binary in BINARIES { + if binary == excluded { + continue; + } + let asset = self.asset_name(binary); + let path = self.release_dir.join(&asset); + checksums.push(format!("{} {}\n", sha256(&path), asset)); + } + fs::write(self.release_dir.join("SHA256SUMS"), checksums.concat()).unwrap(); + } + + fn corrupt_release_asset(&self, binary: &str) { + let path = self.release_dir.join(self.asset_name(binary)); + fs::write(&path, b"tampered bytes\n").unwrap(); + } + + fn preinstall_previous_toolset(&self) { + fs::create_dir_all(&self.install_dir).unwrap(); + for binary in BINARIES { + fs::write( + self.install_dir.join(binary), + format!("{binary} previous binary\n"), + ) + .unwrap(); + } + } + + fn assert_previous_toolset_intact(&self) { + for binary in BINARIES { + let contents = fs::read_to_string(self.install_dir.join(binary)).unwrap(); + assert_eq!( + contents, + format!("{binary} previous binary\n"), + "{binary} must keep its previous contents" + ); + } + } + + fn assert_toolset_installed(&self) { + for binary in BINARIES { + let path = self.install_dir.join(binary); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, format!("{binary} release binary\n")); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "{binary} must be executable"); + } + } + + fn assert_nothing_installed(&self) { + for binary in BINARIES { + assert!( + !self.install_dir.join(binary).exists(), + "{binary} must not be installed" + ); + } + } + + fn run(&self) -> std::process::Output { + self.command().output().unwrap() + } + + fn run_with_second_mv_failure(&self) -> std::process::Output { + self.mv_failure_command().output().unwrap() + } + + fn mv_failure_command(&self) -> Command { + write_executable( + &self.fake_bin.join("mv"), + r#"#!/usr/bin/env bash +set -euo pipefail +count=0 +if [[ -f "$FAKE_MV_COUNT_FILE" ]]; then + read -r count < "$FAKE_MV_COUNT_FILE" +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$FAKE_MV_COUNT_FILE" +if [[ "$count" -eq 2 ]]; then + exit 73 +fi +exec "$REAL_MV" "$@" +"#, + ); + let mut command = self.command(); + command + .env("FAKE_MV_COUNT_FILE", self._temp.path().join("mv-count")) + .env("REAL_MV", "/bin/mv"); + command + } + + fn fake_curl_log(&self) -> PathBuf { + self._temp.path().join("curl-log") + } + + fn temp_path(&self) -> &Path { + self._temp.path() + } + + fn rendered_installer(&self) -> PathBuf { + let source = fs::read_to_string(installer_path()).unwrap(); + let marker = "default_version=\"\""; + assert_eq!(source.matches(marker).count(), 1); + let rendered = source.replacen(marker, &format!("default_version=\"{}\"", self.version), 1); + let path = self._temp.path().join("evidencectl-install.sh"); + fs::write(&path, rendered).unwrap(); + path + } + + fn release_dir(&self) -> &Path { + &self.release_dir + } + + fn install_dir(&self) -> &Path { + &self.install_dir + } + + fn command(&self) -> Command { + self.command_for(&installer_path(), true) + } + + fn command_without_version(&self) -> Command { + self.command_for(&installer_path(), false) + } + + fn command_for(&self, installer: &Path, set_version: bool) -> Command { + let path = format!( + "{}:{}", + self.fake_bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut command = Command::new("bash"); + command + .arg(installer) + .env("PATH", path) + .env("FAKE_RELEASE_DIR", &self.release_dir) + .env("FAKE_CURL_LOG", self.fake_curl_log()) + .env("EVIDENCECTL_INSTALL_DIR", &self.install_dir); + if set_version { + command.env("EVIDENCECTL_VERSION", &self.version); + } + command + } + + fn command_from_stdin(&self, installer: &Path) -> Command { + let mut command = self.command_for(Path::new("/dev/stdin"), false); + command.stdin(Stdio::from(fs::File::open(installer).unwrap())); + command + } +} + +#[cfg(unix)] +fn installer_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh") +} + +/// `/bin/bash` when it is the bash 3.2 macOS ships, which is the interpreter a +/// Mac without Homebrew bash resolves `/usr/bin/env bash` to. +#[cfg(unix)] +fn stock_macos_bash() -> Option { + let path = PathBuf::from("/bin/bash"); + let output = Command::new(&path).arg("--version").output().ok()?; + let banner = String::from_utf8_lossy(&output.stdout); + banner.contains("version 3.").then_some(path) +} + +/// Rebuild a prepared fixture command against a different shell, keeping its +/// arguments and environment. +#[cfg(unix)] +fn reinterpret(source: Command, shell: &Path) -> Command { + let mut command = Command::new(shell); + command.args(source.get_args()); + for (key, value) in source.get_envs() { + match value { + Some(value) => command.env(key, value), + None => command.env_remove(key), + }; + } + command +} + +#[cfg(unix)] +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +fn sha256(path: &Path) -> String { + for (program, args) in [("shasum", vec!["-a", "256"]), ("sha256sum", vec![])] { + if let Ok(output) = Command::new(program).args(args).arg(path).output() { + if output.status.success() { + return String::from_utf8(output.stdout) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string(); + } + } + } + panic!("test needs shasum or sha256sum"); +} diff --git a/crates/registry-evidencectl/tests/jwks.rs b/crates/registry-evidencectl/tests/jwks.rs new file mode 100644 index 000000000..0e00da919 --- /dev/null +++ b/crates/registry-evidencectl/tests/jwks.rs @@ -0,0 +1,239 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn mode_of(path: &Path) -> u32 { + fs::metadata(path) + .unwrap_or_else(|error| panic!("stat {}: {error}", path.display())) + .permissions() + .mode() + & 0o777 +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Generates a signing keypair via the `keygen signing` subcommand and +/// returns the path to its public JWK file. Reuses the tool under test +/// instead of hand-rolling key material. +fn generate_public_jwk(dir: &Path, name: &str, kid: &str) -> std::path::PathBuf { + let out_dir = dir.join(name); + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", kid]) + .output() + .expect("run evidencectl keygen"); + assert!(output.status.success(), "{}", stderr_of(&output)); + out_dir.join("signing-ed25519-public.jwk.json") +} + +#[test] +fn assembles_a_jwks_document_from_public_jwk_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = generate_public_jwk(dir.path(), "first", "kid-a"); + let second = generate_public_jwk(dir.path(), "second", "kid-b"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&first) + .arg(&second) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(&out), 0o644); + let contents = fs::read_to_string(&out).expect("read jwks"); + assert!(contents.ends_with('\n'), "output must end with a newline"); + + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + let keys = document["keys"].as_array().expect("keys array"); + assert_eq!(keys.len(), 2); + let kids: Vec<&str> = keys + .iter() + .map(|key| key["kid"].as_str().unwrap()) + .collect(); + assert!(kids.contains(&"kid-a")); + assert!(kids.contains(&"kid-b")); +} + +#[test] +fn rejects_a_private_jwk_input_without_printing_its_contents() { + let dir = tempfile::tempdir().expect("tempdir"); + let private_path = dir.path().join("oops-private.json"); + let canary = "s3cr3t-d-value-canary"; + fs::write( + &private_path, + format!( + r#"{{"kty":"OKP","crv":"Ed25519","d":"{canary}","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"leaked"}}"# + ), + ) + .expect("write fake private jwk"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&private_path) + .output() + .expect("run evidencectl jwks"); + assert!( + !output.status.success(), + "private JWK input must be rejected" + ); + assert!(!out.exists(), "no output should be written on rejection"); + + let stderr = stderr_of(&output); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(!stdout.contains(canary), "stdout leaked private material"); + assert!(!stderr.contains(canary), "stderr leaked private material"); +} + +#[test] +fn deduplicates_identical_duplicate_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-dup"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let contents = fs::read_to_string(&out).expect("read jwks"); + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + let keys = document["keys"].as_array().expect("keys array"); + assert_eq!( + keys.len(), + 1, + "identical duplicate entries must be deduplicated" + ); +} + +#[test] +fn conflicting_keys_sharing_a_kid_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = generate_public_jwk(dir.path(), "first", "shared-kid"); + let second = generate_public_jwk(dir.path(), "second", "shared-kid"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&first) + .arg(&second) + .output() + .expect("run evidencectl jwks"); + assert!( + !output.status.success(), + "two different keys sharing a kid must be rejected" + ); + assert!(!out.exists()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("shared-kid"), + "error should name the conflicting kid: {stderr}" + ); +} + +#[test] +fn force_replaces_a_symlinked_output_path_without_writing_through_it() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-sym"); + let target = dir.path().join("elsewhere.json"); + fs::write(&target, b"untouched").expect("seed symlink target"); + let out = dir.path().join("jwks.json"); + symlink(&target, &out).expect("create symlink at the output path"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg("--force") + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let metadata = fs::symlink_metadata(&out).expect("stat output path"); + assert!( + metadata.is_file(), + "the symlink must be replaced by a regular file" + ); + assert!(!metadata.file_type().is_symlink()); + assert_eq!(mode_of(&out), 0o644); + assert_eq!( + fs::read(&target).expect("read symlink target"), + b"untouched", + "the symlink's former target must never be written through" + ); +} + +#[test] +fn refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-x"); + let out = dir.path().join("jwks.json"); + + let first = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(first.status.success(), "{}", stderr_of(&first)); + let original = fs::read(&out).expect("read jwks"); + + let second = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!( + !second.status.success(), + "overwrite without --force must be refused" + ); + assert_eq!(fs::read(&out).expect("read jwks"), original); + + let another = generate_public_jwk(dir.path(), "another", "kid-y"); + let third = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg("--force") + .arg(&public) + .arg(&another) + .output() + .expect("run evidencectl jwks"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!(mode_of(&out), 0o644); + + let contents = fs::read_to_string(&out).expect("read jwks"); + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + assert_eq!(document["keys"].as_array().expect("keys array").len(), 2); +} diff --git a/crates/registry-evidencectl/tests/keygen.rs b/crates/registry-evidencectl/tests/keygen.rs new file mode 100644 index 000000000..f69eff062 --- /dev/null +++ b/crates/registry-evidencectl/tests/keygen.rs @@ -0,0 +1,560 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +use registry_platform_crypto::{PrivateJwk, PublicJwk}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn mode_of(path: &Path) -> u32 { + fs::metadata(path) + .unwrap_or_else(|error| panic!("stat {}: {error}", path.display())) + .permissions() + .mode() + & 0o777 +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Asserts neither captured stream contains `needle`, e.g. private key +/// material that must never reach the terminal or logs. +fn assert_output_excludes(output: &Output, needle: &str) { + let stdout = stdout_of(output); + let stderr = stderr_of(output); + assert!( + !stdout.contains(needle), + "stdout leaked secret material: {stdout}" + ); + assert!( + !stderr.contains(needle), + "stderr leaked secret material: {stderr}" + ); +} + +#[test] +fn signing_writes_private_and_public_jwk_with_expected_modes() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "keygen signing failed: {}", + stderr_of(&output) + ); + + assert_eq!(mode_of(&out_dir), 0o700, "out-dir mode"); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + let public_path = out_dir.join("signing-ed25519-public.jwk.json"); + assert_eq!(mode_of(&private_path), 0o600, "private file mode"); + assert_eq!(mode_of(&public_path), 0o644, "public file mode"); + + let private_contents = fs::read_to_string(&private_path).expect("read private jwk"); + let public_contents = fs::read_to_string(&public_path).expect("read public jwk"); + + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + + let expected_kid = public.jkt().expect("thumbprint"); + assert_eq!(private.kid.as_deref(), Some(expected_kid.as_str())); + assert_eq!(public.kid.as_deref(), Some(expected_kid.as_str())); + + // The "d" value must never appear on stdout or stderr. + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn signing_kid_override_replaces_the_default_thumbprint() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", "custom-kid-1"]) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let private_contents = + fs::read_to_string(out_dir.join("signing-ed25519-private-jwk")).expect("read private jwk"); + let public_contents = fs::read_to_string(out_dir.join("signing-ed25519-public.jwk.json")) + .expect("read public jwk"); + + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + assert_eq!(private.kid.as_deref(), Some("custom-kid-1")); + assert_eq!(public.kid.as_deref(), Some("custom-kid-1")); + + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn signing_rejects_an_empty_or_whitespace_only_kid() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", " "]) + .output() + .expect("run evidencectl"); + assert!( + !output.status.success(), + "a whitespace-only --kid must be refused" + ); + assert!( + !out_dir.join("signing-ed25519-private-jwk").exists(), + "no key material should be generated for a refused --kid" + ); +} + +#[test] +fn signing_public_out_overrides_the_default_public_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + let public_out = dir.path().join("elsewhere").join("signing-public.json"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--public-out") + .arg(&public_out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert!(public_out.is_file()); + assert!(!out_dir.join("signing-ed25519-public.jwk.json").exists()); + assert_eq!(mode_of(&public_out), 0o644); +} + +#[test] +fn holder_writes_private_and_public_jwk_with_holder_filenames() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("holder-keys"); + + let output = evidencectl() + .args(["keygen", "holder", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let private_path = out_dir.join("holder-ed25519-private-jwk"); + let public_path = out_dir.join("holder-ed25519-public.jwk.json"); + assert_eq!(mode_of(&private_path), 0o600); + assert_eq!(mode_of(&public_path), 0o644); + + let private_contents = fs::read_to_string(&private_path).expect("read private jwk"); + let public_contents = fs::read_to_string(&public_path).expect("read public jwk"); + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + assert_eq!(private.kid, public.kid); + + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn secret_writes_exactly_32_raw_bytes_with_owner_only_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secrets").join("audit-hmac-key"); + + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(out.parent().unwrap()), 0o700, "parent dir mode"); + assert_eq!(mode_of(&out), 0o600, "secret file mode"); + + let bytes = fs::read(&out).expect("read secret"); + assert_eq!(bytes.len(), 32); +} + +#[test] +fn secret_invocations_generate_independent_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = dir.path().join("first.key"); + let second = dir.path().join("second.key"); + + for out in [&first, &second] { + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + } + + let first_bytes = fs::read(&first).expect("read first secret"); + let second_bytes = fs::read(&second).expect("read second secret"); + assert_eq!(first_bytes.len(), 32); + assert_eq!(second_bytes.len(), 32); + assert_ne!(first_bytes, second_bytes); +} + +/// The Evidence runtime rejects any file-provided secret containing a NUL +/// byte, and a uniform 32-byte draw carries one about 11.8% of the time. A +/// generated secret must therefore never contain one, or roughly one project +/// in five fails at `evidence serve` long after `evidence check` passed. +#[test] +fn secret_never_contains_a_nul_byte() { + let dir = tempfile::tempdir().expect("tempdir"); + + // 64 draws leave under one chance in 3000 of a run where every unfixed + // draw happened to be NUL-free. + for index in 0..64 { + let out = dir.path().join(format!("secret-{index}.key")); + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let bytes = fs::read(&out).expect("read secret"); + assert_eq!(bytes.len(), 32); + assert!( + !bytes.contains(&0), + "draw {index} contains a NUL byte the runtime rejects" + ); + } +} + +#[test] +fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let first = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(first.status.success(), "{}", stderr_of(&first)); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + let original_private = fs::read_to_string(&private_path).expect("read private jwk"); + + let second = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!( + !second.status.success(), + "second run without --force unexpectedly succeeded" + ); + let unchanged = fs::read_to_string(&private_path).expect("read private jwk"); + assert_eq!( + original_private, unchanged, + "file must be untouched on refusal" + ); + + let third = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!( + mode_of(&private_path), + 0o600, + "mode preserved across --force" + ); + + let regenerated = fs::read_to_string(&private_path).expect("read private jwk"); + assert_ne!( + original_private, regenerated, + "--force must regenerate key material" + ); +} + +#[test] +fn secret_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secret.key"); + + let first = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(first.status.success(), "{}", stderr_of(&first)); + let original = fs::read(&out).expect("read secret"); + + let second = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(!second.status.success()); + assert_eq!(fs::read(&out).expect("read secret"), original); + + let third = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!(mode_of(&out), 0o600); + assert_ne!(fs::read(&out).expect("read secret"), original); +} + +#[test] +fn signing_batch_abort_leaves_the_private_file_unwritten() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + + // Pre-create only the public target; the private target must never be + // written once the batch is refused. + fs::write(out_dir.join("signing-ed25519-public.jwk.json"), b"stale").expect("seed public file"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(!output.status.success(), "batch should have been refused"); + assert!( + !out_dir.join("signing-ed25519-private-jwk").exists(), + "private key must not be written when the batch aborts" + ); + let public_contents = + fs::read(out_dir.join("signing-ed25519-public.jwk.json")).expect("read public file"); + assert_eq!( + public_contents, b"stale", + "pre-existing public file must be untouched" + ); +} + +#[test] +fn secret_leaves_a_pre_existing_parent_directorys_mode_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let parent = dir.path().join("secrets"); + fs::create_dir(&parent).expect("create parent dir"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).expect("loosen parent mode"); + + let out = parent.join("audit-hmac-key"); + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!( + mode_of(&parent), + 0o755, + "a parent directory this invocation did not create must keep its own mode" + ); +} + +#[test] +fn signing_out_dir_mode_is_normalized_to_0700_when_pre_created_looser() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + fs::set_permissions(&out_dir, fs::Permissions::from_mode(0o755)).expect("loosen out-dir mode"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!( + mode_of(&out_dir), + 0o700, + "a pre-existing out-dir must be normalized to owner-only" + ); +} + +#[test] +fn signing_force_replaces_a_symlinked_private_path_without_writing_through_it() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + + let target = dir.path().join("attacker-target"); + fs::write(&target, b"untouched").expect("seed symlink target"); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + symlink(&target, &private_path).expect("create symlink at the private path"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let metadata = fs::symlink_metadata(&private_path).expect("stat private path"); + assert!( + metadata.file_type().is_file(), + "the symlink must be replaced by a regular file" + ); + assert!(!metadata.file_type().is_symlink()); + + let target_contents = fs::read(&target).expect("read symlink target"); + assert_eq!( + target_contents, b"untouched", + "the symlink's former target must never be written through" + ); +} + +#[test] +fn signing_error_names_the_offending_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + fs::write(out_dir.join("signing-ed25519-private-jwk"), b"stale").expect("seed private file"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("signing-ed25519-private-jwk"), + "error should name the offending path: {stderr}" + ); +} + +/// A bearer token ends up in an HTTP header, where the raw bytes `keygen +/// secret` writes are not valid. `keygen token` must therefore emit only +/// characters a header value accepts, with no trailing newline: the runtime +/// reads the file whole and would carry one into the header. +#[test] +fn token_writes_a_header_safe_value_with_owner_only_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secrets").join("source-bearer-token"); + + let output = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(out.parent().unwrap()), 0o700, "parent dir mode"); + assert_eq!(mode_of(&out), 0o600, "token file mode"); + + let token = fs::read_to_string(&out).expect("read token"); + assert_eq!(token.len(), 43, "token: {token}"); + assert!( + token + .chars() + .all(|character| character.is_ascii_alphanumeric() + || character == '-' + || character == '_'), + "token carries a character an HTTP header value rejects: {token}" + ); + + // The generated credential is as secret as any private key, and this tool + // never prints those either. + assert_output_excludes(&output, &token); +} + +#[test] +fn token_invocations_generate_independent_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = dir.path().join("first.token"); + let second = dir.path().join("second.token"); + + for out in [&first, &second] { + let output = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + } + + assert_ne!( + fs::read_to_string(&first).expect("read first token"), + fs::read_to_string(&second).expect("read second token") + ); +} + +#[test] +fn token_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("source-bearer-token"); + fs::write(&out, b"already here").expect("seed token"); + + let refused = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(!refused.status.success()); + assert_eq!( + fs::read_to_string(&out).expect("read token"), + "already here", + "a refused run must leave the existing token alone" + ); + + let forced = evidencectl() + .args(["keygen", "token", "--force", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(forced.status.success(), "{}", stderr_of(&forced)); + assert_ne!( + fs::read_to_string(&out).expect("read token"), + "already here" + ); +} + +/// `keygen secret` is the obvious tool for the one secret every scaffolded +/// bundle needs, and it is the wrong one. Its own help has to say so, because +/// the alternative is discovering it at the first live request. +#[test] +fn secret_help_says_it_does_not_make_bearer_tokens() { + let output = evidencectl() + .args(["keygen", "secret", "--help"]) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let help = stdout_of(&output); + assert!( + help.contains("keygen token"), + "keygen secret's help never points at the token generator:\n{help}" + ); +} diff --git a/crates/registry-evidencectl/tests/production_build.rs b/crates/registry-evidencectl/tests/production_build.rs new file mode 100644 index 000000000..7976e7b98 --- /dev/null +++ b/crates/registry-evidencectl/tests/production_build.rs @@ -0,0 +1,854 @@ +#![cfg(unix)] + +//! Production-build filesystem and delegation invariants. +//! +//! These tests deliberately replace the sibling `evidence` binary with a +//! value-free recorder. They pin the adopter tool's responsibilities without +//! copying bundle or fixture semantics out of the runtime. + +use std::{ + collections::BTreeMap, + fs, + os::unix::fs::{symlink, PermissionsExt as _}, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const REVISION: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const LOCAL_URI: &str = "urn:registrystack:evidence:local:forbidden"; +const SECRET_CANARY: &str = "production-build-secret-canary"; + +#[test] +fn build_is_create_only_and_never_changes_an_existing_output() { + let fixture = Fixture::new(); + fs::create_dir(&fixture.output).expect("existing output"); + fs::write(fixture.output.join("owned.txt"), "preserve me\n").expect("existing file"); + + let output = fixture.build(); + + assert_failed(&output, "existing output must be refused"); + assert_eq!( + fs::read_to_string(fixture.output.join("owned.txt")).unwrap(), + "preserve me\n" + ); + assert!(fixture.invocations().is_empty()); + fixture.assert_no_staging_residue(); +} + +#[test] +fn build_rejects_output_inside_the_editable_project_without_modifying_it() { + let fixture = Fixture::new(); + let candidate = fixture.project.join("candidate"); + let before = snapshot(&fixture.project); + + let output = fixture.build_with(&fixture.project, &fixture.target, &candidate); + + assert_failed(&output, "project-contained output must be refused"); + assert!(!candidate.exists()); + assert_eq!(snapshot(&fixture.project), before); + assert!(fixture.invocations().is_empty()); + fixture.assert_no_staging_residue(); +} + +#[test] +fn build_rejects_symlinked_project_target_output_parent_and_artifact() { + for boundary in ["project", "target", "output-parent", "artifact"] { + let fixture = Fixture::new(); + let mut project = fixture.project.clone(); + let mut target = fixture.target.clone(); + let mut output = fixture.output.clone(); + match boundary { + "project" => { + project = fixture.root.join("project-link"); + symlink(&fixture.project, &project).expect("project symlink"); + } + "target" => { + target = fixture.root.join("target-link"); + symlink(&fixture.target, &target).expect("target symlink"); + } + "output-parent" => { + let actual = fixture.root.join("actual-output-parent"); + fs::create_dir(&actual).expect("actual output parent"); + let linked = fixture.root.join("linked-output-parent"); + symlink(&actual, &linked).expect("output parent symlink"); + output = linked.join("candidate"); + } + "artifact" => { + let derivation = fixture.project.join("derivations/answer.rhai"); + fs::remove_file(&derivation).expect("remove regular derivation"); + let outside = fixture.root.join("outside.rhai"); + fs::write( + &outside, + "fn answer(facts, selectors, context) { #{allowed: true} }\n", + ) + .expect("outside derivation"); + symlink(outside, derivation).expect("artifact symlink"); + } + _ => unreachable!(), + } + + let result = fixture.build_with(&project, &target, &output); + assert_failed(&result, boundary); + assert!(!output.exists(), "{boundary} published an output"); + assert!( + fixture.invocations().is_empty(), + "{boundary} reached Evidence" + ); + fixture.assert_no_staging_residue(); + } +} + +#[test] +fn failed_runtime_check_leaves_no_output_or_private_staging() { + let fixture = Fixture::new(); + + let output = fixture.build_failing("check"); + + assert_failed(&output, "runtime rejection must fail the build"); + assert!(!fixture.output.exists()); + fixture.assert_no_staging_residue(); + assert_eq!(fixture.steps(), ["check"]); + assert_value_free(&output); +} + +#[test] +fn successful_build_copies_runtime_exactly_and_excludes_local_and_validation_secrets() { + let fixture = Fixture::new(); + let local = fixture.project.join(".evidence/dev"); + fs::create_dir_all(&local).expect("local state"); + fs::write(local.join("disposable-private-key"), SECRET_CANARY).expect("local secret"); + let runtime = fs::read(&fixture.runtime).expect("target runtime"); + + let output = fixture.build(); + + assert_success(&output, "production build"); + assert_eq!( + fs::read(fixture.output.join("runtime.yaml")).unwrap(), + runtime + ); + let snapshot = snapshot(&fixture.output); + assert_eq!( + snapshot.keys().cloned().collect::>(), + vec![ + PathBuf::from("bundle/adapters/source-extract.rhai"), + PathBuf::from("bundle/adapters/source-prepare.rhai"), + PathBuf::from("bundle/derivations/answer.rhai"), + PathBuf::from("bundle/evidence.yaml"), + PathBuf::from("bundle/fixtures/answer.yaml"), + PathBuf::from("bundle/schemas/facts.schema.yaml"), + PathBuf::from("bundle/schemas/parameters.schema.yaml"), + PathBuf::from("bundle/schemas/response.schema.yaml"), + PathBuf::from("runtime.yaml"), + ] + ); + for (path, bytes) in snapshot { + assert!( + !bytes + .windows(SECRET_CANARY.len()) + .any(|part| part == SECRET_CANARY.as_bytes()), + "{} contains local secret material", + path.display() + ); + assert!(!path.to_string_lossy().contains("validation")); + } + fixture.assert_no_staging_residue(); +} + +#[test] +fn production_metadata_and_fixture_completeness_fail_before_runtime_delegation() { + for label in [ + "missing-governance", + "missing-stable-concept", + "missing-fixture", + ] { + let fixture = Fixture::new(); + match label { + "missing-governance" => fixture.remove_governance(), + "missing-stable-concept" => { + fixture.replace_in_question(" id: urn:example:concepts:allowed\n", "") + } + "missing-fixture" => fs::remove_file(fixture.project.join("fixtures/answer.yaml")) + .expect("remove fixture"), + _ => unreachable!(), + } + let output = fixture.build(); + assert_failed(&output, label); + assert!(!fixture.output.exists()); + assert!(fixture.invocations().is_empty(), "{label} reached Evidence"); + fixture.assert_no_staging_residue(); + } +} + +#[test] +fn disposable_local_identifiers_fail_before_runtime_delegation() { + for original in [ + "urn:example:requirements:allowed:v1", + "urn:example:frameworks:allowed:v1", + "urn:example:evidence-types:allowed:v1", + "urn:example:disclosure-families:allowed", + "urn:example:concepts:allowed", + ] { + let fixture = Fixture::new(); + fixture.replace_in_question(original, LOCAL_URI); + let output = fixture.build(); + assert_failed(&output, "local identifier"); + assert!(!fixture.output.exists()); + assert!(fixture.invocations().is_empty()); + assert!(!stderr(&output).contains(LOCAL_URI)); + } +} + +#[test] +fn plain_http_and_unauthenticated_sources_fail_before_runtime_delegation() { + for (from, to) in [ + ("https://registry.invalid", "http://127.0.0.1:8088"), + ("kind: static-bearer", "kind: none"), + ] { + let fixture = Fixture::new(); + fixture.replace_in_source(from, to); + let output = fixture.build(); + assert_failed(&output, "insecure source"); + assert!(!fixture.output.exists()); + assert!(fixture.invocations().is_empty()); + fixture.assert_no_staging_residue(); + } +} + +#[test] +fn unresolved_review_markers_and_unknown_target_fields_fail_closed() { + let marker = Fixture::new(); + fs::write( + marker.project.join("fixtures/answer.yaml"), + "fixture: TODO(evidencectl)\n", + ) + .expect("review marker"); + let marker_output = marker.build(); + assert_failed(&marker_output, "review marker"); + assert!(marker.invocations().is_empty()); + assert!(!marker.output.exists()); + assert_value_free(&marker_output); + + let unknown = Fixture::new(); + let mut governance = fs::read_to_string(&unknown.governance).unwrap(); + governance.push_str("deploymentGenerator: forbidden\n"); + fs::write(&unknown.governance, governance).unwrap(); + let unknown_output = unknown.build(); + assert_failed(&unknown_output, "unknown target field"); + assert!(unknown.invocations().is_empty()); + assert!(!unknown.output.exists()); + unknown.assert_no_staging_residue(); +} + +#[test] +fn semantic_authority_completeness_is_delegated_to_evidence() { + let fixture = Fixture::new(); + let mut governance = fs::read_to_string(&fixture.governance).unwrap(); + let start = governance + .find("authorityProfiles:") + .expect("authority profile section"); + governance.truncate(start); + governance.push_str("authorityProfiles:\n incomplete: {}\n"); + fs::write(&fixture.governance, governance).unwrap(); + + let output = fixture.build_failing("check"); + + assert_failed(&output, "runtime-owned authority validation"); + assert_eq!(fixture.steps(), ["check"]); + assert!(!fixture.output.exists()); + assert_value_free(&output); + fixture.assert_no_staging_residue(); +} + +#[test] +fn every_referenced_fixture_is_delegated_and_one_failure_prevents_publication() { + let fixture = Fixture::new(); + fixture.add_second_question(); + + let passed = fixture.build(); + assert_success(&passed, "two-fixture build"); + assert_eq!( + fixture.steps(), + [ + "check", + "evaluate:fixtures/answer.yaml", + "evaluate:fixtures/second.yaml" + ] + ); + + let failed = Fixture::new(); + failed.add_second_question(); + let output = failed.build_failing("fixture:fixtures/second.yaml"); + assert_failed(&output, "one rejected fixture"); + assert_eq!( + failed.steps(), + [ + "check", + "evaluate:fixtures/answer.yaml", + "evaluate:fixtures/second.yaml" + ] + ); + assert!(!failed.output.exists()); + assert_value_free(&output); + failed.assert_no_staging_residue(); +} + +#[test] +fn identical_inputs_produce_identical_bundle_bytes_revision_and_stable_report_shape() { + let fixture = Fixture::new(); + let first_output = fixture.root.join("candidate-one"); + let second_output = fixture.root.join("candidate-two"); + + let first = fixture.build_with(&fixture.project, &fixture.target, &first_output); + let second = fixture.build_with(&fixture.project, &fixture.target, &second_output); + + assert_success(&first, "first deterministic build"); + assert_success(&second, "second deterministic build"); + assert_eq!( + snapshot(&first_output.join("bundle")), + snapshot(&second_output.join("bundle")) + ); + assert_report(&first, &first_output); + assert_report(&second, &second_output); + assert_eq!(reported_revision(&first), reported_revision(&second)); +} + +#[test] +fn termination_cancels_evidence_and_removes_only_current_build_staging() { + for signal in [rustix::process::Signal::INT, rustix::process::Signal::TERM] { + let fixture = Fixture::new(); + let ready = fixture.root.join("blocked-evidence-ready"); + let unrelated = fixture.root.join(".evidencectl-build-unrelated"); + fs::create_dir(&unrelated).expect("unrelated staging"); + fs::write(unrelated.join("owned.txt"), "preserve me\n").expect("unrelated sentinel"); + + let mut child = fixture + .command(&fixture.project, &fixture.target, &fixture.output) + .env("FAKE_EVIDENCE_BLOCK", "1") + .env("FAKE_EVIDENCE_BLOCK_READY", &ready) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("blocking evidencectl build starts"); + + let ready_deadline = Instant::now() + Duration::from_secs(10); + while !ready.exists() { + assert!( + child.try_wait().expect("poll blocking build").is_none(), + "build exited before the fake Evidence process blocked" + ); + assert!( + Instant::now() < ready_deadline, + "fake Evidence did not reach its blocking point" + ); + thread::sleep(Duration::from_millis(10)); + } + + let pid = rustix::process::Pid::from_raw( + i32::try_from(child.id()).expect("evidencectl PID fits i32"), + ) + .expect("evidencectl PID is positive"); + rustix::process::kill_process(pid, signal).expect("send signal to evidencectl build"); + + let exit_deadline = Instant::now() + Duration::from_secs(10); + loop { + if child.try_wait().expect("poll interrupted build").is_some() { + break; + } + if Instant::now() >= exit_deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("interrupted evidencectl build did not exit"); + } + thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().expect("collect interrupted build"); + + assert_failed(&output, "signal-interrupted build"); + assert_value_free(&output); + assert!(!fixture.output.exists()); + assert_eq!( + fs::read_to_string(unrelated.join("owned.txt")).unwrap(), + "preserve me\n", + "build cleaned unrelated staging" + ); + fs::remove_dir_all(&unrelated).expect("remove test-owned unrelated staging"); + fixture.assert_no_staging_residue(); + } +} + +struct Fixture { + _temporary: tempfile::TempDir, + root: PathBuf, + project: PathBuf, + target: PathBuf, + governance: PathBuf, + runtime: PathBuf, + output: PathBuf, + evidence: PathBuf, + log: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temporary_root = workspace_root().join("target"); + fs::create_dir_all(&temporary_root).expect("workspace target"); + let temporary = tempfile::Builder::new() + .prefix("production-build-") + .tempdir_in(temporary_root) + .expect("test tempdir"); + let root = temporary.path().to_path_buf(); + let project = root.join("project"); + let target = project.join("deployment-targets/production"); + for directory in [ + "selectors", + "sources", + "adapters", + "schemas", + "questions", + "derivations", + "fixtures", + ] { + fs::create_dir_all(project.join(directory)).expect("project directory"); + } + fs::create_dir_all(&target).expect("target directory"); + + fs::write( + project.join("source.openapi.yaml"), + "openapi: 3.1.0\ninfo: {title: Neutral source, version: 1.0.0}\npaths: {}\n", + ) + .expect("retained OpenAPI"); + fs::write( + project.join("selectors/subject-reference-v1.yaml"), + "maximumAggregateBytes: 128\nfields:\n reference: {type: string, minimumBytes: 1, maximumBytes: 128}\n", + ) + .expect("selector profile"); + fs::write(project.join("sources/registry.yaml"), SOURCE).expect("source"); + fs::write( + project.join("adapters/source-prepare.rhai"), + "fn prepare(selectors, parameters) { #{query: [], body: #{reference: selectors[\"subject\"][\"values\"][\"reference\"]}} }\n", + ) + .expect("prepare script"); + fs::write( + project.join("adapters/source-extract.rhai"), + "fn extract(source_response, parameters) { #{outcome: \"match\", facts: #{allowed: source_response[\"allowed\"]}} }\n", + ) + .expect("extract script"); + fs::write( + project.join("schemas/parameters.schema.yaml"), + "type: object\nadditionalProperties: false\nproperties: {}\n", + ) + .expect("parameters schema"); + fs::write( + project.join("schemas/response.schema.yaml"), + "type: object\nadditionalProperties: false\nrequired: [allowed]\nproperties:\n allowed: {type: boolean}\n", + ) + .expect("response schema"); + fs::write( + project.join("schemas/facts.schema.yaml"), + "type: object\nadditionalProperties: false\nrequired: [allowed]\nproperties:\n allowed: {type: boolean}\n", + ) + .expect("facts schema"); + fs::write(project.join("questions/answer.yaml"), question("answer")).expect("question"); + fs::write( + project.join("derivations/answer.rhai"), + "fn answer(facts, selectors, context) { #{allowed: facts[\"allowed\"]} }\n", + ) + .expect("derivation"); + fs::write( + project.join("fixtures/answer.yaml"), + "fixture: neutral.answer/v1\n", + ) + .expect("fixture"); + + let governance = target.join("governance.yaml"); + fs::write(&governance, GOVERNANCE).expect("target governance"); + let runtime = target.join("runtime.yaml"); + fs::write(&runtime, TARGET_RUNTIME).expect("target runtime"); + + let evidence = root.join("evidence"); + fs::write(&evidence, FAKE_EVIDENCE).expect("fake Evidence"); + let mut permissions = fs::metadata(&evidence).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&evidence, permissions).unwrap(); + + Self { + output: root.join("candidate"), + log: root.join("evidence-invocations"), + _temporary: temporary, + root, + project, + target, + governance, + runtime, + evidence, + } + } + + fn build(&self) -> Output { + self.build_with(&self.project, &self.target, &self.output) + } + + fn build_failing(&self, failure: &str) -> Output { + self.command(&self.project, &self.target, &self.output) + .env("FAKE_EVIDENCE_FAIL", failure) + .output() + .expect("evidencectl build starts") + } + + fn build_with(&self, project: &Path, target: &Path, output: &Path) -> Output { + self.command(project, target, output) + .output() + .expect("evidencectl build starts") + } + + fn command(&self, project: &Path, target: &Path, output: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidencectl")); + command + .arg("build") + .arg("--project") + .arg(project) + .arg("--target") + .arg(target) + .arg("--output") + .arg(output) + .env("EVIDENCE_BIN", &self.evidence) + .env("FAKE_EVIDENCE_LOG", &self.log) + .env_remove("FAKE_EVIDENCE_FAIL"); + command + } + + fn invocations(&self) -> Vec> { + let Ok(contents) = fs::read_to_string(&self.log) else { + return Vec::new(); + }; + contents + .split("===\n") + .filter(|part| !part.is_empty()) + .map(|part| part.lines().map(str::to_owned).collect()) + .collect() + } + + fn steps(&self) -> Vec { + self.invocations() + .into_iter() + .map(|args| { + if let Some(index) = args.iter().position(|arg| arg == "--fixture") { + format!("evaluate:{}", args[index + 1]) + } else { + "check".to_owned() + } + }) + .collect() + } + + fn replace_in_question(&self, from: &str, to: &str) { + replace(&self.project.join("questions/answer.yaml"), from, to); + } + + fn replace_in_source(&self, from: &str, to: &str) { + replace(&self.project.join("sources/registry.yaml"), from, to); + } + + fn remove_governance(&self) { + let path = self.project.join("questions/answer.yaml"); + let mut contents = fs::read_to_string(&path).expect("question reads"); + let start = contents.find("governance:\n").expect("governance section"); + contents.truncate(start); + fs::write(path, contents).expect("question without governance writes"); + } + + fn add_second_question(&self) { + fs::write( + self.project.join("questions/second.yaml"), + question("second"), + ) + .expect("second question"); + fs::write( + self.project.join("derivations/second.rhai"), + "fn answer(facts, selectors, context) { #{allowed: facts[\"allowed\"]} }\n", + ) + .expect("second derivation"); + fs::write( + self.project.join("fixtures/second.yaml"), + "fixture: neutral.second/v1\n", + ) + .expect("second fixture"); + } + + fn assert_no_staging_residue(&self) { + fn collect(path: &Path, names: &mut Vec) { + for entry in fs::read_dir(path).expect("staging scan directory") { + let entry = entry.expect("staging scan entry"); + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).expect("staging scan metadata"); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(".evidencectl-build-") + || name.starts_with(".evidencectl-build-validation-") + { + names.push(entry_path.clone()); + } + if metadata.is_dir() && !metadata.file_type().is_symlink() { + collect(&entry_path, names); + } + } + } + let mut names = Vec::new(); + collect(&self.root, &mut names); + assert!( + names.is_empty(), + "private staging residue remained: {names:?}" + ); + } +} + +fn question(id: &str) -> String { + format!( + r#"id: {id} +question: Is the governed condition satisfied? +purpose: eligibility +subject: + role: subject + selector: reference + profile: subject-reference-v1 +source: + ref: registry +answers: + - concept: allowed + id: urn:example:concepts:allowed + type: boolean +derivation: derivations/{id}.rhai +disclosure: + allow: [allowed] +governance: + requirement: urn:example:requirements:allowed:v1 + kind: criterion + referenceFrameworks: [urn:example:frameworks:allowed:v1] + evidenceType: urn:example:evidence-types:allowed:v1 + validitySeconds: 300 + observationTimezone: UTC + fixtures: fixtures/{id}.yaml + disclosureFamilies: [urn:example:disclosure-families:allowed] +"# + ) +} + +fn replace(path: &Path, from: &str, to: &str) { + let contents = fs::read_to_string(path).expect("replace source reads"); + assert!(contents.contains(from), "replacement source was present"); + fs::write(path, contents.replacen(from, to, 1)).expect("replacement writes"); +} + +fn assert_success(output: &Output, label: &str) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_failed(output: &Output, label: &str) { + assert!( + !output.status.success(), + "{label} unexpectedly succeeded\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); +} + +fn assert_value_free(output: &Output) { + let mut diagnostic = output.stdout.clone(); + diagnostic.extend_from_slice(&output.stderr); + for prohibited in [ + SECRET_CANARY, + "synthetic-selector-canary", + "source-value-canary", + ] { + assert!( + !diagnostic + .windows(prohibited.len()) + .any(|part| part == prohibited.as_bytes()), + "build diagnostic exposed protected test material" + ); + } +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn assert_report(output: &Output, candidate: &Path) { + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + format!( + "Bundle revision: {REVISION}\nCandidate: {}\nProvision secret:file/audit-hmac-key\nProvision secret:file/signing-private-jwk\nProvision secret:file/source-token\nProvision secret:file/subject-binding-hmac-key\nTarget runtime paths and production secret material remain unverified until `evidencectl doctor --project {}` and the target-host Evidence check.\n", + candidate.display(), + candidate.display(), + ) + ); +} + +fn reported_revision(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.strip_prefix("Bundle revision: ")) + .expect("revision report") + .to_owned() +} + +fn snapshot(root: &Path) -> BTreeMap> { + fn visit(root: &Path, path: &Path, files: &mut BTreeMap>) { + let mut entries = fs::read_dir(path) + .expect("snapshot directory") + .map(|entry| entry.unwrap().path()) + .collect::>(); + entries.sort(); + for entry in entries { + let metadata = fs::symlink_metadata(&entry).unwrap(); + assert!(!metadata.file_type().is_symlink()); + if metadata.is_dir() { + visit(root, &entry, files); + } else { + files.insert( + entry.strip_prefix(root).unwrap().to_path_buf(), + fs::read(entry).unwrap(), + ); + } + } + } + let mut files = BTreeMap::new(); + visit(root, root, &mut files); + files +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .to_path_buf() +} + +const SOURCE: &str = r#"transport: http-json +baseUrl: https://registry.invalid +posture: field-projected +authentication: {kind: static-bearer, tokenRef: 'secret:file/source-token'} +request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: subject-reference-v1, fields: [reference]} + prepareScript: adapters/source-prepare.rhai + adapterParameters: {} + adapterParametersSchema: schemas/parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 4, maximumCollectionItems: 8, maximumStringBytes: 128, maximumNormalizedBytes: 1024} + projection: [/allowed] + redirects: deny + timeoutMilliseconds: 1000 + maximumResponseBytes: 4096 + concurrencyLimit: 1 +responseSchema: schemas/response.schema.yaml +extractScript: adapters/source-extract.rhai +factSchema: schemas/facts.schema.yaml +"#; + +const GOVERNANCE: &str = r#"version: 1 +assuranceProfile: production +service: {providerId: urn:example:providers:evidence, trustDomain: urn:example:trust-domains:evidence} +issuer: {id: urn:example:issuers:evidence} +authentication: + kind: oidc-access-token + issuer: https://issuer.invalid + audiences: [evidence] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://issuer.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: {format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: 'secret:file/subject-binding-hmac-key', keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: production-key-1 + activeKeyRef: secret:file/signing-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] +authorityProfiles: + requester: + kind: statutory + requesterTags: [requester] + grants: + - requirement: urn:example:requirements:allowed:v1 + purpose: eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: [{role: subject, selectorProfile: subject-reference-v1, valueOrigin: request}] +"#; + +const TARGET_RUNTIME: &str = r#"version: 1 +bundleDirectory: /srv/evidence/candidate/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 8 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 10000 +secretProviders: + file: {root: /run/secrets/evidence} +auditStorage: {path: /var/lib/evidence/audit.jsonl, maximumFileBytes: 1048576} +outboundTls: {systemRoots: true, trustProfiles: {}} +"#; + +const FAKE_EVIDENCE: &str = r#"#!/bin/sh +set -eu + +for arg in "$@"; do + printf '%s\n' "$arg" >> "$FAKE_EVIDENCE_LOG" +done +printf '%s\n' '===' >> "$FAKE_EVIDENCE_LOG" + +if [ "${FAKE_EVIDENCE_BLOCK:-}" = '1' ]; then + printf '%s\n' ready > "$FAKE_EVIDENCE_BLOCK_READY" + exec sleep 300 +fi + +fixture='' +previous='' +for arg in "$@"; do + if [ "$previous" = '--fixture' ]; then fixture=$arg; fi + previous=$arg +done + +failure=${FAKE_EVIDENCE_FAIL:-} +if [ "$failure" = 'check' ] && [ -z "$fixture" ]; then + printf '%s\n' 'production-build-secret-canary synthetic-selector-canary source-value-canary' >&2 + exit 1 +fi +if [ "$failure" = "fixture:$fixture" ]; then + printf '%s\n' 'production-build-secret-canary synthetic-selector-canary source-value-canary' >&2 + exit 1 +fi + +if [ -z "$fixture" ]; then + printf '%s\n' 'Evidence deployment sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa / sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb passed check' +else + printf '%s\n' 'Evidence fixture passed (1 evaluated cases)' +fi +"#; diff --git a/crates/registry-evidencectl/tests/production_handoff.rs b/crates/registry-evidencectl/tests/production_handoff.rs new file mode 100644 index 000000000..4853d7653 --- /dev/null +++ b/crates/registry-evidencectl/tests/production_handoff.rs @@ -0,0 +1,2187 @@ +#![cfg(unix)] + +//! Exact production-candidate handoff through the real adopter and runtime +//! binaries. The gate is ignored in the ordinary package suite because it +//! starts two services and requires `python3` and `openssl` on the host. + +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::OnceLock, + thread, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use ed25519_dalek::{Signer as _, SigningKey}; +use serde_json::{json, Value}; + +const AUTH_KEY_ID: &str = "acceptance-auth-key"; +const SIGNING_KEY_ID: &str = "production-signing-key-1"; +const TOKEN_AUDIENCE: &str = "registry-evidence-production-test"; +const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/production-acceptance"; +const REQUIREMENT: &str = "urn:example:requirements:adult-status:v1"; +const EVIDENCE_TYPE: &str = "urn:example:evidence-types:adult-status:v1"; +const CONCEPT: &str = "urn:example:concepts:is-adult"; +const PURPOSE: &str = "fixture-eligibility"; +const SELECTOR_CANARY: &str = "synthetic-person-001"; +const AGE_REQUIREMENT: &str = "urn:example:requirements:age-bracket:v1"; +const AGE_CONCEPT: &str = "urn:example:concepts:age-bracket"; +const IMMUNIZATION_REQUIREMENT: &str = "urn:example:requirements:immunization-summary:v1"; +const SCHEDULE_CONCEPT: &str = "urn:example:concepts:schedule-complete"; +const DOSE_COUNT_CONCEPT: &str = "urn:example:concepts:dose-count"; +const RELATIONSHIP_REQUIREMENT: &str = "urn:example:requirements:parent-relationship:v1"; +const RELATIONSHIP_CONCEPT: &str = "urn:example:concepts:relationship-confirmed"; + +#[test] +#[ignore = "exact gate: starts real binaries plus local HTTPS issuer and source"] +fn production_candidate_handoff_reaches_verified_assertion_and_audit() { + let fixture = Fixture::new(); + let evidence = evidence_binary(); + fixture.stage_authoring_project(); + fixture.stage_https_identity(); + fixture.stage_target(); + + let first = fixture.build(evidence); + let first_revision = bundle_revision(&first); + let first_bytes = snapshot_files(&fixture.candidate); + fs::rename(&fixture.candidate, &fixture.first_candidate) + .expect("archive the first create-only candidate"); + + let second = fixture.build(evidence); + let revision = bundle_revision(&second); + assert_eq!( + revision, first_revision, + "bundle revision must be repeatable" + ); + assert_eq!( + snapshot_files(&fixture.candidate), + first_bytes, + "identical inputs and output binding must reproduce every candidate file byte" + ); + assert_eq!( + fs::read(&fixture.target_runtime).expect("target runtime"), + fs::read(fixture.candidate.join("runtime.yaml")).expect("copied runtime"), + "the target runtime must be copied byte-for-byte" + ); + + fixture.provision_target_secrets(); + assert_success( + evidencectl() + .args(["doctor", "--project"]) + .arg(&fixture.candidate) + .output() + .expect("doctor starts"), + "target-host doctor", + ); + assert_success( + evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&fixture.candidate) + .arg("--evidence-bin") + .arg(evidence) + .output() + .expect("fixture driver starts"), + "target-host fixtures", + ); + fixture.assert_compose_revision_distinction(evidence, &revision); + + let mut https = fixture.start_https(); + fixture.wait_for_https(&mut https); + let mut service = fixture.start_evidence(evidence); + fixture.wait_for_evidence(&mut service); + + let token = fixture.access_token(); + let nonce = URL_SAFE_NO_PAD.encode([0x42_u8; 32]); + let (status, response) = post_evidence(fixture.evidence_port, &token, &nonce); + if status != 200 { + let log = fs::read(fixture.root.join("evidence.log")).expect("Evidence diagnostic log"); + let source_token = fs::read(&fixture.source_token).expect("source token"); + for prohibited in [ + token.as_bytes(), + source_token.as_slice(), + SELECTOR_CANARY.as_bytes(), + ] { + assert!( + !log.windows(prohibited.len()).any(|part| part == prohibited), + "Evidence diagnostic leaked protected request or credential data" + ); + } + panic!( + "Evidence request returned HTTP {status}, not HTTP 200; value-free log:\n{}", + String::from_utf8_lossy(&log) + ); + } + fs::write(&fixture.response, &response).expect("retain signed response"); + fs::set_permissions(&fixture.response, fs::Permissions::from_mode(0o600)) + .expect("protect retained response"); + assert!( + fixture.source_marker.is_file(), + "the real request must reach the authenticated HTTPS source" + ); + + let payload = signed_payload(&response); + assert_eq!(payload["assuranceProfile"], "production"); + assert_eq!(payload["configurationRevision"], revision); + assert_eq!(payload["supportedValues"][0]["providesValueFor"], CONCEPT); + assert_eq!(payload["supportedValues"][0]["value"], true); + let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); + for prohibited in [ + b"date_of_birth".as_slice(), + SELECTOR_CANARY.as_bytes(), + token.as_bytes(), + ] { + assert!( + !payload_bytes + .windows(prohibited.len()) + .any(|part| part == prohibited), + "signed payload retained protected source or selector data" + ); + } + let source_token = fs::read(&fixture.source_token).expect("source token"); + assert!( + !payload_bytes + .windows(source_token.len()) + .any(|part| part == source_token), + "signed payload retained a source credential" + ); + + fixture.write_verification_policy(&payload, &nonce, &revision); + assert_success( + Command::new(evidence) + .arg("verify") + .arg("--jws") + .arg(&fixture.response) + .arg("--jwks") + .arg(&fixture.evidence_jwks) + .arg("--policy") + .arg(&fixture.policy) + .output() + .expect("offline verifier starts"), + "independent production verification policy", + ); + + let audit = wait_for_audit(&fixture.audit_path); + assert_audit_contract( + &audit, + &revision, + &[source_token.as_slice(), token.as_bytes()], + ); + stop_gracefully(&mut service, "Evidence"); + stop_forcefully(&mut https); + assert_success( + Command::new(evidence) + .arg("--runtime") + .arg(fixture.candidate.join("runtime.yaml")) + .arg("verify-audit") + .output() + .expect("audit verifier starts"), + "complete audit-chain verification", + ); +} + +#[test] +#[ignore = "exact gate: starts real Mint and Evidence plus local HTTPS routing"] +fn production_candidate_accepts_a_token_from_an_independent_real_mint() { + let fixture = Fixture::new(); + let evidence = evidence_binary(); + let mint = mint_binary(); + fixture.stage_authoring_project(); + fixture.stage_https_identity(); + fixture.stage_target(); + let build = fixture.build(evidence); + let revision = bundle_revision(&build); + fixture.provision_target_secrets(); + let mint_deployment = fixture.stage_mint(); + + assert_success( + Command::new(mint) + .args(["check", "--config"]) + .arg(&mint_deployment.config) + .output() + .expect("Mint check starts"), + "real Mint deployment check", + ); + assert_success( + evidencectl() + .args(["doctor", "--project"]) + .arg(&fixture.candidate) + .arg("--mint-config") + .arg(&mint_deployment.config) + .output() + .expect("paired doctor starts"), + "paired Evidence and Mint doctor", + ); + + let mut https = fixture.start_https(); + fixture.wait_for_https(&mut https); + let mut mint_service = fixture.start_mint(mint, &mint_deployment.config); + wait_for_listener(&mut mint_service, fixture.mint_port, "Mint"); + let mut evidence_service = fixture.start_evidence(evidence); + fixture.wait_for_evidence(&mut evidence_service); + + let public_token_endpoint = format!("https://127.0.0.1:{}/token", fixture.https_port); + let token_output = Command::new(mint) + .arg("token") + .arg("--url") + .arg(&public_token_endpoint) + .arg("--audience") + .arg(public_token_endpoint) + .args(["--client-id", "acceptance-client", "--key"]) + .arg(&mint_deployment.caller_private) + .arg("--ca-certificate") + .arg(&fixture.ca) + .output() + .expect("Mint token starts"); + assert!( + token_output.status.success(), + "Mint token failed without printing a token: {}", + String::from_utf8_lossy(&token_output.stderr) + ); + let token = String::from_utf8(token_output.stdout).expect("Mint token stdout"); + assert_eq!( + token.lines().count(), + 1, + "Mint prints exactly one token line" + ); + let token = token.trim(); + + let nonce = URL_SAFE_NO_PAD.encode([0x24_u8; 32]); + let (status, response) = post_evidence(fixture.evidence_port, token, &nonce); + assert_eq!(status, 200, "a real Mint token must authorize Evidence"); + fs::write(&fixture.response, &response).expect("retain Mint-backed response"); + fs::set_permissions(&fixture.response, fs::Permissions::from_mode(0o600)) + .expect("protect Mint-backed response"); + let payload = signed_payload(&response); + assert_eq!(payload["assuranceProfile"], "production"); + assert_eq!(payload["configurationRevision"], revision); + assert_eq!(payload["supportedValues"][0]["providesValueFor"], CONCEPT); + assert_eq!(payload["supportedValues"][0]["value"], true); + assert!( + !serde_json::to_vec(&payload) + .expect("Mint-backed payload serializes") + .windows(token.len()) + .any(|part| part == token.as_bytes()), + "signed payload retained the Mint access token" + ); + + fixture.write_verification_policy(&payload, &nonce, &revision); + assert_success( + Command::new(evidence) + .arg("verify") + .arg("--jws") + .arg(&fixture.response) + .arg("--jwks") + .arg(&fixture.evidence_jwks) + .arg("--policy") + .arg(&fixture.policy) + .output() + .expect("Mint-backed response verifier starts"), + "Mint-backed independent response verification", + ); + let source_token = fs::read(&fixture.source_token).expect("source token"); + assert_audit_contract( + &wait_for_audit(&fixture.audit_path), + &revision, + &[source_token.as_slice(), token.as_bytes()], + ); + + stop_gracefully(&mut evidence_service, "Evidence"); + stop_gracefully(&mut mint_service, "Mint"); + stop_forcefully(&mut https); + assert_success( + Command::new(evidence) + .arg("--runtime") + .arg(fixture.candidate.join("runtime.yaml")) + .arg("verify-audit") + .output() + .expect("Mint-backed audit verifier starts"), + "Mint-backed complete audit-chain verification", + ); +} + +#[test] +#[ignore = "exact gate: runs the real production builder across all four authoring shapes"] +fn production_build_checks_and_evaluates_every_neutral_authoring_shape() { + let fixture = Fixture::new(); + let evidence = evidence_binary(); + fixture.stage_authoring_project(); + fixture.stage_four_shape_project(); + fixture.stage_target(); + fixture.authorize_four_shapes(); + + let output = fixture.build(evidence); + let revision = bundle_revision(&output); + fixture.provision_target_secrets(); + let (checked_revision, _) = check_revisions( + evidence, + &fixture.candidate.join("runtime.yaml"), + "published four-shape production check", + ); + assert_eq!(checked_revision, revision); + + let bundle: Value = serde_norway::from_slice( + &fs::read(fixture.candidate.join("bundle/evidence.yaml")).expect("four-shape bundle"), + ) + .expect("four-shape bundle parses"); + assert_eq!(bundle["assuranceProfile"], "production"); + let requirements = bundle["requirements"] + .as_array() + .expect("compiled requirements"); + assert_eq!(requirements.len(), 4); + let requirements = requirements + .iter() + .map(|requirement| { + ( + requirement["id"] + .as_str() + .expect("stable requirement identifier"), + requirement, + ) + }) + .collect::>(); + + assert_requirement_forms(&requirements, REQUIREMENT, &["boolean"], 1); + assert_requirement_forms(&requirements, AGE_REQUIREMENT, &["controlled-category"], 1); + assert_requirement_forms( + &requirements, + IMMUNIZATION_REQUIREMENT, + &["boolean", "bounded-integer"], + 1, + ); + assert_requirement_forms(&requirements, RELATIONSHIP_REQUIREMENT, &["boolean"], 2); + for fixture_path in [ + "adult-status.yaml", + "age-bracket.yaml", + "immunization-summary.yaml", + "parent-relationship.yaml", + ] { + assert!( + fixture + .candidate + .join("bundle/fixtures") + .join(fixture_path) + .is_file(), + "the production candidate must capture fixture {fixture_path}" + ); + } + let age_codelist = requirements[AGE_REQUIREMENT]["concepts"][0]["constraints"]["codelist"] + .as_str() + .expect("compiled controlled-category codelist path"); + assert!( + fixture + .candidate + .join("bundle") + .join(age_codelist) + .is_file(), + "the governed controlled-category codelist must be captured" + ); +} + +#[test] +#[ignore = "exact gate: starts and stops real local Evidence and Mint before production build"] +fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { + let fixture = Fixture::new(); + let evidence = evidence_binary(); + let mint = mint_binary(); + let retained_openapi = fixture.root.join("lifecycle.openapi.yaml"); + fs::write( + &retained_openapi, + "openapi: 3.1.0\ninfo: {title: Lifecycle source, version: 1.0.0}\npaths: {}\n", + ) + .expect("lifecycle OpenAPI"); + + assert_success( + evidencectl() + .arg("new") + .arg(&fixture.project) + .arg("--openapi") + .arg(&retained_openapi) + .args(["--profile", "local", "--generate-keys"]) + .output() + .expect("public new starts"), + "public new", + ); + assert!(!fixture.project.join(".evidence").exists()); + fixture.stage_local_project_without_governance(); + assert_success( + evidencectl() + .args(["keygen", "token", "--out"]) + .arg(fixture.project.join("secrets/source-token")) + .output() + .expect("local source token keygen starts"), + "local source token keygen", + ); + + let started = assert_success( + evidencectl() + .args(["dev", "--detach", "--project"]) + .arg(&fixture.project) + .arg("--evidence-bin") + .arg(evidence) + .arg("--mint-bin") + .arg(mint) + .args(["--evidence-port", &fixture.evidence_port.to_string()]) + .args(["--mint-port", &fixture.mint_port.to_string()]) + .args(["--ready-timeout-seconds", "20"]) + .output() + .expect("public dev starts"), + "public dev with omitted governance", + ); + let mut stop_guard = DevStopGuard::new(&fixture.project); + let started_stdout = String::from_utf8(started.stdout).expect("dev stdout"); + assert!(started_stdout.contains(&format!( + "Evidence ready at http://127.0.0.1:{}", + fixture.evidence_port + ))); + assert!(started_stdout.contains(&format!( + "Mint ready at http://127.0.0.1:{}", + fixture.mint_port + ))); + let dev_root = fixture.project.join(".evidence/dev"); + let local_bundle = fs::read(dev_root.join("bundle/evidence.yaml")).expect("local dev bundle"); + assert!( + local_bundle + .windows(b"urn:registrystack:evidence:local:".len()) + .any(|part| part == b"urn:registrystack:evidence:local:"), + "the governance-free dev generation must use disposable local identifiers" + ); + assert_success( + evidencectl() + .args(["dev", "stop", "--project"]) + .arg(&fixture.project) + .output() + .expect("public dev stop starts"), + "public dev stop", + ); + stop_guard.disarm(); + assert!(dev_root.join("state.json").is_file()); + let stopped_dev = snapshot_files(&dev_root); + + fixture.stage_authoring_project(); + fixture.stage_target(); + let governed_question = fs::read_to_string(fixture.project.join("questions/adult-status.yaml")) + .expect("governed question"); + assert!(governed_question.contains(&format!(" requirement: {REQUIREMENT}"))); + assert!(governed_question.contains(&format!(" id: {CONCEPT}"))); + assert!(fixture.project.join("fixtures/adult-status.yaml").is_file()); + + let local_source_token = + fs::read(fixture.project.join("secrets/source-token")).expect("local source token"); + let build = fixture.build(evidence); + bundle_revision(&build); + assert_eq!( + snapshot_files(&dev_root), + stopped_dev, + "production build must neither consume nor mutate stopped local state" + ); + let candidate = snapshot_files(&fixture.candidate); + for (path, bytes) in &candidate { + assert!( + !path.to_string_lossy().contains(".evidence"), + "production candidate captured local state at {}", + path.display() + ); + assert!( + !bytes + .windows(b"urn:registrystack:evidence:local:".len()) + .any(|part| part == b"urn:registrystack:evidence:local:"), + "production candidate retained a disposable local identifier in {}", + path.display() + ); + assert!( + !bytes + .windows(local_source_token.len()) + .any(|part| part == local_source_token), + "production candidate copied local secret material into {}", + path.display() + ); + } + let production_bundle = + fs::read(fixture.candidate.join("bundle/evidence.yaml")).expect("production bundle"); + assert!( + production_bundle + .windows(REQUIREMENT.len()) + .any(|part| part == REQUIREMENT.as_bytes()), + "production candidate must use the newly added stable governance" + ); + + assert_success( + evidencectl() + .args(["dev", "clean", "--project"]) + .arg(&fixture.project) + .output() + .expect("public dev clean starts"), + "public dev clean", + ); +} + +struct DevStopGuard { + project: PathBuf, + active: bool, +} + +impl DevStopGuard { + fn new(project: &Path) -> Self { + Self { + project: project.to_owned(), + active: true, + } + } + + fn disarm(&mut self) { + self.active = false; + } +} + +impl Drop for DevStopGuard { + fn drop(&mut self) { + if self.active { + let _ = evidencectl() + .args(["dev", "stop", "--project"]) + .arg(&self.project) + .output(); + } + } +} + +struct MintDeployment { + config: PathBuf, + caller_private: PathBuf, +} + +struct Fixture { + temporary: tempfile::TempDir, + root: PathBuf, + project: PathBuf, + target: PathBuf, + target_runtime: PathBuf, + candidate: PathBuf, + first_candidate: PathBuf, + secrets: PathBuf, + audit_path: PathBuf, + ca: PathBuf, + tls_cert: PathBuf, + tls_key: PathBuf, + oidc_private: PathBuf, + oidc_jwks: PathBuf, + source_token: PathBuf, + source_marker: PathBuf, + https_ready: PathBuf, + response: PathBuf, + evidence_jwks: PathBuf, + policy: PathBuf, + https_port: u16, + evidence_port: u16, + mint_port: u16, +} + +impl Fixture { + fn new() -> Self { + // macOS exposes its default temporary root through `/var`, which is a + // symlink. Production build correctly refuses that ancestry, so keep + // the exact gate under the workspace's already-created target tree. + let temporary = tempfile::Builder::new() + .prefix("production-handoff-") + .tempdir_in(workspace_root().join("target")) + .expect("acceptance tempdir"); + let root = temporary.path().to_path_buf(); + let project = root.join("authoring"); + let target = project.join("deployment-targets/production"); + let candidate = root.join("candidate"); + let secrets = root.join("production-secrets"); + let ports = free_ports(3); + Self { + target_runtime: target.join("runtime.yaml"), + first_candidate: root.join("first-candidate"), + audit_path: root.join("audit/evidence.jsonl"), + ca: root.join("tls/ca.pem"), + tls_cert: root.join("tls/server.pem"), + tls_key: root.join("tls/server.key"), + oidc_private: root.join("oidc-private/signing-ed25519-private-jwk"), + oidc_jwks: root.join("oidc.jwks.json"), + source_token: secrets.join("source-token"), + source_marker: root.join("source-requested"), + https_ready: root.join("https-ready"), + response: root.join("response.jws.json"), + evidence_jwks: root.join("evidence.jwks.json"), + policy: root.join("verification-policy.yaml"), + https_port: ports[0], + evidence_port: ports[1], + mint_port: ports[2], + temporary, + root, + project, + target, + candidate, + secrets, + } + } + + fn stage_authoring_project(&self) { + for directory in [ + "selectors", + "sources", + "adapters", + "schemas", + "questions", + "derivations", + "fixtures", + ] { + fs::create_dir_all(self.project.join(directory)).expect("authoring directory"); + } + fs::write( + self.project.join("source.openapi.yaml"), + "openapi: 3.1.0\ninfo: {title: Acceptance source, version: 1.0.0}\npaths: {}\n", + ) + .expect("OpenAPI"); + fs::write( + self.project.join("selectors/person-reference-v1.yaml"), + "maximumAggregateBytes: 200\nfields:\n person_id: {type: string, minimumBytes: 1, maximumBytes: 200}\n", + ) + .expect("selector"); + let source_origin = format!("https://127.0.0.1:{}", self.https_port); + fs::write( + self.project.join("sources/people.yaml"), + format!( + r#"transport: http-json +baseUrl: {source_origin} +posture: field-projected +authentication: {{kind: static-bearer, tokenRef: 'secret:file/source-token'}} +request: + method: POST + path: /v1/facts + fixedHeaders: [{{name: Accept, value: application/json}}] + selectorInputs: + - role: subject + alternatives: + - {{profile: person-reference-v1, fields: [person_id]}} + prepareScript: adapters/people-prepare.rhai + adapterParameters: {{requestedFields: [date_of_birth], resultLimit: 2}} + adapterParametersSchema: schemas/people-parameters.schema.yaml + preparationLimits: {{query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096}} + projection: [/total, /date_of_birth] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/people-response.schema.yaml +extractScript: adapters/people-extract.rhai +factSchema: schemas/people-facts.schema.yaml +"# + ), + ) + .expect("source"); + for (path, contents) in [ + ( + "adapters/people-prepare.rhai", + r#"fn prepare(selectors, parameters) { + #{ + query: [], + body: #{ + lookup: #{person_id: selectors["subject"]["values"]["person_id"]}, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} +"#, + ), + ( + "adapters/people-extract.rhai", + r#"fn extract(source_response, parameters) { + let total = source_response["total"]; + if total == 0 { return #{outcome: "no_match"}; } + if total > 1 { return #{outcome: "ambiguous"}; } + let value = get_path(source_response, "/date_of_birth"); + if is_missing(value) { return #{outcome: "match", facts: #{}}; } + #{outcome: "match", facts: #{date_of_birth: value}} +} +"#, + ), + ( + "schemas/people-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [requestedFields, resultLimit]\nproperties:\n requestedFields: {const: [date_of_birth]}\n resultLimit: {const: 2}\n", + ), + ( + "schemas/people-response.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [total]\nproperties:\n total: {type: integer, minimum: 0, maximum: 1000000}\n date_of_birth: {type: string, format: date}\n", + ), + ( + "schemas/people-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [date_of_birth]\nproperties:\n date_of_birth: {type: string, format: date}\n", + ), + ] { + fs::write(self.project.join(path), contents).expect("source artifact"); + } + fs::write( + self.project.join("questions/adult-status.yaml"), + format!( + r#"id: adult-status +question: Is the person at least 18 years old? +purpose: {PURPOSE} +subject: + role: subject + selector: person_id + profile: person-reference-v1 +source: + ref: people +answers: + - concept: is_adult + id: {CONCEPT} + type: boolean +derivation: derivations/adult-status.rhai +disclosure: + allow: [is_adult] +governance: + requirement: {REQUIREMENT} + kind: criterion + referenceFrameworks: [urn:example:frameworks:adult-status:v1] + evidenceType: {EVIDENCE_TYPE} + validitySeconds: 86400 + observationTimezone: Asia/Bangkok + fixtures: fixtures/adult-status.yaml + disclosureFamilies: [urn:example:disclosure-families:adult-status] +"# + ), + ) + .expect("question"); + fs::write( + self.project.join("derivations/adult-status.rhai"), + r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + #{is_adult: compare_dates(context.legal_local_date, add_calendar_years(born, 18)) >= 0} +} +"#, + ) + .expect("derivation"); + fs::write( + self.project.join("fixtures/adult-status.yaml"), + format!( + r#"fixture: registry.evidence.acceptance.production-handoff/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {{person_id: {SELECTOR_CANARY}}} + selectors: + subject: {{profile: person-reference-v1, values: {{person_id: {SELECTOR_CANARY}}}}} + expectedRequestParts: + query: [] + body: {{lookup: {{person_id: {SELECTOR_CANARY}}}, fields: [date_of_birth], limit: 2}} + expectedTransport: + path: /v1/facts + fixedHeaders: [{{name: Accept, value: application/json}}] +cases: + - {{id: positive, source: {{total: 1, date_of_birth: '2000-01-01'}}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: negative-false-is-success, source: {{total: 1, date_of_birth: '2010-01-01'}}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: boundary-on, legal_local_date: '2026-08-02', source: {{total: 1, date_of_birth: '2008-08-02'}}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: missing-fact, source: {{total: 1}}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: no-match, source: {{total: 0}}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: ambiguous, source: {{total: 2}}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false}} + - {{id: negative-wrong-derived-type, injected_derivation: [{{concept_id: {CONCEPT}, value: 'true'}}], expected: output-gate-rejection}} + - {{id: anti-reconstruction, companion_bundle: threshold-ladder, expected: bundle-rejection}} +privacy_expectation: + evidence_contains: [{CONCEPT}] + evidence_excludes: [date_of_birth, person_id] + diagnostics_exclude: [{SELECTOR_CANARY}, fixture-source-canary] +"# + ), + ) + .expect("fixture"); + } + + fn stage_local_project_without_governance(&self) { + self.stage_authoring_project(); + let question_path = self.project.join("questions/adult-status.yaml"); + let question = fs::read_to_string(&question_path).expect("governed adult question"); + let governance = question + .find("governance:\n") + .expect("adult question governance block"); + let question = question[..governance].replace(&format!(" id: {CONCEPT}\n"), ""); + fs::write(&question_path, question).expect("governance-free local question"); + fs::remove_file(self.project.join("fixtures/adult-status.yaml")) + .expect("withhold production fixture during local dev"); + assert!( + !fs::read_to_string(question_path) + .expect("local question") + .contains("governance:"), + "local dev must begin before stable governance exists" + ); + } + + fn stage_four_shape_project(&self) { + for (profile, field) in [ + ("child-reference-v1", "child_id"), + ("candidate-reference-v1", "candidate_id"), + ] { + fs::write( + self.project.join(format!("selectors/{profile}.yaml")), + format!( + "maximumAggregateBytes: 200\nfields:\n {field}: {{type: string, minimumBytes: 1, maximumBytes: 200}}\n" + ), + ) + .expect("role-bound selector"); + } + + let source_origin = format!("https://127.0.0.1:{}", self.https_port); + fs::write( + self.project.join("sources/immunizations.yaml"), + format!( + r#"transport: http-json +baseUrl: {source_origin} +posture: field-projected +authentication: {{kind: static-bearer, tokenRef: 'secret:file/source-token'}} +request: + method: POST + path: /v1/immunizations + fixedHeaders: [{{name: Accept, value: application/json}}] + selectorInputs: + - role: subject + alternatives: + - {{profile: person-reference-v1, fields: [person_id]}} + prepareScript: adapters/immunizations-prepare.rhai + adapterParameters: {{requestedFields: [dose_count], resultLimit: 2}} + adapterParametersSchema: schemas/immunizations-parameters.schema.yaml + preparationLimits: {{query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096}} + projection: [/total, /dose_count] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/immunizations-response.schema.yaml +extractScript: adapters/immunizations-extract.rhai +factSchema: schemas/immunizations-facts.schema.yaml +"# + ), + ) + .expect("immunization source"); + fs::write( + self.project.join("sources/relationships.yaml"), + format!( + r#"transport: http-json +baseUrl: {source_origin} +posture: field-projected +authentication: {{kind: static-bearer, tokenRef: 'secret:file/source-token'}} +request: + method: POST + path: /v1/relationships + fixedHeaders: [{{name: Accept, value: application/json}}] + selectorInputs: + - role: child + alternatives: + - {{profile: child-reference-v1, fields: [child_id]}} + - role: candidate-parent + alternatives: + - {{profile: candidate-reference-v1, fields: [candidate_id]}} + prepareScript: adapters/relationships-prepare.rhai + adapterParameters: {{requestedFields: [relationship_confirmed], resultLimit: 2}} + adapterParametersSchema: schemas/relationships-parameters.schema.yaml + preparationLimits: {{query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096}} + projection: [/total, /relationship_confirmed] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +responseSchema: schemas/relationships-response.schema.yaml +extractScript: adapters/relationships-extract.rhai +factSchema: schemas/relationships-facts.schema.yaml +"# + ), + ) + .expect("relationship source"); + + for (path, contents) in [ + ( + "adapters/immunizations-prepare.rhai", + r#"fn prepare(selectors, parameters) { + #{ + query: [], + body: #{ + lookup: #{person_id: selectors["subject"]["values"]["person_id"]}, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} +"#, + ), + ( + "adapters/immunizations-extract.rhai", + r#"fn extract(source_response, parameters) { + let total = source_response["total"]; + if total == 0 { return #{outcome: "no_match"}; } + if total > 1 { return #{outcome: "ambiguous"}; } + let value = get_path(source_response, "/dose_count"); + if is_missing(value) { return #{outcome: "match", facts: #{}}; } + #{outcome: "match", facts: #{dose_count: value}} +} +"#, + ), + ( + "adapters/relationships-prepare.rhai", + r#"fn prepare(selectors, parameters) { + #{ + query: [], + body: #{ + lookup: #{ + child_id: selectors["child"]["values"]["child_id"], + candidate_id: selectors["candidate-parent"]["values"]["candidate_id"] + }, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} +"#, + ), + ( + "adapters/relationships-extract.rhai", + r#"fn extract(source_response, parameters) { + let total = source_response["total"]; + if total == 0 { return #{outcome: "no_match"}; } + if total > 1 { return #{outcome: "ambiguous"}; } + let value = get_path(source_response, "/relationship_confirmed"); + if is_missing(value) { return #{outcome: "match", facts: #{}}; } + #{outcome: "match", facts: #{relationship_confirmed: value}} +} +"#, + ), + ( + "schemas/immunizations-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [requestedFields, resultLimit]\nproperties:\n requestedFields: {const: [dose_count]}\n resultLimit: {const: 2}\n", + ), + ( + "schemas/immunizations-response.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [total]\nproperties:\n total: {type: integer, minimum: 0, maximum: 1000000}\n dose_count: {type: integer, minimum: 0, maximum: 20}\n", + ), + ( + "schemas/immunizations-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [dose_count]\nproperties:\n dose_count: {type: integer, minimum: 0, maximum: 20}\n", + ), + ( + "schemas/relationships-parameters.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [requestedFields, resultLimit]\nproperties:\n requestedFields: {const: [relationship_confirmed]}\n resultLimit: {const: 2}\n", + ), + ( + "schemas/relationships-response.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [total]\nproperties:\n total: {type: integer, minimum: 0, maximum: 1000000}\n relationship_confirmed: {type: boolean}\n", + ), + ( + "schemas/relationships-facts.schema.yaml", + "type: object\nadditionalProperties: false\nrequired: [relationship_confirmed]\nproperties:\n relationship_confirmed: {type: boolean}\n", + ), + ] { + fs::write(self.project.join(path), contents).expect("four-shape source artifact"); + } + + self.stage_four_shape_questions(); + self.stage_four_shape_fixtures(); + } + + fn stage_four_shape_questions(&self) { + for (path, contents) in [ + ( + "questions/age-bracket.yaml", + format!( + r#"id: age-bracket +question: Which governed age bracket contains this person? +purpose: service-path-selection +subject: + role: subject + selector: person_id + profile: person-reference-v1 +source: + ref: people +answers: + - concept: age_bracket + id: {AGE_CONCEPT} + type: controlled-category + values: [under-18, 18-to-24, 25-to-64, 65-or-older] +derivation: derivations/age-bracket.rhai +disclosure: + allow: [age_bracket] +governance: + requirement: {AGE_REQUIREMENT} + kind: information-requirement + referenceFrameworks: [urn:example:frameworks:age-bracket:v1] + evidenceType: urn:example:evidence-types:age-bracket:v1 + validitySeconds: 86400 + observationTimezone: Asia/Bangkok + fixtures: fixtures/age-bracket.yaml + disclosureFamilies: [urn:example:disclosure-families:age-bracket] +"# + ), + ), + ( + "questions/immunization-summary.yaml", + format!( + r#"id: immunization-summary +question: Is the schedule complete, and how many doses are recorded? +purpose: care-coordination +subject: + role: subject + selector: person_id + profile: person-reference-v1 +source: + ref: immunizations +answers: + - concept: schedule_complete + id: {SCHEDULE_CONCEPT} + type: boolean + - concept: dose_count + id: {DOSE_COUNT_CONCEPT} + type: bounded-integer + minimum: 0 + maximum: 20 +derivation: derivations/immunization-summary.rhai +disclosure: + allow: [schedule_complete, dose_count] +governance: + requirement: {IMMUNIZATION_REQUIREMENT} + kind: information-requirement + referenceFrameworks: [urn:example:frameworks:immunization-summary:v1] + evidenceType: urn:example:evidence-types:immunization-summary:v1 + validitySeconds: 86400 + observationTimezone: Asia/Bangkok + fixtures: fixtures/immunization-summary.yaml + disclosureFamilies: [urn:example:disclosure-families:immunization-summary] +"# + ), + ), + ( + "questions/parent-relationship.yaml", + format!( + r#"id: parent-relationship +question: Is the candidate registered as a parent of the child? +purpose: relationship-check +subjects: + - role: child + selector: child_id + profile: child-reference-v1 + - role: candidate-parent + selector: candidate_id + profile: candidate-reference-v1 +source: + ref: relationships +answers: + - concept: relationship_confirmed + id: {RELATIONSHIP_CONCEPT} + type: boolean +derivation: derivations/parent-relationship.rhai +disclosure: + allow: [relationship_confirmed] +governance: + requirement: {RELATIONSHIP_REQUIREMENT} + kind: criterion + referenceFrameworks: [urn:example:frameworks:parent-relationship:v1] + evidenceType: urn:example:evidence-types:parent-relationship:v1 + validitySeconds: 86400 + observationTimezone: Asia/Bangkok + fixtures: fixtures/parent-relationship.yaml + disclosureFamilies: [urn:example:disclosure-families:parent-relationship] +"# + ), + ), + ] { + fs::write(self.project.join(path), contents).expect("four-shape question"); + } + for (path, contents) in [ + ( + "derivations/age-bracket.rhai", + r#"fn answer(facts, selectors, context) { + let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); + if compare_dates(context.legal_local_date, add_calendar_years(born, 18)) < 0 { + #{age_bracket: "under-18"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 25)) < 0 { + #{age_bracket: "18-to-24"} + } else if compare_dates(context.legal_local_date, add_calendar_years(born, 65)) < 0 { + #{age_bracket: "25-to-64"} + } else { + #{age_bracket: "65-or-older"} + } +} +"#, + ), + ( + "derivations/immunization-summary.rhai", + r#"fn answer(facts, selectors, context) { + let dose_count = required(facts.dose_count, "dose_count_missing"); + #{schedule_complete: dose_count >= 3, dose_count: dose_count} +} +"#, + ), + ( + "derivations/parent-relationship.rhai", + r#"fn answer(facts, selectors, context) { + #{relationship_confirmed: required(facts.relationship_confirmed, "relationship_missing")} +} +"#, + ), + ] { + fs::write(self.project.join(path), contents).expect("four-shape derivation"); + } + } + + fn stage_four_shape_fixtures(&self) { + fs::write( + self.project.join("fixtures/age-bracket.yaml"), + format!( + r#"fixture: registry.evidence.acceptance.production-age-bracket/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {{person_id: {SELECTOR_CANARY}}} + selectors: + subject: {{profile: person-reference-v1, values: {{person_id: {SELECTOR_CANARY}}}}} + expectedRequestParts: + query: [] + body: {{lookup: {{person_id: {SELECTOR_CANARY}}}, fields: [date_of_birth], limit: 2}} + expectedTransport: + path: /v1/facts + fixedHeaders: [{{name: Accept, value: application/json}}] +cases: + - {{id: positive, source: {{total: 1, date_of_birth: '2000-01-01'}}, expected_value: 25-to-64, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: negative-under-18-is-success, source: {{total: 1, date_of_birth: '2010-01-01'}}, expected_value: under-18, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: boundary-on-18, source: {{total: 1, date_of_birth: '2008-08-02'}}, expected_value: 18-to-24, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: missing-fact, source: {{total: 1}}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: no-match, source: {{total: 0}}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: ambiguous, source: {{total: 2}}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false}} + - {{id: negative-wrong-derived-type, injected_derivation: [{{concept_id: {AGE_CONCEPT}, value: true}}], expected: output-gate-rejection}} + - {{id: anti-reconstruction, companion_bundle: threshold-ladder, expected: bundle-rejection}} +privacy_expectation: + evidence_contains: [{AGE_CONCEPT}] + evidence_excludes: [date_of_birth, person_id] + diagnostics_exclude: [{SELECTOR_CANARY}, fixture-source-canary] +"# + ), + ) + .expect("age-bracket fixture"); + + fs::write( + self.project.join("fixtures/immunization-summary.yaml"), + format!( + r#"fixture: registry.evidence.acceptance.production-immunization-summary/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selector: {{person_id: {SELECTOR_CANARY}}} + selectors: + subject: {{profile: person-reference-v1, values: {{person_id: {SELECTOR_CANARY}}}}} + expectedRequestParts: + query: [] + body: {{lookup: {{person_id: {SELECTOR_CANARY}}}, fields: [dose_count], limit: 2}} + expectedTransport: + path: /v1/immunizations + fixedHeaders: [{{name: Accept, value: application/json}}] +cases: + - id: positive + source: {{total: 1, dose_count: 4}} + expected_values: {{schedule-complete: true, dose-count: 4}} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: negative-false-is-success + source: {{total: 1, dose_count: 2}} + expected_values: {{schedule-complete: false, dose-count: 2}} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-maximum + source: {{total: 1, dose_count: 20}} + expected_values: {{schedule-complete: true, dose-count: 20}} + expected_lookup: match + derivation_runs: true + signed_success: true + - {{id: missing-fact, source: {{total: 1}}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: no-match, source: {{total: 0}}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: ambiguous, source: {{total: 2}}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false}} + - id: negative-wrong-derived-type + injected_derivation: + - {{concept_id: {SCHEDULE_CONCEPT}, value: true}} + - {{concept_id: {DOSE_COUNT_CONCEPT}, value: '4'}} + expected: output-gate-rejection + - {{id: anti-reconstruction, companion_bundle: threshold-ladder, expected: bundle-rejection}} +privacy_expectation: + evidence_contains: [{SCHEDULE_CONCEPT}, {DOSE_COUNT_CONCEPT}] + evidence_excludes: [dose_count, person_id] + diagnostics_exclude: [{SELECTOR_CANARY}, fixture-source-canary] +"# + ), + ) + .expect("immunization fixture"); + + fs::write( + self.project.join("fixtures/parent-relationship.yaml"), + format!( + r#"fixture: registry.evidence.acceptance.production-parent-relationship/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selectors: + child: {{profile: child-reference-v1, values: {{child_id: synthetic-child-001}}}} + candidate-parent: {{profile: candidate-reference-v1, values: {{candidate_id: synthetic-parent-001}}}} + expectedRequestParts: + query: [] + body: + lookup: {{child_id: synthetic-child-001, candidate_id: synthetic-parent-001}} + fields: [relationship_confirmed] + limit: 2 + expectedTransport: + path: /v1/relationships + fixedHeaders: [{{name: Accept, value: application/json}}] +cases: + - {{id: positive, source: {{total: 1, relationship_confirmed: true}}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true}} + - {{id: negative-false-is-success, source: {{total: 1, relationship_confirmed: false}}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true}} + - id: boundary-role-order + source: {{total: 1, relationship_confirmed: true}} + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + expected_subject_roles: [child, candidate-parent] + - {{id: missing-fact, source: {{total: 1}}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: no-match, source: {{total: 0}}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: ambiguous, source: {{total: 2}}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false}} + - {{id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false}} + - {{id: negative-wrong-derived-type, injected_derivation: [{{concept_id: {RELATIONSHIP_CONCEPT}, value: 'true'}}], expected: output-gate-rejection}} + - {{id: anti-reconstruction, companion_bundle: relationship-graph, expected: bundle-rejection}} +privacy_expectation: + evidence_contains: [child, candidate-parent, {RELATIONSHIP_CONCEPT}] + evidence_excludes: [child_id, candidate_id, relationship_confirmed] + diagnostics_exclude: [synthetic-child-001, synthetic-parent-001, fixture-source-canary] +"# + ), + ) + .expect("parent relationship fixture"); + } + + fn stage_https_identity(&self) { + let tls = self.ca.parent().expect("TLS directory"); + fs::create_dir(tls).expect("TLS directory"); + fs::write( + tls.join("server.cnf"), + "[server]\nsubjectAltName = IP:127.0.0.1\nbasicConstraints = critical,CA:FALSE\nkeyUsage = critical,digitalSignature,keyEncipherment\nextendedKeyUsage = serverAuth\n", + ) + .expect("OpenSSL config"); + let ca_key = tls.join("ca.key"); + assert_success( + Command::new("openssl") + .args(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256"]) + .args(["-days", "1", "-keyout"]) + .arg(&ca_key) + .arg("-out") + .arg(&self.ca) + .args(["-subj", "/CN=Evidence acceptance CA"]) + .output() + .expect("openssl starts"), + "test HTTPS CA generation", + ); + let csr = tls.join("server.csr"); + assert_success( + Command::new("openssl") + .args(["req", "-new", "-newkey", "rsa:2048", "-nodes", "-sha256"]) + .arg("-keyout") + .arg(&self.tls_key) + .arg("-out") + .arg(&csr) + .args(["-subj", "/CN=127.0.0.1"]) + .output() + .expect("openssl starts"), + "test HTTPS leaf-key generation", + ); + assert_success( + Command::new("openssl") + .args(["x509", "-req", "-sha256", "-days", "1", "-in"]) + .arg(&csr) + .arg("-CA") + .arg(&self.ca) + .arg("-CAkey") + .arg(&ca_key) + .arg("-CAcreateserial") + .arg("-out") + .arg(&self.tls_cert) + .arg("-extfile") + .arg(tls.join("server.cnf")) + .args(["-extensions", "server"]) + .output() + .expect("openssl starts"), + "test HTTPS leaf certificate generation", + ); + fs::set_permissions(&self.tls_key, fs::Permissions::from_mode(0o600)) + .expect("TLS key mode"); + fs::set_permissions(&self.ca, fs::Permissions::from_mode(0o444)).expect("CA mode"); + + let public = self.root.join("oidc-public.jwk.json"); + assert_success( + evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(self.oidc_private.parent().expect("OIDC private directory")) + .args(["--kid", AUTH_KEY_ID, "--public-out"]) + .arg(&public) + .output() + .expect("OIDC keygen starts"), + "external OIDC signing key generation", + ); + assert_success( + evidencectl() + .args(["jwks", "--out"]) + .arg(&self.oidc_jwks) + .arg(&public) + .output() + .expect("OIDC JWKS assembly starts"), + "external OIDC JWKS assembly", + ); + } + + fn stage_target(&self) { + fs::create_dir_all(&self.target).expect("production target"); + let identity = format!("https://127.0.0.1:{}", self.https_port); + fs::write( + self.target.join("governance.yaml"), + format!( + r#"version: 1 +assuranceProfile: production +service: {{providerId: urn:example:providers:evidence, trustDomain: urn:example:trust-domains:acceptance}} +issuer: {{id: urn:example:issuers:evidence}} +authentication: + kind: oidc-access-token + issuer: {identity} + audiences: [{TOKEN_AUDIENCE}] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: {identity}/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: {{format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true}} +subjectBinding: {{secretRef: 'secret:file/subject-binding-hmac-key', keyVersion: 1}} +rateLimits: {{requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}} +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: {SIGNING_KEY_ID} + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: {REQUIREMENT} + purpose: {PURPOSE} + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: [{{role: subject, selectorProfile: person-reference-v1, valueOrigin: request}}] +"# + ), + ) + .expect("governance"); + fs::write( + &self.target_runtime, + format!( + "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 5000\nsecretProviders:\n file:\n root: {secrets}\nauditStorage:\n path: {audit}\n maximumFileBytes: 1048576\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", + bundle = self.candidate.join("bundle").display(), + port = self.evidence_port, + secrets = self.secrets.display(), + audit = self.audit_path.display(), + ), + ) + .expect("runtime"); + } + + fn authorize_four_shapes(&self) { + let path = self.target.join("governance.yaml"); + let mut governance = fs::read_to_string(&path).expect("production governance"); + governance.push_str(&format!( + r#" - requirement: {AGE_REQUIREMENT} + purpose: service-path-selection + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: [{{role: subject, selectorProfile: person-reference-v1, valueOrigin: request}}] + - requirement: {IMMUNIZATION_REQUIREMENT} + purpose: care-coordination + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: [{{role: subject, selectorProfile: person-reference-v1, valueOrigin: request}}] + - requirement: {RELATIONSHIP_REQUIREMENT} + purpose: relationship-check + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - {{role: child, selectorProfile: child-reference-v1, valueOrigin: request}} + - {{role: candidate-parent, selectorProfile: candidate-reference-v1, valueOrigin: request}} +"# + )); + fs::write(path, governance).expect("four-shape production governance"); + } + + fn build(&self, evidence: &Path) -> Output { + let output = evidencectl() + .arg("build") + .arg("--project") + .arg(&self.project) + .arg("--target") + .arg(&self.target) + .arg("--output") + .arg(&self.candidate) + .env("EVIDENCE_BIN", evidence) + .output() + .expect("build starts"); + assert_success(output, "production build") + } + + fn provision_target_secrets(&self) { + fs::create_dir(self.audit_path.parent().expect("audit directory")) + .expect("audit directory"); + fs::set_permissions( + self.audit_path.parent().expect("audit directory"), + fs::Permissions::from_mode(0o700), + ) + .expect("audit directory mode"); + let public = self.root.join("evidence-public.jwk.json"); + assert_success( + evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&self.secrets) + .args(["--kid", SIGNING_KEY_ID, "--public-out"]) + .arg(&public) + .output() + .expect("Evidence signing keygen starts"), + "independent Evidence signing key generation", + ); + for name in ["audit-hmac-key", "subject-binding-hmac-key"] { + assert_success( + evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(self.secrets.join(name)) + .output() + .expect("HMAC keygen starts"), + "independent HMAC generation", + ); + } + assert_success( + evidencectl() + .args(["keygen", "token", "--out"]) + .arg(&self.source_token) + .output() + .expect("source token keygen starts"), + "independent source credential generation", + ); + assert_success( + evidencectl() + .args(["jwks", "--out"]) + .arg(&self.evidence_jwks) + .arg(public) + .output() + .expect("Evidence JWKS assembly starts"), + "trusted Evidence JWKS assembly", + ); + } + + fn assert_compose_revision_distinction(&self, evidence: &Path, revision: &str) { + let (host_bundle, host_runtime) = check_revisions( + evidence, + &self.candidate.join("runtime.yaml"), + "host runtime check", + ); + assert_eq!(host_bundle, revision); + + let compose = self.root.join("compose-adapter"); + fs::create_dir(&compose).expect("Compose adapter directory"); + let runtime = compose.join("runtime.yaml"); + fs::write( + &runtime, + format!( + "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 131072\n maximumConcurrentRequests: 32\n requestTimeoutMilliseconds: 15000\n shutdownGraceMilliseconds: 10000\nsecretProviders:\n file:\n root: {secrets}\nauditStorage:\n path: {audit}\n maximumFileBytes: 2097152\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", + // This absolute host path stands for the unchanged read-only + // candidate/bundle mount in the container execution context. + bundle = self.candidate.join("bundle").display(), + port = free_port(), + secrets = self.secrets.display(), + audit = compose.join("persistent-audit/evidence.jsonl").display(), + ), + ) + .expect("Compose runtime"); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o400)) + .expect("seal Compose runtime"); + + let unchanged_bundle = snapshot_files(&self.candidate.join("bundle")); + let (compose_bundle, compose_runtime) = + check_revisions(evidence, &runtime, "Compose-context runtime check"); + assert_eq!( + snapshot_files(&self.candidate.join("bundle")), + unchanged_bundle, + "the Compose adapter must not edit the governed bundle" + ); + assert_eq!(compose_bundle, revision); + assert_ne!( + compose_runtime, host_runtime, + "environment-specific runtime bindings require an independent runtime revision" + ); + } + + fn stage_mint(&self) -> MintDeployment { + let mint = self.root.join("mint"); + let clients = mint.join("clients"); + fs::create_dir_all(&clients).expect("Mint client registry"); + + let mint_public = mint.join("mint-public.jwk.json"); + assert_success( + evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(mint.join("secrets")) + .args(["--kid", "mint-signing-key-1", "--public-out"]) + .arg(&mint_public) + .output() + .expect("Mint signing keygen starts"), + "independent Mint signing key generation", + ); + let audit = mint.join("audit"); + fs::create_dir(&audit).expect("Mint audit directory"); + fs::set_permissions(&audit, fs::Permissions::from_mode(0o700)) + .expect("Mint audit directory mode"); + let audit_key = mint.join("secrets/mint-audit-hmac-key"); + let mut audit_key_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&audit_key) + .expect("Mint audit key"); + audit_key_file + .write_all(b"production-handoff-mint-audit-key") + .expect("Mint audit key contents"); + audit_key_file.sync_all().expect("sync Mint audit key"); + assert_eq!( + fs::metadata(&audit) + .expect("Mint audit metadata") + .permissions() + .mode() + & 0o777, + 0o700, + ); + assert_eq!( + fs::metadata(&audit_key) + .expect("Mint audit key metadata") + .permissions() + .mode() + & 0o777, + 0o600, + ); + let caller_public = mint.join("caller-public.jwk.json"); + let caller_directory = mint.join("caller"); + assert_success( + evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&caller_directory) + .args(["--kid", "acceptance-client-key-1", "--public-out"]) + .arg(&caller_public) + .output() + .expect("Mint caller keygen starts"), + "independent Mint caller key generation", + ); + let caller_jwk: Value = + serde_json::from_slice(&fs::read(&caller_public).expect("Mint caller public JWK")) + .expect("Mint caller public JWK parses"); + fs::write( + clients.join("acceptance-client.yaml"), + format!( + "clientId: acceptance-client\nprincipal: urn:example:principals:acceptance-client\nevidenceAudience: {EVIDENCE_AUDIENCE}\nrequesterTags: [fixture-agency]\nkeys: [{}]\n", + serde_json::to_string(&caller_jwk).expect("caller JWK serializes") + ), + ) + .expect("Mint client registration"); + + // The HTTPS process now publishes Mint's public signing key at the + // configured public identity. Mint itself remains on a private plain + // HTTP listener behind that operator-owned route. + fs::remove_file(&self.oidc_jwks).expect("replace external IdP JWKS for Mint path"); + assert_success( + evidencectl() + .args(["jwks", "--out"]) + .arg(&self.oidc_jwks) + .arg(&mint_public) + .output() + .expect("Mint JWKS assembly starts"), + "Mint public JWKS assembly", + ); + + let identity = format!("https://127.0.0.1:{}", self.https_port); + let config = mint.join("mint.yaml"); + fs::write( + &config, + format!( + "version: 1\nissuer: {identity}\nlistener: {{address: 127.0.0.1, port: {port}}}\nsigning:\n algorithm: EdDSA\n activeKeyId: mint-signing-key-1\n activeKeyFile: secrets/signing-ed25519-private-jwk\naudit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyFile: secrets/mint-audit-hmac-key\n hashKeyVersion: 1\naccessTokens:\n audiences: [{TOKEN_AUDIENCE}]\n lifetimeSeconds: 300\n claims:\n principal: sub\n requesterTags: evidence_tags\n evidenceAudience: evidence_audience\n grantId: evidence_grant_id\n grantAuthority: evidence_authority\nclientAssertion:\n audience: {identity}/token\n algorithms: [EdDSA]\nclients:\n directory: clients\n", + port = self.mint_port, + ), + ) + .expect("Mint config"); + MintDeployment { + config, + caller_private: caller_directory.join("signing-ed25519-private-jwk"), + } + } + + fn start_https(&self) -> Child { + Command::new("python3") + .arg( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/support/production_handoff_https.py"), + ) + .env("ACCEPTANCE_HTTPS_PORT", self.https_port.to_string()) + .env("ACCEPTANCE_TLS_CERT", &self.tls_cert) + .env("ACCEPTANCE_TLS_KEY", &self.tls_key) + .env("ACCEPTANCE_JWKS", &self.oidc_jwks) + .env("ACCEPTANCE_MINT_PORT", self.mint_port.to_string()) + .env("ACCEPTANCE_SOURCE_TOKEN", &self.source_token) + .env("ACCEPTANCE_SOURCE_MARKER", &self.source_marker) + .env("ACCEPTANCE_READY", &self.https_ready) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("local HTTPS process starts") + } + + fn wait_for_https(&self, child: &mut Child) { + wait_for(Duration::from_secs(10), || { + assert_running(child, "local HTTPS"); + self.https_ready.is_file() + }); + } + + fn start_evidence(&self, evidence: &Path) -> Child { + let log = owner_only_log(&self.root.join("evidence.log")); + Command::new(evidence) + .arg("--runtime") + .arg(self.candidate.join("runtime.yaml")) + .arg("serve") + .env("SSL_CERT_FILE", &self.ca) + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone().expect("clone Evidence log"))) + .stderr(Stdio::from(log)) + .spawn() + .expect("Evidence service starts") + } + + fn start_mint(&self, mint: &Path, config: &Path) -> Child { + let log = owner_only_log(&self.root.join("mint.log")); + Command::new(mint) + .args(["serve", "--config"]) + .arg(config) + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone().expect("clone Mint log"))) + .stderr(Stdio::from(log)) + .spawn() + .expect("Mint service starts") + } + + fn wait_for_evidence(&self, child: &mut Child) { + wait_for(Duration::from_secs(20), || { + assert_running(child, "Evidence"); + http_status(self.evidence_port, "/ready") == Some(200) + }); + } + + fn access_token(&self) -> String { + let private: Value = serde_json::from_slice( + &fs::read(&self.oidc_private).expect("read external OIDC private JWK"), + ) + .expect("external OIDC private JWK parses"); + let secret = URL_SAFE_NO_PAD + .decode(private["d"].as_str().expect("private JWK d")) + .expect("private JWK d decodes"); + let secret: [u8; 32] = secret.try_into().expect("Ed25519 seed length"); + let key = SigningKey::from_bytes(&secret); + let identity = format!("https://127.0.0.1:{}", self.https_port); + let now = Utc::now().timestamp(); + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({"alg":"EdDSA","kid":AUTH_KEY_ID,"typ":"at+jwt"})) + .expect("JWT header"), + ); + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "iss": identity, + "aud": TOKEN_AUDIENCE, + "sub": "synthetic-caller", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE, + })) + .expect("JWT claims"), + ); + let input = format!("{header}.{claims}"); + let signature = URL_SAFE_NO_PAD.encode(key.sign(input.as_bytes()).to_bytes()); + format!("{input}.{signature}") + } + + fn write_verification_policy(&self, payload: &Value, nonce: &str, revision: &str) { + let binding = payload["subjects"][0]["binding"] + .as_str() + .expect("accepted transaction subject binding"); + let policy = json!({ + "expectedAssuranceProfile": "production", + "issuedBy": "urn:example:issuers:evidence", + "providedBy": "urn:example:providers:evidence", + "requirement": REQUIREMENT, + "evidenceType": EVIDENCE_TYPE, + "purpose": PURPOSE, + "audience": EVIDENCE_AUDIENCE, + "configurationRevision": revision, + "requestNonce": nonce, + // A relying party retains this opaque binding from the accepted + // first transaction. Every other expectation is independently + // controlled by the target and retained request in this fixture. + "expectedSubjects": [{"role":"subject","binding":binding}], + "expectedOutputs": [{"concept":CONCEPT,"form":"boolean"}], + "maximumAssertionLifetimeSeconds": 86400, + "clockSkewSeconds": 30, + }); + fs::write( + &self.policy, + serde_norway::to_string(&policy).expect("policy YAML"), + ) + .expect("verification policy"); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + for path in [&self.candidate, &self.first_candidate] { + let _ = make_tree_writable(path); + } + let _ = &self.temporary; + } +} + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn assert_success(output: Output, label: &str) -> Output { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn bundle_revision(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.strip_prefix("Bundle revision: ")) + .filter(|revision| { + revision.len() == 71 + && revision.starts_with("sha256:") + && revision[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .expect("build reports one bundle revision") + .to_owned() +} + +fn assert_requirement_forms( + requirements: &BTreeMap<&str, &Value>, + requirement_id: &str, + expected_forms: &[&str], + expected_subject_roles: usize, +) { + let requirement = requirements + .get(requirement_id) + .unwrap_or_else(|| panic!("missing compiled requirement {requirement_id}")); + let forms = requirement["concepts"] + .as_array() + .expect("compiled concepts") + .iter() + .map(|concept| concept["form"].as_str().expect("compiled concept form")) + .collect::>(); + assert_eq!(forms, expected_forms); + assert_eq!( + requirement["subjectRoles"] + .as_array() + .expect("compiled subject roles") + .len(), + expected_subject_roles + ); +} + +fn check_revisions(evidence: &Path, runtime: &Path, label: &str) -> (String, String) { + let output = assert_success( + Command::new(evidence) + .arg("--runtime") + .arg(runtime) + .arg("check") + .output() + .expect("Evidence check starts"), + label, + ); + let stdout = String::from_utf8(output.stdout).expect("Evidence check stdout"); + let fields = stdout + .lines() + .find(|line| line.starts_with("Evidence deployment ")) + .expect("Evidence check report") + .split_whitespace() + .collect::>(); + assert_eq!(fields.get(3), Some(&"/"), "Evidence revision separator"); + ( + fields.get(2).expect("bundle revision").to_string(), + fields.get(4).expect("runtime revision").to_string(), + ) +} + +fn snapshot_files(root: &Path) -> BTreeMap> { + fn visit(root: &Path, path: &Path, snapshot: &mut BTreeMap>) { + let mut entries = fs::read_dir(path) + .expect("candidate directory") + .map(|entry| entry.expect("candidate entry").path()) + .collect::>(); + entries.sort(); + for entry in entries { + let metadata = fs::symlink_metadata(&entry).expect("candidate metadata"); + assert!( + !metadata.file_type().is_symlink(), + "candidate contains symlink" + ); + if metadata.is_dir() { + visit(root, &entry, snapshot); + } else { + snapshot.insert( + entry + .strip_prefix(root) + .expect("candidate-relative path") + .to_owned(), + fs::read(entry).expect("candidate file"), + ); + } + } + } + let mut snapshot = BTreeMap::new(); + visit(root, root, &mut snapshot); + snapshot +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("reserve port") + .local_addr() + .expect("reserved address") + .port() +} + +fn free_ports(count: usize) -> Vec { + let listeners = (0..count) + .map(|_| TcpListener::bind("127.0.0.1:0").expect("reserve distinct port")) + .collect::>(); + listeners + .iter() + .map(|listener| listener.local_addr().expect("reserved address").port()) + .collect() +} + +fn wait_for_listener(child: &mut Child, port: u16, label: &str) { + wait_for(Duration::from_secs(20), || { + assert_running(child, label); + TcpStream::connect(("127.0.0.1", port)).is_ok() + }); +} + +fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if condition() { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("condition did not become true before the acceptance timeout"); +} + +fn assert_running(child: &mut Child, label: &str) { + if let Some(status) = child.try_wait().expect("child status") { + panic!("{label} exited before readiness with {status}"); + } +} + +fn http_status(port: u16, path: &str) -> Option { + let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut response = String::new(); + stream.read_to_string(&mut response).ok()?; + response + .lines() + .next()? + .split_whitespace() + .nth(1)? + .parse() + .ok() +} + +fn post_evidence(port: u16, token: &str, nonce: &str) -> (u16, Vec) { + let body = serde_json::to_vec(&json!({ + "requestNonce": nonce, + "requirement": REQUIREMENT, + "purpose": PURPOSE, + "subjects": [{ + "role": "subject", + "selector": { + "profile": "person-reference-v1", + "values": {"person_id": SELECTOR_CANARY}, + }, + }], + })) + .expect("request JSON"); + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("Evidence connection"); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .expect("request timeout"); + write!( + stream, + "POST /v1/evidence HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nAccept: application/jose+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("request headers"); + stream.write_all(&body).expect("request body"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("response bytes"); + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("HTTP response separator"); + let headers = std::str::from_utf8(&response[..separator]).expect("HTTP response headers"); + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|value| value.parse::().ok()) + .expect("HTTP response status"); + (status, response[separator + 4..].to_vec()) +} + +fn signed_payload(response: &[u8]) -> Value { + let jws: Value = serde_json::from_slice(response).expect("flattened JWS response"); + let encoded = jws["payload"].as_str().expect("flattened JWS payload"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).expect("payload decodes")) + .expect("Evidence payload JSON") +} + +fn wait_for_audit(path: &Path) -> String { + let mut result = None; + wait_for(Duration::from_secs(10), || { + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + if contents.matches("\"phase\":\"access-attempt\"").count() == 1 + && contents.matches("\"phase\":\"disclosure-release\"").count() == 1 + { + result = Some(contents); + true + } else { + false + } + }); + result.expect("the complete operation audit") +} + +fn assert_audit_contract(audit: &str, revision: &str, credentials: &[&[u8]]) { + let records = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit JSONL")) + .collect::>(); + assert_eq!( + records.len(), + 2, + "one request must write exactly two events" + ); + assert_eq!(records[0]["record"]["phase"], "access-attempt"); + assert_eq!(records[0]["record"]["decision"], "authorized"); + assert_eq!(records[1]["record"]["phase"], "disclosure-release"); + assert_eq!(records[1]["record"]["decision"], "released"); + assert_eq!(records[1]["record"]["signingKeyId"], SIGNING_KEY_ID); + for record in &records { + assert_eq!(record["record"]["bundleRevision"], revision); + assert_eq!(record["record"]["assuranceProfile"], "production"); + } + let bytes = audit.as_bytes(); + for prohibited in [ + SELECTOR_CANARY.as_bytes(), + b"date_of_birth".as_slice(), + b"synthetic-caller".as_slice(), + ] { + assert!( + !bytes + .windows(prohibited.len()) + .any(|part| part == prohibited), + "audit retained protected request, source, principal, or credential data" + ); + } + for credential in credentials { + assert!( + !bytes + .windows(credential.len()) + .any(|part| part == *credential), + "audit retained an access token or source credential" + ); + } +} + +fn stop_gracefully(child: &mut Child, label: &str) { + let pid = + rustix::process::Pid::from_raw(i32::try_from(child.id()).expect("child PID fits i32")) + .expect("child PID is positive"); + rustix::process::kill_process(pid, rustix::process::Signal::TERM).expect("send SIGTERM"); + let status = child.wait().expect("child exits"); + assert!(status.success(), "{label} did not stop cleanly: {status}"); +} + +fn stop_forcefully(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn owner_only_log(path: &Path) -> File { + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .expect("create owner-only log") +} + +fn make_tree_writable(path: &Path) -> std::io::Result<()> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(()); + }; + if metadata.is_dir() { + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(path)? { + make_tree_writable(&entry?.path())?; + } + } else if metadata.is_file() { + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn evidence_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + if let Some(path) = std::env::var_os("EVIDENCE_BIN") { + return PathBuf::from(path); + } + let build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(workspace_root()) + .args([ + "build", + "--locked", + "-p", + "registry-evidence", + "--bin", + "evidence", + "--profile", + ¤t_test_profile(), + "--message-format", + "json-render-diagnostics", + ]) + .output() + .expect("building the Evidence binary"); + assert!( + build.status.success(), + "building the Evidence binary failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + String::from_utf8_lossy(&build.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| message["reason"] == "compiler-artifact") + .filter_map(|message| message["executable"].as_str().map(PathBuf::from)) + .find(|path| path.file_name().is_some_and(|name| name == "evidence")) + .expect("Evidence executable path") + }) +} + +fn mint_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + if let Some(path) = std::env::var_os("MINT_BIN") { + return PathBuf::from(path); + } + let build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(workspace_root()) + .args([ + "build", + "--locked", + "-p", + "registry-mint", + "--bin", + "mint", + "--profile", + ¤t_test_profile(), + "--message-format", + "json-render-diagnostics", + ]) + .output() + .expect("building the Mint binary"); + assert!( + build.status.success(), + "building the Mint binary failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + String::from_utf8_lossy(&build.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| message["reason"] == "compiler-artifact") + .filter_map(|message| message["executable"].as_str().map(PathBuf::from)) + .find(|path| path.file_name().is_some_and(|name| name == "mint")) + .expect("Mint executable path") + }) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .to_path_buf() +} + +fn current_test_profile() -> String { + let executable = std::env::current_exe().expect("test executable"); + let profile = executable + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .expect("test profile"); + if profile == "debug" { + "dev".to_owned() + } else { + profile.to_owned() + } +} diff --git a/crates/registry-evidencectl/tests/request_verify.rs b/crates/registry-evidencectl/tests/request_verify.rs new file mode 100644 index 000000000..63afca3de --- /dev/null +++ b/crates/registry-evidencectl/tests/request_verify.rs @@ -0,0 +1,1007 @@ +use std::{ + fs, + os::unix::{ + fs::{symlink, PermissionsExt as _}, + net::UnixListener, + }, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; + +const TOKEN: &str = "secret.token-canary"; +const CONTEXT: &str = "{\"schema\":\"context-canary\"}\n"; +const VERIFIED: &str = + "{\"purpose\":\"age-check\",\"schema\":\"verified-canary\",\"values\":{\"is_adult\":true}}\n"; +const AGE_CHECKS_TAG: &str = + "policy-v1-bc8c04f766133dc6ffd6e395caa64f9c3b43301c1d308716668c71b8b839c0dc"; + +#[test] +fn public_help_exposes_only_the_adopter_request_and_verify_inputs() { + let request = command() + .args(["request", "prepare", "--help"]) + .output() + .expect("request help"); + assert_success(&request); + let request = String::from_utf8_lossy(&request.stdout); + for visible in [ + "", + "--purpose", + "--subject", + "--name", + "--client", + "--format", + ] { + assert!(request.contains(visible), "missing {visible}: {request}"); + } + for hidden in ["--project", "--evidence-bin", "--mint-bin"] { + assert!(!request.contains(hidden), "test seam leaked: {request}"); + } + + let verify = command() + .args(["verify", "--help"]) + .output() + .expect("verify help"); + assert_success(&verify); + let verify = String::from_utf8_lossy(&verify.stdout); + for visible in ["", "--context", "--output"] { + assert!(verify.contains(visible), "missing {visible}: {verify}"); + } + assert!(!verify.contains("--evidence-bin")); +} + +#[test] +fn prepare_and_verify_delegate_exactly_and_publish_only_safe_artifacts() { + let fixture = Fixture::new(); + let prepared = fixture.prepare("first-assertion"); + assert_success(&prepared); + assert_eq!( + String::from_utf8_lossy(&prepared.stdout), + "Prepared request: .evidence/requests/first-assertion/request.json\n\ + Prepared verification context: .evidence/requests/first-assertion/verification.json\n\ + Prepared authorization: .evidence/requests/first-assertion/authorization.curl\n" + ); + + let retained = fixture.root.join(".evidence/requests/first-assertion"); + assert_mode(&fixture.root.join(".evidence/requests"), 0o700); + assert_mode(&retained, 0o700); + assert_eq!( + sorted_names(&retained), + ["authorization.curl", "request.json", "verification.json"] + ); + for name in ["authorization.curl", "request.json", "verification.json"] { + assert_mode(&retained.join(name), 0o600); + } + + let request_bytes = fs::read(retained.join("request.json")).expect("request"); + let request: Value = serde_json::from_slice(&request_bytes).expect("request JSON"); + assert_eq!( + request, + json!({ + "requestNonce": request["requestNonce"], + "requirement": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "subjects": [{ + "role": "person", + "selector": { + "profile": "local-subject-adult-status-v1", + "values": {"person_id": "person-123"} + } + }] + }) + ); + let nonce = request["requestNonce"].as_str().expect("nonce"); + assert_eq!(nonce.len(), 43); + assert_eq!( + URL_SAFE_NO_PAD + .decode(nonce) + .expect("canonical nonce") + .len(), + 32 + ); + assert_eq!( + fs::read_to_string(retained.join("verification.json")).unwrap(), + CONTEXT + ); + assert_eq!( + fs::read_to_string(retained.join("authorization.curl")).unwrap(), + format!("header = \"Authorization: Bearer {TOKEN}\"\n") + ); + + let mint_args = fs::read_to_string(fixture.mint.with_extension("args")).unwrap(); + assert_eq!( + mint_args.lines().collect::>(), + [ + "token", + "--url", + "http://127.0.0.1:8081/token", + "--client-id", + "local-tutorial-caller", + "--key", + fs::canonicalize(&fixture.root) + .unwrap() + .join(".evidence/dev/generated/keys/caller-private.jwk") + .to_str() + .unwrap(), + "--audience", + "http://127.0.0.1:8081/token", + ] + ); + let evidence_args = fs::read_to_string(fixture.evidence.with_extension("prepare.args")) + .expect("Evidence prepare argv"); + let evidence_args = evidence_args.lines().collect::>(); + assert_eq!(evidence_args[0], "--runtime"); + assert_eq!(evidence_args[2], "prepare-local-verification-context"); + assert_eq!(evidence_args[3], "--request"); + assert!(evidence_args[4].ends_with("/request.json")); + assert_eq!(&evidence_args[5..], ["--response-format", "signed-jws"]); + assert!(!evidence_args.join(" ").contains(TOKEN)); + assert_eq!( + fs::read_to_string(fixture.evidence.with_extension("prepare.stdin")).unwrap(), + "stdin-ok\n" + ); + for non_secret in [ + &request_bytes, + fs::read(retained.join("verification.json")) + .unwrap() + .as_slice(), + &prepared.stdout, + &prepared.stderr, + ] { + assert!(!String::from_utf8_lossy(non_secret).contains(TOKEN)); + } + + let second = fixture.prepare("second-assertion"); + assert_success(&second); + let second: Value = serde_json::from_slice( + &fs::read( + fixture + .root + .join(".evidence/requests/second-assertion/request.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_ne!(request["requestNonce"], second["requestNonce"]); + + let sd_jwt = fixture.prepare_with( + &[ + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + "--format", + "sd-jwt-vc", + ], + "sd-jwt-assertion", + ); + assert_success(&sd_jwt); + let evidence_args = fs::read_to_string(fixture.evidence.with_extension("prepare.args")) + .expect("Evidence SD-JWT prepare argv"); + assert_eq!( + &evidence_args.lines().collect::>()[5..], + ["--response-format", "sd-jwt-vc"] + ); + + let age = fixture.prepare_with( + &[ + "age-bracket", + "--purpose", + "service-path-selection", + "--subject", + "person_id=person-123", + ], + "age-bracket", + ); + assert_success(&age); + let age: Value = serde_json::from_slice( + &fs::read( + fixture + .root + .join(".evidence/requests/age-bracket/request.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + age["requirement"], + "urn:registrystack:evidence:local:requirement:age-bracket" + ); + assert_eq!(age["purpose"], "service-path-selection"); + assert_eq!( + age["subjects"][0]["selector"]["profile"], + "local-subject-age-bracket-v1" + ); + + let response = fixture.root.join("assertion.jws.json"); + fs::write(&response, b"ordinary curl response").expect("response"); + fs::set_permissions(&response, fs::Permissions::from_mode(0o644)).expect("curl mode"); + let verified = fixture.verify("verified.json"); + assert_success(&verified); + assert_eq!(verified.stdout, b"VERIFIED\n"); + let verified_path = fixture.root.join("verified.json"); + assert_mode(&verified_path, 0o600); + assert_eq!(fs::read_to_string(&verified_path).unwrap(), VERIFIED); + assert_eq!( + fs::read_to_string(fixture.evidence.with_extension("verify.args")) + .unwrap() + .lines() + .collect::>(), + [ + "verify-local-response", + "--context", + ".evidence/requests/first-assertion/verification.json", + "--response", + "assertion.jws.json", + ] + ); + + let refused = fixture.verify("verified.json"); + assert!(!refused.status.success()); + assert_eq!(fs::read_to_string(&verified_path).unwrap(), VERIFIED); +} + +#[test] +fn named_client_prepare_uses_the_registered_identity() { + let fixture = Fixture::new(); + fixture.add_named_client("age-checker", "active", 0o600); + fixture.use_explicit_access(); + + let prepared = fixture.prepare_as("age-checker", "named-client"); + assert_success(&prepared); + let mint_args = fs::read_to_string(fixture.mint.with_extension("args")).unwrap(); + assert_eq!( + mint_args.lines().collect::>(), + [ + "token", + "--url", + "http://127.0.0.1:8081/token", + "--client-id", + "age-checker", + "--key", + fs::canonicalize(&fixture.root) + .unwrap() + .join(".evidence/clients/age-checker/private.jwk") + .to_str() + .unwrap(), + "--audience", + "http://127.0.0.1:8081/token", + ] + ); + assert!(fixture + .root + .join(".evidence/requests/named-client/request.json") + .is_file()); +} + +#[test] +fn unusable_named_clients_and_mint_refusal_publish_no_request_artifacts() { + let unknown = Fixture::new(); + unknown.add_named_client("other-client", "active", 0o600); + unknown.use_explicit_access(); + let output = unknown.prepare_as("unknown-client", "unknown-client"); + assert!(!output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "evidencectl: unknown or revoked active client unknown-client\n" + ); + assert_no_request_artifacts(&unknown.root, "unknown-client"); + + let revoked = Fixture::new(); + revoked.add_named_client("other-client", "active", 0o600); + revoked.add_named_client("revoked-client", "revoked", 0o600); + revoked.use_explicit_access(); + let output = revoked.prepare_as("revoked-client", "revoked-client"); + assert!(!output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "evidencectl: unknown or revoked active client revoked-client\n" + ); + assert_no_request_artifacts(&revoked.root, "revoked-client"); + + let unsafe_key = Fixture::new(); + unsafe_key.add_named_client("unsafe-client", "active", 0o644); + unsafe_key.use_explicit_access(); + let output = unsafe_key.prepare_as("unsafe-client", "unsafe-client"); + assert!(!output.status.success()); + assert_no_request_artifacts(&unsafe_key.root, "unsafe-client"); + + let missing_key = Fixture::new(); + missing_key.add_named_client("missing-key-client", "active", 0o600); + missing_key.use_explicit_access(); + fs::remove_dir_all( + missing_key + .root + .join(".evidence/clients/missing-key-client"), + ) + .unwrap(); + let output = missing_key.prepare_as("missing-key-client", "missing-key-client"); + assert!(!output.status.success()); + assert_no_request_artifacts(&missing_key.root, "missing-key-client"); + + let mismatched_key = Fixture::new(); + mismatched_key.add_named_client("mismatched-key-client", "active", 0o600); + mismatched_key.use_explicit_access(); + let registration = mismatched_key + .root + .join("access/clients/mismatched-key-client.yaml"); + let text = fs::read_to_string(®istration).unwrap().replace( + "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + fs::write(®istration, text).unwrap(); + let output = mismatched_key.prepare_as("mismatched-key-client", "mismatched-key-client"); + assert!(!output.status.success()); + assert_no_request_artifacts(&mismatched_key.root, "mismatched-key-client"); + + let refused = Fixture::new(); + refused.add_named_client("refused-client", "active", 0o600); + refused.use_explicit_access(); + fs::write(refused.mint.with_extension("fail"), b"").unwrap(); + let output = refused.prepare_as("refused-client", "refused-client"); + assert!(!output.status.success()); + assert!(!String::from_utf8_lossy(&output.stderr).contains(TOKEN)); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "evidencectl: Registry Mint refused a token for client refused-client\n" + ); + assert_no_request_artifacts(&refused.root, "refused-client"); +} + +#[test] +fn selected_clients_require_an_explicit_current_policy_generation() { + let implicit = Fixture::new(); + implicit.add_named_client("age-checker", "active", 0o600); + let output = implicit.prepare_as("age-checker", "implicit-client"); + assert!(!output.status.success()); + assert_no_request_artifacts(&implicit.root, "implicit-client"); + + let explicit = Fixture::new(); + explicit.add_named_client("age-checker", "active", 0o600); + explicit.use_explicit_access(); + let output = explicit.prepare("missing-client"); + assert!(!output.status.success()); + assert_no_request_artifacts(&explicit.root, "missing-client"); + + private_file( + &explicit.root.join("questions/age-bracket.yaml"), + b"id: age-bracket\n", + 0o644, + ); + private_file( + &explicit.root.join("access/policies/age-checks.yaml"), + b"version: 1\nid: age-checks\nquestions: [adult-status, age-bracket]\n", + 0o644, + ); + let output = explicit.prepare_as("age-checker", "drifted-policy"); + assert!(!output.status.success()); + assert_no_request_artifacts(&explicit.root, "drifted-policy"); +} + +#[test] +fn multi_subject_prepare_requires_the_exact_role_set_and_emits_declaration_order() { + let fixture = Fixture::new(); + let prepared = fixture.prepare_with( + &[ + "relationship-check", + "--purpose", + "relationship-review", + "--subject", + "candidate:person_reference=person-456", + "--subject", + "child:child_reference=child-123", + ], + "relationship", + ); + assert_success(&prepared); + let request: Value = serde_json::from_slice( + &fs::read( + fixture + .root + .join(".evidence/requests/relationship/request.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + request["subjects"], + json!([ + { + "role": "child", + "selector": { + "profile": "child-reference-v1", + "values": {"child_reference": "child-123"} + } + }, + { + "role": "candidate", + "selector": { + "profile": "person-reference-v1", + "values": {"person_reference": "person-456"} + } + } + ]) + ); + + for (name, inputs) in [ + ( + "missing", + vec![ + "relationship-check", + "--purpose", + "relationship-review", + "--subject", + "child:child_reference=child-123", + ], + ), + ( + "duplicate", + vec![ + "relationship-check", + "--purpose", + "relationship-review", + "--subject", + "child:child_reference=child-123", + "--subject", + "child:child_reference=child-456", + ], + ), + ( + "wrong-field", + vec![ + "relationship-check", + "--purpose", + "relationship-review", + "--subject", + "child:person_reference=child-123", + "--subject", + "candidate:child_reference=person-456", + ], + ), + ] { + let refused = fixture.prepare_with(&inputs, name); + assert!(!refused.status.success(), "{name} role set must fail"); + assert!(!fixture.root.join(".evidence/requests").join(name).exists()); + } +} + +#[test] +fn request_inputs_are_exact_and_every_failed_preparation_cleans_staging() { + for arguments in [ + vec![ + "other", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + ], + vec![ + "adult-status", + "--purpose", + "other", + "--subject", + "person_id=person-123", + ], + vec![ + "adult-status", + "--purpose", + "age-check", + "--subject", + "other=person-123", + ], + vec![ + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=", + ], + vec![ + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=a=b", + ], + ] { + let fixture = Fixture::new(); + let output = fixture.prepare_with(&arguments, "first-assertion"); + assert!(!output.status.success(), "{arguments:?}"); + assert!(!fixture + .root + .join(".evidence/requests/first-assertion") + .exists()); + } + + let fixture = Fixture::new(); + fs::write(fixture.evidence.with_extension("fail-prepare"), b"").unwrap(); + let failed = fixture.prepare("first-assertion"); + assert!(!failed.status.success()); + assert!(!String::from_utf8_lossy(&failed.stderr).contains(TOKEN)); + let requests = fixture.root.join(".evidence/requests"); + assert_eq!(sorted_names(&requests), Vec::::new()); + + let existing = Fixture::new(); + let target = existing.root.join(".evidence/requests/first-assertion"); + fs::create_dir_all(&target).unwrap(); + fs::set_permissions(target.parent().unwrap(), fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(target.join("canary"), b"keep").unwrap(); + let failed = existing.prepare("first-assertion"); + assert!(!failed.status.success()); + assert_eq!(fs::read(target.join("canary")).unwrap(), b"keep"); +} + +#[test] +fn unsafe_request_and_verify_paths_are_refused_without_clobbering() { + let public = Fixture::new(); + let requests = public.root.join(".evidence/requests"); + fs::create_dir(&requests).unwrap(); + fs::set_permissions(&requests, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!public.prepare("first-assertion").status.success()); + + let linked = Fixture::new(); + let target = linked.root.join("request-target"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap(); + symlink(&target, linked.root.join(".evidence/requests")).unwrap(); + assert!(!linked.prepare("first-assertion").status.success()); + assert!(sorted_names(&target).is_empty()); + + let verify = Fixture::new(); + fs::write(verify.root.join("assertion.jws.json"), b"response").unwrap(); + let destination = verify.root.join("verified.json"); + let canary = verify.root.join("canary"); + fs::write(&canary, b"keep").unwrap(); + symlink(&canary, &destination).unwrap(); + assert!(!verify.verify("verified.json").status.success()); + assert_eq!(fs::read(&canary).unwrap(), b"keep"); + + fs::remove_file(destination).unwrap(); + let target = verify.root.join("output-target"); + fs::create_dir(&target).unwrap(); + let linked_parent = verify.root.join("linked-output"); + symlink(&target, &linked_parent).unwrap(); + let output = verify.verify("linked-output/verified.json"); + assert!(!output.status.success()); + assert!(sorted_names(&target).is_empty()); +} + +#[test] +fn failed_core_verification_removes_the_unpublished_output() { + let fixture = Fixture::new(); + fs::write(fixture.root.join("assertion.jws.json"), b"response").unwrap(); + fs::write(fixture.evidence.with_extension("fail-verify"), b"").unwrap(); + let output = fixture.verify("verified.json"); + assert!(!output.status.success()); + assert!(!fixture.root.join("verified.json").exists()); + assert!(sorted_names(&fixture.root) + .iter() + .all(|name| !name.starts_with(".verify-"))); +} + +struct Fixture { + _temporary: tempfile::TempDir, + _listener: UnixListener, + root: PathBuf, + evidence: PathBuf, + mint: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temporary = tempfile::tempdir().expect("temporary directory"); + let root = temporary.path().join("project"); + private_directory(&root); + private_directory(&root.join(".evidence")); + private_directory(&root.join(".evidence/dev")); + private_directory(&root.join(".evidence/dev/generated")); + private_directory(&root.join(".evidence/dev/generated/keys")); + private_file(&root.join(".evidence/dev/runtime.yaml"), b"runtime", 0o400); + let caller_key = root.join(".evidence/dev/generated/keys/caller-private.jwk"); + private_file(&caller_key, b"{}", 0o600); + let socket = root.join(".evidence/dev/control.sock"); + let listener = UnixListener::bind(&socket).expect("control socket"); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + let canonical = fs::canonicalize(&root).unwrap(); + let state = json!({ + "schema": "registry.evidencectl.dev-state/v5", + "status": "ready", + "project": canonical, + "runtimePath": canonical.join(".evidence/dev/runtime.yaml"), + "evidenceOrigin": "http://127.0.0.1:8080", + "mintOrigin": "http://127.0.0.1:8081", + "tokenUrl": "http://127.0.0.1:8081/token", + "accessTokenAudience": "registry-evidence-local", + "caller": { + "clientId": "local-tutorial-caller", + "privateKeyPath": canonical.join(".evidence/dev/generated/keys/caller-private.jwk"), + "assertionAudience": "http://127.0.0.1:8081/token", + "evidenceAudience": "urn:registrystack:evidence:local:caller", + "requesterTag": "local-caller" + }, + "accessPolicies": [], + "questions": [ + { + "alias": "adult-status", + "requirementUri": "urn:registrystack:evidence:local:requirement:adult-status", + "purpose": "age-check", + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-adult-status-v1", + "selectorField": "person_id" + }], + "concepts": [{ + "alias": "is_adult", + "uri": "urn:registrystack:evidence:local:concept:adult-status:is_adult", + "form": "boolean" + }] + }, + { + "alias": "age-bracket", + "requirementUri": "urn:registrystack:evidence:local:requirement:age-bracket", + "purpose": "service-path-selection", + "subjects": [{ + "role": "person", + "selectorProfile": "local-subject-age-bracket-v1", + "selectorField": "person_id" + }], + "concepts": [{ + "alias": "age_bracket", + "uri": "urn:registrystack:evidence:local:concept:age-bracket:age_bracket", + "form": "controlled-category" + }] + }, + { + "alias": "relationship-check", + "requirementUri": "urn:registrystack:evidence:local:requirement:relationship-check", + "purpose": "relationship-review", + "subjects": [ + { + "role": "child", + "selectorProfile": "child-reference-v1", + "selectorField": "child_reference" + }, + { + "role": "candidate", + "selectorProfile": "person-reference-v1", + "selectorField": "person_reference" + } + ], + "concepts": [{ + "alias": "relationship_confirmed", + "uri": "urn:registrystack:evidence:local:concept:relationship-check:relationship_confirmed", + "form": "boolean" + }] + } + ], + "failure": null + }); + private_file( + &root.join(".evidence/dev/state.json"), + &serde_json::to_vec(&state).unwrap(), + 0o600, + ); + write_sealed_bundle(&root, &state); + + let mint = temporary.path().join("mint-stub"); + executable( + &mint, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\n[ ! -f \"$0.fail\" ] || exit 31\nprintf '%s\\n' '{TOKEN}'\n" + ) + .as_bytes(), + ); + let evidence = temporary.path().join("evidence-stub"); + executable( + &evidence, + format!( + "#!/bin/sh\ncase \"$1\" in\n --runtime)\n printf '%s\\n' \"$@\" > \"$0.prepare.args\"\n bearer=$(dd bs=65536 count=1 2>/dev/null)\n [ \"$bearer\" = '{TOKEN}' ] || exit 40\n printf 'stdin-ok\\n' > \"$0.prepare.stdin\"\n [ ! -f \"$0.fail-prepare\" ] || exit 41\n printf '%s' '{CONTEXT}'\n ;;\n verify-local-response)\n printf '%s\\n' \"$@\" > \"$0.verify.args\"\n [ ! -f \"$0.fail-verify\" ] || exit 42\n printf '%s' '{VERIFIED}'\n ;;\n *) exit 43 ;;\nesac\n" + ) + .as_bytes(), + ); + Self { + _temporary: temporary, + _listener: listener, + root, + evidence, + mint, + } + } + + fn prepare(&self, name: &str) -> Output { + self.prepare_with( + &[ + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + ], + name, + ) + } + + fn prepare_with(&self, inputs: &[&str], name: &str) -> Output { + command() + .current_dir(&self.root) + .args(["request", "prepare"]) + .args(inputs) + .args(["--name", name, "--project", ".", "--evidence-bin"]) + .arg(&self.evidence) + .arg("--mint-bin") + .arg(&self.mint) + .output() + .expect("prepare command") + } + + fn prepare_as(&self, client_id: &str, name: &str) -> Output { + self.prepare_with( + &[ + "adult-status", + "--purpose", + "age-check", + "--subject", + "person_id=person-123", + "--client", + client_id, + ], + name, + ) + } + + fn add_named_client(&self, client_id: &str, status: &str, key_mode: u32) { + let questions = self.root.join("questions"); + fs::create_dir_all(&questions).expect("authored question directory"); + private_file( + &questions.join("adult-status.yaml"), + b"id: adult-status\n", + 0o644, + ); + let policies = self.root.join("access/policies"); + fs::create_dir_all(&policies).expect("editable policy directory"); + private_file( + &policies.join("age-checks.yaml"), + b"version: 1\nid: age-checks\nquestions: [adult-status]\n", + 0o644, + ); + let clients = self.root.join("access/clients"); + fs::create_dir_all(&clients).expect("editable client directory"); + private_file( + &clients.join(format!("{client_id}.yaml")), + format!( + "version: 1\n\ + clientId: {client_id}\n\ + status: {status}\n\ + principal: urn:registrystack:evidence:local:client:{client_id}\n\ + evidenceAudience: urn:registrystack:evidence:local:client:{client_id}\n\ + policies: [age-checks]\n\ + keys:\n\ + - {{kty: OKP, crv: Ed25519, kid: {client_id}-key-1, alg: EdDSA, use: sig, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo}}\n" + ) + .as_bytes(), + 0o644, + ); + + let private_clients = self.root.join(".evidence/clients"); + if !private_clients.exists() { + private_directory(&private_clients); + } + let private_client = private_clients.join(client_id); + private_directory(&private_client); + private_file( + &private_client.join("private.jwk"), + format!( + "{{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"{client_id}-key-1\",\"alg\":\"EdDSA\",\"x\":\"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\",\"d\":\"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A\"}}" + ) + .as_bytes(), + key_mode, + ); + } + + fn use_explicit_access(&self) { + let path = self.root.join(".evidence/dev/state.json"); + let mut state: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + state["caller"] = Value::Null; + state["accessPolicies"] = json!([{ + "id": "age-checks", + "requesterTag": AGE_CHECKS_TAG, + "questions": ["adult-status"] + }]); + fs::write(&path, serde_json::to_vec(&state).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + write_sealed_bundle(&self.root, &state); + } + + fn verify(&self, output: &str) -> Output { + command() + .current_dir(&self.root) + .args([ + "verify", + "assertion.jws.json", + "--context", + ".evidence/requests/first-assertion/verification.json", + "--output", + output, + "--evidence-bin", + ]) + .arg(&self.evidence) + .output() + .expect("verify command") + } +} + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn private_directory(path: &Path) { + fs::create_dir(path).expect("private directory"); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); +} + +fn private_file(path: &Path, contents: &[u8], mode: u32) { + fs::write(path, contents).expect("private file"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); +} + +fn write_sealed_bundle(root: &Path, state: &Value) { + let bundle_directory = root.join(".evidence/dev/bundle"); + if !bundle_directory.exists() { + private_directory(&bundle_directory); + } + let questions = state["questions"].as_array().expect("state questions"); + let mut selector_profiles = serde_json::Map::new(); + let requirements = questions + .iter() + .map(|question| { + let roles = question["subjects"] + .as_array() + .expect("question subjects") + .iter() + .map(|subject| { + let profile = subject["selectorProfile"] + .as_str() + .expect("selector profile"); + let field = subject["selectorField"].as_str().expect("selector field"); + selector_profiles.insert( + profile.to_owned(), + json!({"fields": {field: {"type": "string"}}}), + ); + json!({"role": subject["role"], "selectorProfiles": [profile]}) + }) + .collect::>(); + let concepts = question["concepts"] + .as_array() + .expect("question concepts") + .iter() + .map(|concept| json!({"id": concept["uri"], "form": concept["form"]})) + .collect::>(); + json!({ + "id": question["requirementUri"], + "purposes": [question["purpose"].clone()], + "subjectRoles": roles, + "concepts": concepts, + }) + }) + .collect::>(); + let grant_for = |question: &Value| { + let subjects = question["subjects"] + .as_array() + .expect("question subjects") + .iter() + .map(|subject| { + json!({ + "role": subject["role"], + "selectorProfile": subject["selectorProfile"], + "valueOrigin": "request", + }) + }) + .collect::>(); + json!({ + "requirement": question["requirementUri"], + "purpose": question["purpose"], + "audienceFrom": "authenticated-requester", + "responseFormats": ["signed-jws"], + "subjects": subjects, + }) + }; + let access_policies = state["accessPolicies"] + .as_array() + .expect("state access policies"); + let authority_profiles = if access_policies.is_empty() { + serde_json::Map::from_iter([( + "local-caller".to_owned(), + json!({ + "kind": "explicit-request", + "requesterTags": ["local-caller"], + "grants": questions.iter().map(grant_for).collect::>(), + }), + )]) + } else { + access_policies + .iter() + .map(|policy| { + let requester_tag = policy["requesterTag"] + .as_str() + .expect("policy requester tag"); + let grants = policy["questions"] + .as_array() + .expect("policy questions") + .iter() + .map(|alias| { + let alias = alias.as_str().expect("question alias"); + let question = questions + .iter() + .find(|question| question["alias"] == alias) + .expect("policy question exists"); + grant_for(question) + }) + .collect::>(); + ( + requester_tag.to_owned(), + json!({ + "kind": "explicit-request", + "requesterTags": [requester_tag], + "grants": grants, + }), + ) + }) + .collect() + }; + let bundle = json!({ + "selectorProfiles": selector_profiles, + "authorityProfiles": authority_profiles, + "requirements": requirements, + }); + let bundle_path = bundle_directory.join("evidence.yaml"); + if bundle_path.exists() { + fs::set_permissions(&bundle_path, fs::Permissions::from_mode(0o600)) + .expect("unseal bundle fixture"); + } + private_file( + &bundle_path, + serde_norway::to_string(&bundle) + .expect("bundle renders") + .as_bytes(), + 0o400, + ); +} + +fn executable(path: &Path, contents: &[u8]) { + private_file(path, contents, 0o700); +} + +fn assert_mode(path: &Path, expected: u32) { + let mode = fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, expected, "{}", path.display()); +} + +fn sorted_names(path: &Path) -> Vec { + let mut names = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + names +} + +fn assert_no_request_artifacts(project: &Path, name: &str) { + let requests = project.join(".evidence/requests"); + assert!(!requests.join(name).exists()); + if requests.exists() { + assert_eq!(sorted_names(&requests), Vec::::new()); + } +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs new file mode 100644 index 000000000..f754b6fae --- /dev/null +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -0,0 +1,363 @@ +//! Acceptance tests for the minimal `evidencectl new --openapi` path. + +#![cfg(unix)] + +use std::{ + fs, + io::{Read as _, Write as _}, + net::TcpListener, + os::unix::fs::{symlink, PermissionsExt as _}, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use tempfile::TempDir; + +const OPENAPI: &str = "# retained comment\r\nopenapi: 3.1.0\r\ninfo:\r\n title: Records\r\n version: 1.0.0\r\npaths: {}\r\n"; + +#[test] +fn bare_new_points_to_openapi_and_writes_nothing() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + let output = evidencectl(&["new", path(&project)]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("--openapi ")); + assert!(!project.exists()); +} + +#[test] +fn openapi_requires_the_explicit_local_profile_before_writing() { + let workspace = TempDir::new().expect("temporary directory"); + let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); + let project = workspace.path().join("project"); + let output = evidencectl(&["new", path(&project), "--openapi", path(&spec)]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("--profile local")); + assert!(!project.exists()); + + let wrong = workspace.path().join("wrong"); + let output = evidencectl(&[ + "new", + path(&wrong), + "--openapi", + path(&spec), + "--profile", + "production", + ]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("invalid value 'production'")); + assert!(!wrong.exists()); +} + +#[test] +fn local_openapi_is_retained_byte_for_byte_without_premature_artifacts() { + let workspace = TempDir::new().expect("temporary directory"); + let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); + let project = workspace.path().join("project"); + let output = openapi_new(&project, path(&spec), &[]); + assert!(output.status.success(), "{}", stderr(&output)); + + assert_eq!( + fs::read(project.join("source.openapi.yaml")).expect("retained OpenAPI"), + OPENAPI.as_bytes() + ); + assert_minimal_project(&project, false); + assert!(stdout(&output).contains("retained exactly")); + assert!(stdout(&output).contains("No question, fixture case, runtime")); + assert!(stdout(&output).contains("evidencectl source suggest --project")); +} + +#[test] +fn remote_openapi_is_retained_byte_for_byte() { + let workspace = TempDir::new().expect("temporary directory"); + let mut remote = OPENAPI.as_bytes().to_vec(); + remote.extend_from_slice(b"# remote trailing bytes\n"); + let url = serve_once(200, remote.clone()); + let project = workspace.path().join("remote-project"); + + let output = openapi_new(&project, &url, &[]); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!( + fs::read(project.join("source.openapi.yaml")).expect("retained remote OpenAPI"), + remote + ); + assert_minimal_project(&project, false); +} + +#[test] +fn invalid_local_or_remote_openapi_writes_nothing_and_cleans_staging() { + let workspace = TempDir::new().expect("temporary directory"); + let invalid = write_spec(workspace.path(), b"not: openapi\n"); + let invalid_project = workspace.path().join("invalid-project"); + let output = openapi_new(&invalid_project, path(&invalid), &["--generate-keys"]); + assert!(!output.status.success()); + assert!(!invalid_project.exists()); + + let failed_url = serve_once(500, b"failure\n".to_vec()); + let failed_project = workspace.path().join("failed-project"); + let output = openapi_new(&failed_project, &failed_url, &[]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("HTTP 500")); + assert!(!failed_project.exists()); + assert_no_staging_directories(workspace.path()); +} + +#[test] +fn unsafe_remote_urls_are_value_free_and_fail_before_network_or_writes() { + let workspace = TempDir::new().expect("temporary directory"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind connection probe"); + listener + .set_nonblocking(true) + .expect("nonblocking connection probe"); + let address = listener.local_addr().expect("probe address"); + + for (name, url, sensitive, expected) in [ + ( + "userinfo", + format!("http://reader:userinfo-secret@{address}/openapi.yaml"), + "userinfo-secret", + "credentials", + ), + ( + "query", + format!("http://{address}/openapi.yaml?access_token=query-secret"), + "query-secret", + "query or fragment", + ), + ( + "fragment", + format!("http://{address}/openapi.yaml#private-fragment"), + "private-fragment", + "query or fragment", + ), + ] { + let project = workspace.path().join(name); + let output = openapi_new(&project, &url, &["--generate-keys"]); + assert!(!output.status.success()); + let logged = format!("{}{}", stdout(&output), stderr(&output)); + assert!(logged.contains(expected), "unexpected refusal: {logged}"); + assert!( + !logged.contains(sensitive) && !logged.contains(&url), + "refusal leaked an unsafe URL value: {logged}" + ); + assert!(!project.exists(), "unsafe URL wrote project {name}"); + } + + assert!( + matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock), + "an unsafe URL reached the network" + ); + assert_no_staging_directories(workspace.path()); +} + +#[test] +fn generate_keys_is_transactional_unbound_owner_only_and_prints_no_secret() { + let workspace = TempDir::new().expect("temporary directory"); + let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); + let project = workspace.path().join("project"); + let output = openapi_new(&project, path(&spec), &["--generate-keys"]); + assert!(output.status.success(), "{}", stderr(&output)); + + assert_minimal_project(&project, true); + assert_eq!( + fs::metadata(project.join("secrets")) + .expect("secret directory") + .permissions() + .mode() + & 0o777, + 0o700 + ); + for (name, mode) in [ + ("signing-ed25519-private-jwk", 0o600), + ("signing-ed25519-public.jwk.json", 0o644), + ("audit-hmac-key", 0o600), + ("subject-binding-hmac-key", 0o600), + ] { + assert_eq!( + fs::metadata(project.join("secrets").join(name)) + .unwrap_or_else(|error| panic!("reading {name}: {error}")) + .permissions() + .mode() + & 0o777, + mode + ); + } + + let private = fs::read_to_string(project.join("secrets/signing-ed25519-private-jwk")) + .expect("private JWK"); + let private: serde_json::Value = serde_json::from_str(&private).expect("private JWK JSON"); + let secret = private["d"].as_str().expect("private key member"); + assert!(!stdout(&output).contains(secret)); + assert!(!stderr(&output).contains(secret)); + assert_no_staging_directories(workspace.path()); +} + +#[test] +fn existing_paths_and_force_are_refused_without_changes() { + let workspace = TempDir::new().expect("temporary directory"); + let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); + + let directory = workspace.path().join("existing"); + fs::create_dir(&directory).expect("existing directory"); + fs::write(directory.join("sentinel"), b"unchanged").expect("sentinel"); + let output = openapi_new(&directory, path(&spec), &[]); + assert!(!output.status.success()); + assert_eq!( + fs::read(directory.join("sentinel")).expect("sentinel"), + b"unchanged" + ); + + let external = workspace.path().join("external"); + fs::create_dir(&external).expect("external directory"); + fs::write(external.join("sentinel"), b"external").expect("external sentinel"); + let symlinked = workspace.path().join("symlinked"); + symlink(&external, &symlinked).expect("project symlink"); + let output = openapi_new(&symlinked, path(&spec), &["--generate-keys"]); + assert!(!output.status.success()); + assert_eq!( + fs::read(external.join("sentinel")).expect("external sentinel"), + b"external" + ); + assert_eq!(entries(&external), ["sentinel"]); + + let forced = workspace.path().join("forced"); + let output = openapi_new(&forced, path(&spec), &["--force"]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("unexpected argument '--force'")); + assert!(!forced.exists()); + assert_no_staging_directories(workspace.path()); +} + +fn assert_minimal_project(project: &Path, with_keys: bool) { + let expected = if with_keys { + vec![ + ".gitignore", + "adapters", + "derivations", + "fixtures", + "questions", + "schemas", + "secrets", + "selectors", + "source.openapi.yaml", + "sources", + ] + } else { + vec![ + ".gitignore", + "adapters", + "derivations", + "fixtures", + "questions", + "schemas", + "selectors", + "source.openapi.yaml", + "sources", + ] + }; + assert_eq!(entries(project), expected); + assert!(entries(&project.join("questions")).is_empty()); + assert!(entries(&project.join("derivations")).is_empty()); + assert!(entries(&project.join("selectors")).is_empty()); + assert!(entries(&project.join("sources")).is_empty()); + assert!(entries(&project.join("adapters")).is_empty()); + assert!(entries(&project.join("schemas")).is_empty()); + assert!(entries(&project.join("fixtures")).is_empty()); + assert_eq!( + fs::read_to_string(project.join(".gitignore")).expect("gitignore"), + "secrets/\n.evidence/\n" + ); + for absent in ["bundle", "runtime.yaml", "evidence.yaml", "README.md"] { + assert!( + !project.join(absent).exists(), + "unexpected generated {absent}" + ); + } +} + +fn openapi_new(project: &Path, openapi: &str, extra: &[&str]) -> Output { + let mut arguments = vec![ + "new", + path(project), + "--openapi", + openapi, + "--profile", + "local", + ]; + arguments.extend_from_slice(extra); + evidencectl(&arguments) +} + +fn evidencectl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +fn write_spec(root: &Path, contents: &[u8]) -> PathBuf { + let path = root.join("records.openapi.yaml"); + fs::write(&path, contents).expect("OpenAPI fixture"); + path +} + +fn serve_once(status: u16, body: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback server"); + let address = listener.local_addr().expect("server address"); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request); + let reason = if status == 200 { + "OK" + } else { + "Internal Server Error" + }; + write!( + stream, + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("response headers"); + stream.write_all(&body).expect("response body"); + }); + format!("http://{address}/openapi.yaml") +} + +fn entries(root: &Path) -> Vec { + let mut names = fs::read_dir(root) + .unwrap_or_else(|error| panic!("reading {}: {error}", root.display())) + .map(|entry| { + entry + .expect("directory entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + names.sort(); + names +} + +fn assert_no_staging_directories(root: &Path) { + assert!( + entries(root) + .into_iter() + .all(|name| !name.starts_with(".evidencectl-new-")), + "failed scaffold left a staging directory" + ); +} + +fn path(path: &Path) -> &str { + path.to_str().expect("UTF-8 test path") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} diff --git a/crates/registry-evidencectl/tests/suggest_e2e.rs b/crates/registry-evidencectl/tests/suggest_e2e.rs new file mode 100644 index 000000000..44ae5ab8c --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_e2e.rs @@ -0,0 +1,504 @@ +//! `evidencectl source suggest` end to end, through the real binary. +//! +//! Every case here drives the installed `evidencectl` executable +//! non-interactively, the way an operator reproduces a reviewed interactive +//! run: a synthetic OpenAPI document and a synthetic sample response are +//! written into a temporary directory, and the command is asked to draft one +//! source from them. Nothing here reaches the network, and no sample value is +//! expected in any assertion: only bounds derived from the sample are. + +use std::{ + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use serde_json::Value; + +/// A two-operation document with one paginated collection response. The +/// vocabulary is deliberately generic: records, trackingId, status, total, +/// recordedOn. +const OPENAPI_DOCUMENT: &str = r#"openapi: 3.0.3 +info: + title: Example record service + version: "1.0.0" +servers: + - url: https://records.example.invalid/v1 +paths: + /records: + get: + summary: List records + parameters: + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + responses: + "200": + description: matching records + content: + application/json: + schema: + type: object + required: [total, records] + properties: + total: + type: integer + records: + type: array + items: + $ref: '#/components/schemas/Record' +components: + schemas: + Record: + type: object + required: [trackingId] + properties: + trackingId: + type: string + status: + type: string + enum: [active, closed] + recordedOn: + type: string + format: date + tags: + type: array + items: + type: string + maxLength: 32 +"#; + +/// Two records, so the array observation is a real length rather than one. +const SAMPLE_RESPONSE: &str = r#"{ + "total": 3, + "records": [ + {"trackingId": "TR-000001", "status": "active", "recordedOn": "2024-05-01"}, + {"trackingId": "TR-000002", "status": "closed", "recordedOn": "2024-05-02"} + ] +} +"#; + +#[test] +fn drafts_into_a_project_and_then_refuses_to_overwrite_the_draft() { + let workspace = tempfile::tempdir().expect("tempdir"); + let sample = write(workspace.path(), "records.sample.json", SAMPLE_RESPONSE); + let project = scaffold(workspace.path(), OPENAPI_DOCUMENT); + + let arguments = vec![ + "source".to_owned(), + "suggest".to_owned(), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/total".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--sample".to_owned(), + path_argument(&sample), + "--source-id".to_owned(), + "source-b".to_owned(), + "--project".to_owned(), + path_argument(&project), + ]; + let output = evidencectl(&arguments); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let schema_path = project.join("schemas/source-b-response.schema.yaml"); + let script_path = project.join("adapters/source-b-extract.rhai"); + let facts_path = project.join("schemas/source-b-facts.schema.yaml"); + let source_path = project.join("sources/source-b.yaml"); + let prepare_path = project.join("adapters/source-b-prepare.rhai"); + let parameters_path = project.join("schemas/source-b-parameters.schema.yaml"); + for path in [ + &schema_path, + &script_path, + &facts_path, + &prepare_path, + ¶meters_path, + ] { + assert!(path.is_file(), "expected {} to be written", path.display()); + } + assert!( + source_path.is_file(), + "the V1 source object must be editable in place" + ); + let source: Value = + serde_norway::from_str(&std::fs::read_to_string(&source_path).expect("read source object")) + .expect("source object parses"); + assert_eq!(source["transport"], "http-json"); + assert!( + source.get("sources").is_none(), + "source file has no wrapper" + ); + assert_eq!(source["authentication"]["kind"], "review-required"); + + // The response schema parses as YAML and carries the sample-derived + // bounds, widened by the narrowing policy rather than copied. + let schema: Value = + serde_norway::from_str(&std::fs::read_to_string(&schema_path).expect("read schema")) + .expect("the drafted response schema parses as YAML"); + assert_eq!(schema["type"], "object"); + assert_eq!(schema["additionalProperties"], Value::Bool(false)); + assert_eq!(schema["properties"]["total"]["minimum"], 0); + assert_eq!(schema["properties"]["total"]["maximum"], 10); + // Two records is weak evidence of how long a page can be; the spec's own + // page-size maximum is the stronger statement and wins for the top-level + // collection. + assert_eq!(schema["properties"]["records"]["maxItems"], 50); + assert_eq!( + schema["properties"]["records"]["items"]["properties"]["trackingId"]["maxLength"], + 16 + ); + + let script = std::fs::read_to_string(&script_path).expect("read extract script"); + assert!( + script.contains(r#"get_path(source_response, "/total")"#), + "extract skeleton must read the selected scalar leaf: {script}" + ); + assert!( + script.contains(r#"get_path(source_response, "/records/0/trackingId")"#), + "extract skeleton must substitute a numeric index for the `*` segment: {script}" + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("evidencectl source suggest: draft for source `source-b`"), + "the report belongs on stdout: {stdout}" + ); + assert!( + stdout.contains("evidencectl source suggest --operation") + && stdout.contains("--project") + && !stdout.contains("--openapi"), + "a project reproduction uses its retained OpenAPI: {stdout}" + ); + // The reproduce line is pasted into a shell, so a pointer carrying `*` is + // quoted rather than left for the shell to expand. + assert!( + stdout.contains("--select '/records/*/trackingId'"), + "the equivalent command must reproduce the selection, quoted: {stdout}" + ); + + // OpenAPI cannot establish governed source policy. The report must make + // that boundary visible instead of presenting generated defaults. + assert!( + stdout.contains("source origin, posture, authentication, selector bindings"), + "the report must name the omitted decisions: {stdout}" + ); + + // Every adopted default is announced with its provenance, so a flag-driven + // run is auditable without re-reading the generated files. + let stderr = stderr_of(&output); + assert!( + stderr.contains("the sample response (widened)"), + "adopted bounds must be announced with their provenance: {stderr}" + ); + assert!( + stderr.contains("a counter usually needs a more generous ceiling"), + "a sampled integer ceiling must be flagged for review: {stderr}" + ); + + // A second identical run must not silently replace a draft an operator may + // already have edited. + let repeat = evidencectl(&arguments); + assert!( + !repeat.status.success(), + "a second run must not overwrite the first draft" + ); + assert!( + stderr_of(&repeat).contains("refusing to overwrite"), + "unexpected refusal message: {}", + stderr_of(&repeat) + ); +} + +#[test] +fn a_project_always_uses_its_retained_openapi() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = scaffold(workspace.path(), OPENAPI_DOCUMENT); + let other = write(workspace.path(), "other.openapi.yaml", OPENAPI_DOCUMENT); + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--project".to_owned(), + path_argument(&project), + "--openapi".to_owned(), + path_argument(&other), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/total".to_owned(), + ]); + assert!(!output.status.success()); + assert!(stderr_of(&output).contains("retained source.openapi.yaml")); + assert!(bundle_entries(&project).is_empty()); +} + +#[test] +fn prints_the_draft_without_a_project_and_writes_nothing() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + let project = scaffold(workspace.path(), OPENAPI_DOCUMENT); + let bundle_before = bundle_entries(&project); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--source-id".to_owned(), + "source-c".to_owned(), + ]); + assert!( + output.status.success(), + "print-only source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + for block in [ + "--- schemas/source-c-response.schema.yaml ---", + "--- adapters/source-c-extract.rhai ---", + "--- schemas/source-c-facts.schema.yaml ---", + ] { + assert!( + stdout.contains(block), + "missing draft block {block}:\n{stdout}" + ); + } + assert!( + stdout.contains("sources:") && stdout.contains("source-c:"), + "the pasteable source block belongs on stdout: {stdout}" + ); + // With no sample, the page-size parameter is the only evidence of how long + // the array can be, and the string leaf has none at all. + assert!( + stdout.contains("maxItems: 50"), + "the page-size parameter must bound the array: {stdout}" + ); + assert!( + stdout.contains("TODO(evidencectl): /records/*/trackingId needs string length bounds"), + "an underivable bound must stay an explicit TODO: {stdout}" + ); + assert!( + stderr_of(&output).contains("a page-size parameter in the spec"), + "adopted page-size bound must be announced: {}", + stderr_of(&output) + ); + + assert_eq!( + bundle_entries(&project), + bundle_before, + "a print-only run must not write into any project" + ); +} + +/// A page-size parameter bounds one page of the top-level collection. It says +/// nothing about how long an array *inside* a record can be, so a nested array +/// stays an explicit TODO instead of inheriting the page size. +#[test] +fn a_page_size_bounds_the_collection_but_not_an_array_inside_a_record() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/tags/*".to_owned(), + "--source-id".to_owned(), + "source-e".to_owned(), + ]); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("maxItems: 50"), + "the page-size parameter must bound the collection: {stdout}" + ); + assert!( + stdout.contains("TODO(evidencectl): /records/*/tags needs maxItems"), + "a nested array must not inherit the page size: {stdout}" + ); + assert_eq!( + stdout.matches("maxItems: 50").count(), + 1, + "only the collection carries the page-size bound: {stdout}" + ); +} + +/// When an operation advertises more than one size ceiling, the bound has to +/// be one a response can actually reach. The smallest ceiling is that bound; +/// the largest is a number the server will never return. +#[test] +fn the_smallest_advertised_size_ceiling_bounds_the_collection() { + let workspace = tempfile::tempdir().expect("tempdir"); + let document = OPENAPI_DOCUMENT.replace( + " - name: pageSize\n", + " - name: limit\n in: query\n schema:\n type: integer\n maximum: 200\n - name: pageSize\n", + ); + let openapi = write(workspace.path(), "records.openapi.yaml", &document); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--source-id".to_owned(), + "source-f".to_owned(), + ]); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("maxItems: 50"), + "the smallest ceiling must bound the collection: {stdout}" + ); + assert!( + !stdout.contains("maxItems: 200"), + "a ceiling the response cannot reach must not become the bound: {stdout}" + ); +} + +/// The runtime's fixed-request method is an enumeration of two. An operation +/// outside it is refused by name, before any file is drafted. +#[test] +fn an_operation_outside_the_runtime_method_enum_is_refused_by_name() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "PATCH /records".to_owned(), + "--select".to_owned(), + "/total".to_owned(), + ]); + assert!( + !output.status.success(), + "PATCH must not draft a source: {}", + stdout_of(&output) + ); + let stderr = stderr_of(&output); + assert!( + stderr.contains("PATCH") && stderr.contains("GET") && stderr.contains("POST"), + "the refusal must name the method and the two admitted ones: {stderr}" + ); +} + +#[test] +fn a_non_interactive_run_names_the_flags_it_needs() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + // `output()` gives the child a null stdin and piped stdout, so neither is a + // terminal and the interactive selection cannot run. + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + ]); + assert!( + !output.status.success(), + "a non-interactive run without the selection flags must fail" + ); + let stderr = stderr_of(&output); + assert!( + stderr.contains("--operation") && stderr.contains("--select"), + "the error must name every missing flag: {stderr}" + ); +} + +/// The classification of `evidence check` is reported in plain words. The +/// runtime is represented here by a stub printing one of its fixed messages, +/// so the assertion is about `evidencectl`'s reporting and not about a +/// deployment project that is not frozen or provisioned yet. +fn evidencectl(arguments: &[String]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +/// Create the only precondition `source suggest --project` requires: an +/// existing project directory. `new` is tested separately and now owns the +/// OpenAPI path itself. +fn scaffold(root: &Path, openapi: &str) -> PathBuf { + let project = root.join("project"); + std::fs::create_dir(&project).expect("project directory"); + for directory in ["sources", "adapters", "schemas"] { + std::fs::create_dir(project.join(directory)).expect("authoring directory"); + } + std::fs::write(project.join("source.openapi.yaml"), openapi).expect("retained OpenAPI"); + project +} + +fn write(root: &Path, name: &str, contents: &str) -> PathBuf { + let path = root.join(name); + std::fs::write(&path, contents).expect("writing a test input"); + path +} + +/// Every path beneath the project's bundle directory, sorted, so a test can +/// prove a run wrote nothing. +fn bundle_entries(project: &Path) -> Vec { + let mut entries = Vec::new(); + let mut pending = vec![project.join("bundle")]; + while let Some(directory) = pending.pop() { + let Ok(children) = std::fs::read_dir(&directory) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if path.is_dir() { + pending.push(path.clone()); + } + entries.push(path); + } + } + entries.sort(); + entries +} + +fn path_argument(path: &Path) -> String { + path.to_str() + .expect("test paths are valid UTF-8") + .to_owned() +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} diff --git a/crates/registry-evidencectl/tests/suggest_emit.rs b/crates/registry-evidencectl/tests/suggest_emit.rs new file mode 100644 index 000000000..68b113fef --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_emit.rs @@ -0,0 +1,1064 @@ +//! Emit stage: draft artifacts from a narrowed response schema, write them +//! into a deployment project, and classify `evidence check` output. +//! +//! Inputs are built here as `NarrowOutcome`/`EmitInputs` literals rather than +//! produced by the other pipeline stages, so a failure names an emit-stage +//! rule and never a bug in the OpenAPI loader, the sampler, or the narrowing +//! heuristics (all still placeholders as this file is written). + +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +#[allow(dead_code)] +#[path = "../src/fixtures.rs"] +mod fixtures; + +#[allow(dead_code)] +#[path = "../src/suggest/emit.rs"] +mod emit; + +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use emit::{CheckClassification, EmitInputs}; +use types::{ + BoundKind, BoundNeed, BoundValues, NarrowOutcome, OperationKey, Provenance, SpecSource, + SuggestedBound, +}; + +/// A schema exercising every case the response-schema renderer must handle: +/// a resolved integer bound (derived from the spec), an unresolved array +/// missing `maxItems`, a nested unresolved string missing length bounds +/// (despite carrying a sample-derived suggestion nobody confirmed yet), and a +/// nullable string that needs no bound because `format` alone satisfies the +/// subset. +fn narrow_outcome_fixture() -> NarrowOutcome { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "event_date": {"type": ["string", "null"], "format": "date"}, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "status": {"type": "string"} + } + } + } + } + }); + + let unresolved = vec![ + BoundNeed { + pointer: "/results".to_owned(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }, + BoundNeed { + pointer: "/results/*/status".to_owned(), + kind: BoundKind::StringLength, + suggestion: Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64, + }, + provenance: Provenance::Sample, + }), + }, + ]; + + NarrowOutcome { schema, unresolved } +} + +fn needs_fixture() -> Vec { + vec![ + BoundNeed { + pointer: "/total".to_owned(), + kind: BoundKind::IntegerRange, + suggestion: Some(SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + provenance: Provenance::Spec, + }), + }, + BoundNeed { + pointer: "/results".to_owned(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }, + BoundNeed { + pointer: "/results/*/status".to_owned(), + kind: BoundKind::StringLength, + suggestion: Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64, + }, + provenance: Provenance::Sample, + }), + }, + ] +} + +fn base_inputs() -> EmitInputs { + EmitInputs { + source_id: "search-a".to_owned(), + operation: OperationKey { + method: "GET".to_owned(), + path: "/v1/records".to_owned(), + }, + status: "200".to_owned(), + media_type: "application/json".to_owned(), + base_url_suggestion: emit::split_server_url("https://api.example.invalid"), + selection: vec![ + "/total".to_owned(), + "/event_date".to_owned(), + "/results/*/status".to_owned(), + ], + narrowed: narrow_outcome_fixture(), + needs: needs_fixture(), + openapi: SpecSource::File(PathBuf::from("tests/fixtures/openapi/example.yaml")), + sample_path: None, + project: None, + } +} + +fn file_contents<'a>(artifacts: &'a types::DraftArtifacts, bundle_relative_path: &str) -> &'a str { + artifacts + .files + .iter() + .find(|file| file.bundle_relative_path == bundle_relative_path) + .unwrap_or_else(|| panic!("expected a draft file at {bundle_relative_path}")) + .contents + .as_str() +} + +fn assert_adjacent(text: &str, comment: &str, next_line_contains: &str) { + let lines: Vec<&str> = text.lines().collect(); + let comment_index = lines + .iter() + .position(|line| line.contains(comment)) + .unwrap_or_else(|| panic!("expected a line containing {comment:?} in:\n{text}")); + let next = lines + .get(comment_index + 1) + .unwrap_or_else(|| panic!("expected a line after {comment:?} in:\n{text}")); + assert!( + next.contains(next_line_contains), + "expected the line after {comment:?} to contain {next_line_contains:?}, got {next:?}" + ); +} + +#[test] +fn response_schema_parses_and_carries_adjacent_annotations() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + assert!(parsed.is_mapping()); + + assert_adjacent( + response_schema, + "# TODO(evidencectl): /results needs maxItems", + "results:", + ); + assert_adjacent( + response_schema, + "# TODO(evidencectl): /results/*/status needs string length bounds", + "status:", + ); + assert_adjacent( + response_schema, + "# derived from the OpenAPI schema", + "total:", + ); + + // event_date needs no bound comment: `format: date` alone satisfies the + // subset, so no BoundNeed exists for it in the fixture. + let event_date_index = response_schema + .lines() + .position(|line| line.trim() == "event_date:") + .expect("event_date property present"); + let preceding = response_schema.lines().nth(event_date_index - 1).unwrap(); + assert!( + !preceding.contains("TODO(evidencectl)") && !preceding.contains("derived from"), + "event_date should carry no bound annotation, got preceding line {preceding:?}" + ); +} + +/// A flow sequence makes `,`, `[`, `]`, `{` and `}` significant anywhere in a +/// member, not only at its start: an unquoted `pending, review` would parse as +/// two enumeration members. +#[test] +fn a_flow_list_member_containing_a_comma_stays_one_member() { + let mut inputs = base_inputs(); + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "status": {"type": "string", "enum": ["pending, review", "closed"]} + } + }), + unresolved: Vec::new(), + }; + inputs.needs = Vec::new(); + inputs.selection = vec!["/status".to_owned(), "/a,b".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + let enumeration = parsed["properties"]["status"]["enum"] + .as_sequence() + .expect("enum is a sequence"); + assert_eq!(enumeration.len(), 2, "got {enumeration:?}"); + assert_eq!(enumeration[0].as_str(), Some("pending, review")); + + let block: serde_norway::Value = + serde_norway::from_str(&artifacts.source_block).expect("source block parses as YAML"); + let projection = block["sources"]["search-a"]["request"]["projection"] + .as_sequence() + .expect("projection is a sequence"); + assert_eq!(projection.len(), 2, "got {projection:?}"); + assert_eq!(projection[1].as_str(), Some("/a,b")); +} + +/// A bound demanded of an array's items node belongs above `items:`; without +/// it the only annotation-carrying place is the object-properties loop, which +/// an array of scalars never reaches. +#[test] +fn a_bound_on_an_array_items_node_is_annotated_above_items() { + let mut inputs = base_inputs(); + let unresolved = vec![BoundNeed { + pointer: "/tags/*".to_owned(), + kind: BoundKind::StringLength, + suggestion: None, + }]; + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "tags": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": {"type": "string"} + } + } + }), + unresolved: unresolved.clone(), + }; + inputs.needs = unresolved; + inputs.selection = vec!["/tags/*".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + assert_adjacent( + response_schema, + "# TODO(evidencectl): /tags/* needs string length bounds", + "items:", + ); +} + +/// `uniqueItems` and an array `const` narrow the accepted response, and the +/// narrowing stage deliberately carries both through; dropping them here would +/// widen the drafted schema past what the specification stated. +#[test] +fn an_array_keeps_unique_items_and_a_constant() { + let mut inputs = base_inputs(); + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "tags": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "uniqueItems": true, + "const": ["alpha", "beta"], + "items": {"type": "string", "maxLength": 16} + } + } + }), + unresolved: Vec::new(), + }; + inputs.needs = Vec::new(); + inputs.selection = vec!["/tags/*".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + assert_eq!( + parsed["properties"]["tags"]["uniqueItems"].as_bool(), + Some(true) + ); + assert_eq!( + parsed["properties"]["tags"]["const"] + .as_sequence() + .map(Vec::len), + Some(2) + ); +} + +#[test] +fn facts_schema_stub_is_minimal_and_parses() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let facts_schema = file_contents(&artifacts, "schemas/search-a-facts.schema.yaml"); + + let parsed: serde_norway::Value = + serde_norway::from_str(facts_schema).expect("facts schema parses as YAML"); + let mapping = parsed.as_mapping().expect("facts schema is a mapping"); + assert_eq!( + mapping + .get(serde_norway::Value::String("type".to_owned())) + .and_then(|v| v.as_str()), + Some("object") + ); + assert_eq!( + mapping + .get(serde_norway::Value::String( + "additionalProperties".to_owned() + )) + .and_then(|v| v.as_bool()), + Some(false) + ); + let required = mapping + .get(serde_norway::Value::String("required".to_owned())) + .and_then(|v| v.as_sequence()) + .expect("required is a sequence"); + assert_eq!(required.len(), 1); + assert_eq!(required[0].as_str(), Some("placeholder_fact")); + + let properties = mapping + .get(serde_norway::Value::String("properties".to_owned())) + .and_then(|v| v.as_mapping()) + .expect("properties is a mapping"); + assert_eq!(properties.len(), 1); + assert!(properties.contains_key(serde_norway::Value::String("placeholder_fact".to_owned()))); + + assert!(facts_schema.contains("TODO(evidencectl)")); +} + +#[test] +fn extract_script_uses_get_path_for_every_selected_leaf_with_wildcard_substitution() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!(script.contains(r#"get_path(source_response, "/total")"#)); + assert!(script.contains(r#"get_path(source_response, "/event_date")"#)); + // The extended pointer's `*` becomes `0` in the plain get_path pointer. + assert!(script.contains(r#"get_path(source_response, "/results/0/status")"#)); + assert_eq!(script.matches("is_missing(leaf_").count(), 3); + + // A selection under an array gets a commented loop sketch. + assert!(script.contains("for element_1 in items_1 {")); + assert!(!script.contains(r#"source_response["total"]"#)); + + assert!(script.contains("fn extract(source_response, parameters) {")); +} + +/// The commented loop sketch is read by an operator and then pasted, so it may +/// only use constructs the runtime's Rhai engine actually registers: ranges +/// (`..`) are disabled, `len` is a property getter rather than a method, and +/// no operator concatenates a string with an integer. +#[test] +fn the_array_loop_sketch_uses_only_constructs_the_runtime_registers() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!(!script.contains("0.."), "ranges are disabled: {script}"); + assert!(!script.contains(".len()"), "len is a getter: {script}"); + assert!( + !script.contains("+ index"), + "string + integer has no operator: {script}" + ); + // The array is reached by its own pointer and iterated directly. + assert!( + script.contains(r#"// let items_1 = get_path(source_response, "/results");"#), + "the sketch must read the array by its own pointer: {script}" + ); + assert!( + script.contains(r#"// if !is_missing(items_1) {"#), + "the sketch must guard an absent array: {script}" + ); + assert!( + script.contains(r#"// let value = get_path(element_1, "/status");"#), + "the sketch must read the leaf from each element: {script}" + ); +} + +/// A pointer crossing two arrays names each array by its own pointer: the +/// segment before the first `*` says nothing about the inner one. +#[test] +fn a_nested_array_loop_sketch_names_every_array_it_crosses() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/results/*/tags/*".to_owned()]; + let artifacts = emit::draft(&inputs).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!( + script.contains(r#"let items_1 = get_path(source_response, "/results");"#), + "the outer array is /results: {script}" + ); + assert!( + script.contains(r#"let items_2 = get_path(element_1, "/tags");"#), + "the inner array is /tags, reached from an outer element: {script}" + ); + assert!( + script.contains("element_2 is the value at /results/*/tags/*"), + "the innermost element is the selected value: {script}" + ); +} + +#[test] +fn get_path_byte_ceiling_is_enforced_and_names_the_pointer() { + let mut inputs = base_inputs(); + let long_segment = "x".repeat(300); + let long_pointer = format!("/{long_segment}"); + inputs.selection = vec![long_pointer.clone()]; + + let error = emit::draft(&inputs).expect_err("oversized pointer must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains(&long_pointer) || message.contains("byte")); + assert!(message.contains("256")); +} + +#[test] +fn get_path_segment_ceiling_is_enforced_and_names_the_pointer() { + let mut inputs = base_inputs(); + let segments: Vec = (0..17).map(|n| format!("s{n}")).collect(); + let deep_pointer = format!("/{}", segments.join("/")); + inputs.selection = vec![deep_pointer.clone()]; + + let error = emit::draft(&inputs).expect_err("pointer with too many segments must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains(&deep_pointer)); + assert!(message.contains("16")); +} + +#[test] +fn source_block_parses_and_carries_only_mechanical_source_facts() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let parsed: serde_norway::Value = + serde_norway::from_str(&artifacts.source_block).expect("source block parses as YAML"); + assert!(parsed.is_mapping()); + + assert!(artifacts.source_block.contains("search-a:")); + assert!(artifacts.source_block.contains("method: GET")); + assert!(artifacts.source_block.contains("path: /v1/records")); + assert!(artifacts.source_block.contains("value: application/json")); + assert!(artifacts + .source_block + .contains("projection: [/total, /event_date, /results/*/status]")); + assert!(artifacts + .source_block + .contains("baseUrl: https://api.example.invalid")); + assert!(artifacts + .source_block + .contains("responseSchema: schemas/search-a-response.schema.yaml")); + assert!(artifacts + .source_block + .contains("extractScript: adapters/search-a-extract.rhai")); + assert!(artifacts + .source_block + .contains("factSchema: schemas/search-a-facts.schema.yaml")); + + let source = &parsed["sources"]["search-a"]; + for governed in [ + "baseUrl", + "posture", + "authentication", + "selectorInputs", + "prepareScript", + "adapterParameters", + "preparationLimits", + ] { + assert!(source.get(governed).is_none(), "draft invented {governed}"); + } +} + +/// OpenAPI establishes the method, but it does not establish the adopter's +/// bounded preparation policy. +#[test] +fn a_get_source_omits_request_channel_policy() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let block = &artifacts.source_block; + + assert!(block.contains("method: GET"), "{block}"); + assert!(!block.contains("query:"), "{block}"); + assert!(!block.contains("jsonBody:"), "{block}"); + assert!(!block.contains("maximumQueryPairs:"), "{block}"); +} + +#[test] +fn a_post_source_omits_request_channel_policy() { + let mut inputs = base_inputs(); + inputs.operation.method = "POST".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + assert!(block.contains("method: POST"), "{block}"); + assert!(!block.contains("query:"), "{block}"); + assert!(!block.contains("jsonBody:"), "{block}"); + assert!(!block.contains("maximumQueryPairs:"), "{block}"); +} + +/// The runtime's fixed-request method admits GET and POST only. +#[test] +fn a_method_outside_the_runtime_enum_is_refused_by_name() { + let mut inputs = base_inputs(); + inputs.operation.method = "PATCH".to_owned(); + + let error = emit::draft(&inputs).expect_err("PATCH is not an admitted method"); + let message = format!("{error:#}"); + assert!(message.contains("PATCH"), "{message}"); + assert!( + message.contains("GET") && message.contains("POST"), + "{message}" + ); +} + +/// A templated OpenAPI path cannot be a `path:`, which the runtime rejects on +/// `{` and `}`. It becomes a `pathTemplate:` without inventing bindings. +#[test] +fn a_templated_path_becomes_a_path_template_without_bindings() { + let mut inputs = base_inputs(); + inputs.operation.path = "/v1/records/{id}".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + let parsed: serde_norway::Value = + serde_norway::from_str(block).expect("source block parses as YAML"); + assert!(parsed.is_mapping()); + + assert!(block.contains("pathTemplate: /v1/records/{id}"), "{block}"); + assert!(!block.contains("path: /v1/records/{id}"), "{block}"); + assert!(!block.contains("pathBindings:"), "{block}"); +} + +/// `baseUrl` is validated as an origin: any path the OpenAPI server URL +/// carries has to move onto the request path instead of staying in the origin. +#[test] +fn a_server_path_prefix_moves_onto_the_request_path() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = emit::split_server_url("https://api.example.invalid:8443/v1/"); + inputs.operation.path = "/records".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + assert!( + block.contains("baseUrl: https://api.example.invalid:8443\n"), + "{block}" + ); + assert!(block.contains("path: /v1/records"), "{block}"); +} + +#[test] +fn a_server_path_prefix_moves_onto_a_path_template_too() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = emit::split_server_url("https://api.example.invalid/v1"); + inputs.operation.path = "/records/{id}".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!( + artifacts + .source_block + .contains("pathTemplate: /v1/records/{id}"), + "{}", + artifacts.source_block + ); +} + +/// A server URL with template variables names no single origin, so the draft +/// falls back to the placeholder rather than emitting an unusable baseUrl. +#[test] +fn a_server_url_with_variables_yields_no_base_url_suggestion() { + assert!(emit::split_server_url("https://{tenant}.example.invalid/v1").is_none()); + assert!(emit::split_server_url("/relative/only").is_none()); + + let split = emit::split_server_url("https://api.example.invalid").expect("plain origin splits"); + assert_eq!(split.base_url, "https://api.example.invalid"); + assert_eq!(split.path_prefix, ""); +} + +/// Acquisition posture is a governed decision that OpenAPI cannot make. +#[test] +fn the_draft_omits_acquisition_posture() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let block = &artifacts.source_block; + + assert!(!block.contains("posture:"), "{block}"); +} + +#[test] +fn source_block_leaves_base_url_absent_without_a_suggestion() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = None; + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!(artifacts + .source_block + .contains("OpenAPI document gives no fixed origin")); + assert!(!artifacts.source_block.contains("baseUrl:")); +} + +#[test] +fn write_into_project_creates_directories_and_writes_every_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let written = emit::write_into_project(&project, &artifacts.files).expect("write"); + assert_eq!(written.len(), 5); + + for file in &artifacts.files { + let path = project.join("bundle").join(&file.bundle_relative_path); + assert!(path.exists(), "expected {path:?} to exist"); + let on_disk = std::fs::read_to_string(&path).expect("read written file"); + assert_eq!(on_disk, file.contents); + } +} + +#[test] +fn write_into_project_refuses_all_collisions_and_writes_nothing() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let response_path = project + .join("bundle") + .join("schemas/search-a-response.schema.yaml"); + let facts_path = project + .join("bundle") + .join("schemas/search-a-facts.schema.yaml"); + std::fs::create_dir_all(response_path.parent().unwrap()).expect("mkdir"); + std::fs::write(&response_path, b"pre-existing\n").expect("seed collision file 1"); + std::fs::write(&facts_path, b"pre-existing\n").expect("seed collision file 2"); + + let error = + emit::write_into_project(&project, &artifacts.files).expect_err("must refuse to overwrite"); + let message = error.to_string(); + assert!(message.contains(&response_path.display().to_string())); + assert!(message.contains(&facts_path.display().to_string())); + + let extract_path = project + .join("bundle") + .join("adapters/search-a-extract.rhai"); + assert!( + !extract_path.exists(), + "a non-colliding file must not be written when any file collides" + ); + // The pre-existing collision files must be untouched. + assert_eq!( + std::fs::read_to_string(&response_path).unwrap(), + "pre-existing\n" + ); +} + +/// A value the operator typed is not a value the pipeline derived, and must +/// not be reported as one. +#[test] +fn an_operator_chosen_bound_is_reported_as_chosen_not_derived() { + let mut inputs = base_inputs(); + inputs.narrowed.unresolved = Vec::new(); + inputs.needs = vec![BoundNeed { + pointer: "/total".to_owned(), + kind: BoundKind::IntegerRange, + suggestion: Some(SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + provenance: Provenance::Operator, + }), + }]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + assert_adjacent(response_schema, "# chosen at the prompt", "total:"); + assert!( + !response_schema.contains("# derived from"), + "{response_schema}" + ); + + assert!( + artifacts.report.contains("Chosen at the prompt:"), + "{}", + artifacts.report + ); + assert!( + artifacts.report.contains("Derived automatically: none."), + "an operator's own answer is not a derivation: {}", + artifacts.report + ); +} + +/// Only a need whose accepted value differs from the suggestion is +/// reattributed: adopting a suggestion unchanged keeps its real provenance. +#[test] +fn only_an_edited_or_invented_bound_is_reattributed_to_the_operator() { + let mut needs = needs_fixture(); + let mut resolutions: std::collections::BTreeMap<(String, BoundKind), BoundValues> = + std::collections::BTreeMap::new(); + // Adopted unchanged. + resolutions.insert( + ("/total".to_owned(), BoundKind::IntegerRange), + BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + ); + // Answered where nothing was suggested. + resolutions.insert( + ("/results".to_owned(), BoundKind::ArrayMaxItems), + BoundValues::MaxItems(32), + ); + // Edited away from the suggestion. + resolutions.insert( + ("/results/*/status".to_owned(), BoundKind::StringLength), + BoundValues::StringLength { + min_length: 1, + max_length: 128, + }, + ); + + emit::attribute_operator_edits(&mut needs, &resolutions); + + assert_eq!( + needs[0].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Spec) + ); + assert_eq!( + needs[1].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Operator) + ); + assert_eq!( + needs[1].suggestion.as_ref().map(|s| &s.values), + Some(&BoundValues::MaxItems(32)) + ); + assert_eq!( + needs[2].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Operator) + ); +} + +/// The report must name the governed decisions absent from a mechanical draft. +#[test] +fn the_report_names_omitted_governed_source_decisions() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + assert!( + artifacts + .report + .contains("source origin, posture, authentication, selector bindings"), + "{}", + artifacts.report + ); +} + +#[test] +fn equivalent_command_is_deterministic_with_the_documented_flag_order() { + let mut inputs = base_inputs(); + inputs.sample_path = Some(PathBuf::from("tests/fixtures/samples/example.json")); + inputs.project = Some(PathBuf::from("/tmp/example-project")); + + let first = emit::draft(&inputs).expect("draft").equivalent_command; + let second = emit::draft(&inputs).expect("draft").equivalent_command; + assert_eq!(first, second, "equivalent_command must be deterministic"); + + let expected = "evidencectl source suggest \ +--operation 'GET /v1/records' \ +--status 200 \ +--media-type application/json \ +--sample tests/fixtures/samples/example.json \ +--source-id search-a \ +--project /tmp/example-project \ +--select /total \ +--select /event_date \ +--select '/results/*/status'"; + assert_eq!(first, expected); +} + +/// The reproduce line is meant to be pasted into a shell. A projection pointer +/// carries `*`, which an interactive zsh expands (and aborts on when nothing +/// matches), so every value a shell would rewrite is quoted. +#[test] +fn the_reproduce_command_quotes_every_value_a_shell_would_rewrite() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/results/*/status".to_owned(), "/a?b".to_owned()]; + inputs.openapi = SpecSource::File(PathBuf::from("/tmp/spec (copy).yaml")); + + let command = emit::draft(&inputs).expect("draft").equivalent_command; + assert!( + command.contains("--select '/results/*/status'"), + "{command}" + ); + assert!(command.contains("--select '/a?b'"), "{command}"); + assert!( + command.contains("--openapi '/tmp/spec (copy).yaml'"), + "{command}" + ); + assert!( + !command.contains("--select /results/*/status"), + "a bare glob must not survive into the reproduce line: {command}" + ); +} + +#[test] +fn equivalent_command_omits_optional_flags_when_absent() { + let inputs = base_inputs(); + let command = emit::draft(&inputs).expect("draft").equivalent_command; + assert!(!command.contains("--sample")); + assert!(!command.contains("--project")); +} + +#[cfg(unix)] +fn write_stub_evidence(dir: &Path, script: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + + let path = dir.join("evidence"); + std::fs::write(&path, script).expect("write stub evidence script"); + let mut permissions = std::fs::metadata(&path).expect("stat stub").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod stub"); + path +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_successful_check_as_bundle_accepted() { + let temp = tempfile::tempdir().expect("tempdir"); + let stub = write_stub_evidence(temp.path(), "#!/bin/sh\nexit 0\n"); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::BundleAccepted); +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_deployment_message_as_bundle_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = "#!/bin/sh\nprintf 'evidence: deployment configuration is invalid: artifact evidence.yaml: unknown field\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + match classification { + CheckClassification::BundleRejected { stderr } => { + assert!(stderr.contains("deployment configuration is invalid")); + } + other => panic!("expected BundleRejected, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_runtime_initialization_message_as_secrets_unprovisioned() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = + "#!/bin/sh\nprintf 'evidence: runtime signing initialization failed\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::SecretsUnprovisioned); +} + +// --- Escaping and pointer rendering ------------------------------------- + +/// The reproduce line is documented as paste-ready. A path carrying `$` or a +/// backtick must therefore reach the shell as literal text: unquoted, `$HOME` +/// expands and a backtick pair runs a command, so the pasted line reproduces +/// something other than the run it claims to reproduce. +#[test] +fn the_reproduce_line_quotes_shell_expansion_characters() { + let mut inputs = base_inputs(); + inputs.openapi = SpecSource::File(PathBuf::from("/srv/specs/$HOME/records`id`.yaml")); + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!( + artifacts + .equivalent_command + .contains("'/srv/specs/$HOME/records`id`.yaml'"), + "expansion characters must be single-quoted: {}", + artifacts.equivalent_command + ); +} + +/// A property name may legally contain a double quote or a backslash. Both +/// terminate or escape inside a Rhai string literal, so an unescaped one +/// produces an extract script that does not parse, or parses as something +/// other than the pointer it was drafted from. +#[test] +fn the_extract_script_escapes_quotes_in_a_pointer() { + let mut inputs = base_inputs(); + inputs.selection = vec![r#"/say"hi"#.to_owned(), r"/back\slash".to_owned()]; + let artifacts = emit::draft(&inputs).expect("draft"); + let extract = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!( + extract.contains(r#"get_path(source_response, "/say\"hi")"#), + "a double quote must be escaped in the Rhai literal:\n{extract}" + ); + assert!( + extract.contains(r#"get_path(source_response, "/back\\slash")"#), + "a backslash must be escaped in the Rhai literal:\n{extract}" + ); +} + +/// The same applies to the drafted YAML: a double-quoted scalar carrying a raw +/// newline or tab folds, silently changing the value the runtime reads. +#[test] +fn drafted_yaml_escapes_control_characters_in_a_scalar() { + let mut inputs = base_inputs(); + inputs.media_type = "application/json\nx-injected: true".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let source_block = artifacts + .files + .iter() + .find(|file| file.bundle_relative_path.ends_with(".yaml")) + .map(|file| file.contents.clone()) + .unwrap_or_default(); + let combined = format!("{}\n{}", artifacts.source_block, source_block); + + assert!( + combined.contains(r"application/json\nx-injected"), + "a newline must be escaped rather than folded:\n{combined}" + ); + // A raw newline inside the quoted scalar would make the injected key a + // sibling mapping entry. + assert!( + !combined.contains("\nx-injected: true"), + "the scalar must not break out into its own mapping key:\n{combined}" + ); +} + +/// Emitted paths are project-relative and never refer to removed source-tree +/// templates. +#[test] +fn emitted_paths_are_project_relative() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let all = artifacts + .files + .iter() + .fold(artifacts.source_block.clone(), |mut text, file| { + text.push('\n'); + text.push_str(&file.contents); + text + }); + + assert!(all.contains("schemas/search-a-response.schema.yaml")); + assert!(all.contains("adapters/search-a-extract.rhai")); + assert!( + !all.contains("templates/bundle/"), + "an evidencectl source path is not a path in the adopter's project:\n{all}" + ); +} + +/// A response body that is itself an array still needs a `maxItems`. The bound +/// belongs to the root node, which has no property to hang a comment on and no +/// pointer text of its own. +#[test] +fn a_root_level_array_carries_its_own_bound_annotation() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/*/trackingId".to_owned()]; + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": {"trackingId": {"type": "string", "minLength": 0, "maxLength": 64}} + } + }), + unresolved: vec![BoundNeed { + pointer: String::new(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }], + }; + inputs.needs = vec![BoundNeed { + pointer: String::new(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + + assert!( + response_schema.contains("TODO(evidencectl): (response root) needs maxItems"), + "the root array's own bound must be annotated:\n{response_schema}" + ); + assert!( + !response_schema.contains("TODO(evidencectl): "), + "an empty pointer must not render as blank text:\n{response_schema}" + ); + assert!( + !artifacts.report.contains("TODO(evidencectl): "), + "an empty pointer must not render as blank text in the report:\n{}", + artifacts.report + ); +} + +/// A rejected bundle and unprovisioned secrets are opposite outcomes: one means +/// the draft is wrong, the other means the draft is fine and the operator has +/// not generated keys yet. Classifying a bundle rejection as the latter reports +/// success for a draft the runtime refused. +#[cfg(unix)] +#[test] +fn verify_never_classifies_a_bundle_rejection_as_unprovisioned_secrets() { + for stage in ["bundle", "source", "rate-limit"] { + let temp = tempfile::tempdir().expect("tempdir"); + let script = format!( + "#!/bin/sh\nprintf 'evidence: runtime {stage} initialization failed\\n' >&2\nexit 1\n" + ); + let stub = write_stub_evidence(temp.path(), &script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert!( + matches!(classification, CheckClassification::BundleRejected { .. }), + "a {stage}-stage failure is a rejected bundle, got {classification:?}" + ); + } +} + +/// A future runtime may append a reason to a secret-stage message. The +/// classification must survive that without loosening into a prefix match that +/// also catches the bundle stage. +#[cfg(unix)] +#[test] +fn verify_classifies_a_secret_stage_message_carrying_a_reason() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = "#!/bin/sh\nprintf 'evidence: runtime audit initialization failed: the audit chain head does not verify\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::SecretsUnprovisioned); +} diff --git a/crates/registry-evidencectl/tests/suggest_fetch.rs b/crates/registry-evidencectl/tests/suggest_fetch.rs new file mode 100644 index 000000000..4cfe9db2b --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_fetch.rs @@ -0,0 +1,290 @@ +//! Reading an OpenAPI document from a URL: which URLs are fetched at all, and +//! what happens to the response. +//! +//! Every test here is offline. The policy half is a pure function over a URL +//! string, and the transport half runs against a throwaway HTTP server bound +//! to a loopback port in this process, which is also the one case where plain +//! `http` is permitted. Nothing in this file reaches a public host, so the +//! suite stays hermetic and runs the same on a machine with no network. + +#[path = "../src/suggest/fetch.rs"] +mod fetch; +// `openapi.rs` pulls in the whole pipeline's type vocabulary; this binary +// exercises the loading slice of it. +#[allow(dead_code)] +#[path = "../src/suggest/openapi.rs"] +mod openapi; +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::{ + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::Path, +}; + +use types::SpecSource; + +// --- which URLs are fetched -------------------------------------------------- + +#[test] +fn an_https_url_is_read_as_a_url() { + let source = fetch::spec_source("https://api.example.test/openapi.yaml").expect("accepted"); + assert!(matches!(source, SpecSource::Url(_))); + assert_eq!(source.display(), "https://api.example.test/openapi.yaml"); +} + +/// The URL test is for a scheme at the front of the argument, so an ordinary +/// path is still a path even when it contains the characters a URL uses. +#[test] +fn a_path_is_read_as_a_path() { + for argument in [ + "openapi.yaml", + "./specs/openapi.yaml", + "/srv/specs/openapi.yaml", + "specs/http://not-a-url.yaml", + ] { + let source = fetch::spec_source(argument).expect("accepted"); + assert!( + matches!(source, SpecSource::File(_)), + "`{argument}` should be read as a file" + ); + } +} + +/// A description read in the clear can be tampered with, and it decides which +/// leaves the operator is offered, so the projection a drafted source ends up +/// reading is only as trustworthy as the transport that carried it. This is +/// the same rule the runtime applies to the source URLs it will itself call. +#[test] +fn plain_http_to_a_host_that_is_not_loopback_is_refused() { + for url in [ + "http://api.example.test/openapi.yaml", + "http://192.0.2.10/openapi.yaml", + "http://localhost:3000/openapi.yaml", + ] { + let error = fetch::spec_source(url).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("loopback"), + "`{url}` message was: {message}" + ); + } +} + +#[test] +fn plain_http_to_a_numeric_loopback_host_is_accepted() { + for url in [ + "http://127.0.0.1:8080/openapi.yaml", + "http://[::1]:8080/openapi.yaml", + ] { + let source = fetch::spec_source(url).unwrap_or_else(|error| panic!("{url}: {error:#}")); + assert!(matches!(source, SpecSource::Url(_)), "`{url}` was refused"); + } +} + +/// The refusal must not quote the URL back: what makes it unacceptable is the +/// credential inside it, and a message is printed to a terminal and kept in a +/// scrollback buffer. +#[test] +fn a_url_carrying_credentials_is_refused_without_echoing_them() { + let error = + fetch::spec_source("https://reader:hunter2@api.example.test/openapi.yaml").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("credentials"), "message was: {message}"); + assert!( + !message.contains("hunter2") && !message.contains("reader"), + "the refusal echoed the credential: {message}" + ); +} + +#[test] +fn a_query_or_fragment_is_refused_without_echoing_its_value() { + for (url, sensitive) in [ + ( + "https://api.example.test/openapi.yaml?access_token=query-secret", + "query-secret", + ), + ( + "https://api.example.test/openapi.yaml#private-fragment", + "private-fragment", + ), + ] { + let error = fetch::spec_source(url).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("query or fragment") && message.contains("local file"), + "message was: {message}" + ); + assert!( + !message.contains(sensitive) && !message.contains(url), + "the refusal echoed the unsafe URL component: {message}" + ); + } +} + +#[test] +fn a_scheme_that_is_not_http_is_refused() { + for url in [ + "ftp://example.test/openapi.yaml", + "file:///tmp/openapi.yaml", + ] { + let error = fetch::spec_source(url).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("not fetched"), + "`{url}` message was: {message}" + ); + } +} + +// --- what happens to the response -------------------------------------------- + +#[test] +fn a_document_served_over_loopback_is_opened_exactly_as_the_file_would_be() { + let fixture = fixture_text("records-3.0.yaml"); + let address = serve(move |_target| ok(&fixture, "application/yaml")); + + let from_url = openapi::Spec::open(&url_source(address, "/openapi.yaml")).expect("opens"); + let from_file = + openapi::Spec::open(&SpecSource::File(fixture_path("records-3.0.yaml"))).expect("opens"); + + assert_eq!( + from_url + .operations() + .into_iter() + .map(|summary| summary.key) + .collect::>(), + from_file + .operations() + .into_iter() + .map(|summary| summary.key) + .collect::>() + ); +} + +/// A description behind authentication answers with a status, not a document. +/// Saying which status came back is what tells the operator to fetch it +/// themselves rather than to go looking for a malformed file. +#[test] +fn a_non_success_status_is_reported_with_its_code() { + let address = serve(|_target| { + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + }); + + let error = openapi::Spec::open(&url_source(address, "/openapi.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("401") && message.contains("authentication"), + "message was: {message}" + ); +} + +/// The ceiling is enforced while reading rather than from the declared length, +/// so a response that never says how long it is cannot talk this into +/// buffering whatever the server feels like sending. +#[test] +fn a_body_past_the_ceiling_is_refused() { + let declared = serve(|_target| ok(&"x".repeat(4096), "application/yaml")); + let undeclared = serve(|_target| { + let mut response = + b"HTTP/1.1 200 OK\r\nContent-Type: application/yaml\r\nConnection: close\r\n\r\n" + .to_vec(); + response.extend_from_slice("x".repeat(4096).as_bytes()); + response + }); + + for address in [declared, undeclared] { + let SpecSource::Url(url) = url_source(address, "/openapi.yaml") else { + unreachable!("built as a URL") + }; + let error = fetch::get(&url, 512).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("512 byte limit"), "message was: {message}"); + } +} + +/// A published description is very often a stable URL pointing at a versioned +/// one, so a redirect is followed; where it lands is checked under the same +/// rule as where it started. +#[test] +fn a_redirect_is_followed_and_its_destination_is_read() { + let fixture = fixture_text("records-3.0.yaml"); + let destination = serve(move |_target| ok(&fixture, "application/yaml")); + let entry = serve(move |_target| { + format!( + "HTTP/1.1 302 Found\r\nLocation: http://{destination}/versioned.yaml\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes() + }); + + let spec = openapi::Spec::open(&url_source(entry, "/latest.yaml")).expect("opens"); + assert!(!spec.operations().is_empty()); +} + +// --- helpers ----------------------------------------------------------------- + +fn fixture_path(name: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/openapi") + .join(name) +} + +fn fixture_text(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)).expect("fixture readable") +} + +fn url_source(address: SocketAddr, path: &str) -> SpecSource { + fetch::spec_source(&format!("http://{address}{path}")).expect("loopback URL accepted") +} + +fn ok(body: &str, content_type: &str) -> Vec { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() +} + +/// Binds a loopback port and answers every request with `respond`, returning +/// the address it bound. +/// +/// The serving thread is left running for the rest of the test binary's life: +/// it holds nothing but a socket, and a test that finished has no way to be +/// waiting on it. Requests are read only far enough to know what was asked +/// for, which is all any test here needs to decide what to send back. +fn serve(respond: impl Fn(&str) -> Vec + Send + 'static) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("binds a loopback port"); + let address = listener.local_addr().expect("has an address"); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + handle(stream, &respond); + } + }); + address +} + +fn handle(mut stream: TcpStream, respond: &impl Fn(&str) -> Vec) { + let mut reader = BufReader::new(stream.try_clone().expect("clones the stream")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + loop { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) => break, + Ok(_) if header == "\r\n" || header == "\n" => break, + Ok(_) => {} + Err(_) => return, + } + } + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let response = respond(target); + let _ = stream.write_all(&response); + let _ = stream.flush(); +} diff --git a/crates/registry-evidencectl/tests/suggest_narrow.rs b/crates/registry-evidencectl/tests/suggest_narrow.rs new file mode 100644 index 000000000..644c4111c --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_narrow.rs @@ -0,0 +1,957 @@ +//! Narrowing stage: an OpenAPI response schema restricted to a projection +//! selection and rewritten into the closed Version 1 response subset. +//! +//! The inputs are built here as JSON literals rather than loaded through the +//! OpenAPI stage, so a failure names a narrowing rule and never a loader bug. +//! Every expectation is checked against the shipped shapes under +//! `products/evidence/fixtures/source-shapes/*/schemas/response.schema.yaml`: +//! closed objects, `required` as the spec-guaranteed subset of kept members, +//! bounded arrays and strings, and the `[T, "null"]` response pair. + +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +#[allow(dead_code)] +#[path = "../src/suggest/narrow.rs"] +mod narrow; + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use narrow::{AdvisoryKind, Plan, Resolution}; +use types::{ + BoundKind, BoundValues, NarrowOutcome, Observations, Observed, Provenance, ResolvedSchema, + SuggestedBound, +}; + +/// Builds a resolved schema from a JSON literal. +fn schema(value: Value) -> ResolvedSchema { + ResolvedSchema(value) +} + +/// Builds a selection from string literals. +fn selection(entries: &[&str]) -> Vec { + entries.iter().map(|entry| (*entry).to_owned()).collect() +} + +/// Builds observations for one pointer. +fn observed(pointer: &str, observation: Observed) -> Observations { + let mut observations = Observations::default(); + observations + .by_pointer + .insert(pointer.to_owned(), observation); + observations +} + +/// Builds a one-entry resolution list for the slice entry point, which takes +/// the same pairs as the `BTreeMap` form declared in `types.rs`. +fn resolved(pointer: &str, kind: BoundKind, values: BoundValues) -> Vec { + vec![((pointer.to_owned(), kind), values)] +} + +fn plan( + schema: &ResolvedSchema, + entries: &[&str], + observations: &Observations, +) -> Vec { + plan_advisories(schema, entries, observations).needs +} + +fn plan_advisories(schema: &ResolvedSchema, entries: &[&str], observations: &Observations) -> Plan { + narrow::plan_advisories(schema, &selection(entries), observations).expect("plan") +} + +fn apply(schema: &ResolvedSchema, entries: &[&str], resolutions: &[Resolution]) -> NarrowOutcome { + narrow::apply_entries(schema, &selection(entries), resolutions).expect("apply") +} + +/// A root object wrapping one property, the smallest schema the subset admits. +fn root(properties: Value, required: Value) -> ResolvedSchema { + schema(json!({ + "type": "object", + "required": required, + "properties": properties, + })) +} + +#[test] +fn an_integer_with_only_a_minimum_raises_a_bound_need() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0}}), + json!(["total"]), + ); + + let needs = plan(&input, &["/total"], &Observations::default()); + + assert_eq!(needs.len(), 1, "one unmet bound: {needs:?}"); + assert_eq!(needs[0].pointer, "/total"); + assert_eq!(needs[0].kind, BoundKind::IntegerRange); + assert!( + needs[0].suggestion.is_none(), + "nothing to derive a maximum from: {:?}", + needs[0].suggestion + ); +} + +#[test] +fn an_integer_with_an_enum_needs_no_bound_and_keeps_the_enum() { + let input = root( + json!({"total": {"type": "integer", "enum": [0, 1, 2]}}), + json!(["total"]), + ); + + assert!(plan(&input, &["/total"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/total"], &[]); + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "enum": [0, 1, 2]}) + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn an_integer_with_a_const_needs_no_bound() { + let input = root( + json!({"page": {"type": "integer", "const": 1}}), + json!(["page"]), + ); + + assert!(plan(&input, &["/page"], &Observations::default()).is_empty()); + let outcome = apply(&input, &["/page"], &[]); + assert_eq!( + outcome.schema["properties"]["page"], + json!({"type": "integer", "const": 1}) + ); +} + +#[test] +fn a_sample_widens_an_integer_range_outward_to_round_numbers() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(0), + max_integer: Some(2), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + let suggestion = needs[0].suggestion.clone().expect("a sample suggestion"); + assert_eq!( + suggestion, + SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 10 + }, + provenance: Provenance::Sample, + } + ); +} + +/// A sampled integer is usually a counter, and a counter's ceiling is the one +/// bound a single response says least about: the widening is deliberately +/// generous rather than snug around what was seen. +#[test] +fn a_sample_integer_maximum_widens_to_a_generous_power_of_ten() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(4), + max_integer: Some(1_000), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 0, + maximum: 10_000 + }, + "the observed floor of 4 is kept at 0 and 1000 widens past 2000 to 10000" + ); +} + +#[test] +fn a_small_sampled_counter_still_gets_a_generous_ceiling() { + for (observed_maximum, expected) in [(12_i64, 100_i64), (60, 1_000), (2, 10)] { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(0), + max_integer: Some(observed_maximum), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 0, + maximum: expected + }, + "observing {observed_maximum} should suggest a ceiling of {expected}" + ); + } +} + +#[test] +fn a_negative_sample_integer_widens_below_zero() { + let input = root(json!({"offset": {"type": "integer"}}), json!(["offset"])); + let observations = observed( + "/offset", + Observed { + min_integer: Some(-30), + max_integer: Some(5), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/offset"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: -50, + maximum: 10 + } + ); +} + +#[test] +fn a_spec_minimum_survives_into_a_sample_suggestion() { + let input = root( + json!({"total": {"type": "integer", "minimum": 1}}), + json!(["total"]), + ); + let observations = observed( + "/total", + Observed { + min_integer: Some(3), + max_integer: Some(3), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 1, + maximum: 10 + }, + "the stated minimum is authoritative; only the missing maximum is derived" + ); +} + +#[test] +fn a_string_with_the_date_format_is_kept_and_needs_no_bound() { + let input = root( + json!({"recordedOn": {"type": "string", "format": "date"}}), + json!([]), + ); + + assert!(plan(&input, &["/recordedOn"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/recordedOn"], &[]); + assert_eq!( + outcome.schema["properties"]["recordedOn"], + json!({"type": "string", "format": "date"}) + ); +} + +#[test] +fn a_string_with_the_uuid_format_is_suggested_fixed_length_and_loses_the_format() { + let input = root( + json!({"trackingId": {"type": "string", "format": "uuid"}}), + json!(["trackingId"]), + ); + + let plan = plan_advisories(&input, &["/trackingId"], &Observations::default()); + + assert_eq!(plan.needs.len(), 1); + assert_eq!(plan.needs[0].kind, BoundKind::StringLength); + assert_eq!( + plan.needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::StringLength { + min_length: 36, + max_length: 36 + }, + provenance: Provenance::Format, + } + ); + assert!( + plan.advisories + .iter() + .any(|advisory| advisory.pointer == "/trackingId" + && advisory.kind == AdvisoryKind::DroppedFormat("uuid".to_owned())), + "the dropped format is reported: {:?}", + plan.advisories + ); + + let outcome = apply( + &input, + &["/trackingId"], + &resolved( + "/trackingId", + BoundKind::StringLength, + BoundValues::StringLength { + min_length: 36, + max_length: 36, + }, + ), + ); + assert_eq!( + outcome.schema["properties"]["trackingId"], + json!({"type": "string", "minLength": 36, "maxLength": 36}), + "the closed subset admits only the date formats, so `uuid` is dropped" + ); +} + +#[test] +fn a_string_with_an_unsupported_format_suggests_nothing_and_reports_the_drop() { + let input = root( + json!({"contact": {"type": "string", "format": "email"}}), + json!(["contact"]), + ); + + let plan = plan_advisories(&input, &["/contact"], &Observations::default()); + + assert_eq!(plan.needs.len(), 1); + assert!( + plan.needs[0].suggestion.is_none(), + "nothing mechanical follows from `email`" + ); + assert_eq!( + plan.advisories[0].kind, + AdvisoryKind::DroppedFormat("email".to_owned()) + ); +} + +#[test] +fn a_sample_string_length_rounds_up_to_the_next_power_of_two() { + let input = root(json!({"status": {"type": "string"}}), json!(["status"])); + let observations = observed( + "/status", + Observed { + max_string_bytes: Some(36), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/status"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64 + }, + provenance: Provenance::Sample, + } + ); +} + +#[test] +fn a_short_sample_string_still_gets_the_sixteen_byte_floor() { + let input = root(json!({"status": {"type": "string"}}), json!(["status"])); + let observations = observed( + "/status", + Observed { + max_string_bytes: Some(3), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/status"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::StringLength { + min_length: 0, + max_length: 16 + } + ); +} + +#[test] +fn a_string_enum_needs_no_bound_and_is_carried_through() { + let input = root( + json!({"status": {"type": "string", "enum": ["open", "closed"]}}), + json!(["status"]), + ); + + assert!(plan(&input, &["/status"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/status"], &[]); + assert_eq!( + outcome.schema["properties"]["status"], + json!({"type": "string", "enum": ["open", "closed"]}) + ); +} + +#[test] +fn an_array_without_max_items_takes_a_widened_clamped_sample_suggestion() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 32}}}), + json!(["records"]), + ); + let observations = observed( + "/records", + Observed { + max_array_items: Some(2), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/records/*"], &observations); + + assert_eq!(needs.len(), 1); + assert_eq!(needs[0].pointer, "/records"); + assert_eq!(needs[0].kind, BoundKind::ArrayMaxItems); + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::MaxItems(8), + provenance: Provenance::Sample, + }, + "two observed elements round up to the next multiple of eight" + ); +} + +#[test] +fn a_large_sample_array_is_clamped_to_the_subset_ceiling() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 32}}}), + json!(["records"]), + ); + let observations = observed( + "/records", + Observed { + max_array_items: Some(500), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/records/*"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::MaxItems(256), + "the closed subset admits maxItems 1..=256" + ); +} + +#[test] +fn a_spec_max_items_outside_the_subset_is_clamped_and_credited_to_the_ceiling() { + let input = root( + json!({ + "records": { + "type": "array", + "maxItems": 5_000, + "items": {"type": "string", "maxLength": 32} + } + }), + json!(["records"]), + ); + + let needs = plan(&input, &["/records/*"], &Observations::default()); + + assert_eq!(needs.len(), 1); + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::MaxItems(256), + provenance: Provenance::SubsetCeiling, + }, + "5000 is not the number the draft ends up stating, so the document does not \ + get the credit for the 256 that replaces it" + ); + + let outcome = apply(&input, &["/records/*"], &[]); + assert!( + outcome.schema["properties"]["records"] + .get("maxItems") + .is_none(), + "an out-of-subset spec bound is never emitted unresolved" + ); +} + +#[test] +fn a_spec_max_items_inside_the_subset_raises_no_need() { + let input = root( + json!({ + "records": { + "type": "array", + "maxItems": 2, + "items": {"type": "string", "maxLength": 32} + } + }), + json!(["records"]), + ); + + assert!(plan(&input, &["/records/*"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/records/*"], &[]); + assert_eq!( + outcome.schema["properties"]["records"], + json!({ + "type": "array", + "minItems": 0, + "maxItems": 2, + "items": {"type": "string", "maxLength": 32} + }) + ); +} + +#[test] +fn a_record_under_a_wildcard_requires_only_the_kept_members_the_spec_guarantees() { + let input = root( + json!({ + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "required": ["id", "status"], + "properties": { + "id": {"type": "string", "maxLength": 64}, + "status": {"type": "string", "enum": ["open", "closed"]}, + "note": {"type": "string", "maxLength": 8} + } + } + } + }), + json!(["total", "results"]), + ); + + let outcome = apply(&input, &["/total", "/results/*/id"], &[]); + + assert_eq!( + outcome.schema, + json!({ + "type": "object", + "additionalProperties": false, + "required": ["results", "total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "results": { + "type": "array", + "minItems": 0, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": {"type": "string", "maxLength": 64} + } + } + } + } + }), + "unselected members leave, and `status` leaves `required` with them" + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn a_record_the_spec_does_not_guarantee_gets_an_empty_required_list() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "properties": {"recordedOn": {"type": "string", "format": "date"}} + } + } + }), + json!(["results"]), + ); + + let outcome = apply(&input, &["/results/*/recordedOn"], &[]); + + assert_eq!( + outcome.schema["properties"]["results"]["items"]["required"], + json!([]), + "projection drops a leaf the record did not carry, so nothing is required" + ); +} + +#[test] +fn a_nullable_type_pair_is_preserved_through_narrowing() { + let input = root( + json!({ + "result": { + "type": ["object", "null"], + "properties": { + "code": {"type": ["string", "null"], "maxLength": 32} + } + } + }), + json!([]), + ); + + let outcome = apply(&input, &["/result/code"], &[]); + + assert_eq!( + outcome.schema["properties"]["result"], + json!({ + "type": ["object", "null"], + "additionalProperties": false, + "required": [], + "properties": {"code": {"type": ["string", "null"], "maxLength": 32}} + }) + ); +} + +#[test] +fn an_explicit_null_the_spec_does_not_admit_is_reported_as_an_advisory() { + let input = root( + json!({"status": {"type": "string", "maxLength": 32}}), + json!(["status"]), + ); + let observations = observed( + "/status", + Observed { + saw_null: true, + max_string_bytes: Some(4), + ..Observed::default() + }, + ); + + let plan = plan_advisories(&input, &["/status"], &observations); + + assert!(plan.needs.is_empty(), "the spec already bounds the string"); + assert_eq!(plan.advisories.len(), 1); + assert_eq!(plan.advisories[0].pointer, "/status"); + assert_eq!(plan.advisories[0].kind, AdvisoryKind::NullOutsideSpec); + assert!( + plan.advisories[0].message().contains("null"), + "the advisory explains itself: {}", + plan.advisories[0].message() + ); +} + +#[test] +fn a_nullable_leaf_that_saw_null_raises_no_advisory() { + let input = root( + json!({"status": {"type": ["string", "null"], "maxLength": 32}}), + json!(["status"]), + ); + let observations = observed( + "/status", + Observed { + saw_null: true, + ..Observed::default() + }, + ); + + assert!(plan_advisories(&input, &["/status"], &observations) + .advisories + .is_empty()); +} + +#[test] +fn an_ancestor_and_its_descendant_cannot_both_be_selected() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "properties": {"id": {"type": "string", "maxLength": 8}} + } + } + }), + json!(["results"]), + ); + + let error = narrow::apply_entries(&input, &selection(&["/results", "/results/*/id"]), &[]) + .expect_err("overlapping projection entries are rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("/results"), "{message}"); + assert!(message.contains("/results/*/id"), "{message}"); + assert!(message.contains("overlap"), "{message}"); +} + +#[test] +fn a_duplicate_selection_entry_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + let error = narrow::plan_advisories( + &input, + &selection(&["/total", "/total"]), + &Observations::default(), + ) + .expect_err("duplicate projection entries are rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("/total"), "{message}"); + assert!(message.contains("duplicat"), "{message}"); +} + +#[test] +fn a_numeric_index_into_an_array_is_rejected_in_favour_of_the_wildcard() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": {"type": "string", "maxLength": 8} + } + }), + json!([]), + ); + + let error = narrow::apply_entries(&input, &selection(&["/results/0"]), &[]) + .expect_err("numeric indexes are not projection syntax"); + let message = format!("{error:#}"); + + assert!(message.contains('*'), "{message}"); +} + +#[test] +fn a_selection_that_is_not_in_the_schema_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + let error = narrow::apply_entries(&input, &selection(&["/missing"]), &[]) + .expect_err("an unknown pointer is a visible failure"); + + assert!(format!("{error:#}").contains("/missing")); +} + +#[test] +fn an_unresolved_bound_is_omitted_from_the_schema_and_reported() { + let input = root( + json!({ + "total": {"type": "integer"}, + "records": {"type": "array", "items": {"type": "string"}} + }), + json!(["total"]), + ); + + let outcome = apply(&input, &["/total", "/records/*"], &[]); + + let total = &outcome.schema["properties"]["total"]; + assert_eq!(total, &json!({"type": "integer"})); + assert!(total.get("minimum").is_none() && total.get("maximum").is_none()); + let records = &outcome.schema["properties"]["records"]; + assert!(records.get("maxItems").is_none()); + assert!(records["items"].get("maxLength").is_none()); + + let reported: Vec<(&str, &BoundKind)> = outcome + .unresolved + .iter() + .map(|need| (need.pointer.as_str(), &need.kind)) + .collect(); + assert_eq!( + reported, + vec![ + ("/records", &BoundKind::ArrayMaxItems), + ("/records/*", &BoundKind::StringLength), + ("/total", &BoundKind::IntegerRange), + ], + "unresolved bounds are reported in schema order" + ); +} + +#[test] +fn a_resolved_bound_is_written_into_the_schema() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + + let outcome = apply( + &input, + &["/total"], + &resolved( + "/total", + BoundKind::IntegerRange, + BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + ), + ); + + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "minimum": 0, "maximum": 1000000}) + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn unselected_siblings_are_pruned_from_every_level() { + let input = root( + json!({ + "total": {"type": "integer", "minimum": 0, "maximum": 100}, + "secret": {"type": "string", "maxLength": 32}, + "meta": { + "type": "object", + "required": ["page"], + "properties": { + "page": {"type": "integer", "const": 1}, + "cursor": {"type": "string", "maxLength": 64} + } + } + }), + json!(["total", "secret", "meta"]), + ); + + let outcome = apply(&input, &["/total", "/meta/page"], &[]); + + assert_eq!( + outcome.schema, + json!({ + "type": "object", + "additionalProperties": false, + "required": ["meta", "total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 100}, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["page"], + "properties": {"page": {"type": "integer", "const": 1}} + } + } + }) + ); +} + +#[test] +fn selecting_a_container_keeps_its_whole_subtree() { + let input = root( + json!({ + "meta": { + "type": "object", + "required": ["page"], + "properties": { + "page": {"type": "integer", "const": 1}, + "cursor": {"type": "string", "maxLength": 64} + } + }, + "other": {"type": "string", "maxLength": 4} + }), + json!(["meta", "other"]), + ); + + let outcome = apply(&input, &["/meta"], &[]); + + assert_eq!( + outcome.schema["properties"]["meta"]["properties"]["cursor"], + json!({"type": "string", "maxLength": 64}) + ); + assert!(outcome.schema["properties"].get("other").is_none()); +} + +#[test] +fn an_escaped_segment_addresses_the_literal_key() { + let input = root( + json!({"a/b": {"type": "string", "maxLength": 8}, "c~d": {"type": "string", "maxLength": 8}}), + json!([]), + ); + + let outcome = apply(&input, &["/a~1b", "/c~0d"], &[]); + + assert!(outcome.schema["properties"].get("a/b").is_some()); + assert!(outcome.schema["properties"].get("c~d").is_some()); +} + +#[test] +fn a_type_outside_the_closed_subset_is_rejected() { + let input = root(json!({"score": {"type": "number"}}), json!(["score"])); + + let error = narrow::apply_entries(&input, &selection(&["/score"]), &[]) + .expect_err("`number` is outside the closed Version 1 subset"); + + assert!(format!("{error:#}").contains("number")); +} + +#[test] +fn a_resolution_outside_the_subset_range_is_rejected() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 8}}}), + json!([]), + ); + + let error = narrow::apply_entries( + &input, + &selection(&["/records/*"]), + &resolved( + "/records", + BoundKind::ArrayMaxItems, + BoundValues::MaxItems(1_000), + ), + ) + .expect_err("a resolved maxItems above 256 is outside the subset"); + + assert!(format!("{error:#}").contains("256")); +} + +#[test] +fn a_resolution_that_matches_no_need_is_rejected() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0, "maximum": 10}}), + json!(["total"]), + ); + + let error = narrow::apply_entries( + &input, + &selection(&["/total"]), + &resolved( + "/totl", + BoundKind::IntegerRange, + BoundValues::IntegerRange { + minimum: 0, + maximum: 10, + }, + ), + ) + .expect_err("a stray resolution is a visible failure"); + + assert!(format!("{error:#}").contains("/totl")); +} + +#[test] +fn an_empty_selection_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + assert!(narrow::apply_entries(&input, &[], &[]).is_err()); +} + +#[test] +fn the_map_entry_point_narrows_the_same_schema() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0, "maximum": 10}}), + json!(["total"]), + ); + + let outcome = narrow::apply(&input, &selection(&["/total"]), &BTreeMap::new()) + .expect("the map entry point delegates to the slice one"); + + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "minimum": 0, "maximum": 10}) + ); +} diff --git a/crates/registry-evidencectl/tests/suggest_openapi.rs b/crates/registry-evidencectl/tests/suggest_openapi.rs new file mode 100644 index 000000000..d5acecf33 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_openapi.rs @@ -0,0 +1,624 @@ +//! Tests for the OpenAPI loading (`openapi.rs`) and schema flattening +//! (`flatten.rs`) stages of `evidencectl source suggest`. +//! +//! `registry-evidencectl` ships only a binary target, so this integration +//! test pulls the modules it needs in directly by path rather than through a +//! library crate. `types` and `openapi`/`flatten` are declared as siblings +//! here, mirroring their nesting under `src/suggest/`, so the `super::types` +//! imports inside `openapi.rs` and `flatten.rs` resolve unchanged. + +// `openapi.rs` dispatches a file path or a URL through `fetch`; this binary +// only ever opens files, so the fetching half of that module is unused here. +#[allow(dead_code)] +#[path = "../src/suggest/fetch.rs"] +mod fetch; +#[path = "../src/suggest/flatten.rs"] +mod flatten; +#[path = "../src/suggest/openapi.rs"] +mod openapi; +// `types.rs` is shared across every pipeline stage; this test binary only +// exercises the openapi/flatten slice of it, so the rest looks unused to +// this crate's own dead-code analysis even though the real `evidencectl` +// binary uses all of it once every stage is wired together. +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::path::{Path, PathBuf}; + +use types::{OperationKey, ResolvedSchema, SpecSource}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/openapi") + .join(name) +} + +/// Opens a fixture through the same entry point the CLI uses, naming it as a +/// local file. Fetching over HTTP is covered in `suggest_fetch.rs`. +fn load(path: &Path) -> anyhow::Result { + openapi::Spec::open(&SpecSource::File(path.to_path_buf())) +} + +fn operation(method: &str, path: &str) -> OperationKey { + OperationKey { + method: method.to_string(), + path: path.to_string(), + } +} + +// --- Spec::open ----------------------------------------------------------- + +#[test] +fn load_accepts_openapi_3_0_yaml() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + assert!(!spec.operations().is_empty()); +} + +#[test] +fn load_accepts_openapi_3_1_json() { + let spec = load(&fixture("records-3.1.json")).expect("loads"); + assert!(!spec.operations().is_empty()); +} + +#[test] +fn load_rejects_unsupported_openapi_version() { + let error = load(&fixture("unsupported-version.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("3.0") || message.contains("3.1"), + "message was: {message}" + ); +} + +#[test] +fn load_rejects_missing_file() { + let error = load(&fixture("does-not-exist.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("does-not-exist.yaml"), + "message was: {message}" + ); +} + +// --- Spec::operations -------------------------------------------------------- + +/// The runtime's fixed-request method is an enumeration of GET and POST, so an +/// operation on any other method is not offerable: the fixture's `DELETE +/// /records` carries a JSON response and is still absent from the listing. +#[test] +fn operations_lists_only_the_methods_the_runtime_admits() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let mut keys: Vec = spec + .operations() + .into_iter() + .map(|summary| summary.key) + .collect(); + keys.sort_by(|a, b| (&a.path, &a.method).cmp(&(&b.path, &b.method))); + assert_eq!( + keys, + vec![operation("GET", "/records"), operation("POST", "/records")] + ); +} + +#[test] +fn operations_reports_summary_and_json_responses() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let get_records = spec + .operations() + .into_iter() + .find(|summary| summary.key == operation("GET", "/records")) + .expect("GET /records present"); + assert_eq!(get_records.summary.as_deref(), Some("Search records")); + assert_eq!( + get_records.json_responses, + vec![("200".to_string(), "application/json".to_string())] + ); +} + +#[test] +fn operations_collects_every_json_response_status() { + let spec = load(&fixture("records-3.1.json")).expect("loads"); + let get_record = spec + .operations() + .into_iter() + .find(|summary| summary.key == operation("GET", "/records/{id}")) + .expect("GET /records/{id} present"); + let mut responses = get_record.json_responses; + responses.sort(); + assert_eq!( + responses, + vec![ + ("200".to_string(), "application/json".to_string()), + ("404".to_string(), "application/json".to_string()), + ] + ); +} + +// --- Spec::response_schema --------------------------------------------------- + +#[test] +fn response_schema_inlines_local_refs_and_normalizes_nullable() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + + let rendered = resolved.schema.0.to_string(); + assert!( + !rendered.contains("$ref"), + "refs should be fully inlined: {rendered}" + ); + + // Top-level `nullable: true` string becomes the 3.1 type pair. + assert_eq!( + resolved.schema.0["properties"]["recordedOn"]["type"], + serde_json::json!(["string", "null"]) + ); + assert!(resolved.schema.0["properties"]["recordedOn"] + .get("nullable") + .is_none()); + + // The $ref'd array item schema (Record) is inlined in place, and its own + // nested `nullable: true` (notes) is normalized too. + let record_item = &resolved.schema.0["properties"]["results"]["items"]; + assert_eq!( + record_item["properties"]["trackingId"]["type"], + serde_json::json!("string") + ); + assert_eq!( + record_item["properties"]["notes"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn response_schema_passes_through_3_1_type_arrays_unchanged() { + let spec = load(&fixture("records-3.1.json")).expect("loads"); + let resolved = spec + .response_schema( + &operation("GET", "/records/{id}"), + "200", + "application/json", + ) + .expect("resolves"); + assert_eq!( + resolved.schema.0["properties"]["status"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn response_schema_rejects_external_ref() { + let spec = load(&fixture("external-ref.yaml")).expect("loads"); + let error = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("external") || message.contains("remote"), + "message was: {message}" + ); +} + +/// A recursive `$ref` bounds how deep the response can be described, not +/// whether the operation can be drafted from at all. The repeat is cut and +/// named, and everything beside it stays selectable. +#[test] +fn response_schema_cuts_a_ref_cycle_and_notes_it() { + let spec = load(&fixture("ref-cycle.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("cycle") && note.contains("#/components/schemas/A")), + "notes were: {:#?}", + resolved.notes + ); + + let (_, warnings) = flatten::candidate_leaves(&resolved.schema); + assert!( + warnings + .iter() + .any(|warning| warning.contains("/child/parent") + && warning.contains("#/components/schemas/A")), + "warnings were: {warnings:#?}" + ); +} + +#[test] +fn a_recursive_schema_still_offers_its_non_recursive_leaves() { + let spec = load(&fixture("recursive-tree.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/nodes"), "200", "application/json") + .expect("resolves"); + let (leaves, _) = flatten::candidate_leaves(&resolved.schema); + + // The repeat is cut where it first repeats, so the recursive branch offers + // nothing and the record's own scalars stay selectable. + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/id", "/label"]); +} + +// --- dialect normalization --------------------------------------------------- + +/// A two-member `anyOf`/`oneOf` against `null` is how several generators spell +/// the 3.1 nullable type pair. It states nothing the closed subset cannot +/// already express, so it is rewritten into the pair rather than skipped as an +/// unsupported union. +#[test] +fn a_two_member_union_against_null_becomes_the_nullable_type_pair() { + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let properties = &resolved.schema.0["properties"]; + + assert_eq!( + properties["note"]["type"], + serde_json::json!(["string", "null"]) + ); + // The collapsed member's own bounds survive the rewrite. + assert_eq!(properties["note"]["maxLength"], serde_json::json!(64)); + assert!(properties["note"].get("anyOf").is_none()); + + assert_eq!( + properties["count"]["type"], + serde_json::json!(["integer", "null"]) + ); + assert_eq!(properties["count"]["maximum"], serde_json::json!(99)); + // A keyword on the union node itself is not lost when the union collapses. + assert_eq!( + properties["count"]["description"], + serde_json::json!("how many were seen") + ); + + // A nullable object keeps its members addressable. + assert_eq!( + properties["parent"]["type"], + serde_json::json!(["object", "null"]) + ); + assert_eq!( + properties["parent"]["properties"]["id"]["maxLength"], + serde_json::json!(36) + ); +} + +/// The subset admits the pair in one order only, so a document writing it the +/// other way round describes something the subset can express and must not be +/// refused over the spelling. +#[test] +fn a_null_first_type_pair_is_reordered() { + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + assert_eq!( + resolved.schema.0["properties"]["reversedPair"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn a_union_of_two_real_types_is_left_for_the_flattener_to_skip() { + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + assert!(resolved.schema.0["properties"]["either"] + .get("anyOf") + .is_some()); + + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!( + pointers, + vec!["/count", "/note", "/parent/id", "/reversedPair"] + ); + assert!( + warnings.iter().any(|warning| warning.contains("/either")), + "warnings were: {warnings:#?}" + ); +} + +/// `properties` and `items` are meaningless on anything but an object and an +/// array, so a node carrying one and no `type` is not ambiguous. Reading it is +/// what lets the tool draft from the collection wrappers large registry APIs +/// actually publish; the reading is announced rather than made silently. +#[test] +fn a_structural_keyword_without_a_type_is_read_as_that_type_and_noted() { + let spec = load(&fixture("implicit-types.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + + assert_eq!(resolved.schema.0["type"], serde_json::json!("object")); + assert_eq!( + resolved.schema.0["properties"]["records"]["type"], + serde_json::json!("array") + ); + assert_eq!( + resolved.schema.0["properties"]["records"]["items"]["type"], + serde_json::json!("object") + ); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("(root)") && note.contains("object")), + "notes were: {:#?}", + resolved.notes + ); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("/records") && note.contains("array")), + "notes were: {:#?}", + resolved.notes + ); + + let (leaves, _) = flatten::candidate_leaves(&resolved.schema); + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/pager/page", "/records/*/id"]); +} + +#[test] +fn a_node_with_neither_a_type_nor_a_structural_keyword_stays_untyped() { + let spec = load(&fixture("implicit-types.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/opaque"), "200", "application/json") + .expect("resolves"); + assert!(resolved.schema.0["properties"]["anything"] + .get("type") + .is_none()); + + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + let pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/known"]); + assert!( + warnings.iter().any(|warning| warning.contains("/anything")), + "warnings were: {warnings:#?}" + ); +} + +#[test] +fn response_schema_rejects_unknown_status() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let error = spec + .response_schema(&operation("GET", "/records"), "500", "application/json") + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("500"), "message was: {message}"); +} + +// --- Spec::servers / Spec::page_size_maximums -------------------------------- + +#[test] +fn servers_lists_declared_base_urls_in_order() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + assert_eq!( + spec.servers(), + vec!["https://records.example.test/api".to_string()] + ); +} + +#[test] +fn servers_is_empty_when_undeclared() { + let spec = load(&fixture("records-3.1.json")).expect("loads"); + assert!(spec.servers().is_empty()); +} + +#[test] +fn page_size_maximums_reads_matching_query_parameters() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records")) + .expect("no ref errors"); + assert_eq!(maximums, vec![100]); +} + +#[test] +fn page_size_maximums_is_empty_without_matching_parameters() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("POST", "/records")) + .expect("no ref errors"); + assert!(maximums.is_empty()); +} + +#[test] +fn page_size_maximums_matches_limit_named_parameters() { + let spec = load(&fixture("records-3.1.json")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records/{id}")) + .expect("no ref errors"); + assert_eq!(maximums, vec![50]); +} + +#[test] +fn page_size_maximums_ignores_a_page_index_beside_a_page_size() { + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records")) + .expect("no ref errors"); + // `page` bounds how many pages exist, not how many items one carries. + // Reading its maximum as an item count would bound the array at 10000. + assert_eq!(maximums, vec![50]); +} + +#[test] +fn page_size_maximums_reads_every_genuine_size_parameter() { + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); + let mut maximums = spec + .page_size_maximums(&operation("GET", "/events")) + .expect("no ref errors"); + maximums.sort_unstable(); + assert_eq!(maximums, vec![25, 200]); +} + +#[test] +fn page_size_maximums_ignores_names_that_only_contain_a_matching_word() { + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/reports")) + .expect("no ref errors"); + assert!( + maximums.is_empty(), + "a byte ceiling and a rate-limit burst are not page sizes: {maximums:?}" + ); +} + +// --- flatten::candidate_leaves ------------------------------------------------ + +#[test] +fn candidate_leaves_flattens_arrays_and_nullable_records() { + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!( + pointers, + vec![ + "/recordedOn", + "/results/*/notes", + "/results/*/status", + "/results/*/trackingId", + "/total", + ] + ); + + let recorded_on = leaves + .iter() + .find(|leaf| leaf.pointer == "/recordedOn") + .expect("present"); + assert_eq!(recorded_on.type_label, "string (date-time)"); + assert!(recorded_on.nullable); + + let notes = leaves + .iter() + .find(|leaf| leaf.pointer == "/results/*/notes") + .expect("present"); + assert!(notes.nullable); + + let total = leaves + .iter() + .find(|leaf| leaf.pointer == "/total") + .expect("present"); + assert_eq!(total.type_label, "integer"); + assert!(!total.nullable); +} + +#[test] +fn candidate_leaves_escapes_member_names_per_rfc_6901() { + let spec = load(&fixture("escaping.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/tags~1primary", "/tilde~0name"]); +} + +#[test] +fn candidate_leaves_skips_and_warns_on_unsupported_constructs() { + let spec = load(&fixture("unsupported-constructs.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/simpleAllOf", "/trackingId"]); + + let simple_all_of = leaves + .iter() + .find(|leaf| leaf.pointer == "/simpleAllOf") + .expect("present"); + assert_eq!(simple_all_of.type_label, "string (date)"); + + assert_eq!(warnings.len(), 5, "warnings: {warnings:#?}"); + assert!(warnings + .iter() + .any(|warning| warning.contains("/choice") && warning.contains("oneOf"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/wildcard") && warning.contains("additionalProperties"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/missingItems") && warning.contains("items"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/freeform") && warning.contains("properties"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/multiType"))); +} + +#[test] +fn candidate_leaves_truncates_at_depth_limit_and_warns() { + fn nested_object(remaining: usize) -> serde_json::Value { + if remaining == 0 { + serde_json::json!({"type": "string"}) + } else { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { "level": nested_object(remaining - 1) }, + }) + } + } + + let schema = serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "shallow": {"type": "string"}, + "deep": nested_object(20), + }, + }); + let resolved = ResolvedSchema(schema); + let (leaves, warnings) = flatten::candidate_leaves(&resolved); + + let pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/shallow"]); + assert!( + warnings + .iter() + .any(|warning| warning.contains("16") && warning.contains("/deep")), + "warnings: {warnings:#?}" + ); +} + +/// The sampler refuses an oversized sample before reading it. The loader is +/// held to the same rule, so a mistaken path is named as one rather than read +/// into memory whole. +#[test] +fn an_oversized_document_is_refused_before_it_is_read() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("huge.openapi.yaml"); + let file = std::fs::File::create(&path).expect("create"); + // Sparse where the filesystem supports it: the point is the declared + // length, not the bytes. + file.set_len(17 * 1024 * 1024).expect("set_len"); + drop(file); + + let error = load(&path).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("exceeding the"), "message was: {message}"); +} diff --git a/crates/registry-evidencectl/tests/suggest_sample.rs b/crates/registry-evidencectl/tests/suggest_sample.rs new file mode 100644 index 000000000..da36ef5d8 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_sample.rs @@ -0,0 +1,310 @@ +//! Tests for the sample-observation stage of `evidencectl source suggest`. +//! +//! `registry-evidencectl` is a binary-only crate (no library target), so the +//! module under test is pulled in directly by path rather than through +//! normal `use registry_evidencectl::...` linkage. `types` is included the +//! same way and declared as a sibling of `sample`, mirroring their real +//! relationship as siblings under `suggest`: `sample.rs` reaches it through +//! `super::types`, and `super` from a crate-root `mod sample;` here is this +//! same crate root, where `mod types;` also lives. + +#[path = "../src/suggest/sample.rs"] +mod sample; +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::{fs, path::PathBuf}; + +use sample::{load_sample, observe}; +use serde_json::json; +use types::Observations; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/samples") + .join(name) +} + +fn load_fixture(name: &str) -> serde_json::Value { + load_sample(&fixture(name)).unwrap_or_else(|error| panic!("failed to load {name}: {error:#}")) +} + +// --- load_sample ----------------------------------------------------------- + +#[test] +fn load_sample_reads_a_valid_json_file() { + let value = load_fixture("nested-records.json"); + assert_eq!(value["total"], json!(42)); +} + +#[test] +fn load_sample_rejects_a_file_that_is_not_valid_json() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("not-json.json"); + fs::write(&path, b"{ this is not json").expect("write fixture"); + + let error = load_sample(&path).expect_err("invalid JSON must be rejected"); + assert!( + error.to_string().contains("not valid JSON"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn load_sample_rejects_a_missing_file() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("missing.json"); + + let error = load_sample(&path).expect_err("a missing file must be rejected"); + assert!( + error + .to_string() + .contains("failed to read sample file metadata"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn load_sample_rejects_a_file_over_the_size_ceiling() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("oversized.json"); + + // A single 4 MiB+1 array element keeps this a well-formed (if useless) + // JSON document while tripping the size ceiling before any parsing. + let mut contents = Vec::with_capacity(4 * 1024 * 1024 + 16); + contents.extend_from_slice(b"\""); + contents.resize(contents.len() + 4 * 1024 * 1024 + 1, b'Z'); + contents.extend_from_slice(b"\""); + fs::write(&path, &contents).expect("write oversized fixture"); + + let error = load_sample(&path).expect_err("an oversized file must be rejected"); + let message = error.to_string(); + assert!(message.contains("exceeding"), "unexpected error: {message}"); + // The ceiling is enforced from file metadata alone, before any read, so + // the fill content can never reach the error message; "ZZZZ" is a + // pattern a temp-directory path could not plausibly contain. + assert!( + !message.contains("ZZZZ"), + "error message must not echo sample content: {message}" + ); +} + +// --- observe: integers ------------------------------------------------------- + +#[test] +fn observe_tracks_integer_min_and_max_across_every_array_element() { + let sample = load_fixture("integer-range.json"); + let selection = vec!["/records/*/priority".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let observed = observations + .by_pointer + .get("/records/*/priority") + .expect("priority observed"); + assert_eq!(observed.min_integer, Some(-7)); + assert_eq!(observed.max_integer, Some(12)); + assert!(!observed.saw_null); + assert_eq!(observed.max_string_bytes, None); +} + +// --- observe: strings, unicode ---------------------------------------------- + +#[test] +fn observe_measures_string_length_in_bytes_not_characters() { + let sample = load_fixture("unicode.json"); + let selection = vec!["/status".to_owned(), "/note".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + // "héllo" is 5 characters but 6 bytes: 'é' is a 2-byte UTF-8 sequence. + let status = observations + .by_pointer + .get("/status") + .expect("status observed"); + assert_eq!("héllo".chars().count(), 5); + assert_eq!(status.max_string_bytes, Some(6)); + + // "日本語" is 3 characters but 9 bytes: each character is 3 UTF-8 bytes. + let note = observations.by_pointer.get("/note").expect("note observed"); + assert_eq!("日本語".chars().count(), 3); + assert_eq!(note.max_string_bytes, Some(9)); +} + +// --- observe: arrays --------------------------------------------------------- + +#[test] +fn observe_records_max_array_items_for_the_wildcard_array_itself() { + let sample = load_fixture("nested-records.json"); + let selection = vec!["/results/*/trackingId".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let results = observations + .by_pointer + .get("/results") + .expect("results array observed"); + assert_eq!(results.max_array_items, Some(2)); + + let tracking_id = observations + .by_pointer + .get("/results/*/trackingId") + .expect("trackingId observed"); + assert_eq!( + tracking_id.max_string_bytes, + Some("def-456-longer".len() as u64) + ); +} + +#[test] +fn observe_records_max_array_items_for_an_array_selected_wholesale_under_a_wildcard() { + let sample = load_fixture("nested-records.json"); + // `tags` is itself an array within each record; it is selected without a + // further `*`, exercising the "array selected wholesale" case nested + // under an outer wildcard. + let selection = vec!["/results/*/tags".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let tags = observations + .by_pointer + .get("/results/*/tags") + .expect("tags observed"); + // Record one carries 3 tags, record two carries 1: the largest is kept. + assert_eq!(tags.max_array_items, Some(3)); +} + +#[test] +fn observe_records_a_top_level_array_selected_wholesale() { + let sample = load_fixture("nested-records.json"); + let selection = vec!["/results".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let results = observations + .by_pointer + .get("/results") + .expect("results observed"); + assert_eq!(results.max_array_items, Some(2)); +} + +// --- observe: nulls and absence --------------------------------------------- + +#[test] +fn observe_flags_an_explicit_null_without_recording_a_length() { + let sample = load_fixture("nulls-and-absent.json"); + let selection = vec!["/recordedOn".to_owned(), "/status".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let recorded_on = observations + .by_pointer + .get("/recordedOn") + .expect("recordedOn observed"); + assert!(recorded_on.saw_null); + assert_eq!(recorded_on.max_string_bytes, None); + + let status = observations + .by_pointer + .get("/status") + .expect("status observed"); + assert!(!status.saw_null); + assert_eq!(status.max_string_bytes, Some("active".len() as u64)); +} + +#[test] +fn observe_leaves_an_absent_pointer_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + let selection = vec!["/doesNotExist".to_owned()]; + + let observations = observe(&sample, &selection).expect("an absent pointer is not an error"); + + assert!(!observations.by_pointer.contains_key("/doesNotExist")); + assert!(observations.by_pointer.is_empty()); +} + +#[test] +fn observe_leaves_a_wildcard_on_a_non_array_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + // `status` is a string, not an array: the `*` segment cannot land. + let selection = vec!["/status/*/child".to_owned()]; + + let observations = observe(&sample, &selection).expect("a type mismatch is not an error"); + + assert!(observations.by_pointer.is_empty()); +} + +#[test] +fn observe_leaves_a_key_lookup_on_a_non_object_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + // `status` is a string, not an object: it has no `child` member. + let selection = vec!["/status/child".to_owned()]; + + let observations = observe(&sample, &selection).expect("a type mismatch is not an error"); + + assert!(observations.by_pointer.is_empty()); +} + +// --- observe: pointer escaping ----------------------------------------------- + +#[test] +fn observe_unescapes_tilde_one_and_tilde_zero_segments() { + let sample = load_fixture("escaping.json"); + // "~1" decodes to "/", so "/a~1b" reaches the key "a/b". + // "~0" decodes to "~", so "/a~0b" reaches the key "a~b". + let selection = vec!["/a~1b".to_owned(), "/a~0b".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let slash_key = observations.by_pointer.get("/a~1b").expect("a/b observed"); + assert_eq!(slash_key.max_string_bytes, Some("slash-key".len() as u64)); + + let tilde_key = observations.by_pointer.get("/a~0b").expect("a~b observed"); + assert_eq!(tilde_key.max_string_bytes, Some("tilde-key".len() as u64)); +} + +// --- observe: malformed pointer syntax -------------------------------------- + +#[test] +fn observe_rejects_a_pointer_missing_its_leading_slash() { + let sample = json!({ "status": "open" }); + let selection = vec!["status".to_owned()]; + + let error = + observe(&sample, &selection).expect_err("a pointer without a leading slash is malformed"); + assert!(error + .to_string() + .contains("must be a non-empty extended JSON Pointer")); +} + +#[test] +fn observe_rejects_an_empty_pointer() { + let sample = json!({ "status": "open" }); + let selection = vec![String::new()]; + + let error = observe(&sample, &selection).expect_err("an empty pointer is malformed"); + assert!(error + .to_string() + .contains("must be a non-empty extended JSON Pointer")); +} + +// --- privacy: no sample string value survives into Observations ------------ + +#[test] +fn observe_never_carries_a_sample_string_value_into_its_debug_rendering() { + let sample = load_fixture("canary.json"); + let canary = sample["status"].as_str().expect("canary value").to_owned(); + let selection = vec!["/status".to_owned()]; + + let observations: Observations = observe(&sample, &selection).expect("observe"); + + let rendered = format!("{observations:?}"); + assert!( + !rendered.contains(&canary), + "Debug rendering of Observations must never contain a sample string value: {rendered}" + ); + // The pointer and the derived length are expected to appear; only the + // value itself must be absent. + assert!(rendered.contains("/status")); +} diff --git a/crates/registry-evidencectl/tests/support/production_handoff_https.py b/crates/registry-evidencectl/tests/support/production_handoff_https.py new file mode 100644 index 000000000..3c4a9320e --- /dev/null +++ b/crates/registry-evidencectl/tests/support/production_handoff_https.py @@ -0,0 +1,111 @@ +"""Silent local HTTPS issuer and sanitized Evidence source for acceptance tests.""" + +import http.client +import http.server +import json +import os +import pathlib +import ssl + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = "EvidenceAcceptance/1" + + def log_message(self, _format, *_args): + pass + + def do_GET(self): + if self.path == "/.well-known/jwks.json": + self._json(pathlib.Path(os.environ["ACCEPTANCE_JWKS"]).read_bytes()) + return + self.send_error(404) + + def do_POST(self): + if self.path == "/token" and os.environ.get("ACCEPTANCE_MINT_PORT"): + self._proxy_mint_token() + return + if self.path != "/v1/facts": + self.send_error(404) + return + expected = "Bearer " + pathlib.Path( + os.environ["ACCEPTANCE_SOURCE_TOKEN"] + ).read_text().strip() + if self.headers.get("Authorization") != expected: + self.send_error(401) + return + try: + length = int(self.headers.get("Content-Length", "0")) + request = json.loads(self.rfile.read(length)) + except (ValueError, json.JSONDecodeError): + self.send_error(400) + return + if request != { + "lookup": {"person_id": "synthetic-person-001"}, + "fields": ["date_of_birth"], + "limit": 2, + }: + self.send_error(400) + return + pathlib.Path(os.environ["ACCEPTANCE_SOURCE_MARKER"]).write_text("requested\n") + self._json(b'{"total":1,"date_of_birth":"2000-01-01"}') + + def _proxy_mint_token(self): + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self.send_error(400) + return + if length <= 0 or length > 65536: + self.send_error(413) + return + body = self.rfile.read(length) + upstream = http.client.HTTPConnection( + "127.0.0.1", int(os.environ["ACCEPTANCE_MINT_PORT"]), timeout=5 + ) + try: + upstream.request( + "POST", + "/token", + body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = upstream.getresponse() + response_body = response.read(1048577) + if len(response_body) > 1048576: + self.send_error(502) + return + self.send_response(response.status) + self.send_header( + "Content-Type", response.getheader("Content-Type", "application/json") + ) + self.send_header("Content-Length", str(len(response_body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(response_body) + except (OSError, http.client.HTTPException): + self.send_error(502) + finally: + upstream.close() + + def _json(self, body): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + +address = ("127.0.0.1", int(os.environ["ACCEPTANCE_HTTPS_PORT"])) +server = http.server.ThreadingHTTPServer(address, Handler) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +# The stand-in sets its own floor rather than inheriting whatever the runtime +# happens to allow, so the handoff is proven over the transport a deployment +# would actually run. +context.minimum_version = ssl.TLSVersion.TLSv1_2 +context.load_cert_chain( + os.environ["ACCEPTANCE_TLS_CERT"], os.environ["ACCEPTANCE_TLS_KEY"] +) +server.socket = context.wrap_socket(server.socket, server_side=True) +pathlib.Path(os.environ["ACCEPTANCE_READY"]).write_text("ready\n") +server.serve_forever() diff --git a/crates/registry-language-server/src/index.rs b/crates/registry-language-server/src/index.rs index 633ec522d..b36044b0d 100644 --- a/crates/registry-language-server/src/index.rs +++ b/crates/registry-language-server/src/index.rs @@ -20,8 +20,6 @@ pub enum RegistrySymbolKind { Entity, Service, Consultation, - Claim, - CredentialProfile, Fixture, Environment, } @@ -34,8 +32,6 @@ impl RegistrySymbolKind { Self::Entity => "entity", Self::Service => "service", Self::Consultation => "consultation", - Self::Claim => "claim", - Self::CredentialProfile => "credential profile", Self::Fixture => "fixture", Self::Environment => "environment", } @@ -47,8 +43,6 @@ impl RegistrySymbolKind { Self::Integration | Self::Entity => SymbolKind::MODULE, Self::Service => SymbolKind::INTERFACE, Self::Consultation => SymbolKind::FUNCTION, - Self::Claim => SymbolKind::PROPERTY, - Self::CredentialProfile => SymbolKind::OBJECT, Self::Fixture => SymbolKind::EVENT, Self::Environment => SymbolKind::PACKAGE, } @@ -119,14 +113,6 @@ impl SymbolQuery { name: name.into(), } } - - fn scoped(kind: RegistrySymbolKind, scope: impl Into, name: impl Into) -> Self { - Self { - kind, - scope: Some(scope.into()), - name: name.into(), - } - } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -695,68 +681,6 @@ impl IndexBuilder<'_> { } } } - - if let Some(claims) = service.value.get("claims").and_then(YamlValue::as_mapping) { - for claim in claims { - self.add_resolvable_symbol( - SymbolKey::scoped( - RegistrySymbolKind::Claim, - &service_name, - &claim.key.value, - ), - Some(service_name.clone()), - path, - claim.key.range, - ); - if let Some(output) = claim.value.get_scalar("output") { - if let Some((consultation, _)) = output.value.split_once('.') { - self.add_reference( - SymbolQuery::scoped( - RegistrySymbolKind::Consultation, - &service_name, - consultation, - ), - path, - scalar_prefix_range(output, consultation), - ); - } - } - } - } - - if let Some(profiles) = service - .value - .get("credential_profiles") - .and_then(YamlValue::as_mapping) - { - for profile in profiles { - self.add_resolvable_symbol( - SymbolKey::scoped( - RegistrySymbolKind::CredentialProfile, - &service_name, - &profile.key.value, - ), - Some(service_name.clone()), - path, - profile.key.range, - ); - if let Some(claims) = - profile.value.get("claims").and_then(YamlValue::as_sequence) - { - for claim in claims.iter().filter_map(YamlValue::as_scalar) { - self.add_reference( - SymbolQuery::scoped( - RegistrySymbolKind::Claim, - &service_name, - &claim.value, - ), - path, - claim.range, - ); - } - } - } - } } } @@ -843,19 +767,6 @@ impl IndexBuilder<'_> { name.range, ); } - if let Some(claims) = document - .get("expect") - .and_then(|expect| expect.get("claims")) - .and_then(YamlValue::as_mapping) - { - for claim in claims { - self.add_reference( - SymbolQuery::global(RegistrySymbolKind::Claim, &claim.key.value), - path, - claim.key.range, - ); - } - } } fn extract_environment(&mut self, path: &Path, relative: &Path, document: &YamlValue) { @@ -952,6 +863,7 @@ struct YamlPair { enum YamlValue { Scalar(YamlScalar), Mapping(Vec), + #[allow(dead_code)] Sequence(Vec), Other, } @@ -964,6 +876,7 @@ impl YamlValue { } } + #[allow(dead_code)] fn as_sequence(&self) -> Option<&[YamlValue]> { match self { Self::Sequence(entries) => Some(entries), @@ -1094,12 +1007,6 @@ fn scalar_from_node( }) } -fn scalar_prefix_range(scalar: &YamlScalar, prefix: &str) -> Range { - let mut end = scalar.range.start; - end.character += prefix.encode_utf16().count() as u32; - Range::new(scalar.range.start, end) -} - fn meaningful_named_children(node: Node<'_>) -> Vec> { let mut cursor = node.walk(); node.named_children(&mut cursor) @@ -1281,13 +1188,9 @@ entities: residents: { file: entities/residents.yaml } services: person-check: - kind: evidence + kind: consultation_api consultations: person_record: { integration: people } - claims: - active: { output: person_record.active, disclosure: predicate } - credential_profiles: - person-status: { claims: [active] } records: kind: records_api entity: residents @@ -1311,7 +1214,7 @@ services: write( temp.path(), "integrations/people/fixtures/active.yaml", - "name: active-person\nexpect: { claims: { active: true } }\n", + "name: active-person\n", ); temp } @@ -1332,28 +1235,18 @@ services: && symbol.name == "residents" && symbol.location.path.ends_with("residents.yaml") })); + assert!(index.symbols().iter().any(|symbol| { + symbol.kind == RegistrySymbolKind::Consultation && symbol.name == "person_record" + })); let manifest = temp.path().join(PROJECT_FILE); let locations = index.definitions_at(&manifest, Position::new(10, 38)); assert_eq!(locations.len(), 1); assert!(locations[0].path.ends_with("integration.yaml")); - - let fixture = temp.path().join("integrations/people/fixtures/active.yaml"); - let claim_locations = index.definitions_at(&fixture, Position::new(1, 21)); - assert_eq!(claim_locations.len(), 1); - assert_eq!(claim_locations[0].path, normalize_lookup_path(&manifest)); - - let consultation_locations = index.definitions_at(&manifest, Position::new(12, 28)); - assert_eq!(consultation_locations.len(), 1); - assert_eq!( - consultation_locations[0].path, - normalize_lookup_path(&manifest) - ); - assert_eq!(consultation_locations[0].range.start, Position::new(10, 6)); } #[test] - fn reports_missing_duplicate_and_ambiguous_references() { + fn reports_missing_and_duplicate_references() { let temp = fixture_project(); write( temp.path(), @@ -1366,22 +1259,9 @@ services: first: consultations: lookup: { integration: missing } - claims: - shared: { cel: true } - shared: { cel: false } - broken: { output: absent.value } - credential_profiles: - broken: { claims: [absent-claim] } - second: - claims: - shared: { cel: true } + lookup: { integration: people } "#, ); - write( - temp.path(), - "integrations/people/fixtures/active.yaml", - "name: active-person\nexpect: { claims: { shared: true, absent-fixture-claim: true } }\n", - ); let index = ProjectIndex::load(temp.path()).unwrap(); let messages = index .diagnostics() @@ -1394,19 +1274,7 @@ services: .any(|message| message.contains("Unknown integration"))); assert!(messages .iter() - .any(|message| message.contains("Duplicate claim"))); - assert!(messages - .iter() - .any(|message| message.contains("Ambiguous claim"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown consultation"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown claim reference 'absent-claim'"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown claim reference 'absent-fixture-claim'"))); + .any(|message| message.contains("Duplicate consultation"))); } #[test] @@ -1581,7 +1449,7 @@ services: (RegistrySymbolKind::Registry, "fictional-citizen-registry"), (RegistrySymbolKind::Integration, "person-record"), (RegistrySymbolKind::Service, "person-verification"), - (RegistrySymbolKind::Claim, "person-active"), + (RegistrySymbolKind::Consultation, "person_record"), (RegistrySymbolKind::Fixture, "active-person"), (RegistrySymbolKind::Environment, "local"), ] { @@ -1595,6 +1463,33 @@ services: } } + #[test] + fn retired_notary_authoring_fields_are_not_indexed() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + PROJECT_FILE, + r#"version: 1 +registry: { id: demo } +services: + retired: + claims: + active: { cel: true } + credential_profiles: + status: { claims: [active] } +"#, + ); + + let index = ProjectIndex::load(temp.path()).unwrap(); + assert!( + index + .symbols() + .iter() + .all(|symbol| symbol.name != "active" && symbol.name != "status"), + "retired Notary authoring fields must not remain in the current editor surface" + ); + } + #[test] fn maintained_authoring_catalog_workspaces_have_no_reference_diagnostics() { let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/crates/registry-language-server/tests/protocol.rs b/crates/registry-language-server/tests/protocol.rs index 170b8fc04..4e594041b 100644 --- a/crates/registry-language-server/tests/protocol.rs +++ b/crates/registry-language-server/tests/protocol.rs @@ -21,12 +21,9 @@ integrations: people: { file: integrations/people/integration.yaml } services: check: + kind: consultation_api consultations: lookup: { integration: people } - claims: - active: { output: lookup.active } - credential_profiles: - status: { claims: [active, missing] } "#, ) .unwrap(); @@ -162,7 +159,7 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "result": null }), ); - let mut published_missing_reference = false; + let mut published_manifest_diagnostics = false; for _ in 0..3 { let notification = receive(&mut stdout); if notification.get("method").and_then(Value::as_str) @@ -170,22 +167,13 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { && notification.pointer("/params/uri").and_then(Value::as_str) == Some(manifest_uri.as_str()) { - published_missing_reference = notification + published_manifest_diagnostics = notification .pointer("/params/diagnostics") .and_then(Value::as_array) - .is_some_and(|diagnostics| { - diagnostics.iter().any(|diagnostic| { - diagnostic - .get("message") - .and_then(Value::as_str) - .is_some_and(|message| { - message.contains("Unknown claim reference 'missing'") - }) - }) - }); + .is_some_and(Vec::is_empty); } } - assert!(published_missing_reference); + assert!(published_manifest_diagnostics); send( &mut stdin, @@ -195,7 +183,7 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "method": "textDocument/definition", "params": { "textDocument": { "uri": manifest_uri }, - "position": { "line": 7, "character": 31 } + "position": { "line": 8, "character": 31 } } }), ); @@ -233,13 +221,13 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "jsonrpc": "2.0", "id": 4, "method": "workspace/symbol", - "params": { "query": "active" } + "params": { "query": "lookup" } }), ); let symbols = receive_response(&mut stdout, 4); assert_eq!( symbols.pointer("/result/0/name").and_then(Value::as_str), - Some("active") + Some("lookup") ); let changed_manifest = fs::read_to_string(&manifest_path) diff --git a/crates/registry-manifest-cli/tests/cli.rs b/crates/registry-manifest-cli/tests/cli.rs index 2672ffde2..8af7c31c1 100644 --- a/crates/registry-manifest-cli/tests/cli.rs +++ b/crates/registry-manifest-cli/tests/cli.rs @@ -204,28 +204,25 @@ fn validate_prints_source_digest_and_rejects_runtime_only_keys() { let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); assert!(stdout.contains("source_manifest_digest: sha256:")); - let federation = dir.join("federation.yaml"); + let endpoints = dir.join("endpoints.yaml"); write_minimal_manifest( - &federation, + &endpoints, r#" -federation: - node_id: did:web:registry.example.test - issuer: https://registry.example.test - jwks_uri: https://registry.example.test/.well-known/jwks.json - federation_api: https://registry.example.test/federation - supported_protocol_versions: - - registry-notary-federation/v0.1 +data_services: + - id: person_api + title: Person API + endpoint_url: https://registry.example.test/v1/person datasets: [] "#, ); let output = Command::new(bin()) .arg("validate") - .arg(&federation) + .arg(&endpoints) .output() .expect("run cli"); assert!( output.status.success(), - "federation jwks_uri should be source metadata, stderr: {}", + "a declared endpoint URL should be source metadata, stderr: {}", String::from_utf8_lossy(&output.stderr) ); diff --git a/crates/registry-manifest-core/src/lib.rs b/crates/registry-manifest-core/src/lib.rs index 3ed071cb4..c297bfae8 100644 --- a/crates/registry-manifest-core/src/lib.rs +++ b/crates/registry-manifest-core/src/lib.rs @@ -19,7 +19,8 @@ const JSON_SCHEMA_DRAFT_2020_12: &str = "https://json-schema.org/draft/2020-12/s const EU_DATA_THEME_SCHEME: &str = "http://publications.europa.eu/resource/authority/data-theme"; const EUROVOC_THEME_SCHEME: &str = "http://eurovoc.europa.eu/100141"; const EU_LOCATION_IRI: &str = "http://publications.europa.eu/resource/authority/country/EUR"; -const REGISTRY_NOTARY_FEDERATION_PROTOCOL: &str = "registry-notary-federation/v0.1"; +/// The single evidence-offering access kind this layer validates in depth. +pub const REGISTRY_EVIDENCE_ACCESS_KIND: &str = "registry-evidence"; const CATALOG_SCHEMA_VERSION: &str = "registry-manifest-catalog/v1"; const EVIDENCE_OFFERINGS_SCHEMA_VERSION: &str = "registry-manifest-evidence-offerings/v1"; const EVIDENCE_OFFERING_SCHEMA_VERSION: &str = "registry-manifest-evidence-offering/v1"; @@ -368,8 +369,6 @@ pub struct MetadataManifest { pub vocabularies: BTreeMap, #[serde(default)] pub profiles: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation: Option, #[serde(default)] pub evaluation_profiles: Vec, #[serde(default)] @@ -433,8 +432,6 @@ struct MetadataManifestFields { #[serde(default)] profiles: Vec, #[serde(default)] - federation: Option, - #[serde(default)] evaluation_profiles: Vec, #[serde(default)] ecosystem_bindings: Vec, @@ -463,7 +460,6 @@ impl From for MetadataManifest { catalog: fields.catalog, vocabularies: fields.vocabularies, profiles: fields.profiles, - federation: fields.federation, evaluation_profiles: fields.evaluation_profiles, ecosystem_bindings: fields.ecosystem_bindings, requirements: fields.requirements, @@ -548,17 +544,6 @@ pub struct ProfileClaim { pub version: String, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct FederationManifest { - pub node_id: String, - pub issuer: String, - pub jwks_uri: String, - pub federation_api: String, - #[serde(default)] - pub supported_protocol_versions: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct EvaluationProfileManifest { @@ -1261,8 +1246,6 @@ pub struct CompiledMetadataInner { pub datasets: BTreeMap, pub codelists: BTreeMap, pub profiles: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub federation: Option, pub evaluation_profiles: Vec, pub ecosystem_bindings: Vec, } @@ -1692,10 +1675,6 @@ impl CompiledMetadata { &self.inner.profiles } - pub fn federation(&self) -> Option<&FederationManifest> { - self.inner.federation.as_ref() - } - pub fn evaluation_profiles(&self) -> &[EvaluationProfileManifest] { &self.inner.evaluation_profiles } @@ -1785,7 +1764,6 @@ impl CompiledMetadata { datasets, codelists, profiles: self.inner.profiles.clone(), - federation: self.inner.federation.clone(), evaluation_profiles: self.inner.evaluation_profiles.clone(), ecosystem_bindings: self.inner.ecosystem_bindings.clone(), }), @@ -1816,87 +1794,6 @@ impl ValidationError { } } -fn validate_federation(federation: Option<&FederationManifest>, errors: &mut Vec) { - let Some(federation) = federation else { - return; - }; - - validate_non_empty(&federation.node_id, "federation.node_id", errors); - validate_https_url(&federation.issuer, "federation.issuer", errors); - validate_https_url(&federation.jwks_uri, "federation.jwks_uri", errors); - validate_https_url( - &federation.federation_api, - "federation.federation_api", - errors, - ); - if let Some(issuer_host) = https_url_host(&federation.issuer) { - validate_federation_endpoint_host( - &federation.jwks_uri, - "federation.jwks_uri", - &issuer_host, - errors, - ); - validate_federation_endpoint_host( - &federation.federation_api, - "federation.federation_api", - &issuer_host, - errors, - ); - } - if !federation - .supported_protocol_versions - .iter() - .any(|version| version == REGISTRY_NOTARY_FEDERATION_PROTOCOL) - { - errors.push(ValidationError::new( - "federation.supported_protocol_versions", - format!( - "supported protocol versions must include {REGISTRY_NOTARY_FEDERATION_PROTOCOL}" - ), - )); - } - for (index, version) in federation.supported_protocol_versions.iter().enumerate() { - validate_non_empty( - version, - format!("federation.supported_protocol_versions[{index}]"), - errors, - ); - } - - match did_web_host(&federation.node_id) { - Some(did_web_host) => { - if url_host(&federation.issuer) - .is_some_and(|issuer_host| !did_web_host.eq_ignore_ascii_case(issuer_host.as_str())) - { - errors.push(ValidationError::new( - "federation.node_id", - "DID:web node id must bind to federation issuer host", - )); - } - } - None => errors.push(ValidationError::new( - "federation.node_id", - "federation node id must be a did:web identifier", - )), - } -} - -fn validate_federation_endpoint_host( - value: &str, - path: impl Into, - issuer_host: &str, - errors: &mut Vec, -) { - if let Some(endpoint_host) = https_url_host(value) { - if !issuer_host.eq_ignore_ascii_case(&endpoint_host) { - errors.push(ValidationError::new( - path, - "federation endpoint host must bind to federation issuer host", - )); - } - } -} - fn validate_evaluation_profiles<'a>( manifest: &'a MetadataManifest, errors: &mut Vec, @@ -2538,7 +2435,6 @@ pub fn validate_manifest(manifest: &MetadataManifest) -> Result<(), MetadataErro } } - validate_federation(manifest.federation.as_ref(), &mut errors); let evaluation_profile_rulesets = validate_evaluation_profiles(manifest, &mut errors); validate_ecosystem_bindings(manifest, &mut errors); let requirement_ids = validate_requirements(manifest, &mut errors); @@ -2581,20 +2477,6 @@ pub fn validate_manifest(manifest: &MetadataManifest) -> Result<(), MetadataErro } } - if manifest.federation.is_none() - && manifest.datasets.iter().any(|dataset| { - dataset - .evidence_offerings - .iter() - .any(|offering| offering.access.kind == "registry-notary") - }) - { - errors.push(ValidationError::new( - "federation", - "registry-notary access requires a top-level federation block", - )); - } - let mut dataset_ids = BTreeSet::new(); let mut offering_ids = BTreeSet::new(); for (dataset_index, dataset) in manifest.datasets.iter().enumerate() { @@ -2824,7 +2706,6 @@ pub fn compile_manifest(manifest: &MetadataManifest) -> Result Value { if !evidence_offerings.is_empty() { catalog["evidence_offerings"] = json!(evidence_offerings); } - if let Some(federation) = compiled.federation() { - catalog["federation"] = json!(federation); - } if !compiled.evaluation_profiles().is_empty() { catalog["evaluation_profiles"] = json!(compiled.evaluation_profiles()); } @@ -4328,7 +4206,7 @@ fn validate_evidence_offerings( "access kind must not be empty", )); } - if offering.access.conforms_to.as_deref() != Some(REGISTRY_NOTARY_FEDERATION_PROTOCOL) { + if offering.access.kind != REGISTRY_EVIDENCE_ACCESS_KIND { validate_optional_uri( offering.access.conforms_to.as_deref(), format!("{offering_path}.access.conforms_to"), @@ -4336,8 +4214,8 @@ fn validate_evidence_offerings( errors, ); } - if offering.access.kind == "registry-notary" { - validate_registry_notary_access( + if offering.access.kind == REGISTRY_EVIDENCE_ACCESS_KIND { + validate_registry_evidence_access( offering, &offering_path, evaluation_profile_rulesets, @@ -4375,17 +4253,25 @@ fn validate_evidence_offerings( } } -fn validate_registry_notary_access( +/// Validates the one access kind this layer knows how to check. +/// +/// The offering vocabulary itself is portable and product-neutral. Only the +/// `registry-evidence` kind carries endpoint expectations here, because it +/// names a service whose shape this repository defines: an evidence endpoint +/// and the discovery document a relying party reads to verify what that +/// endpoint returns. Every other kind is left to the runtime that consumes it. +fn validate_registry_evidence_access( offering: &EvidenceOfferingManifest, offering_path: &str, evaluation_profile_rulesets: &BTreeSet<&str>, errors: &mut Vec, ) { - if offering.access.conforms_to.as_deref() != Some(REGISTRY_NOTARY_FEDERATION_PROTOCOL) { - errors.push(ValidationError::new( + match offering.access.conforms_to.as_deref() { + Some(conforms_to) if !conforms_to.trim().is_empty() => {} + _ => errors.push(ValidationError::new( format!("{offering_path}.access.conforms_to"), - format!("registry-notary access must conform to {REGISTRY_NOTARY_FEDERATION_PROTOCOL}"), - )); + "registry-evidence access must declare the response profile it conforms to", + )), } match offering.access.endpoint_url.as_deref() { Some(endpoint_url) => validate_https_url( @@ -4395,7 +4281,7 @@ fn validate_registry_notary_access( ), None => errors.push(ValidationError::new( format!("{offering_path}.access.endpoint_url"), - "registry-notary access must declare an HTTPS endpoint URL", + "registry-evidence access must declare an HTTPS endpoint URL", )), } match offering.access.discovery_url.as_deref() { @@ -4406,7 +4292,7 @@ fn validate_registry_notary_access( ), None => errors.push(ValidationError::new( format!("{offering_path}.access.discovery_url"), - "registry-notary access must declare an HTTPS discovery URL", + "registry-evidence access must declare an HTTPS discovery URL", )), } if !offering.access.ruleset.trim().is_empty() @@ -4414,7 +4300,7 @@ fn validate_registry_notary_access( { errors.push(ValidationError::new( format!("{offering_path}.access.ruleset"), - "registry-notary access.ruleset must reference a known evaluation profile ruleset", + "registry-evidence access.ruleset must reference a known evaluation profile ruleset", )); } } @@ -6729,15 +6615,8 @@ fn https_url_host(value: &str) -> Option { .and_then(url_host_after_scheme) } -fn url_host(value: &str) -> Option { - value - .strip_prefix("https://") - .or_else(|| value.strip_prefix("http://")) - .and_then(url_host_after_scheme) -} - -// Returns the full authority (host plus port when present), lower-cased, so that -// did:web bindings can match the issuer URL on origin boundary, not just host. +// Returns the full authority (host plus port when present), lower-cased, so a +// comparison lands on the origin boundary rather than the bare host. fn url_host_after_scheme(remainder: &str) -> Option { let authority = remainder .split(['/', '?', '#']) @@ -6749,22 +6628,6 @@ fn url_host_after_scheme(remainder: &str) -> Option { (!host.is_empty()).then(|| host.to_ascii_lowercase()) } -fn did_web_host(node_id: &str) -> Option { - node_id - .strip_prefix("did:web:") - .and_then(|method_id| method_id.split(':').next()) - .filter(|host| !host.is_empty()) - .map(|host| { - host.replace("%3A", ":") - .replace("%3a", ":") - .replace("%5B", "[") - .replace("%5b", "[") - .replace("%5D", "]") - .replace("%5d", "]") - .to_ascii_lowercase() - }) -} - fn validate_dataset_public_service_id( value: &str, path: impl Into, diff --git a/crates/registry-manifest-core/tests/metadata_core.rs b/crates/registry-manifest-core/tests/metadata_core.rs index 3b6059b17..bc29d5b07 100644 --- a/crates/registry-manifest-core/tests/metadata_core.rs +++ b/crates/registry-manifest-core/tests/metadata_core.rs @@ -2095,7 +2095,7 @@ datasets: ); } -fn federated_evaluation_manifest() -> MetadataManifest { +fn evidence_offering_manifest() -> MetadataManifest { serde_yaml_ng::from_str( r#" schema_version: registry-manifest/v1 @@ -2105,13 +2105,6 @@ catalog: title: Federated Evaluation publisher: name: Example Registry -federation: - node_id: did:web:registry.example.test - issuer: https://registry.example.test - jwks_uri: https://registry.example.test/.well-known/jwks.json - federation_api: https://registry.example.test/federation - supported_protocol_versions: - - registry-notary-federation/v0.1 evaluation_profiles: - id: age_eligibility_profile ruleset: age-eligibility-v1 @@ -2129,8 +2122,8 @@ datasets: - id: residents title: Residents evidence_offerings: - - id: age_notary - title: Age notary + - id: age_evidence_offering + title: Age evidence service evidence_type: age_evidence issuing_authority: id: civil_registry @@ -2138,10 +2131,10 @@ datasets: entity: resident lookup_keys: [national_id] access: - kind: registry-notary - conforms_to: registry-notary-federation/v0.1 - endpoint_url: https://notary.example.test/evaluate - discovery_url: https://notary.example.test/.well-known/registry-notary + kind: registry-evidence + conforms_to: registry.assertion-evidence/v1 + endpoint_url: https://evidence.example.test/v1/assertions + discovery_url: https://evidence.example.test/.well-known/evidence/jwks.json ruleset: age-eligibility-v1 entities: - name: resident @@ -2150,20 +2143,20 @@ datasets: type: string "#, ) - .expect("federated evaluation manifest parses") + .expect("evidence offering manifest parses") } #[test] -fn federated_evaluation_manifest_validates_and_renders_catalog_fields() { - let manifest = federated_evaluation_manifest(); +fn evidence_offering_manifest_validates_and_renders_catalog_fields() { + let manifest = evidence_offering_manifest(); - validate_manifest(&manifest).expect("federated manifest validates"); - let compiled = compile_manifest(&manifest).expect("federated manifest compiles"); + validate_manifest(&manifest).expect("evidence offering manifest validates"); + let compiled = compile_manifest(&manifest).expect("evidence offering manifest compiles"); let catalog = render_catalog(&compiled); - assert_eq!( - catalog["federation"]["supported_protocol_versions"][0], - json!("registry-notary-federation/v0.1") + assert!( + catalog.get("federation").is_none(), + "catalog must not republish a federation block: {catalog}" ); assert_eq!( catalog["evaluation_profiles"][0]["id"], @@ -3321,8 +3314,8 @@ codelists: [] } #[test] -fn validation_rejects_registry_notary_unresolved_ruleset() { - let mut manifest = federated_evaluation_manifest(); +fn validation_rejects_registry_evidence_unresolved_ruleset() { + let mut manifest = evidence_offering_manifest(); manifest.datasets[0].evidence_offerings[0].access.ruleset = "missing_profile".to_string(); let error = validate_manifest(&manifest).expect_err("unresolved ruleset rejected"); @@ -3338,25 +3331,39 @@ fn validation_rejects_registry_notary_unresolved_ruleset() { } #[test] -fn validation_rejects_registry_notary_bad_conforms_to() { - let mut manifest = federated_evaluation_manifest(); +fn validation_rejects_blank_registry_evidence_conforms_to() { + let mut manifest = evidence_offering_manifest(); manifest.datasets[0].evidence_offerings[0] .access - .conforms_to = Some("registry_relay:evidence-server-v1".to_string()); + .conforms_to = Some(" ".to_string()); - let error = validate_manifest(&manifest).expect_err("bad protocol rejected"); + let error = validate_manifest(&manifest).expect_err("blank conformance target rejected"); let MetadataError::Validation { errors } = error else { panic!("unexpected error: {error:?}"); }; assert!(errors.iter().any(|error| { error.path == "datasets[0].evidence_offerings[0].access.conforms_to" - && error.message.contains("registry-notary-federation/v0.1") + && error.message.contains("registry-evidence access") })); } +#[test] +fn validation_accepts_a_registry_evidence_conforms_to_that_is_not_a_uri() { + // conforms_to names the response profile the endpoint returns, and the + // portable manifest layer deliberately does not pin it to a product's + // contract version, so a bare profile identifier is a legitimate value. + // Requiring a URI here would reject what the Evidence runtime returns. + let mut manifest = evidence_offering_manifest(); + manifest.datasets[0].evidence_offerings[0] + .access + .conforms_to = Some("registry.assertion-evidence/v1".to_string()); + + validate_manifest(&manifest).expect("a profile identifier is a valid conformance target"); +} + #[test] fn validation_rejects_duplicate_evaluation_profile_ids() { - let mut manifest = federated_evaluation_manifest(); + let mut manifest = evidence_offering_manifest(); manifest .evaluation_profiles .push(manifest.evaluation_profiles[0].clone()); @@ -3374,187 +3381,91 @@ fn validation_rejects_duplicate_evaluation_profile_ids() { } #[test] -fn validation_rejects_invalid_federation_urls_and_did_web_binding() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "http://registry.example.test".to_string(); - federation.jwks_uri = "http://registry.example.test/.well-known/jwks.json".to_string(); - federation.federation_api = "http://registry.example.test/federation".to_string(); - federation.node_id = "did:web:other.example.test".to_string(); - - let error = validate_manifest(&manifest).expect_err("bad federation rejected"); - let MetadataError::Validation { errors } = error else { - panic!("unexpected error: {error:?}"); - }; - assert!(errors.iter().any(|error| error.path == "federation.issuer")); - assert!(errors - .iter() - .any(|error| error.path == "federation.jwks_uri")); - assert!(errors - .iter() - .any(|error| error.path == "federation.federation_api")); - assert!(errors.iter().any(|error| { - error.path == "federation.node_id" - && error - .message - .contains("must bind to federation issuer host") - })); -} - -#[test] -fn validation_accepts_federation_endpoints_on_issuer_host() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "https://registry.example.test/issuer".to_string(); - federation.jwks_uri = "https://registry.example.test/.well-known/jwks.json".to_string(); - federation.federation_api = "https://registry.example.test/federation".to_string(); - - validate_manifest(&manifest).expect("federation endpoints bind to issuer host"); -} +fn manifest_rejects_a_retired_federation_block() { + let raw = r#" +schema_version: registry-manifest/v1 +catalog: + id: retired-federation + base_url: https://registry.example.test + title: Retired Federation + publisher: + name: Example Registry +federation: + node_id: did:web:registry.example.test + issuer: https://registry.example.test + jwks_uri: https://registry.example.test/.well-known/jwks.json + federation_api: https://registry.example.test/federation + supported_protocol_versions: + - registry-notary-federation/v0.1 +"#; -#[test] -fn validation_rejects_federation_endpoints_on_cross_hosts() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.jwks_uri = "https://keys.example.test/.well-known/jwks.json".to_string(); - federation.federation_api = "https://api.example.test/federation".to_string(); + let error = serde_yaml_ng::from_str::(raw) + .expect_err("retired federation block rejected"); - let error = validate_manifest(&manifest).expect_err("cross-host federation endpoints rejected"); - let MetadataError::Validation { errors } = error else { - panic!("unexpected error: {error:?}"); - }; assert!( - errors.iter().any(|error| { - error.path == "federation.jwks_uri" - && error - .message - .contains("must bind to federation issuer host") - }), - "expected JWKS host binding error, got: {errors:?}" - ); - assert!( - errors.iter().any(|error| { - error.path == "federation.federation_api" - && error - .message - .contains("must bind to federation issuer host") - }), - "expected federation API host binding error, got: {errors:?}" + error.to_string().contains("federation"), + "expected the unknown-field error to name federation, got: {error}" ); } #[test] -fn validation_rejects_did_web_port_mismatch_against_issuer() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "https://registry.example.test:9090".to_string(); - federation.jwks_uri = "https://registry.example.test:9090/.well-known/jwks.json".to_string(); - federation.federation_api = "https://registry.example.test:9090/federation".to_string(); - federation.node_id = "did:web:registry.example.test%3A8080".to_string(); - - let error = validate_manifest(&manifest).expect_err("port mismatch rejected"); +fn validation_requires_https_endpoints_for_registry_evidence_access() { + let mut manifest = evidence_offering_manifest(); + let access = &mut manifest.datasets[0].evidence_offerings[0].access; + access.endpoint_url = Some("http://evidence.example.test/v1/assertions".to_string()); + access.discovery_url = Some("http://evidence.example.test/.well-known/jwks.json".to_string()); + + let error = validate_manifest(&manifest).expect_err("insecure evidence endpoints rejected"); let MetadataError::Validation { errors } = error else { panic!("unexpected error: {error:?}"); }; assert!( - errors.iter().any(|error| { - error.path == "federation.node_id" - && error - .message - .contains("must bind to federation issuer host") - }), - "expected DID:web port mismatch to be reported, got: {errors:?}" + errors + .iter() + .any(|error| error.path == "datasets[0].evidence_offerings[0].access.endpoint_url"), + "expected an endpoint_url error, got: {errors:?}" ); -} - -#[test] -fn validation_rejects_did_web_with_port_against_default_port_issuer() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "https://registry.example.test".to_string(); - federation.jwks_uri = "https://registry.example.test/.well-known/jwks.json".to_string(); - federation.federation_api = "https://registry.example.test/federation".to_string(); - federation.node_id = "did:web:registry.example.test%3A8443".to_string(); - - let error = validate_manifest(&manifest).expect_err("asymmetric port rejected"); - let MetadataError::Validation { errors } = error else { - panic!("unexpected error: {error:?}"); - }; assert!( - errors.iter().any(|error| { - error.path == "federation.node_id" - && error - .message - .contains("must bind to federation issuer host") - }), - "expected DID:web port-vs-default-port asymmetry to be reported, got: {errors:?}" + errors + .iter() + .any(|error| error.path == "datasets[0].evidence_offerings[0].access.discovery_url"), + "expected a discovery_url error, got: {errors:?}" ); } #[test] -fn validation_rejects_default_port_did_web_against_issuer_with_port() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "https://registry.example.test:8443".to_string(); - federation.jwks_uri = "https://registry.example.test:8443/.well-known/jwks.json".to_string(); - federation.federation_api = "https://registry.example.test:8443/federation".to_string(); - federation.node_id = "did:web:registry.example.test".to_string(); - - let error = validate_manifest(&manifest).expect_err("asymmetric port rejected"); +fn validation_requires_declared_endpoints_for_registry_evidence_access() { + let mut manifest = evidence_offering_manifest(); + let access = &mut manifest.datasets[0].evidence_offerings[0].access; + access.endpoint_url = None; + access.discovery_url = None; + access.conforms_to = None; + + let error = validate_manifest(&manifest).expect_err("undeclared evidence access rejected"); let MetadataError::Validation { errors } = error else { panic!("unexpected error: {error:?}"); }; - assert!( - errors.iter().any(|error| { - error.path == "federation.node_id" - && error - .message - .contains("must bind to federation issuer host") - }), - "expected DID:web default-vs-explicit-port asymmetry to be reported, got: {errors:?}" - ); -} - -#[test] -fn validation_accepts_did_web_port_match_against_issuer() { - let mut manifest = federated_evaluation_manifest(); - let federation = manifest.federation.as_mut().expect("federation"); - federation.issuer = "https://registry.example.test:8443".to_string(); - federation.jwks_uri = "https://registry.example.test:8443/.well-known/jwks.json".to_string(); - federation.federation_api = "https://registry.example.test:8443/federation".to_string(); - federation.node_id = "did:web:registry.example.test%3A8443".to_string(); - - validate_manifest(&manifest).expect("matching port binds"); + for field in ["endpoint_url", "discovery_url", "conforms_to"] { + let path = format!("datasets[0].evidence_offerings[0].access.{field}"); + assert!( + errors + .iter() + .any(|error| error.path == path + && error.message.contains("registry-evidence access")), + "expected a registry-evidence error for {field}, got: {errors:?}" + ); + } } #[test] -fn validation_reports_missing_federation_block_once_across_offerings() { - let mut manifest = federated_evaluation_manifest(); - manifest.federation = None; - let template = manifest.datasets[0].evidence_offerings[0].clone(); - for index in 1..3 { - let mut copy = template.clone(); - copy.id = format!("age_notary_{index}"); - manifest.datasets[0].evidence_offerings.push(copy); - } +fn validation_leaves_an_unrecognized_access_kind_to_the_consuming_runtime() { + let mut manifest = evidence_offering_manifest(); + let access = &mut manifest.datasets[0].evidence_offerings[0].access; + access.kind = "registry-notary".to_string(); + access.conforms_to = Some("https://example.test/protocols/some-other-service".to_string()); - let error = validate_manifest(&manifest).expect_err("missing federation rejected"); - let MetadataError::Validation { errors } = error else { - panic!("unexpected error: {error:?}"); - }; - let federation_errors = errors - .iter() - .filter(|error| { - error.path == "federation" - && error - .message - .contains("registry-notary access requires a top-level federation block") - }) - .count(); - assert_eq!( - federation_errors, 1, - "expected exactly one federation-missing error, got {federation_errors}: {errors:?}" - ); + validate_manifest(&manifest) + .expect("the portable layer does not enumerate product access kinds"); } #[test] diff --git a/crates/registry-mint/Cargo.toml b/crates/registry-mint/Cargo.toml new file mode 100644 index 000000000..fb3ca48e0 --- /dev/null +++ b/crates/registry-mint/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "registry-mint" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Minimal OAuth token issuer that authenticates clients by private key JWT." +repository.workspace = true +publish = false + +[[bin]] +name = "mint" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +clap.workspace = true +http.workspace = true +jsonwebtoken.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-audit.workspace = true +registry-platform-crypto.workspace = true +registry-platform-oidc.workspace = true +reqwest.workspace = true +rustix.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +ulid.workspace = true +url.workspace = true +zeroize.workspace = true + +[dev-dependencies] +axum-test.workspace = true +ed25519-dalek.workspace = true +registry-evidence.workspace = true +tempfile.workspace = true diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md new file mode 100644 index 000000000..438f7905e --- /dev/null +++ b/crates/registry-mint/README.md @@ -0,0 +1,325 @@ +# Registry Mint + +Mint issues short-lived access tokens to registered machine clients. It exists +so that a resource server such as Evidence can require signed, expiring, +audience-bound tokens without the deployment first having to stand up a general +purpose identity provider. + +Mint is not a product line. It is a small supporting service for deployments +that have many callers and no IdP. + +## Why a server, and not just a shared JWKS + +A resource server configured with a pooled JWK set can answer only one +question: *was this token signed by one of the trusted keys?* It cannot answer +the question that authorization actually depends on: *which caller signed it, +and what is that caller permitted to assert?* + +Key selection inside a JWK set is by `kid`, and `kid` is chosen by whoever +built the token. So in a pooled set every key is equally authoritative for +every claim. Any client holding any trusted key can mint a token naming any +principal, any requester tags, and any evidence audience. + +Mint closes that by splitting the two questions across two places: + +- The **client registry** (`clients/*.yaml`) binds a client id to *that + client's* public keys and to the authority Mint will assert for it. +- The **token endpoint** verifies an incoming client assertion against the + keys of the client it claims to be, selected *before* signature + verification, and then writes the authority from the registry, never from + the assertion payload. + +A client therefore holds its own key and signs for itself, but possession of a +key no longer decides what may be said. + +## Protocol + +Mint speaks the `client_credentials` grant with `private_key_jwt` client +authentication (RFC 7523). A client builds a short-lived JWT assertion signed +with its own private key, and posts it to the token endpoint: + +``` +POST /token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +&client_assertion= +``` + +The assertion must carry `iss` and `sub` equal to the client id, `aud` equal to +the configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside the +configured maximum lifetime. Every `jti` is single use: presenting the same +assertion twice is refused. + +The response is a signed `at+jwt` access token. Errors collapse to +`invalid_client` so that the endpoint cannot be used to probe which client ids +are registered. + +Endpoints: + +| Path | Purpose | +|---|---| +| `POST /token` | Issue an access token | +| `GET /.well-known/jwks.json` | Public keys for verifying minted tokens (path is configurable) | +| `GET /.well-known/oauth-authorization-server` | Metadata pointing at the above | +| `GET /health`, `GET /ready` | Liveness, and readiness (503 without clients or a writable audit chain) | + +## Configuration + +One YAML document. Every path in it resolves relative to the document's own +directory. Everything here is startup-only: issuer identity, signing and audit +keys, listener, and token policy are fixed for the life of the process. + +```yaml +version: 1 +issuer: https://mint.example.org +listener: + address: 127.0.0.1 + port: 8081 +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: secrets/signing.jwk + # Public JWKs of keys that no longer sign but may still have live tokens. + retiredPublicJwkFiles: [] +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + # Optional. Required only to issue delegated tokens; see below. + actor: evidence_actor +clientAssertion: + audience: https://mint.example.org/token + maximumLifetimeSeconds: 300 + algorithms: [EdDSA] +clients: + directory: clients +``` + +The `accessTokens.claims` names must match the resource server's +`authentication` block, because the resource server reads its principal, +requester tags, evidence audience, and grant pair from configurable claim +names. Access token lifetime is bounded to 60..=3600 seconds; a long-lived +bearer token is the thing Mint exists to avoid. + +The signing key file must be a private JWK and must be readable only by its +owner. Never commit it, print it, or pass it on a command line. + +The audit key file is also owner-only and must contain at least 32 bytes. The +audit directory, chain, and lock file must be owned by the Mint process user and +unavailable to group and other users. For a new deployment, `openssl rand -hex +32 > secrets/audit-hmac-key` followed by `chmod 600 +secrets/audit-hmac-key` is sufficient. Mint verifies the keyed chain at startup +and holds a single-writer lock for the process lifetime. It writes a durable +release record before returning every access token; if that write fails, the +request returns `server_error` and readiness fails. Denials are recorded with +value-free error categories. Raw assertions, tokens, client ids, actors, +principals, and subject values never enter the chain; successful records use +keyed pseudonyms where correlation is needed. + +`audit.maximumFileBytes` is a per-segment threshold, not a total capacity limit. +When an append would exceed the threshold, Mint seals the active segment as +`.` and opens a new active segment online. The +keyed chain continues across the seam. Mint never deletes or compacts sealed +segments, so monitor total capacity and archive sealed history under the +deployment's retention policy while retaining the matching audit key. Never +rename or archive the active segment while Mint is running. + +## Registering a client + +One `*.yaml` file per client in `clients.directory`: + +```yaml +clientId: health-desk +principal: service:health-desk +evidenceAudience: https://evidence.example.org +requesterTags: [health-desk, region-north] +# Optional. Minted only for callers acting under a recorded authority. +grant: + id: grant-2026-014 + authority: ministry-of-health +keys: + - kty: OKP + crv: Ed25519 + kid: health-desk-2026-01 + x: "..." +``` + +Only public JWKs are accepted; a document carrying a private member is +rejected. The load is all-or-nothing, so one malformed registration fails the +whole load and a partially applied registry can never serve. + +## Delegation: a token bound to one subject + +A caller can be issued a token that is valid only for evidence *about one named +person*. The point is containment rather than labelling: a client that loops +over the wrong list or reuses a request object cannot cross from one person to +another, because the subject is not a request parameter it controls. + +Three things have to line up. + +**The registration** says which agents this client may act as, and which +selector fields it may bind, at which claim paths: + +```yaml +clientId: appointment-scheduler +principal: service:appointment-scheduler +evidenceAudience: https://scheduler.example.org +requesterTags: [scheduling-agent] +delegation: + # Optional. Omitted means the client names its own actor. + actors: [urn:example:agent:appointment-scheduler] + subjectClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +keys: [...] +``` + +A client with no `delegation` block cannot obtain a delegated token at all. + +**The request** names the actor and the subject inside the client's own signed +assertion, in an `on_behalf_of` member: + +```json +{ + "iss": "appointment-scheduler", + "sub": "appointment-scheduler", + "aud": "https://mint.example.org/token", + "iat": 1785671511, "exp": 1785671631, "jti": "...", + "on_behalf_of": { + "actor": "urn:example:agent:appointment-scheduler", + "subject": {"given_name": "...", "family_name": "...", "birth_date": "..."} + } +} +``` + +Placing it inside the assertion is deliberate: the actor and the subject are +covered by the client's signature, so nothing between the client and Mint can +alter who the token is for. `on_behalf_of` is Mint's own member rather than RFC +8693 `act`, because token exchange presents a subject's own credential, which is +exactly what a deployment without an identity provider does not have. + +The subject must carry the registration's subject fields exactly: a missing +field or an extra one is refused, like every other delegation failure, as +`invalid_client`. + +**The resource server bundle** declares that subject role's `valueOrigin` as +`authenticated-context`, with `valueClaims` mirroring `subjectClaims` above. +That is what makes the property hold: Evidence reads the selector from the token +and rejects any request that carries selector values of its own. A request +naming a different person is refused for carrying values at all, not for +carrying the wrong ones. + +Two limits worth stating plainly. This defends against a *buggy* client, not a +*compromised* one: a client holding its own signing key can ask Mint for a token +naming a different subject, within the fields its registration permits. And +Evidence confines an actor-bearing token to `kind: delegated` authority profiles +but does not conversely require an actor to reach one, so an undelegated token +matches such a grant and is stopped when the subject cannot be resolved. + +[`demo/`](demo/) runs all of this end to end against the real binaries, with +every request printed before it is sent. + +The registry is the one reloadable part of Mint. `SIGHUP` reloads it in place, +keeping the previous registry if the new one does not load. Onboarding, +offboarding, and caller key rotation therefore never restart the resource +server. + +## Running + +```bash +mint check --config /etc/mint/mint.yaml +``` + +```bash +mint serve --config /etc/mint/mint.yaml +``` + +Verify the retained chain with the same configuration and audit key: + +```bash +mint verify-audit --config /etc/mint/mint.yaml +``` + +The command verifies every sealed segment. It also verifies the active segment +when no Mint process owns the writer lock; otherwise it reports +`active-segment: not verified`. + +All three operator commands accept `MINT_CONFIG` in place of `--config`. +`check` loads the configuration, signing key, audit chain, and client registry, +then exits without opening a socket. + +Mint serves plain HTTP and expects to sit behind TLS termination it does not +manage. + +## Getting a token in development + +```bash +mint token --url https://mint.example.org/token \ + --client-id scheduler --key ./dev/scheduler.jwk +``` + +It prints the access token on stdout and nothing else, so `TOKEN=$(mint token +...)` is the whole usage. Diagnostics go to stderr; `--verbose` prints the +endpoint's full response instead. + +This is a *client* tool. It signs a client assertion with the caller's own key +and presents it to a running endpoint, exactly as an adopter's client library +would. It reads no server configuration and never touches Mint's signing key. +There is deliberately no subcommand that signs an access token directly: that +would be a way to obtain authority without authenticating, inside the binary +whose purpose is to make authority depend on authentication. Anything `mint +token` can obtain, the same client could have obtained over the wire. + +The key file gets the same treatment as Mint's own signing key: a regular file, +owned by you, unreadable by group and other, reached without traversing a +symlink. + +For a delegated token: + +```bash +mint token --url https://mint.example.org/token \ + --client-id scheduler --key ./dev/scheduler.jwk \ + --actor urn:example:agent:appointment-scheduler \ + --subject-file ./dev/subject.json +``` + +`--subject-file` holds a flat JSON object of selector fields +(`{"given_name": "Amara", "birth_date": "1998-04-02"}`). It is a file rather +than repeated flags because those are a real person's identifying details, and +command lines are visible to every process on the host and land in shell +history. + +Two more flags matter in development. `--audience` overrides the assertion +audience, which defaults to `--url`; they differ when the endpoint is reached +over loopback but configured with its public URL. `--ca-certificate` trusts a +PEM bundle in addition to the system roots, for a deployment behind a private +CA. + +## Verify a change + +```bash +cargo test --locked -p registry-mint +``` + +`tests/evidence_compatibility.rs` is the test that justifies the crate: it +drives the real router over a real on-disk deployment and feeds the minted +token to Evidence's own authenticator. `tests/delegated_subject_binding.rs` +does the same for delegation, running Evidence's own entitlement match and +selector resolution over a token from the real Mint router. The dependency runs +one way only. Evidence does not depend on Mint. + +`tests/token_cli.rs` runs `mint token` against a real `mint serve` as two +processes, which is the only place the stdout contract can be observed. diff --git a/crates/registry-mint/demo/.gitignore b/crates/registry-mint/demo/.gitignore new file mode 100644 index 000000000..3b785b09b --- /dev/null +++ b/crates/registry-mint/demo/.gitignore @@ -0,0 +1,3 @@ +# The throwaway deployment `run.sh` provisions: keys, certificates, configs, +# audit log. Regenerated on every run, never committed. +.run/ diff --git a/crates/registry-mint/demo/README.md b/crates/registry-mint/demo/README.md new file mode 100644 index 000000000..46b7b2cee --- /dev/null +++ b/crates/registry-mint/demo/README.md @@ -0,0 +1,275 @@ +# Delegated, subject-bound access: a runnable demonstration + +An agent needs to know which region one person lives in. It must not be able to +learn that about anybody else, even if the agent's own code is wrong. + +This directory runs that end to end against the real Mint and the real Evidence +binaries: a real client assertion, a real token, a real signed evidence +assertion, and a real refusal. + +```bash +crates/registry-mint/demo/run.sh +``` + +It needs `cargo`, `uv`, and `openssl`, binds four loopback ports (8080, 8090, +8092, 8443), and leaves its throwaway deployment in `demo/.run/` for inspection. +Everything it generates is disposable: fresh keys per run, synthetic people, a +private CA that exists for the lifetime of the demonstration. + +## What to read + +- [`walkthrough.py`](walkthrough.py) is the demonstration. Six steps, every + request printed before it is sent, with the reasoning next to it. +- [`evidence-bundle/evidence.yaml`](evidence-bundle/evidence.yaml) is the + policy. The security property is one line of it. +- Everything under [`support/`](support/) is deployment plumbing: key + generation, a TLS terminator, a stand-in registry source. None of it decides + anything. Read it only if you want to know why the demonstration needs a + certificate. + +## The mechanism, in one paragraph + +The client authenticates to Mint with a JWT it signs with its own key +(RFC 7523 `private_key_jwt`), so there is no shared secret and Mint holds only +public keys. The delegation request rides *inside* that signed JWT, in an +`on_behalf_of` member, which is what makes the actor and the subject +tamper-evident between the client and Mint. Mint checks both against the +client's registration and mints them as claims. The Evidence bundle then +declares that subject role's `valueOrigin` as `authenticated-context`, which +means Evidence reads the selector from those claims and **refuses any request +that carries selector values of its own**. That refusal is the containment: it +is not "you named the wrong person", it is "you do not get to name a person". + +## The flow + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Mint as Registry Mint + participant Evidence + participant Source as Registry source + + Note over Client: Signs a client assertion with its own private key.
The delegation request rides inside that signature. + Client->>Mint: POST /token, assertion carrying on_behalf_of + Mint->>Mint: Verify the signature against the keys registered for scheduler + Mint->>Mint: Check the actor and the subject fields against that registration + Mint->>Mint: Durably append the keyed token-release audit record + Mint-->>Client: Access token with evidence_actor and identity.* claims + + Note over Client,Evidence: The person is named nowhere in the request below. + Client->>Evidence: POST /v1/evidence, requirement and purpose only + Evidence->>Evidence: Match the delegated authority profile + Evidence->>Evidence: Read the selector from the token (valueOrigin: authenticated-context) + Evidence->>Source: One fixed-authority lookup, for that person only + Source-->>Evidence: Registry record + Evidence-->>Client: Signed assertion: coarse region, opaque subject binding + + Note over Client,Evidence: The containment, with the same valid token. + Client->>Evidence: POST /v1/evidence carrying selector values for someone else + Evidence--xClient: 400 invalid_selector +``` + +Two properties are visible in the shape of that diagram. Nothing the client +sends after step 1 names a person, and the only arrow that reaches the registry +source is the one Evidence draws for the subject its own authority context +resolved. The final refusal is not a lookup that failed; it is a request that +was never allowed to describe anybody. + +## The four requests + +These are the requests the walkthrough sends, written as curl so they can be +read without running anything. `run.sh` stops its servers when it exits, so to +issue them by hand you would keep the deployment in `.run/` and start the four +processes yourself. + +### 1. The client assertion + +The client builds and signs this itself. Nothing between it and Mint can change +who the token is for. + +```json +{ + "iss": "scheduler", + "sub": "scheduler", + "aud": "https://localhost:8443/token", + "iat": 1785671511, + "exp": 1785671631, + "jti": "demo-1", + "on_behalf_of": { + "actor": "urn:example:demo:agent:appointment-scheduler", + "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} + } +} +``` + +`on_behalf_of` is Mint's own member, not RFC 8693 `act`. Token exchange presents +a subject's own credential, which is exactly what a deployment without an +identity provider does not have. + +### 2. The token request + +```bash +curl -sS --cacert crates/registry-mint/demo/.run/ca.pem https://localhost:8443/token \ + -d grant_type=client_credentials \ + -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ + --data-urlencode "client_assertion@/path/to/assertion.jwt" +``` + +Mint verifies the signature against the keys registered for `scheduler`, then +checks the delegation request against the same registration: + +```yaml +clientId: scheduler +principal: urn:example:demo:principal:scheduler +evidenceAudience: https://scheduler.demo.invalid +requesterTags: [demo-agent] +keys: [...] +delegation: + actors: [urn:example:demo:agent:appointment-scheduler] + subjectClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +``` + +That block is the whole authorization decision Mint makes: which agents this +client may act as, and which selector fields it may bind, at which claim paths. +Neither answer comes from the request. A client with no `delegation:` block +cannot obtain a delegated token at all. + +The resulting token: + +```json +{ + "iss": "https://localhost:8443", + "sub": "urn:example:demo:principal:scheduler", + "aud": "evidence.demo.invalid", + "client_id": "scheduler", + "evidence_tags": ["demo-agent"], + "evidence_audience": "https://scheduler.demo.invalid", + "evidence_actor": "urn:example:demo:agent:appointment-scheduler", + "identity": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, + "exp": 1785671811, "iat": 1785671511, "nbf": 1785671511, "jti": "01KZ..." +} +``` + +`evidence_actor` says who is acting. `identity.*` says who they are acting for. + +### 3. The evidence request + +```bash +curl -sS http://127.0.0.1:8080/v1/evidence \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "requestNonce": "<32 random bytes, base64url, unpadded>", + "requirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1"}}] + }' +``` + +Note what is not in that body: the person. `requestNonce` is a caller +correlation value, echoed into the assertion and kept away from authorization, +sources, and audit. The bundle says where the subject +comes from instead. + +```yaml +authorityProfiles: + delegated-agent-v1: + kind: delegated + requesterTags: [demo-agent] + grants: + - requirement: urn:example:demo:requirement:residence-region:v1 + purpose: demo-routing + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-v1 + valueOrigin: authenticated-context # <- the security property + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +``` + +Evidence answers with a signed assertion whose payload carries a coarse region +and an opaque subject binding. The person's name and their residence code are in +neither the request nor the answer: + +```json +{ + "supportsRequirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "audience": "https://scheduler.demo.invalid", + "subjects": [{"role": "subject", "binding": "urn:evidence:subject:v1_lARwiBg..."}], + "supportedValues": [ + {"providesValueFor": "urn:example:demo:concept:residence-region", "value": "REGION-NORTH"} + ] +} +``` + +### 4. The same token, pointed at somebody else + +```bash +curl -sS http://127.0.0.1:8080/v1/evidence \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "requestNonce": "<32 random bytes, base64url, unpadded>", + "requirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1", + "values": {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"}}}] + }' +``` + +```json +{"type": "https://registrystack.org/problems/evidence/invalid_selector", + "title": "Request is not valid", "status": 400, "code": "invalid_selector"} +``` + +There is no request this token can make about Kofi Mensah. The refusal is for +carrying selector values at all, not for carrying the wrong ones, so a client +bug that puts the wrong person in the body cannot reach that person. + +## What this does and does not defend against + +It defends against a **buggy** client. A client that loops over the wrong list, +reuses a request object, or confuses two sessions cannot cross from one person +to another, because the subject is not a request parameter it controls. + +It does not defend against a **compromised** client. A client holding its own +signing key can ask Mint for a token naming a different subject, within the +fields its registration permits. Closing that would mean Mint resolving the +subject from a server-side grant record rather than from the caller's request, +which is a larger change and is not what this builds. + +Two further limits worth stating plainly: + +- Evidence confines an *actor-bearing* token to `kind: delegated` authority + profiles, but it does not conversely require an actor to reach one. An + undelegated token therefore matches this grant and is stopped when the subject + cannot be resolved, rather than at the authority match. Nothing leaks either + way, but the two are not interchangeable: were this grant to gain a subject + role whose values come from the request, an undelegated token would reach it. +- The registry source still receives the person's identifying details. Data + minimization here is about what the *caller* learns, not about what the source + is asked. + +## Why the demonstration needs a certificate + +Evidence requires the token issuer and its key set to be HTTPS, with no +exception for loopback, and Mint expects TLS to be terminated upstream. Rather +than work around that, `run.sh` issues a throwaway CA and puts a small TLS +terminator in front of Mint, exactly where your ingress would sit. The CA is +trusted only by the demonstration's own Evidence process, through `SSL_CERT_FILE`. + +## The same property, as a test + +Steps 3 and 4 are also asserted in +[`tests/delegated_subject_binding.rs`](../tests/delegated_subject_binding.rs), +which loads this same bundle and runs Evidence's own authorization decision over +a token minted by the real Mint router. The demonstration and the test cannot +drift: they share `evidence-bundle/`, and the test's constants must match it. diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai new file mode 100644 index 000000000..691d7c164 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{ + given_name: subject["values"]["given_name"], + family_name: subject["values"]["family_name"], + birth_date: subject["values"]["birth_date"] + }, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai new file mode 100644 index 000000000..a2af00768 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai @@ -0,0 +1,13 @@ +fn extract(source_response, parameters) { + let total = source_response["total"]; + if total == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { return #{outcome: "ambiguous"}; } + let official_residence_code = get_path(source_response, "/official_residence_code"); + if is_missing(official_residence_code) { + return #{outcome: "match", facts: #{}}; + } + #{outcome: "match", facts: #{official_residence_code: official_residence_code}} +} diff --git a/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml b/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml new file mode 100644 index 000000000..e7bfbbf26 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml @@ -0,0 +1,6 @@ +id: urn:example:demo:codelist:region-map +version: '2026-01' +entries: + R-101: REGION-NORTH + R-201: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai b/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai new file mode 100644 index 000000000..ef3749040 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup( + evaluation_context.codelists["region-map"], + required(facts.official_residence_code, "required_fact_missing") + ); + [#{ + concept_id: "urn:example:demo:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + }] +} diff --git a/crates/registry-mint/demo/evidence-bundle/evidence.yaml b/crates/registry-mint/demo/evidence-bundle/evidence.yaml new file mode 100644 index 000000000..426ac9ffe --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/evidence.yaml @@ -0,0 +1,103 @@ +# Demonstration Evidence bundle for delegated, subject-bound access. +# +# The one line that matters is `valueOrigin: authenticated-context` under +# `delegated-agent-v1`. It moves the subject out of the request body and into +# the access token: Evidence reads the selector from the claims listed in +# `valueClaims`, and refuses any request that carries selector values of its +# own. A token minted for one person therefore cannot be pointed at another. +# +# Synthetic identifiers only. Nothing here describes a real person or source. +version: 1 +assuranceProfile: production +service: {providerId: urn:example:demo:provider:evidence, trustDomain: urn:example:demo:trust-domain:delegation} +issuer: {id: urn:example:demo:issuer:authority} +authentication: + kind: oidc-access-token + # Mint's public origin. Evidence requires HTTPS for both the issuer and the + # key set, so the demonstration puts a TLS terminator in front of Mint exactly + # as a real deployment would. + issuer: https://localhost:8443 + audiences: [evidence.demo.invalid] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://localhost:8443/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + # The claim Mint writes the delegated agent into. Its presence is what + # confines a token to `kind: delegated` authority profiles. + actorClaim: evidence_actor +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: demo-evidence-key, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +selectorProfiles: + demographics-v1: + maximumAggregateBytes: 420 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} +sources: + demo-source: + transport: http-json + # The stand-in registry source the demonstration runs on loopback. Evidence + # permits plain HTTP only for a numeric loopback host. + baseUrl: http://127.0.0.1:8092 + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: demographics-v1, fields: [given_name, family_name, birth_date]}] + prepareScript: adapters/demo-source-prepare.rhai + adapterParameters: {requestedFields: [official_residence_code], resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /official_residence_code] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml + extractScript: adapters/demo-source.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + delegated-agent-v1: + kind: delegated + requesterTags: [demo-agent] + grants: + - requirement: urn:example:demo:requirement:residence-region:v1 + purpose: demo-routing + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-v1 + valueOrigin: authenticated-context + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +requirements: + - id: urn:example:demo:requirement:residence-region:v1 + kind: information-requirement + source: demo-source + purposes: [demo-routing] + subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [demographics-v1]}] + referenceFrameworks: [urn:example:demo:framework:residence-region:v1] + evidenceType: urn:example:demo:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: {script: derivations/residence-region.rhai, parameters: {}} + concepts: + - id: urn:example:demo:concept:residence-region + form: controlled-code + required: true + constraints: {codelist: codelists/region-map.yaml, codelistVersion: '2026-01', maximumBytes: 32} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:demo:disclosure-family:residence-region]} + existenceDisclosure: collapse-unresolved diff --git a/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml b/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml new file mode 100644 index 000000000..75c140ba3 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml @@ -0,0 +1,31 @@ +fixture: registry.mint.demo.delegation/v1 +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selectors: + subject: + profile: demographics-v1 + values: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} + expectedRequestParts: + query: [] + body: + lookup: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} + fields: [official_residence_code] + limit: 2 + expectedTransport: + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - {id: positive, source: {total: 1, official_residence_code: R-101}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-unknown-code, source: {total: 1, official_residence_code: R-999}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-overly-precise-output, injected_derivation: [{concept_id: urn:example:demo:concept:residence-region, value: R-101}], expected: output-gate-rejection} + - {id: boundary-other-coarse-region, source: {total: 1, official_residence_code: R-201}, expected_value: REGION-SOUTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: http-503, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: anti-reconstruction, companion_bundle: geographic-overlap, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:demo:concept:residence-region, REGION-NORTH] + evidence_excludes: [official_residence_code, given_name, family_name, birth_date, R-101] + diagnostics_exclude: [Amara, Okafor, '1998-04-02', R-101, REGION-NORTH] diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..39fc57931 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [official_residence_code] + resultLimit: {const: 2} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml new file mode 100644 index 000000000..70d83308f --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/crates/registry-mint/demo/run.sh b/crates/registry-mint/demo/run.sh new file mode 100755 index 000000000..71de1e15b --- /dev/null +++ b/crates/registry-mint/demo/run.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Start a throwaway Mint and Evidence deployment on loopback, run the delegation +# walkthrough against them, then tear everything down. +# +# Nothing here is the demonstration. `walkthrough.py` is. +set -euo pipefail + +demo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +workspace="$(cd "${demo_dir}/../../.." && pwd)" +run_dir="${demo_dir}/.run" +log_dir="${run_dir}/logs" + +# The source's bearer token. Generated per run, exported to the two processes +# that need it, and never printed or passed as an argument. +DEMO_SOURCE_TOKEN="$(openssl rand -hex 24)" +export DEMO_SOURCE_TOKEN + +pids=() +cleanup() { + for pid in "${pids[@]:-}"; do + kill "${pid}" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_port() { + local port="$1" name="$2" + for _ in $(seq 1 100); do + if nc -z 127.0.0.1 "${port}" 2>/dev/null; then + return 0 + fi + sleep 0.1 + done + printf 'error: %s never listened on port %s\n' "${name}" "${port}" >&2 + printf 'see %s/%s.log\n' "${log_dir}" "${name}" >&2 + return 1 +} + +printf '== building mint and evidence\n' +cargo build --locked --manifest-path "${workspace}/Cargo.toml" \ + -p registry-mint -p registry-evidence --bins >/dev/null + +printf '== provisioning a throwaway deployment in %s\n' "${run_dir}" +uv run --quiet "${demo_dir}/support/provision.py" "${run_dir}" "${demo_dir}/evidence-bundle" \ + >/dev/null +mkdir -p "${log_dir}" + +printf '== starting the stand-in registry source, Mint, its TLS front, and Evidence\n' +uv run --quiet "${demo_dir}/support/mock_source.py" 8092 >"${log_dir}/source.log" 2>&1 & +pids+=("$!") + +"${workspace}/target/debug/mint" serve --config "${run_dir}/mint/mint.yaml" \ + >"${log_dir}/mint.log" 2>&1 & +pids+=("$!") +wait_for_port 8090 mint + +uv run --quiet "${demo_dir}/support/tls_front.py" 8443 8090 \ + "${run_dir}/tls.pem" "${run_dir}/tls.key" >"${log_dir}/tls.log" 2>&1 & +pids+=("$!") + +# Evidence fetches Mint's key set over HTTPS. SSL_CERT_FILE is how the demo's +# private CA becomes trusted for this process, and only this process. +SSL_CERT_FILE="${run_dir}/ca.pem" \ + "${workspace}/target/debug/evidence" --runtime "${run_dir}/evidence/runtime.yaml" serve \ + >"${log_dir}/evidence.log" 2>&1 & +pids+=("$!") + +wait_for_port 8092 source +wait_for_port 8443 tls +wait_for_port 8080 evidence + +printf '\n' +uv run --quiet "${demo_dir}/walkthrough.py" "${run_dir}" diff --git a/crates/registry-mint/demo/support/mock_source.py b/crates/registry-mint/demo/support/mock_source.py new file mode 100644 index 000000000..8dada5bd8 --- /dev/null +++ b/crates/registry-mint/demo/support/mock_source.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""A stand-in registry source for the delegation demonstration. + +Deployment plumbing, not part of the security story. Evidence has to call +*something* to answer a requirement; this is the smallest thing that answers. + +It serves one route, `POST /v1/facts`, and answers from a fixed synthetic table. +Everything it knows is invented. +""" + +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Synthetic people, synthetic residence codes. The bundle's codelist maps +# R-101 to REGION-NORTH and R-201 to REGION-SOUTH. +RECORDS = { + ("Amara", "Okafor", "1998-04-02"): "R-101", + ("Kofi", "Mensah", "1971-11-30"): "R-201", +} + +EXPECTED_BEARER = os.environ["DEMO_SOURCE_TOKEN"] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/v1/facts": + return self.reply(404, {"error": "not_found"}) + if self.headers.get("Authorization") != f"Bearer {EXPECTED_BEARER}": + return self.reply(401, {"error": "unauthorized"}) + + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + lookup = body.get("lookup", {}) + key = ( + lookup.get("given_name"), + lookup.get("family_name"), + lookup.get("birth_date"), + ) + + # Note what the source receives: the person's identifying details, and + # nothing about the requirement, the purpose, or the caller. + print(f"source <- lookup for {key[0]} {key[1]}", file=sys.stderr, flush=True) + + code = RECORDS.get(key) + if code is None: + return self.reply(200, {"total": 0}) + return self.reply(200, {"total": 1, "official_residence_code": code}) + + def reply(self, status, payload): + encoded = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format, *args): # noqa: A002 - the base class names it + pass # the one line printed in do_POST is the whole log we want + + +if __name__ == "__main__": + port = int(sys.argv[1]) + HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/crates/registry-mint/demo/support/provision.py b/crates/registry-mint/demo/support/provision.py new file mode 100644 index 000000000..88e147941 --- /dev/null +++ b/crates/registry-mint/demo/support/provision.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["cryptography>=42"] +# /// +"""Lay out a throwaway deployment of Mint and Evidence for the demonstration. + +Deployment plumbing, not part of the security story: keys, certificates, +configuration files, and file permissions. The walkthrough in `walkthrough.py` +is the part worth reading. + +Everything this writes is disposable and local. The keys are generated fresh on +every run and are worthless outside this directory. +""" + +import base64 +import datetime as dt +import json +import os +import secrets +import shutil +import stat +import sys +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.x509.oid import NameOID + +MINT_PORT = 8090 +TLS_PORT = 8443 +EVIDENCE_PORT = 8080 +SOURCE_PORT = 8092 + +MINT_ORIGIN = f"https://localhost:{TLS_PORT}" +AGENT = "urn:example:demo:agent:appointment-scheduler" + + +def b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def ed25519_jwk(kid: str) -> tuple[dict, dict]: + """Return (private JWK, public JWK) for a fresh Ed25519 key.""" + private = ed25519.Ed25519PrivateKey.generate() + x = b64( + private.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + ) + d = b64( + private.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + ) + public_jwk = {"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x} + return {**public_jwk, "d": d}, public_jwk + + +def write(path: Path, text: str, mode: int = 0o644) -> Path: + """Write a file everyone on the machine may read: certificates, configuration.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(mode) + return path + + +def write_secret(path: Path, text: str) -> Path: + """Write a file that is never wider than owner read/write, not even briefly. + + Creating the file and then narrowing it would leave a freshly generated + signing key readable by anyone on the machine for the length of the write. + `os.open` carries the mode into the creation; the `chmod` after it only + undoes the umask. + """ + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) + path.chmod(0o600) + return path + + +def issue_tls_certificate(root: Path) -> None: + """A private CA and one `localhost` server certificate. + + Evidence insists the token issuer and its key set be HTTPS, with no + exception for loopback. That is the right default and the demonstration + respects it rather than working around it. + """ + now = dt.datetime.now(dt.timezone.utc) + ca_key = ed25519.Ed25519PrivateKey.generate() + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "registry-stack demo CA")]) + ca_certificate = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, None) + ) + + server_key = ed25519.Ed25519PrivateKey.generate() + server_certificate = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])) + .issuer_name(ca_name) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .sign(ca_key, None) + ) + + pem = serialization.Encoding.PEM + write(root / "ca.pem", ca_certificate.public_bytes(pem).decode()) + write(root / "tls.pem", server_certificate.public_bytes(pem).decode()) + write_secret( + root / "tls.key", + server_key.private_bytes( + pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode(), + ) + + +def provision_mint(root: Path) -> None: + mint = root / "mint" + signing_private, _ = ed25519_jwk("mint-key-1") + write_secret(mint / "secrets/signing.jwk", json.dumps(signing_private)) + write_secret(mint / "secrets/audit-hmac-key", secrets.token_hex(32)) + + for client_id in ("scheduler", "service-desk"): + private, public = ed25519_jwk(f"{client_id}-key-1") + write_secret(root / f"client-keys/{client_id}.jwk", json.dumps(private)) + + # `scheduler` is the delegated caller. Its registration is the whole + # authorization decision Mint makes: which agents it may act as, and + # which selector fields it may bind, at which claim paths. + delegation = ( + "delegation:\n" + f" actors: [{AGENT}]\n" + " subjectClaims:\n" + " given_name: identity.given_name\n" + " family_name: identity.family_name\n" + " birth_date: identity.birth_date\n" + if client_id == "scheduler" + else "" + ) + write( + mint / f"clients/{client_id}.yaml", + f"clientId: {client_id}\n" + f"principal: urn:example:demo:principal:{client_id}\n" + f"evidenceAudience: https://{client_id}.demo.invalid\n" + "requesterTags: [demo-agent]\n" + f"keys: [{json.dumps(public)}]\n" + delegation, + ) + + write( + mint / "mint.yaml", + f"""version: 1 +issuer: {MINT_ORIGIN} +listener: {{address: 127.0.0.1, port: {MINT_PORT}}} +signing: + algorithm: EdDSA + activeKeyId: mint-key-1 + activeKeyFile: secrets/signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [evidence.demo.invalid] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: evidence_actor +clientAssertion: + audience: {MINT_ORIGIN}/token + algorithms: [EdDSA] +clients: + directory: clients +""", + ) + + +def provision_evidence(root: Path, bundle_source: Path) -> None: + evidence = root / "evidence" + + # Evidence refuses a bundle it could write to, so the copy is frozen. + bundle = evidence / "bundle" + shutil.copytree(bundle_source, bundle) + for path in sorted(bundle.rglob("*"), reverse=True): + path.chmod(0o555 if path.is_dir() else 0o444) + bundle.chmod(0o555) + + # `secretProviders.file.root` in the runtime file below. The name stays + # clear of the word "secret" because this is a directory path, written into + # a world-readable configuration file, and a scanner that reads names alone + # cannot tell it apart from the material inside it. + provider_root = evidence / "secrets" + provider_root.mkdir(parents=True, exist_ok=True) + provider_root.chmod(0o700) # Evidence refuses a group- or world-readable root + + signing_private, _ = ed25519_jwk("demo-evidence-key") + write_secret(provider_root / "signing-key", json.dumps(signing_private)) + write_secret(provider_root / "audit-hash-key", secrets.token_hex(32)) + write_secret(provider_root / "subject-binding-key", secrets.token_hex(32)) + write_secret(provider_root / "source-token", os.environ["DEMO_SOURCE_TOKEN"]) + + (evidence / "audit").mkdir(parents=True, exist_ok=True) + write( + evidence / "runtime.yaml", + f"""version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {EVIDENCE_PORT} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 5000 +secretProviders: + file: + root: {provider_root} +auditStorage: + path: {evidence / "audit/evidence.jsonl"} + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {{}} +""", + 0o444, # Evidence refuses a runtime file it could write to + ) + + +if __name__ == "__main__": + root = Path(sys.argv[1]).resolve() + bundle_source = Path(sys.argv[2]).resolve() + if root.exists(): + # Frozen directories need their write bit back before removal. + for path in sorted(root.rglob("*"), reverse=True): + path.chmod(path.stat().st_mode | stat.S_IWUSR) + shutil.rmtree(root) + root.mkdir(parents=True) + + issue_tls_certificate(root) + provision_mint(root) + provision_evidence(root, bundle_source) + print(f"provisioned {root}") diff --git a/crates/registry-mint/demo/support/test_provision.py b/crates/registry-mint/demo/support/test_provision.py new file mode 100644 index 000000000..d4cbf5f2a --- /dev/null +++ b/crates/registry-mint/demo/support/test_provision.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Tests for the demonstration's provisioning plumbing. + +Only the part that matters outside the demonstration: a private key or a bearer +token must not exist on disk readable by anyone else, not even for the moment +between creating the file and narrowing it. + +Run with `uv run --with cryptography --no-project python -m unittest +crates/registry-mint/demo/support/test_provision.py`; the tests skip where +`cryptography` is unavailable. +""" + +import importlib.util +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path + +SUPPORT = Path(__file__).resolve().parent + + +def load_module(): + specification = importlib.util.spec_from_file_location( + "demo_provision", SUPPORT / "provision.py" + ) + module = importlib.util.module_from_spec(specification) + try: + specification.loader.exec_module(module) + except ImportError as error: # pragma: no cover - depends on the environment + raise unittest.SkipTest(f"provision.py needs {error.name}") from None + return module + + +try: + provision = load_module() +except unittest.SkipTest: # pragma: no cover - depends on the environment + provision = None + + +@unittest.skipIf(provision is None, "cryptography is not installed") +class SecretFileModeTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + previous = os.umask(0) + self.addCleanup(os.umask, previous) + + def test_a_secret_is_never_wider_than_owner_read_write(self): + path = provision.write_secret(self.root / "signing.jwk", "not-a-real-key") + + self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) + self.assertEqual("not-a-real-key", path.read_text()) + + def test_the_file_is_created_at_its_final_mode_not_narrowed_afterwards(self): + modes = [] + real_open = os.open + + def spy(path, flags, mode, **rest): + modes.append(mode) + return real_open(path, flags, mode, **rest) + + os.open = spy + self.addCleanup(setattr, os, "open", real_open) + provision.write_secret(self.root / "audit-hash-key", "not-a-real-key") + + self.assertEqual([0o600], modes) + + def test_ordinary_files_keep_their_readable_mode(self): + path = provision.write(self.root / "ca.pem", "not-a-real-certificate") + + self.assertEqual(0o644, stat.S_IMODE(path.stat().st_mode)) + + +if __name__ == "__main__": + sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-mint/demo/support/test_tls_front.py b/crates/registry-mint/demo/support/test_tls_front.py new file mode 100644 index 000000000..26b7e49da --- /dev/null +++ b/crates/registry-mint/demo/support/test_tls_front.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Tests for the demonstration's TLS front. + +The front is deployment plumbing, but it is plumbing in a public repository, so +the two properties worth pinning are the ones a real ingress would be judged on: +it forwards only the routes the deployment declares, and it never relays a +header it cannot write safely. +""" + +import importlib.util +import shutil +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import ThreadingHTTPServer +from pathlib import Path + +SUPPORT = Path(__file__).resolve().parent + + +def load_module(): + specification = importlib.util.spec_from_file_location( + "demo_tls_front", SUPPORT / "tls_front.py" + ) + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +tls_front = load_module() + + +class RouteAllowlistTests(unittest.TestCase): + def test_declared_routes_forward_to_a_constant_path(self): + self.assertEqual("/token", tls_front.route_for("POST", "/token")) + self.assertEqual( + "/.well-known/jwks.json", + tls_front.route_for("GET", "/.well-known/jwks.json"), + ) + + def test_every_forwarded_path_is_one_this_file_declares(self): + for method in ("GET", "POST", "PUT"): + for path in ("/token", "/.well-known/jwks.json", "/anything"): + forwarded = tls_front.route_for(method, path) + if forwarded is not None: + self.assertIn((method, forwarded), tls_front.ROUTES) + + def test_undeclared_routes_are_refused(self): + for method, path in ( + ("GET", "/token"), # right path, wrong method + ("POST", "/.well-known/jwks.json"), + ("GET", "/health"), + ("GET", "/"), + ("GET", "/token/../admin"), + ("GET", "http://elsewhere.invalid/token"), # absolute-form request line + ("GET", "//elsewhere.invalid/token"), + ("GET", "/.well-known/jwks.json?x=1"), + ): + with self.subTest(method=method, path=path): + self.assertIsNone(tls_front.route_for(method, path)) + + +class HeaderValidationTests(unittest.TestCase): + def test_ordinary_headers_are_well_formed(self): + self.assertTrue(tls_front.well_formed("Content-Type", "application/json")) + + def test_control_characters_are_rejected_in_name_or_value(self): + for name, value in ( + ("X-Demo", "ok\r\nInjected: yes"), + ("X-Demo", "ok\nInjected: yes"), + ("X-Demo", "ok\r"), + ("X-Demo", "ok\x00"), + ("X-Demo\r\nInjected", "ok"), + ("X-Demo\n", "ok"), + ): + with self.subTest(name=name, value=value): + self.assertFalse(tls_front.well_formed(name, value)) + + +class TlsContextTests(unittest.TestCase): + def test_the_listener_refuses_anything_below_tls_1_2(self): + certificate, key = write_self_signed() + context = tls_front.tls_context(certificate, key) + self.assertEqual(ssl.TLSVersion.TLSv1_2, context.minimum_version) + + +class ForwardingTests(unittest.TestCase): + """End to end over real sockets, with a stub standing in for Mint.""" + + def setUp(self): + self.upstream = StubUpstream() + self.upstream.start() + self.addCleanup(self.upstream.stop) + + tls_front.UPSTREAM_PORT = self.upstream.port + self.front = ThreadingHTTPServer(("127.0.0.1", 0), tls_front.Handler) + threading.Thread(target=self.front.serve_forever, daemon=True).start() + self.addCleanup(self.front.server_close) # cleanups run last-registered first + self.addCleanup(self.front.shutdown) + self.port = self.front.server_address[1] + + def request(self, raw: bytes) -> bytes: + # `Connection: close` so the read below ends at end of message rather + # than waiting out the keep-alive. + raw = raw[: -len(b"\r\n\r\n")] + b"\r\nConnection: close\r\n\r\n" + with socket.create_connection(("127.0.0.1", self.port), timeout=5) as client: + client.sendall(raw) + chunks = [] + while True: + chunk = client.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + def test_a_declared_route_round_trips(self): + self.upstream.reply = ( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Content-Length: 2\r\n\r\n{}" + ) + response = self.request( + b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + self.assertIn(b"200 OK", response) + self.assertIn(b"{}", response) + self.assertEqual(["/.well-known/jwks.json"], self.upstream.seen) + + def test_an_undeclared_route_never_reaches_the_upstream(self): + response = self.request(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + self.assertIn(b"404", response) + self.assertEqual([], self.upstream.seen) + + def test_a_request_header_carrying_a_control_character_is_refused(self): + response = self.request( + b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n" + b"X-Demo: first\r\n\tsecond\r\n\r\n" + ) + self.assertIn(b"400", response) + self.assertEqual([], self.upstream.seen) + + def test_an_upstream_header_that_cannot_be_written_safely_is_not_relayed(self): + # An obs-folded header: `http.client` hands this back with the newline + # still in the value, and writing it out verbatim would split the + # response. + self.upstream.reply = ( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n" + b"X-Demo: first\r\n\tsecond\r\n\r\n{}" + ) + response = self.request( + b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + self.assertIn(b"502", response) + self.assertNotIn(b"second", response) + + +class StubUpstream: + """A raw socket server so a test can send bytes `http.server` would refuse.""" + + def __init__(self): + self.reply = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" + self.seen: list[str] = [] + self.socket = socket.socket() + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind(("127.0.0.1", 0)) + self.socket.listen(8) + self.port = self.socket.getsockname()[1] + self.running = True + + def start(self): + threading.Thread(target=self.serve, daemon=True).start() + + def serve(self): + while self.running: + try: + connection, _ = self.socket.accept() + except OSError: + return + with connection: + connection.settimeout(5) + try: + request = connection.recv(65536) + except OSError: + continue + if not request: + continue + self.seen.append(request.split(b" ")[1].decode()) + connection.sendall(self.reply) + + def stop(self): + self.running = False + self.socket.close() + + +def write_self_signed() -> tuple[str, str]: + """A throwaway certificate and key, via `openssl`. + + The demonstration's own provisioning uses `cryptography`, but nothing in + this repository's continuous integration installs it, and a test that skips + is a test that does not hold. `openssl` is already what `run.sh` reaches for + and is present wherever this suite runs. + """ + openssl = shutil.which("openssl") + if openssl is None: # pragma: no cover - depends on the environment + raise unittest.SkipTest("openssl is not installed") + + directory = Path(tempfile.mkdtemp()) + certificate_path = directory / "tls.pem" + key_path = directory / "tls.key" + subprocess.run( + [ + openssl, "req", "-x509", "-newkey", "ed25519", "-noenc", + "-days", "1", "-subj", "/CN=localhost", + "-keyout", str(key_path), "-out", str(certificate_path), + ], + check=True, + capture_output=True, + ) + return str(certificate_path), str(key_path) + + +if __name__ == "__main__": + sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-mint/demo/support/tls_front.py b/crates/registry-mint/demo/support/tls_front.py new file mode 100644 index 000000000..242d7a74e --- /dev/null +++ b/crates/registry-mint/demo/support/tls_front.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""A TLS terminator in front of Mint, for the delegation demonstration. + +Deployment plumbing, not part of the security story. Mint speaks plain HTTP and +expects an operator-controlled TLS front, and Evidence refuses a non-HTTPS token +issuer, so the demonstration supplies one. In production this is your ingress. + +It forwards to Mint on loopback and adds nothing. What it does do is what an +ingress is expected to do: publish only the routes the deployment declares, and +refuse anything it cannot pass on without changing the shape of a message. +""" + +import http.client +import json +import ssl +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# The only routes this deployment puts through the front: Mint's key set, which +# Evidence fetches, and its token endpoint, which the client posts to. Mint's +# other routes stay on loopback. +ROUTES = ( + ("GET", "/.well-known/jwks.json"), + ("POST", "/token"), +) + +# Hop-by-hop headers belong to one connection and are not relayed onto the next. +NOT_RELAYED = ("transfer-encoding", "connection", "content-length") + +# A header carrying one of these would end the header block early, so the front +# refuses the message rather than passing on something it cannot write intact. +CONTROL_CHARACTERS = ("\r", "\n", "\x00") + +UPSTREAM_PORT = None + + +def route_for(method: str, path: str) -> str | None: + """The upstream path for a request, or `None` if the front does not serve it. + + The value returned is one of this file's own literals, never the caller's + request line: the target of the upstream request is fixed here and cannot be + steered from outside. + """ + for allowed_method, allowed_path in ROUTES: + if method == allowed_method and path == allowed_path: + return allowed_path + return None + + +def well_formed(name: str, value: str) -> bool: + """Whether a header can be relayed without changing the message's framing.""" + return not any( + character in name or character in value for character in CONTROL_CHARACTERS + ) + + +def tls_context(certificate: str, key: str) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # An ingress sets its own floor rather than inheriting whatever the runtime + # happens to allow. + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(certificate, key) + return context + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self.forward("GET", None) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + self.forward("POST", self.rfile.read(length)) + + def forward(self, method, body): + target = route_for(method, self.path) + if target is None: + self.refuse(404, "no such route") + return + + headers = { + name: value + for name, value in self.headers.items() + if name.lower() not in ("host", "connection") + } + if not all(well_formed(name, value) for name, value in headers.items()): + self.refuse(400, "request header carried a control character") + return + + upstream = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=10) + try: + upstream.request(method, target, body=body, headers=headers) + response = upstream.getresponse() + payload = response.read() + status = response.status + relayed = [ + (name, value) + for name, value in response.getheaders() + if name.lower() not in NOT_RELAYED + ] + finally: + upstream.close() + + if not all(well_formed(name, value) for name, value in relayed): + self.refuse(502, "upstream header carried a control character") + return + + self.send_response(status) + for name, value in relayed: + self.send_header(name, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def refuse(self, status, reason): + payload = json.dumps({"error": reason}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): # noqa: A002 - the base class names it + pass + + +if __name__ == "__main__": + listen_port, UPSTREAM_PORT, certificate, key = ( + int(sys.argv[1]), + int(sys.argv[2]), + sys.argv[3], + sys.argv[4], + ) + server = ThreadingHTTPServer(("127.0.0.1", listen_port), Handler) + server.socket = tls_context(certificate, key).wrap_socket( + server.socket, server_side=True + ) + server.serve_forever() diff --git a/crates/registry-mint/demo/walkthrough.py b/crates/registry-mint/demo/walkthrough.py new file mode 100644 index 000000000..81ea29b85 --- /dev/null +++ b/crates/registry-mint/demo/walkthrough.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["cryptography>=42", "requests>=2.31"] +# /// +"""Delegated, subject-bound access, end to end. + +An agent needs to know which region one person lives in. It must not be able to +learn that about anyone else, even if the agent's own code is wrong. + + 1. The client signs a request for a token, naming the agent it is acting as + and the person it is acting for. + 2. Mint checks that request against the client's registration and issues a + token carrying both. + 3. The client asks Evidence for evidence, and does not name the person. + 4. The client tries to name a different person, and cannot. + +Two further steps show the refusals that hold that shape up: what Mint will not +issue, and what a token without a delegation cannot reach. + +Every request below is printed before it is sent. Run it with: + + crates/registry-mint/demo/run.sh +""" + +import base64 +import json +import secrets +import sys +from pathlib import Path + +import requests +from cryptography.hazmat.primitives.asymmetric import ed25519 + +MINT = "https://localhost:8443" +EVIDENCE = "http://127.0.0.1:8080" + +REQUIREMENT = "urn:example:demo:requirement:residence-region:v1" +PURPOSE = "demo-routing" +AGENT = "urn:example:demo:agent:appointment-scheduler" + +AMARA = {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} +KOFI = {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"} + + +# -------------------------------------------------------------------------- +# Step 1: the client assertion. +# +# The client authenticates to Mint with a JWT it signs with its own key +# (RFC 7523 `private_key_jwt`). There is no shared secret to leak, and Mint +# holds only public keys. +# +# The delegation request rides *inside* that JWT, in `on_behalf_of`. That +# placement is the point: the actor and the subject are covered by the client's +# signature, so nothing between the client and Mint can alter who the token is +# for. +# -------------------------------------------------------------------------- + + +def build_client_assertion(client_id, private_key, jti, on_behalf_of=None): + claims = { + "iss": client_id, + "sub": client_id, + "aud": f"{MINT}/token", + "iat": now(), + "exp": now() + 120, + "jti": jti, # Mint refuses a second assertion with the same jti + } + if on_behalf_of is not None: + claims["on_behalf_of"] = on_behalf_of + + announce("the client assertion the client is about to sign", claims) + return sign_jwt({"alg": "EdDSA", "typ": "JWT", "kid": private_key["kid"]}, claims, + private_key) + + +# -------------------------------------------------------------------------- +# Step 2: the token request. +# +# Mint verifies the signature against the keys registered for this client, then +# checks the delegation request against the same registration: is this an actor +# the client may act as, and are these exactly the selector fields it may bind? +# Neither answer comes from the request. +# -------------------------------------------------------------------------- + + +def request_token(assertion): + form = { + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + } + announce(f"POST {MINT}/token", {**form, "client_assertion": ""}) + return requests.post(f"{MINT}/token", data=form, verify=CA, timeout=10) + + +# -------------------------------------------------------------------------- +# Step 3: the evidence request. +# +# Note what is *not* in this body: the person. The bundle declares the subject's +# `valueOrigin` as `authenticated-context`, so Evidence reads the selector out +# of the token's claims and refuses to read it from the request. +# -------------------------------------------------------------------------- + + +def request_evidence(token, subject_values=None): + selector = {"profile": "demographics-v1"} + if subject_values is not None: + selector["values"] = subject_values + body = { + # A caller-generated correlation value. Evidence echoes it into the + # assertion and keeps it away from authorization, sources, and audit. + "requestNonce": request_nonce(), + "requirement": REQUIREMENT, + "purpose": PURPOSE, + "subjects": [{"role": "subject", "selector": selector}], + } + announce(f"POST {EVIDENCE}/v1/evidence", body, header="Authorization: Bearer ") + return requests.post( + f"{EVIDENCE}/v1/evidence", + json=body, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + + +def main(run_dir): + global CA + CA = str(run_dir / "ca.pem") + scheduler = load_jwk(run_dir / "client-keys/scheduler.jwk") + service_desk = load_jwk(run_dir / "client-keys/service-desk.jwk") + + heading("1. The client asks Mint for a token to act for one person") + assertion = build_client_assertion( + "scheduler", + scheduler, + "demo-1", + on_behalf_of={"actor": AGENT, "subject": AMARA}, + ) + response = request_token(assertion) + expect(response, 200) + token = response.json()["access_token"] + + heading("2. What Mint put in the token") + claims = decode_jwt_claims(token) + show(claims) + note( + "`evidence_actor` says who is acting. `identity.*` says who they are acting for.", + "Both were checked against the client's registration, not taken on trust.", + ) + + heading("3. The client asks Evidence for evidence, naming no one") + response = request_evidence(token) + expect(response, 200) + show(decode_evidence(response.json())) + note( + "Evidence resolved the subject from the token, called the source, and", + "returned a coarse region. The person's name and their residence code", + "are in neither the request nor the answer. The subject appears only as", + "an opaque binding that cannot be reversed into a name.", + ) + + heading("4. The same token, pointed at somebody else") + response = request_evidence(token, subject_values=KOFI) + expect(response, 400) + show(response.json()) + note( + "This is the containment. A bug in the client that puts the wrong person", + "in the request body does not reach that person: Evidence refuses the", + "request for carrying selector values at all, not for carrying the wrong", + "ones. There is no request this token can make about Kofi Mensah.", + ) + + heading("5. Mint refuses what it was not asked to allow") + for jti, description, client_id, key, on_behalf_of in ( + ( + "demo-wrong-actor", + "an actor this client may not act as", + "scheduler", + scheduler, + {"actor": "urn:example:demo:agent:someone-else", "subject": AMARA}, + ), + ( + "demo-undelegated", + "a client with no delegation in its registration", + "service-desk", + service_desk, + {"actor": AGENT, "subject": AMARA}, + ), + ( + "demo-extra-field", + "a subject carrying a field the registration does not bind", + "scheduler", + scheduler, + {"actor": AGENT, "subject": {**AMARA, "national_id": "synthetic-1"}}, + ), + ): + response = request_token( + build_client_assertion(client_id, key, jti, on_behalf_of=on_behalf_of) + ) + expect(response, 401) + print(f" refused: {description} -> {response.status_code} {response.json()}\n") + + heading("6. And an undelegated token cannot use the delegated grant") + response = request_token(build_client_assertion("service-desk", service_desk, "demo-plain")) + expect(response, 200) + plain_token = response.json()["access_token"] + note( + "This token is valid and carries the same requester tag. It simply has no", + "`evidence_actor` and no `identity.*`, so there is no subject to resolve.", + ) + response = request_evidence(plain_token) + expect(response, 400) + show(response.json()) + note( + "Worth being precise about the shape of this refusal. Evidence confines", + "an actor-bearing token to `kind: delegated` authority profiles, but it", + "does not require an actor to reach one. So this token matches the grant", + "and is stopped when the subject cannot be resolved, rather than at the", + "authority match. Nothing leaks either way.", + ) + + print("\nAll six steps behaved as described.") + + +# -------------------------------------------------------------------------- +# Below here is only formatting and JWT mechanics. Nothing decides anything. +# -------------------------------------------------------------------------- + + +def now(): + import time + + return int(time.time()) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def request_nonce() -> str: + """32 random bytes, base64url without padding. Evidence rejects anything else.""" + return b64url(secrets.token_bytes(32)) + + +def unb64url(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def load_jwk(path: Path) -> dict: + return json.loads(path.read_text()) + + +def sign_jwt(header: dict, claims: dict, private_jwk: dict) -> str: + key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"])) + signing_input = ".".join( + b64url(json.dumps(part, separators=(",", ":")).encode()) for part in (header, claims) + ) + return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" + + +def decode_jwt_claims(token: str) -> dict: + """Read the claims without verifying. Evidence verifies; this only shows.""" + return json.loads(unb64url(token.split(".")[1])) + + +def decode_evidence(assertion: dict) -> dict: + """Show the signed evidence assertion's payload rather than its base64. + + A verifier would check the signature over `protected` and `payload` against + Evidence's published key set. This only makes the answer legible. + """ + return { + "protected": json.loads(unb64url(assertion["protected"])), + "payload": json.loads(unb64url(assertion["payload"])), + "signature": assertion["signature"][:16] + "...", + } + + +def heading(text): + print(f"\n{'=' * 76}\n{text}\n{'=' * 76}") + + +def announce(what, payload, header=None): + print(f"\n {what}") + if header: + print(f" {header}") + for line in json.dumps(payload, indent=2).splitlines(): + print(f" {line}") + print() + + +def show(payload): + for line in json.dumps(payload, indent=2).splitlines(): + print(f" {line}") + + +def note(*lines): + print() + for line in lines: + print(f" -> {line}") + + +def expect(response, status): + if response.status_code != status: + print(f"\nunexpected {response.status_code}: {response.text}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main(Path(sys.argv[1]).resolve()) diff --git a/crates/registry-mint/src/assertion.rs b/crates/registry-mint/src/assertion.rs new file mode 100644 index 000000000..6ed81be25 --- /dev/null +++ b/crates/registry-mint/src/assertion.rs @@ -0,0 +1,1053 @@ +//! RFC 7523 `private_key_jwt` client authentication. +//! +//! The single most important property in this module is that an assertion is +//! verified against **only the keys registered for the client it claims to be**. +//! +//! The alternative, pooling every client key into one JWK set, is what makes +//! distributing signing keys unsafe: key selection happens by `kid`, which the +//! signer chooses, and nothing downstream re-checks which key was used against +//! the claims that were signed. In a pooled set, client A signs with A's key, +//! writes `iss: client-b`, and verification succeeds. Selecting the key set by +//! the asserted client id *before* verifying removes that move entirely: A's +//! key simply is not in B's set, so the signature fails. +//! +//! Everything else here is bounding: strict structural preflight, an audience +//! bound to this endpoint, a bounded assertion lifetime, and single-use `jti`. + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_canonical_json::parse_json_strict; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::Value; + +use crate::{ + clients::{ClientRegistry, Delegation, RegisteredClient}, + config::ClientAssertionConfig, + error::TokenError, + replay::{ReplayCache, ReplayError}, + ON_BEHALF_OF_CLAIM, +}; + +/// Bounds chosen so a hostile caller cannot make Mint allocate before any +/// signature has been checked. +const MAX_ASSERTION_BYTES: usize = 16 * 1024; +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_CLAIMS_BYTES: usize = 8 * 1024; +const MAX_CLIENT_ID_BYTES: usize = 256; +const MAX_JTI_BYTES: usize = 256; +/// Evidence rejects an actor longer than this, and a selector value longer than +/// this could not satisfy any selector profile. +const MAX_DELEGATION_VALUE_BYTES: usize = 512; + +/// Tolerance for clock difference between a caller and Mint. Applied to the +/// assertion's own `exp` and `nbf`, not to the tokens Mint issues. +const CLOCK_SKEW_SECONDS: i64 = 30; + +/// `JWT` is the conventional RFC 7523 assertion type. The explicit type is +/// accepted too, for callers that prefer unambiguous typing. +const ALLOWED_ASSERTION_TYP: [&str; 2] = ["JWT", "client-assertion+jwt"]; + +/// The parsed but *unverified* surface of an assertion, used only to decide +/// which client's keys to verify against. +struct AssertionPreflight { + claims: Value, +} + +/// A delegation request that the registry permits, ready to be minted. +/// +/// Every value here came from the signed assertion and was then checked against +/// the client's registration: the actor against its permitted set, and the +/// subject against the exact selector fields it declared. Nothing unbounded or +/// undeclared survives into this type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedDelegation { + actor: String, + /// Selector field to its value, keyed exactly as the registration declared. + subject: BTreeMap, +} + +impl ResolvedDelegation { + /// Crate-internal because the type's meaning is that its contents already + /// passed [`build_delegation`]; nothing outside this crate may assert one. + pub(crate) fn new(actor: String, subject: BTreeMap) -> Self { + Self { actor, subject } + } + + #[must_use] + pub fn actor(&self) -> &str { + &self.actor + } + + #[must_use] + pub fn subject(&self) -> &BTreeMap { + &self.subject + } +} + +/// An authenticated client, and the delegation it authenticated for. +#[derive(Clone, Debug)] +pub struct AuthenticatedClient { + pub client: Arc, + pub delegation: Option, +} + +/// Authenticates client assertions against a registry snapshot. +/// +/// One verifier is built per registered client at construction time, each bound +/// to that client's own static JWK set. +pub struct ClientAuthenticator { + registry: Arc, + verifiers: BTreeMap>, + maximum_lifetime_seconds: i64, + replay: Arc, +} + +impl std::fmt::Debug for ClientAuthenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClientAuthenticator") + .field("clients", &self.verifiers.len()) + .field("maximum_lifetime_seconds", &self.maximum_lifetime_seconds) + .finish_non_exhaustive() + } +} + +impl ClientAuthenticator { + /// Build one verifier per registered client. + /// + /// The `replay` cache is passed in rather than created here so that + /// reloading the registry never forgets spent assertion identifiers. + #[must_use] + pub fn new( + registry: Arc, + config: &ClientAssertionConfig, + replay: Arc, + ) -> Self { + let algorithms = config + .algorithms + .iter() + .map(|algorithm| algorithm.as_jsonwebtoken()) + .collect::>(); + let allowed_typ = ALLOWED_ASSERTION_TYP.map(ToOwned::to_owned).to_vec(); + + let mut verifiers = BTreeMap::new(); + for client_id in registry.client_ids() { + let client = registry + .get(client_id) + .expect("client id came from this registry"); + // The static set holds this client's public keys and nothing else. + let fetcher = Arc::new(JwksFetcher::new_static( + client.jwks().clone(), + JwksFetcherConfig::defaults(), + )); + let verifier_config = TokenVerifierConfig::access_token_profile( + // An assertion issues from the client itself. + client_id.to_owned(), + vec![config.audience.clone()], + algorithms.clone(), + allowed_typ.clone(), + ) + .with_leeway(Duration::from_secs(CLOCK_SKEW_SECONDS.unsigned_abs())); + verifiers.insert( + client_id.to_owned(), + Arc::new(TokenVerifier::new(verifier_config, fetcher)), + ); + } + + Self { + registry, + verifiers, + maximum_lifetime_seconds: config.maximum_lifetime_seconds as i64, + replay, + } + } + + #[must_use] + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// Authenticate a client assertion and return the client it proves. + /// + /// The returned client is the registry entry, which is where all authority + /// is read from. The one thing carried forward from the assertion payload + /// is the delegation request, and only after the registry has confirmed + /// that this client may delegate, to that actor, over exactly those subject + /// fields. + pub async fn authenticate( + &self, + assertion: &str, + now: i64, + ) -> Result { + let preflight = preflight(assertion)?; + let client_id = asserted_client_id(&preflight.claims)?; + + // Selecting the key set before verifying is the whole point: an + // unknown client never reaches a signature check, and a known one is + // checked against its own keys only. + let client = self + .registry + .get(client_id) + .ok_or_else(|| TokenError::invalid_client("unknown client"))?; + let verifier = self + .verifiers + .get(client_id) + .ok_or_else(|| TokenError::server_error("registry and verifiers disagree"))?; + + let verified = verifier + .verify(assertion) + .await + .map_err(|_| TokenError::invalid_client("assertion signature or claims rejected"))?; + + // RFC 7523 section 3: for client authentication the subject is the + // client itself. Without this an assertion could name a different + // subject while still being signed by a legitimate client key. + let subject = verified + .claims + .sub + .as_deref() + .ok_or_else(|| TokenError::invalid_client("assertion has no subject"))?; + if subject != client_id { + return Err(TokenError::invalid_client( + "assertion subject does not match its issuer", + )); + } + + let issued_at = verified + .claims + .iat + .ok_or_else(|| TokenError::invalid_client("assertion has no issued-at"))?; + let expires_at = verified + .claims + .exp + .ok_or_else(|| TokenError::invalid_client("assertion has no expiry"))?; + // A long-lived assertion is a long-lived bearer credential. Bound it + // regardless of what the caller chose. + if expires_at <= issued_at + || expires_at.saturating_sub(issued_at) > self.maximum_lifetime_seconds + { + return Err(TokenError::invalid_client( + "assertion lifetime exceeds the configured maximum", + )); + } + // The verifier also checks expiry, but against its own read of the + // system clock. Freshness, the replay window, and the audit record must + // agree on one instant, so they are all decided against `now`. + if expires_at.saturating_add(CLOCK_SKEW_SECONDS) <= now { + return Err(TokenError::invalid_client("assertion has expired")); + } + if issued_at.saturating_sub(CLOCK_SKEW_SECONDS) > now { + return Err(TokenError::invalid_client("assertion is not yet issued")); + } + + // Resolved before the assertion is spent so a rejected delegation does + // not burn the caller's jti. + let delegation = resolve_delegation(client, &verified.claims.extra)?; + + let jti = verified + .claims + .extra + .get("jti") + .and_then(Value::as_str) + .ok_or_else(|| TokenError::invalid_client("assertion has no jti"))?; + if jti.is_empty() || jti.len() > MAX_JTI_BYTES { + return Err(TokenError::invalid_client("assertion jti is not bounded")); + } + // Namespaced by client so two clients choosing the same jti do not + // lock each other out. + let replay_key = format!("{client_id}\u{0}{jti}"); + // Remembered past `exp` by the same skew the freshness check tolerates. + // Forgetting it at `exp` would leave a window in which the assertion is + // still accepted but no longer recorded as spent. + self.replay + .remember( + &replay_key, + expires_at.saturating_add(CLOCK_SKEW_SECONDS), + now, + ) + .map_err(|error| match error { + ReplayError::AlreadyUsed => TokenError::invalid_client("assertion already used"), + ReplayError::Saturated => TokenError::server_error("replay cache saturated"), + ReplayError::Poisoned => TokenError::server_error("replay cache poisoned"), + })?; + + Ok(AuthenticatedClient { + client: Arc::clone(client), + delegation, + }) + } +} + +/// Reconcile the delegation the assertion asks for with the one the registry +/// permits. +/// +/// Both directions fail closed. A client with no registered delegation cannot +/// obtain an actor or a bound subject by asking for one, and a client that *is* +/// registered for delegation cannot obtain an ordinary unbounded token by +/// omitting the request. The second half is what stops a delegated caller +/// quietly widening its own reach. +fn resolve_delegation( + client: &RegisteredClient, + claims: &serde_json::Map, +) -> Result, TokenError> { + let requested = claims.get(ON_BEHALF_OF_CLAIM); + match (client.delegation(), requested) { + (None, None) => Ok(None), + (None, Some(_)) => Err(TokenError::invalid_client( + "client is not registered to act on behalf of a subject", + )), + (Some(_), None) => Err(TokenError::invalid_client( + "a delegated client must name the actor and subject it acts for", + )), + (Some(registered), Some(requested)) => Ok(Some(build_delegation(registered, requested)?)), + } +} + +fn build_delegation( + registered: &Delegation, + requested: &Value, +) -> Result { + let invalid = |reason: &'static str| TokenError::invalid_client(reason); + + let requested = requested + .as_object() + .ok_or_else(|| invalid("the delegation request is malformed"))?; + // An unrecognized member would be silently dropped, leaving the caller + // believing it constrained something it did not. + if requested.len() != 2 || !requested.contains_key("actor") { + return Err(invalid("the delegation request is malformed")); + } + + let actor = requested + .get("actor") + .and_then(Value::as_str) + .ok_or_else(|| invalid("the delegation request is malformed"))?; + if actor.trim().is_empty() || actor.len() > MAX_DELEGATION_VALUE_BYTES { + return Err(invalid("the delegated actor is not bounded")); + } + if !registered.permits_actor(actor) { + return Err(invalid("the client may not act as this actor")); + } + + let subject = requested + .get("subject") + .and_then(Value::as_object) + .ok_or_else(|| invalid("the delegation request is malformed"))?; + // Exactly the declared fields: a missing one would leave the resource + // server unable to resolve the subject, and an extra one would be minted + // nowhere while looking to the caller as though it had been honoured. + if subject.len() != registered.subject_claims.len() { + return Err(invalid( + "the delegated subject does not match its registration", + )); + } + let mut resolved = BTreeMap::new(); + for field in registered.subject_claims.keys() { + let value = subject + .get(field) + .ok_or_else(|| invalid("the delegated subject does not match its registration"))?; + resolved.insert(field.clone(), bounded_selector_value(value)?); + } + + Ok(ResolvedDelegation::new(actor.to_owned(), resolved)) +} + +/// The value shapes a resource server can read back out as a selector value. +fn bounded_selector_value(value: &Value) -> Result { + match value { + Value::String(text) if !text.is_empty() && text.len() <= MAX_DELEGATION_VALUE_BYTES => { + Ok(value.clone()) + } + Value::Bool(_) => Ok(value.clone()), + // Only integers survive a JSON round trip into a selector value. + Value::Number(number) if number.is_i64() => Ok(value.clone()), + _ => Err(TokenError::invalid_client( + "a delegated subject value is not a bounded string, integer, or boolean", + )), + } +} + +/// Structural validation performed before any allocation-heavy or +/// cryptographic work, mirroring the strictness Evidence applies to bearer +/// tokens. +fn preflight(assertion: &str) -> Result { + let malformed = || TokenError::invalid_client("assertion is malformed"); + + if assertion.is_empty() || assertion.len() > MAX_ASSERTION_BYTES { + return Err(malformed()); + } + let segments = assertion.split('.').collect::>(); + if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { + return Err(malformed()); + } + + let header = decode_segment(segments[0], MAX_HEADER_BYTES)?; + if !header.is_object() { + return Err(malformed()); + } + let claims = decode_segment(segments[1], MAX_CLAIMS_BYTES)?; + if !claims.is_object() { + return Err(malformed()); + } + // A present but undecodable or empty signature is malformed regardless of + // what the verifier would later say about it. + let signature = URL_SAFE_NO_PAD + .decode(segments[2]) + .map_err(|_| malformed())?; + if signature.is_empty() { + return Err(malformed()); + } + + Ok(AssertionPreflight { claims }) +} + +/// Decode one base64url segment into strictly parsed JSON. +/// +/// `parse_json_strict` rejects duplicate members, so a header or claim set that +/// says one thing to a lenient parser and another to a strict one cannot get +/// past this point. +fn decode_segment(segment: &str, maximum_bytes: usize) -> Result { + let malformed = || TokenError::invalid_client("assertion is malformed"); + if segment.len() > maximum_bytes { + return Err(malformed()); + } + let bytes = URL_SAFE_NO_PAD.decode(segment).map_err(|_| malformed())?; + if bytes.len() > maximum_bytes { + return Err(malformed()); + } + parse_json_strict(&bytes).map_err(|_| malformed()) +} + +fn asserted_client_id(claims: &Value) -> Result<&str, TokenError> { + let issuer = claims + .get("iss") + .and_then(Value::as_str) + .ok_or_else(|| TokenError::invalid_client("assertion has no issuer"))?; + if issuer.is_empty() || issuer.len() > MAX_CLIENT_ID_BYTES { + return Err(TokenError::invalid_client( + "assertion issuer is not bounded", + )); + } + Ok(issuer) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::config::Algorithm; + use serde_json::json; + + // Deterministic per-seed Ed25519 keys so tests can hold several distinct + // client identities at once. + pub(crate) fn test_key(seed: u8) -> (registry_platform_crypto::PrivateJwk, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let private = registry_platform_crypto::PrivateJwk::parse( + &json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}) + .to_string(), + ) + .expect("test private JWK parses"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + (private, public) + } + + fn sign_assertion( + private: ®istry_platform_crypto::PrivateJwk, + typ: &str, + claims: &Value, + ) -> String { + let kid = private.kid.clone().expect("test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": typ, "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), private) + .expect("test key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) + } + + const AUDIENCE: &str = "https://mint.example.org/token"; + const NOW: i64 = 1_800_000_000; + + fn assertion_claims(client_id: &str, jti: &str) -> Value { + json!({ + "iss": client_id, + "sub": client_id, + "aud": AUDIENCE, + "iat": NOW, + "exp": NOW + 120, + "jti": jti, + }) + } + + fn registry_with(clients: &[(&str, &Value)]) -> Arc { + registry_of( + &clients + .iter() + .map(|(id, key)| (*id, *key, "")) + .collect::>(), + ) + } + + /// Each entry is a client id, its public key, and any extra registration + /// lines (a `delegation:` block, in these tests). + fn registry_of(clients: &[(&str, &Value, &str)]) -> Arc { + let directory = tempfile::tempdir().expect("temp dir"); + for (client_id, public, extra) in clients { + let document = format!( + "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [tag-{client_id}]\nkeys: [{public}]\n{extra}" + ); + std::fs::write(directory.path().join(format!("{client_id}.yaml")), document) + .expect("write client registration"); + } + Arc::new(ClientRegistry::load(directory.path()).expect("registry loads")) + } + + fn authenticator(registry: Arc) -> ClientAuthenticator { + let config = ClientAssertionConfig { + audience: AUDIENCE.to_owned(), + maximum_lifetime_seconds: 300, + algorithms: vec![Algorithm::EdDSA], + replay_cache_entries: 256, + }; + ClientAuthenticator::new(registry, &config, Arc::new(ReplayCache::new(256))) + } + + #[tokio::test] + async fn a_valid_assertion_authenticates_its_client() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + let authenticated = authenticator + .authenticate(&assertion, NOW) + .await + .expect("valid assertion authenticates"); + assert_eq!(authenticated.client.client_id(), "client-a"); + assert_eq!(authenticated.client.principal(), "urn:example:client-a"); + assert_eq!(authenticated.delegation, None); + } + + /// The core security property. Client A holds a real, registered key. It + /// cannot use that key to speak as client B. + #[tokio::test] + async fn one_clients_key_cannot_sign_an_assertion_for_another_client() { + let (private_a, public_a) = test_key(1); + let (_private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + // A signs an assertion that claims to be B. + let forged = sign_assertion(&private_a, "JWT", &assertion_claims("client-b", "jti-1")); + + let error = authenticator + .authenticate(&forged, NOW) + .await + .expect_err("a forged assertion must be rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion signature or claims rejected") + ); + } + + /// Even naming its own kid does not help: the kid is looked up inside the + /// asserted client's key set, where A's key does not exist. + #[tokio::test] + async fn naming_a_foreign_kid_does_not_reach_another_clients_key_set() { + let (private_a, public_a) = test_key(1); + let (_private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + let mut claims = assertion_claims("client-b", "jti-1"); + claims["sub"] = json!("client-b"); + let forged = sign_assertion(&private_a, "JWT", &claims); + assert!(authenticator.authenticate(&forged, NOW).await.is_err()); + } + + #[tokio::test] + async fn an_unknown_client_is_rejected_before_any_signature_check() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-z", "jti-1")); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("unknown clients are rejected"); + assert_eq!(error, TokenError::invalid_client("unknown client")); + } + + #[tokio::test] + async fn the_subject_must_equal_the_issuer() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims["sub"] = json!("someone-else"); + let assertion = sign_assertion(&private, "JWT", &claims); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a mismatched subject is rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion subject does not match its issuer") + ); + } + + #[tokio::test] + async fn an_assertion_is_single_use() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a replayed assertion is rejected"); + assert_eq!(error, TokenError::invalid_client("assertion already used")); + } + + /// Freshness tolerates clock skew, so an assertion stays acceptable for a + /// short window past its own `exp`. The replay record has to outlive that + /// window: if it expired first, a captured assertion would become + /// replayable exactly as it was about to stop being useful. + #[tokio::test] + async fn an_assertion_stays_single_use_for_as_long_as_it_stays_acceptable() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + + // One second past `exp`, still inside the accepted skew window. + let error = authenticator + .authenticate(&assertion, NOW + 121) + .await + .expect_err("a replayed assertion is rejected while it is still accepted"); + assert_eq!(error, TokenError::invalid_client("assertion already used")); + + // Past the window the assertion is refused on freshness instead, so the + // replay record has no further work to do. + let error = authenticator + .authenticate(&assertion, NOW + 151) + .await + .expect_err("an assertion past the skew window is refused"); + assert_eq!(error, TokenError::invalid_client("assertion has expired")); + } + + #[tokio::test] + async fn two_clients_may_use_the_same_jti_value() { + let (private_a, public_a) = test_key(1); + let (private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + let from_a = sign_assertion(&private_a, "JWT", &assertion_claims("client-a", "shared")); + let from_b = sign_assertion(&private_b, "JWT", &assertion_claims("client-b", "shared")); + assert!(authenticator.authenticate(&from_a, NOW).await.is_ok()); + assert!(authenticator.authenticate(&from_b, NOW).await.is_ok()); + } + + #[tokio::test] + async fn an_assertion_for_another_audience_is_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims["aud"] = json!("https://another-service.example.org/token"); + let assertion = sign_assertion(&private, "JWT", &claims); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); + } + + #[tokio::test] + async fn expired_and_over_long_assertions_are_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + + let mut expired = assertion_claims("client-a", "jti-1"); + expired["iat"] = json!(NOW - 400); + expired["exp"] = json!(NOW - 300); + let assertion = sign_assertion(&private, "JWT", &expired); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an expired assertion is rejected"), + TokenError::invalid_client("assertion has expired") + ); + + let mut ahead = assertion_claims("client-a", "jti-3"); + ahead["iat"] = json!(NOW + 400); + ahead["exp"] = json!(NOW + 500); + let assertion = sign_assertion(&private, "JWT", &ahead); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an assertion issued in the future is rejected"), + TokenError::invalid_client("assertion is not yet issued") + ); + + let mut over_long = assertion_claims("client-a", "jti-2"); + over_long["exp"] = json!(NOW + 4_000); + let assertion = sign_assertion(&private, "JWT", &over_long); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an over-long assertion is rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion lifetime exceeds the configured maximum") + ); + } + + #[tokio::test] + async fn an_assertion_without_a_jti_is_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims.as_object_mut().expect("claims object").remove("jti"); + let assertion = sign_assertion(&private, "JWT", &claims); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a jti is required"); + assert_eq!(error, TokenError::invalid_client("assertion has no jti")); + } + + #[tokio::test] + async fn an_unsigned_or_malformed_assertion_never_reaches_the_registry() { + let authenticator = authenticator(registry_with(&[("client-a", &test_key(1).1)])); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT","kid":"key-1"}"#); + let claims = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&assertion_claims("client-a", "jti-1")).expect("claims")); + + for candidate in [ + String::new(), + "not-a-jwt".to_owned(), + "a.b".to_owned(), + "a.b.c.d".to_owned(), + format!("{header}.{claims}."), + format!("{header}..x"), + format!("{header}.{claims}.!!!"), + ] { + let error = authenticator + .authenticate(&candidate, NOW) + .await + .expect_err("malformed assertions are rejected"); + assert_eq!(error, TokenError::invalid_client("assertion is malformed")); + } + } + + #[tokio::test] + async fn duplicate_json_members_are_rejected_by_the_strict_preflight() { + let (private, _public) = test_key(1); + let public = test_key(1).1; + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + + // Two `iss` members: a lenient parser would take one, a strict parser + // refuses to guess. + let raw_claims = format!( + r#"{{"iss":"client-a","iss":"client-b","sub":"client-a","aud":"{AUDIENCE}","iat":{NOW},"exp":{},"jti":"jti-1"}}"#, + NOW + 120 + ); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT","kid":"key-1"}"#); + let claims = URL_SAFE_NO_PAD.encode(raw_claims.as_bytes()); + let signing_input = format!("{header}.{claims}"); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), &private) + .expect("test key signs"); + let assertion = format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("duplicate members are rejected"); + assert_eq!(error, TokenError::invalid_client("assertion is malformed")); + } + + const DELEGATION: &str = "delegation:\n actors: [urn:example:agent-one, urn:example:agent-two]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + + fn on_behalf_of(jti: &str, request: Value) -> Value { + let mut claims = assertion_claims("client-a", jti); + claims[ON_BEHALF_OF_CLAIM] = request; + claims + } + + fn subject_request() -> Value { + json!({ + "actor": "urn:example:agent-one", + "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, + }) + } + + /// A delegated assertion carries the actor and the subject; both survive + /// only because the registration named them. + #[tokio::test] + async fn a_delegated_assertion_resolves_the_actor_and_subject_it_names() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + + let authenticated = authenticator + .authenticate(&assertion, NOW) + .await + .expect("a permitted delegation authenticates"); + let delegation = authenticated.delegation.expect("delegation resolved"); + assert_eq!(delegation.actor(), "urn:example:agent-one"); + assert_eq!( + delegation.subject(), + &BTreeMap::from([ + ("birth_date".to_owned(), json!("1998-04-02")), + ("given_name".to_owned(), json!("Amara")), + ]) + ); + } + + /// Asking is not enough. Delegation is a property of the registration. + #[tokio::test] + async fn a_client_with_no_registered_delegation_cannot_ask_for_one() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an undelegated client is refused"); + assert_eq!( + error, + TokenError::invalid_client("client is not registered to act on behalf of a subject") + ); + } + + /// The other direction, and the one that is easy to miss: if omitting the + /// request produced an ordinary token, a delegated caller could widen its + /// own reach from one subject to every subject by leaving out a claim. + #[tokio::test] + async fn a_delegated_client_cannot_widen_itself_by_omitting_the_request() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a delegated client must name its subject"); + assert_eq!( + error, + TokenError::invalid_client( + "a delegated client must name the actor and subject it acts for" + ) + ); + } + + #[tokio::test] + async fn an_actor_outside_the_registered_set_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let mut request = subject_request(); + request["actor"] = json!("urn:example:agent-three"); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an unregistered actor is refused"); + assert_eq!( + error, + TokenError::invalid_client("the client may not act as this actor") + ); + } + + /// Without an `actors` list the client names its own actor, so the actor is + /// an audit label rather than a bound. The subject binding is unaffected. + #[tokio::test] + async fn an_open_actor_list_still_binds_the_subject() { + let (private, public) = test_key(1); + let open = "delegation:\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + let authenticator = authenticator(registry_of(&[("client-a", &public, open)])); + let mut request = subject_request(); + request["actor"] = json!("urn:example:anything"); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); + + let authenticated = authenticator + .authenticate(&assertion, NOW) + .await + .expect("any actor is permitted"); + let delegation = authenticated.delegation.expect("delegation resolved"); + assert_eq!(delegation.actor(), "urn:example:anything"); + assert_eq!(delegation.subject().len(), 2); + } + + /// A missing field would leave the resource server unable to resolve the + /// subject; an extra one would be minted nowhere while looking to the caller + /// as though it had been honoured. + #[tokio::test] + async fn the_subject_must_carry_exactly_the_registered_fields() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut missing = subject_request(); + missing["subject"] = json!({"given_name": "Amara"}); + let mut extra = subject_request(); + extra["subject"] = json!({ + "given_name": "Amara", + "birth_date": "1998-04-02", + "national_id": "some-identifier", + }); + let mut renamed = subject_request(); + renamed["subject"] = json!({"given_name": "Amara", "family_name": "Okafor"}); + + for (index, request) in [missing, extra, renamed].into_iter().enumerate() { + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a mismatched subject is refused"); + assert_eq!( + error, + TokenError::invalid_client("the delegated subject does not match its registration") + ); + } + } + + /// Only the shapes a resource server can read back out as a selector value. + #[tokio::test] + async fn a_subject_value_that_is_not_a_selector_value_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + for (index, value) in [ + json!(null), + json!(""), + json!(1.5), + json!(["Amara"]), + json!({"value": "Amara"}), + json!("x".repeat(513)), + ] + .into_iter() + .enumerate() + { + let mut request = subject_request(); + request["subject"] = json!({"given_name": value, "birth_date": "1998-04-02"}); + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an unusable subject value is refused"); + assert_eq!( + error, + TokenError::invalid_client( + "a delegated subject value is not a bounded string, integer, or boolean" + ) + ); + } + + // Integers and booleans are selector values, so they are accepted. + let mut numeric = subject_request(); + numeric["subject"] = json!({"given_name": 42, "birth_date": true}); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-ok", numeric)); + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + } + + /// A malformed request must not cost the caller its `jti`: the delegation is + /// reconciled before the assertion is spent, so correcting the request and + /// retrying works. + #[tokio::test] + async fn a_refused_delegation_does_not_spend_the_assertion() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut wrong = subject_request(); + wrong["actor"] = json!("urn:example:agent-three"); + let refused = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", wrong)); + assert!(authenticator.authenticate(&refused, NOW).await.is_err()); + + // Same jti, corrected request. + let corrected = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + assert!(authenticator.authenticate(&corrected, NOW).await.is_ok()); + } + + #[tokio::test] + async fn a_structurally_malformed_delegation_request_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut unknown_member = subject_request(); + unknown_member["scope"] = json!("everything"); + let mut no_subject = subject_request(); + no_subject + .as_object_mut() + .expect("request object") + .remove("subject"); + + for (index, request) in [ + json!("urn:example:agent-one"), + json!([{"actor": "urn:example:agent-one"}]), + json!({"subject": {"given_name": "Amara", "birth_date": "1998-04-02"}}), + json!({"actor": "urn:example:agent-one", "subject": "Amara"}), + unknown_member, + no_subject, + ] + .into_iter() + .enumerate() + { + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a malformed delegation request is refused"); + assert_eq!( + error, + TokenError::invalid_client("the delegation request is malformed") + ); + } + + let mut blank_actor = subject_request(); + blank_actor["actor"] = json!(" "); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-blank", blank_actor)); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a blank actor is refused"), + TokenError::invalid_client("the delegated actor is not bounded") + ); + } + + #[tokio::test] + async fn an_access_token_type_is_not_accepted_as_a_client_assertion() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "at+jwt", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); + } +} diff --git a/crates/registry-mint/src/audit.rs b/crates/registry-mint/src/audit.rs new file mode 100644 index 000000000..cdf1ea842 --- /dev/null +++ b/crates/registry-mint/src/audit.rs @@ -0,0 +1,374 @@ +//! Fail-closed Mint audit over one durable, segmented keyed JSONL chain. + +use registry_platform_audit::{ + verify_segmented_audit_chain, AuditChainHasher, AuditEnvelope, AuditError, AuditHashSecret, + AuditKeyHasher, ChainState, DurableSegmentedJsonlSink, +}; +use registry_platform_canonical_json::canonicalize_json; +use serde::Serialize; +use thiserror::Error; + +use crate::{ + assertion::AuthenticatedClient, + config::AuditConfig, + secretfile::{self, SecretFileError}, + token::MintedToken, +}; + +const AUDIT_SCHEMA: &str = "registry.mint.audit/v1"; + +#[derive(Debug, Error)] +pub enum MintAuditError { + #[error("the audit hash key could not be read")] + Secret(#[source] SecretFileError), + #[error("the audit chain could not be initialized or written")] + Audit(#[from] AuditError), + #[error("an audit-safe reference could not be constructed")] + Reference, + #[error( + "sealed segment {sequence} is archived or missing from the chain; this is not corruption" + )] + SegmentMissing { sequence: u64 }, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AuditPhase { + TokenRelease, + Denial, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AuditDecision { + Issued, + Rejected, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MintAuditEvent { + schema: &'static str, + operation: String, + phase: AuditPhase, + decision: AuditDecision, + #[serde(skip_serializing_if = "Option::is_none")] + client_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + authority_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actor_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + subject_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + token_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signing_key_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expires_at_unix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + delegated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + safe_error_category: Option, +} + +/// Minimal operator report returned by `mint verify-audit`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MintAuditSummary { + pub segments: usize, + pub records: usize, + pub last_hash: Option<[u8; 32]>, + pub first_sequence: Option, + pub last_sequence: Option, + pub active_verified: bool, +} + +/// Process-lifetime Mint audit boundary. +pub struct MintAuditLog { + sink: DurableSegmentedJsonlSink, + chain: ChainState, + key_hasher: AuditKeyHasher, + key_version: u32, + scope: String, +} + +impl std::fmt::Debug for MintAuditLog { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MintAuditLog") + .field("path", &self.sink.path()) + .field("key_version", &self.key_version) + .finish_non_exhaustive() + } +} + +impl MintAuditLog { + pub async fn initialize(config: &AuditConfig, issuer: &str) -> Result { + let secret = + secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; + let secret = AuditHashSecret::new(secret.as_bytes().to_vec())?; + let chain_hasher = AuditChainHasher::keyed(secret.clone()); + let key_hasher = AuditKeyHasher::Keyed(secret); + let sink = DurableSegmentedJsonlSink::open(config.path.clone(), config.maximum_file_bytes)?; + let chain = ChainState::bootstrap_or_start_empty(&sink, chain_hasher).await?; + Ok(Self { + sink, + chain, + key_hasher, + key_version: config.hash_key_version, + scope: issuer.to_owned(), + }) + } + + /// Check the audit configuration without taking the serving writer lock. + /// + /// `mint check` reads a configuration while the deployment it describes is + /// usually still serving, and the chain admits one writer for the life of + /// that process. Opening the sink here would report a healthy deployment + /// back as a broken one. The hash key is what a misconfigured deployment + /// actually gets wrong, and reading it takes nothing the writer holds. + pub fn check(config: &AuditConfig) -> Result<(), MintAuditError> { + let secret = + secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; + AuditHashSecret::new(secret.as_bytes().to_vec())?; + Ok(()) + } + + /// Verify the retained chain without taking the serving writer lock. + pub fn verify(config: &AuditConfig) -> Result { + let secret = + secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; + let secret = AuditHashSecret::new(secret.as_bytes().to_vec())?; + let summary = verify_segmented_audit_chain(&config.path, &AuditChainHasher::keyed(secret)) + .map_err(|error| match error { + AuditError::SegmentMissing { sequence } => { + MintAuditError::SegmentMissing { sequence } + } + error => MintAuditError::Audit(error), + })?; + Ok(MintAuditSummary { + segments: summary.segments, + records: summary.records, + last_hash: summary.last_hash, + first_sequence: summary.first_sequence, + last_sequence: summary.last_sequence, + active_verified: summary.active_verified, + }) + } + + pub async fn append_issued( + &self, + operation: &str, + authenticated: &AuthenticatedClient, + token: &MintedToken, + ) -> Result { + let client_pseudonym = self.pseudonym("client", authenticated.client.client_id())?; + let grant = authenticated + .client + .grant() + .map(|grant| serde_json::json!({"id": grant.id, "authority": grant.authority})); + let authority = serde_json::json!({ + "principal": authenticated.client.principal(), + "evidenceAudience": authenticated.client.evidence_audience(), + "requesterTags": authenticated.client.requester_tags(), + "grant": grant, + }); + let authority = canonicalize_json(&authority).map_err(|_| MintAuditError::Reference)?; + let authority = String::from_utf8(authority).map_err(|_| MintAuditError::Reference)?; + let authority_pseudonym = self.pseudonym("authority", &authority)?; + let (actor_pseudonym, subject_pseudonym) = match &authenticated.delegation { + Some(delegation) => { + let actor = self.pseudonym("actor", delegation.actor())?; + let subject = serde_json::to_value(delegation.subject()) + .map_err(|_| MintAuditError::Reference)?; + let subject = canonicalize_json(&subject).map_err(|_| MintAuditError::Reference)?; + let subject = String::from_utf8(subject).map_err(|_| MintAuditError::Reference)?; + (Some(actor), Some(self.pseudonym("subject", &subject)?)) + } + None => (None, None), + }; + self.append(MintAuditEvent { + schema: AUDIT_SCHEMA, + operation: operation.to_owned(), + phase: AuditPhase::TokenRelease, + decision: AuditDecision::Issued, + client_pseudonym: Some(client_pseudonym), + authority_pseudonym: Some(authority_pseudonym), + actor_pseudonym, + subject_pseudonym, + token_id: Some(token.token_id().to_owned()), + signing_key_id: Some(token.signing_key_id().to_owned()), + expires_at_unix: Some(token.expires_at_unix()), + delegated: Some(authenticated.delegation.is_some()), + safe_error_category: None, + }) + .await + } + + pub async fn append_rejected( + &self, + operation: &str, + safe_error_category: &str, + ) -> Result { + self.append(MintAuditEvent { + schema: AUDIT_SCHEMA, + operation: operation.to_owned(), + phase: AuditPhase::Denial, + decision: AuditDecision::Rejected, + client_pseudonym: None, + authority_pseudonym: None, + actor_pseudonym: None, + subject_pseudonym: None, + token_id: None, + signing_key_id: None, + expires_at_unix: None, + delegated: None, + safe_error_category: Some(safe_error_category.to_owned()), + }) + .await + } + + #[must_use] + pub async fn ready(&self) -> bool { + self.chain.try_last_hash().is_some() && self.sink.ready().await + } + + fn pseudonym(&self, class: &str, protected: &str) -> Result { + if protected.is_empty() { + return Err(MintAuditError::Reference); + } + let digest = self + .key_hasher + .audit_reference_hash(class, &self.scope, protected) + .map_err(|_| MintAuditError::Reference)?; + Ok(format!("hmac-sha256:v{}:{digest}", self.key_version)) + } + + async fn append(&self, event: MintAuditEvent) -> Result { + self.chain + .append(&self.sink, event) + .await + .map_err(MintAuditError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt}; + + // A fixed, non-secret audit HMAC key. Held as a byte literal rather than + // written inline so a secret scanner does not read the write call as an + // assignment of a live credential. + const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + + fn fixture() -> (tempfile::TempDir, AuditConfig) { + let directory = tempfile::tempdir().expect("temp dir"); + let secret = directory.path().join("audit-key"); + fs::write(&secret, AUDIT_HASH_KEY).expect("write audit key"); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).expect("restrict key"); + let config = AuditConfig { + path: directory.path().join("audit/mint.jsonl"), + maximum_file_bytes: 1_048_576, + hash_key_file: secret, + hash_key_version: 1, + }; + (directory, config) + } + + #[tokio::test] + async fn a_keyed_chain_restarts_and_verifies() { + let (_directory, config) = fixture(); + { + let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("audit initializes"); + audit + .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") + .await + .expect("first decision is durable"); + } + { + let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("audit restarts"); + audit + .append_rejected("urn:ulid:01K00000000000000000000001", "invalid-request") + .await + .expect("second decision is durable"); + } + let summary = MintAuditLog::verify(&config).expect("chain verifies"); + assert_eq!(summary.segments, 1); + assert_eq!(summary.records, 2); + assert!(summary.last_hash.is_some()); + assert!(summary.active_verified); + } + + #[tokio::test] + async fn a_second_writer_is_refused() { + let (_directory, config) = fixture(); + let first = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("first writer initializes"); + let second = MintAuditLog::initialize(&config, "https://mint.example.org").await; + assert!(second.is_err(), "a second writer must not fork the chain"); + drop(first); + } + + #[tokio::test] + async fn corruption_is_refused_at_restart_and_verification() { + let (_directory, config) = fixture(); + { + let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("audit initializes"); + audit + .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") + .await + .expect("decision is durable"); + } + let mut contents = fs::read_to_string(&config.path).expect("read chain"); + contents = contents.replace("invalid-client", "invalid-request"); + fs::write(&config.path, contents).expect("tamper with chain"); + assert!(MintAuditLog::verify(&config).is_err()); + assert!( + MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .is_err() + ); + } + + #[tokio::test] + async fn rotation_seals_history_without_breaking_restart_or_verification() { + let (_directory, mut config) = fixture(); + config.maximum_file_bytes = 550; + { + let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("audit initializes"); + for index in 0..8 { + audit + .append_rejected( + &format!("urn:ulid:01K0000000000000000000000{index}"), + "invalid-client", + ) + .await + .expect("decision is durable"); + } + } + let first_segment = config.path.with_extension("jsonl.00000001"); + assert!(first_segment.exists(), "rotation seals the active segment"); + let summary = MintAuditLog::verify(&config).expect("segmented chain verifies"); + assert_eq!(summary.records, 8); + assert!(summary.segments > 1); + assert_eq!(summary.first_sequence, Some(1)); + + let restarted = MintAuditLog::initialize(&config, "https://mint.example.org") + .await + .expect("audit restarts from the segmented tail"); + restarted + .append_rejected("urn:ulid:01K00000000000000000000009", "invalid-request") + .await + .expect("post-restart decision is durable"); + } +} diff --git a/crates/registry-mint/src/caller.rs b/crates/registry-mint/src/caller.rs new file mode 100644 index 000000000..7a18ddff4 --- /dev/null +++ b/crates/registry-mint/src/caller.rs @@ -0,0 +1,332 @@ +//! The caller's half of the protocol: building a client assertion. +//! +//! This is what a client does, not what Mint does. It holds no server state, +//! reads no server configuration, and touches no signing key of Mint's. It +//! signs with the *caller's* own private key, exactly as an adopter's client +//! library would, and produces an assertion that the token endpoint then +//! verifies on its own terms. +//! +//! That separation is deliberate and worth stating plainly, because the obvious +//! alternative is a subcommand that signs an access token directly with Mint's +//! signing key. That would be a way to obtain authority without authenticating, +//! inside the binary whose entire purpose is to make authority depend on +//! authentication. There is no such path here and there should never be one. +//! +//! Getting an assertion right by hand is fiddly (exact claims, a fresh `jti`, a +//! lifetime inside the configured bound) and getting it wrong yields an opaque +//! `invalid_client`. A first-party builder removes that guesswork and doubles as +//! executable documentation of the format. + +use std::collections::BTreeMap; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{PrivateJwk, SigningAlgorithm}; +use serde_json::{json, Map, Value}; + +use crate::ON_BEHALF_OF_CLAIM; + +/// Refusals that happen before anything is signed. +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub enum AssertionError { + #[error("{0}")] + Invalid(&'static str), + #[error("the assertion could not be signed: {0}")] + Signing(String), +} + +/// What a caller is asking for. +/// +/// `actor` and `subject` are the delegation request. Mint requires them +/// together or not at all, and refuses a registered delegated client that omits +/// them, so this refuses the halfway states here rather than spending a network +/// round trip to be told. +#[derive(Debug)] +pub struct AssertionRequest<'a> { + pub client_id: &'a str, + /// The token endpoint's configured `clientAssertion.audience`. + pub audience: &'a str, + pub lifetime_seconds: i64, + pub actor: Option<&'a str>, + pub subject: Option>, +} + +/// Build and sign one client assertion. +/// +/// `now` is a Unix timestamp, passed in rather than read so that the claim +/// arithmetic is testable. +pub fn sign_client_assertion( + key: &PrivateJwk, + request: &AssertionRequest<'_>, + now: i64, +) -> Result { + if request.client_id.trim().is_empty() { + return Err(AssertionError::Invalid("a client id is required")); + } + if request.audience.trim().is_empty() { + return Err(AssertionError::Invalid("an assertion audience is required")); + } + // Mint bounds the assertion lifetime and applies 30 seconds of clock skew + // either way; anything outside this is a caller error, not a policy choice. + if !(1..=300).contains(&request.lifetime_seconds) { + return Err(AssertionError::Invalid( + "the assertion lifetime must be 1..=300 seconds", + )); + } + + let mut claims = Map::new(); + claims.insert("iss".to_owned(), json!(request.client_id)); + claims.insert("sub".to_owned(), json!(request.client_id)); + claims.insert("aud".to_owned(), json!(request.audience)); + claims.insert("iat".to_owned(), json!(now)); + claims.insert("exp".to_owned(), json!(now + request.lifetime_seconds)); + // Every assertion is single use. A caller-chosen `jti` would make a repeat + // an accident waiting to happen, so it is generated here and never reused. + claims.insert("jti".to_owned(), json!(ulid::Ulid::new().to_string())); + + match (request.actor, &request.subject) { + (None, None) => {} + (Some(actor), Some(subject)) => { + if actor.trim().is_empty() { + return Err(AssertionError::Invalid("the actor must not be empty")); + } + if subject.is_empty() { + return Err(AssertionError::Invalid( + "the subject must name at least one selector field", + )); + } + claims.insert( + ON_BEHALF_OF_CLAIM.to_owned(), + json!({"actor": actor, "subject": subject}), + ); + } + _ => { + return Err(AssertionError::Invalid( + "a delegation needs both an actor and a subject", + )) + } + } + + let algorithm = match key + .algorithm() + .map_err(|error| AssertionError::Signing(error.to_string()))? + { + SigningAlgorithm::EdDsa => "EdDSA", + SigningAlgorithm::Es256 => "ES256", + SigningAlgorithm::Rs256 => "RS256", + }; + let header = json!({ + "alg": algorithm, + "typ": "JWT", + "kid": key + .kid + .as_deref() + .ok_or(AssertionError::Invalid("the signing key needs a kid"))?, + }); + + let encode = |value: &Value| -> Result { + serde_json::to_vec(value) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|error| AssertionError::Signing(error.to_string())) + }; + let signing_input = format!("{}.{}", encode(&header)?, encode(&Value::Object(claims))?); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), key) + .map_err(|error| AssertionError::Signing(error.to_string()))?; + Ok(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_800_000_000; + + fn key() -> PrivateJwk { + let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + PrivateJwk::parse( + &json!({ + "kty": "OKP", + "crv": "Ed25519", + "kid": "caller-key-1", + "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing.to_bytes()), + }) + .to_string(), + ) + .expect("the test key parses") + } + + fn request<'a>(client_id: &'a str, audience: &'a str) -> AssertionRequest<'a> { + AssertionRequest { + client_id, + audience, + lifetime_seconds: 120, + actor: None, + subject: None, + } + } + + fn claims_of(assertion: &str) -> Value { + let payload = assertion.split('.').nth(1).expect("a payload segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).expect("base64url")) + .expect("claims parse") + } + + #[test] + fn an_assertion_carries_exactly_what_the_token_endpoint_requires() { + let assertion = sign_client_assertion( + &key(), + &request("scheduler", "https://mint.example.org/token"), + NOW, + ) + .expect("the assertion signs"); + + let claims = claims_of(&assertion); + assert_eq!(claims["iss"], json!("scheduler")); + assert_eq!(claims["sub"], json!("scheduler")); + assert_eq!(claims["aud"], json!("https://mint.example.org/token")); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!(claims["exp"], json!(NOW + 120)); + assert!(claims.get(ON_BEHALF_OF_CLAIM).is_none()); + + let header: Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(assertion.split('.').next().expect("a header segment")) + .expect("base64url"), + ) + .expect("header parses"); + assert_eq!(header["alg"], json!("EdDSA")); + assert_eq!(header["typ"], json!("JWT")); + assert_eq!(header["kid"], json!("caller-key-1")); + } + + /// Reusing a `jti` is refused by Mint as a replay, so the builder must never + /// produce the same one twice even when called with an identical request. + #[test] + fn every_assertion_gets_its_own_jti() { + let key = key(); + let request = request("scheduler", "https://mint.example.org/token"); + let first = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); + let second = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); + + assert_ne!(first["jti"], second["jti"]); + assert!(first["jti"].as_str().is_some_and(|jti| !jti.is_empty())); + } + + #[test] + fn a_delegation_request_rides_inside_the_signed_claims() { + let subject = BTreeMap::from([ + ("given_name".to_owned(), json!("Amara")), + ("birth_date".to_owned(), json!("1998-04-02")), + ]); + let assertion = sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + subject: Some(subject), + ..request("scheduler", "https://mint.example.org/token") + }, + NOW, + ) + .expect("the assertion signs"); + + let claims = claims_of(&assertion); + assert_eq!( + claims[ON_BEHALF_OF_CLAIM], + json!({ + "actor": "urn:example:agent:scheduler", + "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, + }) + ); + } + + /// Mint requires the actor and subject together, and refuses a registered + /// delegated client that sends neither. Answering here saves a round trip + /// that could only ever return an opaque `invalid_client`. + #[test] + fn half_a_delegation_is_refused_before_anything_is_signed() { + let audience = "https://mint.example.org/token"; + let expected = || AssertionError::Invalid("a delegation needs both an actor and a subject"); + + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + ..request("scheduler", audience) + }, + NOW, + ), + Err(expected()) + ); + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), + ..request("scheduler", audience) + }, + NOW, + ), + Err(expected()) + ); + } + + #[test] + fn empty_and_out_of_range_inputs_are_refused() { + let audience = "https://mint.example.org/token"; + assert_eq!( + sign_client_assertion(&key(), &request(" ", audience), NOW), + Err(AssertionError::Invalid("a client id is required")) + ); + assert_eq!( + sign_client_assertion(&key(), &request("scheduler", ""), NOW), + Err(AssertionError::Invalid("an assertion audience is required")) + ); + for lifetime in [0, -1, 301] { + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + lifetime_seconds: lifetime, + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid( + "the assertion lifetime must be 1..=300 seconds" + )), + "lifetime {lifetime} must be refused" + ); + } + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some(" "), + subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid("the actor must not be empty")) + ); + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + subject: Some(BTreeMap::new()), + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid( + "the subject must name at least one selector field" + )) + ); + } +} diff --git a/crates/registry-mint/src/clients.rs b/crates/registry-mint/src/clients.rs new file mode 100644 index 000000000..4c43e30c6 --- /dev/null +++ b/crates/registry-mint/src/clients.rs @@ -0,0 +1,784 @@ +//! The client registry: the server-side binding from keys to authority. +//! +//! This module is the reason Mint exists. A JWKS answers only "was this signed +//! by a trusted key?" The registry answers the question that actually matters: +//! "*this specific client* holds *these specific keys*, and is permitted to act +//! as *this principal* with *these tags* for *this audience*." +//! +//! Two rules keep that binding meaningful: +//! +//! 1. A client's assertion is verified against that client's keys only, never +//! against a pooled key set. See [`crate::assertion`]. +//! 2. Authority is read from here, never from the assertion payload. +//! +//! The registry is reloadable so that onboarding, offboarding, and caller key +//! rotation never require restarting a resource server. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, fs, + path::Path, + sync::Arc, +}; + +use jsonwebtoken::{jwk::JwkSet, DecodingKey}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use thiserror::Error; +use url::Url; + +/// Evidence rejects principals longer than this, so a longer one could never +/// be used. +const MAX_PRINCIPAL_BYTES: usize = 512; +/// Evidence accepts at most this many requester tags. +const MAX_TAGS: usize = 32; +const MAX_KEYS_PER_CLIENT: usize = 8; +const MAX_CLIENT_FILE_BYTES: u64 = 256 * 1024; +const MAX_CLIENTS: usize = 4_096; +/// Evidence permits at most this many fields in one selector profile, so a +/// larger subject could never satisfy one. +const MAX_SUBJECT_FIELDS: usize = 16; +const MAX_DELEGATED_ACTORS: usize = 64; +/// The longest claim path Evidence will resolve. +const MAX_CLAIM_PATH_BYTES: usize = 512; + +/// JWK members that only ever appear in private keys. +const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"]; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ClientRegistryError { + #[error("the client registry directory is unavailable")] + DirectoryUnavailable, + #[error("a client registration file is unreadable")] + Unreadable, + #[error("client registration {0} is invalid: {1}")] + Invalid(String, &'static str), + #[error("client registration document {0} is malformed: {1}")] + Document(String, String), + #[error("client id {0} is registered more than once")] + Duplicate(String), + #[error("the client registry holds more than {MAX_CLIENTS} clients")] + TooManyClients, +} + +/// A grant reference minted into tokens for callers acting under a recorded +/// authority. Evidence requires the id and authority together or not at all. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Grant { + pub id: String, + pub authority: String, +} + +/// Permission for a client to obtain tokens bound to one delegated actor acting +/// for one subject. +/// +/// This is what makes a delegated token narrower than an ordinary one rather +/// than merely differently labelled. The subject's selector values are minted +/// into the token at [`Delegation::subject_claims`], which must mirror the +/// `valueClaims` of the matching `authenticated-context` entitlement in the +/// resource server's bundle. The resource server then reads the subject from +/// the token and refuses any request that carries its own selector values, so a +/// token issued for one subject cannot reach another. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Delegation { + /// A closed set of actor identities this client may act as. Omitted means + /// the client names its own actor, bounded only by length. + #[serde(default)] + pub actors: Option>, + /// Selector field name to the claim path its value is minted at. + pub subject_claims: BTreeMap, +} + +impl Delegation { + /// Whether `actor` is one this client may act as. + #[must_use] + pub fn permits_actor(&self, actor: &str) -> bool { + match &self.actors { + Some(actors) => actors.iter().any(|permitted| permitted == actor), + None => true, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClientDocument { + client_id: String, + principal: String, + evidence_audience: String, + requester_tags: Vec, + #[serde(default)] + grant: Option, + #[serde(default)] + delegation: Option, + keys: Vec, +} + +/// One registered client: its public keys, and the authority Mint will assert +/// on its behalf. +#[derive(Clone)] +pub struct RegisteredClient { + client_id: String, + principal: String, + evidence_audience: String, + requester_tags: Vec, + grant: Option, + delegation: Option, + jwks: JwkSet, +} + +impl RegisteredClient { + #[must_use] + pub fn client_id(&self) -> &str { + &self.client_id + } + + #[must_use] + pub fn principal(&self) -> &str { + &self.principal + } + + #[must_use] + pub fn evidence_audience(&self) -> &str { + &self.evidence_audience + } + + #[must_use] + pub fn requester_tags(&self) -> &[String] { + &self.requester_tags + } + + #[must_use] + pub fn grant(&self) -> Option<&Grant> { + self.grant.as_ref() + } + + /// The delegation this client is registered for, if any. A client with no + /// delegation may never obtain a token carrying an actor or a bound + /// subject. + #[must_use] + pub fn delegation(&self) -> Option<&Delegation> { + self.delegation.as_ref() + } + + /// The public keys registered for this client, and nothing else. This is + /// the set an assertion from this client is verified against. + #[must_use] + pub fn jwks(&self) -> &JwkSet { + &self.jwks + } +} + +/// Authority data identifies real callers, so it is kept out of logs for the +/// same reason Evidence redacts its authenticated context. +impl fmt::Debug for RegisteredClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RegisteredClient") + .field("client_id", &"[redacted]") + .field("principal", &"[redacted]") + .field("evidence_audience", &"[redacted]") + .field( + "requester_tags", + &format_args!("[{} redacted]", self.requester_tags.len()), + ) + .field("grant", &self.grant.as_ref().map(|_| "[redacted]")) + .field( + "delegation", + &self.delegation.as_ref().map(|_| "[redacted]"), + ) + .field("keys", &self.jwks.keys.len()) + .finish() + } +} + +/// An immutable snapshot of the registered clients. +#[derive(Debug, Default)] +pub struct ClientRegistry { + clients: BTreeMap>, +} + +impl ClientRegistry { + /// Load every `*.yaml` registration in `directory`. + /// + /// The load is all-or-nothing: one malformed registration fails the whole + /// load, so a partially applied registry can never serve. + pub fn load(directory: &Path) -> Result { + let entries = + fs::read_dir(directory).map_err(|_| ClientRegistryError::DirectoryUnavailable)?; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|_| ClientRegistryError::Unreadable)?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) == Some("yaml") { + paths.push(path); + } + } + paths.sort(); + + let mut clients = BTreeMap::new(); + for path in paths { + let client = load_client_file(&path)?; + if clients.contains_key(client.client_id()) { + return Err(ClientRegistryError::Duplicate( + client.client_id().to_owned(), + )); + } + clients.insert(client.client_id().to_owned(), Arc::new(client)); + } + if clients.len() > MAX_CLIENTS { + return Err(ClientRegistryError::TooManyClients); + } + Ok(Self { clients }) + } + + #[must_use] + pub fn get(&self, client_id: &str) -> Option<&Arc> { + self.clients.get(client_id) + } + + #[must_use] + pub fn len(&self) -> usize { + self.clients.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.clients.is_empty() + } + + #[must_use] + pub fn client_ids(&self) -> Vec<&str> { + self.clients.keys().map(String::as_str).collect() + } +} + +fn load_client_file(path: &Path) -> Result { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_owned(); + + let metadata = fs::symlink_metadata(path).map_err(|_| ClientRegistryError::Unreadable)?; + if !metadata.is_file() { + return Err(ClientRegistryError::Invalid( + name, + "registrations must be regular files", + )); + } + if metadata.len() > MAX_CLIENT_FILE_BYTES { + return Err(ClientRegistryError::Invalid( + name, + "registration is too large", + )); + } + + let text = fs::read_to_string(path).map_err(|_| ClientRegistryError::Unreadable)?; + let document: ClientDocument = serde_norway::from_str(&text) + .map_err(|error| ClientRegistryError::Document(name.clone(), error.to_string()))?; + build_client(&name, document) +} + +fn build_client( + name: &str, + document: ClientDocument, +) -> Result { + let invalid = |reason: &'static str| ClientRegistryError::Invalid(name.to_owned(), reason); + + if document.client_id.trim().is_empty() || document.client_id.len() > 256 { + return Err(invalid("client id must be 1..=256 bytes")); + } + if document.principal.trim().is_empty() || document.principal.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("principal must be 1..=512 bytes")); + } + if document.evidence_audience.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("evidence audience must be at most 512 bytes")); + } + // Evidence parses this claim as a URL and mixes it into the subject-binding + // MAC, so a value that fails to parse there must fail here. + Url::parse(&document.evidence_audience) + .map_err(|_| invalid("evidence audience must be a URL"))?; + + if document.requester_tags.is_empty() || document.requester_tags.len() > MAX_TAGS { + return Err(invalid("between 1 and 32 requester tags are required")); + } + for tag in &document.requester_tags { + if tag.trim().is_empty() || tag.len() > 256 { + return Err(invalid("requester tags must be 1..=256 bytes")); + } + } + let unique_tags = document.requester_tags.iter().collect::>(); + if unique_tags.len() != document.requester_tags.len() { + return Err(invalid("requester tags must be unique")); + } + + if let Some(grant) = &document.grant { + if grant.id.trim().is_empty() || grant.id.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("grant id must be 1..=512 bytes")); + } + if grant.authority.trim().is_empty() || grant.authority.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("grant authority must be 1..=512 bytes")); + } + } + + if let Some(delegation) = &document.delegation { + validate_delegation(delegation, &invalid)?; + } + + let jwks = build_public_jwks(document.keys, &invalid)?; + + Ok(RegisteredClient { + client_id: document.client_id, + principal: document.principal, + evidence_audience: document.evidence_audience, + requester_tags: document.requester_tags, + grant: document.grant, + delegation: document.delegation, + jwks, + }) +} + +fn validate_delegation( + delegation: &Delegation, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result<(), ClientRegistryError> { + if let Some(actors) = &delegation.actors { + // An empty list reads as "no restriction" but means "nothing may be + // requested", so it is refused rather than silently interpreted. + if actors.is_empty() || actors.len() > MAX_DELEGATED_ACTORS { + return Err(invalid("between 1 and 64 delegated actors are required")); + } + for actor in actors { + if actor.trim().is_empty() || actor.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("delegated actors must be 1..=512 bytes")); + } + } + if actors.iter().collect::>().len() != actors.len() { + return Err(invalid("delegated actors must be unique")); + } + } + + if delegation.subject_claims.is_empty() || delegation.subject_claims.len() > MAX_SUBJECT_FIELDS + { + return Err(invalid("between 1 and 16 subject claims are required")); + } + for (field, path) in &delegation.subject_claims { + if !valid_selector_field(field) { + return Err(invalid("subject claim field names are invalid")); + } + if !valid_claim_path(path) { + return Err(invalid("subject claim paths are invalid")); + } + } + + // Two fields minted at one path would leave whichever came last in place, + // so the resource server would read one field's value for both. + let paths = delegation.subject_claims.values().collect::>(); + if paths.len() != delegation.subject_claims.len() { + return Err(invalid("subject claim paths must be unique")); + } + + // Minting builds nested objects, so `identity` and `identity.given_name` + // cannot both hold a value. Sorting groups any prefix with what it + // prefixes, so comparing neighbours is enough. + let mut segmented = paths + .iter() + .map(|path| path.split('.').collect::>()) + .collect::>(); + segmented.sort_unstable(); + if segmented + .windows(2) + .any(|pair| pair[1].starts_with(&pair[0])) + { + return Err(invalid( + "subject claim paths must not nest inside one another", + )); + } + Ok(()) +} + +/// The selector field name grammar Evidence enforces on bundle selector +/// profiles. A field Mint accepts but Evidence rejects could never resolve. +fn valid_selector_field(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +/// The claim path grammar Evidence resolves tokens against. +fn valid_claim_path(value: &str) -> bool { + if value.is_empty() || value.len() > MAX_CLAIM_PATH_BYTES { + return false; + } + value.split('.').all(|segment| { + let bytes = segment.as_bytes(); + !bytes.is_empty() + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) +} + +fn build_public_jwks( + keys: Vec, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result { + if keys.is_empty() || keys.len() > MAX_KEYS_PER_CLIENT { + return Err(invalid("between 1 and 8 keys are required")); + } + + let mut kids = BTreeSet::new(); + for key in &keys { + let object = key + .as_object() + .ok_or_else(|| invalid("keys must be objects"))?; + reject_private_material(object, invalid)?; + let kid = object + .get("kid") + .and_then(Value::as_str) + .ok_or_else(|| invalid("every key must carry a kid"))?; + if kid.trim().is_empty() || kid.len() > 256 { + return Err(invalid("key ids must be 1..=256 bytes")); + } + if !kids.insert(kid.to_owned()) { + return Err(invalid("key ids must be unique within a client")); + } + } + + let jwks: JwkSet = serde_json::from_value(Value::Object( + [("keys".to_owned(), Value::Array(keys))] + .into_iter() + .collect::>(), + )) + .map_err(|_| invalid("keys are not a valid JWK set"))?; + + // Prove at load time that every key is actually usable, so a broken + // registration fails at startup rather than at the first token request. + for jwk in &jwks.keys { + DecodingKey::from_jwk(jwk).map_err(|_| invalid("a key is not a usable public key"))?; + } + Ok(jwks) +} + +/// Reject anything carrying private key material. A client registration is +/// public data; a private member here would mean an operator pasted a signing +/// key into the registry. +fn reject_private_material( + object: &Map, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result<(), ClientRegistryError> { + if contains_private_material(object) { + return Err(invalid("client keys must not contain private key material")); + } + Ok(()) +} + +/// Whether a JWK object carries any member that only exists in a private key. +/// +/// Shared with the published JWKS so neither the registry nor the public key +/// set can ever carry private material. +#[must_use] +pub fn contains_private_material(object: &Map) -> bool { + PRIVATE_JWK_MEMBERS + .iter() + .any(|member| object.contains_key(*member)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLIENT_A: &str = r#" +clientId: client-a +principal: urn:example:client-a +evidenceAudience: https://client-a.example.org +requesterTags: [ministry-of-health] +keys: + - {kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo} +"#; + + fn registry_from(files: &[(&str, &str)]) -> Result { + let directory = tempfile::tempdir().expect("temp dir"); + for (name, contents) in files { + fs::write(directory.path().join(name), contents).expect("write client"); + } + ClientRegistry::load(directory.path()) + } + + fn load_one(contents: &str) -> Result { + registry_from(&[("client.yaml", contents)]) + } + + fn registry_error(files: &[(&str, &str)]) -> ClientRegistryError { + registry_from(files).expect_err("the registration must be rejected") + } + + fn load_error(contents: &str) -> ClientRegistryError { + registry_error(&[("client.yaml", contents)]) + } + + fn invalid(reason: &'static str) -> ClientRegistryError { + ClientRegistryError::Invalid("client.yaml".to_owned(), reason) + } + + #[test] + fn a_valid_registration_binds_keys_to_authority() { + let registry = load_one(CLIENT_A).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + + assert_eq!(client.principal(), "urn:example:client-a"); + assert_eq!(client.evidence_audience(), "https://client-a.example.org"); + assert_eq!(client.requester_tags(), ["ministry-of-health"]); + assert_eq!(client.grant(), None); + assert_eq!(client.jwks().keys.len(), 1); + assert_eq!(registry.len(), 1); + } + + #[test] + fn private_key_material_is_rejected() { + for member in PRIVATE_JWK_MEMBERS { + let text = CLIENT_A.replace( + "alg: EdDSA,", + &format!("alg: EdDSA, {member}: nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A,"), + ); + assert_eq!( + load_error(&text), + invalid("client keys must not contain private key material"), + "member {member} must be rejected" + ); + } + } + + #[test] + fn keys_require_unique_non_empty_kids() { + let text = CLIENT_A.replace("kid: client-a-2026-01, ", ""); + assert_eq!(load_error(&text), invalid("every key must carry a kid")); + + let duplicated = format!( + "{CLIENT_A} - {{kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo}}\n" + ); + assert_eq!( + load_error(&duplicated), + invalid("key ids must be unique within a client") + ); + } + + #[test] + fn evidence_audience_must_parse_as_a_url() { + let text = CLIENT_A.replace("https://client-a.example.org", "not-a-url"); + assert_eq!( + load_error(&text), + invalid("evidence audience must be a URL") + ); + } + + #[test] + fn requester_tags_are_required_bounded_and_unique() { + let text = CLIENT_A.replace("[ministry-of-health]", "[]"); + assert_eq!( + load_error(&text), + invalid("between 1 and 32 requester tags are required") + ); + + let text = CLIENT_A.replace("[ministry-of-health]", "[a, a]"); + assert_eq!(load_error(&text), invalid("requester tags must be unique")); + } + + #[test] + fn duplicate_client_ids_across_files_fail_the_whole_load() { + assert_eq!( + registry_error(&[("a.yaml", CLIENT_A), ("b.yaml", CLIENT_A)]), + ClientRegistryError::Duplicate("client-a".to_owned()) + ); + } + + #[test] + fn unknown_registration_fields_are_rejected() { + let text = CLIENT_A.replace("clientId: client-a", "clientId: client-a\nunexpected: true"); + assert!(matches!( + load_one(&text), + Err(ClientRegistryError::Document(_, _)) + )); + } + + #[test] + fn a_grant_requires_both_an_id_and_an_authority() { + let text = format!("{CLIENT_A}grant: {{id: grant-1, authority: statute-7}}\n"); + let registry = load_one(&text).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + assert_eq!( + client.grant(), + Some(&Grant { + id: "grant-1".to_owned(), + authority: "statute-7".to_owned() + }) + ); + + let text = format!("{CLIENT_A}grant: {{id: grant-1}}\n"); + assert!(matches!( + load_one(&text), + Err(ClientRegistryError::Document(_, _)) + )); + } + + /// A registration with a `delegation` block, whose subject claims mirror + /// the `valueClaims` of an `authenticated-context` entitlement. + fn delegated(body: &str) -> String { + format!("{CLIENT_A}delegation:\n{body}") + } + + const SUBJECT_CLAIMS: &str = + " subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + + #[test] + fn a_delegated_registration_declares_its_actors_and_subject_claims() { + let text = delegated(&format!( + " actors: [urn:example:agent:scheduling]\n{SUBJECT_CLAIMS}" + )); + let registry = load_one(&text).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let delegation = client.delegation().expect("client-a may delegate"); + + assert_eq!( + delegation.subject_claims, + BTreeMap::from([ + ("given_name".to_owned(), "identity.given_name".to_owned()), + ("birth_date".to_owned(), "identity.birth_date".to_owned()), + ]) + ); + assert!(delegation.permits_actor("urn:example:agent:scheduling")); + assert!(!delegation.permits_actor("urn:example:agent:other")); + + // Ordinary registrations stay undelegated, which is what makes an + // assertion asking to delegate refusable. + assert_eq!( + load_one(CLIENT_A) + .expect("registry loads") + .get("client-a") + .expect("client-a is registered") + .delegation(), + None + ); + } + + #[test] + fn an_omitted_actor_list_permits_any_actor_the_client_names() { + let registry = load_one(&delegated(SUBJECT_CLAIMS)).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let delegation = client.delegation().expect("client-a may delegate"); + + assert_eq!(delegation.actors, None); + assert!(delegation.permits_actor("urn:example:agent:anything")); + } + + #[test] + fn an_actor_list_must_be_non_empty_bounded_and_unique() { + assert_eq!( + load_error(&delegated(&format!(" actors: []\n{SUBJECT_CLAIMS}"))), + invalid("between 1 and 64 delegated actors are required") + ); + assert_eq!( + load_error(&delegated(&format!(" actors: [a, a]\n{SUBJECT_CLAIMS}"))), + invalid("delegated actors must be unique") + ); + assert_eq!( + load_error(&delegated(&format!(" actors: ['']\n{SUBJECT_CLAIMS}"))), + invalid("delegated actors must be 1..=512 bytes") + ); + } + + #[test] + fn subject_claim_paths_must_use_the_resource_servers_claim_path_grammar() { + for path in ["identity..given_name", "1identity.given_name", "", "a.b!c"] { + let text = delegated(&format!(" subjectClaims:\n given_name: \"{path}\"\n")); + assert_eq!( + load_error(&text), + invalid("subject claim paths are invalid"), + "path {path:?} must be rejected" + ); + } + } + + #[test] + fn subject_claim_field_names_follow_the_selector_profile_grammar() { + for field in ["Given_Name", "1given", "given name", ""] { + let text = delegated(&format!( + " subjectClaims:\n \"{field}\": identity.given_name\n" + )); + assert_eq!( + load_error(&text), + invalid("subject claim field names are invalid"), + "field {field:?} must be rejected" + ); + } + } + + /// Minting builds nested objects, so a path and a path inside it cannot + /// both hold a value. + #[test] + fn subject_claim_paths_must_be_unique_and_must_not_nest() { + let text = delegated( + " subjectClaims:\n given_name: identity.name\n family_name: identity.name\n", + ); + assert_eq!( + load_error(&text), + invalid("subject claim paths must be unique") + ); + + let text = delegated( + " subjectClaims:\n given_name: identity\n family_name: identity.family_name\n", + ); + assert_eq!( + load_error(&text), + invalid("subject claim paths must not nest inside one another") + ); + } + + #[test] + fn a_delegation_must_bind_at_least_one_subject_claim() { + // Delegation with no subject binding would be an actor label on an + // otherwise unbounded token. + let text = delegated(" subjectClaims: {}\n"); + assert_eq!( + load_error(&text), + invalid("between 1 and 16 subject claims are required") + ); + + let fields = (0..17) + .map(|index| format!(" field_{index}: identity.f{index}\n")) + .collect::(); + assert_eq!( + load_error(&delegated(&format!(" subjectClaims:\n{fields}"))), + invalid("between 1 and 16 subject claims are required") + ); + } + + #[test] + fn debug_output_redacts_client_authority() { + let registry = load_one(CLIENT_A).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let rendered = format!("{client:?}"); + assert!(!rendered.contains("urn:example:client-a")); + assert!(!rendered.contains("ministry-of-health")); + assert!(!rendered.contains("client-a.example.org")); + } + + #[test] + fn non_yaml_files_are_ignored() { + let registry = registry_from(&[ + ("client.yaml", CLIENT_A), + ("notes.txt", "not a registration"), + ("client.yaml.bak", "also not a registration"), + ]) + .expect("registry loads"); + assert_eq!(registry.len(), 1); + } +} diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs new file mode 100644 index 000000000..7a32e530a --- /dev/null +++ b/crates/registry-mint/src/config.rs @@ -0,0 +1,1012 @@ +//! Startup-only Mint configuration. +//! +//! Everything in this file is fixed for the lifetime of the serving process: +//! issuer identity, signing and audit keys, listener, and token policy. The one part of +//! Mint's state that is intentionally reloadable is the client registry, which +//! lives in [`crate::clients`]. + +use std::{ + collections::BTreeSet, + net::IpAddr, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; +use thiserror::Error; +use url::Url; + +/// Supported signature algorithms, shared by minted tokens and accepted client +/// assertions. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +pub enum Algorithm { + EdDSA, + ES256, + RS256, +} + +impl Algorithm { + #[must_use] + pub fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm { + match self { + Self::EdDSA => jsonwebtoken::Algorithm::EdDSA, + Self::ES256 => jsonwebtoken::Algorithm::ES256, + Self::RS256 => jsonwebtoken::Algorithm::RS256, + } + } + + #[must_use] + pub fn as_header_value(self) -> &'static str { + match self { + Self::EdDSA => "EdDSA", + Self::ES256 => "ES256", + Self::RS256 => "RS256", + } + } +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ConfigError { + #[error("the configuration file is unavailable")] + Unavailable, + #[error("the configuration document is invalid: {0}")] + Document(String), + #[error("configuration is invalid: {0}")] + Invalid(&'static str), +} + +fn default_jwks_path() -> String { + MINT_JWKS_PATH.to_owned() +} + +pub(crate) const MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; +pub(crate) const MINT_TOKEN_PATH: &str = "/token"; +pub(crate) const MINT_METADATA_PATH: &str = "/.well-known/oauth-authorization-server"; +pub(crate) const MINT_HEALTH_PATH: &str = "/health"; +pub(crate) const MINT_READY_PATH: &str = "/ready"; + +/// Every path the router registers besides the configured JWKS path. +/// +/// The router panics when one path is registered twice, so the configured +/// JWKS path is checked against this list where the configuration is read. +pub(crate) const MINT_FIXED_ROUTES: [&str; 4] = [ + MINT_TOKEN_PATH, + MINT_METADATA_PATH, + MINT_HEALTH_PATH, + MINT_READY_PATH, +]; + +fn default_maximum_request_bytes() -> u32 { + 16 * 1024 +} + +fn default_request_timeout_milliseconds() -> u64 { + 5_000 +} + +fn default_assertion_lifetime_seconds() -> u64 { + 300 +} + +fn default_replay_cache_entries() -> usize { + 8_192 +} + +fn default_principal_claim() -> String { + "sub".to_owned() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + pub address: String, + pub port: u16, + #[serde(default = "default_maximum_request_bytes")] + pub maximum_request_bytes: u32, + #[serde(default = "default_request_timeout_milliseconds")] + pub request_timeout_milliseconds: u64, +} + +impl ListenerConfig { + pub fn bind_address(&self) -> Result { + self.address + .parse() + .map_err(|_| ConfigError::Invalid("listener address is not an IP address")) + } + + /// Reject limits no token request can survive. + /// + /// A zero body limit or a zero timeout leaves Mint reporting itself ready + /// while every token request fails, which is an outage the readiness probe + /// cannot see. The bounds match the Evidence listener. + fn validate(&self) -> Result<(), ConfigError> { + if !(1_024..=1_048_576).contains(&self.maximum_request_bytes) { + return Err(ConfigError::Invalid( + "listener maximumRequestBytes must be 1024..=1048576", + )); + } + if !(1..=30_000).contains(&self.request_timeout_milliseconds) { + return Err(ConfigError::Invalid( + "listener requestTimeoutMilliseconds must be 1..=30000", + )); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SigningConfig { + pub algorithm: Algorithm, + pub active_key_id: String, + /// Path to the private JWK, resolved relative to the configuration file. + pub active_key_file: PathBuf, + /// Public JWKs of keys that no longer sign but may still have live tokens. + #[serde(default)] + pub retired_public_jwk_files: Vec, + #[serde(default = "default_jwks_path")] + pub jwks_path: String, +} + +/// Required, fail-closed audit storage for token decisions. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditConfig { + /// Append-only keyed JSONL chain, resolved relative to the configuration. + pub path: PathBuf, + /// Per-segment rotation threshold. Sealed segments are never deleted. + pub maximum_file_bytes: u64, + /// Owner-only master HMAC key, resolved relative to the configuration. + pub hash_key_file: PathBuf, + /// Version label written into privacy-preserving audit handles. + pub hash_key_version: u32, +} + +/// Names of the claims Mint writes into minted access tokens. +/// +/// These must match the resource server's `authentication` block. Evidence, for +/// example, reads its principal, requester tags, evidence audience, and grant +/// pair from configurable claim names. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClaimNames { + #[serde(default = "default_principal_claim")] + pub principal: String, + pub requester_tags: String, + pub evidence_audience: String, + pub grant_id: String, + pub grant_authority: String, + /// The delegated actor identity, present only where a deployment issues + /// delegated tokens. Omitting it is what stops a registry entry that + /// declares delegation from ever being served. + #[serde(default)] + pub actor: Option, +} + +impl ClaimNames { + fn validate(&self) -> Result<(), ConfigError> { + let mut names = vec![ + self.principal.as_str(), + self.requester_tags.as_str(), + self.evidence_audience.as_str(), + self.grant_id.as_str(), + self.grant_authority.as_str(), + ]; + names.extend(self.actor.as_deref()); + for name in &names { + if name.trim().is_empty() || name.len() > 128 { + return Err(ConfigError::Invalid("claim names must be 1..=128 bytes")); + } + } + // A duplicated name would make one claim silently overwrite another, + // so authority could be smuggled through the wrong field. + let unique = names.iter().collect::>(); + if unique.len() != names.len() { + return Err(ConfigError::Invalid("claim names must be distinct")); + } + // These are written by Mint itself and must not be overridable. Minting + // writes the registered claims last, so any of these reused as a claim + // name would silently replace what Mint decided with what the registry + // did: an `aud` shadow yields a token whose audience is the principal + // and which still verifies. + for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { + if names.contains(&reserved) { + return Err(ConfigError::Invalid( + "authority claim names must not shadow registered JWT claims", + )); + } + } + // `sub` is the exception. It always carries the principal, so naming + // the principal claim `sub` rewrites the same value and is the default; + // any other claim named `sub` would replace the principal. + if names.iter().skip(1).any(|name| *name == "sub") { + return Err(ConfigError::Invalid( + "authority claim names must not shadow registered JWT claims", + )); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccessTokenConfig { + /// The `aud` written into minted tokens. Matches the resource server's + /// configured audiences. + pub audiences: Vec, + pub lifetime_seconds: u64, + pub claims: ClaimNames, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientAssertionConfig { + /// The value clients must set as the assertion `aud`. Binding assertions to + /// this endpoint stops one presented to another service being replayed here. + pub audience: String, + #[serde(default = "default_assertion_lifetime_seconds")] + pub maximum_lifetime_seconds: u64, + pub algorithms: Vec, + #[serde(default = "default_replay_cache_entries")] + pub replay_cache_entries: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientsConfig { + /// Directory of per-client registration files, relative to the config file. + pub directory: PathBuf, +} + +/// The transport validation boundary selected for this Mint process. +/// +/// The strict default preserves Mint's HTTPS-only deployment contract. +/// `SupervisedLocalDevelopment` is an explicit exception for a supervised +/// process pair on one canonical loopback origin. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ValidationMode { + #[default] + Strict, + SupervisedLocalDevelopment, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MintConfig { + pub version: u32, + #[serde(default)] + pub validation_mode: ValidationMode, + pub issuer: String, + pub listener: ListenerConfig, + pub signing: SigningConfig, + pub audit: AuditConfig, + pub access_tokens: AccessTokenConfig, + pub client_assertion: ClientAssertionConfig, + pub clients: ClientsConfig, +} + +impl MintConfig { + /// Load and validate a configuration document, resolving every path + /// relative to the document's own directory. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|_| ConfigError::Unavailable)?; + let mut config: Self = serde_norway::from_str(&text) + .map_err(|error| ConfigError::Document(error.to_string()))?; + let root = path + .parent() + .ok_or(ConfigError::Invalid("configuration path has no parent"))?; + config.resolve_paths(root); + config.validate()?; + Ok(config) + } + + fn resolve_paths(&mut self, root: &Path) { + let resolve = |path: &Path| -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } + }; + self.signing.active_key_file = resolve(&self.signing.active_key_file); + self.signing.retired_public_jwk_files = self + .signing + .retired_public_jwk_files + .iter() + .map(|path| resolve(path)) + .collect(); + self.audit.path = resolve(&self.audit.path); + self.audit.hash_key_file = resolve(&self.audit.hash_key_file); + self.clients.directory = resolve(&self.clients.directory); + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return Err(ConfigError::Invalid( + "only configuration version 1 is supported", + )); + } + match self.validation_mode { + ValidationMode::Strict => validate_https_issuer(&self.issuer)?, + ValidationMode::SupervisedLocalDevelopment => { + self.validate_supervised_local_development_transport()?; + } + } + self.listener.bind_address()?; + self.listener.validate()?; + + if self.signing.active_key_id.trim().is_empty() || self.signing.active_key_id.len() > 256 { + return Err(ConfigError::Invalid("active key id must be 1..=256 bytes")); + } + if !self.signing.jwks_path.starts_with('/') { + return Err(ConfigError::Invalid("jwks path must be absolute")); + } + if !is_plain_route_path(&self.signing.jwks_path) { + return Err(ConfigError::Invalid( + "jwks path must be a plain absolute path with no query, fragment, or route pattern", + )); + } + if MINT_FIXED_ROUTES.contains(&self.signing.jwks_path.as_str()) { + return Err(ConfigError::Invalid( + "jwks path must not take a route Mint already serves", + )); + } + if self.audit.path.as_os_str().is_empty() + || self.audit.hash_key_file.as_os_str().is_empty() + || self.audit.hash_key_version == 0 + { + return Err(ConfigError::Invalid( + "audit path, hash key file, and non-zero hash key version are required", + )); + } + if !(1_048_576..=1_099_511_627_776).contains(&self.audit.maximum_file_bytes) { + return Err(ConfigError::Invalid( + "audit maximumFileBytes must be 1048576..=1099511627776", + )); + } + if self.audit.path == self.audit.hash_key_file + || self.audit.path == self.signing.active_key_file + || self.audit.hash_key_file == self.signing.active_key_file + { + return Err(ConfigError::Invalid( + "audit storage and secret paths must be distinct from signing material", + )); + } + + if self.access_tokens.audiences.is_empty() || self.access_tokens.audiences.len() > 16 { + return Err(ConfigError::Invalid( + "between 1 and 16 audiences are required", + )); + } + for audience in &self.access_tokens.audiences { + if audience.trim().is_empty() || audience.len() > 512 { + return Err(ConfigError::Invalid("audiences must be 1..=512 bytes")); + } + } + // A long-lived bearer token is the thing Mint exists to avoid, and a + // token shorter than the verifier's clock skew is unusable. + if !(60..=3600).contains(&self.access_tokens.lifetime_seconds) { + return Err(ConfigError::Invalid( + "access token lifetime must be 60..=3600 seconds", + )); + } + self.access_tokens.claims.validate()?; + + if self.validation_mode == ValidationMode::Strict { + validate_https_endpoint(&self.client_assertion.audience)?; + } + if !(30..=600).contains(&self.client_assertion.maximum_lifetime_seconds) { + return Err(ConfigError::Invalid( + "client assertion lifetime must be 30..=600 seconds", + )); + } + if self.client_assertion.algorithms.is_empty() { + return Err(ConfigError::Invalid( + "at least one client assertion algorithm is required", + )); + } + if self.client_assertion.replay_cache_entries < 256 { + return Err(ConfigError::Invalid( + "the replay cache must hold at least 256 entries", + )); + } + Ok(()) + } + + fn validate_supervised_local_development_transport(&self) -> Result<(), ConfigError> { + let port = parse_canonical_supervised_local_origin(&self.issuer)?; + if self.listener.address != "127.0.0.1" || self.listener.port != port { + return Err(ConfigError::Invalid( + "supervised local development listener must exactly match its canonical issuer origin", + )); + } + if self.signing.jwks_path != MINT_JWKS_PATH { + return Err(ConfigError::Invalid( + "supervised local development JWKS path must match the fixed Mint path", + )); + } + if self.client_assertion.audience != format!("{}{MINT_TOKEN_PATH}", self.issuer) { + return Err(ConfigError::Invalid( + "supervised local development client assertion audience must match the fixed Mint token endpoint", + )); + } + Ok(()) + } +} + +/// Accept only a path that survives both trips the JWKS path has to make. +/// +/// The router registers this string literally and the metadata document +/// publishes it as `jwks_uri`. A query or fragment is lost on the way back: a +/// client fetching the advertised URI sends the path alone, so it would never +/// reach a route registered with the decoration attached. A route pattern is +/// the opposite failure, matching paths the metadata never advertised. Either +/// way Mint reports itself ready while its published key set does not resolve, +/// which is an outage no probe can see. +/// +/// So: one or more non-empty segments of unreserved path characters, no dot +/// segments, and nothing that could be read as a pattern. +fn is_plain_route_path(path: &str) -> bool { + let Some(rest) = path.strip_prefix('/') else { + return false; + }; + rest.split('/').all(|segment| { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') + }) + }) +} + +/// Parse the only HTTP origin admitted for supervised local development. +/// +/// Exact reconstruction rejects URL-parser aliases such as a trailing slash, +/// leading-zero port, alternate IPv4 spelling, credentials, query, or fragment. +fn parse_canonical_supervised_local_origin(value: &str) -> Result { + let port = value + .strip_prefix("http://127.0.0.1:") + .filter(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .filter(|port| !port.starts_with('0')) + .and_then(|port| port.parse::().ok()) + .filter(|port| *port != 0) + .ok_or(ConfigError::Invalid( + "supervised local development issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", + ))?; + if value != format!("http://127.0.0.1:{port}") { + return Err(ConfigError::Invalid( + "supervised local development issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", + )); + } + Ok(port) +} + +/// Require an issuer that is `https`, has a host, and carries no credentials, +/// query, or fragment. Resource servers compare this string exactly, so any +/// variable part of it would weaken the comparison. +pub fn validate_https_issuer(issuer: &str) -> Result<(), ConfigError> { + let url = + Url::parse(issuer).map_err(|_| ConfigError::Invalid("issuer must be an absolute URL"))?; + if url.scheme() != "https" { + return Err(ConfigError::Invalid("issuer must use https")); + } + if !url.has_host() { + return Err(ConfigError::Invalid("issuer must have a host")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ConfigError::Invalid("issuer must not carry credentials")); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(ConfigError::Invalid( + "issuer must not carry a query or fragment", + )); + } + Ok(()) +} + +fn validate_https_endpoint(endpoint: &str) -> Result<(), ConfigError> { + let url = Url::parse(endpoint) + .map_err(|_| ConfigError::Invalid("client assertion audience must be an absolute URL"))?; + if url.scheme() != "https" { + return Err(ConfigError::Invalid( + "client assertion audience must use https", + )); + } + if !url.has_host() { + return Err(ConfigError::Invalid( + "client assertion audience must have a host", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ConfigError::Invalid( + "client assertion audience must not carry credentials", + )); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(ConfigError::Invalid( + "client assertion audience must not carry a query or fragment", + )); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::fs; + + pub(crate) const VALID: &str = r#" +version: 1 +issuer: https://mint.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: secrets/signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.example.org/token + algorithms: [EdDSA] +clients: + directory: clients +"#; + + fn load_from(text: &str) -> Result { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("mint.yaml"); + fs::write(&path, text).expect("write config"); + MintConfig::load(&path) + } + + fn load_error(text: &str) -> ConfigError { + load_from(text).expect_err("the document must be rejected") + } + + /// A valid configuration for tests in other modules that need one but do + /// not exercise loading itself. + pub(crate) fn sample_config() -> MintConfig { + load_from(VALID).expect("the sample configuration is valid") + } + + #[test] + fn a_valid_document_loads_and_resolves_paths_against_the_config_directory() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("mint.yaml"); + fs::write(&path, VALID).expect("write config"); + let config = MintConfig::load(&path).expect("valid config loads"); + + assert_eq!(config.issuer, "https://mint.example.org"); + assert_eq!(config.validation_mode, ValidationMode::Strict); + assert_eq!( + config.signing.active_key_file, + directory.path().join("secrets/signing.jwk") + ); + assert_eq!(config.clients.directory, directory.path().join("clients")); + assert_eq!(config.audit.path, directory.path().join("audit/mint.jsonl")); + assert_eq!(config.audit.maximum_file_bytes, 1_073_741_824); + assert_eq!( + config.audit.hash_key_file, + directory.path().join("secrets/audit-hmac-key") + ); + assert_eq!(config.audit.hash_key_version, 1); + assert_eq!(config.signing.jwks_path, "/.well-known/jwks.json"); + assert_eq!(config.client_assertion.maximum_lifetime_seconds, 300); + } + + #[test] + fn audit_configuration_is_required_bounded_and_separate_from_secrets() { + assert!(load_from(&VALID.replace( + "audit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyFile: secrets/audit-hmac-key\n hashKeyVersion: 1\n", + "" + )) + .is_err()); + assert_eq!( + load_error(&VALID.replace("hashKeyVersion: 1", "hashKeyVersion: 0")), + ConfigError::Invalid( + "audit path, hash key file, and non-zero hash key version are required" + ) + ); + assert_eq!( + load_error(&VALID.replace("maximumFileBytes: 1073741824", "maximumFileBytes: 1024")), + ConfigError::Invalid("audit maximumFileBytes must be 1048576..=1099511627776") + ); + assert_eq!( + load_error(&VALID.replace( + "hashKeyFile: secrets/audit-hmac-key", + "hashKeyFile: secrets/signing.jwk" + )), + ConfigError::Invalid( + "audit storage and secret paths must be distinct from signing material" + ) + ); + } + + #[test] + fn a_jwks_path_may_not_take_a_route_mint_already_serves() { + for path in MINT_FIXED_ROUTES { + let text = VALID.replace( + "activeKeyFile: secrets/signing.jwk", + &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: {path}"), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("jwks path must not take a route Mint already serves"), + "jwks path {path} must be rejected" + ); + } + } + + #[test] + fn a_jwks_path_must_be_a_path_a_client_can_fetch() { + // The path is registered as a route and published as `jwks_uri`. A + // query or fragment survives neither trip: the router matches the + // literal string, and a client sends only the path back. A route + // pattern is worse, because it matches paths the metadata never named. + for path in [ + "/keys?tenant=a", + "/keys#v1", + "/keys/{tenant}", + "/{*rest}", + "/keys//v1", + "/keys/", + "/keys/../token", + "/keys/.", + "/keys v1", + "/keys%2ftoken", + ] { + let text = VALID.replace( + "activeKeyFile: secrets/signing.jwk", + &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: \"{path}\""), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("jwks path must be a plain absolute path with no query, fragment, or route pattern"), + "jwks path {path} must be rejected" + ); + } + } + + #[test] + fn a_plain_absolute_jwks_path_is_accepted() { + for path in [ + "/.well-known/jwks.json", + "/keys", + "/v1/keys.json", + "/a~b-c_d", + ] { + let text = VALID.replace( + "activeKeyFile: secrets/signing.jwk", + &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: \"{path}\""), + ); + let config = load_from(&text).expect("a plain absolute path loads"); + assert_eq!(config.signing.jwks_path, path); + } + } + + #[test] + fn listener_limits_must_admit_a_request() { + for (field, value) in [ + ("maximumRequestBytes", 0), + ("maximumRequestBytes", 1_048_577), + ("requestTimeoutMilliseconds", 0), + ("requestTimeoutMilliseconds", 30_001), + ] { + let text = VALID.replace( + "listener: {address: 127.0.0.1, port: 8081}", + &format!("listener: {{address: 127.0.0.1, port: 8081, {field}: {value}}}"), + ); + assert!( + matches!(load_from(&text), Err(ConfigError::Invalid(_))), + "{field} {value} must be rejected" + ); + } + } + + #[test] + fn unknown_fields_are_rejected() { + let text = VALID.replace("version: 1", "version: 1\nunexpected: true"); + assert!(matches!(load_from(&text), Err(ConfigError::Document(_)))); + } + + #[test] + fn issuers_must_be_https_hosts_without_credentials_or_query() { + for issuer in [ + "http://mint.example.org", + "https://user:pass@mint.example.org", + "https://mint.example.org?tenant=a", + "https://mint.example.org#frag", + "mint.example.org", + "https://", + ] { + let text = VALID.replace("https://mint.example.org\n", &format!("{issuer}\n")); + assert!( + matches!(load_from(&text), Err(ConfigError::Invalid(_))), + "issuer {issuer} must be rejected" + ); + } + } + + #[test] + fn supervised_local_development_accepts_only_the_exact_mint_transport() { + let local = VALID + .replace( + "version: 1", + "version: 1\nvalidationMode: supervised-local-development", + ) + .replace( + "issuer: https://mint.example.org", + "issuer: http://127.0.0.1:8081", + ) + .replace( + "audience: https://mint.example.org/token", + "audience: http://127.0.0.1:8081/token", + ); + let config = load_from(&local).expect("the supervised local transport is valid"); + assert_eq!( + config.validation_mode, + ValidationMode::SupervisedLocalDevelopment + ); + + for port in [1_u16, u16::MAX] { + let boundary = local.replace("8081", &port.to_string()); + load_from(&boundary).unwrap_or_else(|error| { + panic!("canonical boundary port {port} must be accepted: {error}") + }); + } + + for invalid_issuer in [ + "http://localhost:8081", + "http://[::1]:8081", + "http://127.0.0.2:8081", + "http://127.00.0.1:8081", + "http://127.0.0.1", + "http://127.0.0.1:0", + "http://127.0.0.1:08081", + "http://127.0.0.1:65536", + "http://user@127.0.0.1:8081", + "http://127.0.0.1:8081/", + "http://127.0.0.1:8081?tenant=x", + "http://127.0.0.1:8081#fragment", + "https://127.0.0.1:8081", + ] { + let invalid = local.replace( + "issuer: http://127.0.0.1:8081", + &format!("issuer: {invalid_issuer}"), + ); + assert!( + load_from(&invalid).is_err(), + "accepted supervised local issuer {invalid_issuer}" + ); + } + + for invalid_audience in [ + "http://127.0.0.1:8081", + "http://127.0.0.1:8081/token/", + "http://127.0.0.1:8081/TOKEN", + "http://127.0.0.1:8081/oauth/token", + "http://127.0.0.1:8081/token?tenant=x", + "http://127.0.0.1:8081/token#fragment", + "http://127.0.0.1:8082/token", + ] { + let invalid = local.replace( + "audience: http://127.0.0.1:8081/token", + &format!("audience: {invalid_audience}"), + ); + assert!( + load_from(&invalid).is_err(), + "accepted supervised local assertion audience {invalid_audience}" + ); + } + + for replacement in [ + "listener: {address: 127.0.0.2, port: 8081}", + "listener: {address: 127.0.0.1, port: 8082}", + "listener: {address: 127.0.0.1, port: 0}", + ] { + let invalid = local.replace("listener: {address: 127.0.0.1, port: 8081}", replacement); + assert!( + load_from(&invalid).is_err(), + "accepted mismatched listener {replacement}" + ); + } + + let wrong_jwks = local.replace( + "activeKeyFile: secrets/signing.jwk", + "activeKeyFile: secrets/signing.jwk\n jwksPath: /.well-known/keys.json", + ); + assert!( + load_from(&wrong_jwks).is_err(), + "accepted a non-Mint JWKS path" + ); + } + + #[test] + fn strict_mode_is_the_https_only_default() { + let default = load_from(VALID).expect("the existing strict document remains valid"); + assert_eq!(default.validation_mode, ValidationMode::Strict); + + let explicit = VALID.replace("version: 1", "version: 1\nvalidationMode: strict"); + assert_eq!( + load_from(&explicit) + .expect("the explicit strict mode is valid") + .validation_mode, + ValidationMode::Strict + ); + + let local_without_mode = VALID + .replace( + "issuer: https://mint.example.org", + "issuer: http://127.0.0.1:8081", + ) + .replace( + "audience: https://mint.example.org/token", + "audience: http://127.0.0.1:8081/token", + ); + assert!( + load_from(&local_without_mode).is_err(), + "strict Mint inherited the local HTTP exception" + ); + + for invalid_audience in [ + "http://127.0.0.1:8081/token", + "https://user:pass@mint.example.org/token", + "https://mint.example.org/token?tenant=x", + "https://mint.example.org/token#fragment", + "mint.example.org/token", + "https://", + ] { + let invalid = VALID.replace( + "audience: https://mint.example.org/token", + &format!("audience: {invalid_audience}"), + ); + assert!( + load_from(&invalid).is_err(), + "strict Mint accepted assertion audience {invalid_audience}" + ); + } + } + + #[test] + fn access_token_lifetime_is_bounded_on_both_sides() { + for lifetime in ["1", "59", "3601", "86400"] { + let text = VALID.replace( + "lifetimeSeconds: 300", + &format!("lifetimeSeconds: {lifetime}"), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("access token lifetime must be 60..=3600 seconds"), + "lifetime {lifetime} must be rejected" + ); + } + } + + #[test] + fn duplicate_claim_names_are_rejected() { + let text = VALID.replace("grantId: evidence_grant_id", "grantId: evidence_tags"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("claim names must be distinct") + ); + } + + #[test] + fn authority_claims_cannot_shadow_registered_jwt_claims() { + for reserved in ["iss", "aud", "exp", "jti", "client_id"] { + let text = VALID.replace( + "requesterTags: evidence_tags", + &format!("requesterTags: {reserved}"), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "claim {reserved} must be rejected" + ); + } + } + + #[test] + fn the_principal_claim_is_bound_by_the_same_rule_as_the_others() { + // Minting writes the registered claims after the JWT ones, so a + // principal named for a reserved claim would overwrite it. `aud` is the + // one that matters most: the token would carry the principal as its + // audience and still verify. + for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { + let text = VALID.replace("principal: sub", &format!("principal: {reserved}")); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "principal {reserved} must be rejected" + ); + } + } + + #[test] + fn only_the_principal_may_be_named_sub() { + // `sub` always carries the principal, so naming the principal claim + // `sub` is the default and merely rewrites the same value. + load_from(VALID).expect("principal may be named sub"); + + // Any other claim named `sub` would replace the principal with its own + // value, which for requester tags is not even a string. The principal + // moves off `sub` first, so this is the shadowing rule answering rather + // than the distinctness rule. + let renamed = VALID.replace("principal: sub", "principal: evidence_principal"); + for field in [ + "requesterTags: evidence_tags", + "evidenceAudience: evidence_audience", + "grantId: evidence_grant_id", + "grantAuthority: evidence_authority", + ] { + let name = field.split(':').next().expect("a claim field name"); + let text = renamed.replace(field, &format!("{name}: sub")); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "{name} must not be named sub" + ); + } + } + + #[test] + fn the_actor_claim_is_optional_and_obeys_every_other_claim_name_rule() { + // A deployment that never delegates names no actor claim at all. + assert!(sample_config().access_tokens.claims.actor.is_none()); + + let with_actor = |name: &str| { + VALID.replace( + "grantAuthority: evidence_authority", + &format!("grantAuthority: evidence_authority\n actor: {name}"), + ) + }; + + let config = load_from(&with_actor("evidence_actor")).expect("an actor claim is accepted"); + assert_eq!( + config.access_tokens.claims.actor.as_deref(), + Some("evidence_actor") + ); + + // Reusing another authority claim would let the actor overwrite it. + assert_eq!( + load_error(&with_actor("evidence_tags")), + ConfigError::Invalid("claim names must be distinct") + ); + assert_eq!( + load_error(&with_actor("client_id")), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims") + ); + assert_eq!( + load_error(&with_actor("\"\"")), + ConfigError::Invalid("claim names must be 1..=128 bytes") + ); + } + + #[test] + fn version_must_be_one_and_audiences_must_be_present() { + let text = VALID.replace("version: 1", "version: 2"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("only configuration version 1 is supported") + ); + + let text = VALID.replace("audiences: [evidence]", "audiences: []"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("between 1 and 16 audiences are required") + ); + } +} diff --git a/crates/registry-mint/src/error.rs b/crates/registry-mint/src/error.rs new file mode 100644 index 000000000..1531c3b87 --- /dev/null +++ b/crates/registry-mint/src/error.rs @@ -0,0 +1,161 @@ +//! OAuth 2.0 token endpoint errors. +//! +//! The public error code is deliberately coarse. Whether a client is unknown, +//! presented a bad signature, replayed a `jti`, or sent an expired assertion, +//! the caller sees `invalid_client`. Distinguishing those cases on the wire +//! would turn the token endpoint into an oracle for probing the client +//! registry. The specific reason is retained for operator logs only. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; + +/// RFC 6749 section 5.2 error codes, restricted to the ones Mint can return. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum TokenErrorCode { + InvalidRequest, + InvalidClient, + UnsupportedGrantType, + ServerError, +} + +impl TokenErrorCode { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InvalidClient => "invalid_client", + Self::UnsupportedGrantType => "unsupported_grant_type", + Self::ServerError => "server_error", + } + } + + #[must_use] + pub fn status(self) -> StatusCode { + match self { + Self::InvalidRequest | Self::UnsupportedGrantType => StatusCode::BAD_REQUEST, + Self::InvalidClient => StatusCode::UNAUTHORIZED, + Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +#[derive(Debug, Serialize)] +struct TokenErrorBody { + error: &'static str, +} + +/// A token endpoint failure carrying a public code and a private reason. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct TokenError { + code: TokenErrorCode, + reason: &'static str, +} + +impl TokenError { + #[must_use] + pub fn new(code: TokenErrorCode, reason: &'static str) -> Self { + Self { code, reason } + } + + #[must_use] + pub fn invalid_request(reason: &'static str) -> Self { + Self::new(TokenErrorCode::InvalidRequest, reason) + } + + /// Every client authentication failure collapses to this variant. + #[must_use] + pub fn invalid_client(reason: &'static str) -> Self { + Self::new(TokenErrorCode::InvalidClient, reason) + } + + #[must_use] + pub fn unsupported_grant_type(reason: &'static str) -> Self { + Self::new(TokenErrorCode::UnsupportedGrantType, reason) + } + + #[must_use] + pub fn server_error(reason: &'static str) -> Self { + Self::new(TokenErrorCode::ServerError, reason) + } + + #[must_use] + pub fn code(&self) -> TokenErrorCode { + self.code + } + + /// Operator-facing detail. Never sent to the caller. + #[must_use] + pub fn reason(&self) -> &'static str { + self.reason + } + + /// Render a token-operation failure with its privacy-safe correlation id. + #[must_use] + pub fn into_operation_response(self, operation: &str) -> Response { + self.respond(Some(operation)) + } + + fn respond(self, operation: Option<&str>) -> Response { + tracing::warn!( + target: "registry_mint::token", + operation, + error = self.code.as_str(), + reason = self.reason, + "token request rejected" + ); + let mut response = ( + self.code.status(), + Json(TokenErrorBody { + error: self.code.as_str(), + }), + ) + .into_response(); + if self.code == TokenErrorCode::InvalidClient { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderValue::from_static("Bearer error=\"invalid_client\""), + ); + } + response + } +} + +impl IntoResponse for TokenError { + fn into_response(self) -> Response { + self.respond(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_authentication_failures_share_one_public_code() { + for reason in [ + "unknown client", + "signature did not verify", + "assertion replayed", + "assertion expired", + ] { + let error = TokenError::invalid_client(reason); + assert_eq!(error.code().as_str(), "invalid_client"); + assert_eq!(error.code().status(), StatusCode::UNAUTHORIZED); + } + } + + #[test] + fn public_codes_match_the_oauth_registry() { + assert_eq!(TokenErrorCode::InvalidRequest.as_str(), "invalid_request"); + assert_eq!(TokenErrorCode::InvalidClient.as_str(), "invalid_client"); + assert_eq!( + TokenErrorCode::UnsupportedGrantType.as_str(), + "unsupported_grant_type" + ); + assert_eq!(TokenErrorCode::ServerError.as_str(), "server_error"); + } +} diff --git a/crates/registry-mint/src/lib.rs b/crates/registry-mint/src/lib.rs new file mode 100644 index 000000000..21d1c44de --- /dev/null +++ b/crates/registry-mint/src/lib.rs @@ -0,0 +1,66 @@ +//! Registry Mint: a minimal OAuth 2.0 token issuer for RegistryStack services. +//! +//! Mint exists to answer one question that a JWKS alone cannot answer: *which +//! principal signed this token, and what is that principal allowed to assert?* +//! +//! A resource server such as Evidence verifies an access token by selecting a +//! key from a JWKS using the token's own `kid` header, then reading the +//! authority claims out of the payload. Nothing in that flow binds a key to a +//! permitted claim set, so every key published in a JWKS is equally +//! authoritative for every claim. Distributing signing keys directly to callers +//! therefore makes each caller an issuer able to speak as any other. +//! +//! Mint keeps that binding server-side. Callers hold their own private keys and +//! authenticate with an RFC 7523 `private_key_jwt` client assertion. Mint +//! verifies that assertion against **only the keys registered for the asserted +//! client**, then mints an access token whose authority claims are read from +//! the server-side client registry and never from the assertion. A caller can +//! prove who it is; it cannot choose what it is allowed to say. +//! +//! # Trust split +//! +//! - Issuer identity, signing and audit keys, listener, and token policy are startup-only +//! and immutable for the process lifetime. +//! - The client registry is reloadable, so onboarding, offboarding, and key +//! rotation for callers never require restarting a resource server. +//! +//! That split is the point of running Mint as a separate process: Evidence +//! keeps its immutable governed bundle while the caller population changes. +//! +//! # Naming +//! +//! "Issuer" here means OAuth token issuance. It is unrelated to the verifiable +//! credential issuance performed by Registry Notary. + +#[cfg(not(unix))] +compile_error!( + "registry-mint requires a Unix target for owner-only signing and audit file guarantees" +); + +pub mod assertion; +pub mod audit; +pub mod caller; +pub mod clients; +pub mod config; +pub mod error; +pub mod replay; +pub mod secretfile; +pub mod server; +pub mod token; + +/// RFC 7523 client assertion type for `private_key_jwt` authentication. +pub const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +/// The only grant type Mint issues tokens for. +pub const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; + +/// Media type of the minted access tokens. +pub const ACCESS_TOKEN_TYP: &str = "at+jwt"; + +/// The client assertion member naming the actor and subject a delegated token +/// is requested for. +/// +/// This is Mint's own member, not RFC 8693 `act`: token exchange presents a +/// subject's own credential, which is precisely what a deployment without an +/// identity provider does not have. +pub const ON_BEHALF_OF_CLAIM: &str = "on_behalf_of"; diff --git a/crates/registry-mint/src/main.rs b/crates/registry-mint/src/main.rs new file mode 100644 index 000000000..29d8e52ad --- /dev/null +++ b/crates/registry-mint/src/main.rs @@ -0,0 +1,387 @@ +//! The `mint` binary. +//! +//! Three subcommands. `check` validates a deployment without opening a socket +//! and `serve` runs the token endpoint; `SIGHUP` reloads the client registry in +//! place so onboarding a caller never restarts the service. +//! +//! `token` is the odd one out: it is a *caller* tool, not an operator one. It +//! reads no server configuration and never touches Mint's signing key. It signs +//! a client assertion with the caller's own key and presents it to a running +//! token endpoint, which then decides on its own terms. Obtaining a token still +//! requires authenticating, in the CLI exactly as over the wire. + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::ExitCode, + sync::Arc, +}; + +use clap::{Parser, Subcommand}; +use registry_mint::{ + audit::MintAuditLog, + caller::{sign_client_assertion, AssertionRequest}, + config::MintConfig, + secretfile, + server::{serve, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, +}; +use registry_platform_audit::OptionalHashHex; +use serde_json::Value; + +#[derive(Debug, Parser)] +#[command(name = "mint", about = "Registry Stack token issuer", version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Load the configuration, keys, audit chain, and client registry, then exit. + Check { + #[arg(long, env = "MINT_CONFIG")] + config: PathBuf, + }, + /// Serve the token endpoint until terminated. + Serve { + #[arg(long, env = "MINT_CONFIG")] + config: PathBuf, + }, + /// Verify the retained keyed Mint audit chain named by the configuration. + VerifyAudit { + #[arg(long, env = "MINT_CONFIG")] + config: PathBuf, + }, + /// Obtain an access token from a running token endpoint, as a client would. + /// + /// This authenticates. It signs a client assertion with the caller's own + /// key and posts it; the endpoint decides. Nothing here can produce a token + /// the same request over the wire would not have produced. + Token { + /// The token endpoint, for example `https://mint.example.org/token`. + #[arg(long)] + url: String, + /// The `clientId` this caller is registered under. + #[arg(long)] + client_id: String, + /// The caller's private JWK. Must be owner-only and not a symlink. + #[arg(long)] + key: PathBuf, + /// The endpoint's configured `clientAssertion.audience`. Defaults to + /// `--url`, which is the usual configuration. + #[arg(long)] + audience: Option, + /// Request a delegated token for this actor. Requires `--subject-file`. + #[arg(long)] + actor: Option, + /// A JSON object of subject selector fields, for the actor to act for. + /// + /// A file rather than repeated flags on purpose: these are a real + /// person's identifying details, and command lines are visible to every + /// process on the host and land in shell history. + #[arg(long)] + subject_file: Option, + /// Assertion lifetime in seconds. + #[arg(long, default_value_t = 120)] + lifetime_seconds: i64, + /// Trust this PEM certificate bundle in addition to the system roots, + /// for a development deployment behind a private CA. + #[arg(long)] + ca_certificate: Option, + /// Print the full endpoint response instead of the access token alone. + #[arg(long)] + verbose: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + // `token` writes the access token to stdout and nothing else, so its + // diagnostics go to stderr and the caller can pipe the token straight into + // whatever needs it. The services keep structured logs on stdout. + let logs = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .json(); + if matches!(cli.command, Command::Token { .. }) { + logs.with_writer(std::io::stderr).init(); + } else { + logs.init(); + } + + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + // Startup failures name the failing stage, never the key material + // or the file contents that produced them. + tracing::error!(target: "registry_mint", "{message}"); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<(), String> { + match cli.command { + Command::Check { config } => { + let config = MintConfig::load(&config) + .map_err(|error| format!("the configuration could not be loaded: {error}"))?; + let clients = MintService::check(&config) + .map_err(|error| format!("the configuration cannot be served: {error}"))?; + tracing::info!( + target: "registry_mint", + issuer = config.issuer, + clients, + "configuration is valid" + ); + Ok(()) + } + Command::Serve { config } => { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("the async runtime could not start: {error}"))?; + runtime.block_on(async move { + let service = Arc::new(load(&config).await?); + let reloads = Arc::clone(&service); + tokio::spawn(async move { reload_on_hangup(reloads).await }); + serve(service, shutdown_signal()) + .await + .map_err(|error| format!("the listener failed: {error}")) + }) + } + Command::VerifyAudit { config } => { + let config = MintConfig::load(&config) + .map_err(|error| format!("the configuration could not be loaded: {error}"))?; + let summary = MintAuditLog::verify(&config.audit) + .map_err(|error| format!("the audit chain did not verify: {error}"))?; + let sealed_sequence = match (summary.first_sequence, summary.last_sequence) { + (Some(first), Some(last)) => format!("{first}-{last}"), + _ => "none".to_owned(), + }; + let active_segment = if summary.active_verified { + "verified" + } else { + "not verified: a running writer holds it, so only sealed history was proven" + }; + println!( + "segments: {}\nrecords: {}\nsealed-sequence: {}\nhead: {}\nactive-segment: {}", + summary.segments, + summary.records, + sealed_sequence, + OptionalHashHex(summary.last_hash), + active_segment, + ); + Ok(()) + } + Command::Token { + url, + client_id, + key, + audience, + actor, + subject_file, + lifetime_seconds, + ca_certificate, + verbose, + } => { + // The caller's key gets the same file guarantees as Mint's own: + // a regular file, owned by this user, unreadable by anyone else, + // and reached without traversing a symlink. + let key = secretfile::read_owner_only(&key) + .map_err(|error| format!("the client key could not be read: {error}"))?; + let key = registry_platform_crypto::PrivateJwk::parse(&key) + .map_err(|error| format!("the client key is not a usable private JWK: {error}"))?; + + let subject = subject_file.as_deref().map(read_subject).transpose()?; + let assertion = sign_client_assertion( + &key, + &AssertionRequest { + client_id: &client_id, + audience: audience.as_deref().unwrap_or(&url), + lifetime_seconds, + actor: actor.as_deref(), + subject, + }, + now_seconds()?, + ) + .map_err(|error| format!("the client assertion could not be built: {error}"))?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("the async runtime could not start: {error}"))?; + let response = + runtime.block_on(request_token(&url, &assertion, ca_certificate.as_deref()))?; + + if verbose { + println!("{response}"); + } else { + let token = response + .get("access_token") + .and_then(Value::as_str) + .ok_or_else(|| "the endpoint returned no access token".to_owned())?; + println!("{token}"); + } + Ok(()) + } + } +} + +/// Read the delegation subject: a flat JSON object of selector fields. +fn read_subject(path: &Path) -> Result, String> { + let bytes = std::fs::read(path) + .map_err(|error| format!("the subject file could not be read: {error}"))?; + let subject: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("the subject file is not JSON: {error}"))?; + let Value::Object(fields) = subject else { + return Err("the subject file must hold a JSON object of selector fields".to_owned()); + }; + // Selector values are scalars. Rejecting anything else here names the + // problem, where the endpoint could only answer `invalid_client`. + for (name, value) in &fields { + if value.is_object() || value.is_array() || value.is_null() { + return Err(format!("the subject field `{name}` must be a scalar value")); + } + } + Ok(fields.into_iter().collect()) +} + +async fn request_token( + url: &str, + assertion: &str, + ca_certificate: Option<&Path>, +) -> Result { + let mut client = reqwest::Client::builder(); + if let Some(path) = ca_certificate { + let pem = std::fs::read(path) + .map_err(|error| format!("the CA certificate could not be read: {error}"))?; + for certificate in reqwest::Certificate::from_pem_bundle(&pem) + .map_err(|error| format!("the CA certificate could not be parsed: {error}"))? + { + client = client.add_root_certificate(certificate); + } + } + let client = client + .build() + .map_err(|error| format!("the HTTP client could not be built: {error}"))?; + + let response = client + .post(url) + .form(&[ + ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), + ("client_assertion_type", CLIENT_ASSERTION_TYPE), + ("client_assertion", assertion), + ]) + .send() + .await + .map_err(|error| format!("the token request failed: {error}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("the token response could not be read: {error}"))?; + if !status.is_success() { + // The request carried a signed client assertion, which is a bearer + // credential at the endpoint it is bound to until it expires. Whatever + // answered here is not necessarily that endpoint, and a refusal body is + // free to quote the form back. Report the two bounded OAuth fields and + // drop the rest rather than write the assertion into stderr, the + // operator's logs, and their scrollback. + return Err(format!( + "the endpoint refused the request ({status}): {}", + oauth_error(&body) + )); + } + serde_json::from_str(&body).map_err(|error| format!("the token response is not JSON: {error}")) +} + +/// Summarize a refusal using only the two fields RFC 6749 defines for one. +/// +/// Both are reproduced as printable ASCII within the length the RFC's own +/// grammar allows, so a hostile or merely careless endpoint cannot use the +/// refusal to write arbitrary bytes, control sequences, or the caller's own +/// request into the terminal. +fn oauth_error(body: &str) -> String { + let Ok(Value::Object(fields)) = serde_json::from_str::(body) else { + return "the response carried no OAuth error".to_owned(); + }; + let field = |name: &str| -> Option { + let value = fields.get(name)?.as_str()?; + let bounded: String = value + .chars() + .filter(|character| { + character.is_ascii_graphic() || *character == ' ' || *character == '\t' + }) + .take(200) + .collect(); + (!bounded.is_empty()).then_some(bounded) + }; + match (field("error"), field("error_description")) { + (Some(error), Some(description)) => format!("{error}: {description}"), + (Some(error), None) => error, + _ => "the response carried no OAuth error".to_owned(), + } +} + +fn now_seconds() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .map_err(|_| "the system clock is before the Unix epoch".to_owned()) +} + +async fn load(config: &Path) -> Result { + let config = MintConfig::load(config) + .map_err(|error| format!("the configuration could not be loaded: {error}"))?; + MintService::load(config) + .await + .map_err(|error| format!("the service could not start: {error}")) +} + +/// Reload the client registry on every `SIGHUP`, keeping the previous registry +/// when the new one does not load. +async fn reload_on_hangup(service: Arc) { + let mut hangup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) { + Ok(hangup) => hangup, + Err(error) => { + tracing::error!(target: "registry_mint", "the hangup handler could not be installed: {error}"); + return; + } + }; + while hangup.recv().await.is_some() { + match service.reload_clients() { + Ok(clients) => { + tracing::info!(target: "registry_mint", clients, "client registry reloaded"); + } + Err(error) => { + tracing::error!( + target: "registry_mint", + "the client registry was not reloaded and the previous one is still in use: {error}" + ); + } + } + } +} + +async fn shutdown_signal() { + let interrupt = async { + let _ = tokio::signal::ctrl_c().await; + }; + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut terminate) => { + terminate.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { + () = interrupt => {} + () = terminate => {} + } +} diff --git a/crates/registry-mint/src/replay.rs b/crates/registry-mint/src/replay.rs new file mode 100644 index 000000000..931d89e6c --- /dev/null +++ b/crates/registry-mint/src/replay.rs @@ -0,0 +1,116 @@ +//! Single-use enforcement for client assertion `jti` values. +//! +//! A captured client assertion is a bearer credential until it expires. The +//! cache remembers every accepted `jti` until its own expiry, so a captured +//! assertion buys an attacker nothing. + +use std::{collections::HashMap, sync::Mutex}; + +use thiserror::Error; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ReplayError { + #[error("the assertion identifier has already been used")] + AlreadyUsed, + #[error("the replay cache is saturated")] + Saturated, + #[error("the replay cache is poisoned")] + Poisoned, +} + +/// A bounded set of assertion identifiers that have already been spent. +#[derive(Debug)] +pub struct ReplayCache { + capacity: usize, + entries: Mutex>, +} + +impl ReplayCache { + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity, + entries: Mutex::new(HashMap::new()), + } + } + + /// Record `jti` as spent until `expires_at`. + /// + /// Saturation fails closed rather than evicting a live entry. Evicting the + /// oldest entry would let a caller flush the cache with fresh assertions + /// and then replay the one it evicted, which is precisely what this cache + /// exists to prevent. Only clients that already passed signature + /// verification can reach this code, so the failure is bounded to + /// authenticated misbehaviour and is visible to operators. + pub fn remember(&self, jti: &str, expires_at: i64, now: i64) -> Result<(), ReplayError> { + let mut entries = self.entries.lock().map_err(|_| ReplayError::Poisoned)?; + entries.retain(|_, entry_expiry| *entry_expiry > now); + if entries.contains_key(jti) { + return Err(ReplayError::AlreadyUsed); + } + if entries.len() >= self.capacity { + return Err(ReplayError::Saturated); + } + entries.insert(jti.to_owned(), expires_at); + Ok(()) + } + + #[must_use] + pub fn len(&self) -> usize { + self.entries + .lock() + .map(|entries| entries.len()) + .unwrap_or(0) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_identifier_is_accepted_once_and_then_refused() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!( + cache.remember("jti-1", 100, 0), + Err(ReplayError::AlreadyUsed) + ); + } + + #[test] + fn distinct_identifiers_do_not_collide() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); + assert_eq!(cache.len(), 2); + } + + #[test] + fn entries_are_pruned_once_their_own_expiry_passes() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + // At 101 the assertion is expired anyway, so forgetting it is safe and + // the slot is reclaimed. + assert_eq!(cache.remember("jti-2", 200, 101), Ok(())); + assert_eq!(cache.len(), 1); + } + + #[test] + fn saturation_fails_closed_instead_of_evicting_a_live_entry() { + let cache = ReplayCache::new(2); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-3", 100, 0), Err(ReplayError::Saturated)); + // The entry an attacker would have wanted evicted is still remembered. + assert_eq!( + cache.remember("jti-1", 100, 0), + Err(ReplayError::AlreadyUsed) + ); + } +} diff --git a/crates/registry-mint/src/secretfile.rs b/crates/registry-mint/src/secretfile.rs new file mode 100644 index 000000000..1cc693df2 --- /dev/null +++ b/crates/registry-mint/src/secretfile.rs @@ -0,0 +1,116 @@ +//! Bounded, owner-only reads of private key material. +//! +//! Mint holds an access-token signing key and an audit HMAC key. Client +//! registrations carry public keys only, so this module is deliberately small +//! and is the single file-read boundary for private material. + +use std::{fs, os::unix::fs::MetadataExt, path::Path}; + +use thiserror::Error; +use zeroize::Zeroizing; + +/// Upper bound on a Mint secret file, generous for any supported JWK or HMAC key. +pub const MAX_SECRET_BYTES: u64 = 64 * 1024; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SecretFileError { + #[error("the secret file is unavailable")] + Unavailable, + #[error("the secret file is not a regular, single-link, owner-only file")] + Unsafe, + #[error("the secret file is too large")] + TooLarge, + #[error("the secret file could not be read")] + Read, + #[error("the secret file is not valid UTF-8")] + InvalidValue, +} + +/// Read a secret file that must be a regular file, owned by the running user, +/// unreadable by group and other, and reachable without traversing a symlink. +/// +/// `symlink_metadata` is used rather than `metadata` so a symlink fails the +/// regular-file check instead of being silently followed to its target. The +/// link count is pinned to one so a hard link created by another user cannot +/// alias the same inode under weaker permissions. +pub fn read_owner_only(path: &Path) -> Result, SecretFileError> { + let metadata = fs::symlink_metadata(path).map_err(|_| SecretFileError::Unavailable)?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(SecretFileError::Unsafe); + } + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(SecretFileError::Unsafe); + } + if metadata.mode() & 0o077 != 0 { + return Err(SecretFileError::Unsafe); + } + if metadata.len() > MAX_SECRET_BYTES { + return Err(SecretFileError::TooLarge); + } + let bytes = Zeroizing::new(fs::read(path).map_err(|_| SecretFileError::Read)?); + let text = std::str::from_utf8(&bytes).map_err(|_| SecretFileError::InvalidValue)?; + Ok(Zeroizing::new(text.trim().to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, os::unix::fs::PermissionsExt}; + + fn write_key(directory: &Path, name: &str, mode: u32) -> std::path::PathBuf { + let path = directory.join(name); + let mut file = fs::File::create(&path).expect("create key file"); + file.write_all(b" key-material ").expect("write key file"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("set mode"); + path + } + + #[test] + fn owner_only_files_are_read_and_trimmed() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = write_key(directory.path(), "signing.jwk", 0o600); + let value = read_owner_only(&path).expect("owner-only file reads"); + assert_eq!(&*value, "key-material"); + } + + #[test] + fn group_or_world_readable_files_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + for mode in [0o640, 0o604, 0o644, 0o660] { + let path = write_key(directory.path(), &format!("key-{mode:o}.jwk"), mode); + assert_eq!( + read_owner_only(&path), + Err(SecretFileError::Unsafe), + "mode {mode:o} must be rejected" + ); + } + } + + #[test] + fn symlinked_and_hard_linked_secrets_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + let target = write_key(directory.path(), "target.jwk", 0o600); + + let symlink = directory.path().join("symlink.jwk"); + std::os::unix::fs::symlink(&target, &symlink).expect("create symlink"); + assert_eq!(read_owner_only(&symlink), Err(SecretFileError::Unsafe)); + + let hard_link = directory.path().join("hard.jwk"); + fs::hard_link(&target, &hard_link).expect("create hard link"); + assert_eq!(read_owner_only(&hard_link), Err(SecretFileError::Unsafe)); + assert_eq!(read_owner_only(&target), Err(SecretFileError::Unsafe)); + } + + #[test] + fn directories_and_missing_paths_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + assert_eq!( + read_owner_only(directory.path()), + Err(SecretFileError::Unsafe) + ); + assert_eq!( + read_owner_only(&directory.path().join("absent.jwk")), + Err(SecretFileError::Unavailable) + ); + } +} diff --git a/crates/registry-mint/src/server.rs b/crates/registry-mint/src/server.rs new file mode 100644 index 000000000..5c6cca1f4 --- /dev/null +++ b/crates/registry-mint/src/server.rs @@ -0,0 +1,666 @@ +//! The Mint HTTP boundary. +//! +//! Four routes: the token endpoint, the published key set, authorization server +//! metadata, and the two liveness probes. Everything a caller sends is treated +//! as an unauthenticated claim about identity until the client assertion has +//! been verified against that client's own registered keys. +//! +//! The service holds two kinds of state with deliberately different lifetimes. +//! Issuer identity, signing and audit keys, listener, and token policy are startup-only: +//! changing them means restarting. The client registry is reloadable, so +//! onboarding or removing a caller never restarts a resource server. + +use std::{ + future::{Future, IntoFuture}, + io, + net::SocketAddr, + sync::{Arc, RwLock}, + time::Duration, +}; + +use axum::{ + body::{to_bytes, Body}, + extract::State, + http::{ + header::{CACHE_CONTROL, CONTENT_TYPE, PRAGMA}, + HeaderMap, HeaderValue, Request, StatusCode, + }, + middleware::{from_fn, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use serde_json::{json, Value}; +use thiserror::Error; +use tokio::net::TcpListener; + +use crate::{ + assertion::ClientAuthenticator, + audit::{MintAuditError, MintAuditLog}, + clients::{ClientRegistry, ClientRegistryError}, + config::{MintConfig, MINT_HEALTH_PATH, MINT_METADATA_PATH, MINT_READY_PATH, MINT_TOKEN_PATH}, + error::TokenError, + replay::ReplayCache, + token::{MinterError, TokenMinter}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, +}; + +const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; +const JSON_MEDIA_TYPE: &str = "application/json"; +const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; + +#[derive(Debug, Error)] +pub enum ServiceError { + #[error("the signing key could not be loaded: {0}")] + Minter(#[from] MinterError), + #[error("the client registry could not be loaded: {0}")] + Registry(#[from] ClientRegistryError), + #[error("the audit boundary could not be initialized: {0}")] + Audit(#[from] MintAuditError), + #[error("client {0} cannot be served: {1}")] + Delegation(String, &'static str), +} + +/// The whole serving state: an immutable minter over a reloadable registry. +pub struct MintService { + config: MintConfig, + minter: TokenMinter, + /// Swapped wholesale on reload. Readers clone the `Arc` and release the + /// lock before any await, so a reload never blocks in-flight requests. + authenticator: RwLock>, + /// Owned by the service rather than the authenticator so that reloading the + /// registry never forgets which assertion identifiers were already spent. + replay: Arc, + audit: MintAuditLog, + metadata: Value, +} + +impl std::fmt::Debug for MintService { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MintService") + .field("issuer", &self.config.issuer) + .field("clients", &self.client_count()) + .finish_non_exhaustive() + } +} + +impl MintService { + /// Load the keys, audit chain, and client registry described by `config`. + pub async fn load(config: MintConfig) -> Result { + let minter = TokenMinter::new(&config)?; + let registry = Arc::new(ClientRegistry::load(&config.clients.directory)?); + check_delegations(®istry, minter.claims())?; + let replay = Arc::new(ReplayCache::new( + config.client_assertion.replay_cache_entries, + )); + let authenticator = + ClientAuthenticator::new(registry, &config.client_assertion, Arc::clone(&replay)); + let audit = MintAuditLog::initialize(&config.audit, &config.issuer).await?; + let metadata = build_metadata(&config); + Ok(Self { + config, + minter, + authenticator: RwLock::new(Arc::new(authenticator)), + replay, + audit, + metadata, + }) + } + + /// Validate a configuration without taking what a serving instance holds. + /// + /// Everything [`MintService::load`] does except claiming the audit writer, + /// so an operator can check an edited configuration against the deployment + /// it is about to replace. Returns the number of registered clients. + pub fn check(config: &MintConfig) -> Result { + let minter = TokenMinter::new(config)?; + let registry = ClientRegistry::load(&config.clients.directory)?; + check_delegations(®istry, minter.claims())?; + MintAuditLog::check(&config.audit)?; + Ok(registry.len()) + } + + /// Re-read the client registry directory and swap it in atomically. + /// + /// A failed reload leaves the previous registry in place: a malformed file + /// dropped into the directory must not silently revoke every caller. + pub fn reload_clients(&self) -> Result { + let registry = Arc::new(ClientRegistry::load(&self.config.clients.directory)?); + // Checked on every reload, not only at startup: a registration dropped + // into the directory later must clear the same bar. + check_delegations(®istry, self.minter.claims())?; + let count = registry.len(); + let authenticator = Arc::new(ClientAuthenticator::new( + registry, + &self.config.client_assertion, + Arc::clone(&self.replay), + )); + *self + .authenticator + .write() + .expect("the client registry lock is never poisoned") = authenticator; + Ok(count) + } + + #[must_use] + pub fn client_count(&self) -> usize { + self.authenticator().registry().len() + } + + #[must_use] + pub fn issuer(&self) -> &str { + &self.config.issuer + } + + #[must_use] + pub fn jwks(&self) -> &Value { + self.minter.jwks() + } + + fn authenticator(&self) -> Arc { + Arc::clone( + &self + .authenticator + .read() + .expect("the client registry lock is never poisoned"), + ) + } + + /// Authenticate a token request and mint the authority its registry entry + /// carries. Nothing is read from the assertion payload. + async fn issue( + &self, + operation: &str, + request: &TokenRequest, + now: i64, + ) -> Result { + if request.grant_type != GRANT_TYPE_CLIENT_CREDENTIALS { + return Err(TokenError::unsupported_grant_type( + "grant type is not supported", + )); + } + if request.client_assertion_type != CLIENT_ASSERTION_TYPE { + return Err(TokenError::invalid_request( + "client assertion type is not supported", + )); + } + + // Cloned out of the lock so a concurrent reload cannot block here. + let authenticator = self.authenticator(); + let authenticated = authenticator + .authenticate(&request.client_assertion, now) + .await?; + let token = self.minter.mint(&authenticated, now).await?; + let body = serde_json::to_vec(&token) + .map_err(|_| TokenError::server_error("the token response could not be serialized"))?; + self.audit + .append_issued(operation, &authenticated, &token) + .await + .map_err(|_| TokenError::server_error("the token release could not be audited"))?; + Ok(json_response(StatusCode::OK, JSON_MEDIA_TYPE, body)) + } + + async fn reject(&self, operation: &str, error: TokenError) -> Response { + if self + .audit + .append_rejected(operation, error.code().as_str()) + .await + .is_err() + { + tracing::error!( + target: "registry_mint::audit", + operation, + "the token denial could not be audited" + ); + return TokenError::server_error("the token decision could not be audited") + .into_operation_response(operation); + } + error.into_operation_response(operation) + } + + #[must_use] + async fn ready(&self) -> bool { + self.client_count() > 0 && self.audit.ready().await + } +} + +/// Refuse a registry whose delegations this configuration cannot express. +/// +/// The registry and the claim-name configuration are loaded independently, so +/// this is the only place their agreement can be established. A disagreement +/// caught here is an operator error at startup or reload; caught at the first +/// token request instead, it would be an outage for one caller and a token +/// missing its actor for another. +fn check_delegations( + registry: &ClientRegistry, + claims: &crate::config::ClaimNames, +) -> Result<(), ServiceError> { + // The claims Mint writes itself. A subject minted over one of these would + // replace authority the registry, not the caller, is supposed to decide. + let mut reserved = vec!["iss", "aud", "exp", "iat", "nbf", "jti", "client_id", "sub"]; + reserved.push(claims.principal.as_str()); + reserved.push(claims.requester_tags.as_str()); + reserved.push(claims.evidence_audience.as_str()); + reserved.push(claims.grant_id.as_str()); + reserved.push(claims.grant_authority.as_str()); + reserved.extend(claims.actor.as_deref()); + + for client_id in registry.client_ids() { + let client = registry + .get(client_id) + .expect("client id came from this registry"); + let Some(delegation) = client.delegation() else { + continue; + }; + if claims.actor.is_none() { + return Err(ServiceError::Delegation( + client_id.to_owned(), + "it declares a delegation but no actor claim name is configured", + )); + } + for path in delegation.subject_claims.values() { + let root = path.split('.').next().unwrap_or(path); + if reserved.contains(&root) { + return Err(ServiceError::Delegation( + client_id.to_owned(), + "a subject claim path would overwrite an authority claim", + )); + } + } + } + Ok(()) +} + +fn build_metadata(config: &MintConfig) -> Value { + let issuer = config.issuer.trim_end_matches('/'); + let algorithms = { + let mut algorithms = config + .client_assertion + .algorithms + .iter() + .map(|algorithm| algorithm.as_header_value()) + .collect::>(); + algorithms.sort_unstable(); + algorithms.dedup(); + algorithms + }; + json!({ + "issuer": config.issuer, + "token_endpoint": format!("{issuer}{MINT_TOKEN_PATH}"), + "jwks_uri": format!("{issuer}{}", config.signing.jwks_path), + "grant_types_supported": [GRANT_TYPE_CLIENT_CREDENTIALS], + "token_endpoint_auth_methods_supported": ["private_key_jwt"], + "token_endpoint_auth_signing_alg_values_supported": algorithms, + // Mint has no authorization endpoint: there is no user to redirect. + "response_types_supported": [], + }) +} + +/// The three parameters Mint reads from a token request. +/// +/// RFC 6749 section 3.1 requires unrecognized parameters to be ignored and +/// forbids any parameter appearing more than once, so this is parsed by hand +/// rather than through a permissive form deserializer. +#[derive(Debug)] +struct TokenRequest { + grant_type: String, + client_assertion_type: String, + client_assertion: String, +} + +fn parse_token_request(body: &[u8]) -> Result { + let mut grant_type = None; + let mut client_assertion_type = None; + let mut client_assertion = None; + + for (name, value) in url::form_urlencoded::parse(body) { + let slot = match name.as_ref() { + "grant_type" => &mut grant_type, + "client_assertion_type" => &mut client_assertion_type, + "client_assertion" => &mut client_assertion, + // Ignored by RFC 6749 section 3.1. + _ => continue, + }; + // A repeated parameter leaves which value was authenticated ambiguous. + if slot.is_some() { + return Err(TokenError::invalid_request( + "a request parameter was repeated", + )); + } + *slot = Some(value.into_owned()); + } + + Ok(TokenRequest { + grant_type: grant_type + .ok_or_else(|| TokenError::invalid_request("grant_type is missing"))?, + client_assertion_type: client_assertion_type + .ok_or_else(|| TokenError::invalid_request("client_assertion_type is missing"))?, + client_assertion: client_assertion + .ok_or_else(|| TokenError::invalid_request("client_assertion is missing"))?, + }) +} + +/// Build the router over an already loaded service. +pub fn build_app(service: Arc) -> Router { + let jwks_path = service.config.signing.jwks_path.clone(); + let routes = Router::new() + .route(MINT_TOKEN_PATH, post(token)) + .route(&jwks_path, get(jwks)) + .route(MINT_METADATA_PATH, get(metadata)) + .route(MINT_HEALTH_PATH, get(health)) + .route(MINT_READY_PATH, get(ready)) + .fallback(unknown_route) + .method_not_allowed_fallback(unknown_route) + .with_state(service); + routes.layer(from_fn(add_no_store)) +} + +/// Bind the configured listener and serve until `shutdown` resolves. +pub async fn serve(service: Arc, shutdown: F) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let bind_ip = service + .config + .listener + .bind_address() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let address = SocketAddr::new(bind_ip, service.config.listener.port); + let listener = TcpListener::bind(address).await?; + tracing::info!( + target: "registry_mint::server", + issuer = %service.config.issuer, + clients = service.client_count(), + "mint listening" + ); + let app = build_app(service); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .into_future() + .await +} + +async fn token(State(service): State>, request: Request) -> Response { + let operation = format!("urn:ulid:{}", ulid::Ulid::new()); + if !has_exact_content_type(request.headers(), FORM_MEDIA_TYPE) { + return service + .reject( + &operation, + TokenError::invalid_request("content type must be form encoded"), + ) + .await; + } + + let maximum_bytes = service.config.listener.maximum_request_bytes as usize; + let timeout = Duration::from_millis(service.config.listener.request_timeout_milliseconds); + let body = + match tokio::time::timeout(timeout, to_bytes(request.into_body(), maximum_bytes)).await { + Ok(Ok(body)) => body, + Ok(Err(_)) => { + return service + .reject( + &operation, + TokenError::invalid_request("the request body could not be read"), + ) + .await + } + Err(_) => { + return service + .reject( + &operation, + TokenError::invalid_request("the request body timed out"), + ) + .await; + } + }; + + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let parsed = match parse_token_request(&body) { + Ok(parsed) => parsed, + Err(error) => return service.reject(&operation, error).await, + }; + match service.issue(&operation, &parsed, now).await { + Ok(response) => response, + Err(error) => service.reject(&operation, error).await, + } +} + +async fn jwks(State(service): State>) -> Response { + match serde_json::to_vec(service.jwks()) { + Ok(body) => json_response(StatusCode::OK, JWKS_MEDIA_TYPE, body), + Err(_) => TokenError::server_error("the key set could not be serialized").into_response(), + } +} + +async fn metadata(State(service): State>) -> Response { + match serde_json::to_vec(&service.metadata) { + Ok(body) => json_response(StatusCode::OK, JSON_MEDIA_TYPE, body), + Err(_) => TokenError::server_error("the metadata could not be serialized").into_response(), + } +} + +async fn health() -> Response { + json_response( + StatusCode::OK, + JSON_MEDIA_TYPE, + br#"{"status":"ok"}"#.to_vec(), + ) +} + +async fn ready(State(service): State>) -> Response { + // A Mint with no clients or a poisoned audit writer is live but cannot + // safely issue a token, so admission fails until the process is repaired. + if !service.ready().await { + return json_response( + StatusCode::SERVICE_UNAVAILABLE, + JSON_MEDIA_TYPE, + br#"{"status":"not ready"}"#.to_vec(), + ); + } + json_response( + StatusCode::OK, + JSON_MEDIA_TYPE, + br#"{"status":"ready"}"#.to_vec(), + ) +} + +async fn unknown_route() -> Response { + TokenError::invalid_request("no such route").into_response() +} + +/// RFC 6749 section 5.1 requires both headers on token responses. Applying them +/// to every route keeps the key set and metadata out of shared caches too. +async fn add_no_store(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert(PRAGMA, HeaderValue::from_static("no-cache")); + headers.insert( + http::header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +fn json_response(status: StatusCode, media_type: &'static str, body: Vec) -> Response { + ( + status, + [(CONTENT_TYPE, HeaderValue::from_static(media_type))], + body, + ) + .into_response() +} + +fn has_exact_content_type(headers: &HeaderMap, expected: &str) -> bool { + let Some(value) = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + // Only a bare type or one carrying the redundant charset is accepted; a + // multipart or otherwise decorated type is not this endpoint's input. + let (media_type, parameters) = match value.split_once(';') { + Some((media_type, parameters)) => (media_type, Some(parameters)), + None => (value, None), + }; + if !media_type.trim().eq_ignore_ascii_case(expected) { + return false; + } + match parameters { + None => true, + Some(parameters) => parameters.trim().eq_ignore_ascii_case("charset=utf-8"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_repeated_parameter_is_rejected() { + let error = parse_token_request(b"grant_type=a&grant_type=b") + .expect_err("a repeated parameter must be rejected"); + assert_eq!( + error, + TokenError::invalid_request("a request parameter was repeated") + ); + } + + #[test] + fn unrecognized_parameters_are_ignored() { + let request = parse_token_request( + b"grant_type=client_credentials&scope=anything&client_assertion_type=t&client_assertion=a", + ) + .expect("unrecognized parameters must be ignored"); + assert_eq!(request.grant_type, "client_credentials"); + assert_eq!(request.client_assertion_type, "t"); + assert_eq!(request.client_assertion, "a"); + } + + #[test] + fn each_required_parameter_is_required() { + for body in [ + &b"client_assertion_type=t&client_assertion=a"[..], + &b"grant_type=g&client_assertion=a"[..], + &b"grant_type=g&client_assertion_type=t"[..], + ] { + let error = + parse_token_request(body).expect_err("a missing parameter must be rejected"); + assert_eq!(error.code(), crate::error::TokenErrorCode::InvalidRequest); + } + } + + #[test] + fn content_type_must_be_the_form_media_type() { + let mut headers = HeaderMap::new(); + for (value, expected) in [ + ("application/x-www-form-urlencoded", true), + ("application/x-www-form-urlencoded; charset=utf-8", true), + ("application/x-www-form-urlencoded; charset=UTF-8", true), + ("application/json", false), + ("multipart/form-data; boundary=x", false), + ("application/x-www-form-urlencoded; boundary=x", false), + ] { + headers.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("header")); + assert_eq!( + has_exact_content_type(&headers, FORM_MEDIA_TYPE), + expected, + "{value}" + ); + } + } + + fn registry_with(extra: &str) -> ClientRegistry { + let directory = tempfile::tempdir().expect("temp dir"); + let public = crate::assertion::tests::test_key(1).1; + std::fs::write( + directory.path().join("client-a.yaml"), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [tag-a]\nkeys: [{public}]\n{extra}"), + ) + .expect("write client registration"); + ClientRegistry::load(directory.path()).expect("registry loads") + } + + fn claim_names() -> crate::config::ClaimNames { + crate::config::tests::sample_config().access_tokens.claims + } + + const DELEGATION: &str = "delegation:\n subjectClaims:\n given_name: identity.given_name\n"; + + /// The registry and the claim-name configuration are loaded independently, + /// so a delegation with nowhere to mint its actor has to be caught here or + /// not at all. + #[test] + fn a_delegation_without_a_configured_actor_claim_refuses_to_load() { + let mut claims = claim_names(); + claims.actor = None; + let error = check_delegations(®istry_with(DELEGATION), &claims) + .expect_err("an unconfigured actor claim must refuse the registry"); + assert!(matches!(error, ServiceError::Delegation(client, _) if client == "client-a")); + + claims.actor = Some("evidence_actor".to_owned()); + assert!(check_delegations(®istry_with(DELEGATION), &claims).is_ok()); + } + + /// A subject path rooted at a claim Mint writes itself would let the caller + /// choose authority the registry is supposed to decide. + #[test] + fn a_subject_path_rooted_at_an_authority_claim_refuses_to_load() { + let mut claims = claim_names(); + claims.actor = Some("evidence_actor".to_owned()); + + for root in [ + "iss", + "sub", + "jti", + "client_id", + claims.requester_tags.as_str(), + claims.evidence_audience.as_str(), + claims.grant_id.as_str(), + claims.grant_authority.as_str(), + "evidence_actor", + ] { + let registry = registry_with(&format!( + "delegation:\n subjectClaims:\n given_name: {root}.given_name\n" + )); + assert!( + check_delegations(®istry, &claims).is_err(), + "a subject path rooted at {root} must be refused" + ); + } + } + + /// A registry with no delegations is unaffected by the actor claim either + /// way, so an existing deployment does not have to configure one. + #[test] + fn an_undelegated_registry_loads_without_an_actor_claim() { + let mut claims = claim_names(); + claims.actor = None; + assert!(check_delegations(®istry_with(""), &claims).is_ok()); + } + + #[test] + fn metadata_describes_the_endpoints_a_client_needs() { + let config = crate::config::tests::sample_config(); + let metadata = build_metadata(&config); + assert_eq!(metadata["issuer"], json!("https://mint.example.org")); + assert_eq!( + metadata["token_endpoint"], + json!("https://mint.example.org/token") + ); + assert_eq!( + metadata["jwks_uri"], + json!("https://mint.example.org/.well-known/jwks.json") + ); + assert_eq!( + metadata["token_endpoint_auth_methods_supported"], + json!(["private_key_jwt"]) + ); + assert_eq!( + metadata["grant_types_supported"], + json!(["client_credentials"]) + ); + } +} diff --git a/crates/registry-mint/src/token.rs b/crates/registry-mint/src/token.rs new file mode 100644 index 000000000..ef8b9ef4c --- /dev/null +++ b/crates/registry-mint/src/token.rs @@ -0,0 +1,831 @@ +//! Access token minting. +//! +//! Every authority claim written here is read from the server-side client +//! registry. That is what makes a caller's private key a proof of *identity* +//! rather than a licence to assert whatever it likes: the caller chooses which +//! registry entry it authenticates as, and the registry chooses what that entry +//! may say. +//! +//! A delegated token is the one case where values reach a token from the +//! caller, and they arrive already reconciled against the registration by +//! [`crate::assertion`]: the actor is one the client may act as, and the +//! subject holds exactly the selector fields the registration declared, minted +//! at exactly the claim paths it declared. The registry still fixes the shape; +//! the caller only fills it in. +//! +//! Minting the subject into the token is what bounds a delegated token to one +//! person. A resource server configured to read that subject from the token +//! refuses any request carrying its own selector values, so a token issued for +//! one subject cannot be turned toward another, however the caller misbehaves. + +use std::path::Path; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use thiserror::Error; + +use crate::{ + assertion::AuthenticatedClient, + clients::{contains_private_material, Delegation, RegisteredClient}, + config::{Algorithm, ClaimNames, MintConfig}, + error::TokenError, + secretfile::{self, SecretFileError}, + ACCESS_TOKEN_TYP, +}; + +#[derive(Debug, Error)] +pub enum MinterError { + #[error("the signing key file could not be read: {0}")] + SigningKeyFile(#[from] SecretFileError), + #[error("the signing key is invalid: {0}")] + SigningKey(&'static str), + #[error("a retired public key is invalid: {0}")] + RetiredKey(&'static str), +} + +/// A minted access token and the lifetime the caller should assume. +#[derive(Debug, Serialize)] +pub struct MintedToken { + pub access_token: String, + pub token_type: &'static str, + pub expires_in: u64, + #[serde(skip)] + token_id: String, + #[serde(skip)] + signing_key_id: String, + #[serde(skip)] + expires_at_unix: i64, +} + +impl MintedToken { + #[must_use] + pub(crate) fn token_id(&self) -> &str { + &self.token_id + } + + #[must_use] + pub(crate) fn signing_key_id(&self) -> &str { + &self.signing_key_id + } + + #[must_use] + pub(crate) fn expires_at_unix(&self) -> i64 { + self.expires_at_unix + } +} + +/// Signs access tokens with the configured active key. +pub struct TokenMinter { + issuer: String, + audience: Value, + lifetime_seconds: i64, + claims: ClaimNames, + algorithm: Algorithm, + signer: LocalJwkSigner, + jwks: Value, +} + +impl std::fmt::Debug for TokenMinter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TokenMinter") + .field("issuer", &self.issuer) + .field("algorithm", &self.algorithm) + .field("key_id", &self.signer.key_id()) + .finish_non_exhaustive() + } +} + +impl TokenMinter { + /// Load the active signing key and build the published JWK set. + pub fn new(config: &MintConfig) -> Result { + let key_text = secretfile::read_owner_only(&config.signing.active_key_file)?; + let private = PrivateJwk::parse(&key_text) + .map_err(|_| MinterError::SigningKey("not a private JWK"))?; + + // A mismatch here would publish one key id and sign with another, so + // verifiers would fail to find the key that actually signed. + if private.kid.as_deref() != Some(config.signing.active_key_id.as_str()) { + return Err(MinterError::SigningKey( + "key id does not match the configured active key id", + )); + } + let signer = + LocalJwkSigner::new(private).map_err(|_| MinterError::SigningKey("is not usable"))?; + if signer.public_jwk().alg.as_deref() != Some(config.signing.algorithm.as_header_value()) { + return Err(MinterError::SigningKey( + "algorithm does not match the configured signing algorithm", + )); + } + + let jwks = build_jwks(&signer, &config.signing.retired_public_jwk_files)?; + + let audience = if config.access_tokens.audiences.len() == 1 { + Value::String(config.access_tokens.audiences[0].clone()) + } else { + Value::Array( + config + .access_tokens + .audiences + .iter() + .map(|audience| Value::String(audience.clone())) + .collect(), + ) + }; + + Ok(Self { + issuer: config.issuer.clone(), + audience, + lifetime_seconds: config.access_tokens.lifetime_seconds as i64, + claims: config.access_tokens.claims.clone(), + algorithm: config.signing.algorithm, + signer, + jwks, + }) + } + + /// The public key set resource servers fetch to verify minted tokens. + #[must_use] + pub fn jwks(&self) -> &Value { + &self.jwks + } + + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// The claim names this minter writes authority into. + #[must_use] + pub fn claims(&self) -> &ClaimNames { + &self.claims + } + + /// Mint an access token carrying the registry's authority for `client`. + pub async fn mint( + &self, + authenticated: &AuthenticatedClient, + now: i64, + ) -> Result { + let client: &RegisteredClient = &authenticated.client; + let expires_at = now + self.lifetime_seconds; + let token_id = ulid::Ulid::new().to_string(); + let mut claims = Map::new(); + claims.insert("iss".to_owned(), Value::String(self.issuer.clone())); + claims.insert("aud".to_owned(), self.audience.clone()); + claims.insert("iat".to_owned(), json!(now)); + claims.insert("nbf".to_owned(), json!(now)); + claims.insert("exp".to_owned(), json!(expires_at)); + claims.insert("jti".to_owned(), Value::String(token_id.clone())); + // `client_id` records which registration authenticated; the principal + // is what the resource server acts on. They are allowed to differ. + claims.insert( + "client_id".to_owned(), + Value::String(client.client_id().to_owned()), + ); + + // `sub` always carries the principal so the token is meaningful to a + // standard OAuth consumer, even when the resource server reads the + // principal from a differently named claim. + claims.insert( + "sub".to_owned(), + Value::String(client.principal().to_owned()), + ); + claims.insert( + self.claims.principal.clone(), + Value::String(client.principal().to_owned()), + ); + claims.insert( + self.claims.requester_tags.clone(), + Value::Array( + client + .requester_tags() + .iter() + .map(|tag| Value::String(tag.clone())) + .collect(), + ), + ); + claims.insert( + self.claims.evidence_audience.clone(), + Value::String(client.evidence_audience().to_owned()), + ); + // Evidence requires the grant id and authority together or not at all, + // which the registry already guarantees by construction. + if let Some(grant) = client.grant() { + claims.insert( + self.claims.grant_id.clone(), + Value::String(grant.id.clone()), + ); + claims.insert( + self.claims.grant_authority.clone(), + Value::String(grant.authority.clone()), + ); + } + + if let Some(delegation) = &authenticated.delegation { + let registered = client.delegation().ok_or_else(|| { + TokenError::server_error("a delegation was resolved for an undelegated client") + })?; + // Startup refuses a registry that declares delegation without a + // configured actor claim, so reaching here means the two disagree. + let actor_claim = self.claims.actor.as_ref().ok_or_else(|| { + TokenError::server_error("no actor claim is configured for delegated tokens") + })?; + claims.insert( + actor_claim.clone(), + Value::String(delegation.actor().to_owned()), + ); + write_subject_claims(&mut claims, registered, delegation)?; + } + + let header = json!({ + "alg": self.algorithm.as_header_value(), + "typ": ACCESS_TOKEN_TYP, + "kid": self.signer.key_id(), + }); + let signing_input = format!( + "{}.{}", + encode_json(&header)?, + encode_json(&Value::Object(claims))? + ); + let signature = self + .signer + .sign(signing_input.as_bytes()) + .await + .map_err(|_| TokenError::server_error("the access token could not be signed"))?; + + Ok(MintedToken { + access_token: format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)), + token_type: "Bearer", + expires_in: self.lifetime_seconds as u64, + token_id, + signing_key_id: self.signer.key_id().to_owned(), + expires_at_unix: expires_at, + }) + } +} + +/// Write each subject selector value at the claim path its registration +/// declared, creating the intermediate objects the path implies. +/// +/// The registration's paths were checked at load time to be well formed, +/// unique, and non-nesting, so no write here can overwrite another. Anything +/// that would still collide is a bug rather than a caller's doing, and is +/// refused rather than allowed to overwrite an authority claim. +fn write_subject_claims( + claims: &mut Map, + registered: &Delegation, + delegation: &crate::assertion::ResolvedDelegation, +) -> Result<(), TokenError> { + let collision = + || TokenError::server_error("a subject claim path collides with an authority claim"); + + for (field, path) in ®istered.subject_claims { + let value = delegation + .subject() + .get(field) + .ok_or_else(|| TokenError::server_error("a resolved subject field is missing"))?; + + let mut segments = path.split('.').peekable(); + let mut current = &mut *claims; + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + if current.contains_key(segment) { + return Err(collision()); + } + current.insert(segment.to_owned(), value.clone()); + break; + } + let entry = current + .entry(segment.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + current = entry.as_object_mut().ok_or_else(collision)?; + } + } + Ok(()) +} + +fn encode_json(value: &Value) -> Result { + let bytes = serde_json::to_vec(value) + .map_err(|_| TokenError::server_error("a token component could not be serialized"))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// Publish the active public key plus any retired public keys whose tokens may +/// still be in flight. +fn build_jwks( + signer: &LocalJwkSigner, + retired: &[std::path::PathBuf], +) -> Result { + let active = serde_json::to_value(signer.public_jwk()) + .map_err(|_| MinterError::SigningKey("public key could not be serialized"))?; + let mut keys = vec![active]; + for path in retired { + keys.push(load_retired_public_key(path)?); + } + // One key id must resolve to one key. A verifier that indexes the set by + // `kid` is free to keep either entry, so a repeated id could leave the + // retired key standing in for the key every new token is signed with, + // with Mint still reporting itself ready. + let mut key_ids = std::collections::BTreeSet::new(); + for key in &keys { + let key_id = key + .get("kid") + .and_then(Value::as_str) + .ok_or(MinterError::RetiredKey("has no key id"))?; + if !key_ids.insert(key_id) { + return Err(MinterError::RetiredKey( + "repeats a key id already in the published set", + )); + } + } + Ok(json!({ "keys": keys })) +} + +fn load_retired_public_key(path: &Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|_| MinterError::RetiredKey("is unreadable"))?; + let value: Value = + serde_json::from_str(&text).map_err(|_| MinterError::RetiredKey("is not JSON"))?; + let object = value + .as_object() + .ok_or(MinterError::RetiredKey("is not a JSON object"))?; + // The whole point of the published set is that it is public. + if contains_private_material(object) { + return Err(MinterError::RetiredKey("contains private key material")); + } + if !object.get("kid").is_some_and(Value::is_string) { + return Err(MinterError::RetiredKey("has no key id")); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clients::ClientRegistry; + use std::{fs, os::unix::fs::PermissionsExt}; + + const NOW: i64 = 1_800_000_000; + + fn ed25519_key(seed: u8, kid: &str) -> (String, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let private = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + (private.to_string(), public) + } + + struct Fixture { + _directory: tempfile::TempDir, + minter: TokenMinter, + registry: ClientRegistry, + } + + fn fixture(grant: Option<&str>) -> Fixture { + build_fixture(grant, "", "") + } + + /// `registration` appends lines to the client registration, `claim` appends + /// lines to the configured claim names. Both are how the delegation tests + /// reach a shape the plain fixture does not have. + fn build_fixture(grant: Option<&str>, registration: &str, claim: &str) -> Fixture { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir_all(root.join("clients")).expect("client dir"); + + let (private, _public) = ed25519_key(9, "mint-2026-01"); + let key_path = root.join("signing.jwk"); + fs::write(&key_path, private).expect("write signing key"); + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + + let grant_line = grant + .map(|value| format!("grant: {value}\n")) + .unwrap_or_default(); + fs::write( + root.join("clients/client-a.yaml"), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n{registration}", ed25519_key(1, "client-a-1").1), + ) + .expect("write client"); + + let config_path = root.join("mint.yaml"); + let mut document = String::from( + r#" +version: 1 +issuer: https://mint.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +"#, + ); + document.push_str(claim); + document.push_str( + r#"clientAssertion: + audience: https://mint.example.org/token + algorithms: [EdDSA] +clients: + directory: clients +"#, + ); + fs::write(&config_path, document).expect("write config"); + + let config = MintConfig::load(&config_path).expect("config loads"); + let registry = ClientRegistry::load(&config.clients.directory).expect("registry loads"); + let minter = TokenMinter::new(&config).expect("minter builds"); + Fixture { + _directory: directory, + minter, + registry, + } + } + + /// The active signer the published set is built around, matching the + /// `mint-2026-01` key id the fixture configuration declares. + fn active_signer() -> LocalJwkSigner { + let (private, _public) = ed25519_key(9, "mint-2026-01"); + LocalJwkSigner::new(PrivateJwk::parse(&private).expect("private JWK parses")) + .expect("signer builds") + } + + fn write_public_key(directory: &Path, name: &str, seed: u8, kid: &str) -> std::path::PathBuf { + let path = directory.join(name); + let (_private, public) = ed25519_key(seed, kid); + fs::write(&path, public.to_string()).expect("write public key"); + path + } + + #[test] + fn retired_keys_publish_beside_the_active_key() { + let directory = tempfile::tempdir().expect("temp dir"); + let retired = write_public_key(directory.path(), "retired.jwk", 4, "mint-2025-07"); + + let jwks = build_jwks(&active_signer(), &[retired]).expect("set builds"); + + let ids: Vec<&str> = jwks["keys"] + .as_array() + .expect("keys is an array") + .iter() + .map(|key| key["kid"].as_str().expect("key id is a string")) + .collect(); + assert_eq!(ids, ["mint-2026-01", "mint-2025-07"]); + } + + #[test] + fn a_retired_key_may_not_repeat_the_active_key_id() { + let directory = tempfile::tempdir().expect("temp dir"); + // Different key material published under the id the active key already + // uses. A verifier keyed by `kid` would be free to resolve either, so + // the retired key could displace the key every new token is signed + // with while Mint went on reporting itself ready. + let retired = write_public_key(directory.path(), "retired.jwk", 4, "mint-2026-01"); + + let error = build_jwks(&active_signer(), &[retired]).expect_err("duplicate id is rejected"); + + assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + } + + #[test] + fn two_retired_keys_may_not_repeat_one_key_id() { + let directory = tempfile::tempdir().expect("temp dir"); + let first = write_public_key(directory.path(), "first.jwk", 4, "mint-2025-07"); + let second = write_public_key(directory.path(), "second.jwk", 5, "mint-2025-07"); + + let error = + build_jwks(&active_signer(), &[first, second]).expect_err("duplicate id is rejected"); + + assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + } + + fn undelegated(client: &std::sync::Arc) -> AuthenticatedClient { + AuthenticatedClient { + client: std::sync::Arc::clone(client), + delegation: None, + } + } + + fn decode_claims(token: &str) -> Value { + let segment = token.split('.').nth(1).expect("token has a claims segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("claims decode")) + .expect("claims parse") + } + + fn decode_header(token: &str) -> Value { + let segment = token.split('.').next().expect("token has a header segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("header decode")) + .expect("header parse") + } + + #[tokio::test] + async fn minted_claims_come_from_the_registry() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert_eq!(claims["iss"], json!("https://mint.example.org")); + assert_eq!(claims["aud"], json!("evidence")); + assert_eq!(claims["sub"], json!("urn:example:client-a")); + assert_eq!(claims["client_id"], json!("client-a")); + assert_eq!( + claims["evidence_tags"], + json!(["ministry-of-health", "tier-one"]) + ); + assert_eq!( + claims["evidence_audience"], + json!("https://client-a.example.org") + ); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!(claims["nbf"], json!(NOW)); + assert_eq!(claims["exp"], json!(NOW + 300)); + assert_eq!(minted.expires_in, 300); + assert_eq!(minted.token_type, "Bearer"); + } + + #[tokio::test] + async fn the_header_names_the_active_key_and_access_token_type() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + + assert_eq!( + decode_header(&minted.access_token), + json!({"alg": "EdDSA", "typ": "at+jwt", "kid": "mint-2026-01"}) + ); + } + + #[tokio::test] + async fn a_grant_is_minted_as_a_matched_pair_or_not_at_all() { + let without = fixture(None); + let client = without.registry.get("client-a").expect("client registered"); + let claims = decode_claims( + &without + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints") + .access_token, + ); + assert!(claims.get("evidence_grant_id").is_none()); + assert!(claims.get("evidence_authority").is_none()); + + let with = fixture(Some("{id: grant-1, authority: statute-7}")); + let client = with.registry.get("client-a").expect("client registered"); + let claims = decode_claims( + &with + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints") + .access_token, + ); + assert_eq!(claims["evidence_grant_id"], json!("grant-1")); + assert_eq!(claims["evidence_authority"], json!("statute-7")); + } + + #[tokio::test] + async fn every_token_carries_a_distinct_identifier() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let first = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + let second = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + + let first_jti = decode_claims(&first.access_token)["jti"].clone(); + let second_jti = decode_claims(&second.access_token)["jti"].clone(); + assert_ne!(first_jti, second_jti); + } + + const DELEGATION: &str = + "delegation:\n actors: [urn:example:agent-one]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + const ACTOR_CLAIM: &str = " actor: evidence_actor\n"; + + fn delegated_fixture() -> Fixture { + build_fixture(None, DELEGATION, ACTOR_CLAIM) + } + + fn delegation(subject: &[(&str, Value)]) -> crate::assertion::ResolvedDelegation { + crate::assertion::ResolvedDelegation::new( + "urn:example:agent-one".to_owned(), + subject + .iter() + .map(|(field, value)| ((*field).to_owned(), value.clone())) + .collect(), + ) + } + + fn delegated( + client: &std::sync::Arc, + subject: &[(&str, Value)], + ) -> AuthenticatedClient { + AuthenticatedClient { + client: std::sync::Arc::clone(client), + delegation: Some(delegation(subject)), + } + } + + /// The subject is minted at exactly the claim paths the registration + /// declared, which is what lets a resource server read it back out as the + /// selector it will not accept from the request body. + #[tokio::test] + async fn a_delegated_token_carries_the_actor_and_the_subject_at_their_declared_paths() { + let fixture = delegated_fixture(); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint( + &delegated( + client, + &[ + ("given_name", json!("Amara")), + ("birth_date", json!("1998-04-02")), + ], + ), + NOW, + ) + .await + .expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert_eq!(claims["evidence_actor"], json!("urn:example:agent-one")); + assert_eq!( + claims["identity"], + json!({"given_name": "Amara", "birth_date": "1998-04-02"}) + ); + // The registry's own authority is unchanged by the delegation. + assert_eq!(claims["sub"], json!("urn:example:client-a")); + assert_eq!(claims["client_id"], json!("client-a")); + } + + /// An ordinary token from the same minter carries neither, so a resource + /// server reading the subject from the token has nothing to read. + #[tokio::test] + async fn an_undelegated_token_carries_no_actor_and_no_subject() { + let fixture = delegated_fixture(); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert!(claims.get("evidence_actor").is_none()); + assert!(claims.get("identity").is_none()); + } + + /// Startup refuses a registry that declares delegation without a configured + /// actor claim, so this can only be reached by a bug. It must fail rather + /// than mint a token whose subject no resource server can attribute. + #[tokio::test] + async fn minting_a_delegation_without_a_configured_actor_claim_is_a_server_error() { + let fixture = build_fixture(None, DELEGATION, ""); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint( + &delegated( + client, + &[ + ("given_name", json!("Amara")), + ("birth_date", json!("1998-04-02")), + ], + ), + NOW, + ) + .await + .expect_err("an unconfigured actor claim must not mint"); + assert_eq!( + error, + TokenError::server_error("no actor claim is configured for delegated tokens") + ); + } + + #[tokio::test] + async fn a_delegation_resolved_for_an_undelegated_client_is_a_server_error() { + let fixture = build_fixture(None, "", ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) + .await + .expect_err("an undelegated client must not mint a delegation"); + assert_eq!( + error, + TokenError::server_error("a delegation was resolved for an undelegated client") + ); + } + + /// Two fields under one path prefix have to nest into one object rather than + /// the second overwriting the first. + #[tokio::test] + async fn subject_claims_sharing_a_path_prefix_nest_into_one_object() { + let deep = "delegation:\n subjectClaims:\n given_name: subject.identity.given_name\n region: subject.residence.region\n"; + let fixture = build_fixture(None, deep, ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint( + &delegated( + client, + &[("given_name", json!("Amara")), ("region", json!("north"))], + ), + NOW, + ) + .await + .expect("token mints"); + + assert_eq!( + decode_claims(&minted.access_token)["subject"], + json!({"identity": {"given_name": "Amara"}, "residence": {"region": "north"}}) + ); + } + + /// The startup check refuses a subject path rooted at an authority claim, so + /// this is unreachable in a loaded server. If it were ever reached, the + /// delegation must not be allowed to overwrite the authority. + #[tokio::test] + async fn a_subject_path_colliding_with_an_authority_claim_is_a_server_error() { + let colliding = + "delegation:\n subjectClaims:\n given_name: evidence_audience.given_name\n"; + let fixture = build_fixture(None, colliding, ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) + .await + .expect_err("a colliding subject path must not mint"); + assert_eq!( + error, + TokenError::server_error("a subject claim path collides with an authority claim") + ); + } + + #[test] + fn the_published_key_set_carries_public_material_only() { + let fixture = fixture(None); + let rendered = serde_json::to_string(fixture.minter.jwks()).expect("jwks serializes"); + for member in [ + "\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"", "\"k\"", + ] { + assert!( + !rendered.contains(member), + "the published key set must not contain {member}" + ); + } + assert!(rendered.contains("mint-2026-01")); + } + + #[test] + fn debug_output_never_reveals_the_signing_key() { + let fixture = fixture(None); + let rendered = format!("{:?}", fixture.minter); + + // Useful for operators: which key is live, and under what identity. + assert!(rendered.contains("mint-2026-01")); + assert!(rendered.contains("https://mint.example.org")); + + // The private scalar of the fixture's signing key, verbatim. Debug is + // the easiest place for key material to escape into a log line. + let private_scalar = URL_SAFE_NO_PAD.encode([9u8; 32]); + assert!( + !rendered.contains(&private_scalar), + "the debug output must never carry private key material" + ); + } +} diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs new file mode 100644 index 000000000..31520528d --- /dev/null +++ b/crates/registry-mint/tests/delegated_subject_binding.rs @@ -0,0 +1,649 @@ +//! End-to-end proof that a delegated token Mint issues is bound to one subject. +//! +//! The claim is narrow and worth stating precisely: a client that is registered +//! for delegation asks Mint for a token *for a named person*, and the resulting +//! token can only ever produce evidence about that person. Not because the +//! client is well behaved, but because Evidence reads the subject out of the +//! token and refuses to read it from the request at all. +//! +//! Nothing here stubs a boundary. The Mint router is the real one, the bundle is +//! the demonstration bundle under `demo/evidence-bundle`, and the authorization +//! decision is Evidence's own `match_entitlement` and `resolve_selectors`. + +use std::{collections::BTreeMap, fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; + +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence::{ + auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}, + bundle::Bundle, + config::{AuthorityKind, ValueOrigin}, + model::{EvidenceRequest, RequestedSelector, RequestedSubject, SelectorValue}, + selector::{authorize_and_resolve, AuthorizationError, ResolvedSelectorValue}, +}; +use registry_mint::{ + config::MintConfig, + server::{build_app, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, ON_BEHALF_OF_CLAIM, +}; +use registry_platform_crypto::PrivateJwk; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; + +/// These four must agree with `demo/evidence-bundle/evidence.yaml`. If they ever +/// drift, this test is the thing that says so. +const ISSUER: &str = "https://localhost:8443"; +const EVIDENCE_AUDIENCE: &str = "evidence.demo.invalid"; +const ACTOR_CLAIM: &str = "evidence_actor"; +const REQUIREMENT: &str = "urn:example:demo:requirement:residence-region:v1"; +const PURPOSE: &str = "demo-routing"; +const ASSERTION_AUDIENCE: &str = "https://localhost:8443/token"; + +const AGENT: &str = "urn:example:demo:agent:appointment-scheduler"; + +// A fixed, non-secret audit HMAC key. Held as a byte literal rather than +// written inline so a secret scanner does not read the write call as an +// assignment of a live credential. +const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + +fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + let private_document = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); + (private, public, private_document) +} + +struct Deployment { + _directory: tempfile::TempDir, + service: Arc, + audit_path: std::path::PathBuf, +} + +/// A Mint deployment whose claim names, issuer, and audience are the ones the +/// demonstration bundle expects. +async fn deployment() -> Deployment { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, _, signing_document) = key_pair(9); + let signing_path = root.join("secrets/signing.jwk"); + fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); + fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) + .expect("restrict signing key"); + let audit_key_path = root.join("secrets/audit-hmac-key"); + fs::write(&audit_key_path, AUDIT_HASH_KEY).expect("write audit key"); + fs::set_permissions(&audit_key_path, fs::Permissions::from_mode(0o600)) + .expect("restrict audit key"); + let audit_path = root.join("audit/mint.jsonl"); + + // The delegated caller: it may act as one agent, over exactly three + // selector fields, minted at exactly the paths the bundle reads. + let (_, scheduler_public, _) = key_pair(1); + fs::write( + root.join("clients/scheduler.yaml"), + format!( + "clientId: scheduler\nprincipal: urn:example:demo:principal:scheduler\nevidenceAudience: https://scheduler.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{scheduler_public}]\ndelegation:\n actors: [{AGENT}]\n subjectClaims:\n given_name: identity.given_name\n family_name: identity.family_name\n birth_date: identity.birth_date\n" + ), + ) + .expect("write delegated client"); + + // The same authority, without delegation. Its tokens carry no actor. + let (_, desk_public, _) = key_pair(2); + fs::write( + root.join("clients/service-desk.yaml"), + format!( + "clientId: service-desk\nprincipal: urn:example:demo:principal:service-desk\nevidenceAudience: https://service-desk.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{desk_public}]\n" + ), + ) + .expect("write undelegated client"); + + let config_path = root.join("mint.yaml"); + fs::write( + &config_path, + format!( + r#" +version: 1 +issuer: {ISSUER} +listener: {{address: 127.0.0.1, port: 0}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [{EVIDENCE_AUDIENCE}] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: {ACTOR_CLAIM} +clientAssertion: + audience: {ASSERTION_AUDIENCE} + algorithms: [EdDSA] +clients: + directory: clients +"# + ), + ) + .expect("write config"); + + let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); + let service = Arc::new( + MintService::load(config) + .await + .expect("the deployment loads"), + ); + Deployment { + _directory: directory, + service, + audit_path, + } +} + +fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { + let kid = private.kid.clone().expect("the test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = + registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + +fn assertion_claims(client_id: &str, jti: &str) -> Value { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + json!({ + "iss": client_id, + "sub": client_id, + "aud": ASSERTION_AUDIENCE, + "iat": now, + "exp": now + 120, + "jti": jti, + }) +} + +/// The delegation request, carried inside the client's own signed assertion so +/// the values are covered by the client's signature rather than travelling as +/// unauthenticated form parameters. +fn delegated_claims(jti: &str, subject: &[(&str, &str)]) -> Value { + let mut claims = assertion_claims("scheduler", jti); + claims[ON_BEHALF_OF_CLAIM] = json!({ + "actor": AGENT, + "subject": subject + .iter() + .map(|(field, value)| ((*field).to_owned(), json!(value))) + .collect::>(), + }); + claims +} + +fn token_form(assertion: &str) -> Vec<(String, String)> { + vec![ + ( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + ), + ( + "client_assertion_type".to_owned(), + CLIENT_ASSERTION_TYPE.to_owned(), + ), + ("client_assertion".to_owned(), assertion.to_owned()), + ] +} + +async fn mint_token(http: &TestServer, assertion: &str) -> String { + let response = http.post("/token").form(&token_form(assertion)).await; + response.assert_status_ok(); + response.json::()["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned() +} + +/// Evidence's authenticator over the key set Mint published, configured exactly +/// as the demonstration bundle configures it. +fn evidence_authenticator(jwks: &Value) -> Authenticator { + let key_set: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); + let verifier_config = TokenVerifierConfig::access_token_profile( + ISSUER.to_owned(), + vec![EVIDENCE_AUDIENCE.to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ); + Authenticator::new( + Arc::new(TokenVerifier::new( + verifier_config, + Arc::new(JwksFetcher::new_static( + key_set, + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: Some(ACTOR_CLAIM.to_owned()), + }, + ) +} + +struct LoadedBundle { + _directory: tempfile::TempDir, + bundle: Bundle, +} + +/// Evidence refuses a writable bundle, so the demonstration bundle is copied to +/// a temporary root and frozen before loading. +fn demo_bundle() -> LoadedBundle { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path().join("bundle"); + fs::create_dir(&root).expect("create bundle root"); + copy_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/evidence-bundle"), + &root, + ); + make_read_only(&root); + let bundle = Bundle::load(&root).expect("the demonstration bundle loads"); + LoadedBundle { + _directory: directory, + bundle, + } +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("the demonstration bundle is readable") { + let entry = entry.expect("bundle entry is readable"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("entry type is readable").is_dir() { + fs::create_dir(&destination).expect("bundle directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("bundle file is copied"); + } + } +} + +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle entry is readable"); + let child = entry.path(); + if entry.file_type().expect("entry type is readable").is_dir() { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("bundle root is immutable"); +} + +/// The request a delegated caller sends: it names the requirement, the purpose, +/// and the *shape* of the subject, and carries no selector values at all. +fn subject_bound_request() -> EvidenceRequest { + EvidenceRequest { + // The nonce is a caller correlation value that never reaches + // authorization, which is the only thing under test here. + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), + requirement: REQUIREMENT.to_owned(), + purpose: PURPOSE.to_owned(), + subjects: vec![RequestedSubject { + role: "subject".to_owned(), + selector: RequestedSelector { + profile: "demographics-v1".to_owned(), + values: None, + }, + }], + // A holder key belongs to the SD-JWT VC response format and never + // reaches authorization, which is the only thing under test here. + holder_key: None, + } +} + +fn resolved_values( + authorization: ®istry_evidence::selector::ResolvedAuthorization, +) -> BTreeMap { + authorization + .subjects + .iter() + .flat_map(|subject| subject.fields.iter()) + .map(|field| { + let value = match &field.value { + ResolvedSelectorValue::String(value) => value.clone(), + ResolvedSelectorValue::Date(value) => value.to_string(), + other => format!("{other:?}"), + }; + (field.name.clone(), value) + }) + .collect() +} + +async fn context_for(http: &TestServer, jwks: &Value, assertion: &str) -> AuthenticatedContext { + let token = mint_token(http, assertion).await; + evidence_authenticator(jwks) + .authenticate(&token) + .await + .expect("Evidence accepts a token Mint issued") +} + +/// The whole point, in one test: the subject the client named to Mint is the +/// subject Evidence resolves, and the client never names it again. +#[tokio::test] +async fn a_delegated_token_authorizes_evidence_about_exactly_its_own_subject() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let context = context_for(&http, &jwks, &assertion).await; + assert_eq!(context.actor(), Some(AGENT)); + + let audit = fs::read_to_string(&deployment.audit_path).expect("read Mint audit"); + assert!(audit.contains("\"phase\":\"token-release\"")); + assert!(audit.contains("\"decision\":\"issued\"")); + assert!(audit.contains("\"clientPseudonym\":\"hmac-sha256:v1:")); + assert!(audit.contains("\"authorityPseudonym\":\"hmac-sha256:v1:")); + assert!(audit.contains("\"subjectPseudonym\":\"hmac-sha256:v1:")); + for protected in [ + "scheduler", + "urn:example:demo:principal:scheduler", + AGENT, + "Amara", + "Okafor", + "1998-04-02", + &assertion, + ] { + assert!( + !audit.contains(protected), + "Mint audit retained protected token input" + ); + } + + let authorization = authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) + .expect("a delegated token authorizes its own subject"); + + assert_eq!(authorization.authority_kind, AuthorityKind::Delegated); + assert_eq!(authorization.subjects.len(), 1); + assert_eq!( + authorization.subjects[0].value_origin, + ValueOrigin::AuthenticatedContext + ); + assert_eq!( + resolved_values(&authorization), + BTreeMap::from([ + ("given_name".to_owned(), "Amara".to_owned()), + ("family_name".to_owned(), "Okafor".to_owned()), + ("birth_date".to_owned(), "1998-04-02".to_owned()), + ]) + ); +} + +/// The containment Jeremi asked for. A client holding a token for one person +/// cannot reach a second person by putting their details in the request: the +/// request is refused for carrying selector values at all, so there is no +/// version of this request that reaches a different subject. +#[tokio::test] +async fn a_delegated_token_cannot_be_pointed_at_a_different_subject() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let context = context_for(&http, &jwks, &assertion).await; + + let mut request = subject_bound_request(); + request.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Kofi".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Mensah".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1971-11-30".to_owned()), + ), + ])); + + let error = authorize_and_resolve(&loaded.bundle, &request, &context) + .expect_err("a request carrying its own selector values is refused"); + assert_eq!(error, AuthorizationError::Selector); + + // And repeating the caller's own subject does not help either: the refusal + // is for supplying values, not for supplying the wrong ones. + let mut echoed = subject_bound_request(); + echoed.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Amara".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Okafor".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1998-04-02".to_owned()), + ), + ])); + assert_eq!( + authorize_and_resolve(&loaded.bundle, &echoed, &context) + .expect_err("supplying values is refused even when they match"), + AuthorizationError::Selector + ); +} + +/// Two tokens from the same client and the same key resolve to two different +/// people, so the binding is a property of the token rather than of the client. +#[tokio::test] +async fn each_token_carries_its_own_subject() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + let (private, _, _) = key_pair(1); + + let first = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let second = sign_assertion( + &private, + &delegated_claims( + "jti-2", + &[ + ("given_name", "Kofi"), + ("family_name", "Mensah"), + ("birth_date", "1971-11-30"), + ], + ), + ); + + let first = context_for(&http, &jwks, &first).await; + let second = context_for(&http, &jwks, &second).await; + + let resolve = |context: &AuthenticatedContext| { + resolved_values( + &authorize_and_resolve(&loaded.bundle, &subject_bound_request(), context) + .expect("each delegated token authorizes its own subject"), + ) + }; + assert_eq!(resolve(&first)["given_name"], "Amara"); + assert_eq!(resolve(&second)["given_name"], "Kofi"); +} + +/// A token with no actor cannot produce evidence from the subject-bound grant, +/// because there is nowhere for the subject to come from: the grant reads it +/// from claims the token does not carry, and refuses to read it from the +/// request. +/// +/// Note the shape of the refusal. Evidence confines an *actor-bearing* token to +/// `kind: delegated` profiles, but it does not conversely require an actor to +/// reach one, so this token matches the grant and is stopped at selector +/// resolution rather than at entitlement matching. Nothing leaks either way, but +/// the two are not interchangeable: were this grant to gain a subject role whose +/// values come from the request, an undelegated token would reach it. +#[tokio::test] +async fn an_undelegated_token_cannot_use_the_delegated_grant() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("service-desk", "jti-1")); + let context = context_for(&http, &jwks, &assertion).await; + assert_eq!(context.actor(), None); + + assert_eq!( + authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) + .expect_err("an undelegated token has no subject to resolve"), + AuthorizationError::Selector + ); + + // Supplying the subject in the request does not rescue it: the grant refuses + // request-borne selector values from any caller. + let mut request = subject_bound_request(); + request.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Amara".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Okafor".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1998-04-02".to_owned()), + ), + ])); + assert_eq!( + authorize_and_resolve(&loaded.bundle, &request, &context) + .expect_err("an undelegated token cannot name a subject either"), + AuthorizationError::Selector + ); +} + +/// Mint refuses the request before it ever becomes a token: the actor is not one +/// this registration may act as. +#[tokio::test] +async fn mint_refuses_an_actor_the_registration_does_not_permit() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(1); + let mut claims = delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ); + claims[ON_BEHALF_OF_CLAIM]["actor"] = json!("urn:example:demo:agent:someone-else"); + let assertion = sign_assertion(&private, &claims); + + let response = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); + let audit = fs::read_to_string(&deployment.audit_path).expect("read Mint audit"); + assert!(audit.contains("\"phase\":\"denial\"")); + assert!(audit.contains("\"safeErrorCategory\":\"invalid_client\"")); + assert!(!audit.contains("someone-else")); +} + +/// A signed access token is not released unless its audit record is durable. +#[tokio::test] +async fn an_unwritable_audit_chain_prevents_token_release() { + let deployment = deployment().await; + fs::set_permissions(&deployment.audit_path, fs::Permissions::from_mode(0o400)) + .expect("make audit unwritable"); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion( + &private, + &assertion_claims("service-desk", "jti-audit-down"), + ); + let response = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(response.status_code(), 500); + assert_eq!(response.json::(), json!({"error": "server_error"})); + + let readiness = http.get("/ready").await; + assert_eq!(readiness.status_code(), 503); +} + +/// The other direction: an undelegated registration cannot obtain a subject +/// binding by asking for one. +#[tokio::test] +async fn mint_refuses_a_delegation_from_an_undelegated_client() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(2); + let mut claims = assertion_claims("service-desk", "jti-1"); + claims[ON_BEHALF_OF_CLAIM] = json!({ + "actor": AGENT, + "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, + }); + let assertion = sign_assertion(&private, &claims); + + let response = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); +} diff --git a/crates/registry-mint/tests/evidence_compatibility.rs b/crates/registry-mint/tests/evidence_compatibility.rs new file mode 100644 index 000000000..1bd1ecee8 --- /dev/null +++ b/crates/registry-mint/tests/evidence_compatibility.rs @@ -0,0 +1,581 @@ +//! End-to-end proof that a token Mint issues is one Evidence accepts. +//! +//! This is the test that justifies the crate existing. It drives the real Mint +//! router over a real deployment on disk, and feeds the resulting access token +//! to the real Evidence authenticator. Nothing here stubs a boundary: if the +//! two products ever disagree about claim names, algorithms, token type, +//! issuer, or audience, this fails. + +use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc, time::Duration}; + +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence::{ + auth::{AuthenticationClaimsConfig, Authenticator}, + config::{ + AccessTokenAlgorithm, AccessTokenType, AssuranceProfile, AuthenticationConfig, + AuthenticationKind, + }, +}; +use registry_mint::{ + config::MintConfig, + server::{build_app, serve, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, +}; +use registry_platform_crypto::PrivateJwk; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; + +// A fixed, non-secret audit HMAC key. Held as a byte literal rather than +// written inline so a secret scanner does not read the write call as an +// assignment of a live credential. +const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; +const ISSUER: &str = "https://mint.example.org"; +const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; +const LOCAL_ISSUER: &str = "http://127.0.0.1:18081"; +const LOCAL_ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18081/token"; +const EVIDENCE_AUDIENCE: &str = "evidence.example.org"; + +/// The claim names shared by the two configuration documents. Evidence reads +/// exactly these, and Mint writes exactly these. +const PRINCIPAL_CLAIM: &str = "sub"; +const REQUESTER_TAGS_CLAIM: &str = "evidence_tags"; +const EVIDENCE_AUDIENCE_CLAIM: &str = "evidence_audience"; +const GRANT_ID_CLAIM: &str = "evidence_grant_id"; +const GRANT_AUTHORITY_CLAIM: &str = "evidence_authority"; + +/// Deterministic Ed25519 material, so a test can hold several distinct +/// identities and know which one signed what. +fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + let private_document = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); + (private, public, private_document) +} + +struct Deployment { + /// Held so the directory outlives the service that reads from it. + _directory: tempfile::TempDir, + service: Arc, +} + +/// Write a complete Mint deployment to disk and load it exactly as the binary +/// would, including the owner-only permission requirement on the signing key. +async fn deployment() -> Deployment { + deployment_with_transport(None, ISSUER, 0, ASSERTION_AUDIENCE).await +} + +async fn supervised_local_development_deployment() -> Deployment { + deployment_with_transport( + Some("supervised-local-development"), + LOCAL_ISSUER, + 18081, + LOCAL_ASSERTION_AUDIENCE, + ) + .await +} + +async fn deployment_with_transport( + validation_mode: Option<&str>, + issuer: &str, + listener_port: u16, + assertion_audience: &str, +) -> Deployment { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, _, signing_document) = key_pair(9); + let signing_path = root.join("secrets/signing.jwk"); + fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); + fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) + .expect("restrict signing key"); + let audit_key_path = root.join("secrets/audit-hmac-key"); + fs::write(&audit_key_path, AUDIT_HASH_KEY).expect("write audit key"); + fs::set_permissions(&audit_key_path, fs::Permissions::from_mode(0o600)) + .expect("restrict audit key"); + + let (_, health_public, _) = key_pair(1); + write_client( + root, + "health-ministry", + &health_public, + Some(("grant-7", "statute-12")), + ); + let (_, statistics_public, _) = key_pair(2); + write_client(root, "statistics-office", &statistics_public, None); + + let config_path = root.join("mint.yaml"); + let validation_mode = validation_mode + .map(|mode| format!("validationMode: {mode}\n")) + .unwrap_or_default(); + fs::write( + &config_path, + format!( + r#" +version: 1 +{validation_mode}issuer: {issuer} +listener: {{address: 127.0.0.1, port: {listener_port}}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [{EVIDENCE_AUDIENCE}] + lifetimeSeconds: 300 + claims: + principal: {PRINCIPAL_CLAIM} + requesterTags: {REQUESTER_TAGS_CLAIM} + evidenceAudience: {EVIDENCE_AUDIENCE_CLAIM} + grantId: {GRANT_ID_CLAIM} + grantAuthority: {GRANT_AUTHORITY_CLAIM} +clientAssertion: + audience: {assertion_audience} + algorithms: [EdDSA] +clients: + directory: clients +"# + ), + ) + .expect("write config"); + + let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); + let service = Arc::new( + MintService::load(config) + .await + .expect("the deployment loads"), + ); + Deployment { + _directory: directory, + service, + } +} + +fn write_client(root: &Path, client_id: &str, public: &Value, grant: Option<(&str, &str)>) { + let mut document = format!( + "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [{client_id}]\nkeys: [{public}]\n" + ); + if let Some((id, authority)) = grant { + document.push_str(&format!("grant: {{id: {id}, authority: {authority}}}\n")); + } + fs::write(root.join(format!("clients/{client_id}.yaml")), document) + .expect("write client registration"); +} + +fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { + let kid = private.kid.clone().expect("the test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = + registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + +fn assertion_claims(client_id: &str, jti: &str) -> Value { + assertion_claims_for_audience(client_id, jti, ASSERTION_AUDIENCE) +} + +fn assertion_claims_for_audience(client_id: &str, jti: &str, audience: &str) -> Value { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + json!({ + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 120, + "jti": jti, + }) +} + +fn token_form(assertion: &str) -> Vec<(String, String)> { + vec![ + ( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + ), + ( + "client_assertion_type".to_owned(), + CLIENT_ASSERTION_TYPE.to_owned(), + ), + ("client_assertion".to_owned(), assertion.to_owned()), + ] +} + +/// Build the Evidence authenticator the way `Authenticator::from_config` does, +/// but over the key set Mint actually published rather than an HTTPS fetch. +fn evidence_authenticator(jwks: &Value) -> Authenticator { + evidence_authenticator_for_issuer(jwks, ISSUER) +} + +fn evidence_authenticator_for_issuer(jwks: &Value, issuer: &str) -> Authenticator { + let key_set: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); + let verifier_config = TokenVerifierConfig::access_token_profile( + issuer.to_owned(), + vec![EVIDENCE_AUDIENCE.to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ); + let fetcher = Arc::new(JwksFetcher::new_static( + key_set, + JwksFetcherConfig::defaults(), + )); + Authenticator::new( + Arc::new(TokenVerifier::new(verifier_config, fetcher)), + AuthenticationClaimsConfig { + principal_claim: PRINCIPAL_CLAIM.to_owned(), + requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), + evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), + grant_id_claim: GRANT_ID_CLAIM.to_owned(), + grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), + actor_claim: None, + }, + ) +} + +#[tokio::test] +async fn a_client_signing_with_its_own_key_receives_a_token_evidence_accepts() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let jwks = http.get("/.well-known/jwks.json").await; + jwks.assert_status_ok(); + assert_eq!(jwks.header("content-type"), "application/jwk-set+json"); + let published = jwks.json::(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-1")); + let response = http.post("/token").form(&token_form(&assertion)).await; + response.assert_status_ok(); + // RFC 6749 section 5.1: a token response must never be cached. + assert_eq!(response.header("cache-control"), "no-store"); + let body = response.json::(); + assert_eq!(body["token_type"], json!("Bearer")); + assert_eq!(body["expires_in"], json!(300)); + let access_token = body["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned(); + + let context = evidence_authenticator(&published) + .authenticate(&access_token) + .await + .expect("Evidence accepts a token Mint issued"); + + // Every one of these came from the server-side registry, not the assertion. + assert_eq!(context.principal(), "urn:example:health-ministry"); + assert_eq!(context.requester_tags(), ["health-ministry"]); + assert_eq!( + context.evidence_audience(), + "https://health-ministry.example.org" + ); + assert_eq!(context.grant_id(), Some("grant-7")); + assert_eq!(context.grant_authority(), Some("statute-12")); + assert_eq!(context.actor(), None); +} + +#[tokio::test] +async fn supervised_local_development_tokens_remain_evidence_compatible() { + let deployment = supervised_local_development_deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let published = http.get("/.well-known/jwks.json").await.json::(); + + let (private, _, _) = key_pair(1); + let claims = assertion_claims_for_audience( + "health-ministry", + "jti-supervised-local", + LOCAL_ASSERTION_AUDIENCE, + ); + let assertion = sign_assertion(&private, &claims); + let response = http.post("/token").form(&token_form(&assertion)).await; + response.assert_status_ok(); + let access_token = response.json::()["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned(); + + let context = evidence_authenticator_for_issuer(&published, LOCAL_ISSUER) + .authenticate(&access_token) + .await + .expect("Evidence accepts a token from the supervised local Mint mode"); + assert_eq!(context.principal(), "urn:example:health-ministry"); + assert_eq!(context.requester_tags(), ["health-ministry"]); +} + +#[tokio::test] +async fn evidence_fetches_keys_from_a_real_supervised_local_mint() { + // Hold the OS allocation while the matching deployment is authored and + // loaded. Release it only immediately before public `serve` binds it. + let reservation = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); + let port = reservation + .local_addr() + .expect("read reserved address") + .port(); + let issuer = format!("http://127.0.0.1:{port}"); + let token_endpoint = format!("{issuer}/token"); + let jwks_uri = format!("{issuer}/.well-known/jwks.json"); + let deployment = deployment_with_transport( + Some("supervised-local-development"), + &issuer, + port, + &token_endpoint, + ) + .await; + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let service = Arc::clone(&deployment.service); + drop(reservation); + let server = tokio::spawn(async move { + serve(service, async { + let _ = shutdown_rx.await; + }) + .await + }); + + // Keep every fallible boundary inside this result so Mint is asked to shut + // down even when the real HTTP exchange or verification fails. + let proof: Result<_, Box> = async { + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(1)) + .build()?; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if server.is_finished() { + return Err(std::io::Error::other( + "Mint stopped before its readiness endpoint responded", + )); + } + if client + .get(format!("{issuer}/ready")) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + + let (private, _, _) = key_pair(1); + let claims = assertion_claims_for_audience( + "health-ministry", + "jti-real-supervised-local", + &token_endpoint, + ); + let assertion = sign_assertion(&private, &claims); + let response = client + .post(&token_endpoint) + .form(&token_form(&assertion)) + .send() + .await?; + if !response.status().is_success() { + return Err(std::io::Error::other(format!( + "Mint token endpoint returned {}", + response.status() + )) + .into()); + } + let body = response.json::().await?; + let access_token = body["access_token"] + .as_str() + .ok_or_else(|| std::io::Error::other("token response has no access token"))?; + + let authentication = AuthenticationConfig { + kind: AuthenticationKind::OidcAccessToken, + issuer: issuer.clone(), + audiences: vec![EVIDENCE_AUDIENCE.to_owned()], + token_types: vec![AccessTokenType::AtJwt], + algorithms: vec![AccessTokenAlgorithm::EdDSA], + jwks_uri, + principal_claim: PRINCIPAL_CLAIM.to_owned(), + requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), + evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), + grant_id_claim: GRANT_ID_CLAIM.to_owned(), + grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), + actor_claim: None, + }; + Authenticator::from_config(&authentication, AssuranceProfile::Local) + .authenticate(access_token) + .await + .map_err(Into::into) + } + .await; + + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("Mint shuts down within the grace period") + .expect("Mint server task joins") + .expect("Mint shuts down cleanly"); + + let context = proof.expect("the real local Mint and Evidence boundary is compatible"); + assert_eq!(context.principal(), "urn:example:health-ministry"); + assert_eq!(context.requester_tags(), ["health-ministry"]); + assert_eq!( + context.claim_path("iss"), + Some(&Value::String(issuer.clone())) + ); + assert_eq!( + context.claim_path("aud"), + Some(&Value::String(EVIDENCE_AUDIENCE.to_owned())) + ); + assert_eq!( + context.evidence_audience(), + "https://health-ministry.example.org" + ); + assert_eq!(context.grant_id(), Some("grant-7")); + assert_eq!(context.grant_authority(), Some("statute-12")); + assert_eq!(context.actor(), None); +} + +#[tokio::test] +async fn a_registered_client_cannot_borrow_another_clients_authority() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + // The health ministry's own key, but claiming to be the statistics office. + let (health_private, _, _) = key_pair(1); + let forged = sign_assertion( + &health_private, + &assertion_claims("statistics-office", "jti-forged"), + ); + let response = http.post("/token").form(&token_form(&forged)).await; + + assert_eq!(response.status_code(), 401); + // The public error never distinguishes an unknown client from a bad + // signature, so it cannot be used to enumerate the registry. + assert_eq!(response.json::(), json!({"error": "invalid_client"})); + + let unknown = sign_assertion( + &health_private, + &assertion_claims("no-such-client", "jti-x"), + ); + let response = http.post("/token").form(&token_form(&unknown)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); +} + +#[tokio::test] +async fn an_assertion_cannot_be_presented_twice() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-once")); + + let first = http.post("/token").form(&token_form(&assertion)).await; + first.assert_status_ok(); + + let replayed = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(replayed.status_code(), 401); + assert_eq!(replayed.json::(), json!({"error": "invalid_client"})); +} + +#[tokio::test] +async fn a_client_without_a_registered_grant_receives_no_grant_claims() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let published = http.get("/.well-known/jwks.json").await.json::(); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-2")); + let response = http.post("/token").form(&token_form(&assertion)).await; + response.assert_status_ok(); + let access_token = response.json::()["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned(); + + let context = evidence_authenticator(&published) + .authenticate(&access_token) + .await + .expect("Evidence accepts the token"); + assert_eq!(context.principal(), "urn:example:statistics-office"); + assert_eq!(context.grant_id(), None); + assert_eq!(context.grant_authority(), None); +} + +#[tokio::test] +async fn the_published_metadata_points_at_the_endpoints_that_exist() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let metadata = http.get("/.well-known/oauth-authorization-server").await; + metadata.assert_status_ok(); + let document = metadata.json::(); + assert_eq!(document["issuer"], json!(ISSUER)); + assert_eq!(document["token_endpoint"], json!(ASSERTION_AUDIENCE)); + assert_eq!( + document["jwks_uri"], + json!(format!("{ISSUER}/.well-known/jwks.json")) + ); + assert_eq!( + document["token_endpoint_auth_methods_supported"], + json!(["private_key_jwt"]) + ); + + // The metadata must describe routes this router actually serves. + http.get("/.well-known/jwks.json").await.assert_status_ok(); + let ready = http.get("/ready").await; + ready.assert_status_ok(); +} + +#[tokio::test] +async fn the_token_endpoint_refuses_anything_but_the_supported_grant() { + let deployment = deployment().await; + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-grant")); + + let mut form = token_form(&assertion); + form[0].1 = "password".to_owned(); + let response = http.post("/token").form(&form).await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "unsupported_grant_type"}) + ); + + let mut form = token_form(&assertion); + form[1].1 = "urn:example:something-else".to_owned(); + let response = http.post("/token").form(&form).await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "invalid_request"}) + ); + + // A bearer-style secret is not an accepted authentication method. + let response = http + .post("/token") + .form(&vec![( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + )]) + .await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "invalid_request"}) + ); +} diff --git a/crates/registry-mint/tests/token_cli.rs b/crates/registry-mint/tests/token_cli.rs new file mode 100644 index 000000000..0acaee19e --- /dev/null +++ b/crates/registry-mint/tests/token_cli.rs @@ -0,0 +1,488 @@ +//! `mint token` against a real `mint serve`, as two processes. +//! +//! The point of the subcommand is that it is an ordinary client: it proves who +//! it is and the endpoint decides. Testing it at the process boundary is what +//! shows that. It also pins the output contract the subcommand exists for, that +//! stdout carries the access token and nothing else, which no in-process test +//! of the builder could observe. + +use std::{ + fs, + io::ErrorKind, + net::{TcpListener, TcpStream}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; + +const ISSUER: &str = "https://mint.example.org"; +const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; +const ACTOR: &str = "urn:example:agent:scheduler"; + +/// Deterministic Ed25519 material, so a test knows which identity signed what. +fn key_pair(seed: u8) -> (Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let kid = format!("key-{seed}"); + ( + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}), + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, + "d": URL_SAFE_NO_PAD.encode(seed_bytes)}), + ) +} + +/// A running `mint serve`, killed when the test drops it however it ends. +struct Server { + _directory: tempfile::TempDir, + child: Child, + port: u16, + root: PathBuf, + config: PathBuf, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Server { + fn token_url(&self) -> String { + format!("http://127.0.0.1:{}/token", self.port) + } + + fn caller_key(&self, client_id: &str) -> PathBuf { + self.root.join(format!("{client_id}.jwk")) + } +} + +fn write_owner_only(path: &Path, contents: &str) { + fs::write(path, contents).expect("write secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("restrict secret"); +} + +/// Write a deployment, start the binary on it, and wait for the listener. +fn server() -> Server { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path().to_path_buf(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, signing_private) = key_pair(9); + write_owner_only( + &root.join("secrets/signing.jwk"), + &signing_private.to_string(), + ); + write_owner_only( + &root.join("secrets/audit-hmac-key"), + "0123456789abcdef0123456789abcdef", + ); + + // `listener.port: 0` would leave the test unable to find the port, so an + // ephemeral one is reserved and released. The window is a test-only risk. + let port = TcpListener::bind("127.0.0.1:0") + .expect("reserve a port") + .local_addr() + .expect("the reserved port") + .port(); + + let (scheduler_public, scheduler_private) = key_pair(1); + write_owner_only(&root.join("scheduler.jwk"), &scheduler_private.to_string()); + fs::write( + root.join("clients/scheduler.yaml"), + format!( + "clientId: scheduler +principal: urn:example:principal:scheduler +evidenceAudience: https://scheduler.example.org +requesterTags: [scheduler] +keys: [{scheduler_public}] +delegation: + actors: [{ACTOR}] + subjectClaims: + given_name: identity.given_name + birth_date: identity.birth_date +" + ), + ) + .expect("write scheduler registration"); + + let (reporter_public, reporter_private) = key_pair(2); + write_owner_only(&root.join("reporter.jwk"), &reporter_private.to_string()); + fs::write( + root.join("clients/reporter.yaml"), + format!( + "clientId: reporter +principal: urn:example:principal:reporter +evidenceAudience: https://reporter.example.org +requesterTags: [reporter] +keys: [{reporter_public}] +" + ), + ) + .expect("write reporter registration"); + + let config = root.join("mint.yaml"); + fs::write( + &config, + format!( + "version: 1 +issuer: {ISSUER} +listener: {{address: 127.0.0.1, port: {port}}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +audit: + path: audit/mint.jsonl + maximumFileBytes: 1073741824 + hashKeyFile: secrets/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [evidence.example.org] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: evidence_actor +clientAssertion: + audience: {ASSERTION_AUDIENCE} + algorithms: [EdDSA] +clients: + directory: clients +" + ), + ) + .expect("write config"); + + let child = Command::new(env!("CARGO_BIN_EXE_mint")) + .arg("serve") + .arg("--config") + .arg(&config) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("the mint binary starts"); + + let server = Server { + _directory: directory, + child, + port, + root, + config, + }; + wait_for_listener(port); + server +} + +fn wait_for_listener(port: u16) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + match TcpStream::connect(("127.0.0.1", port)) { + Ok(_) => return, + Err(error) if error.kind() == ErrorKind::ConnectionRefused => { + assert!( + Instant::now() < deadline, + "the token endpoint never accepted" + ); + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => panic!("the token endpoint could not be reached: {error}"), + } + } +} + +fn mint_token(server: &Server, client_id: &str, extra: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_mint")); + command + .arg("token") + .arg("--url") + .arg(server.token_url()) + // The endpoint is reached over loopback but its configured assertion + // audience is its public URL, which is the deployment shape behind a + // TLS terminator and the reason the flag exists. + .arg("--audience") + .arg(ASSERTION_AUDIENCE) + .arg("--client-id") + .arg(client_id) + .arg("--key") + .arg(server.caller_key(client_id)) + .args(extra); + command.output().expect("the mint binary runs") +} + +fn claims_of(token: &str) -> Value { + let segments: Vec<&str> = token.split('.').collect(); + assert_eq!(segments.len(), 3, "an access token has three segments"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1]).expect("base64url")) + .expect("claims parse") +} + +fn stdout_of(output: &Output) -> String { + assert!( + output.status.success(), + "the command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()).expect("stdout is UTF-8") +} + +#[test] +fn the_subcommand_obtains_a_token_the_endpoint_agreed_to_issue() { + let server = server(); + let output = mint_token(&server, "reporter", &[]); + let stdout = stdout_of(&output); + + // Exactly one line, so `TOKEN=$(mint token ...)` is the whole usage. + assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); + let claims = claims_of(stdout.trim()); + + assert_eq!(claims["iss"], json!(ISSUER)); + assert_eq!(claims["sub"], json!("urn:example:principal:reporter")); + assert_eq!(claims["client_id"], json!("reporter")); + assert_eq!(claims["evidence_tags"], json!(["reporter"])); + assert_eq!( + claims["evidence_audience"], + json!("https://reporter.example.org") + ); + // The authority came from the registry, not from anything the caller sent. + assert!(claims.get("evidence_actor").is_none()); + + let verification = Command::new(env!("CARGO_BIN_EXE_mint")) + .arg("verify-audit") + .arg("--config") + .arg(&server.config) + .output() + .expect("the verifier runs"); + assert!(verification.status.success()); + let verification = String::from_utf8_lossy(&verification.stdout); + assert!(verification.contains("active-segment: not verified")); +} + +#[test] +fn the_configuration_check_runs_against_a_deployment_that_is_already_serving() { + // The whole point of `mint check` is to read a configuration before + // restarting the service that is running on it. One writer holds the audit + // chain for the life of the serving process, so a check that took the + // writer would report every live deployment as broken. + let server = server(); + let output = Command::new(env!("CARGO_BIN_EXE_mint")) + .arg("check") + .arg("--config") + .arg(&server.config) + .output() + .expect("the checker runs"); + + assert!( + output.status.success(), + "check failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn a_delegated_token_carries_the_actor_and_the_subject_from_the_registry_paths() { + let server = server(); + let subject = server.root.join("subject.json"); + fs::write( + &subject, + json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + ACTOR, + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + let claims = claims_of(stdout_of(&output).trim()); + + assert_eq!(claims["evidence_actor"], json!(ACTOR)); + assert_eq!( + claims["identity"], + json!({"given_name": "Amara", "birth_date": "1998-04-02"}), + "the claim paths are the registry's, not the request's" + ); +} + +/// The subcommand authenticates; it does not decide. An actor the registration +/// does not permit must be refused by the endpoint, with no token printed. +#[test] +fn an_unregistered_actor_is_refused_by_the_endpoint() { + let server = server(); + let subject = server.root.join("other-subject.json"); + fs::write( + &subject, + json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + "urn:example:agent:not-registered", + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "a refusal must print no token: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid_client"), + "the refusal should be reported: {stderr}" + ); +} + +/// A refused request must not put the signed assertion back on the terminal. +/// +/// `--url` and `--audience` are separate flags precisely so the assertion can be +/// audience-bound to the public endpoint while the request travels over +/// loopback. Point `--url` at the wrong host and the assertion, still valid at +/// the real Mint until it expires, is now that host's to echo. Repeating an +/// arbitrary body into stderr writes it to the operator's logs and scrollback +/// too, which is a second place to lose it from. +#[test] +fn a_refusal_does_not_echo_the_signed_assertion_back() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind an echoing endpoint"); + let port = listener.local_addr().expect("a bound address").port(); + let echo = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept the token request"); + let mut request = Vec::new(); + // The form is small and the client closes after it, so reading to the + // content length is not worth a parser here. + let mut buffer = [0u8; 8192]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).expect("read the request"); + request.extend_from_slice(&buffer[..read]); + if read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let body = String::from_utf8_lossy(&request).into_owned(); + let payload = json!({ + "error": "invalid_request", + "error_description": "unrecognized request", + "received": body, + }) + .to_string(); + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}", + payload.len() + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).expect("write the refusal"); + body + }); + + let server = server(); + let mut command = Command::new(env!("CARGO_BIN_EXE_mint")); + command + .arg("token") + .arg("--url") + .arg(format!("http://127.0.0.1:{port}/token")) + .arg("--audience") + .arg(ASSERTION_AUDIENCE) + .arg("--client-id") + .arg("scheduler") + .arg("--key") + .arg(server.caller_key("scheduler")); + let output = command.output().expect("the mint binary runs"); + let received = echo.join().expect("the echoing endpoint finished"); + + let assertion = received + .split("client_assertion=") + .nth(1) + .expect("the endpoint received an assertion") + .split('&') + .next() + .expect("the assertion is a form value") + .to_owned(); + assert!( + assertion.len() > 64, + "the test needs a real assertion to look for: {assertion}" + ); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains(&assertion), + "the refusal echoed the signed assertion back" + ); + // The status and the OAuth error stay, because that is what tells an + // operator which endpoint refused and why. + assert!( + stderr.contains("400") && stderr.contains("invalid_request"), + "the refusal should still name the status and the error: {stderr}" + ); +} + +/// A subject file that is not a flat object of scalars is a caller mistake with +/// an opaque server-side answer, so it is named locally instead. +#[test] +fn a_malformed_subject_file_is_refused_before_the_request() { + let server = server(); + let subject = server.root.join("nested-subject.json"); + fs::write( + &subject, + json!({"identity": {"given_name": "Amara"}}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + ACTOR, + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("must be a scalar value"), + "the mistake should be named: {stderr}" + ); +} + +/// A key file anyone else can read is a key that should be assumed leaked. +#[test] +fn a_group_readable_client_key_is_refused() { + let server = server(); + let key = server.caller_key("reporter"); + fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).expect("loosen the key file"); + + let output = mint_token(&server, "reporter", &[]); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("client key could not be read"), + "the refusal should name the key file: {stderr}" + ); +} diff --git a/crates/registry-notary-client/Cargo.toml b/crates/registry-notary-client/Cargo.toml deleted file mode 100644 index eda1ce25b..000000000 --- a/crates/registry-notary-client/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "registry-notary-client" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Registry Notary HTTP client and binding facade." -readme = "README.md" -repository.workspace = true -publish = false - -[lints] -workspace = true - -[features] -default = ["rustls"] -rustls = [] -native-tls = [] -oid4vci = ["dep:registry-platform-oid4vci"] -federation = [] -json-facade = [] -test-support = [] -verifier = ["dep:base64", "dep:flate2", "dep:registry-platform-crypto", "dep:sha2"] - -[dependencies] -async-trait.workspace = true -base64 = { workspace = true, optional = true } -flate2 = { version = "1", optional = true } -registry-notary-core.workspace = true -registry-platform-crypto = { workspace = true, optional = true } -registry-platform-httputil.workspace = true -registry-platform-oid4vci = { workspace = true, optional = true } -reqwest.workspace = true -secrecy.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2 = { workspace = true, optional = true } -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -tracing.workspace = true - -[dev-dependencies] -axum.workspace = true -axum-test = { version = "20" } -registry-platform-crypto.workspace = true -registry-platform-sdjwt.workspace = true -registry-notary-server = { workspace = true, features = ["registry-notary-cel"] } -serde_norway.workspace = true -tempfile = { version = "3" } -tokio.workspace = true diff --git a/crates/registry-notary-client/README.md b/crates/registry-notary-client/README.md deleted file mode 100644 index 98247977d..000000000 --- a/crates/registry-notary-client/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# registry-notary-client - -Typed Rust HTTP client for Registry Notary. - -Use this crate when Rust application code needs to call Registry Notary without -reimplementing request shapes, purpose handling, route-specific retry, -bounded-response reads, JWKS refresh behavior, or redacted error mapping. - -## Quick Start - -```rust -use registry_notary_client::RegistryNotaryClient; - -let client = RegistryNotaryClient::builder("https://notary.example.gov") - .bearer_token("access-token") - .default_purpose("benefits_eligibility") - .user_agent("benefits-api/1.0") - .build()?; -``` - -Evaluate one target: - -```rust -let response = client - .evaluate_target("Person") - .target_identifier("national_id", "person-1") - .target_identifier_issuer("civil_registry") - .relationship("self") - .claims(["person-is-alive"]) - .disclosure("predicate") - .send() - .await?; - -if let Some(result) = response.body.first_result() { - println!("{} = {:?}", result.claim_id, result.satisfied); -} -``` - -## Main API - -- `RegistryNotaryClient::builder(base_url)` creates a client. -- `evaluate_target(target_type)` starts the ergonomic evaluation builder. -- `evaluate_request`, `batch_evaluate_request`, `render_request`, and - `issue_credential_request` accept core wire request types. `render_request` - extracts `evaluation_id` into the route path before sending the body. -- `health`, `ready`, `admin_reload`, `openapi_json`, `metrics`, `list_claims`, - `get_claim`, and `list_formats` cover operational, discovery, claim catalog, - and format routes. -- `service_document`, `issuer_jwks`, `refresh_jwks`, and `raw_issuer_jwks` - cover discovery and key rotation workflows. -- `credential_status` and `update_credential_status` cover minimal credential - lifecycle status. -- `oid4vci_*` methods are available with the `oid4vci` feature. -- `federation_evaluate_jws` is available with the `federation` feature. -- `verify_sd_jwt_vc`, `verify_credential_response`, and - `verify_oid4vci_credential` are available with the `verifier` feature. These - methods are explicit and opt-in; response decoding never verifies - credentials implicitly. -- `facade::NotaryClientHandle` is available with the `json-facade` feature for - binding authors. - -See [`docs/client-sdk-guide.md`](../../docs/client-sdk-guide.md) for examples in -Rust, Python, and Node.js. - -## Features - -- `oid4vci`: OpenID4VCI endpoint helpers. -- `federation`: delegated evaluation JWS submission. -- `json-facade`: canonical wire-shape JSON facade. -- `verifier`: explicit SD-JWT VC verification against trusted issuer JWKS. -- `test-support`: test-only HTTP client override and loopback HTTP allowance. - -## Explicit SD-JWT VC Verification - -Enable the `verifier` feature to verify credential material after a caller has -chosen the trust policy: - -```rust -use registry_notary_client::{HolderBindingPolicy, VerifyOptions}; - -let options = VerifyOptions::new("did:web:notary.example") - .expected_vct("https://credentials.example/vct/person-is-alive") - .holder_binding(HolderBindingPolicy::Required); - -let verified = client - .verify_credential_response(&credential.body, options) - .await?; -``` - -Verification resolves the JWS `kid` against the client's trusted issuer JWKS, -uses the normal JWKS TTL cache, forces one refresh on `key.unknown`, and then -stops. It checks the allowed algorithm list, header type, issuer, `vct`, -`exp`/`nbf`/`iat` with bounded skew, disclosure digests, and required -holder-binding confirmation. When an SD-JWT VC presentation includes a -key-binding JWT, the verifier separates it from disclosures and verifies its -holder proof signature against `cnf.jwk`. When required holder binding is paired -with `VerifyOptions::key_binding_challenge`, the trailing key-binding JWT is -mandatory and must match the expected audience and nonce. Verifier errors expose -stable redacted codes such as `signature.invalid`, `key.unknown`, -`algorithm.disallowed`, `claim.issuer_mismatch`, `claim.time_invalid`, -`disclosure.digest_mismatch`, and `holder_binding.required`. - -Python and Node wrappers do not expose verifier wrappers in this first phase; -use the Rust verifier or perform verification in application-specific wallet -code. - -## Safety Contract - -The client: - -- rejects multiple auth modes at build time; -- requires HTTPS for non-loopback hosts, with HTTP loopback allowed only in - debug or `test-support` builds; -- disables redirects and ignores proxy environment variables; -- bounds every response body; -- returns successful response status in `NotaryResponse`; -- captures `X-Request-Id` before body decoding; -- rejects `Idempotency-Key` on routes that do not honor it; -- retries only GET routes and idempotent batch evaluation; -- redacts raw Problem Details `detail`, compact credentials, holder proofs, - nonces, SD-JWT disclosures, token material, and credential response bodies - from `Debug`, `Display`, and portable errors. - -## Wire Contract - -Request types come from `registry-notary-core` where the server wire contract is -shared. Batch evaluation uses `registry_notary_core::BatchEvaluateResponse` -directly. Client-owned wrappers such as `Evaluation` and -`CredentialIssueResponse` exist for ergonomic accessors or redacted formatting, -not as compatibility workarounds. - -## Verification - -```bash -cargo test -p registry-notary-client -cargo test -p registry-notary-client --features json-facade,oid4vci,federation,verifier -cargo doc -p registry-notary-client --no-deps --all-features -``` diff --git a/crates/registry-notary-client/src/auth.rs b/crates/registry-notary-client/src/auth.rs deleted file mode 100644 index a1a60abb7..000000000 --- a/crates/registry-notary-client/src/auth.rs +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Authentication primitives for the Registry Notary client. - -use async_trait::async_trait; -use secrecy::{ExposeSecret, SecretString}; - -use crate::error::NotaryClientError; - -/// Static authentication material configured on a client. -/// -/// `Debug` output redacts the underlying secret. The public builder exposes -/// this through [`crate::NotaryClientBuilder::bearer_token`] and -/// [`crate::NotaryClientBuilder::api_key`]. -#[derive(Clone)] -pub enum Auth { - /// Send an `Authorization: Bearer ...` header. - Bearer(SecretString), - /// Send an `X-Api-Key` header. - ApiKey(SecretString), -} - -impl std::fmt::Debug for Auth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bearer(_) => f.write_str("Bearer()"), - Self::ApiKey(_) => f.write_str("ApiKey()"), - } - } -} - -/// One resolved authentication header for a single request. -/// -/// Implementations of [`AuthProvider`] return this type when credentials are -/// minted or refreshed dynamically. `Debug` and `Display` never print the -/// secret value. -#[derive(Clone)] -pub enum AuthHeader { - /// A complete `Authorization` header value, for example `Bearer `. - Authorization(SecretString), - /// An `X-Api-Key` header value. - ApiKey(SecretString), -} - -impl std::fmt::Debug for AuthHeader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Authorization(_) => f.write_str("Authorization()"), - Self::ApiKey(_) => f.write_str("ApiKey()"), - } - } -} - -impl std::fmt::Display for AuthHeader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl Auth { - pub(crate) fn header(&self) -> AuthHeader { - match self { - Self::Bearer(token) => AuthHeader::Authorization(SecretString::from(format!( - "Bearer {}", - token.expose_secret() - ))), - Self::ApiKey(token) => AuthHeader::ApiKey(token.clone()), - } - } -} - -#[async_trait] -/// Dynamic per-request authentication provider. -/// -/// Use this when a caller needs to refresh an access token, consult a secure -/// credential store, or mint short-lived credentials before each request. The -/// client awaits the provider before sending the request and still enforces the -/// single-auth-mode rule at build time. -pub trait AuthProvider: Send + Sync { - /// Return the authentication header to attach to the next request. - async fn auth_header(&self) -> Result; -} diff --git a/crates/registry-notary-client/src/client.rs b/crates/registry-notary-client/src/client.rs deleted file mode 100644 index 6f2dca23c..000000000 --- a/crates/registry-notary-client/src/client.rs +++ /dev/null @@ -1,1403 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Typed Registry Notary HTTP client. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use registry_notary_core::{ - BatchEvaluateRequest, ClaimRef, CredentialIssueRequest, EvaluateRequest, EvidenceEntity, - EvidenceIdentifier, EvidenceOnBehalfOf, EvidenceRelationship, RenderEvaluationRequest, - RenderRequest, RequestVariables, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, -}; -use registry_platform_httputil::read_bounded; -use reqwest::{Method, StatusCode, Url}; -use secrecy::SecretString; -use serde::de::DeserializeOwned; -use serde::Serialize; - -use crate::auth::{Auth, AuthHeader, AuthProvider}; -use crate::error::{ - parse_retry_after, NotaryClientBuildError, NotaryClientError, Oid4vciError, ProblemDetails, -}; -use crate::headers; -use crate::options::{RequestOptions, RetryPolicy}; -use crate::responses::{ - AdminReloadResponse, CredentialIssueResponse, CredentialStatusResponse, - CredentialStatusUpdateRequest, EvaluateResponse, Evaluation, FormatsResponse, HealthResponse, - ListClaimsResponse, NotaryResponse, ReadinessResponse, -}; -#[cfg(feature = "verifier")] -use crate::verifier::{VerificationError, VerifiedCredential, VerifyOptions}; - -const LIMIT_SMALL: u64 = 64 * 1024; -const LIMIT_DISCOVERY: u64 = 2 * 1024 * 1024; -const LIMIT_OPERATION: u64 = 8 * 1024 * 1024; -const LIMIT_BATCH: u64 = 16 * 1024 * 1024; -const JWKS_TTL: Duration = Duration::from_secs(10 * 60); - -#[derive(Clone)] -enum AuthState { - Static(Auth), - Provider(Arc), -} - -impl std::fmt::Debug for AuthState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Static(auth) => f.debug_tuple("Static").field(auth).finish(), - Self::Provider(_) => f.write_str("Provider()"), - } - } -} - -/// Cloneable typed HTTP client for a Registry Notary service. -/// -/// Construct with [`RegistryNotaryClient::builder`]. Clones share the same -/// underlying `reqwest::Client` and JWKS cache. -#[derive(Debug, Clone)] -pub struct RegistryNotaryClient { - base_url: Url, - http: reqwest::Client, - auth: Option, - default_purpose: Option, - retry_policy: RetryPolicy, - jwks_cache: Arc>>, -} - -#[derive(Debug, Clone)] -struct CachedJwks { - body: serde_json::Value, - expires_at: Instant, -} - -/// Builder for [`RegistryNotaryClient`]. -/// -/// Exactly one authentication mode may be configured. The builder rejects -/// non-HTTPS base URLs except HTTP loopback in debug or `test-support` builds. -#[derive(Default)] -pub struct NotaryClientBuilder { - base_url: Option, - bearer_token: Option, - api_key: Option, - auth_provider: Option>, - default_purpose: Option, - timeout: Option, - user_agent: Option, - retry_policy: Option, - #[cfg(any(test, feature = "test-support"))] - reqwest_client: Option, -} - -impl std::fmt::Debug for NotaryClientBuilder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NotaryClientBuilder") - .field("base_url", &self.base_url) - .field( - "bearer_token", - &self.bearer_token.as_ref().map(|_| ""), - ) - .field("api_key", &self.api_key.as_ref().map(|_| "")) - .field( - "auth_provider", - &self.auth_provider.as_ref().map(|_| ""), - ) - .field("default_purpose", &self.default_purpose) - .field("timeout", &self.timeout) - .field("user_agent", &self.user_agent) - .field("retry_policy", &self.retry_policy) - .finish() - } -} - -impl RegistryNotaryClient { - /// Start building a client for `base_url`. - /// - /// `base_url` may include a path prefix; route paths are joined under that - /// prefix. - #[must_use] - pub fn builder(base_url: impl Into) -> NotaryClientBuilder { - NotaryClientBuilder { - base_url: Some(base_url.into()), - ..NotaryClientBuilder::default() - } - } - - /// Fetch `GET /healthz`. - pub async fn health(&self) -> Result, NotaryClientError> { - self.get_json( - "/healthz", - RequestOptions::default(), - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Fetch `GET /ready`. - pub async fn ready(&self) -> Result, NotaryClientError> { - self.get_json( - "/ready", - RequestOptions::default(), - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Trigger `POST /admin/v1/reload`. - /// - /// Requires the server-side admin scope or equivalent API key. - pub async fn admin_reload( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.post_json( - "/admin/v1/reload", - &serde_json::json!({}), - options, - LIMIT_SMALL, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Fetch the generated OpenAPI document from `GET /openapi.json`. - pub async fn openapi_json( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/openapi.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// Fetch `GET /.well-known/evidence-service`. - pub async fn service_document( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/.well-known/evidence-service", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// Fetch and cache `GET /.well-known/evidence/jwks.json`. - /// - /// Calls without request options use a short in-process cache. Use - /// [`Self::refresh_jwks`] to force a refresh, or [`Self::raw_issuer_jwks`] - /// to fetch without updating the cache. - pub async fn issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - if options.is_empty() { - let cached = self - .jwks_cache - .lock() - .expect("jwks cache lock poisoned") - .as_ref() - .filter(|cached| cached.expires_at > Instant::now()) - .map(|cached| cached.body.clone()); - if let Some(body) = cached { - return Ok(NotaryResponse { - body, - status: StatusCode::OK, - request_id: None, - retry_after: None, - }); - } - } - self.fetch_issuer_jwks(options).await - } - - /// Force-refresh the evidence issuer JWKS cache. - pub async fn refresh_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.fetch_issuer_jwks(options).await - } - - /// Fetch Prometheus metrics from `GET /metrics`. - /// - /// Metrics are operational data and are returned as text. - pub async fn metrics( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let response = self - .execute( - Method::GET, - "/metrics", - None, - options, - LIMIT_DISCOVERY, - RouteRetry::Get, - ErrorKind::Problem, - None, - None, - ) - .await?; - let request_id = response.request_id.clone(); - let status = response.status; - String::from_utf8(response.body).map_or_else( - |_| Err(NotaryClientError::Decode { status, request_id }), - |body| { - Ok(NotaryResponse { - body, - status, - request_id: response.request_id, - retry_after: response.retry_after, - }) - }, - ) - } - - async fn fetch_issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - let response: NotaryResponse = self - .get_json( - "/.well-known/evidence/jwks.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await?; - let cached = CachedJwks { - body: response.body.clone(), - expires_at: Instant::now() + JWKS_TTL, - }; - *self.jwks_cache.lock().expect("jwks cache lock poisoned") = Some(cached); - Ok(response) - } - - /// Fetch the issuer JWKS without using or updating the client cache. - pub async fn raw_issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/.well-known/evidence/jwks.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - #[cfg(feature = "verifier")] - /// Explicitly verify an SD-JWT VC compact credential against issuer JWKS. - /// - /// This method is opt-in. Transport methods continue to decode response - /// bodies without performing verification. Verification reuses the - /// `issuer_jwks` TTL cache, forces one `refresh_jwks` on `key.unknown`, - /// and never performs an unbounded refresh loop. A status-bearing - /// credential additionally requires [`crate::StatusListPolicy`] in the - /// options. Its signed status list is fetched through a DNS-pinned, - /// no-redirect, no-proxy, bounded HTTPS request and fails closed. - pub async fn verify_sd_jwt_vc( - &self, - compact: &str, - options: VerifyOptions, - ) -> Result { - let mut jwks = self - .issuer_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - let pending = match crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options) { - Err(error) if error.is_unknown_key() => { - jwks = self - .refresh_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options)? - } - Err(error) => return Err(error), - Ok(pending) => pending, - }; - if let Some(status) = &pending.status { - let status_token = crate::verifier::fetch_status_list_token(status, &options).await?; - match crate::verifier::verify_status_list_token(&status_token, status, &jwks, &options) - { - Err(error) if error.is_status_unknown_key() => { - jwks = self - .refresh_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - crate::verifier::verify_status_list_token( - &status_token, - status, - &jwks, - &options, - )?; - } - Err(error) => return Err(error), - Ok(()) => {} - } - } - Ok(pending.credential) - } - - #[cfg(feature = "verifier")] - /// Explicitly verify a direct credential-issuance response. - pub async fn verify_credential_response( - &self, - response: &CredentialIssueResponse, - options: VerifyOptions, - ) -> Result { - self.verify_sd_jwt_vc(&response.credential, options).await - } - - #[cfg(feature = "oid4vci")] - /// Fetch OpenID4VCI issuer metadata. - /// - /// This helper wraps the endpoint only. It does not generate holder proofs. - pub async fn oid4vci_issuer_metadata( - &self, - options: RequestOptions, - ) -> Result< - NotaryResponse, - NotaryClientError, - > { - self.reject_idempotency(&options)?; - self.get_json( - "/.well-known/openid-credential-issuer", - options, - LIMIT_DISCOVERY, - ErrorKind::Oid4vci, - ) - .await - } - - #[cfg(feature = "oid4vci")] - /// Submit an OpenID4VCI credential request. - /// - /// The caller is responsible for holder-key custody and proof JWT creation. - pub async fn oid4vci_credential( - &self, - request: registry_platform_oid4vci::CredentialRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> - { - self.reject_idempotency(&options)?; - self.post_json( - "/oid4vci/credential", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Oid4vci, - ) - .await - } - - #[cfg(all(feature = "oid4vci", feature = "verifier"))] - /// Explicitly verify an OpenID4VCI credential response. - pub async fn verify_oid4vci_credential( - &self, - response: ®istry_platform_oid4vci::CredentialResponse, - options: VerifyOptions, - ) -> Result { - self.verify_sd_jwt_vc(oid4vci_compact_credential(&response.credential)?, options) - .await - } - - /// List configured claim definitions with `GET /v1/claims`. - /// - /// The current server contract returns a bounded, unpaginated list. - pub async fn list_claims( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json("/v1/claims", options, LIMIT_DISCOVERY, ErrorKind::Problem) - .await - } - - /// Fetch one claim definition by claim id. - pub async fn get_claim( - &self, - claim_id: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - &format!("/v1/claims/{}", encode_path_segment(claim_id)), - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// List evidence formats supported by the service. - pub async fn list_formats( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json("/v1/formats", options, LIMIT_DISCOVERY, ErrorKind::Problem) - .await - } - - /// Start the ergonomic evaluation builder for one target entity. - #[must_use] - pub fn evaluate_target(&self, target_type: impl Into) -> EvaluateBuilder<'_> { - EvaluateBuilder { - client: self, - target: EvidenceEntity::new(target_type), - requester: None, - relationship: None, - on_behalf_of: None, - variables: BTreeMap::new(), - claims: Vec::new(), - disclosure: None, - format: None, - purpose: None, - request_id: None, - traceparent: None, - } - } - - /// Start the ergonomic evaluation builder for a Person target id. - /// - /// This convenience helper maps to the v1 `target` request model. Prefer - /// [`Self::evaluate_target`] when the caller needs identifiers, attributes, - /// requester context, or non-person targets. - #[must_use] - pub fn evaluate(&self, subject_id: impl Into) -> EvaluateBuilder<'_> { - self.evaluate_target("Person").target_id(subject_id) - } - - /// Submit a raw typed [`EvaluateRequest`]. - /// - /// This method is best when the caller already has the core wire request. - /// It applies default purpose and claim-result format handling before - /// sending. - pub async fn evaluate_request( - &self, - mut request: EvaluateRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - let mut options = self.prepare_purpose(options, request.purpose.as_deref())?; - options.accept = options - .accept - .or_else(|| Some(FORMAT_CLAIM_RESULT_JSON.to_string())); - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - let mut request = request; - if request.format.is_none() { - request.format = Some(FORMAT_CLAIM_RESULT_JSON.to_string()); - } - self.post_json( - "/v1/evaluations", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Submit a raw typed [`BatchEvaluateRequest`]. - /// - /// Batch evaluation is the only POST route where the client allows - /// `Idempotency-Key`; retries require that key. Requests above the hard - /// 100-member platform ceiling fail locally before transport. - pub async fn batch_evaluate_request( - &self, - mut request: BatchEvaluateRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> - { - if request.items.len() > MAX_BATCH_EVALUATION_MEMBERS_V1 { - return Err(NotaryClientBuildError::BatchTooLarge { - actual: request.items.len(), - maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, - } - .into()); - } - let mut options = self.prepare_purpose(options, request.purpose.as_deref())?; - options.accept = options - .accept - .or_else(|| Some(FORMAT_CLAIM_RESULT_JSON.to_string())); - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - if request.format.is_none() { - request.format = Some(FORMAT_CLAIM_RESULT_JSON.to_string()); - } - self.post_json( - "/v1/batch-evaluations", - &request, - options, - LIMIT_BATCH, - RouteRetry::PostBatch, - ErrorKind::Problem, - ) - .await - } - - /// Render a stored evaluation into a requested evidence format. - /// - /// The server models `evaluation_id` as a path parameter. This method accepts - /// the core [`RenderRequest`] DTO for caller ergonomics, then moves - /// `evaluation_id` into `/v1/evaluations/{evaluation_id}/render` before the - /// request body is serialized. - pub async fn render_request( - &self, - request: RenderRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let path = format!( - "/v1/evaluations/{}/render", - encode_path_segment(&request.evaluation_id) - ); - let body = RenderEvaluationRequest::from(request); - self.post_json( - &path, - &body, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Issue a credential from a stored evaluation. - /// - /// Returned credential material is present in typed fields but redacted from - /// `Debug` output. - pub async fn issue_credential_request( - &self, - mut request: CredentialIssueRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let options = self.prepare_purpose(options, request.purpose.as_deref())?; - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - self.post_json( - "/v1/credentials", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Fetch minimal credential status by credential id. - pub async fn credential_status( - &self, - credential_id: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - &format!( - "/v1/credentials/{}/status", - encode_path_segment(credential_id) - ), - options, - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Update minimal credential status through the admin route. - pub async fn update_credential_status( - &self, - credential_id: &str, - status: impl Into, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - self.post_json( - &format!( - "/admin/v1/credentials/{}/status", - encode_path_segment(credential_id) - ), - &CredentialStatusUpdateRequest { - status: status.into(), - }, - options, - LIMIT_SMALL, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - #[cfg(feature = "federation")] - /// Submit an already-signed federation evaluation JWS. - /// - /// The client does not mint or sign federation JWTs. - pub async fn federation_evaluate_jws( - &self, - compact_jws: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - self.execute( - Method::POST, - "/federation/v1/evaluations", - Some(compact_jws.as_bytes().to_vec()), - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - Some(headers::APPLICATION_JWT), - None, - ) - .await - .and_then(|response| { - let request_id = response.request_id.clone(); - let status = response.status; - String::from_utf8(response.body).map_or_else( - |_| Err(NotaryClientError::Decode { status, request_id }), - |body| { - Ok(NotaryResponse { - body, - status, - request_id: response.request_id, - retry_after: response.retry_after, - }) - }, - ) - }) - } - - async fn get_json( - &self, - path: &str, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - ) -> Result, NotaryClientError> { - self.get_json_accepting_status(path, options, limit, error_kind, None) - .await - } - - async fn get_json_accepting_status( - &self, - path: &str, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - accepted_status: Option, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let response = self - .execute( - Method::GET, - path, - None, - options, - limit, - RouteRetry::Get, - error_kind, - None, - accepted_status, - ) - .await?; - let body = - serde_json::from_slice(&response.body).map_err(|_| NotaryClientError::Decode { - status: response.status, - request_id: response.request_id.clone(), - })?; - Ok(response.map(body)) - } - - async fn post_json( - &self, - path: &str, - body: &B, - mut options: RequestOptions, - limit: u64, - retry: RouteRetry, - error_kind: ErrorKind, - ) -> Result, NotaryClientError> { - if matches!(retry, RouteRetry::PostNoRetry) { - self.reject_idempotency(&options)?; - } - options.accept = options - .accept - .or_else(|| Some(headers::APPLICATION_JSON.to_string())); - let raw = - serde_json::to_vec(body).map_err(|_| NotaryClientBuildError::RequestSerialization)?; - let response = self - .execute( - Method::POST, - path, - Some(raw), - options, - limit, - retry, - error_kind, - Some(headers::APPLICATION_JSON), - None, - ) - .await?; - let body = - serde_json::from_slice(&response.body).map_err(|_| NotaryClientError::Decode { - status: response.status, - request_id: response.request_id.clone(), - })?; - Ok(response.map(body)) - } - - #[allow(clippy::too_many_arguments)] - async fn execute( - &self, - method: Method, - path: &str, - body: Option>, - options: RequestOptions, - limit: u64, - route_retry: RouteRetry, - error_kind: ErrorKind, - content_type: Option<&str>, - accepted_status: Option, - ) -> Result>, NotaryClientError> { - let attempts = allowed_attempts(&self.retry_policy, route_retry, &options); - let mut attempt = 0; - loop { - attempt += 1; - let result = self - .send_once( - method.clone(), - path, - body.clone(), - options.clone(), - limit, - error_kind, - content_type, - accepted_status, - ) - .await; - match result { - Ok(response) => return Ok(response), - Err(error) if attempt < attempts && should_retry(&self.retry_policy, &error) => { - let delay = retry_delay(&self.retry_policy, attempt, &error); - tokio::time::sleep(delay).await; - } - Err(error) => return Err(error), - } - } - } - - #[allow(clippy::too_many_arguments)] - async fn send_once( - &self, - method: Method, - path: &str, - body: Option>, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - content_type: Option<&str>, - accepted_status: Option, - ) -> Result>, NotaryClientError> { - let url = self.url(path)?; - let auth_header = match &self.auth { - Some(AuthState::Static(auth)) => Some(auth.header()), - Some(AuthState::Provider(provider)) => Some(provider.auth_header().await?), - None => None, - }; - let mut request = self.http.request(method, url); - if let Some(auth) = auth_header { - request = match auth { - AuthHeader::Authorization(value) => request.header( - reqwest::header::AUTHORIZATION, - secrecy::ExposeSecret::expose_secret(&value), - ), - AuthHeader::ApiKey(value) => { - request.header("x-api-key", secrecy::ExposeSecret::expose_secret(&value)) - } - }; - } - if let Some(accept) = &options.accept { - request = request.header(reqwest::header::ACCEPT, accept); - } - if let Some(content_type) = content_type { - request = request.header(reqwest::header::CONTENT_TYPE, content_type); - } - if let Some(purpose) = &options.purpose { - request = request.header(headers::DATA_PURPOSE, purpose); - } - if let Some(request_id) = &options.request_id { - request = request.header(headers::REQUEST_ID, request_id); - } - if let Some(traceparent) = &options.traceparent { - request = request.header(headers::TRACEPARENT, traceparent); - } - if let Some(idempotency_key) = &options.idempotency_key { - request = request.header(headers::IDEMPOTENCY_KEY, idempotency_key); - } - if let Some(body) = body { - request = request.body(body); - } - let response = request.send().await.map_err(NotaryClientError::Transport)?; - let status = response.status(); - let request_id = response - .headers() - .get(headers::REQUEST_ID) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let retry_after = parse_retry_after( - response - .headers() - .get(headers::RETRY_AFTER) - .and_then(|value| value.to_str().ok()), - response - .headers() - .get(headers::DATE) - .and_then(|value| value.to_str().ok()), - ); - let bytes = - read_bounded(response, limit) - .await - .map_err(|_| NotaryClientError::BodyTooLarge { - request_id: request_id.clone(), - })?; - if status.is_success() || accepted_status == Some(status) { - return Ok(NotaryResponse { - body: bytes, - status, - request_id, - retry_after, - }); - } - match error_kind { - ErrorKind::Problem => { - let problem = serde_json::from_slice::(&bytes).map_err(|_| { - NotaryClientError::Decode { - status, - request_id: request_id.clone(), - } - })?; - Err(NotaryClientError::Problem { - status, - problem: Box::new(problem), - request_id, - retry_after, - }) - } - ErrorKind::Oid4vci => { - let error = serde_json::from_slice::(&bytes).map_err(|_| { - NotaryClientError::Decode { - status, - request_id: request_id.clone(), - } - })?; - Err(NotaryClientError::Oid4vci { - status, - error, - request_id, - retry_after, - }) - } - } - } - - fn prepare_purpose( - &self, - mut options: RequestOptions, - body_purpose: Option<&str>, - ) -> Result { - if options.purpose.is_none() { - options.purpose = self - .default_purpose - .clone() - .or_else(|| body_purpose.map(ToOwned::to_owned)); - } - if let (Some(header), Some(body)) = (options.purpose.as_deref(), body_purpose) { - if header != body { - return Err(NotaryClientBuildError::PurposeConflict); - } - } - Ok(options) - } - - fn reject_idempotency(&self, options: &RequestOptions) -> Result<(), NotaryClientBuildError> { - if options.idempotency_key.is_some() { - Err(NotaryClientBuildError::UnsupportedIdempotencyKey) - } else { - Ok(()) - } - } - - fn url(&self, path: &str) -> Result { - self.base_url - .join(path.trim_start_matches('/')) - .map_err(|err| NotaryClientBuildError::Url(err.to_string())) - } -} - -#[cfg(all(feature = "oid4vci", feature = "verifier"))] -fn oid4vci_compact_credential( - credential: ®istry_platform_oid4vci::CredentialValue, -) -> Result<&str, VerificationError> { - match credential { - registry_platform_oid4vci::CredentialValue::String(compact) => Ok(compact.as_str()), - registry_platform_oid4vci::CredentialValue::Object(_) => { - Err(VerificationError::UnsupportedCredentialShape { - code: "credential.unsupported_shape", - }) - } - } -} - -impl NotaryClientBuilder { - /// Configure bearer-token authentication. - #[must_use] - pub fn bearer_token(mut self, token: impl Into) -> Self { - self.bearer_token = Some(SecretString::from(token.into())); - self - } - - /// Configure API-key authentication. - #[must_use] - pub fn api_key(mut self, token: impl Into) -> Self { - self.api_key = Some(SecretString::from(token.into())); - self - } - - /// Configure dynamic authentication. - #[must_use] - pub fn auth_provider(mut self, provider: Arc) -> Self { - self.auth_provider = Some(provider); - self - } - - /// Configure the default data purpose for evaluation requests. - /// - /// A body purpose must match this value unless overridden through - /// [`RequestOptions`]. - #[must_use] - pub fn default_purpose(mut self, purpose: impl Into) -> Self { - self.default_purpose = Some(purpose.into()); - self - } - - /// Configure request timeout. Defaults to 30 seconds. - #[must_use] - pub fn timeout(mut self, timeout: Duration) -> Self { - self.timeout = Some(timeout); - self - } - - /// Configure the `User-Agent` header. - #[must_use] - pub fn user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = Some(user_agent.into()); - self - } - - /// Configure route-aware retry behavior. - #[must_use] - pub fn retry_policy(mut self, retry_policy: RetryPolicy) -> Self { - self.retry_policy = Some(retry_policy); - self - } - - #[cfg(any(test, feature = "test-support"))] - /// Override the HTTP client in tests. - /// - /// This is intentionally unavailable in production builds because it can - /// bypass transport safety defaults. - #[must_use] - pub fn reqwest_client(mut self, client: reqwest::Client) -> Self { - self.reqwest_client = Some(client); - self - } - - /// Build the client and validate base URL and auth configuration. - pub fn build(self) -> Result { - let base_url = self.base_url.unwrap_or_default(); - let mut base_url = - Url::parse(&base_url).map_err(|err| NotaryClientBuildError::Url(err.to_string()))?; - if !base_url.path().ends_with('/') { - base_url - .path_segments_mut() - .map_err(|_| NotaryClientBuildError::Url("base URL cannot be a base".to_string()))? - .push(""); - } - validate_base_url(&base_url)?; - let auth_count = usize::from(self.bearer_token.is_some()) - + usize::from(self.api_key.is_some()) - + usize::from(self.auth_provider.is_some()); - if auth_count > 1 { - return Err(NotaryClientBuildError::MultipleAuthModes); - } - let auth = if let Some(token) = self.bearer_token { - Some(AuthState::Static(Auth::Bearer(token))) - } else if let Some(token) = self.api_key { - Some(AuthState::Static(Auth::ApiKey(token))) - } else { - self.auth_provider.map(AuthState::Provider) - }; - #[cfg(any(test, feature = "test-support"))] - let http = if let Some(client) = self.reqwest_client { - client - } else { - build_http_client(self.timeout, self.user_agent) - }; - #[cfg(not(any(test, feature = "test-support")))] - let http = build_http_client(self.timeout, self.user_agent); - Ok(RegistryNotaryClient { - base_url, - http, - auth, - default_purpose: self.default_purpose, - retry_policy: self.retry_policy.unwrap_or_default(), - jwks_cache: Arc::new(Mutex::new(None)), - }) - } -} - -#[derive(Debug, Clone, Copy)] -enum ErrorKind { - Problem, - #[cfg_attr(not(feature = "oid4vci"), allow(dead_code))] - Oid4vci, -} - -#[derive(Debug, Clone, Copy)] -enum RouteRetry { - Get, - PostBatch, - PostNoRetry, -} - -fn allowed_attempts(policy: &RetryPolicy, route: RouteRetry, options: &RequestOptions) -> usize { - match route { - RouteRetry::Get => policy.max_attempts.max(1), - RouteRetry::PostBatch if options.idempotency_key.is_some() => policy.max_attempts.max(1), - RouteRetry::PostBatch | RouteRetry::PostNoRetry => 1, - } -} - -fn should_retry(policy: &RetryPolicy, error: &NotaryClientError) -> bool { - match error { - NotaryClientError::Transport(_) => policy.retry_transport_errors, - NotaryClientError::Problem { status, .. } | NotaryClientError::Oid4vci { status, .. } => { - (*status == StatusCode::TOO_MANY_REQUESTS && policy.retry_rate_limited) - || (*status == StatusCode::SERVICE_UNAVAILABLE && policy.retry_unavailable) - } - _ => false, - } -} - -fn retry_delay(policy: &RetryPolicy, attempt: usize, error: &NotaryClientError) -> Duration { - if let Some(crate::RetryAfter::Delta(delay)) = error.retry_after() { - return (*delay).min(policy.max_delay); - } - let multiplier = 1_u32 - .checked_shl(attempt.saturating_sub(1) as u32) - .unwrap_or(u32::MAX); - policy - .base_delay - .saturating_mul(multiplier) - .min(policy.max_delay) -} - -fn build_http_client(timeout: Option, user_agent: Option) -> reqwest::Client { - let mut builder = reqwest::Client::builder() - .timeout(timeout.unwrap_or(Duration::from_secs(30))) - .redirect(reqwest::redirect::Policy::none()) - .no_proxy(); - if let Some(user_agent) = user_agent { - builder = builder.user_agent(user_agent); - } - builder - .build() - .expect("registry notary client options are valid") -} - -fn validate_base_url(url: &Url) -> Result<(), NotaryClientBuildError> { - if url.scheme() == "https" { - return Ok(()); - } - #[cfg(any(debug_assertions, feature = "test-support"))] - { - if url.scheme() == "http" - && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) - { - return Ok(()); - } - } - Err(NotaryClientBuildError::InsecureBaseUrl) -} - -fn encode_path_segment(segment: &str) -> String { - segment - .bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - vec![byte as char] - } - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() -} - -/// Fluent builder for one high-level evaluation request. -pub struct EvaluateBuilder<'a> { - client: &'a RegistryNotaryClient, - target: EvidenceEntity, - requester: Option, - relationship: Option, - on_behalf_of: Option, - variables: BTreeMap, - claims: Vec, - disclosure: Option, - format: Option, - purpose: Option, - request_id: Option, - traceparent: Option, -} - -impl<'a> EvaluateBuilder<'a> { - /// Set the target entity id. - #[must_use] - pub fn target_id(mut self, id: impl Into) -> Self { - self.target.id = Some(id.into()); - self - } - - /// Add a target identifier. - #[must_use] - pub fn target_identifier( - mut self, - scheme: impl Into, - value: impl Into, - ) -> Self { - self.target.identifiers.push(EvidenceIdentifier { - scheme: scheme.into(), - value: value.into(), - issuer: None, - country: None, - }); - self - } - - /// Set the issuer on the most recently added target identifier. - #[must_use] - pub fn target_identifier_issuer(mut self, issuer: impl Into) -> Self { - if let Some(identifier) = self.target.identifiers.last_mut() { - identifier.issuer = Some(issuer.into()); - } - self - } - - /// Set the country on the most recently added target identifier. - #[must_use] - pub fn target_identifier_country(mut self, country: impl Into) -> Self { - if let Some(identifier) = self.target.identifiers.last_mut() { - identifier.country = Some(country.into()); - } - self - } - - /// Add a target matching attribute. - #[must_use] - pub fn target_attribute( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - self.target.attributes.insert(name.into(), value.into()); - self - } - - /// Set a pre-built target entity. - #[must_use] - pub fn target(mut self, target: EvidenceEntity) -> Self { - self.target = target; - self - } - - /// Set the requester entity. - #[must_use] - pub fn requester(mut self, requester: EvidenceEntity) -> Self { - self.requester = Some(requester); - self - } - - /// Set the relationship type between requester and target. - #[must_use] - pub fn relationship(mut self, relationship_type: impl Into) -> Self { - self.relationship = Some(EvidenceRelationship { - relationship_type: relationship_type.into(), - attributes: BTreeMap::new(), - }); - self - } - - /// Add an attribute to the requester-target relationship. - #[must_use] - pub fn relationship_attribute( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - let relationship = self - .relationship - .get_or_insert_with(|| EvidenceRelationship { - relationship_type: "unspecified".to_string(), - attributes: BTreeMap::new(), - }); - relationship.attributes.insert(name.into(), value.into()); - self - } - - /// Set the delegated/on-behalf-of context using the frozen minimal actor - /// envelope. Simple deployments omit this entirely. - #[must_use] - pub fn on_behalf_of(mut self, on_behalf_of: EvidenceOnBehalfOf) -> Self { - self.on_behalf_of = Some(on_behalf_of); - self - } - - /// Add one declared RFC 3339 full-date request variable. - #[must_use] - pub fn request_variable_date( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - self.variables.insert(name.into(), value.into()); - self - } - - /// Set the identifier type for the first target identifier. - /// - /// Prefer [`Self::target_identifier`] for new code. - #[must_use] - pub fn id_type(mut self, id_type: impl Into) -> Self { - let subject_id = self.target.id.take().unwrap_or_default(); - self.target.identifiers.insert( - 0, - EvidenceIdentifier { - scheme: id_type.into(), - value: subject_id, - issuer: None, - country: None, - }, - ); - self - } - - /// Add one claim id. - #[must_use] - pub fn claim(mut self, claim: impl Into) -> Self { - self.claims.push(ClaimRef::new(claim.into())); - self - } - - /// Add multiple claim ids. - #[must_use] - pub fn claims(mut self, claims: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.claims - .extend(claims.into_iter().map(|claim| ClaimRef::new(claim.into()))); - self - } - - /// Set the disclosure mode. - #[must_use] - pub fn disclosure(mut self, disclosure: impl Into) -> Self { - self.disclosure = Some(disclosure.into()); - self - } - - /// Set the requested response format. - #[must_use] - pub fn format(mut self, format: impl Into) -> Self { - self.format = Some(format.into()); - self - } - - /// Set the data purpose for this request. - #[must_use] - pub fn purpose(mut self, purpose: impl Into) -> Self { - self.purpose = Some(purpose.into()); - self - } - - /// Set `X-Request-Id` for this request. - #[must_use] - pub fn request_id(mut self, request_id: impl Into) -> Self { - self.request_id = Some(request_id.into()); - self - } - - /// Set W3C `traceparent` for this request. - #[must_use] - pub fn traceparent(mut self, traceparent: impl Into) -> Self { - self.traceparent = Some(traceparent.into()); - self - } - - /// Send the evaluation request. - pub async fn send(self) -> Result, NotaryClientError> { - let variables = RequestVariables::try_new(self.variables) - .map_err(|_| NotaryClientBuildError::RequestSerialization)?; - let request = EvaluateRequest { - requester: self.requester, - target: Some(self.target), - relationship: self.relationship, - on_behalf_of: self.on_behalf_of, - variables, - claims: self.claims, - disclosure: self.disclosure, - format: self.format, - purpose: self.purpose.clone(), - }; - let options = RequestOptions { - purpose: self.purpose, - request_id: self.request_id, - traceparent: self.traceparent, - accept: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), - ..RequestOptions::default() - }; - self.client - .evaluate_request(request, options) - .await - .map(|response| { - let request_id = response.request_id; - let retry_after = response.retry_after; - let results = response.body.results; - NotaryResponse { - body: Evaluation { results }, - status: response.status, - request_id, - retry_after, - } - }) - } -} diff --git a/crates/registry-notary-client/src/error.rs b/crates/registry-notary-client/src/error.rs deleted file mode 100644 index c3cdec691..000000000 --- a/crates/registry-notary-client/src/error.rs +++ /dev/null @@ -1,384 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Error types for the Registry Notary client. - -use crate::options::RetryAfter; -use crate::responses::ReadinessChecks; - -use std::time::Duration; - -use time::format_description::well_known::Rfc2822; -use time::OffsetDateTime; - -/// Errors raised while constructing a client or preparing a request. -#[derive(Debug, thiserror::Error)] -pub enum NotaryClientBuildError { - /// The base URL could not be parsed. - #[error("invalid base URL")] - Url(String), - /// The base URL is not HTTPS. Debug and `test-support` builds allow HTTP - /// loopback for local tests. - #[error("base URL must use https unless test-support HTTP loopback is enabled")] - InsecureBaseUrl, - /// More than one auth mode was configured. - #[error("multiple authentication modes configured")] - MultipleAuthModes, - /// The purpose in [`crate::RequestOptions`] conflicts with the request body. - #[error("request purpose conflicts with request body purpose")] - PurposeConflict, - /// The request body failed to serialize before sending. - #[error("request body could not be serialized")] - RequestSerialization, - /// An idempotency key was supplied on a route that ignores it. - #[error("idempotency key is not supported for this route")] - UnsupportedIdempotencyKey, - /// The request exceeds the hard Registry Notary batch ceiling. - #[error("batch contains {actual} members; the hard maximum is {maximum}")] - BatchTooLarge { actual: usize, maximum: usize }, -} - -/// RFC 9457 Problem Details emitted by Registry Notary. -/// -/// The server may include sensitive details such as subject identifiers or -/// source-field names in `detail`. `Debug`, `Display`, and portable errors do -/// not render that field. -#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct ProblemDetails { - /// Problem type URI, deserialized from the JSON `type` field. - #[serde(rename = "type")] - pub problem_type: Option, - /// Human-readable title. - pub title: String, - /// HTTP status code. - pub status: u16, - /// Sensitive detail. Do not log this directly. - pub detail: String, - /// Stable machine-readable code. - pub code: String, - /// Server request/correlation id, when included in the problem body. - #[serde(default)] - pub request_id: Option, - /// Readiness status for `GET /ready` failures. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub readiness_status: Option, - /// Typed readiness checks for `GET /ready` failures. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub checks: Option, -} - -impl std::fmt::Debug for ProblemDetails { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ProblemDetails") - .field("problem_type", &self.problem_type) - .field("title", &self.title) - .field("status", &self.status) - .field("detail", &"") - .field("code", &self.code) - .field("request_id", &self.request_id) - .field("readiness_status", &self.readiness_status) - .finish() - } -} - -impl std::fmt::Display for ProblemDetails { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.title, self.code) - } -} - -/// OpenID4VCI error envelope. -/// -/// `error_description` can include holder or credential details and is redacted -/// from incidental formatting. -#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Oid4vciError { - /// OAuth/OID4VCI error code. - pub error: String, - /// Optional sensitive description. - #[serde(default)] - pub error_description: Option, -} - -impl std::fmt::Debug for Oid4vciError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Oid4vciError") - .field("error", &self.error) - .field( - "error_description", - &self.error_description.as_ref().map(|_| ""), - ) - .finish() - } -} - -impl std::fmt::Display for Oid4vciError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.error) - } -} - -/// Language-binding-safe error family. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PortableErrorKind { - /// Registry Notary Problem Details. - Problem, - /// OpenID4VCI error envelope. - Oid4vci, - /// Response body could not be decoded. - Decode, - /// Response body exceeded the client limit. - BodyTooLarge, - /// Transport failure before a response was decoded. - Transport, - /// Client build or request preparation failure. - Build, -} - -/// Redacted error envelope intended for Python, Node, and FFI boundaries. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct PortableClientError { - /// Broad error family. - pub kind: PortableErrorKind, - /// HTTP status when a response was available. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - /// Stable problem or client error code. - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - /// Safe title suitable for application logs. - pub title: String, - /// Whether retry may be useful if the route also allows retry. - pub retryable: bool, - /// Server request id, when present. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, -} - -/// Errors returned by client operations. -/// -/// `Display` is intentionally opaque for decode/body failures and redacts -/// sensitive problem or OID4VCI detail. Use [`Self::request_id`], -/// [`Self::status`], and [`Self::problem_code`] for safe logging. -#[derive(Debug, thiserror::Error)] -pub enum NotaryClientError { - /// Build or request-preparation failure. - #[error(transparent)] - Build(#[from] NotaryClientBuildError), - /// Request transport failed. - #[error("transport error")] - Transport(#[source] reqwest::Error), - /// Registry Notary returned Problem Details. - #[error("registry notary problem: {problem}")] - Problem { - status: reqwest::StatusCode, - problem: Box, - request_id: Option, - retry_after: Option, - }, - /// OpenID4VCI endpoint returned an OID4VCI error envelope. - #[error("openid4vci error: {error}")] - Oid4vci { - status: reqwest::StatusCode, - error: Oid4vciError, - request_id: Option, - retry_after: Option, - }, - /// Response body could not be decoded. - #[error("failed to decode response body")] - Decode { - status: reqwest::StatusCode, - request_id: Option, - }, - /// Response body exceeded the configured route limit. - #[error("response body exceeded configured size limit")] - BodyTooLarge { request_id: Option }, -} - -impl NotaryClientError { - /// HTTP status associated with the error, when available. - #[must_use] - pub fn status(&self) -> Option { - match self { - Self::Problem { status, .. } | Self::Oid4vci { status, .. } => Some(*status), - Self::Decode { status, .. } => Some(*status), - _ => None, - } - } - - /// Stable server or OID4VCI problem code, when available. - #[must_use] - pub fn problem_code(&self) -> Option<&str> { - match self { - Self::Problem { problem, .. } => Some(problem.code.as_str()), - Self::Oid4vci { error, .. } => Some(error.error.as_str()), - _ => None, - } - } - - /// Server request id captured before decoding the response body. - #[must_use] - pub fn request_id(&self) -> Option<&str> { - match self { - Self::Problem { request_id, .. } - | Self::Oid4vci { request_id, .. } - | Self::Decode { request_id, .. } - | Self::BodyTooLarge { request_id } => request_id.as_deref(), - _ => None, - } - } - - /// Parsed `Retry-After` header, when the server provided one. - #[must_use] - pub fn retry_after(&self) -> Option<&RetryAfter> { - match self { - Self::Problem { retry_after, .. } | Self::Oid4vci { retry_after, .. } => { - retry_after.as_ref() - } - _ => None, - } - } - - /// Whether the error class is retryable in principle. - /// - /// Route-specific retry rules still apply. - #[must_use] - pub fn is_retryable(&self) -> bool { - matches!(self.status().map(|status| status.as_u16()), Some(429 | 503)) - || matches!(self, Self::Transport(_)) - } - - /// Convert to a redacted portable envelope for bindings or FFI. - #[must_use] - pub fn portable(&self) -> PortableClientError { - match self { - Self::Problem { - status, - problem, - request_id, - .. - } => PortableClientError { - kind: PortableErrorKind::Problem, - status: Some(status.as_u16()), - code: Some(problem.code.clone()), - title: problem.title.clone(), - retryable: self.is_retryable(), - request_id: request_id.clone(), - }, - Self::Oid4vci { - status, - error, - request_id, - .. - } => PortableClientError { - kind: PortableErrorKind::Oid4vci, - status: Some(status.as_u16()), - code: Some(error.error.clone()), - title: "OpenID4VCI error".to_string(), - retryable: self.is_retryable(), - request_id: request_id.clone(), - }, - Self::Decode { status, request_id } => PortableClientError { - kind: PortableErrorKind::Decode, - status: Some(status.as_u16()), - code: Some("decode.failed".to_string()), - title: "Failed to decode response body".to_string(), - retryable: false, - request_id: request_id.clone(), - }, - Self::BodyTooLarge { request_id } => PortableClientError { - kind: PortableErrorKind::BodyTooLarge, - status: None, - code: Some("body.too_large".to_string()), - title: "Response body exceeded configured size limit".to_string(), - retryable: false, - request_id: request_id.clone(), - }, - Self::Transport(_) => PortableClientError { - kind: PortableErrorKind::Transport, - status: None, - code: Some("transport.failed".to_string()), - title: "Transport error".to_string(), - retryable: true, - request_id: None, - }, - Self::Build(error) => PortableClientError { - kind: PortableErrorKind::Build, - status: None, - code: Some( - match error { - NotaryClientBuildError::Url(_) => "build.invalid_url", - NotaryClientBuildError::InsecureBaseUrl => "build.insecure_base_url", - NotaryClientBuildError::MultipleAuthModes => "build.multiple_auth_modes", - NotaryClientBuildError::PurposeConflict => "request.purpose_conflict", - NotaryClientBuildError::RequestSerialization => { - "request.serialization_failed" - } - NotaryClientBuildError::UnsupportedIdempotencyKey => { - "request.unsupported_idempotency_key" - } - NotaryClientBuildError::BatchTooLarge { .. } => "batch.too_large", - } - .to_string(), - ), - title: error.to_string(), - retryable: false, - request_id: None, - }, - } - } -} - -pub(crate) fn parse_retry_after(raw: Option<&str>, date_raw: Option<&str>) -> Option { - let raw = raw?.trim(); - if raw.is_empty() { - return None; - } - if let Ok(seconds) = raw.parse::() { - return Some(RetryAfter::Delta(Duration::from_secs(seconds))); - } - if let (Some(retry_at), Some(server_at)) = ( - parse_http_date(raw), - date_raw.and_then(|value| parse_http_date(value.trim())), - ) { - let delta = retry_at - server_at; - if delta <= time::Duration::ZERO { - return Some(RetryAfter::Delta(Duration::ZERO)); - } - return Some(RetryAfter::Delta(Duration::new( - delta.whole_seconds() as u64, - delta.subsec_nanoseconds() as u32, - ))); - } - Some(RetryAfter::HttpDate(raw.to_string())) -} - -fn parse_http_date(raw: &str) -> Option { - OffsetDateTime::parse(raw, &Rfc2822).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retry_after_http_date_uses_server_date_for_delta() { - let retry_after = parse_retry_after( - Some("Wed, 31 Dec 2099 00:00:02 GMT"), - Some("Wed, 31 Dec 2099 00:00:00 GMT"), - ); - - assert_eq!(retry_after, Some(RetryAfter::Delta(Duration::from_secs(2)))); - } - - #[test] - fn retry_after_http_date_without_valid_date_preserves_raw_value() { - let retry_after = parse_retry_after(Some("Wed, 31 Dec 2099 00:00:02 GMT"), None); - - assert_eq!( - retry_after, - Some(RetryAfter::HttpDate( - "Wed, 31 Dec 2099 00:00:02 GMT".to_string() - )) - ); - } -} diff --git a/crates/registry-notary-client/src/facade.rs b/crates/registry-notary-client/src/facade.rs deleted file mode 100644 index 63820ab9a..000000000 --- a/crates/registry-notary-client/src/facade.rs +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Binding-safe JSON facade over the typed client. - -use registry_notary_core::{ - BatchEvaluateRequest, CredentialIssueRequest, EvaluateRequest, RenderRequest, -}; - -use crate::{PortableClientError, RegistryNotaryClient, RequestOptions}; - -/// JSON facade for language bindings. -/// -/// Inputs and outputs use canonical wire JSON shape: snake_case field names and -/// the same request/response structure as the HTTP API. The facade converts typed client -/// errors into redacted [`PortableClientError`] values. -#[derive(Debug, Clone)] -pub struct NotaryClientHandle { - client: RegistryNotaryClient, -} - -impl NotaryClientHandle { - /// Wrap a typed client. - #[must_use] - pub fn new(client: RegistryNotaryClient) -> Self { - Self { client } - } - - /// Submit canonical JSON for `POST /v1/evaluations`. - pub async fn evaluate_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .evaluate_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/batch-evaluations`. - pub async fn batch_evaluate_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .batch_evaluate_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/evaluations/{evaluation_id}/render`. - pub async fn render_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .render_request(request, options) - .await - .map(|response| response.body) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/credentials`. - pub async fn issue_credential_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .issue_credential_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/claims`. - pub async fn list_claims_json( - &self, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .list_claims(options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/claims/{claim_id}`. - pub async fn get_claim_json( - &self, - claim_id: String, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .get_claim(&claim_id, options) - .await - .map(|response| response.body) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/credentials/{credential_id}/status`. - pub async fn credential_status_json( - &self, - credential_id: String, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .credential_status(&credential_id, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } -} - -fn parse_options(options: serde_json::Value) -> Result { - parse_value::(options) -} - -fn parse_value( - value: serde_json::Value, -) -> Result { - serde_json::from_value(value).map_err(|_| PortableClientError { - kind: crate::PortableErrorKind::Decode, - status: None, - code: Some("decode.failed".to_string()), - title: "Failed to decode request JSON".to_string(), - retryable: false, - request_id: None, - }) -} diff --git a/crates/registry-notary-client/src/federation/mod.rs b/crates/registry-notary-client/src/federation/mod.rs deleted file mode 100644 index 525ad15d7..000000000 --- a/crates/registry-notary-client/src/federation/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Federation helper module. - -pub use crate::client::RegistryNotaryClient; diff --git a/crates/registry-notary-client/src/headers.rs b/crates/registry-notary-client/src/headers.rs deleted file mode 100644 index af384ad3c..000000000 --- a/crates/registry-notary-client/src/headers.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Header and media-type constants for Registry Notary clients. - -pub const DATA_PURPOSE: &str = "data-purpose"; -pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key"; -pub const REQUEST_ID: &str = "x-request-id"; -pub const TRACEPARENT: &str = "traceparent"; -pub const RETRY_AFTER: &str = "retry-after"; -pub const DATE: &str = "date"; -pub const APPLICATION_JSON: &str = "application/json"; -pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json"; -pub const APPLICATION_JWT: &str = "application/jwt"; diff --git a/crates/registry-notary-client/src/lib.rs b/crates/registry-notary-client/src/lib.rs deleted file mode 100644 index 587dcc958..000000000 --- a/crates/registry-notary-client/src/lib.rs +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Typed Registry Notary HTTP client. -//! -//! This crate is the Rust client for Registry Notary. It wraps the HTTP API -//! with typed request and response types, strict transport defaults, bounded -//! response reads, route-aware retries, and redacted error surfaces. -//! -//! # Quick start -//! -//! ```no_run -//! # async fn run() -> Result<(), Box> { -//! use registry_notary_client::RegistryNotaryClient; -//! -//! let client = RegistryNotaryClient::builder("https://notary.example.gov") -//! .bearer_token("token") -//! .default_purpose("benefits_eligibility") -//! .user_agent("benefits-api/1.0") -//! .build()?; -//! -//! let evaluation = client -//! .evaluate_target("Person") -//! .target_identifier("national_id", "person-1") -//! .relationship("self") -//! .claims(["person-is-alive"]) -//! .disclosure("predicate") -//! .send() -//! .await?; -//! -//! if let Some(result) = evaluation.body.first_result() { -//! println!("{} = {:?}", result.claim_id, result.satisfied); -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! # Wire request types and helper wrappers -//! -//! Request types come from `registry-notary-core` for routes whose wire shapes -//! are part of the service contract. Batch evaluation now uses -//! [`registry_notary_core::BatchEvaluateResponse`] directly. The helper -//! wrappers in [`responses`] are not compatibility workarounds; they add -//! redacted formatting or ergonomic accessors on top of the wire responses. -//! -//! # Feature flags -//! -//! - `oid4vci` enables OpenID4VCI endpoint helpers. -//! - `federation` enables delegated evaluation JWS submission. -//! - `json-facade` enables a binding-safe JSON facade for Python and Node -//! wrappers. -//! - `verifier` enables explicit, opt-in SD-JWT VC verification helpers. -//! - `test-support` exposes the test-only `reqwest::Client` override and -//! loopback HTTP allowance. -//! -//! # Safety contract -//! -//! The client rejects multiple authentication modes, disables redirects, -//! ignores proxy environment variables, bounds every response body, and redacts -//! raw Problem Details `detail`, compact credentials, holder proofs, nonces, -//! SD-JWT disclosures, and token material from incidental formatting surfaces. - -pub mod auth; -mod client; -pub mod error; -pub mod headers; -pub mod options; -pub mod responses; - -#[cfg(feature = "json-facade")] -pub mod facade; -#[cfg(feature = "federation")] -pub mod federation; -#[cfg(feature = "oid4vci")] -pub mod oid4vci; -#[cfg(feature = "verifier")] -pub mod verifier; - -pub use client::{EvaluateBuilder, NotaryClientBuilder, RegistryNotaryClient}; -pub use error::{ - NotaryClientBuildError, NotaryClientError, Oid4vciError, PortableClientError, - PortableErrorKind, ProblemDetails, -}; -pub use options::{RequestOptions, RetryAfter, RetryPolicy}; -pub use responses::{ - AdminReloadResponse, CredentialIssueResponse, CredentialStatusResponse, - CredentialStatusUpdateRequest, EnabledSignerSurfaceChecks, EvaluateResponse, Evaluation, - FormatsResponse, HealthResponse, ListClaimsResponse, NotaryResponse, ReadinessChecks, - ReadinessResponse, SignerCustodyChecks, SignerCustodySurfaces, SignerSurfaceChecks, - SigningProviderReadinessChecks, -}; -#[cfg(feature = "verifier")] -pub use verifier::{ - HolderBindingPolicy, StatusListPolicy, StatusListPolicyError, VerificationError, - VerifiedCredential, VerifyOptions, -}; diff --git a/crates/registry-notary-client/src/oid4vci/metadata.rs b/crates/registry-notary-client/src/oid4vci/metadata.rs deleted file mode 100644 index 0c46adf9d..000000000 --- a/crates/registry-notary-client/src/oid4vci/metadata.rs +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI metadata re-exports. - -pub use registry_platform_oid4vci::{ - CredentialIssuerMetadata, CredentialOffer, CredentialRequest, CredentialResponse, -}; diff --git a/crates/registry-notary-client/src/oid4vci/mod.rs b/crates/registry-notary-client/src/oid4vci/mod.rs deleted file mode 100644 index b7cede456..000000000 --- a/crates/registry-notary-client/src/oid4vci/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI helper module. - -pub mod metadata; diff --git a/crates/registry-notary-client/src/options.rs b/crates/registry-notary-client/src/options.rs deleted file mode 100644 index 28888cb3e..000000000 --- a/crates/registry-notary-client/src/options.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Per-request options and retry policy. - -use std::time::Duration; - -/// Per-request options shared by route methods. -/// -/// These map to safe request headers. Unsupported combinations, such as an -/// idempotency key on a route that does not honor it, are rejected before a -/// request is sent. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -#[non_exhaustive] -pub struct RequestOptions { - /// Data-purpose value sent as the `Data-Purpose` header. - pub purpose: Option, - /// Caller-supplied request id sent as `X-Request-Id`. - pub request_id: Option, - /// `Idempotency-Key` for routes that explicitly support replay-safe POST. - pub idempotency_key: Option, - /// Override the route's default `Accept` header. - pub accept: Option, - /// W3C trace context header to propagate to the server. - pub traceparent: Option, -} - -impl RequestOptions { - /// Start building request options fluently. - #[must_use] - pub fn builder() -> RequestOptionsBuilder { - RequestOptionsBuilder::default() - } - - pub(crate) fn is_empty(&self) -> bool { - self.purpose.is_none() - && self.request_id.is_none() - && self.idempotency_key.is_none() - && self.accept.is_none() - && self.traceparent.is_none() - } -} - -/// Builder for [`RequestOptions`]. -#[derive(Debug, Clone, Default)] -pub struct RequestOptionsBuilder { - options: RequestOptions, -} - -impl RequestOptionsBuilder { - /// Set the `Data-Purpose` header. - #[must_use] - pub fn purpose(mut self, purpose: impl Into) -> Self { - self.options.purpose = Some(purpose.into()); - self - } - - /// Set the `X-Request-Id` header. - #[must_use] - pub fn request_id(mut self, request_id: impl Into) -> Self { - self.options.request_id = Some(request_id.into()); - self - } - - /// Set the `Idempotency-Key` header. - /// - /// The client only permits this on batch evaluation, where the server has a - /// replay contract. - #[must_use] - pub fn idempotency_key(mut self, key: impl Into) -> Self { - self.options.idempotency_key = Some(key.into()); - self - } - - /// Override the `Accept` header for this request. - #[must_use] - pub fn accept(mut self, accept: impl Into) -> Self { - self.options.accept = Some(accept.into()); - self - } - - /// Set the W3C `traceparent` header. - #[must_use] - pub fn traceparent(mut self, traceparent: impl Into) -> Self { - self.options.traceparent = Some(traceparent.into()); - self - } - - /// Finish building the options. - #[must_use] - pub fn build(self) -> RequestOptions { - self.options - } -} - -/// Route-aware retry policy. -/// -/// Retries are conservative by default. GET routes may retry when the selected -/// error class is enabled. Batch evaluation may retry only when an -/// `Idempotency-Key` is supplied. Non-deduplicated POST routes such as -/// evaluation, render, credential issuance, OID4VCI credential, and federation -/// submission are not retried even when this policy allows retryable errors. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RetryPolicy { - /// Maximum attempts, including the first request. - pub max_attempts: usize, - /// Base exponential-backoff delay. - pub base_delay: Duration, - /// Maximum delay between attempts. - pub max_delay: Duration, - /// Retry transport errors on retry-eligible routes. - pub retry_transport_errors: bool, - /// Retry HTTP 429 on retry-eligible routes. - pub retry_rate_limited: bool, - /// Retry HTTP 503 on retry-eligible routes. - pub retry_unavailable: bool, -} - -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_attempts: 1, - base_delay: Duration::from_millis(50), - max_delay: Duration::from_secs(1), - retry_transport_errors: false, - retry_rate_limited: false, - retry_unavailable: false, - } - } -} - -/// Parsed `Retry-After` header value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RetryAfter { - /// Delta-seconds form. - Delta(Duration), - /// HTTP-date form. Callers can log or interpret this if needed. - HttpDate(String), -} diff --git a/crates/registry-notary-client/src/responses.rs b/crates/registry-notary-client/src/responses.rs deleted file mode 100644 index e73e92c12..000000000 --- a/crates/registry-notary-client/src/responses.rs +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Client-owned response types and ergonomic wrappers. - -use std::{collections::BTreeMap, fmt}; - -use registry_notary_core::{BatchEvaluateResponse, ClaimResultView}; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; - -use crate::options::RetryAfter; - -#[doc(hidden)] -pub trait SafeDebug { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result; -} - -macro_rules! impl_safe_debug { - ($($t:ty),* $(,)?) => { - $( - impl SafeDebug for $t { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } - } - )* - }; -} - -/// Response body for `POST /v1/evaluations`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvaluateResponse { - /// Claim results returned by the server. - pub results: Vec, -} - -/// Response body for `GET /v1/claims`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListClaimsResponse { - /// Claim definitions as server-owned JSON documents. - pub data: Vec, -} - -/// Response body for `GET /v1/formats`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FormatsResponse { - /// Supported evidence formats. - pub formats: Vec, -} - -/// Response body for direct credential issuance. -/// -/// This contains credential material intentionally. `Debug` redacts the compact -/// credential, issuer-signed JWT, and disclosures. -#[derive(Clone, Serialize, Deserialize)] -pub struct CredentialIssueResponse { - /// Server credential id. - pub credential_id: String, - /// Credential profile used for issuance. - pub credential_profile: String, - /// Credential format, for example SD-JWT VC. - pub format: String, - /// Issuer identifier. - pub issuer: String, - /// Credential expiry timestamp. - pub expires_at: String, - /// Compact credential body. - pub credential: String, - /// Issuer-signed JWT component. - pub issuer_signed_jwt: String, - /// SD-JWT disclosures. - pub disclosures: Vec, -} - -impl fmt::Debug for CredentialIssueResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialIssueResponse") - .field("credential_id", &self.credential_id) - .field("credential_profile", &self.credential_profile) - .field("format", &self.format) - .field("issuer", &self.issuer) - .field("expires_at", &self.expires_at) - .field("credential", &"") - .field("issuer_signed_jwt", &"") - .field("disclosures", &"") - .finish() - } -} - -/// Response body for credential status lookup and update. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CredentialStatusResponse { - /// Server credential id. - pub credential_id: String, - /// Issuer identifier. - pub issuer: String, - /// Credential profile used for issuance. - pub credential_profile: String, - /// Current lifecycle status. - pub status: String, - /// Issuance timestamp. - pub issued_at: String, - /// Expiry timestamp. - pub expires_at: String, - /// Last status update timestamp. - pub updated_at: String, -} - -/// Request body for admin credential status update. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CredentialStatusUpdateRequest { - /// New status value. - pub status: String, -} - -/// Response body for `POST /admin/v1/reload`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AdminReloadResponse { - /// Whether reload executed. - pub reloaded: bool, - /// Reload status. - pub status: String, - /// Human-readable detail. - pub detail: String, -} - -/// Health response body. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct HealthResponse { - /// Overall status. - pub status: String, - /// Service-specific checks. - pub checks: serde_json::Value, -} - -/// Readiness response body. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ReadinessResponse { - /// Overall readiness status. - pub status: String, - /// Readiness checks, including signer-custody facts. - pub checks: ReadinessChecks, -} - -/// Aggregate readiness checks returned on both success and failure. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ReadinessChecks { - /// Total number of readiness checks. - pub total: usize, - /// Checks that are ready. - pub ok: usize, - /// Checks that are serving with reduced assurance. - pub degraded: usize, - /// Checks that are not ready. - pub failed: usize, - /// Signing-provider health and custody facts. - pub signing_providers: SigningProviderReadinessChecks, -} - -/// Runtime signer health and custody checks. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SigningProviderReadinessChecks { - /// Active signing providers checked by the runtime. - pub total: usize, - /// Signing providers that are ready. - pub ok: usize, - /// Signing providers that are not ready. - pub failed: usize, - /// Custody facts for configured signing roles. - pub custody: SignerCustodyChecks, -} - -/// Public, non-secret signer-custody facts. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerCustodyChecks { - /// Active provider kinds and their counts. - pub active_provider_counts: BTreeMap, - /// Distinct providers bound to custody-relevant signing roles. - pub signing_provider_count: usize, - /// Custody-relevant providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Whether the deployment profile requires explicit custody approval. - pub custody_approval_required: bool, - /// Whether the operator declared that custody review has approved the signers. - pub custody_approved: bool, - /// Custody-relevant providers not covered by approval. - pub unapproved_signing_provider_count: usize, - /// Counts grouped by signing surface. - pub surfaces: SignerCustodySurfaces, -} - -/// Signer-custody facts grouped by Notary signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerCustodySurfaces { - /// Credential issuance signer facts. - pub credential_issuance: SignerSurfaceChecks, - /// Access-token issuance signer facts. - pub access_token_issuance: EnabledSignerSurfaceChecks, - /// Federation response signer facts. - pub federation: EnabledSignerSurfaceChecks, -} - -/// Signer counts for an always-available signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerSurfaceChecks { - /// Distinct signing providers used by the surface. - pub signing_provider_count: usize, - /// Surface providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Surface providers not covered by custody approval. - pub unapproved_signing_provider_count: usize, -} - -/// Signer counts for an optional signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EnabledSignerSurfaceChecks { - /// Whether the optional surface is enabled. - pub enabled: bool, - /// Distinct signing providers used by the surface. - pub signing_provider_count: usize, - /// Surface providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Surface providers not covered by custody approval. - pub unapproved_signing_provider_count: usize, -} - -impl_safe_debug!( - EvaluateResponse, - ListClaimsResponse, - FormatsResponse, - CredentialIssueResponse, - CredentialStatusResponse, - AdminReloadResponse, - HealthResponse, - ReadinessResponse, - ReadinessChecks, - SigningProviderReadinessChecks, - SignerCustodyChecks, - SignerCustodySurfaces, - SignerSurfaceChecks, - EnabledSignerSurfaceChecks, - BatchEvaluateResponse, - serde_json::Value, - String, -); - -impl SafeDebug for Vec { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } -} - -#[cfg(feature = "oid4vci")] -impl_safe_debug!( - registry_platform_oid4vci::CredentialIssuerMetadata, - registry_platform_oid4vci::CredentialOffer, -); - -#[cfg(feature = "oid4vci")] -impl SafeDebug for registry_platform_oid4vci::CredentialResponse { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("") - } -} - -/// HTTP response wrapper returned by all typed client methods. -/// -/// The wrapper preserves selected response metadata captured before body -/// decoding. `Debug` uses [`SafeDebug`] to avoid accidental credential leaks for -/// sensitive body types. -#[derive(Clone)] -pub struct NotaryResponse { - /// Decoded response body. - pub body: T, - /// HTTP status returned by the server. - pub status: StatusCode, - /// Server `X-Request-Id`, when present. - pub request_id: Option, - /// Server `Retry-After`, when present. - pub retry_after: Option, -} - -struct SafeDebugBody<'a, T>(&'a T); - -impl fmt::Debug for SafeDebugBody<'_, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt_debug(f) - } -} - -impl fmt::Debug for NotaryResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("NotaryResponse") - .field("body", &SafeDebugBody(&self.body)) - .field("status", &self.status) - .field("request_id", &self.request_id) - .field("retry_after", &self.retry_after) - .finish() - } -} - -impl NotaryResponse { - pub(crate) fn map(self, body: U) -> NotaryResponse { - NotaryResponse { - body, - status: self.status, - request_id: self.request_id, - retry_after: self.retry_after, - } - } -} - -/// Ergonomic wrapper over an evaluation response. -#[derive(Debug, Clone)] -pub struct Evaluation { - /// Claim results returned by the server. - pub results: Vec, -} - -impl Evaluation { - /// Return the first result's evaluation id. - #[must_use] - pub fn evaluation_id(&self) -> Option<&str> { - self.results - .first() - .map(|result| result.evaluation_id.as_str()) - } - - /// Return the first claim result. - #[must_use] - pub fn first_result(&self) -> Option<&ClaimResultView> { - self.results.first() - } - - /// Return the first result matching `claim_id`. - #[must_use] - pub fn result_for(&self, claim_id: &str) -> Option<&ClaimResultView> { - self.results - .iter() - .find(|result| result.claim_id == claim_id) - } -} - -impl_safe_debug!(Evaluation); diff --git a/crates/registry-notary-client/src/verifier.rs b/crates/registry-notary-client/src/verifier.rs deleted file mode 100644 index b446f8b16..000000000 --- a/crates/registry-notary-client/src/verifier.rs +++ /dev/null @@ -1,1434 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Explicit SD-JWT VC verification helpers. - -use std::collections::BTreeSet; -use std::fmt; -use std::io::Read; -use std::time::Duration; - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use flate2::read::ZlibDecoder; -use registry_notary_core::SD_JWT_VC_JWT_TYP; -use registry_platform_crypto::{verify, PublicJwk, SigningAlgorithm}; -use registry_platform_httputil::{read_bounded, BoundedReadError, FetchUrlError, FetchUrlPolicy}; -use reqwest::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE}; -use reqwest::{StatusCode, Url}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use thiserror::Error; -use time::OffsetDateTime; - -const STATUS_LIST_MEDIA_TYPE: &str = "application/statuslist+jwt"; -const STATUS_LIST_FETCH_TIMEOUT: Duration = Duration::from_secs(10); -const STATUS_LIST_DNS_TIMEOUT: Duration = Duration::from_secs(3); -const MAX_STATUS_LIST_RESPONSE_BYTES: u64 = 256 * 1024; -const MAX_STATUS_LIST_COMPRESSED_BYTES: usize = 128 * 1024; -const MAX_STATUS_LIST_DECOMPRESSED_BYTES: usize = 128 * 1024; -const MAX_STATUS_LIST_URI_BYTES: usize = 4_096; -const MAX_STATUS_LIST_ORIGINS: usize = 16; -const MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS: i64 = 300; -const MAX_STATUS_LIST_CLOCK_SKEW_SECONDS: u64 = 60; - -/// Trusted status-list origins associated with exactly one credential issuer. -/// -/// The primary origin is required. Additional origins are accepted only after -/// the caller adds each exact HTTPS origin explicitly. Paths, queries, -/// fragments, URL credentials, localhost, private networks, and unsafe DNS -/// answers are still rejected by the fetch path. -#[derive(Clone, PartialEq, Eq)] -pub struct StatusListPolicy { - issuer: String, - origins: BTreeSet, - #[cfg(any(test, feature = "test-support"))] - allow_loopback_http: bool, -} - -impl fmt::Debug for StatusListPolicy { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("StatusListPolicy") - .field("issuer", &"") - .field("trusted_origin_count", &self.origins.len()) - .finish() - } -} - -impl StatusListPolicy { - /// Bind an issuer to its primary trusted HTTPS status origin. - pub fn new( - issuer: impl Into, - trusted_origin: impl AsRef, - ) -> Result { - let issuer = issuer.into(); - if issuer.trim().is_empty() { - return Err(StatusListPolicyError::InvalidIssuer); - } - let origin = parse_status_origin(trusted_origin.as_ref(), false)?; - Ok(Self { - issuer, - origins: BTreeSet::from([origin]), - #[cfg(any(test, feature = "test-support"))] - allow_loopback_http: false, - }) - } - - /// Add one exact HTTPS origin that is trusted for the same issuer. - pub fn allow_origin(mut self, origin: impl AsRef) -> Result { - if self.origins.len() >= MAX_STATUS_LIST_ORIGINS { - return Err(StatusListPolicyError::TooManyOrigins); - } - let origin = parse_status_origin(origin.as_ref(), false)?; - self.origins.insert(origin); - Ok(self) - } - - /// Construct a loopback-only HTTP policy for local test harnesses. - /// - /// This escape hatch is absent from production release builds. It does not - /// admit non-loopback HTTP origins. - #[cfg(any(test, feature = "test-support"))] - pub fn loopback_for_testing( - issuer: impl Into, - trusted_origin: impl AsRef, - ) -> Result { - let issuer = issuer.into(); - if issuer.trim().is_empty() { - return Err(StatusListPolicyError::InvalidIssuer); - } - let origin = parse_status_origin(trusted_origin.as_ref(), true)?; - Ok(Self { - issuer, - origins: BTreeSet::from([origin]), - allow_loopback_http: true, - }) - } - - fn permits(&self, issuer: &str, url: &Url) -> Result<(), VerificationError> { - if self.issuer != issuer { - return Err(VerificationError::StatusPolicy { - code: "status.policy_issuer_mismatch", - }); - } - let origin = url.origin().ascii_serialization(); - if !self.origins.contains(&origin) { - return Err(VerificationError::StatusPolicy { - code: "status.origin_untrusted", - }); - } - Ok(()) - } - - fn fetch_url_policy(&self) -> FetchUrlPolicy { - #[cfg(any(test, feature = "test-support"))] - if self.allow_loopback_http { - return FetchUrlPolicy::dev(); - } - FetchUrlPolicy::strict() - } -} - -/// Invalid caller-owned status trust configuration. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum StatusListPolicyError { - #[error("status-list issuer must not be empty")] - InvalidIssuer, - #[error("status-list origin must be an exact HTTPS origin")] - InvalidOrigin, - #[error("status-list origin contains URL credentials or resource components")] - OriginHasResourceComponents, - #[error("status-list origin allow-list exceeds the supported bound")] - TooManyOrigins, -} - -/// Caller-owned policy for explicit SD-JWT VC verification. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VerifyOptions { - /// Issuer identifier expected in the `iss` claim. - pub expected_issuer: String, - /// Accepted JWS algorithms. Defaults to `EdDSA`. - pub accepted_algorithms: BTreeSet, - /// Expected SD-JWT VC `vct`, when the caller wants a concrete profile. - pub expected_vct: Option, - /// Allowed skew for `exp`, `nbf`, and future `iat` checks. - pub clock_skew: Duration, - /// Holder-binding policy for the embedded `cnf` confirmation. - pub holder_binding: HolderBindingPolicy, - /// Expected key-binding JWT audience for verifier-controlled challenges. - pub expected_key_binding_audience: Option, - /// Expected key-binding JWT nonce for verifier-controlled challenges. - pub expected_key_binding_nonce: Option, - /// Mandatory trust policy when the credential carries a status reference. - pub status_list: Option, - /// Test hook for deterministic time checks. Production callers should leave - /// this unset. - pub now: Option, -} - -impl VerifyOptions { - #[must_use] - pub fn new(expected_issuer: impl Into) -> Self { - Self { - expected_issuer: expected_issuer.into(), - accepted_algorithms: BTreeSet::from(["EdDSA".to_string()]), - expected_vct: None, - clock_skew: Duration::from_secs(60), - holder_binding: HolderBindingPolicy::NotRequired, - expected_key_binding_audience: None, - expected_key_binding_nonce: None, - status_list: None, - now: None, - } - } - - #[must_use] - pub fn expected_vct(mut self, expected_vct: impl Into) -> Self { - self.expected_vct = Some(expected_vct.into()); - self - } - - #[must_use] - pub fn accepted_algorithms( - mut self, - algorithms: impl IntoIterator>, - ) -> Self { - self.accepted_algorithms = algorithms.into_iter().map(Into::into).collect(); - self - } - - #[must_use] - pub fn clock_skew(mut self, clock_skew: Duration) -> Self { - self.clock_skew = clock_skew; - self - } - - #[must_use] - pub fn holder_binding(mut self, holder_binding: HolderBindingPolicy) -> Self { - self.holder_binding = holder_binding; - self - } - - #[must_use] - pub fn key_binding_challenge( - mut self, - expected_audience: impl Into, - expected_nonce: impl Into, - ) -> Self { - self.expected_key_binding_audience = Some(expected_audience.into()); - self.expected_key_binding_nonce = Some(expected_nonce.into()); - self - } - - /// Configure fail-closed verification for status-bearing credentials. - #[must_use] - pub fn status_list(mut self, policy: StatusListPolicy) -> Self { - self.status_list = Some(policy); - self - } - - #[must_use] - pub fn now(mut self, now: OffsetDateTime) -> Self { - self.now = Some(now); - self - } -} - -/// Holder-binding expectation for the credential confirmation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum HolderBindingPolicy { - /// Do not require a holder confirmation. - NotRequired, - /// Require `cnf.jwk`, and validate that it is a public JWK. - Required, - /// Require `cnf.jwk` and this exact `cnf.kid`. - RequiredKid(String), -} - -/// Verified credential metadata returned after successful verification. -#[derive(Clone, PartialEq, Eq)] -pub struct VerifiedCredential { - pub issuer: String, - pub subject: Option, - pub credential_id: Option, - pub vct: String, - pub key_id: String, - pub algorithm: String, - pub expires_at: i64, - pub not_before: Option, - pub issued_at: i64, - pub disclosure_count: usize, - pub holder_key_id: Option, -} - -impl fmt::Debug for VerifiedCredential { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("VerifiedCredential") - .field("issuer", &self.issuer) - .field("subject", &self.subject.as_ref().map(|_| "")) - .field("credential_id", &self.credential_id) - .field("vct", &self.vct) - .field("key_id", &self.key_id) - .field("algorithm", &self.algorithm) - .field("expires_at", &self.expires_at) - .field("not_before", &self.not_before) - .field("issued_at", &self.issued_at) - .field("disclosure_count", &self.disclosure_count) - .field("holder_key_id", &self.holder_key_id) - .finish() - } -} - -/// Redacted verifier error with a stable policy code. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum VerificationError { - #[error("SD-JWT VC is malformed")] - Malformed { code: &'static str }, - #[error("SD-JWT VC header uses an unsupported type")] - HeaderType { code: &'static str }, - #[error("SD-JWT VC header contains an untrusted key reference")] - UntrustedKeyReference { code: &'static str }, - #[error("SD-JWT VC algorithm is not accepted")] - AlgorithmDisallowed { code: &'static str }, - #[error("SD-JWT VC signing key metadata does not match the header")] - AlgorithmKeyMismatch { code: &'static str }, - #[error("SD-JWT VC signing key is missing")] - MissingKeyId { code: &'static str }, - #[error("SD-JWT VC signing key is unknown")] - UnknownKey { code: &'static str }, - #[error("SD-JWT VC signature is invalid")] - InvalidSignature { code: &'static str }, - #[error("SD-JWT VC issuer does not match policy")] - IssuerMismatch { code: &'static str }, - #[error("SD-JWT VC credential profile does not match policy")] - VctMismatch { code: &'static str }, - #[error("SD-JWT VC time claim is invalid")] - TimeClaim { code: &'static str }, - #[error("SD-JWT VC disclosure digest does not match")] - DisclosureDigestMismatch { code: &'static str }, - #[error("SD-JWT VC holder binding does not match policy")] - HolderBinding { code: &'static str }, - #[error("OID4VCI credential response does not contain a compact SD-JWT VC")] - UnsupportedCredentialShape { code: &'static str }, - #[error("issuer JWKS could not be loaded")] - JwksUnavailable { code: &'static str }, - #[error("credential status trust policy is missing or does not match")] - StatusPolicy { code: &'static str }, - #[error("status-bearing credentials require the asynchronous client verifier")] - StatusVerificationRequired { code: &'static str }, - #[error("credential status endpoint could not be reached safely")] - StatusFetch { code: &'static str }, - #[error("credential status response does not satisfy transport policy")] - StatusResponse { code: &'static str }, - #[error("credential status-list token is invalid")] - StatusToken { code: &'static str }, - #[error("credential status is not valid")] - CredentialStatus { code: &'static str }, -} - -impl VerificationError { - #[must_use] - pub const fn code(&self) -> &'static str { - match self { - Self::Malformed { code } - | Self::HeaderType { code } - | Self::UntrustedKeyReference { code } - | Self::AlgorithmDisallowed { code } - | Self::AlgorithmKeyMismatch { code } - | Self::MissingKeyId { code } - | Self::UnknownKey { code } - | Self::InvalidSignature { code } - | Self::IssuerMismatch { code } - | Self::VctMismatch { code } - | Self::TimeClaim { code } - | Self::DisclosureDigestMismatch { code } - | Self::HolderBinding { code } - | Self::UnsupportedCredentialShape { code } - | Self::JwksUnavailable { code } - | Self::StatusPolicy { code } - | Self::StatusVerificationRequired { code } - | Self::StatusFetch { code } - | Self::StatusResponse { code } - | Self::StatusToken { code } - | Self::CredentialStatus { code } => code, - } - } - - #[must_use] - pub const fn is_unknown_key(&self) -> bool { - matches!(self, Self::UnknownKey { .. }) - } - - #[must_use] - pub(crate) fn is_status_unknown_key(&self) -> bool { - matches!(self, Self::StatusToken { code } if *code == "status.key.unknown") - } - - pub(crate) const fn jwks_unavailable() -> Self { - Self::JwksUnavailable { - code: "jwks.unavailable", - } - } -} - -/// Verify one status-free SD-JWT VC against caller-supplied trusted JWKS. -/// -/// A status-bearing credential always fails with `status.policy_required` or -/// `status.fetch_required`. Use [`crate::RegistryNotaryClient::verify_sd_jwt_vc`] -/// so the status material is fetched and verified under [`StatusListPolicy`]. -pub fn verify_sd_jwt_vc( - compact: &str, - jwks: &Value, - options: &VerifyOptions, -) -> Result { - let pending = verify_sd_jwt_vc_pending(compact, jwks, options)?; - if pending.status.is_some() { - return if options.status_list.is_some() { - Err(VerificationError::StatusVerificationRequired { - code: "status.fetch_required", - }) - } else { - Err(VerificationError::StatusPolicy { - code: "status.policy_required", - }) - }; - } - Ok(pending.credential) -} - -pub(crate) struct PendingCredentialVerification { - pub(crate) credential: VerifiedCredential, - pub(crate) status: Option, -} - -#[derive(Clone)] -pub(crate) struct StatusListReference { - uri: Url, - index: u64, -} - -pub(crate) fn verify_sd_jwt_vc_pending( - compact: &str, - jwks: &Value, - options: &VerifyOptions, -) -> Result { - let parsed = ParsedSdJwt::parse(compact)?; - let header = decode_segment(parsed.header_b64)?; - let payload = decode_segment(parsed.payload_b64)?; - - reject_untrusted_header_references(&header)?; - let alg = required_string(&header, "alg")?; - if !options.accepted_algorithms.contains(alg) { - return Err(VerificationError::AlgorithmDisallowed { - code: "algorithm.disallowed", - }); - } - if header.get("typ").and_then(Value::as_str) != Some(SD_JWT_VC_JWT_TYP) { - return Err(VerificationError::HeaderType { - code: "header.typ_mismatch", - }); - } - let kid = required_string(&header, "kid").map_err(|_| VerificationError::MissingKeyId { - code: "key.missing", - })?; - let jwk = find_jwk(jwks, kid)?; - if jwk_alg(&jwk) != Some(alg) || jwk.alg.as_deref().is_some_and(|jwk_alg| jwk_alg != alg) { - return Err(VerificationError::AlgorithmKeyMismatch { - code: "algorithm.key_mismatch", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(parsed.signature_b64) - .map_err(|_| VerificationError::Malformed { - code: "token.malformed", - })?; - verify(parsed.signing_input().as_bytes(), &signature, &jwk).map_err(|_| { - VerificationError::InvalidSignature { - code: "signature.invalid", - } - })?; - - verify_claims(&payload, &parsed, options, alg, kid) -} - -fn verify_claims( - payload: &Value, - parsed: &ParsedSdJwt<'_>, - options: &VerifyOptions, - alg: &str, - kid: &str, -) -> Result { - let issuer = required_string(payload, "iss")?; - if issuer != options.expected_issuer { - return Err(VerificationError::IssuerMismatch { - code: "claim.issuer_mismatch", - }); - } - let vct = required_string(payload, "vct")?; - if options - .expected_vct - .as_deref() - .is_some_and(|expected| expected != vct) - { - return Err(VerificationError::VctMismatch { - code: "claim.vct_mismatch", - }); - } - - let now = options - .now - .unwrap_or_else(OffsetDateTime::now_utc) - .unix_timestamp(); - let skew = i64::try_from(options.clock_skew.as_secs()).unwrap_or(i64::MAX); - let exp = required_i64(payload, "exp")?; - let iat = required_i64(payload, "iat")?; - let nbf = optional_i64(payload, "nbf")?; - if exp <= now.saturating_sub(skew) || iat > now.saturating_add(skew) { - return Err(VerificationError::TimeClaim { - code: "claim.time_invalid", - }); - } - if let Some(nbf) = nbf { - if nbf > now.saturating_add(skew) { - return Err(VerificationError::TimeClaim { - code: "claim.time_invalid", - }); - } - } - - let disclosed_status = verify_disclosures(payload, &parsed.disclosures)?; - let holder_key_id = verify_holder_binding(HolderBindingContext { - payload, - policy: &options.holder_binding, - key_binding_jwt: parsed.key_binding_jwt, - sd_hash_input: parsed.sd_hash_input, - expected_audience: options.expected_key_binding_audience.as_deref(), - expected_nonce: options.expected_key_binding_nonce.as_deref(), - now, - skew, - })?; - let status = parse_status_reference(payload, disclosed_status.as_ref())?; - let credential = VerifiedCredential { - issuer: issuer.to_string(), - subject: payload - .get("sub") - .and_then(Value::as_str) - .map(ToString::to_string), - credential_id: payload - .get("jti") - .or_else(|| payload.get("id")) - .and_then(Value::as_str) - .map(ToString::to_string), - vct: vct.to_string(), - key_id: kid.to_string(), - algorithm: alg.to_string(), - expires_at: exp, - not_before: nbf, - issued_at: iat, - disclosure_count: parsed.disclosures.len(), - holder_key_id, - }; - Ok(PendingCredentialVerification { credential, status }) -} - -fn parse_status_reference( - payload: &Value, - disclosed_status: Option<&Value>, -) -> Result, VerificationError> { - let status = match (payload.get("status"), disclosed_status) { - (None, None) => return Ok(None), - (Some(status), None) | (None, Some(status)) => status, - (Some(_), Some(_)) => { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }) - } - }; - let status_list = status - .as_object() - .and_then(|status| status.get("status_list")) - .and_then(Value::as_object) - .ok_or(VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - let index = - status_list - .get("idx") - .and_then(Value::as_u64) - .ok_or(VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let raw_uri = status_list - .get("uri") - .and_then(Value::as_str) - .filter(|uri| !uri.is_empty() && uri.len() <= MAX_STATUS_LIST_URI_BYTES) - .ok_or(VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - let uri = Url::parse(raw_uri).map_err(|_| VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - if uri.host().is_none() - || !uri.username().is_empty() - || uri.password().is_some() - || uri.fragment().is_some() - { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }); - } - Ok(Some(StatusListReference { uri, index })) -} - -pub(crate) async fn fetch_status_list_token( - reference: &StatusListReference, - options: &VerifyOptions, -) -> Result { - let policy = options - .status_list - .as_ref() - .ok_or(VerificationError::StatusPolicy { - code: "status.policy_required", - })?; - policy.permits(&options.expected_issuer, &reference.uri)?; - let validated = policy - .fetch_url_policy() - .validate_for_immediate_fetch_with_timeout(&reference.uri, STATUS_LIST_DNS_TIMEOUT) - .await - .map_err(map_status_fetch_url_error)?; - let response = validated - .immediate_get_with_timeout(STATUS_LIST_FETCH_TIMEOUT) - .map_err(map_status_fetch_url_error)? - .header(ACCEPT, STATUS_LIST_MEDIA_TYPE) - .header(ACCEPT_ENCODING, "identity") - .send() - .await - .map_err(|_| VerificationError::StatusFetch { - code: "status.unreachable", - })?; - - if response.status().is_redirection() { - return Err(VerificationError::StatusFetch { - code: "status.redirect_denied", - }); - } - if response.status() != StatusCode::OK { - return Err(VerificationError::StatusResponse { - code: "status.http_status_invalid", - }); - } - if !has_exact_status_list_media_type(response.headers()) { - return Err(VerificationError::StatusResponse { - code: "status.media_type_invalid", - }); - } - if !has_identity_content_encoding(response.headers()) { - return Err(VerificationError::StatusResponse { - code: "status.content_encoding_denied", - }); - } - let body = read_bounded(response, MAX_STATUS_LIST_RESPONSE_BYTES) - .await - .map_err(map_status_body_error)?; - let compact = String::from_utf8(body).map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - if compact.trim() != compact || !is_compact_jws(&compact) { - return Err(VerificationError::StatusToken { - code: "status.token_malformed", - }); - } - Ok(compact) -} - -pub(crate) fn verify_status_list_token( - compact: &str, - reference: &StatusListReference, - jwks: &Value, - options: &VerifyOptions, -) -> Result<(), VerificationError> { - let mut parts = compact.split('.'); - let header_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let payload_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let signature_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - if parts.next().is_some() { - return Err(VerificationError::StatusToken { - code: "status.token_malformed", - }); - } - - let header = decode_status_segment(header_b64)?; - if ["crit", "jku", "jwk", "x5u", "x5c"] - .iter() - .any(|forbidden| header.get(forbidden).is_some()) - { - return Err(VerificationError::StatusToken { - code: "status.header.untrusted_key_reference", - }); - } - if header.get("typ").and_then(Value::as_str) != Some("statuslist+jwt") { - return Err(VerificationError::StatusToken { - code: "status.header.typ_mismatch", - }); - } - let algorithm = status_required_string(&header, "alg")?; - if !options.accepted_algorithms.contains(algorithm) { - return Err(VerificationError::StatusToken { - code: "status.algorithm.disallowed", - }); - } - let kid = status_required_string(&header, "kid")?; - let jwk = find_jwk(jwks, kid).map_err(|_| VerificationError::StatusToken { - code: "status.key.unknown", - })?; - if jwk_alg(&jwk) != Some(algorithm) - || jwk - .alg - .as_deref() - .is_some_and(|jwk_algorithm| jwk_algorithm != algorithm) - { - return Err(VerificationError::StatusToken { - code: "status.algorithm.key_mismatch", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let signing_input = format!("{header_b64}.{payload_b64}"); - verify(signing_input.as_bytes(), &signature, &jwk).map_err(|_| { - VerificationError::StatusToken { - code: "status.signature.invalid", - } - })?; - - let payload = decode_status_segment(payload_b64)?; - let status_uri = reference.uri.as_str(); - if status_required_string(&payload, "iss")? != options.expected_issuer { - return Err(VerificationError::StatusToken { - code: "status.claim.issuer_mismatch", - }); - } - if status_required_string(&payload, "sub")? != status_uri { - return Err(VerificationError::StatusToken { - code: "status.claim.uri_mismatch", - }); - } - if !exact_audience_matches(&payload, status_uri) { - return Err(VerificationError::StatusToken { - code: "status.claim.audience_mismatch", - }); - } - verify_status_time_claims(&payload, options)?; - let status = indexed_status(&payload, reference.index)?; - match status { - 0 => Ok(()), - 1 => Err(VerificationError::CredentialStatus { - code: "status.revoked", - }), - 2 => Err(VerificationError::CredentialStatus { - code: "status.suspended", - }), - _ => Err(VerificationError::CredentialStatus { - code: "status.unknown", - }), - } -} - -fn verify_status_time_claims( - payload: &Value, - options: &VerifyOptions, -) -> Result<(), VerificationError> { - let now = options - .now - .unwrap_or_else(OffsetDateTime::now_utc) - .unix_timestamp(); - let skew = i64::try_from( - options - .clock_skew - .as_secs() - .min(MAX_STATUS_LIST_CLOCK_SKEW_SECONDS), - ) - .expect("bounded status clock skew fits i64"); - let iat = payload - .get("iat") - .and_then(Value::as_i64) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let exp = payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let ttl = payload - .get("ttl") - .and_then(Value::as_i64) - .filter(|ttl| *ttl > 0 && *ttl <= MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let nbf = match payload.get("nbf") { - None => None, - Some(value) => Some(value.as_i64().ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?), - }; - let lifetime = exp.checked_sub(iat).ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - if lifetime <= 0 - || lifetime > ttl - || exp <= now.saturating_sub(skew) - || iat > now.saturating_add(skew) - || nbf.is_some_and(|not_before| not_before > now.saturating_add(skew)) - || nbf.is_some_and(|not_before| not_before >= exp) - { - return Err(VerificationError::StatusToken { - code: "status.claim.time_invalid", - }); - } - Ok(()) -} - -fn indexed_status(payload: &Value, index: u64) -> Result { - let status_list = payload - .get("status_list") - .and_then(Value::as_object) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })?; - let bits = status_list - .get("bits") - .and_then(Value::as_u64) - .filter(|bits| matches!(bits, 1 | 2 | 4 | 8)) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })? as u8; - let encoded = status_list - .get("lst") - .and_then(Value::as_str) - .filter(|encoded| !encoded.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })?; - let compressed = - URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| VerificationError::StatusToken { - code: "status.list.malformed", - })?; - if compressed.len() > MAX_STATUS_LIST_COMPRESSED_BYTES { - return Err(VerificationError::StatusToken { - code: "status.list.compressed_too_large", - }); - } - let list = decompress_status_list(&compressed)?; - let entries_per_byte = u64::from(8 / bits); - let byte_index = index / entries_per_byte; - let byte_index = usize::try_from(byte_index).map_err(|_| VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let byte = list - .get(byte_index) - .copied() - .ok_or(VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let shift = u8::try_from((index % entries_per_byte) * u64::from(bits)).map_err(|_| { - VerificationError::StatusToken { - code: "status.index.invalid", - } - })?; - let mask = if bits == 8 { - u8::MAX - } else { - (1_u8 << bits) - 1 - }; - Ok((byte >> shift) & mask) -} - -fn decompress_status_list(compressed: &[u8]) -> Result, VerificationError> { - let mut decoder = ZlibDecoder::new(compressed); - let mut decompressed = Vec::with_capacity(MAX_STATUS_LIST_DECOMPRESSED_BYTES.min(8_192)); - (&mut decoder) - .take((MAX_STATUS_LIST_DECOMPRESSED_BYTES + 1) as u64) - .read_to_end(&mut decompressed) - .map_err(|_| VerificationError::StatusToken { - code: "status.list.malformed", - })?; - if decompressed.len() > MAX_STATUS_LIST_DECOMPRESSED_BYTES - || decoder.total_in() != compressed.len() as u64 - { - return Err(VerificationError::StatusToken { - code: "status.list.decompression_limit", - }); - } - Ok(decompressed) -} - -fn decode_status_segment(segment: &str) -> Result { - let decoded = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - serde_json::from_slice(&decoded).map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - }) -} - -fn status_required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, VerificationError> { - value - .get(field) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - }) -} - -fn exact_audience_matches(payload: &Value, expected: &str) -> bool { - match payload.get("aud") { - Some(Value::String(audience)) => audience == expected, - Some(Value::Array(audiences)) if audiences.len() == 1 => { - audiences[0].as_str() == Some(expected) - } - _ => false, - } -} - -fn has_exact_status_list_media_type(headers: &reqwest::header::HeaderMap) -> bool { - let mut values = headers.get_all(CONTENT_TYPE).iter(); - matches!( - (values.next(), values.next()), - (Some(value), None) - if value - .to_str() - .is_ok_and(|value| value.eq_ignore_ascii_case(STATUS_LIST_MEDIA_TYPE)) - ) -} - -fn has_identity_content_encoding(headers: &reqwest::header::HeaderMap) -> bool { - let mut values = headers.get_all(CONTENT_ENCODING).iter(); - match (values.next(), values.next()) { - (None, None) => true, - (Some(value), None) => value - .to_str() - .is_ok_and(|value| value.eq_ignore_ascii_case("identity")), - _ => false, - } -} - -fn map_status_fetch_url_error(error: FetchUrlError) -> VerificationError { - match error { - FetchUrlError::Dns { .. } - | FetchUrlError::NoAddresses - | FetchUrlError::ValidationTimeout { .. } - | FetchUrlError::ValidationTask(_) => VerificationError::StatusFetch { - code: "status.unreachable", - }, - _ => VerificationError::StatusFetch { - code: "status.destination_unsafe", - }, - } -} - -fn map_status_body_error(error: BoundedReadError) -> VerificationError { - match error { - BoundedReadError::ContentLengthExceeded { .. } - | BoundedReadError::BodyTooLarge { .. } - | BoundedReadError::LengthOverflow => VerificationError::StatusResponse { - code: "status.response_too_large", - }, - BoundedReadError::Transport(_) => VerificationError::StatusFetch { - code: "status.unreachable", - }, - _ => VerificationError::StatusResponse { - code: "status.response_invalid", - }, - } -} - -fn parse_status_origin( - raw_origin: &str, - allow_loopback_http: bool, -) -> Result { - if raw_origin.len() > MAX_STATUS_LIST_URI_BYTES { - return Err(StatusListPolicyError::InvalidOrigin); - } - let origin = Url::parse(raw_origin).map_err(|_| StatusListPolicyError::InvalidOrigin)?; - if origin.host().is_none() || origin.port() == Some(0) { - return Err(StatusListPolicyError::InvalidOrigin); - } - if !origin.username().is_empty() - || origin.password().is_some() - || origin.path() != "/" - || origin.query().is_some() - || origin.fragment().is_some() - { - return Err(StatusListPolicyError::OriginHasResourceComponents); - } - let valid_scheme = origin.scheme() == "https" - || (allow_loopback_http - && origin.scheme() == "http" - && matches!(origin.host_str(), Some("127.0.0.1" | "localhost" | "::1"))); - if !valid_scheme { - return Err(StatusListPolicyError::InvalidOrigin); - } - Ok(origin.origin().ascii_serialization()) -} - -fn verify_disclosures( - payload: &Value, - disclosures: &[&str], -) -> Result, VerificationError> { - if disclosures.is_empty() && payload.get("_sd").is_none() { - return Ok(None); - } - if payload.get("_sd_alg").and_then(Value::as_str) != Some("sha-256") { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - } - let expected = payload - .get("_sd") - .and_then(Value::as_array) - .ok_or(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - })? - .iter() - .map(|digest| { - digest.as_str().map(ToString::to_string).ok_or( - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }, - ) - }) - .collect::, _>>()?; - - let mut actual = BTreeSet::new(); - let mut disclosed_status = None; - for disclosure in disclosures { - let decoded = URL_SAFE_NO_PAD.decode(disclosure).map_err(|_| { - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - } - })?; - let value: Value = serde_json::from_slice(&decoded).map_err(|_| { - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - } - })?; - let Some(items) = value.as_array().filter(|items| items.len() >= 3) else { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - }; - let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); - if !expected.contains(&digest) || !actual.insert(digest) { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - } - if items.get(1).and_then(Value::as_str) == Some("status") - && disclosed_status.replace(items[2].clone()).is_some() - { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }); - } - } - Ok(disclosed_status) -} - -struct HolderBindingContext<'a> { - payload: &'a Value, - policy: &'a HolderBindingPolicy, - key_binding_jwt: Option<&'a str>, - sd_hash_input: &'a str, - expected_audience: Option<&'a str>, - expected_nonce: Option<&'a str>, - now: i64, - skew: i64, -} - -fn verify_holder_binding( - context: HolderBindingContext<'_>, -) -> Result, VerificationError> { - if matches!(context.policy, HolderBindingPolicy::NotRequired) - && (context.key_binding_jwt.is_none() - || context.expected_audience.is_none() - || context.expected_nonce.is_none()) - { - return Ok(context - .payload - .get("cnf") - .and_then(|cnf| cnf.get("kid")) - .and_then(Value::as_str) - .map(ToString::to_string)); - } - let cnf = context - .payload - .get("cnf") - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.required", - })?; - let jwk_value = cnf.get("jwk").ok_or(VerificationError::HolderBinding { - code: "holder_binding.required", - })?; - let jwk_json = - serde_json::to_string(jwk_value).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.invalid", - })?; - let holder_jwk = PublicJwk::parse(&jwk_json).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.invalid", - })?; - let actual_kid = cnf - .get("kid") - .and_then(Value::as_str) - .map(ToString::to_string); - if let HolderBindingPolicy::RequiredKid(expected) = context.policy { - if actual_kid.as_deref() != Some(expected.as_str()) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.kid_mismatch", - }); - } - } - if context.key_binding_jwt.is_none() - && context.expected_audience.is_some() - && context.expected_nonce.is_some() - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.challenge_required", - }); - } - if let Some(key_binding_jwt) = context.key_binding_jwt { - verify_key_binding_jwt( - key_binding_jwt, - context.sd_hash_input, - &holder_jwk, - context.expected_audience, - context.expected_nonce, - context.now, - context.skew, - )?; - } - Ok(actual_kid) -} - -fn verify_key_binding_jwt( - compact: &str, - sd_hash_input: &str, - holder_jwk: &PublicJwk, - expected_audience: Option<&str>, - expected_nonce: Option<&str>, - now: i64, - skew: i64, -) -> Result<(), VerificationError> { - let mut parts = compact.split('.'); - let header_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let payload_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let signature_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if parts.next().is_some() - || header_b64.is_empty() - || payload_b64.is_empty() - || signature_b64.is_empty() - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let header = decode_segment(header_b64).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - reject_untrusted_header_references(&header)?; - if header.get("alg").and_then(Value::as_str) != Some("EdDSA") - || header.get("typ").and_then(Value::as_str) != Some("kb+jwt") - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let signing_input = format!("{header_b64}.{payload_b64}"); - verify(signing_input.as_bytes(), &signature, holder_jwk).map_err(|_| { - VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - } - })?; - let payload = decode_segment(payload_b64).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let iat = - payload - .get("iat") - .and_then(Value::as_i64) - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if iat > now.saturating_add(skew) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let exp = - payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if exp <= now.saturating_sub(skew) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - if payload - .get("nbf") - .and_then(Value::as_i64) - .is_some_and(|nbf| nbf > now.saturating_add(skew)) - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let expected_sd_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(sd_hash_input.as_bytes())); - if payload.get("sd_hash").and_then(Value::as_str) != Some(expected_sd_hash.as_str()) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let (Some(expected_audience), Some(expected_nonce)) = (expected_audience, expected_nonce) - else { - return Err(VerificationError::HolderBinding { - code: "holder_binding.challenge_required", - }); - }; - if !audience_matches(&payload, expected_audience) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - if payload.get("nonce").and_then(Value::as_str) != Some(expected_nonce) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - Ok(()) -} - -fn audience_matches(payload: &Value, expected_audience: &str) -> bool { - match payload.get("aud") { - Some(Value::String(audience)) => audience == expected_audience, - Some(Value::Array(audiences)) => audiences - .iter() - .any(|audience| audience.as_str() == Some(expected_audience)), - _ => false, - } -} - -fn reject_untrusted_header_references(header: &Value) -> Result<(), VerificationError> { - for forbidden in ["crit", "jku", "jwk", "x5u", "x5c"] { - if header.get(forbidden).is_some() { - return Err(VerificationError::UntrustedKeyReference { - code: "header.untrusted_key_reference", - }); - } - } - Ok(()) -} - -fn find_jwk(jwks: &Value, kid: &str) -> Result { - let keys = jwks - .get("keys") - .and_then(Value::as_array) - .ok_or(VerificationError::UnknownKey { - code: "key.unknown", - })?; - for key in keys { - if key.get("kid").and_then(Value::as_str) == Some(kid) { - let json = serde_json::to_string(key).map_err(|_| VerificationError::UnknownKey { - code: "key.unknown", - })?; - return PublicJwk::parse(&json).map_err(|_| VerificationError::UnknownKey { - code: "key.unknown", - }); - } - } - Err(VerificationError::UnknownKey { - code: "key.unknown", - }) -} - -fn jwk_alg(jwk: &PublicJwk) -> Option<&'static str> { - match jwk.algorithm().ok()? { - SigningAlgorithm::EdDsa => Some("EdDSA"), - SigningAlgorithm::Rs256 => Some("RS256"), - SigningAlgorithm::Es256 => Some("ES256"), - } -} - -fn decode_segment(segment: &str) -> Result { - let decoded = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| VerificationError::Malformed { - code: "token.malformed", - })?; - serde_json::from_slice(&decoded).map_err(|_| VerificationError::Malformed { - code: "token.malformed", - }) -} - -fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, VerificationError> { - value - .get(field) - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - }) -} - -fn required_i64(value: &Value, field: &str) -> Result { - value - .get(field) - .and_then(Value::as_i64) - .ok_or(VerificationError::TimeClaim { - code: "claim.time_invalid", - }) -} - -fn optional_i64(value: &Value, field: &str) -> Result, VerificationError> { - value - .get(field) - .map(|raw| { - raw.as_i64().ok_or(VerificationError::TimeClaim { - code: "claim.time_invalid", - }) - }) - .transpose() -} - -struct ParsedSdJwt<'a> { - header_b64: &'a str, - payload_b64: &'a str, - signature_b64: &'a str, - disclosures: Vec<&'a str>, - key_binding_jwt: Option<&'a str>, - sd_hash_input: &'a str, -} - -impl<'a> ParsedSdJwt<'a> { - fn parse(compact: &'a str) -> Result { - let mut parts = compact.split('~'); - let issuer_jwt = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let mut presentation_parts = parts.filter(|part| !part.is_empty()).collect::>(); - let key_binding_jwt = presentation_parts - .last() - .copied() - .filter(|part| is_compact_jws(part)); - if key_binding_jwt.is_some() { - presentation_parts.pop(); - } - if presentation_parts.iter().any(|part| is_compact_jws(part)) { - return Err(VerificationError::Malformed { - code: "token.malformed", - }); - } - let sd_hash_input = if let Some(key_binding_jwt) = key_binding_jwt { - compact - .strip_suffix(key_binding_jwt) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - })? - } else { - compact - }; - let mut jwt_parts = issuer_jwt.split('.'); - let header_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let payload_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let signature_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - if jwt_parts.next().is_some() - || header_b64.is_empty() - || payload_b64.is_empty() - || signature_b64.is_empty() - { - return Err(VerificationError::Malformed { - code: "token.malformed", - }); - } - Ok(Self { - header_b64, - payload_b64, - signature_b64, - disclosures: presentation_parts, - key_binding_jwt, - sd_hash_input, - }) - } - - fn signing_input(&self) -> String { - format!("{}.{}", self.header_b64, self.payload_b64) - } -} - -fn is_compact_jws(value: &str) -> bool { - let mut parts = value.split('.'); - let Some(header) = parts.next() else { - return false; - }; - let Some(payload) = parts.next() else { - return false; - }; - let Some(signature) = parts.next() else { - return false; - }; - parts.next().is_none() && !header.is_empty() && !payload.is_empty() && !signature.is_empty() -} diff --git a/crates/registry-notary-client/tests/client_contract.rs b/crates/registry-notary-client/tests/client_contract.rs deleted file mode 100644 index c98ce2790..000000000 --- a/crates/registry-notary-client/tests/client_contract.rs +++ /dev/null @@ -1,1491 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -use std::net::SocketAddr; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; -use std::time::{Duration, Instant}; - -use axum::body::Bytes; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, StatusCode, Uri}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use registry_notary_client::auth::{AuthHeader, AuthProvider}; -use registry_notary_client::{ - CredentialIssueResponse, NotaryClientBuildError, NotaryClientError, NotaryResponse, - RegistryNotaryClient, RequestOptions, RetryPolicy, -}; -use registry_notary_core::{ - BatchEvaluateResponse, BatchStatus, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, -}; -use secrecy::SecretString; -use serde_json::json; -use tokio::net::TcpListener; - -#[tokio::test] -async fn builder_rejects_multiple_auth_modes() { - let error = RegistryNotaryClient::builder("https://notary.example") - .bearer_token("bearer-secret") - .api_key("api-secret") - .build() - .expect_err("multiple auth modes are rejected"); - - assert!(matches!(error, NotaryClientBuildError::MultipleAuthModes)); -} - -#[tokio::test] -async fn builder_rejects_non_loopback_http() { - let error = RegistryNotaryClient::builder("http://example.com") - .bearer_token("bearer-secret") - .build() - .expect_err("non-loopback http is rejected"); - - assert!(matches!(error, NotaryClientBuildError::InsecureBaseUrl)); -} - -#[tokio::test] -async fn builder_preserves_encoded_base_path_when_adding_trailing_slash() { - let app = Router::new().fallback(get(base_path_health_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(format!("{base}/tenant%20one")) - .build() - .expect("client builds"); - - let response = client.health().await.expect("health response"); - - assert_eq!(response.body.status, "ok"); -} - -#[tokio::test] -async fn debug_redacts_auth_material() { - let builder = RegistryNotaryClient::builder("https://notary.example") - .bearer_token("super-secret-token") - .api_key("another-secret"); - - let rendered = format!("{builder:?}"); - assert!(!rendered.contains("super-secret-token")); - assert!(!rendered.contains("another-secret")); - assert!(rendered.contains("")); -} - -#[test] -fn credential_issue_response_debug_redacts_credential_material() { - let response = CredentialIssueResponse { - credential_id: "cred-1".to_string(), - credential_profile: "profile-1".to_string(), - format: "application/dc+sd-jwt".to_string(), - issuer: "did:web:notary.example".to_string(), - expires_at: "2026-05-29T00:00:00Z".to_string(), - credential: "issuer.jwt~disclosure-secret~".to_string(), - issuer_signed_jwt: "issuer.jwt".to_string(), - disclosures: vec!["disclosure-secret".to_string()], - }; - - let debug = format!("{response:?}"); - - assert!(debug.contains("cred-1")); - assert!(debug.contains("")); - assert!(!debug.contains("issuer.jwt")); - assert!(!debug.contains("disclosure-secret")); - assert!(!debug.contains("issuer.jwt~disclosure-secret~")); - - let wrapped = NotaryResponse { - body: response, - status: StatusCode::OK, - request_id: Some("req-credential".to_string()), - retry_after: None, - }; - let wrapped_debug = format!("{wrapped:?}"); - - assert!(wrapped_debug.contains("req-credential")); - assert!(wrapped_debug.contains("")); - assert!(!wrapped_debug.contains("issuer.jwt")); - assert!(!wrapped_debug.contains("disclosure-secret")); -} - -#[test] -fn serialization_build_error_has_specific_portable_code() { - let error = NotaryClientError::Build(NotaryClientBuildError::RequestSerialization); - - assert_eq!( - error.portable().code.as_deref(), - Some("request.serialization_failed") - ); -} - -#[test] -fn notary_response_debug_keeps_non_sensitive_body_metadata() { - let response = NotaryResponse { - body: registry_notary_client::HealthResponse { - status: "ok".to_string(), - checks: json!({ "database": "ready" }), - }, - status: StatusCode::OK, - request_id: Some("req-health".to_string()), - retry_after: None, - }; - - let debug = format!("{response:?}"); - - assert!(debug.contains("req-health")); - assert!(debug.contains("ok")); - assert!(debug.contains("database")); - assert!(!debug.contains("")); -} - -struct FixedAuthProvider; - -#[async_trait::async_trait] -impl AuthProvider for FixedAuthProvider { - async fn auth_header(&self) -> Result { - Ok(AuthHeader::ApiKey(SecretString::from( - "provider-secret".to_string(), - ))) - } -} - -#[tokio::test] -async fn auth_provider_sends_redacted_dynamic_header() { - let app = Router::new().route( - "/healthz", - get(|headers: HeaderMap| async move { - assert_eq!( - headers - .get("x-api-key") - .and_then(|value| value.to_str().ok()), - Some("provider-secret") - ); - Json(json!({ "status": "ok", "checks": {} })) - }), - ); - let base = spawn(app).await; - let provider: Arc = Arc::new(FixedAuthProvider); - let client = RegistryNotaryClient::builder(base) - .auth_provider(provider) - .build() - .expect("client builds"); - - let response = client.health().await.expect("health succeeds"); - - assert_eq!(response.body.status, "ok"); -} - -#[tokio::test] -async fn batch_response_family_deserializes_from_wire_json() { - let value = json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - }); - - let parsed: BatchEvaluateResponse = - serde_json::from_value(value).expect("batch response deserializes"); - assert_eq!(parsed.batch_id, "batch-1"); - assert!(matches!(parsed.status, BatchStatus::Completed)); -} - -#[tokio::test] -async fn evaluate_sends_safe_headers_and_parses_metadata() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .build() - .expect("client builds"); - - let response = client - .evaluate_target("Person") - .target_identifier("NATIONAL_ID", "subject-1") - .target_identifier_issuer("civil_registry") - .relationship("self") - .request_variable_date("as_of_date", "2026-01-01") - .claim("claim-a") - .request_id("req-123") - .send() - .await - .expect("evaluate succeeds"); - - assert_eq!(response.request_id.as_deref(), Some("req-123")); - assert!(response.body.results.is_empty()); -} - -#[tokio::test] -async fn ready_200_returns_typed_custody_checks() { - let app = Router::new().route( - "/ready", - get(|| async { - Json(json!({ - "status": "ready", - "checks": readiness_checks_json(true, 0), - })) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let response = client.ready().await.expect("ready response parses"); - - assert_eq!(response.body.status, "ready"); - let custody = response.body.checks.signing_providers.custody; - assert!(custody.custody_approval_required); - assert!(custody.custody_approved); - assert_eq!(custody.unapproved_signing_provider_count, 0); - assert_eq!(custody.active_provider_counts.get("pkcs11"), Some(&1)); -} - -#[tokio::test] -async fn ready_503_returns_problem_details() { - let app = Router::new().route( - "/ready", - get(|| async { - ( - StatusCode::SERVICE_UNAVAILABLE, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/readiness/not-ready", - "title": "Evidence runtime is not ready", - "status": 503, - "detail": "one or more readiness checks are not ready", - "code": "readiness.not_ready", - "request_id": "01J00000000000000000000000", - "readiness_status": "not_ready", - "checks": readiness_checks_json(false, 1) - })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.ready().await.expect_err("ready 503 is a problem"); - - match error { - NotaryClientError::Problem { - status, problem, .. - } => { - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(problem.code, "readiness.not_ready"); - assert_eq!( - problem.request_id.as_deref(), - Some("01J00000000000000000000000") - ); - assert_eq!(problem.readiness_status.as_deref(), Some("not_ready")); - let custody = &problem - .checks - .as_ref() - .expect("readiness checks are retained") - .signing_providers - .custody; - assert!(custody.custody_approval_required); - assert!(!custody.custody_approved); - assert_eq!(custody.unapproved_signing_provider_count, 1); - } - other => panic!("expected readiness problem, got {other:?}"), - } -} - -fn readiness_checks_json(custody_approved: bool, unapproved_count: usize) -> serde_json::Value { - json!({ - "total": 3, - "ok": if unapproved_count == 0 { 3 } else { 1 }, - "degraded": 0, - "failed": usize::from(unapproved_count > 0), - "signing_providers": { - "total": 1, - "ok": 1, - "failed": 0, - "custody": { - "active_provider_counts": { - "pkcs11": 1, - }, - "signing_provider_count": 1, - "local_software_signing_provider_count": 0, - "custody_approval_required": true, - "custody_approved": custody_approved, - "unapproved_signing_provider_count": unapproved_count, - "surfaces": { - "credential_issuance": { - "signing_provider_count": 1, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": unapproved_count, - }, - "access_token_issuance": { - "enabled": false, - "signing_provider_count": 0, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": 0, - }, - "federation": { - "enabled": false, - "signing_provider_count": 0, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": 0, - }, - }, - }, - }, - }) -} - -#[tokio::test] -async fn jwks_uses_ttl_cache_and_refresh_forces_reload() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/.well-known/evidence/jwks.json", get(jwks_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let first = client - .issuer_jwks(RequestOptions::default()) - .await - .expect("first fetch succeeds"); - let second = client - .issuer_jwks(RequestOptions::default()) - .await - .expect("second fetch uses cache"); - let refreshed = client - .refresh_jwks(RequestOptions::default()) - .await - .expect("refresh fetches network"); - - assert_eq!(first.body["keys"][0]["kid"], "kid-1"); - assert_eq!(second.body["keys"][0]["kid"], "kid-1"); - assert_eq!(refreshed.body["keys"][0]["kid"], "kid-2"); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn metrics_returns_text_body() { - let app = Router::new().route("/metrics", get(|| async { "requests_total 1\n" })); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let response = client - .metrics(RequestOptions::default()) - .await - .expect("metrics parses"); - - assert_eq!(response.body, "requests_total 1\n"); -} - -#[tokio::test] -async fn typed_route_methods_parse_success_responses_and_escape_paths() { - let app = Router::new() - .route("/healthz", get(health_handler)) - .route("/admin/v1/reload", post(admin_reload_handler)) - .route( - "/openapi.json", - get(|| async { Json(json!({ "openapi": "3.1.0" })) }), - ) - .route( - "/.well-known/evidence-service", - get(|| async { Json(json!({ "issuer": "notary.example" })) }), - ) - .route("/.well-known/evidence/jwks.json", get(jwks_static_handler)) - .route("/v1/claims/{claim_id}", get(claim_handler)) - .route( - "/v1/formats", - get(|| async { - Json(json!({ - "formats": [ - { "id": FORMAT_CLAIM_RESULT_JSON, "kind": "json", "status": "active" } - ] - })) - }), - ) - .route( - "/v1/evaluations/{evaluation_id}/render", - post(render_handler), - ) - .route("/v1/credentials", post(issue_credential_handler)) - .route( - "/v1/credentials/{credential_id}/status", - get(credential_status_handler), - ) - .route( - "/admin/v1/credentials/{credential_id}/status", - post(update_credential_status_handler), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .api_key("api-secret") - .build() - .expect("client builds"); - - assert_eq!(client.health().await.expect("health").body.status, "ok"); - assert_eq!( - client - .admin_reload(RequestOptions::default()) - .await - .expect("admin reload") - .body - .status, - "noop" - ); - assert_eq!( - client - .openapi_json(RequestOptions::default()) - .await - .expect("openapi") - .body["openapi"], - "3.1.0" - ); - assert_eq!( - client - .service_document(RequestOptions::default()) - .await - .expect("service document") - .body["issuer"], - "notary.example" - ); - assert_eq!( - client - .raw_issuer_jwks(RequestOptions::default()) - .await - .expect("raw jwks") - .body["keys"][0]["kid"], - "kid-static" - ); - assert_eq!( - client - .get_claim("claim one", RequestOptions::default()) - .await - .expect("claim") - .body["id"], - "claim one" - ); - assert_eq!( - client - .list_formats(RequestOptions::default()) - .await - .expect("formats") - .body - .formats[0] - .id, - FORMAT_CLAIM_RESULT_JSON - ); - assert_eq!( - client - .render_request( - registry_notary_core::RenderRequest { - evaluation_id: "eval-1".to_string(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - disclosure: None, - claims: None, - purpose: None, - }, - RequestOptions::default(), - ) - .await - .expect("render") - .body["rendered"], - true - ); - assert_eq!( - client - .issue_credential_request( - registry_notary_core::CredentialIssueRequest { - evaluation_id: "eval-1".to_string(), - credential_profile: None, - format: None, - claims: None, - disclosure: None, - purpose: None, - holder: None, - }, - RequestOptions::default(), - ) - .await - .expect("credential issue") - .body - .credential_id, - "cred-1" - ); - assert_eq!( - client - .credential_status("cred 1", RequestOptions::default()) - .await - .expect("credential status") - .body - .status, - "valid" - ); - assert_eq!( - client - .update_credential_status("cred 1", "revoked", RequestOptions::default()) - .await - .expect("credential status update") - .body - .status, - "revoked" - ); -} - -#[tokio::test] -async fn purpose_conflict_fails_client_side() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("header-purpose") - .build() - .expect("client builds"); - - let error = client - .evaluate_request( - registry_notary_core::EvaluateRequest { - requester: None, - target: Some(registry_notary_core::EvidenceEntity::from_subject_request( - "Person", - registry_notary_core::SubjectRequest { - id: "subject-1".to_string(), - id_type: None, - }, - )), - relationship: None, - on_behalf_of: None, - variables: Default::default(), - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect_err("purpose conflict fails before request"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::PurposeConflict) - )); -} - -#[tokio::test] -async fn raw_evaluate_preserves_body_only_purpose() { - let app = Router::new().route("/v1/evaluations", post(body_purpose_evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .evaluate_request( - registry_notary_core::EvaluateRequest { - requester: None, - target: Some(registry_notary_core::EvidenceEntity::from_subject_request( - "Person", - registry_notary_core::SubjectRequest { - id: "subject-1".to_string(), - id_type: None, - }, - )), - relationship: None, - on_behalf_of: None, - variables: Default::default(), - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect("evaluate succeeds"); - - assert!(response.body.results.is_empty()); -} - -#[tokio::test] -async fn raw_batch_preserves_body_only_purpose() { - let app = Router::new().route("/v1/batch-evaluations", post(body_purpose_batch_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .batch_evaluate_request( - registry_notary_core::BatchEvaluateRequest { - items: vec![registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - )], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect("batch evaluate succeeds"); - - assert_eq!(response.body.batch_id, "batch-1"); -} - -#[tokio::test] -async fn typed_batch_rejects_platform_ceiling_plus_one_before_transport() { - let calls = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route( - "/v1/batch-evaluations", - post(|State(calls): State>| async move { - calls.fetch_add(1, Ordering::SeqCst); - body_purpose_batch_handler( - HeaderMap::new(), - Bytes::from_static(br#"{"purpose":"body-purpose"}"#), - ) - .await - }), - ) - .with_state(Arc::clone(&calls)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - let item = registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - ); - let error = client - .batch_evaluate_request( - registry_notary_core::BatchEvaluateRequest { - items: vec![item; MAX_BATCH_EVALUATION_MEMBERS_V1 + 1], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect_err("the typed client rejects the hard ceiling plus one"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::BatchTooLarge { - actual, - maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, - }) if actual == MAX_BATCH_EVALUATION_MEMBERS_V1 + 1 - )); - assert_eq!(calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn raw_credential_issue_preserves_body_only_purpose() { - let app = Router::new().route( - "/v1/credentials", - post(body_purpose_issue_credential_handler), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .issue_credential_request( - registry_notary_core::CredentialIssueRequest { - evaluation_id: "eval-1".to_string(), - credential_profile: None, - format: None, - claims: None, - disclosure: None, - purpose: Some("body-purpose".to_string()), - holder: None, - }, - RequestOptions::default(), - ) - .await - .expect("credential issue succeeds"); - - assert_eq!(response.body.credential_id, "cred-1"); -} - -#[tokio::test] -async fn idempotency_is_rejected_on_routes_that_ignore_it() { - let app = Router::new().route( - "/v1/evaluations/{evaluation_id}/render", - post(|| async { Json(json!({})) }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .render_request( - registry_notary_core::RenderRequest { - evaluation_id: "eval-1".to_string(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - disclosure: None, - claims: None, - purpose: None, - }, - RequestOptions::builder() - .idempotency_key("ignored-key") - .build(), - ) - .await - .expect_err("unsupported idempotency is rejected"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::UnsupportedIdempotencyKey) - )); -} - -#[tokio::test] -async fn batch_retry_requires_idempotency_key() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/batch-evaluations", post(flaky_batch_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let request = registry_notary_core::BatchEvaluateRequest { - items: vec![registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - )], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: None, - }; - - let without_key = client - .batch_evaluate_request(request.clone(), RequestOptions::default()) - .await - .expect_err("without key no retry occurs"); - assert!(matches!(without_key, NotaryClientError::Problem { .. })); - assert_eq!(state.load(Ordering::SeqCst), 1); - state.store(0, Ordering::SeqCst); - - let with_key = client - .batch_evaluate_request( - request, - RequestOptions::builder() - .idempotency_key("batch-key") - .build(), - ) - .await - .expect("with idempotency key retry succeeds"); - assert_eq!(with_key.body.batch_id, "batch-1"); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn retry_after_delta_on_problem_controls_retry_delay() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/claims", get(retry_after_then_claims_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - base_delay: Duration::from_secs(5), - max_delay: Duration::from_secs(5), - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let started = Instant::now(); - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("retry-after zero allows immediate retry"); - - assert!(started.elapsed() < Duration::from_millis(500)); - assert!(response.body.data.is_empty()); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn retry_after_http_date_uses_server_date_for_retry_delay() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/claims", get(retry_after_http_date_then_claims_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - base_delay: Duration::from_secs(5), - max_delay: Duration::from_secs(5), - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let started = Instant::now(); - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("retry-after HTTP-date equal to server date allows immediate retry"); - - assert!(started.elapsed() < Duration::from_millis(500)); - assert!(response.body.data.is_empty()); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn accepted_status_decode_error_keeps_response_status() { - let app = Router::new().route( - "/ready", - get(|| async { - ( - StatusCode::SERVICE_UNAVAILABLE, - [("x-request-id", "req-ready-decode")], - "not-json", - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.ready().await.expect_err("invalid ready JSON fails"); - - match error { - NotaryClientError::Decode { status, request_id } => { - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(request_id.as_deref(), Some("req-ready-decode")); - } - other => panic!("expected decode error, got {other:?}"), - } -} - -#[tokio::test] -async fn decode_error_display_is_opaque() { - let app = Router::new().route( - "/v1/claims", - get(|| async { - ( - StatusCode::OK, - [("x-request-id", "req-decode")], - "not-json-with-secret-token", - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .list_claims(RequestOptions::default()) - .await - .expect_err("invalid JSON fails"); - assert_eq!(error.to_string(), "failed to decode response body"); - assert!(!format!("{error:?}").contains("not-json-with-secret-token")); - assert_eq!(error.request_id(), Some("req-decode")); -} - -#[tokio::test] -async fn problem_debug_redacts_detail() { - let app = Router::new().route("/v1/claims", get(problem_with_sensitive_detail)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .list_claims(RequestOptions::default()) - .await - .expect_err("problem maps"); - assert!(!format!("{error:?}").contains("subj-sensitive")); - assert!(!error.to_string().contains("subj-sensitive")); -} - -#[tokio::test] -async fn body_too_large_error_is_opaque_and_carries_request_id() { - let body = format!("credential-secret-{}", "x".repeat(80 * 1024)); - let app = Router::new().route( - "/healthz", - get(move || { - let body = body.clone(); - async move { (StatusCode::OK, [("x-request-id", "req-large")], body) } - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.health().await.expect_err("body cap triggers"); - assert!(matches!(error, NotaryClientError::BodyTooLarge { .. })); - assert_eq!(error.request_id(), Some("req-large")); - assert_eq!( - error.to_string(), - "response body exceeded configured size limit" - ); - assert!(!format!("{error:?}").contains("credential-secret")); -} - -#[tokio::test] -async fn content_encoding_header_is_not_auto_decompressed() { - let app = Router::new().route( - "/v1/claims", - get(|| async { - ( - StatusCode::OK, - [("content-encoding", "gzip")], - Json(json!({ "data": [] })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("plain body is not decompressed despite header"); - assert!(response.body.data.is_empty()); -} - -#[cfg(feature = "federation")] -#[tokio::test] -async fn federation_posts_already_signed_jws_without_minting() { - let app = Router::new().route("/federation/v1/evaluations", post(federation_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .federation_evaluate_jws("header.payload.signature", RequestOptions::default()) - .await - .expect("federation succeeds"); - - assert_eq!(response.body, "signed-response-jws"); -} - -#[cfg(feature = "oid4vci")] -#[tokio::test] -async fn oid4vci_errors_use_oid4vci_envelope() { - let app = Router::new().route( - "/oid4vci/credential", - post(|| async { - ( - StatusCode::BAD_REQUEST, - [("content-type", "application/json")], - Json(json!({ - "error": "invalid_request", - "error_description": "nonce request contained sensitive value subj-1" - })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client - .oid4vci_credential( - registry_platform_oid4vci::CredentialRequest { - format: registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: registry_platform_oid4vci::PROOF_TYPE_JWT.to_string(), - jwt: "proof-jwt".to_string(), - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }, - RequestOptions::default(), - ) - .await - .expect_err("oid4vci error maps"); - - assert_eq!(error.to_string(), "openid4vci error: invalid_request"); - assert!(!format!("{error:?}").contains("subj-1")); - - match error { - NotaryClientError::Oid4vci { error, .. } => { - assert_eq!(error.error, "invalid_request"); - let portable = NotaryClientError::Oid4vci { - status: StatusCode::BAD_REQUEST, - error, - request_id: None, - retry_after: None, - } - .portable(); - let rendered = serde_json::to_value(portable).expect("portable serializes"); - assert!(rendered.get("detail").is_none()); - assert!(!rendered.to_string().contains("subj-1")); - } - other => panic!("expected oid4vci error, got {other:?}"), - } -} - -#[cfg(feature = "oid4vci")] -#[tokio::test] -async fn oid4vci_success_routes_parse_typed_responses() { - let app = Router::new() - .route( - "/.well-known/openid-credential-issuer", - get(oid4vci_metadata_handler), - ) - .route("/oid4vci/credential", post(oid4vci_credential_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let metadata = client - .oid4vci_issuer_metadata(RequestOptions::default()) - .await - .expect("metadata"); - let credential = client - .oid4vci_credential( - registry_platform_oid4vci::CredentialRequest { - format: registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: registry_platform_oid4vci::PROOF_TYPE_JWT.to_string(), - jwt: "proof-jwt".to_string(), - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }, - RequestOptions::default(), - ) - .await - .expect("credential"); - - assert_eq!(metadata.body.credential_issuer, "https://issuer.example"); - assert_eq!( - credential.body.credential, - registry_platform_oid4vci::CredentialValue::from("sd-jwt-credential") - ); - - let metadata_debug = format!("{metadata:?}"); - assert!(metadata_debug.contains("https://issuer.example")); - - let credential_debug = format!("{credential:?}"); - assert!(credential_debug.contains("")); - assert!(!credential_debug.contains("sd-jwt-credential")); - assert!(!credential_debug.contains("proof-jwt")); -} - -#[cfg(all(feature = "oid4vci", feature = "verifier"))] -#[tokio::test] -async fn oid4vci_verifier_rejects_object_credential_shape_before_jwks_fetch() { - let client = RegistryNotaryClient::builder("http://127.0.0.1:9") - .build() - .expect("client builds"); - let response = registry_platform_oid4vci::CredentialResponse { - credential: registry_platform_oid4vci::CredentialValue::Object(json!({ - "credential": "not-a-compact-sd-jwt" - })), - credentials: Vec::new(), - format: Some(registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string()), - c_nonce: None, - c_nonce_expires_in: None, - }; - - let error = client - .verify_oid4vci_credential( - &response, - registry_notary_client::VerifyOptions::new("https://issuer.example"), - ) - .await - .expect_err("object credential shape is not a compact SD-JWT VC"); - - assert_eq!(error.code(), "credential.unsupported_shape"); -} - -async fn evaluate_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some(FORMAT_CLAIM_RESULT_JSON) - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("benefits") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - assert_eq!(parsed["target"]["type"], json!("Person")); - assert_eq!( - parsed["target"]["identifiers"], - json!([{ - "scheme": "NATIONAL_ID", - "value": "subject-1", - "issuer": "civil_registry" - }]) - ); - assert_eq!(parsed["relationship"]["type"], json!("self")); - assert_eq!(parsed["variables"]["as_of_date"], json!("2026-01-01")); - ( - StatusCode::OK, - [("x-request-id", "req-123")], - Json(json!({ "results": [] })), - ) - .into_response() -} - -async fn body_purpose_evaluate_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - Json(json!({ "results": [] })).into_response() -} - -async fn body_purpose_batch_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn body_purpose_issue_credential_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("issue body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - issue_credential_json() -} - -async fn health_handler() -> Response { - Json(json!({ "status": "ok", "checks": {} })).into_response() -} - -async fn base_path_health_handler(uri: Uri) -> Response { - assert_eq!(uri.path(), "/tenant%20one/healthz"); - health_handler().await -} - -async fn admin_reload_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("x-api-key") - .and_then(|value| value.to_str().ok()), - Some("api-secret") - ); - Json(json!({ "reloaded": false, "status": "noop", "detail": "unchanged" })).into_response() -} - -async fn jwks_static_handler() -> Response { - Json(json!({ "keys": [{ "kty": "OKP", "kid": "kid-static", "crv": "Ed25519", "x": "abc" }] })) - .into_response() -} - -async fn claim_handler(Path(claim_id): Path, uri: Uri) -> Response { - assert_eq!(uri.path(), "/v1/claims/claim%20one"); - Json(json!({ "id": claim_id, "title": "Claim One" })).into_response() -} - -async fn render_handler(Path(evaluation_id): Path, body: Bytes) -> Response { - assert_eq!(evaluation_id, "eval-1"); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("render body parses"); - assert!(parsed.get("evaluation_id").is_none()); - Json(json!({ "rendered": true })).into_response() -} - -async fn issue_credential_handler(body: Bytes) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("issue body parses"); - assert_eq!(parsed["evaluation_id"], "eval-1"); - issue_credential_json() -} - -fn issue_credential_json() -> Response { - Json(json!({ - "credential_id": "cred-1", - "credential_profile": "profile-1", - "format": "application/dc+sd-jwt", - "issuer": "did:web:notary.example", - "expires_at": "2026-05-29T00:00:00Z", - "credential": "issuer.jwt~disclosure~", - "issuer_signed_jwt": "issuer.jwt", - "disclosures": ["disclosure"] - })) - .into_response() -} - -async fn credential_status_handler(Path(credential_id): Path, uri: Uri) -> Response { - assert_eq!(uri.path(), "/v1/credentials/cred%201/status"); - Json(credential_status_json(&credential_id, "valid")).into_response() -} - -async fn update_credential_status_handler( - Path(credential_id): Path, - body: Bytes, -) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("status body parses"); - assert_eq!(parsed["status"], "revoked"); - Json(credential_status_json(&credential_id, "revoked")).into_response() -} - -fn credential_status_json(credential_id: &str, status: &str) -> serde_json::Value { - json!({ - "credential_id": credential_id, - "issuer": "did:web:notary.example", - "credential_profile": "profile-1", - "status": status, - "issued_at": "2026-05-29T00:00:00Z", - "expires_at": "2026-05-30T00:00:00Z", - "updated_at": "2026-05-29T01:00:00Z" - }) -} - -async fn flaky_batch_handler( - State(counter): State>, - headers: HeaderMap, -) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return problem(StatusCode::SERVICE_UNAVAILABLE); - } - if headers - .get("idempotency-key") - .and_then(|value| value.to_str().ok()) - != Some("batch-key") - { - return problem(StatusCode::SERVICE_UNAVAILABLE); - } - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn retry_after_then_claims_handler(State(counter): State>) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return ( - StatusCode::SERVICE_UNAVAILABLE, - [("retry-after", "0")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": 503, - "detail": "evidence not available", - "code": "evidence.not_available" - })), - ) - .into_response(); - } - Json(json!({ "data": [] })).into_response() -} - -async fn retry_after_http_date_then_claims_handler( - State(counter): State>, -) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return ( - StatusCode::SERVICE_UNAVAILABLE, - [ - ("retry-after", "Wed, 31 Dec 2099 00:00:00 GMT"), - ("date", "Wed, 31 Dec 2099 00:00:00 GMT"), - ], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": 503, - "detail": "evidence not available", - "code": "evidence.not_available" - })), - ) - .into_response(); - } - Json(json!({ "data": [] })).into_response() -} - -async fn jwks_handler(State(counter): State>) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - Json(json!({ - "keys": [ - { "kty": "OKP", "kid": format!("kid-{call}"), "crv": "Ed25519", "x": "abc" } - ] - })) - .into_response() -} - -#[cfg(feature = "federation")] -async fn federation_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("content-type") - .and_then(|value| value.to_str().ok()), - Some("application/jwt") - ); - assert_eq!(body.as_ref(), b"header.payload.signature"); - "signed-response-jws".into_response() -} - -#[cfg(feature = "oid4vci")] -async fn oid4vci_metadata_handler() -> Response { - Json(json!({ - "credential_issuer": "https://issuer.example", - "credential_endpoint": "https://issuer.example/oid4vci/credential", - "credential_configurations_supported": {} - })) - .into_response() -} - -#[cfg(feature = "oid4vci")] -async fn oid4vci_credential_handler(body: Bytes) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("credential body parses"); - assert_eq!(parsed["proof"]["jwt"], "proof-jwt"); - Json(json!({ - "credential": "sd-jwt-credential", - "format": "dc+sd-jwt" - })) - .into_response() -} - -fn problem(status: StatusCode) -> Response { - ( - status, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": status.as_u16(), - "detail": "the requested evidence is unavailable", - "code": "evidence.not_available" - })), - ) - .into_response() -} - -async fn problem_with_sensitive_detail() -> Response { - ( - StatusCode::NOT_FOUND, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/not-found", - "title": "Source missing", - "status": 404, - "detail": "subject subj-sensitive was not found", - "code": "source.not_found" - })), - ) - .into_response() -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test server binds"); - let addr: SocketAddr = listener.local_addr().expect("local addr available"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server serves"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-client/tests/facade_contract.rs b/crates/registry-notary-client/tests/facade_contract.rs deleted file mode 100644 index 18a75a6af..000000000 --- a/crates/registry-notary-client/tests/facade_contract.rs +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "json-facade")] - -use std::net::SocketAddr; - -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use registry_notary_client::facade::NotaryClientHandle; -use registry_notary_client::{PortableErrorKind, RegistryNotaryClient}; -use registry_notary_core::FORMAT_CLAIM_RESULT_JSON; -use serde_json::json; -use tokio::net::TcpListener; - -#[tokio::test] -async fn facade_accepts_canonical_snake_case_json() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let response = handle - .evaluate_json( - json!({ - "target": { - "type": "Person", - "identifiers": [{ "scheme": "NATIONAL_ID", "value": "subject-1" }] - }, - "claims": ["claim-a"] - }), - json!({}), - ) - .await - .expect("facade evaluate succeeds"); - - assert_eq!(response, json!({ "results": [] })); -} - -#[tokio::test] -async fn facade_core_methods_share_typed_validation_and_wire_shape() { - let app = Router::new() - .route("/v1/batch-evaluations", post(batch_handler)) - .route( - "/v1/evaluations/{evaluation_id}/render", - post(render_handler), - ) - .route("/v1/credentials", post(issue_handler)) - .route( - "/v1/claims", - get(|| async { Json(json!({ "data": [{ "id": "claim-a" }] })) }), - ) - .route( - "/v1/claims/claim-a", - get(|| async { Json(json!({ "id": "claim-a" })) }), - ) - .route( - "/v1/credentials/cred-1/status", - get(|| async { Json(credential_status("valid")) }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .default_purpose("benefits") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let batch = handle - .batch_evaluate_json( - json!({ - "items": [{ - "target": { - "type": "Person", - "identifiers": [{ "scheme": "NATIONAL_ID", "value": "subject-1" }] - } - }], - "claims": ["claim-a"] - }), - json!({ "idempotency_key": "batch-key" }), - ) - .await - .expect("facade batch succeeds"); - let rendered = handle - .render_json( - json!({ "evaluation_id": "eval-1", "format": FORMAT_CLAIM_RESULT_JSON }), - json!({}), - ) - .await - .expect("facade render succeeds"); - let issued = handle - .issue_credential_json(json!({ "evaluation_id": "eval-1" }), json!({})) - .await - .expect("facade issue succeeds"); - let claims = handle - .list_claims_json(json!({})) - .await - .expect("facade list claims succeeds"); - let claim = handle - .get_claim_json("claim-a".to_string(), json!({})) - .await - .expect("facade get claim succeeds"); - let status = handle - .credential_status_json("cred-1".to_string(), json!({})) - .await - .expect("facade credential status succeeds"); - - assert_eq!(batch["batch_id"], "batch-1"); - assert_eq!(rendered["rendered"], true); - assert_eq!(issued["credential_id"], "cred-1"); - assert_eq!(claims["data"][0]["id"], "claim-a"); - assert_eq!(claim["id"], "claim-a"); - assert_eq!(status["status"], "valid"); -} - -#[tokio::test] -async fn facade_error_excludes_detail() { - let app = Router::new().route("/v1/claims", get(problem_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let error = handle - .list_claims_json(json!({})) - .await - .expect_err("problem maps to portable error"); - - assert_eq!(error.kind, PortableErrorKind::Problem); - assert_eq!(error.status, Some(404)); - assert_eq!(error.code.as_deref(), Some("target.not_found")); - assert_eq!(error.title, "Target not found"); - let rendered = serde_json::to_value(&error).expect("portable error serializes"); - assert!(rendered.get("detail").is_none()); -} - -async fn evaluate_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("benefits") - ); - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some(FORMAT_CLAIM_RESULT_JSON) - ); - Json(json!({ "results": [] })).into_response() -} - -async fn batch_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("idempotency-key") - .and_then(|value| value.to_str().ok()), - Some("batch-key") - ); - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn render_handler() -> Response { - Json(json!({ "rendered": true })).into_response() -} - -async fn issue_handler() -> Response { - Json(json!({ - "credential_id": "cred-1", - "credential_profile": "profile-1", - "format": "application/dc+sd-jwt", - "issuer": "did:web:notary.example", - "expires_at": "2026-05-29T00:00:00Z", - "credential": "issuer.jwt~disclosure~", - "issuer_signed_jwt": "issuer.jwt", - "disclosures": ["disclosure"] - })) - .into_response() -} - -fn credential_status(status: &str) -> serde_json::Value { - json!({ - "credential_id": "cred-1", - "issuer": "did:web:notary.example", - "credential_profile": "profile-1", - "status": status, - "issued_at": "2026-05-29T00:00:00Z", - "expires_at": "2026-05-30T00:00:00Z", - "updated_at": "2026-05-29T01:00:00Z" - }) -} - -async fn problem_handler() -> Response { - ( - StatusCode::NOT_FOUND, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/target/not-found", - "title": "Target not found", - "status": 404, - "detail": "target identifier subj-sensitive was not found", - "code": "target.not_found" - })), - ) - .into_response() -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test server binds"); - let addr: SocketAddr = listener.local_addr().expect("local addr available"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server serves"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-client/tests/status_verifier_contract.rs b/crates/registry-notary-client/tests/status_verifier_contract.rs deleted file mode 100644 index 83c53e94a..000000000 --- a/crates/registry-notary-client/tests/status_verifier_contract.rs +++ /dev/null @@ -1,527 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(all(feature = "verifier", feature = "test-support"))] - -use std::collections::BTreeMap; -use std::io::Write; -use std::sync::{Arc, Mutex}; - -use axum::body::Body; -use axum::extract::State; -use axum::http::{header, HeaderValue, Response, StatusCode}; -use axum::routing::get; -use axum::{Json, Router}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use flate2::write::ZlibEncoder; -use flate2::Compression; -use registry_notary_client::verifier; -use registry_notary_client::{ - RegistryNotaryClient, StatusListPolicy, VerificationError, VerifyOptions, -}; -use registry_platform_crypto::PrivateJwk; -use registry_platform_sdjwt::{Disclosure, SdJwtIssuanceInput, SdJwtIssuer}; -use serde_json::{json, Value}; -use time::OffsetDateTime; -use tokio::net::TcpListener; - -const ISSUER: &str = "did:web:issuer.test"; -const VCT: &str = "https://vct.example/test"; -const NOW: i64 = 1_700_000_010; -const ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:issuer.test#key-1"}"#; - -#[derive(Clone)] -struct HarnessState { - jwks: Value, - status: Arc>, -} - -#[derive(Clone)] -struct StatusHttpResponse { - status: StatusCode, - content_type: Option<&'static str>, - content_encoding: Option<&'static str>, - location: Option<&'static str>, - body: String, -} - -impl Default for StatusHttpResponse { - fn default() -> Self { - Self { - status: StatusCode::SERVICE_UNAVAILABLE, - content_type: Some("application/statuslist+jwt"), - content_encoding: None, - location: None, - body: String::new(), - } - } -} - -struct StatusHarness { - base_url: String, - status_uri: String, - state: HarnessState, - client: RegistryNotaryClient, -} - -impl StatusHarness { - async fn start() -> Self { - let state = HarnessState { - jwks: jwks(), - status: Arc::new(Mutex::new(StatusHttpResponse::default())), - }; - let app = Router::new() - .route("/.well-known/evidence/jwks.json", get(jwks_handler)) - .route("/status", get(status_handler)) - .with_state(state.clone()); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test listener binds"); - let address = listener.local_addr().expect("test listener has address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server remains available"); - }); - let base_url = format!("http://{address}"); - let status_uri = format!("{base_url}/status"); - let client = RegistryNotaryClient::builder(&base_url) - .build() - .expect("test client builds"); - Self { - base_url, - status_uri, - state, - client, - } - } - - fn options(&self) -> VerifyOptions { - VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::loopback_for_testing(ISSUER, &self.base_url) - .expect("loopback test policy is valid"), - ) - } - - async fn credential(&self, status_uri: &str, index: u64) -> String { - issuer() - .issue(SdJwtIssuanceInput { - iss: ISSUER.to_string(), - sub_ref: "subject-ref".to_string(), - credential_id: Some("urn:ulid:01HG0000000000000000000000".to_string()), - iat: NOW, - exp: NOW + 600, - vct: VCT.to_string(), - status: Some(json!({ - "status_list": { - "idx": index, - "uri": status_uri, - } - })), - public_claims: BTreeMap::new(), - cnf: None, - disclosures: vec![Disclosure { - name: "claim-a".to_string(), - value: json!({"satisfied": true}), - }], - }) - .await - .expect("status-bearing credential issues") - .jwt - } - - async fn signed_status(&self, payload: Value) -> String { - issuer() - .sign_compact_jwt("statuslist+jwt", payload) - .await - .expect("status token signs") - } - - fn valid_payload(&self, encoded_list: &str) -> Value { - json!({ - "iss": ISSUER, - "sub": self.status_uri, - "aud": self.status_uri, - "iat": NOW, - "exp": NOW + 100, - "ttl": 100, - "status_list": { - "bits": 8, - "lst": encoded_list, - } - }) - } - - fn respond(&self, response: StatusHttpResponse) { - *self.state.status.lock().expect("status response lock") = response; - } - - async fn respond_with_token(&self, token: String) { - self.respond(StatusHttpResponse { - status: StatusCode::OK, - body: token, - ..StatusHttpResponse::default() - }); - } -} - -#[tokio::test] -async fn async_verifier_accepts_signed_valid_status() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - harness.respond_with_token(token).await; - - let verified = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect("valid status-bearing credential verifies"); - - assert_eq!(verified.issuer, ISSUER); -} - -#[tokio::test] -async fn synchronous_verifier_never_skips_status() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - - let missing_policy = verifier::verify_sd_jwt_vc( - &credential, - &jwks(), - &VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")), - ) - .expect_err("status cannot be skipped without policy"); - assert_code(missing_policy, "status.policy_required"); - - let requires_fetch = verifier::verify_sd_jwt_vc(&credential, &jwks(), &harness.options()) - .expect_err("synchronous verification cannot skip the fetch"); - assert_code(requires_fetch, "status.fetch_required"); -} - -#[tokio::test] -async fn revoked_suspended_and_unknown_status_fail_closed() { - for (encoded, expected_code) in [ - ("eJxjBAAAAgAC", "status.revoked"), - ("eJxjAgAAAwAD", "status.suspended"), - (&encoded_status_list(&[3]), "status.unknown"), - ] { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness.signed_status(harness.valid_payload(encoded)).await; - harness.respond_with_token(token).await; - - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("non-valid status is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn invalid_status_claims_signature_index_and_compression_fail_closed() { - let harness = StatusHarness::start().await; - let mut cases = Vec::new(); - - let mut wrong_issuer = harness.valid_payload("eJxjAAAAAQAB"); - wrong_issuer["iss"] = json!("did:web:other.example"); - cases.push((wrong_issuer, 0, "status.claim.issuer_mismatch")); - - let mut wrong_uri = harness.valid_payload("eJxjAAAAAQAB"); - wrong_uri["sub"] = json!(format!("{}/other", harness.base_url)); - cases.push((wrong_uri, 0, "status.claim.uri_mismatch")); - - let mut wrong_audience = harness.valid_payload("eJxjAAAAAQAB"); - wrong_audience["aud"] = json!("https://verifier.example"); - cases.push((wrong_audience, 0, "status.claim.audience_mismatch")); - - let mut stale = harness.valid_payload("eJxjAAAAAQAB"); - stale["iat"] = json!(NOW - 400); - stale["exp"] = json!(NOW - 300); - cases.push((stale, 0, "status.claim.time_invalid")); - - let mut excessive_lifetime = harness.valid_payload("eJxjAAAAAQAB"); - excessive_lifetime["exp"] = json!(NOW + 301); - excessive_lifetime["ttl"] = json!(301); - cases.push((excessive_lifetime, 0, "status.claim.time_invalid")); - - cases.push(( - harness.valid_payload("eJxjAAAAAQAB"), - 1, - "status.index.invalid", - )); - - let decompression_bomb = encoded_status_list(&vec![0; 128 * 1024 + 1]); - cases.push(( - harness.valid_payload(&decompression_bomb), - 0, - "status.list.decompression_limit", - )); - - for (payload, index, expected_code) in cases { - let credential = harness.credential(&harness.status_uri, index).await; - let token = harness.signed_status(payload).await; - harness.respond_with_token(token).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("invalid status material is rejected"); - assert_code(error, expected_code); - } - - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - harness.respond_with_token(tamper_signature(&token)).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("invalid status signature is rejected"); - assert_code(error, "status.signature.invalid"); - - let valid_token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - for (token, expected_code) in [ - ( - rewrite_status_header(&valid_token, |header| { - header["kid"] = json!("did:web:issuer.test#unknown") - }), - "status.key.unknown", - ), - ( - rewrite_status_header(&valid_token, |header| header["alg"] = json!("RS256")), - "status.algorithm.disallowed", - ), - ( - rewrite_status_header(&valid_token, |header| { - header["jku"] = json!("https://attacker.example/jwks.json") - }), - "status.header.untrusted_key_reference", - ), - ] { - harness.respond_with_token(token).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("untrusted status signing metadata is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn status_transport_rejects_redirect_media_type_encoding_and_size() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - - let cases = [ - ( - StatusHttpResponse { - status: StatusCode::SERVICE_UNAVAILABLE, - ..StatusHttpResponse::default() - }, - "status.http_status_invalid", - ), - ( - StatusHttpResponse { - status: StatusCode::FOUND, - location: Some("https://other.example/status"), - ..StatusHttpResponse::default() - }, - "status.redirect_denied", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - content_type: Some("application/json"), - body: "not-a-token".to_string(), - ..StatusHttpResponse::default() - }, - "status.media_type_invalid", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - content_encoding: Some("gzip"), - body: "not-a-token".to_string(), - ..StatusHttpResponse::default() - }, - "status.content_encoding_denied", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - body: "not-a-compact-jwt".to_string(), - ..StatusHttpResponse::default() - }, - "status.token_malformed", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - body: "x".repeat(256 * 1024 + 1), - ..StatusHttpResponse::default() - }, - "status.response_too_large", - ), - ]; - - for (response, expected_code) in cases { - harness.respond(response); - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("unsafe status response is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn status_origin_and_destination_must_be_explicit_and_safe() { - let harness = StatusHarness::start().await; - let untrusted_uri = "https://status.other.example/status"; - let credential = harness.credential(untrusted_uri, 0).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("unlisted status origin is rejected before fetch"); - assert_code(error, "status.origin_untrusted"); - - let unsafe_uri = "https://127.0.0.1/status"; - let credential = harness.credential(unsafe_uri, 0).await; - let options = VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::new(ISSUER, "https://127.0.0.1") - .expect("structurally valid HTTPS origin"), - ); - let error = harness - .client - .verify_sd_jwt_vc(&credential, options) - .await - .expect_err("private destination is rejected"); - assert_code(error, "status.destination_unsafe"); - - let closed_listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("closed-port probe binds"); - let closed_address = closed_listener - .local_addr() - .expect("closed-port probe has address"); - drop(closed_listener); - let unreachable_origin = format!("http://{closed_address}"); - let unreachable_uri = format!("{unreachable_origin}/status"); - let credential = harness.credential(&unreachable_uri, 0).await; - let options = VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::loopback_for_testing(ISSUER, unreachable_origin) - .expect("loopback test policy is valid"), - ); - let error = harness - .client - .verify_sd_jwt_vc(&credential, options) - .await - .expect_err("unreachable status endpoint is rejected"); - assert_code(error, "status.unreachable"); -} - -#[test] -fn status_policy_requires_exact_https_origins() { - assert!(StatusListPolicy::new(ISSUER, "http://status.example").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://status.example/path").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://user@status.example").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://status.example") - .expect("primary origin is accepted") - .allow_origin("https://status-backup.example:8443") - .is_ok()); -} - -fn issuer() -> SdJwtIssuer { - SdJwtIssuer::from_jwk(PrivateJwk::parse(ISSUER_JWK).expect("issuer JWK parses")) - .expect("issuer builds") -} - -fn jwks() -> Value { - let public = PrivateJwk::parse(ISSUER_JWK) - .expect("issuer JWK parses") - .public(); - json!({"keys": [serde_json::to_value(public).expect("public JWK serializes")]}) -} - -fn encoded_status_list(bytes: &[u8]) -> String { - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(bytes).expect("status list compresses"); - URL_SAFE_NO_PAD.encode(encoder.finish().expect("status compression finishes")) -} - -fn rewrite_status_header(token: &str, mutate: impl FnOnce(&mut Value)) -> String { - let mut parts = token.split('.'); - let encoded_header = parts.next().expect("status token has header"); - let payload = parts.next().expect("status token has payload"); - let signature = parts.next().expect("status token has signature"); - assert!(parts.next().is_none()); - let mut header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(encoded_header) - .expect("status header decodes"), - ) - .expect("status header is JSON"); - mutate(&mut header); - let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); - format!("{header}.{payload}.{signature}") -} - -fn tamper_signature(token: &str) -> String { - let mut parts = token.split('.'); - let header = parts.next().expect("status token has header"); - let payload = parts.next().expect("status token has payload"); - let encoded_signature = parts.next().expect("status token has signature"); - assert!(parts.next().is_none()); - let mut signature = URL_SAFE_NO_PAD - .decode(encoded_signature) - .expect("status signature decodes"); - signature[0] ^= 1; - format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - -fn assert_code(error: VerificationError, expected: &str) { - assert_eq!(error.code(), expected, "unexpected verifier error: {error}"); -} - -async fn jwks_handler(State(state): State) -> Json { - Json(state.jwks) -} - -async fn status_handler(State(state): State) -> Response { - let response = state.status.lock().expect("status response lock").clone(); - let mut builder = Response::builder().status(response.status); - if let Some(content_type) = response.content_type { - builder = builder.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - } - if let Some(content_encoding) = response.content_encoding { - builder = builder.header( - header::CONTENT_ENCODING, - HeaderValue::from_static(content_encoding), - ); - } - if let Some(location) = response.location { - builder = builder.header(header::LOCATION, HeaderValue::from_static(location)); - } - builder - .body(Body::from(response.body)) - .expect("test response builds") -} diff --git a/crates/registry-notary-client/tests/verifier_contract.rs b/crates/registry-notary-client/tests/verifier_contract.rs deleted file mode 100644 index c9b5ce009..000000000 --- a/crates/registry-notary-client/tests/verifier_contract.rs +++ /dev/null @@ -1,638 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "verifier")] - -use std::collections::BTreeMap; -use std::net::SocketAddr; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; - -use axum::extract::State; -use axum::routing::get; -use axum::{Json, Router}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use registry_notary_client::verifier; -use registry_notary_client::{ - HolderBindingPolicy, RegistryNotaryClient, VerificationError, VerifyOptions, -}; -use registry_notary_core::SD_JWT_VC_JWT_TYP; -use registry_platform_crypto::{did_jwk_from_public_jwk, sign, PrivateJwk}; -use registry_platform_sdjwt::{Disclosure, HolderConfirmation, SdJwtIssuanceInput, SdJwtIssuer}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use tokio::net::TcpListener; - -const ISSUER: &str = "did:web:issuer.test"; -const VCT: &str = "https://vct.example/test"; -const NOW: i64 = 1_700_000_010; -const KB_AUD: &str = "https://verifier.example/callback"; -const KB_NONCE: &str = "nonce-1700000010"; -const ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:issuer.test#key-1"}"#; -const ISSUER_P256_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"did:web:issuer.test#p256-key-1"}"#; -const ROTATED_ISSUER_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"did:web:issuer.test#key-2"}"#; -const HOLDER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"holder-key-1"}"#; - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_valid_holder_bound_credential() { - let holder = holder_did(); - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder)).await; - let verified = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::RequiredKid(holder.clone())), - ) - .expect("credential verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.vct, VCT); - assert_eq!(verified.key_id, "did:web:issuer.test#key-1"); - assert_eq!(verified.algorithm, "EdDSA"); - assert_eq!(verified.disclosure_count, 1); - assert_eq!(verified.holder_key_id.as_deref(), Some(holder.as_str())); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_credential_without_disclosures() { - let compact = issue_plain_jwt_vc(ISSUER_JWK, ISSUER, NOW, NOW + 50); - - let verified = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect("credential without disclosures verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.disclosure_count, 0); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_es256_credential() { - let compact = issue_sd_jwt(ISSUER_P256_JWK, ISSUER, NOW, NOW + 50, None).await; - - let verified = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_P256_JWK), - &options().accepted_algorithms(["ES256"]), - ) - .expect("ES256 credential verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.vct, VCT); - assert_eq!(verified.key_id, "did:web:issuer.test#p256-key-1"); - assert_eq!(verified.algorithm, "ES256"); - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_selectively_disclosed_subset() { - let compact = issue_sd_jwt_with_claims( - ISSUER_JWK, - ISSUER, - NOW, - NOW + 50, - None, - &["claim-a", "claim-b"], - ) - .await; - let presentation = keep_first_disclosure(&compact); - - let verified = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect("selective disclosure verifies"); - - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_duplicate_presented_disclosure() { - let compact = issue_sd_jwt_with_claims( - ISSUER_JWK, - ISSUER, - NOW, - NOW + 50, - None, - &["claim-a", "claim-b"], - ) - .await; - let presentation = duplicate_first_disclosure(&compact); - - let error = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect_err("duplicate disclosure is rejected"); - - assert_code(error, "disclosure.digest_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_separates_key_binding_jwt_from_disclosures() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let verified = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect("key binding jwt is not treated as a disclosure"); - - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_key_binding_without_expected_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::Required), - ) - .expect_err("key binding challenge is required"); - - assert_code(error, "holder_binding.challenge_required"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_missing_key_binding_for_expected_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("missing key binding jwt is rejected for verifier challenge"); - - assert_code(error, "holder_binding.challenge_required"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_optional_key_binding_without_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let verified = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect("optional key binding does not require a verifier challenge"); - - assert_eq!( - verified.holder_key_id.as_deref(), - Some(holder_did().as_str()) - ); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_audience() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge("https://other-verifier.example/callback", KB_NONCE), - ) - .expect_err("key binding audience must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_nonce() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, "stale-nonce"), - ) - .expect_err("key binding nonce must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_sd_hash() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": "wrong" - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("key binding sd_hash must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_key_binding_sd_hash_without_trailing_separator() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest( - compact.strip_suffix('~').unwrap_or(&compact).as_bytes() - )), - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("key binding sd_hash must cover the trailing separator"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_expired_key_binding_jwt() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW - 300, - "exp": NOW - 200, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest( - compact.as_bytes() - )), - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("expired key binding jwt must be rejected"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_bad_key_binding_jwt() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", unsigned_compact_jws()); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("bad key binding jwt is rejected"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_bad_signature() { - let compact = tamper_signature(&issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("bad signature is rejected"); - - assert_code(error, "signature.invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_unknown_kid() { - let compact = rewrite_header( - &issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await, - |header| header["kid"] = json!("did:web:issuer.test#missing"), - ); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("unknown kid is rejected"); - - assert_code(error, "key.unknown"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_disallowed_algorithm() { - let compact = rewrite_header( - &issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await, - |header| header["alg"] = json!("RS256"), - ); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("disallowed alg is rejected"); - - assert_code(error, "algorithm.disallowed"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_issuer() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &VerifyOptions::new("did:web:other.example").now(now()), - ) - .expect_err("wrong issuer is rejected"); - - assert_code(error, "claim.issuer_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_expired_credential() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW - 300, NOW - 200, None).await; - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("expired credential is rejected"); - - assert_code(error, "claim.time_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_invalid_disclosure_digest() { - let compact = tamper_disclosure(&issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("invalid disclosure digest is rejected"); - - assert_code(error, "disclosure.digest_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_missing_required_holder_binding() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::Required), - ) - .expect_err("missing holder binding is rejected"); - - assert_code(error, "holder_binding.required"); -} - -#[tokio::test] -async fn verifier_errors_do_not_render_credential_fragments() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &VerifyOptions::new("did:web:other.example").now(now()), - ) - .expect_err("wrong issuer is rejected"); - let rendered = format!("{error:?} {error}"); - - assert!(rendered.contains("claim.issuer_mismatch")); - assert!(!rendered.contains(&compact)); - for fragment in compact.split(['.', '~']) { - if fragment.len() > 12 { - assert!( - !rendered.contains(fragment), - "error rendered compact credential fragment" - ); - } - } -} - -#[tokio::test] -async fn client_verifier_refreshes_once_on_unknown_kid() { - let counter = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route( - "/.well-known/evidence/jwks.json", - get(rotating_jwks_handler), - ) - .with_state(Arc::clone(&counter)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - let compact = issue_sd_jwt(ROTATED_ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - - let verified = client - .verify_sd_jwt_vc(&compact, options()) - .await - .expect("refresh finds rotated key"); - - assert_eq!(verified.key_id, "did:web:issuer.test#key-2"); - assert_eq!(counter.load(Ordering::SeqCst), 2); -} - -async fn issue_sd_jwt( - private_jwk: &str, - issuer_id: &str, - iat: i64, - exp: i64, - holder_id: Option<&str>, -) -> String { - issue_sd_jwt_with_claims(private_jwk, issuer_id, iat, exp, holder_id, &["claim-a"]).await -} - -async fn issue_sd_jwt_with_claims( - private_jwk: &str, - issuer_id: &str, - iat: i64, - exp: i64, - holder_id: Option<&str>, - claim_names: &[&str], -) -> String { - let sd_jwt_issuer = - SdJwtIssuer::from_jwk(PrivateJwk::parse(private_jwk).expect("issuer jwk parses")) - .expect("issuer builds"); - let holder_confirmation = holder_id.map(|kid| { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - HolderConfirmation { - jwk: holder.public(), - kid: Some(kid.to_string()), - } - }); - sd_jwt_issuer - .issue(SdJwtIssuanceInput { - iss: issuer_id.to_string(), - sub_ref: holder_id.unwrap_or("subject-ref").to_string(), - credential_id: Some("urn:ulid:01HG0000000000000000000000".to_string()), - iat, - exp, - vct: VCT.to_string(), - status: None, - public_claims: BTreeMap::new(), - cnf: holder_confirmation, - disclosures: claim_names - .iter() - .map(|claim_name| Disclosure { - name: (*claim_name).to_string(), - value: json!({"satisfied": true}), - }) - .collect(), - }) - .await - .expect("sd-jwt issues") - .jwt -} - -fn issue_plain_jwt_vc(private_jwk: &str, issuer_id: &str, iat: i64, exp: i64) -> String { - let issuer = PrivateJwk::parse(private_jwk).expect("issuer jwk parses"); - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ - "alg": "EdDSA", - "kid": "did:web:issuer.test#key-1", - "typ": SD_JWT_VC_JWT_TYP, - })) - .expect("header serializes"), - ); - let payload = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ - "iss": issuer_id, - "sub": "subject-ref", - "jti": "urn:ulid:01HG0000000000000000000000", - "iat": iat, - "exp": exp, - "vct": VCT, - })) - .expect("payload serializes"), - ); - let signing_input = format!("{header}.{payload}"); - let signature = sign(signing_input.as_bytes(), &issuer).expect("issuer signs"); - format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature)) -} - -fn options() -> VerifyOptions { - VerifyOptions::new(ISSUER).expected_vct(VCT).now(now()) -} - -fn now() -> time::OffsetDateTime { - time::OffsetDateTime::from_unix_timestamp(NOW).expect("test timestamp") -} - -fn jwks(private_jwk: &str) -> Value { - let public = PrivateJwk::parse(private_jwk).expect("jwk parses").public(); - json!({ "keys": [serde_json::to_value(public).expect("public jwk serializes")] }) -} - -fn holder_did() -> String { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - did_jwk_from_public_jwk(&holder.public()).expect("holder did encodes") -} - -fn assert_code(error: VerificationError, code: &str) { - assert_eq!(error.code(), code); -} - -fn tamper_signature(compact: &str) -> String { - let (jwt, suffix) = compact.split_once('~').expect("sd-jwt has disclosure"); - let mut parts = jwt.split('.').collect::>(); - let mut signature = URL_SAFE_NO_PAD - .decode(parts[2]) - .expect("signature base64 decodes"); - signature[0] ^= 0x01; - let signature = URL_SAFE_NO_PAD.encode(signature); - parts[2] = &signature; - format!("{}~{suffix}", parts.join(".")) -} - -fn tamper_disclosure(compact: &str) -> String { - let mut parts = compact.split('~').collect::>(); - let mut disclosure = parts[1].to_string(); - let replacement = if disclosure.ends_with('A') { "B" } else { "A" }; - disclosure.pop(); - disclosure.push_str(replacement); - parts[1] = &disclosure; - parts.join("~") -} - -fn keep_first_disclosure(compact: &str) -> String { - let mut parts = compact.split('~'); - let jwt = parts.next().expect("issuer jwt"); - let first_disclosure = parts.next().expect("first disclosure"); - format!("{jwt}~{first_disclosure}~") -} - -fn duplicate_first_disclosure(compact: &str) -> String { - let mut parts = compact.split('~'); - let jwt = parts.next().expect("issuer jwt"); - let first_disclosure = parts.next().expect("first disclosure"); - format!("{jwt}~{first_disclosure}~{first_disclosure}~") -} - -fn unsigned_compact_jws() -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"kb+jwt"}"#); - let payload = URL_SAFE_NO_PAD.encode(br#"{"iat":1700000010}"#); - format!("{header}.{payload}.signature") -} - -fn signed_key_binding_jwt(sd_jwt: &str) -> String { - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest(sd_jwt.as_bytes())), - })) -} - -fn signed_key_binding_jwt_with_payload(payload: Value) -> String { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"kb+jwt","kid":"holder-key-1"}"#); - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload serializes")); - let signing_input = format!("{header}.{payload}"); - let signature = sign(signing_input.as_bytes(), &holder).expect("holder proof signs"); - format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature)) -} - -fn rewrite_header(compact: &str, mutate: impl FnOnce(&mut Value)) -> String { - let (jwt, suffix) = compact.split_once('~').expect("sd-jwt has disclosure"); - let mut parts = jwt.split('.').collect::>(); - let mut header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(parts[0]) - .expect("header base64 decodes"), - ) - .expect("header json decodes"); - mutate(&mut header); - let header_b64 = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); - parts[0] = &header_b64; - format!("{}~{suffix}", parts.join(".")) -} - -async fn rotating_jwks_handler(State(counter): State>) -> Json { - let call = counter.fetch_add(1, Ordering::SeqCst); - if call == 0 { - Json(jwks(ISSUER_JWK)) - } else { - Json(jwks(ROTATED_ISSUER_JWK)) - } -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test server"); - let addr: SocketAddr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("test server runs"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-core/Cargo.toml b/crates/registry-notary-core/Cargo.toml deleted file mode 100644 index c7b9cac39..000000000 --- a/crates/registry-notary-core/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "registry-notary-core" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Core Registry Notary domain, configuration, and credential primitives." -readme = "README.md" -repository.workspace = true -publish = false - -[lints] -workspace = true - -[dependencies] -base64.workspace = true -humantime-serde.workspace = true -ipnet = { workspace = true, features = ["serde"] } -registry-platform-authcommon.workspace = true -registry-platform-config.workspace = true -registry-platform-crypto.workspace = true -registry-platform-httputil.workspace = true -registry-platform-oid4vci.workspace = true -registry-platform-ops.workspace = true -registry-platform-sdjwt.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -thiserror.workspace = true -time.workspace = true -ulid.workspace = true -url.workspace = true -utoipa.workspace = true - -[dev-dependencies] -serde_norway.workspace = true -tempfile.workspace = true -tokio.workspace = true diff --git a/crates/registry-notary-core/README.md b/crates/registry-notary-core/README.md deleted file mode 100644 index 541d25d8c..000000000 --- a/crates/registry-notary-core/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# registry-notary-core - -Portable Registry Notary domain model, configuration, and credential -primitives. - -This crate owns the serializable contracts shared by the server, binary, tests, -and downstream tooling. - -## What It Provides - -- Standalone Registry Notary configuration types and validation. -- Claim, requester/target evidence, source binding, disclosure, and evaluation - models. -- Static-peer federation config models, validation constants, and audit fields - for delegated evaluation. -- Error types used across the workspace. -- SD-JWT VC issuance helpers for claim views. -- OpenAPI-compatible schema derives for public contract types. - -## Typical Use - -```rust -use registry_notary_core::StandaloneRegistryNotaryConfig; - -fn load(raw_yaml: &str) -> Result> { - let config: StandaloneRegistryNotaryConfig = serde_norway::from_str(raw_yaml)?; - config.validate()?; - Ok(config) -} -``` - -## Boundary - -This crate is runtime-neutral. It should not own Axum routes, outbound HTTP -clients, tracing setup, process startup, or storage for evaluated evidence. - -## Testing - -```sh -cargo test -p registry-notary-core -``` - -## License - -Apache-2.0. diff --git a/crates/registry-notary-core/config/documentation-intent.json b/crates/registry-notary-core/config/documentation-intent.json deleted file mode 100644 index 0e2aba9c5..000000000 --- a/crates/registry-notary-core/config/documentation-intent.json +++ /dev/null @@ -1,7804 +0,0 @@ -{ - "$schema": "https://id.registrystack.org/schemas/registryctl/project-documentation/registry.runtime.configuration_intent.v1.schema.json", - "format_version": "1.0", - "runtime_schema": "notary", - "schema_id": "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json", - "schema_source": "registry-notary.config.schema.json", - "profiles": [ - { - "id": "notary_audit_internal", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_audit_secret_reference", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_audit_sensitive", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_auth_internal", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_auth_oidc_scope_map_open_map", - "purpose": "Each reviewed key is an external token scope and each value is the bounded Notary scope mapping granted for that exact token scope.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key is an external token scope and each value is the bounded Notary scope mapping granted for that exact token scope." - }, - { - "id": "notary_auth_secret_reference", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_auth_sensitive", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_cel_internal", - "purpose": "Controls bounded CEL evaluation limits used by reviewed Notary policy expressions.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_config_trust_internal", - "purpose": "Controls Notary verification of signed configuration bundles, trust anchors, and rollback protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_config_trust_sensitive", - "purpose": "Controls Notary verification of signed configuration bundles, trust anchors, and rollback protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_credential_status_sensitive", - "purpose": "Controls Notary credential-status publication and retention behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_deployment_internal", - "purpose": "Declares Notary deployment posture and reviewed waiver metadata used by operator checks.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_deployment_sensitive", - "purpose": "Declares Notary deployment posture and reviewed waiver metadata used by operator checks.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_inputs_open_map", - "purpose": "Each reviewed key names a consultation input and each value binds it to an approved claim or variable source.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a consultation input and each value binds it to an approved claim or variable source." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_open_map", - "purpose": "Each reviewed key names a Relay consultation and each value defines the bounded consultation contract used to support this evidence claim.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a Relay consultation and each value defines the bounded consultation contract used to support this evidence claim." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_output_fields_open_map", - "purpose": "Each reviewed key names a closed structured output field and each value defines its requiredness and bounded recursive schema.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.16.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a closed structured output field and each value defines its requiredness and bounded recursive schema." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_outputs_open_map", - "purpose": "Each reviewed key names a consultation output and each value defines its bounded evidence-result interpretation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a consultation output and each value defines its bounded evidence-result interpretation." - }, - { - "id": "notary_evidence_credential_profiles_open_map", - "purpose": "Each reviewed key names a credential profile and each value defines its exact claims, format, and issuance contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a credential profile and each value defines its exact claims, format, and issuance contract." - }, - { - "id": "notary_evidence_internal", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_evidence_secret_reference", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_evidence_sensitive", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_evidence_signing_keys_open_map", - "purpose": "Each reviewed key names a signing-key binding and each value references the operator-managed signing material and lifecycle metadata.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a signing-key binding and each value references the operator-managed signing material and lifecycle metadata." - }, - { - "id": "notary_evidence_variables_open_map", - "purpose": "Each reviewed key names a Notary evidence variable and each value defines its bounded source and evaluation contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a Notary evidence variable and each value defines its bounded source and evaluation contract." - }, - { - "id": "notary_federation_internal", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_federation_secret_reference", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_federation_sensitive", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_instance_internal", - "purpose": "Identifies the Notary instance and its deployment environment for operational correlation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_instance_sensitive", - "purpose": "Identifies the Notary instance and its deployment environment for operational correlation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_oid4vci_credential_configurations_open_map", - "purpose": "Each reviewed key names an OpenID4VCI credential configuration and each value defines the advertised and issued credential contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names an OpenID4VCI credential configuration and each value defines the advertised and issued credential contract." - }, - { - "id": "notary_oid4vci_internal", - "purpose": "Controls Notary OpenID4VCI issuer, client, grant, proof, nonce, and credential-configuration behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_oid4vci_sensitive", - "purpose": "Controls Notary OpenID4VCI issuer, client, grant, proof, nonce, and credential-configuration behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_root_structural", - "purpose": "Defines the complete Notary runtime configuration boundary consumed when a Notary instance starts.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_server_internal", - "purpose": "Controls Notary listeners, transport, timeouts, request limits, and administrative endpoints.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_server_sensitive", - "purpose": "Controls Notary listeners, transport, timeouts, request limits, and administrative endpoints.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_state_internal", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_state_secret_reference", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_state_sensitive", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_subject_access_sensitive", - "purpose": "Controls Notary subject and assisted-access policy, authorization details, and relationship proof behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - } - ], - "assignments": [ - { - "schema": "notary", - "pointer": "", - "key_path": "", - "path_kind": "root", - "profile": "notary_root_structural", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/audit", - "key_path": "audit", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/hash_secret_env", - "key_path": "audit.hash_secret_env", - "path_kind": "property", - "profile": "notary_audit_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/max_files", - "key_path": "audit.max_files", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/max_size_mb", - "key_path": "audit.max_size_mb", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/path", - "key_path": "audit.path", - "path_kind": "property", - "profile": "notary_audit_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/sink", - "key_path": "audit.sink", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/syslog_socket_path", - "key_path": "audit.syslog_socket_path", - "path_kind": "property", - "profile": "notary_audit_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/auth", - "key_path": "auth", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/access_token_signing", - "key_path": "auth.access_token_signing", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/access_token_ttl_seconds", - "key_path": "auth.access_token_signing.access_token_ttl_seconds", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/allowed_algorithms", - "key_path": "auth.access_token_signing.allowed_algorithms", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/allowed_algorithms/items", - "key_path": "auth.access_token_signing.allowed_algorithms[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/audiences", - "key_path": "auth.access_token_signing.audiences", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/audiences/items", - "key_path": "auth.access_token_signing.audiences[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/enabled", - "key_path": "auth.access_token_signing.enabled", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/issuer", - "key_path": "auth.access_token_signing.issuer", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/signing_key_id", - "key_path": "auth.access_token_signing.signing_key_id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/token_typ", - "key_path": "auth.access_token_signing.token_typ", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids", - "key_path": "auth.access_token_signing.verification_key_ids", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids/items", - "key_path": "auth.access_token_signing.verification_key_ids[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/api_keys", - "key_path": "auth.api_keys", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/api_keys/items", - "key_path": "auth.api_keys[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.api_keys[].authorization_details", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.api_keys[].authorization_details.access_mode", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.api_keys[].authorization_details.actions", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions/items", - "key_path": "auth.api_keys[].authorization_details.actions[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAssistedAccessContext/properties/channel", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context.channel", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.api_keys[].authorization_details.assurance_level", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.api_keys[].authorization_details.claims", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims/items", - "key_path": "auth.api_keys[].authorization_details.claims[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/id", - "key_path": "auth.api_keys[].authorization_details.claims[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/version", - "key_path": "auth.api_keys[].authorization_details.claims[].version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.api_keys[].authorization_details.consent_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.api_keys[].authorization_details.disclosure", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.api_keys[].authorization_details.format", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.api_keys[].authorization_details.jurisdiction", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.api_keys[].authorization_details.legal_basis_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.api_keys[].authorization_details.locations", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations/items", - "key_path": "auth.api_keys[].authorization_details.locations[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.api_keys[].authorization_details.purpose", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.api_keys[].authorization_details.relationship", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/proof_claim", - "key_path": "auth.api_keys[].authorization_details.relationship.proof_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/relationship_type", - "key_path": "auth.api_keys[].authorization_details.relationship.relationship_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/schema_version", - "key_path": "auth.api_keys[].authorization_details.schema_version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.api_keys[].authorization_details.subject", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/binding_claim", - "key_path": "auth.api_keys[].authorization_details.subject.binding_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/id_type", - "key_path": "auth.api_keys[].authorization_details.subject.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.api_keys[].authorization_details.target", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id", - "key_path": "auth.api_keys[].authorization_details.target.id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id_type", - "key_path": "auth.api_keys[].authorization_details.target.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/type", - "key_path": "auth.api_keys[].authorization_details.type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/fingerprint", - "key_path": "auth.api_keys[].fingerprint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.api_keys[].fingerprint.name", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.api_keys[].fingerprint.path", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/provider", - "key_path": "auth.api_keys[].fingerprint.provider", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/id", - "key_path": "auth.api_keys[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes", - "key_path": "auth.api_keys[].scopes", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes/items", - "key_path": "auth.api_keys[].scopes[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/bearer_tokens", - "key_path": "auth.bearer_tokens", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/bearer_tokens/items", - "key_path": "auth.bearer_tokens[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.bearer_tokens[].authorization_details", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.bearer_tokens[].authorization_details.access_mode", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.bearer_tokens[].authorization_details.actions", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions/items", - "key_path": "auth.bearer_tokens[].authorization_details.actions[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAssistedAccessContext/properties/channel", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context.channel", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.bearer_tokens[].authorization_details.assurance_level", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.bearer_tokens[].authorization_details.claims", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims/items", - "key_path": "auth.bearer_tokens[].authorization_details.claims[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/id", - "key_path": "auth.bearer_tokens[].authorization_details.claims[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/version", - "key_path": "auth.bearer_tokens[].authorization_details.claims[].version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.bearer_tokens[].authorization_details.consent_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.bearer_tokens[].authorization_details.disclosure", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.bearer_tokens[].authorization_details.format", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.bearer_tokens[].authorization_details.jurisdiction", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.bearer_tokens[].authorization_details.legal_basis_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.bearer_tokens[].authorization_details.locations", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations/items", - "key_path": "auth.bearer_tokens[].authorization_details.locations[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.bearer_tokens[].authorization_details.purpose", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.bearer_tokens[].authorization_details.relationship", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/proof_claim", - "key_path": "auth.bearer_tokens[].authorization_details.relationship.proof_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/relationship_type", - "key_path": "auth.bearer_tokens[].authorization_details.relationship.relationship_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/schema_version", - "key_path": "auth.bearer_tokens[].authorization_details.schema_version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.bearer_tokens[].authorization_details.subject", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/binding_claim", - "key_path": "auth.bearer_tokens[].authorization_details.subject.binding_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/id_type", - "key_path": "auth.bearer_tokens[].authorization_details.subject.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.bearer_tokens[].authorization_details.target", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id", - "key_path": "auth.bearer_tokens[].authorization_details.target.id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id_type", - "key_path": "auth.bearer_tokens[].authorization_details.target.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/type", - "key_path": "auth.bearer_tokens[].authorization_details.type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/fingerprint", - "key_path": "auth.bearer_tokens[].fingerprint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.bearer_tokens[].fingerprint.name", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.bearer_tokens[].fingerprint.path", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/provider", - "key_path": "auth.bearer_tokens[].fingerprint.provider", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/id", - "key_path": "auth.bearer_tokens[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes", - "key_path": "auth.bearer_tokens[].scopes", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes/items", - "key_path": "auth.bearer_tokens[].scopes[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/oidc", - "key_path": "auth.oidc", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allow_insecure_localhost", - "key_path": "auth.oidc.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_algorithms", - "key_path": "auth.oidc.allowed_algorithms", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_algorithms/items", - "key_path": "auth.oidc.allowed_algorithms[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_clients", - "key_path": "auth.oidc.allowed_clients", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_clients/items", - "key_path": "auth.oidc.allowed_clients[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_token_types", - "key_path": "auth.oidc.allowed_token_types", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_token_types/items", - "key_path": "auth.oidc.allowed_token_types[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/audiences", - "key_path": "auth.oidc.audiences", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/audiences/items", - "key_path": "auth.oidc.audiences[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/issuer", - "key_path": "auth.oidc.issuer", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/jwks_url", - "key_path": "auth.oidc.jwks_url", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/leeway", - "key_path": "auth.oidc.leeway", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/principal_claim", - "key_path": "auth.oidc.principal_claim", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_claim", - "key_path": "auth.oidc.scope_claim", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map", - "key_path": "auth.oidc.scope_map", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map/additionalProperties", - "key_path": "auth.oidc.scope_map.*", - "path_kind": "map_value", - "profile": "notary_auth_oidc_scope_map_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map/additionalProperties/items", - "key_path": "auth.oidc.scope_map.*[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_separator", - "key_path": "auth.oidc.scope_separator", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_endpoint", - "key_path": "auth.oidc.userinfo_endpoint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_issuers", - "key_path": "auth.oidc.userinfo_issuers", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_issuers/items", - "key_path": "auth.oidc.userinfo_issuers[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/cel", - "key_path": "cel", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/allow_regex", - "key_path": "cel.allow_regex", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/eval_timeout_ms", - "key_path": "cel.eval_timeout_ms", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_binding_json_bytes", - "key_path": "cel.max_binding_json_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_expression_bytes", - "key_path": "cel.max_expression_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_list_items", - "key_path": "cel.max_list_items", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_object_depth", - "key_path": "cel.max_object_depth", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_object_keys", - "key_path": "cel.max_object_keys", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_result_json_bytes", - "key_path": "cel.max_result_json_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_string_bytes", - "key_path": "cel.max_string_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/mode", - "key_path": "cel.mode", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_count", - "key_path": "cel.worker_count", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_memory_bytes", - "key_path": "cel.worker_memory_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_stderr_bytes", - "key_path": "cel.worker_stderr_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/config_trust", - "key_path": "config_trust", - "path_kind": "property", - "profile": "notary_config_trust_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/antirollback_state_path", - "key_path": "config_trust.antirollback_state_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/break_glass_override_path", - "key_path": "config_trust.break_glass_override_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/bundle_path", - "key_path": "config_trust.bundle_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/trust_anchor_path", - "key_path": "config_trust.trust_anchor_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/credential_status", - "key_path": "credential_status", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/base_url", - "key_path": "credential_status.base_url", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/enabled", - "key_path": "credential_status.enabled", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/retention_seconds", - "key_path": "credential_status.retention_seconds", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/deployment", - "key_path": "deployment", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/evidence", - "key_path": "deployment.evidence", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_cursor_path", - "key_path": "deployment.evidence.audit_ack_cursor_path", - "path_kind": "property", - "profile": "notary_deployment_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_max_age_secs", - "key_path": "deployment.evidence.audit_ack_max_age_secs", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_offhost_shipping", - "key_path": "deployment.evidence.audit_offhost_shipping", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/signer_custody_approved", - "key_path": "deployment.evidence.signer_custody_approved", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/multi_instance", - "key_path": "deployment.multi_instance", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/profile", - "key_path": "deployment.profile", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers", - "key_path": "deployment.waivers", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers/items", - "key_path": "deployment.waivers[]", - "path_kind": "array_item", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/expires", - "key_path": "deployment.waivers[].expires", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/finding", - "key_path": "deployment.waivers[].finding", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/reference", - "key_path": "deployment.waivers[].reference", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/summary", - "key_path": "deployment.waivers[].summary", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/evidence", - "key_path": "evidence", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/allowed_purposes", - "key_path": "evidence.allowed_purposes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/allowed_purposes/items", - "key_path": "evidence.allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/api_base_url", - "key_path": "evidence.api_base_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/api_version", - "key_path": "evidence.api_version", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims", - "key_path": "evidence.claims", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims_url", - "key_path": "evidence.claims_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims/items", - "key_path": "evidence.claims[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/cccev", - "key_path": "evidence.claims[].cccev", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type", - "key_path": "evidence.claims[].cccev.evidence_type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type_iri", - "key_path": "evidence.claims[].cccev.evidence_type_iri", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/requirement_type", - "key_path": "evidence.claims[].cccev.requirement_type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/credential_profiles", - "key_path": "evidence.claims[].credential_profiles", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/credential_profiles/items", - "key_path": "evidence.claims[].credential_profiles[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/depends_on", - "key_path": "evidence.claims[].depends_on", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/depends_on/items", - "key_path": "evidence.claims[].depends_on[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/disclosure", - "key_path": "evidence.claims[].disclosure", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/allowed", - "key_path": "evidence.claims[].disclosure.allowed", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/allowed/items", - "key_path": "evidence.claims[].disclosure.allowed[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/default", - "key_path": "evidence.claims[].disclosure.default", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/downgrade", - "key_path": "evidence.claims[].disclosure.downgrade", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/evidence_mode", - "key_path": "evidence.claims[].evidence_mode", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/consultations", - "key_path": "evidence.claims[].evidence_mode.consultations", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/consultations/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/inputs", - "key_path": "evidence.claims[].evidence_mode.consultations.*.inputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/inputs/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.inputs.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_inputs_open_map", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/outputs", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/outputs/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_outputs_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/4/properties/fields", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/4/properties/fields/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_output_fields_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputObjectFieldContract/properties/required", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*.required", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputObjectFieldContract/properties/schema", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*.schema", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/5/properties/items", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.items", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/2/properties/max_bytes", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.max_bytes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/5/properties/max_items", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.max_items", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/1/properties/maximum", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.maximum", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/1/properties/minimum", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.minimum", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/0/properties/nullable", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.nullable", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/0/properties/type", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/profile", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationProfileRef/properties/contract_hash", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile.contract_hash", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationProfileRef/properties/id", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile.id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/type", - "key_path": "evidence.claims[].evidence_mode.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/formats", - "key_path": "evidence.claims[].formats", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/formats/items", - "key_path": "evidence.claims[].formats[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/id", - "key_path": "evidence.claims[].id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/inputs", - "key_path": "evidence.claims[].inputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/inputs/items", - "key_path": "evidence.claims[].inputs[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimInputConfig/properties/name", - "key_path": "evidence.claims[].inputs[].name", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimInputConfig/properties/type", - "key_path": "evidence.claims[].inputs[].type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/oots", - "key_path": "evidence.claims[].oots", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/authentication_level_of_assurance", - "key_path": "evidence.claims[].oots.authentication_level_of_assurance", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/enabled", - "key_path": "evidence.claims[].oots.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/evidence_type_classification", - "key_path": "evidence.claims[].oots.evidence_type_classification", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/evidence_type_list", - "key_path": "evidence.claims[].oots.evidence_type_list", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/languages", - "key_path": "evidence.claims[].oots.languages", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/languages/items", - "key_path": "evidence.claims[].oots.languages[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/reference_framework", - "key_path": "evidence.claims[].oots.reference_framework", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/requirement", - "key_path": "evidence.claims[].oots.requirement", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/operations", - "key_path": "evidence.claims[].operations", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimOperationsConfig/properties/batch_evaluate", - "key_path": "evidence.claims[].operations.batch_evaluate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/BatchOperationConfig/properties/enabled", - "key_path": "evidence.claims[].operations.batch_evaluate.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/BatchOperationConfig/properties/max_subjects", - "key_path": "evidence.claims[].operations.batch_evaluate.max_subjects", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimOperationsConfig/properties/evaluate", - "key_path": "evidence.claims[].operations.evaluate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OperationConfig/properties/enabled", - "key_path": "evidence.claims[].operations.evaluate.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/purpose", - "key_path": "evidence.claims[].purpose", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/required_scopes", - "key_path": "evidence.claims[].required_scopes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/required_scopes/items", - "key_path": "evidence.claims[].required_scopes[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/rule", - "key_path": "evidence.claims[].rule", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/consultation", - "key_path": "evidence.claims[].rule.consultation", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/2/properties/expression", - "key_path": "evidence.claims[].rule.expression", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/output", - "key_path": "evidence.claims[].rule.output", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/type", - "key_path": "evidence.claims[].rule.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/semantics", - "key_path": "evidence.claims[].semantics", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/concept", - "key_path": "evidence.claims[].semantics.concept", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from", - "key_path": "evidence.claims[].semantics.derived_from", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from/items", - "key_path": "evidence.claims[].semantics.derived_from[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/predicate", - "key_path": "evidence.claims[].semantics.predicate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/property", - "key_path": "evidence.claims[].semantics.property", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/value_mapping", - "key_path": "evidence.claims[].semantics.value_mapping", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/vocabulary", - "key_path": "evidence.claims[].semantics.vocabulary", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/subject_type", - "key_path": "evidence.claims[].subject_type", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/title", - "key_path": "evidence.claims[].title", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/value", - "key_path": "evidence.claims[].value", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/max_bytes", - "key_path": "evidence.claims[].value.max_bytes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/nullable", - "key_path": "evidence.claims[].value.nullable", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/type", - "key_path": "evidence.claims[].value.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/unit", - "key_path": "evidence.claims[].value.unit", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/version", - "key_path": "evidence.claims[].version", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/concurrency", - "key_path": "evidence.concurrency", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConcurrencyConfig/properties/subjects", - "key_path": "evidence.concurrency.subjects", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/credential_profiles", - "key_path": "evidence.credential_profiles", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/credential_profiles/additionalProperties", - "key_path": "evidence.credential_profiles.*", - "path_kind": "map_value", - "profile": "notary_evidence_credential_profiles_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/allowed_claims", - "key_path": "evidence.credential_profiles.*.allowed_claims", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/allowed_claims/items", - "key_path": "evidence.credential_profiles.*.allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/disclosure", - "key_path": "evidence.credential_profiles.*.disclosure", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialDisclosureConfig/properties/allowed", - "key_path": "evidence.credential_profiles.*.disclosure.allowed", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialDisclosureConfig/properties/allowed/items", - "key_path": "evidence.credential_profiles.*.disclosure.allowed[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/format", - "key_path": "evidence.credential_profiles.*.format", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/holder_binding", - "key_path": "evidence.credential_profiles.*.holder_binding", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/allowed_did_methods", - "key_path": "evidence.credential_profiles.*.holder_binding.allowed_did_methods", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/allowed_did_methods/items", - "key_path": "evidence.credential_profiles.*.holder_binding.allowed_did_methods[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/mode", - "key_path": "evidence.credential_profiles.*.holder_binding.mode", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/proof_of_possession", - "key_path": "evidence.credential_profiles.*.holder_binding.proof_of_possession", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/issuer", - "key_path": "evidence.credential_profiles.*.issuer", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/signing_key", - "key_path": "evidence.credential_profiles.*.signing_key", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/validity_seconds", - "key_path": "evidence.credential_profiles.*.validity_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/vct", - "key_path": "evidence.credential_profiles.*.vct", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/enabled", - "key_path": "evidence.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/formats_url", - "key_path": "evidence.formats_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/inline_batch_limit", - "key_path": "evidence.inline_batch_limit", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/machine_quota", - "key_path": "evidence.machine_quota", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/MachineQuotaConfig/properties/enabled", - "key_path": "evidence.machine_quota.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/MachineQuotaConfig/properties/subjects_per_minute", - "key_path": "evidence.machine_quota.subjects_per_minute", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/max_credential_validity_seconds", - "key_path": "evidence.max_credential_validity_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/relay", - "key_path": "evidence.relay", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allow_insecure_localhost", - "key_path": "evidence.relay.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allow_insecure_private_network", - "key_path": "evidence.relay.allow_insecure_private_network", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allowed_private_cidrs", - "key_path": "evidence.relay.allowed_private_cidrs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allowed_private_cidrs/items", - "key_path": "evidence.relay.allowed_private_cidrs[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/base_url", - "key_path": "evidence.relay.base_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/max_in_flight", - "key_path": "evidence.relay.max_in_flight", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/root_certificate_path", - "key_path": "evidence.relay.root_certificate_path", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/token_file", - "key_path": "evidence.relay.token_file", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/workload_client_id", - "key_path": "evidence.relay.workload_client_id", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/service_id", - "key_path": "evidence.service_id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/signing_keys", - "key_path": "evidence.signing_keys", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/signing_keys/additionalProperties", - "key_path": "evidence.signing_keys.*", - "path_kind": "map_value", - "profile": "notary_evidence_signing_keys_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/alg", - "key_path": "evidence.signing_keys.*.alg", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/key_id_hex", - "key_path": "evidence.signing_keys.*.key_id_hex", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/key_label", - "key_path": "evidence.signing_keys.*.key_label", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/kid", - "key_path": "evidence.signing_keys.*.kid", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/module_path", - "key_path": "evidence.signing_keys.*.module_path", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/password_env", - "key_path": "evidence.signing_keys.*.password_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/path", - "key_path": "evidence.signing_keys.*.path", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/pin_env", - "key_path": "evidence.signing_keys.*.pin_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/private_jwk_env", - "key_path": "evidence.signing_keys.*.private_jwk_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/provider", - "key_path": "evidence.signing_keys.*.provider", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/public_jwk_env", - "key_path": "evidence.signing_keys.*.public_jwk_env", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/publish_until_unix_seconds", - "key_path": "evidence.signing_keys.*.publish_until_unix_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/status", - "key_path": "evidence.signing_keys.*.status", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/token_label", - "key_path": "evidence.signing_keys.*.token_label", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/variables", - "key_path": "evidence.variables", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/variables/additionalProperties", - "key_path": "evidence.variables.*", - "path_kind": "map_value", - "profile": "notary_evidence_variables_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RequestVariableConfig/properties/from", - "key_path": "evidence.variables.*.from", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RequestVariableConfig/properties/type", - "key_path": "evidence.variables.*.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/federation", - "key_path": "federation", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/clock_leeway_seconds", - "key_path": "federation.clock_leeway_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/emergency_denylist", - "key_path": "federation.emergency_denylist", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/kids", - "key_path": "federation.emergency_denylist.kids", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/kids/items", - "key_path": "federation.emergency_denylist.kids[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/node_ids", - "key_path": "federation.emergency_denylist.node_ids", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/node_ids/items", - "key_path": "federation.emergency_denylist.node_ids[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/enabled", - "key_path": "federation.enabled", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/evaluation_profiles", - "key_path": "federation.evaluation_profiles", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/evaluation_profiles/items", - "key_path": "federation.evaluation_profiles[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/assurance_level", - "key_path": "federation.evaluation_profiles[].assurance_level", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/claim_id", - "key_path": "federation.evaluation_profiles[].claim_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/consent_ref", - "key_path": "federation.evaluation_profiles[].consent_ref", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/disclosure", - "key_path": "federation.evaluation_profiles[].disclosure", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/id", - "key_path": "federation.evaluation_profiles[].id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/jurisdiction", - "key_path": "federation.evaluation_profiles[].jurisdiction", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/legal_basis_ref", - "key_path": "federation.evaluation_profiles[].legal_basis_ref", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/max_claim_result_age_seconds", - "key_path": "federation.evaluation_profiles[].max_claim_result_age_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/ruleset", - "key_path": "federation.evaluation_profiles[].ruleset", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/subject_id_type", - "key_path": "federation.evaluation_profiles[].subject_id_type", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/federation_api", - "key_path": "federation.federation_api", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/inbound_body_limit_bytes", - "key_path": "federation.inbound_body_limit_bytes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/issuer", - "key_path": "federation.issuer", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/jwks_uri", - "key_path": "federation.jwks_uri", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/max_request_lifetime_seconds", - "key_path": "federation.max_request_lifetime_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/node_id", - "key_path": "federation.node_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/pairwise_subject_hash", - "key_path": "federation.pairwise_subject_hash", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPairwiseSubjectHashConfig/properties/secret_env", - "key_path": "federation.pairwise_subject_hash.secret_env", - "path_kind": "property", - "profile": "notary_federation_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/peers", - "key_path": "federation.peers", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/peers/items", - "key_path": "federation.peers[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allow_insecure_localhost", - "key_path": "federation.peers[].allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allow_insecure_private_network", - "key_path": "federation.peers[].allow_insecure_private_network", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_profiles", - "key_path": "federation.peers[].allowed_profiles", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_profiles/items", - "key_path": "federation.peers[].allowed_profiles[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_protocol_versions", - "key_path": "federation.peers[].allowed_protocol_versions", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_protocol_versions/items", - "key_path": "federation.peers[].allowed_protocol_versions[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_purposes", - "key_path": "federation.peers[].allowed_purposes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_purposes/items", - "key_path": "federation.peers[].allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/evaluation_scopes", - "key_path": "federation.peers[].evaluation_scopes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/evaluation_scopes/items", - "key_path": "federation.peers[].evaluation_scopes[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/issuer", - "key_path": "federation.peers[].issuer", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/jwks_uri", - "key_path": "federation.peers[].jwks_uri", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/node_id", - "key_path": "federation.peers[].node_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/response_shaping", - "key_path": "federation.response_shaping", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationResponseShapingConfig/properties/minimum_denial_latency_ms", - "key_path": "federation.response_shaping.minimum_denial_latency_ms", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/signing", - "key_path": "federation.signing", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationSigningConfig/properties/signing_key", - "key_path": "federation.signing.signing_key", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/supported_protocol_versions", - "key_path": "federation.supported_protocol_versions", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/supported_protocol_versions/items", - "key_path": "federation.supported_protocol_versions[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/instance", - "key_path": "instance", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/environment", - "key_path": "instance.environment", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/id", - "key_path": "instance.id", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/jurisdiction", - "key_path": "instance.jurisdiction", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/owner", - "key_path": "instance.owner", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/public_base_url", - "key_path": "instance.public_base_url", - "path_kind": "property", - "profile": "notary_instance_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/oid4vci", - "key_path": "oid4vci", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/accepted_token_audiences", - "key_path": "oid4vci.accepted_token_audiences", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/accepted_token_audiences/items", - "key_path": "oid4vci.accepted_token_audiences[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization", - "key_path": "oid4vci.authorization", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization_servers", - "key_path": "oid4vci.authorization_servers", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization_servers/items", - "key_path": "oid4vci.authorization_servers[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciAuthorizationConfig/properties/require_pkce_method", - "key_path": "oid4vci.authorization.require_pkce_method", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_configurations", - "key_path": "oid4vci.credential_configurations", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_configurations/additionalProperties", - "key_path": "oid4vci.credential_configurations.*", - "path_kind": "map_value", - "profile": "notary_oid4vci_credential_configurations_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claim_id", - "key_path": "oid4vci.credential_configurations.*.claim_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims", - "key_path": "oid4vci.credential_configurations.*.claims", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims/items", - "key_path": "oid4vci.credential_configurations.*.claims[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/display_name", - "key_path": "oid4vci.credential_configurations.*.claims[].display_name", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/id", - "key_path": "oid4vci.credential_configurations.*.claims[].id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/output_path", - "key_path": "oid4vci.credential_configurations.*.claims[].output_path", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/output_path/items", - "key_path": "oid4vci.credential_configurations.*.claims[].output_path[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/sd", - "key_path": "oid4vci.credential_configurations.*.claims[].sd", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/credential_profile", - "key_path": "oid4vci.credential_configurations.*.credential_profile", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/cryptographic_binding_methods_supported", - "key_path": "oid4vci.credential_configurations.*.cryptographic_binding_methods_supported", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/cryptographic_binding_methods_supported/items", - "key_path": "oid4vci.credential_configurations.*.cryptographic_binding_methods_supported[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/display", - "key_path": "oid4vci.credential_configurations.*.display", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/display_name", - "key_path": "oid4vci.credential_configurations.*.display_name", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_color", - "key_path": "oid4vci.credential_configurations.*.display.background_color", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_image", - "key_path": "oid4vci.credential_configurations.*.display.background_image", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.background_image.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.background_image.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.background_image.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/description", - "key_path": "oid4vci.credential_configurations.*.display.description", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/locale", - "key_path": "oid4vci.credential_configurations.*.display.locale", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/logo", - "key_path": "oid4vci.credential_configurations.*.display.logo", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.logo.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.logo.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.logo.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/secondary_image", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/text_color", - "key_path": "oid4vci.credential_configurations.*.display.text_color", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/format", - "key_path": "oid4vci.credential_configurations.*.format", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/proof_signing_alg_values_supported", - "key_path": "oid4vci.credential_configurations.*.proof_signing_alg_values_supported", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/proof_signing_alg_values_supported/items", - "key_path": "oid4vci.credential_configurations.*.proof_signing_alg_values_supported[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/representative_issuance", - "key_path": "oid4vci.credential_configurations.*.representative_issuance", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciRepresentativeIssuanceConfig/properties/ceremony", - "key_path": "oid4vci.credential_configurations.*.representative_issuance.ceremony", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciRepresentativeIssuanceConfig/properties/relationship", - "key_path": "oid4vci.credential_configurations.*.representative_issuance.relationship", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/scope", - "key_path": "oid4vci.credential_configurations.*.scope", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/vct", - "key_path": "oid4vci.credential_configurations.*.vct", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_endpoint", - "key_path": "oid4vci.credential_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_issuer", - "key_path": "oid4vci.credential_issuer", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/display", - "key_path": "oid4vci.display", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/display/items", - "key_path": "oid4vci.display[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/locale", - "key_path": "oid4vci.display[].locale", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/logo", - "key_path": "oid4vci.display[].logo", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.display[].logo.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.display[].logo.uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.display[].logo.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/name", - "key_path": "oid4vci.display[].name", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/enabled", - "key_path": "oid4vci.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/nonce", - "key_path": "oid4vci.nonce", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/nonce_endpoint", - "key_path": "oid4vci.nonce_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciNonceConfig/properties/enabled", - "key_path": "oid4vci.nonce.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciNonceConfig/properties/ttl_seconds", - "key_path": "oid4vci.nonce.ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/offer_endpoint", - "key_path": "oid4vci.offer_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/pre_authorized_code", - "key_path": "oid4vci.pre_authorized_code", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/enabled", - "key_path": "oid4vci.pre_authorized_code.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/esignet", - "key_path": "oid4vci.pre_authorized_code.esignet", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/allow_insecure_localhost", - "key_path": "oid4vci.pre_authorized_code.esignet.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/authorize_url", - "key_path": "oid4vci.pre_authorized_code.esignet.authorize_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/client_id", - "key_path": "oid4vci.pre_authorized_code.esignet.client_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/client_signing_key_id", - "key_path": "oid4vci.pre_authorized_code.esignet.client_signing_key_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/issuer", - "key_path": "oid4vci.pre_authorized_code.esignet.issuer", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/jwks_uri", - "key_path": "oid4vci.pre_authorized_code.esignet.jwks_uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/login_state_ttl_seconds", - "key_path": "oid4vci.pre_authorized_code.esignet.login_state_ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/redirect_uri", - "key_path": "oid4vci.pre_authorized_code.esignet.redirect_uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/scopes", - "key_path": "oid4vci.pre_authorized_code.esignet.scopes", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/scopes/items", - "key_path": "oid4vci.pre_authorized_code.esignet.scopes[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/token_url", - "key_path": "oid4vci.pre_authorized_code.esignet.token_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/userinfo_url", - "key_path": "oid4vci.pre_authorized_code.esignet.userinfo_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/pre_authorized_code_ttl_seconds", - "key_path": "oid4vci.pre_authorized_code.pre_authorized_code_ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/tx_code", - "key_path": "oid4vci.pre_authorized_code.tx_code", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/input_mode", - "key_path": "oid4vci.pre_authorized_code.tx_code.input_mode", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/length", - "key_path": "oid4vci.pre_authorized_code.tx_code.length", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/required", - "key_path": "oid4vci.pre_authorized_code.tx_code.required", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/proof", - "key_path": "oid4vci.proof", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciProofConfig/properties/max_age_seconds", - "key_path": "oid4vci.proof.max_age_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciProofConfig/properties/max_clock_skew_seconds", - "key_path": "oid4vci.proof.max_clock_skew_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/server", - "key_path": "server", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/admin_listener", - "key_path": "server.admin_listener", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/bind", - "key_path": "server.admin_listener.bind", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/mode", - "key_path": "server.admin_listener.mode", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/bind", - "key_path": "server.bind", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/cors", - "key_path": "server.cors", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCorsConfig/properties/allowed_origins", - "key_path": "server.cors.allowed_origins", - "path_kind": "property", - "profile": "notary_server_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCorsConfig/properties/allowed_origins/items", - "key_path": "server.cors.allowed_origins[]", - "path_kind": "array_item", - "profile": "notary_server_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/http1_header_read_timeout", - "key_path": "server.http1_header_read_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/max_connections", - "key_path": "server.max_connections", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/openapi_requires_auth", - "key_path": "server.openapi_requires_auth", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/request_body_timeout", - "key_path": "server.request_body_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/request_timeout", - "key_path": "server.request_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips", - "key_path": "server.trusted_proxy_ips", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips/items", - "key_path": "server.trusted_proxy_ips[]", - "path_kind": "array_item", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/state", - "key_path": "state", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/postgresql", - "key_path": "state.postgresql", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/connect_timeout_ms", - "key_path": "state.postgresql.connect_timeout_ms", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/max_connections", - "key_path": "state.postgresql.max_connections", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/operation_timeout_ms", - "key_path": "state.postgresql.operation_timeout_ms", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/root_certificate_path", - "key_path": "state.postgresql.root_certificate_path", - "path_kind": "property", - "profile": "notary_state_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/sensitive_state_key_env", - "key_path": "state.postgresql.sensitive_state_key_env", - "path_kind": "property", - "profile": "notary_state_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/url_env", - "key_path": "state.postgresql.url_env", - "path_kind": "property", - "profile": "notary_state_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/storage", - "key_path": "state.storage", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/subject_access", - "key_path": "subject_access", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_claims", - "key_path": "subject_access.allowed_claims", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_claims/items", - "key_path": "subject_access.allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_disclosures", - "key_path": "subject_access.allowed_disclosures", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_disclosures/items", - "key_path": "subject_access.allowed_disclosures[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_formats", - "key_path": "subject_access.allowed_formats", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_formats/items", - "key_path": "subject_access.allowed_formats[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_operations", - "key_path": "subject_access.allowed_operations", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/batch_evaluate", - "key_path": "subject_access.allowed_operations.batch_evaluate", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/evaluate", - "key_path": "subject_access.allowed_operations.evaluate", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/issue_credential", - "key_path": "subject_access.allowed_operations.issue_credential", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/render", - "key_path": "subject_access.allowed_operations.render", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_purposes", - "key_path": "subject_access.allowed_purposes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_purposes/items", - "key_path": "subject_access.allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_wallet_origins", - "key_path": "subject_access.allowed_wallet_origins", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_wallet_origins/items", - "key_path": "subject_access.allowed_wallet_origins[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/citizen_clients", - "key_path": "subject_access.citizen_clients", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_audiences", - "key_path": "subject_access.citizen_clients.allowed_audiences", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_audiences/items", - "key_path": "subject_access.citizen_clients.allowed_audiences[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_client_ids", - "key_path": "subject_access.citizen_clients.allowed_client_ids", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_client_ids/items", - "key_path": "subject_access.citizen_clients.allowed_client_ids[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/credential_profiles", - "key_path": "subject_access.credential_profiles", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/credential_profiles/items", - "key_path": "subject_access.credential_profiles[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/delegation", - "key_path": "subject_access.delegation", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/allowed_relationships", - "key_path": "subject_access.delegation.allowed_relationships", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/allowed_relationships/items", - "key_path": "subject_access.delegation.allowed_relationships[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_claims", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_claims", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_claims/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_disclosures", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_disclosures", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_disclosures/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_disclosures[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_formats", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_formats", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_formats/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_formats[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_purposes", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_purposes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_purposes/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/max_proof_age_seconds", - "key_path": "subject_access.delegation.allowed_relationships[].max_proof_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/proof_claim", - "key_path": "subject_access.delegation.allowed_relationships[].proof_claim", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/relationship_type", - "key_path": "subject_access.delegation.allowed_relationships[].relationship_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/target_id_type", - "key_path": "subject_access.delegation.allowed_relationships[].target_id_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/enabled", - "key_path": "subject_access.delegation.enabled", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/enabled", - "key_path": "subject_access.enabled", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/rate_limits", - "key_path": "subject_access.rate_limits", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/credential_issuance_per_principal_per_hour", - "key_path": "subject_access.rate_limits.credential_issuance_per_principal_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/invalid_token_per_client_address_per_minute", - "key_path": "subject_access.rate_limits.invalid_token_per_client_address_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/per_holder_per_hour", - "key_path": "subject_access.rate_limits.per_holder_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/per_principal_per_minute", - "key_path": "subject_access.rate_limits.per_principal_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/subject_mismatch_per_principal_per_hour", - "key_path": "subject_access.rate_limits.subject_mismatch_per_principal_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/tx_code_attempts_per_code_per_minute", - "key_path": "subject_access.rate_limits.tx_code_attempts_per_code_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/required_scopes", - "key_path": "subject_access.required_scopes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/required_scopes/items", - "key_path": "subject_access.required_scopes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/scope_policy", - "key_path": "subject_access.scope_policy", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/subject_binding", - "key_path": "subject_access.subject_binding", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/allow_sub_as_civil_id", - "key_path": "subject_access.subject_binding.allow_sub_as_civil_id", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/claim_source", - "key_path": "subject_access.subject_binding.claim_source", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/id_type", - "key_path": "subject_access.subject_binding.id_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/normalize", - "key_path": "subject_access.subject_binding.normalize", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/request_field", - "key_path": "subject_access.subject_binding.request_field", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/token_claim", - "key_path": "subject_access.subject_binding.token_claim", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/token_policy", - "key_path": "subject_access.token_policy", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/assurance_claim_source", - "key_path": "subject_access.token_policy.assurance_claim_source", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_access_token_lifetime_seconds", - "key_path": "subject_access.token_policy.max_access_token_lifetime_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_auth_age_seconds", - "key_path": "subject_access.token_policy.max_auth_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_clock_leeway_seconds", - "key_path": "subject_access.token_policy.max_clock_leeway_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_credential_validity_seconds", - "key_path": "subject_access.token_policy.max_credential_validity_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_evaluation_age_seconds", - "key_path": "subject_access.token_policy.max_evaluation_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/required_acr_values", - "key_path": "subject_access.token_policy.required_acr_values", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/required_acr_values/items", - "key_path": "subject_access.token_policy.required_acr_values[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - } - ], - "overrides": [ - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids", - "key_path": "auth.access_token_signing.verification_key_ids", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.api_keys[].authorization_details", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.api_keys[].authorization_details.access_mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.api_keys[].authorization_details.actions", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.api_keys[].authorization_details.assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.api_keys[].authorization_details.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.api_keys[].authorization_details.consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.api_keys[].authorization_details.disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.api_keys[].authorization_details.format", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.api_keys[].authorization_details.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.api_keys[].authorization_details.legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.api_keys[].authorization_details.locations", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.api_keys[].authorization_details.purpose", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.api_keys[].authorization_details.relationship", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.api_keys[].authorization_details.subject", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.api_keys[].authorization_details.target", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.api_keys[].fingerprint.name", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.api_keys[].fingerprint.path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.bearer_tokens[].authorization_details", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.bearer_tokens[].authorization_details.access_mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.bearer_tokens[].authorization_details.actions", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.bearer_tokens[].authorization_details.assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.bearer_tokens[].authorization_details.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.bearer_tokens[].authorization_details.consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.bearer_tokens[].authorization_details.disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.bearer_tokens[].authorization_details.format", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.bearer_tokens[].authorization_details.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.bearer_tokens[].authorization_details.legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.bearer_tokens[].authorization_details.locations", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.bearer_tokens[].authorization_details.purpose", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.bearer_tokens[].authorization_details.relationship", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.bearer_tokens[].authorization_details.subject", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.bearer_tokens[].authorization_details.target", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.bearer_tokens[].fingerprint.name", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.bearer_tokens[].fingerprint.path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/cel", - "key_path": "cel", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/properties/config_trust", - "key_path": "config_trust", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/break_glass_override_path", - "key_path": "config_trust.break_glass_override_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/credential_status", - "key_path": "credential_status", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/properties/deployment", - "key_path": "deployment", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_cursor_path", - "key_path": "deployment.evidence.audit_ack_cursor_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_max_age_secs", - "key_path": "deployment.evidence.audit_ack_max_age_secs", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/profile", - "key_path": "deployment.profile", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers", - "key_path": "deployment.waivers", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/summary", - "key_path": "deployment.waivers[].summary", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type", - "key_path": "evidence.claims[].cccev.evidence_type", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type_iri", - "key_path": "evidence.claims[].cccev.evidence_type_iri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/semantics", - "key_path": "evidence.claims[].semantics", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/concept", - "key_path": "evidence.claims[].semantics.concept", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from", - "key_path": "evidence.claims[].semantics.derived_from", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/predicate", - "key_path": "evidence.claims[].semantics.predicate", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/property", - "key_path": "evidence.claims[].semantics.property", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/value_mapping", - "key_path": "evidence.claims[].semantics.value_mapping", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/vocabulary", - "key_path": "evidence.claims[].semantics.vocabulary", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/max_bytes", - "key_path": "evidence.claims[].value.max_bytes", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a per-claim string byte ceiling." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/nullable", - "key_path": "evidence.claims[].value.nullable", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/relay", - "key_path": "evidence.relay", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/publish_until_unix_seconds", - "key_path": "evidence.signing_keys.*.publish_until_unix_seconds", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/federation", - "key_path": "federation", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/assurance_level", - "key_path": "federation.evaluation_profiles[].assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/consent_ref", - "key_path": "federation.evaluation_profiles[].consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/disclosure", - "key_path": "federation.evaluation_profiles[].disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/jurisdiction", - "key_path": "federation.evaluation_profiles[].jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/legal_basis_ref", - "key_path": "federation.evaluation_profiles[].legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/max_claim_result_age_seconds", - "key_path": "federation.evaluation_profiles[].max_claim_result_age_seconds", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/instance", - "key_path": "instance", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/jurisdiction", - "key_path": "instance.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/owner", - "key_path": "instance.owner", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/public_base_url", - "key_path": "instance.public_base_url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/oid4vci", - "key_path": "oid4vci", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claim_id", - "key_path": "oid4vci.credential_configurations.*.claim_id", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims", - "key_path": "oid4vci.credential_configurations.*.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/representative_issuance", - "key_path": "oid4vci.credential_configurations.*.representative_issuance", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_color", - "key_path": "oid4vci.credential_configurations.*.display.background_color", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_image", - "key_path": "oid4vci.credential_configurations.*.display.background_image", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.background_image.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.background_image.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.background_image.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/description", - "key_path": "oid4vci.credential_configurations.*.display.description", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/locale", - "key_path": "oid4vci.credential_configurations.*.display.locale", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/logo", - "key_path": "oid4vci.credential_configurations.*.display.logo", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.logo.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.logo.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.logo.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/secondary_image", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/text_color", - "key_path": "oid4vci.credential_configurations.*.display.text_color", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/locale", - "key_path": "oid4vci.display[].locale", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/logo", - "key_path": "oid4vci.display[].logo", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.display[].logo.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.display[].logo.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.display[].logo.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/admin_listener", - "key_path": "server.admin_listener", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/mode", - "key_path": "server.admin_listener.mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/openapi_requires_auth", - "key_path": "server.openapi_requires_auth", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips", - "key_path": "server.trusted_proxy_ips", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/properties/state", - "key_path": "state", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/postgresql", - "key_path": "state.postgresql", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/root_certificate_path", - "key_path": "state.postgresql.root_certificate_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/subject_access", - "key_path": "subject_access", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - } - ] -} diff --git a/crates/registry-notary-core/src/config.rs b/crates/registry-notary-core/src/config.rs deleted file mode 100644 index 9e001e5e2..000000000 --- a/crates/registry-notary-core/src/config.rs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary configuration model. - -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::collections::HashSet; -use std::net::{IpAddr, SocketAddr}; -use std::path::PathBuf; -use std::time::{Duration, SystemTime}; - -use registry_platform_authcommon::CredentialFingerprintRef; -use registry_platform_config::DeprecatedConfigField; -use registry_platform_crypto::validate_did_web_https_issuer_binding; -use registry_platform_crypto::PublicJwk; -pub use registry_platform_crypto::{ - KeyProviderKind as SigningKeyProviderConfig, KeyStatus as SigningKeyStatus, -}; -use registry_platform_oid4vci::{ - CREDENTIAL_SIGNING_ALG_EDDSA, CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK, - SD_JWT_VC_FORMAT as OID4VCI_SD_JWT_VC_FORMAT, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::deployment::DeploymentConfig; -use crate::model::{ - is_request_variable_name, DisclosureProfile, EvidenceAuthorizationDetails, FORMAT_CCCEV_JSONLD, - FORMAT_CLAIM_RESULT_JSON, FORMAT_SD_JWT_VC, MAX_REQUEST_VARIABLES_V1, - SD_JWT_VC_HOLDER_BINDING_METHOD, SD_JWT_VC_SIGNING_ALG, -}; - -mod audit; -mod auth; -mod cel; -mod credential_status; -mod errors; -mod evidence; -mod federation; -mod http; -mod oid4vci; -mod root; -pub mod schema; -mod state; -mod subject_access; - -pub use audit::*; -pub use auth::*; -pub use cel::*; -pub use credential_status::*; -pub use errors::*; -pub use evidence::*; -pub use federation::*; -pub use http::*; -pub use oid4vci::*; -pub use root::*; -pub use state::*; -pub use subject_access::*; - -#[cfg(test)] -mod tests; diff --git a/crates/registry-notary-core/src/config/audit.rs b/crates/registry-notary-core/src/config/audit.rs deleted file mode 100644 index d898404b9..000000000 --- a/crates/registry-notary-core/src/config/audit.rs +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evidence audit sink configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAuditConfig { - #[serde(default = "default_audit_sink")] - pub sink: String, - #[serde(default)] - pub path: Option, - #[serde(default)] - pub hash_secret_env: Option, - #[serde(default)] - pub max_size_mb: Option, - #[serde(default)] - pub max_files: Option, - #[serde(default)] - pub syslog_socket_path: Option, -} - -impl Default for EvidenceAuditConfig { - fn default() -> Self { - Self { - sink: default_audit_sink(), - path: None, - hash_secret_env: None, - max_size_mb: None, - max_files: None, - syslog_socket_path: None, - } - } -} - -impl EvidenceAuditConfig { - pub const DEFAULT_MAX_SIZE_MB: u64 = 100; - pub const DEFAULT_MAX_FILES: u32 = 14; - - pub fn max_size_bytes(&self) -> u64 { - self.max_size_mb.unwrap_or(Self::DEFAULT_MAX_SIZE_MB) * 1024 * 1024 - } - - pub fn max_files(&self) -> u32 { - self.max_files.unwrap_or(Self::DEFAULT_MAX_FILES) - } -} - -pub(super) fn default_audit_sink() -> String { - "stdout".to_string() -} - -/// A durable audit sink retains the evidence trail beyond process stdout. -/// -/// `stdout` and `none` are not durable, retained sinks for a production-shaped -/// deployment; `file`, `jsonl`, and `syslog` write to a retained destination. -pub(super) fn audit_sink_is_durable(config: &EvidenceAuditConfig) -> bool { - matches!(config.sink.as_str(), "file" | "jsonl" | "syslog") -} diff --git a/crates/registry-notary-core/src/config/auth.rs b/crates/registry-notary-core/src/config/auth.rs deleted file mode 100644 index 0a55962d8..000000000 --- a/crates/registry-notary-core/src/config/auth.rs +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Caller authentication and token-signing configuration. - -use super::*; - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryCorsConfig { - #[serde(default)] - pub allowed_origins: Vec, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAuthConfig { - #[serde(default)] - pub api_keys: Vec, - #[serde(default)] - pub bearer_tokens: Vec, - #[serde(default)] - pub oidc: Option, - /// Trust anchor for Notary-minted access tokens (the pre-authorized-code - /// flow's second verifier). Disabled by default so existing configs load - /// unchanged. - #[serde(default)] - pub access_token_signing: AccessTokenSigningConfig, -} - -/// Self-issued access-token signing configuration. -/// -/// When `enabled`, the Notary mints its own access tokens (for the -/// pre-authorized-code flow) signed with a dedicated `signing_keys` entry that -/// MUST be distinct from any credential-signing key. The minted token's -/// `iss`/`aud`/`typ`/alg pin the second verifier's trust anchor. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct AccessTokenSigningConfig { - #[serde(default)] - pub enabled: bool, - /// Issuer (`iss`) the Notary stamps into its own access tokens. - #[serde(default)] - pub issuer: String, - /// Audiences (`aud`) accepted for Notary-minted access tokens. - #[serde(default)] - pub audiences: Vec, - /// Allowed signing algorithms. Only EdDSA is supported. - #[serde(default = "default_access_token_signing_algorithms")] - pub allowed_algorithms: Vec, - /// Header `typ` stamped into Notary access tokens, distinct from the - /// credential `typ` so a token cannot be replayed as another class. - #[serde(default = "default_access_token_typ")] - pub token_typ: String, - /// `evidence.signing_keys` entry used to sign access tokens. Must be a - /// dedicated key, never a credential-signing key. - #[serde(default)] - pub signing_key_id: String, - /// Additional publish-only `evidence.signing_keys` entries accepted for - /// verifying previously minted Notary access tokens and pre-authorized - /// codes during a governed key rotation. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub verification_key_ids: Vec, - /// Access-token lifetime in seconds. - #[serde(default = "default_access_token_ttl_seconds")] - pub access_token_ttl_seconds: u64, -} - -impl Default for AccessTokenSigningConfig { - fn default() -> Self { - Self { - enabled: false, - issuer: String::new(), - audiences: Vec::new(), - allowed_algorithms: default_access_token_signing_algorithms(), - token_typ: default_access_token_typ(), - signing_key_id: String::new(), - verification_key_ids: Vec::new(), - access_token_ttl_seconds: default_access_token_ttl_seconds(), - } - } -} - -pub(super) fn default_access_token_signing_algorithms() -> Vec { - vec![CREDENTIAL_SIGNING_ALG_EDDSA.to_string()] -} - -pub(super) fn default_access_token_typ() -> String { - "registry-notary-access+jwt".to_string() -} - -pub(super) const fn default_access_token_ttl_seconds() -> u64 { - 300 -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct EvidenceCredentialConfig { - pub id: String, - #[schemars(with = "schema::CredentialFingerprintSchema")] - pub fingerprint: CredentialFingerprintRef, - #[serde(default)] - pub scopes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authorization_details: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceOidcAuthConfig { - pub issuer: String, - pub jwks_url: String, - #[serde(default)] - pub userinfo_endpoint: Option, - #[serde(default)] - pub userinfo_issuers: Vec, - #[serde(default)] - pub audiences: Vec, - #[serde(default)] - pub allowed_clients: Vec, - #[serde(default = "default_oidc_allowed_algorithms")] - pub allowed_algorithms: Vec, - #[serde(default = "default_oidc_allowed_token_types")] - pub allowed_token_types: Vec, - #[serde(default = "default_oidc_scope_claim")] - pub scope_claim: String, - #[serde(default = "default_oidc_scope_separator")] - pub scope_separator: String, - #[serde(default)] - pub scope_map: BTreeMap>, - #[serde(default = "default_oidc_principal_claim")] - pub principal_claim: String, - #[serde(default = "default_oidc_leeway", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub leeway: Duration, - #[serde(default)] - pub allow_insecure_localhost: bool, -} - -pub(super) fn default_oidc_allowed_algorithms() -> Vec { - vec![SD_JWT_VC_SIGNING_ALG.to_string()] -} - -pub(super) fn default_oidc_allowed_token_types() -> Vec { - vec!["JWT".to_string()] -} - -pub(super) fn default_oidc_scope_claim() -> String { - "scope".to_string() -} - -pub(super) fn default_oidc_scope_separator() -> String { - " ".to_string() -} - -pub(super) fn default_oidc_principal_claim() -> String { - "sub".to_string() -} - -pub(super) fn default_oidc_leeway() -> Duration { - Duration::from_secs(60) -} - -impl EvidenceOidcAuthConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.issuer.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "issuer must not be empty".to_string(), - }); - } - if self.jwks_url.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "jwks_url must not be empty".to_string(), - }); - } - validate_jwks_url_transport(&self.jwks_url, self.allow_insecure_localhost)?; - if let Some(userinfo_endpoint) = self.userinfo_endpoint.as_deref() { - if userinfo_endpoint.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "userinfo_endpoint must not be empty when configured".to_string(), - }); - } - validate_jwks_url_transport(userinfo_endpoint, self.allow_insecure_localhost)?; - } - validate_entries("auth.oidc.userinfo_issuers", &self.userinfo_issuers)?; - if self.audiences.is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "audiences must list at least one accepted audience".to_string(), - }); - } - if self.scope_separator.chars().count() != 1 { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "scope_separator must be exactly one character".to_string(), - }); - } - if self.principal_claim.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "principal_claim must not be empty".to_string(), - }); - } - Ok(()) - } -} - -pub(super) fn validate_jwks_url_transport( - jwks_url: &str, - allow_insecure_localhost: bool, -) -> Result<(), EvidenceConfigError> { - let jwks_url = jwks_url.trim(); - if jwks_url.starts_with("https://") - || (allow_insecure_localhost && is_insecure_localhost_url(jwks_url)) - { - return Ok(()); - } - Err(EvidenceConfigError::InvalidOidcConfig { - reason: - "jwks_url must use https unless allow_insecure_localhost permits an http localhost URL" - .to_string(), - }) -} - -pub(super) fn is_insecure_localhost_url(url: &str) -> bool { - let Some(rest) = url.strip_prefix("http://") else { - return false; - }; - let authority = rest - .split(['/', '?', '#']) - .next() - .unwrap_or_default() - .rsplit('@') - .next() - .unwrap_or_default(); - let host = if let Some(after_bracket) = authority.strip_prefix('[') { - after_bracket.split(']').next().unwrap_or_default() - } else { - authority.split(':').next().unwrap_or_default() - }; - matches!(host, "localhost" | "127.0.0.1" | "::1") -} diff --git a/crates/registry-notary-core/src/config/cel.rs b/crates/registry-notary-core/src/config/cel.rs deleted file mode 100644 index 991ebb3aa..000000000 --- a/crates/registry-notary-core/src/config/cel.rs +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! CEL worker configuration. - -use super::*; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryCelConfig { - #[serde(default = "default_cel_mode")] - pub mode: String, - #[serde(default = "default_cel_worker_count")] - pub worker_count: usize, - #[serde(default = "default_cel_eval_timeout_ms")] - pub eval_timeout_ms: u64, - #[serde(default)] - pub allow_regex: bool, - #[serde(default = "default_cel_max_expression_bytes")] - pub max_expression_bytes: usize, - #[serde(default = "default_cel_max_binding_json_bytes")] - pub max_binding_json_bytes: usize, - #[serde(default = "default_cel_max_result_json_bytes")] - pub max_result_json_bytes: usize, - #[serde(default = "default_cel_max_string_bytes")] - pub max_string_bytes: usize, - #[serde(default = "default_cel_max_list_items")] - pub max_list_items: usize, - #[serde(default = "default_cel_max_object_depth")] - pub max_object_depth: usize, - #[serde(default = "default_cel_max_object_keys")] - pub max_object_keys: usize, - #[serde(default = "default_cel_worker_memory_bytes")] - pub worker_memory_bytes: u64, - #[serde(default = "default_cel_worker_stderr_bytes")] - pub worker_stderr_bytes: usize, -} - -impl Default for RegistryNotaryCelConfig { - fn default() -> Self { - Self { - mode: default_cel_mode(), - worker_count: default_cel_worker_count(), - eval_timeout_ms: default_cel_eval_timeout_ms(), - allow_regex: false, - max_expression_bytes: default_cel_max_expression_bytes(), - max_binding_json_bytes: default_cel_max_binding_json_bytes(), - max_result_json_bytes: default_cel_max_result_json_bytes(), - max_string_bytes: default_cel_max_string_bytes(), - max_list_items: default_cel_max_list_items(), - max_object_depth: default_cel_max_object_depth(), - max_object_keys: default_cel_max_object_keys(), - worker_memory_bytes: default_cel_worker_memory_bytes(), - worker_stderr_bytes: default_cel_worker_stderr_bytes(), - } - } -} - -impl RegistryNotaryCelConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.mode != "worker" && self.mode != "disabled" { - return invalid_cel("cel.mode must be worker or disabled"); - } - if self.worker_count == 0 || self.worker_count > 16 { - return invalid_cel("cel.worker_count must be between 1 and 16"); - } - if self.eval_timeout_ms == 0 || self.eval_timeout_ms > 30_000 { - return invalid_cel("cel.eval_timeout_ms must be between 1 and 30000"); - } - if self.max_expression_bytes == 0 || self.max_expression_bytes > 256 * 1024 { - return invalid_cel("cel.max_expression_bytes must be between 1 and 262144"); - } - if self.max_binding_json_bytes == 0 || self.max_binding_json_bytes > 1024 * 1024 { - return invalid_cel("cel.max_binding_json_bytes must be between 1 and 1048576"); - } - if self.max_result_json_bytes == 0 || self.max_result_json_bytes > 1024 * 1024 { - return invalid_cel("cel.max_result_json_bytes must be between 1 and 1048576"); - } - if self.max_string_bytes == 0 || self.max_string_bytes > 256 * 1024 { - return invalid_cel("cel.max_string_bytes must be between 1 and 262144"); - } - if self.max_list_items == 0 || self.max_list_items > 100_000 { - return invalid_cel("cel.max_list_items must be between 1 and 100000"); - } - if self.max_object_depth == 0 || self.max_object_depth > 64 { - return invalid_cel("cel.max_object_depth must be between 1 and 64"); - } - if self.max_object_keys == 0 || self.max_object_keys > 2048 { - return invalid_cel("cel.max_object_keys must be between 1 and 2048"); - } - if self.worker_memory_bytes < 32 * 1024 * 1024 - || self.worker_memory_bytes > 1024 * 1024 * 1024 - { - return invalid_cel("cel.worker_memory_bytes must be between 33554432 and 1073741824"); - } - if self.worker_stderr_bytes == 0 || self.worker_stderr_bytes > 64 * 1024 { - return invalid_cel("cel.worker_stderr_bytes must be between 1 and 65536"); - } - Ok(()) - } -} - -pub(super) fn registry_notary_cel_config_is_default(config: &RegistryNotaryCelConfig) -> bool { - config == &RegistryNotaryCelConfig::default() -} - -pub(super) fn invalid_cel(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidCelConfig { - reason: reason.into(), - }) -} - -pub(super) fn default_cel_mode() -> String { - "worker".to_string() -} - -pub(super) const fn default_cel_worker_count() -> usize { - 2 -} - -pub(super) const fn default_cel_eval_timeout_ms() -> u64 { - 2_000 -} - -pub(super) const fn default_cel_max_expression_bytes() -> usize { - 8 * 1024 -} - -pub(super) const fn default_cel_max_binding_json_bytes() -> usize { - 64 * 1024 -} - -pub(super) const fn default_cel_max_result_json_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_cel_max_string_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_cel_max_list_items() -> usize { - 1024 -} - -pub(super) const fn default_cel_max_object_depth() -> usize { - 16 -} - -pub(super) const fn default_cel_max_object_keys() -> usize { - 256 -} - -pub(super) const fn default_cel_worker_memory_bytes() -> u64 { - 128 * 1024 * 1024 -} - -pub(super) const fn default_cel_worker_stderr_bytes() -> usize { - 1024 -} diff --git a/crates/registry-notary-core/src/config/credential_status.rs b/crates/registry-notary-core/src/config/credential_status.rs deleted file mode 100644 index fab4a0de1..000000000 --- a/crates/registry-notary-core/src/config/credential_status.rs +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Credential status store configuration. - -use super::*; - -pub const CREDENTIAL_STATUS_VALID: &str = "valid"; -pub const CREDENTIAL_STATUS_SUSPENDED: &str = "suspended"; -pub const CREDENTIAL_STATUS_REVOKED: &str = "revoked"; -pub const CREDENTIAL_STATUS_EXPIRED: &str = "expired"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialStatusConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub base_url: String, - #[serde(default = "default_credential_status_retention_seconds")] - pub retention_seconds: u64, -} - -impl Default for CredentialStatusConfig { - fn default() -> Self { - Self { - enabled: false, - base_url: String::new(), - retention_seconds: default_credential_status_retention_seconds(), - } - } -} - -impl CredentialStatusConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - validate_credential_status_http_url("credential_status.base_url", &self.base_url)?; - if self.retention_seconds == 0 { - return invalid_credential_status( - "credential_status.retention_seconds must be greater than zero", - ); - } - Ok(()) - } -} - -pub(super) fn credential_status_config_is_default(config: &CredentialStatusConfig) -> bool { - config == &CredentialStatusConfig::default() -} - -pub(super) const fn default_credential_status_retention_seconds() -> u64 { - 86_400 -} - -pub(super) fn validate_credential_status_non_empty( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_credential_status(format!("{field} must not be empty")); - } - Ok(()) -} - -pub(super) fn validate_credential_status_http_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_credential_status_non_empty(field, value)?; - let Some(rest) = value - .strip_prefix("https://") - .or_else(|| value.strip_prefix("http://")) - else { - return invalid_credential_status(format!("{field} must be an HTTP or HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_credential_status(format!("{field} must include a valid host")); - } - Ok(()) -} - -pub(super) fn invalid_credential_status( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidCredentialStatusConfig { - reason: reason.into(), - }) -} diff --git a/crates/registry-notary-core/src/config/errors.rs b/crates/registry-notary-core/src/config/errors.rs deleted file mode 100644 index 5d6e8a4a5..000000000 --- a/crates/registry-notary-core/src/config/errors.rs +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Configuration validation errors. - -#[derive(Debug, thiserror::Error)] -pub enum EvidenceConfigError { - #[error("evidence.enabled must be true for the standalone Registry Notary")] - EvidenceDisabled, - #[error("at least one API key, bearer token, or OIDC authenticator must be configured")] - NoCredentialsConfigured, - #[error("invalid auth config: {reason}")] - InvalidAuthConfig { reason: String }, - #[error("invalid auth.oidc config: {reason}")] - InvalidOidcConfig { reason: String }, - #[error("invalid subject_access config: {reason}")] - InvalidSubjectAccessConfig { reason: String }, - #[error("invalid oid4vci config: {reason}")] - InvalidOid4vciConfig { reason: String }, - #[error("invalid auth.access_token_signing config: {reason}")] - InvalidAccessTokenSigningConfig { reason: String }, - #[error("invalid state config: {reason}")] - InvalidStateConfig { reason: String }, - #[error("invalid credential status config: {reason}")] - InvalidCredentialStatusConfig { reason: String }, - #[error("invalid cel config: {reason}")] - InvalidCelConfig { reason: String }, - #[error("invalid federation config: {reason}")] - InvalidFederationConfig { reason: String }, - #[error("invalid server config: {reason}")] - InvalidServerConfig { reason: String }, - #[error("invalid config_trust config: {reason}")] - InvalidConfigTrustConfig { reason: String }, - #[error("invalid deployment config: {reason}")] - InvalidDeploymentConfig { reason: String }, - #[error( - "deployment.evidence.audit_ack_max_age_secs is set but deployment.evidence.audit_ack_cursor_path is not; a freshness window is meaningless with no cursor to read. Set audit_ack_cursor_path to the registry.audit.ack_cursor.v1 file the audit shipper maintains, or remove audit_ack_max_age_secs" - )] - AuditAckMaxAgeWithoutCursor, - #[error( - "deployment.evidence.audit_ack_cursor_path is set with a local file audit sink but deployment.evidence.audit_offhost_shipping is false; an ack cursor asserts observed off-host shipping that has not been declared. Set audit_offhost_shipping: true once shipping is in place, or remove audit_ack_cursor_path" - )] - AuditAckCursorWithoutShippingDeclared, - #[error("invalid evidence.relay config: {reason}")] - InvalidRelayConfig { reason: String }, - #[error("invalid evidence.variables config: {reason}")] - InvalidRequestVariableConfig { reason: String }, - #[error("claim id must not be empty")] - InvalidClaim, - /// REQ-DM-CLAIM-001 requires a claim's `id` to be unique across the - /// configuration; RS-DM-CLAIM Section 10 previously documented this as an - /// operator responsibility the loader did not enforce. - #[error("claim id '{claim}' is used by more than one claim; claim ids must be unique")] - DuplicateClaimId { claim: String }, - #[error("claim '{claim}' has invalid semantics config: {reason}")] - InvalidClaimSemantics { claim: String, reason: String }, - #[error("claim '{claim}' has invalid value config: {reason}")] - InvalidClaimValueConfig { claim: String, reason: String }, - #[error("claim '{claim}' has invalid evidence_mode: {reason}")] - InvalidClaimEvidenceMode { claim: String, reason: String }, - #[error("claim '{claim}' dependency closure exceeds v1 bounds ({nodes} nodes, {edges} edges)")] - ClaimDependencyGraphTooLarge { - claim: String, - nodes: usize, - edges: usize, - }, - /// REQ-DM-CLAIM-008 requires a claim's `disclosure.default` to be a - /// member of `disclosure.allowed`; RS-DM-CLAIM Section 10 previously - /// documented this as unchecked at load, surfacing only when a result - /// was rendered. - #[error( - "claim '{claim}' disclosure.default '{default}' is not a member of \ - disclosure.allowed ({allowed}); a claim's default disclosure mode must be one \ - it is permitted to render", - allowed = allowed.join(", ") - )] - ClaimDisclosureDefaultNotAllowed { - claim: String, - default: String, - allowed: Vec, - }, - #[error( - "claim '{claim}' formats must not be empty; omit formats to use the default \ - application/vnd.registry-notary.claim-result+json representation, or list one or more response formats" - )] - EmptyClaimFormats { claim: String }, - #[error( - "claim '{claim}' formats must include the canonical evaluation response format \ - application/vnd.registry-notary.claim-result+json; add it alongside any supported additional evaluation renderers" - )] - MissingCanonicalClaimFormat { claim: String }, - #[error( - "claim '{claim}' has unsupported evaluation response format '{format}' in formats; \ - supported formats are application/vnd.registry-notary.claim-result+json and \ - application/ld+json; profile=\"cccev\". SD-JWT VC belongs in credential_profiles, not claim formats" - )] - UnsupportedClaimFormat { claim: String, format: String }, - #[error("allowed purpose must not be empty")] - InvalidPurpose, - #[error("concurrency.subjects must be >= 1")] - InvalidConcurrency, - #[error("invalid evidence.machine_quota config: {reason}")] - InvalidMachineQuotaConfig { reason: String }, - #[error("invalid evidence batch config: {reason}")] - InvalidBatchConfig { reason: String }, - /// Credential holder binding only works with did:jwk because holder_jwk() - /// only implements did:jwk resolution. Restrict allowed_did_methods to - /// ["did:jwk"] or leave it empty when holder binding is disabled. - #[error( - "credential profile '{profile}': holder binding is only supported with did:jwk, \ - but allowed_did_methods contains unsupported method(s): {methods}; \ - restrict allowed_did_methods to [\"did:jwk\"]", - methods = methods.join(", ") - )] - UnsupportedCredentialProfileDidMethods { - profile: String, - methods: Vec, - }, - #[error("claim '{claim}' depends_on unknown claim '{unknown}'")] - DependsOnUnknownClaim { claim: String, unknown: String }, - #[error( - "depends_on cycle detected: {cycle}", - cycle = cycle.join(" -> ") - )] - DependsOnCycle { cycle: Vec }, - /// A credential profile with an empty `allowed_claims` would short-circuit - /// the issuance-time claim filter (api.rs treats empty as "all claims - /// allowed"). Reject at load time so operators must explicitly enumerate - /// the claims a profile may bind to. - #[error( - "credential profile '{profile}': allowed_claims must list at least one \ - claim; an empty list would permit any claim at issuance" - )] - EmptyAllowedClaims { profile: String }, - #[error("invalid credential claim binding: {reason}")] - InvalidCredentialClaimBinding { reason: String }, - /// Registry Notary currently issues only SD-JWT VC credentials using the - /// current `application/dc+sd-jwt` media type. Reject aliases and profile - /// labels so operator config cannot drift from the wire contract. - #[error( - "credential profile '{profile}': unsupported format '{format}'; \ - supported credential format is application/dc+sd-jwt" - )] - UnsupportedCredentialProfileFormat { profile: String, format: String }, - #[error("signing key '{key}' is invalid: {reason}")] - InvalidSigningKeyConfig { key: String, reason: String }, - #[error("credential profile '{profile}' references unknown signing key '{key}'")] - UnknownCredentialProfileSigningKey { profile: String, key: String }, - #[error("credential profile '{profile}' references non-active signing key '{key}'")] - CredentialProfileSigningKeyNotActive { profile: String, key: String }, - #[error( - "credential profile '{profile}' validity_seconds {validity_seconds} must be between 1 and {max_validity_seconds}" - )] - InvalidCredentialProfileValidity { - profile: String, - validity_seconds: i64, - max_validity_seconds: u64, - }, - #[error("credential profile '{profile}' issuer does not match signing key '{key}': {reason}")] - CredentialProfileSigningKeyIssuerMismatch { - profile: String, - key: String, - reason: String, - }, -} diff --git a/crates/registry-notary-core/src/config/evidence/claims.rs b/crates/registry-notary-core/src/config/evidence/claims.rs deleted file mode 100644 index 978e26432..000000000 --- a/crates/registry-notary-core/src/config/evidence/claims.rs +++ /dev/null @@ -1,1117 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Claim definitions, rules, and operation configuration. - -use std::borrow::Cow; - -use schemars::{Schema, SchemaGenerator}; - -use super::*; - -pub const MAX_CLAIM_DEPENDENCY_NODES_V1: usize = 64; -pub const MAX_CLAIM_DEPENDENCY_EDGES_V1: usize = 256; -pub const MAX_CLAIM_VALUE_STRING_BYTES_V1: u32 = 64 * 1024; -pub const MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1: usize = 8; -pub const MAX_RELAY_OUTPUT_SCHEMA_NODES_V1: usize = 256; -pub const MAX_RELAY_OUTPUT_EXPANDED_NODES_V1: usize = 4_096; -pub const MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1: usize = 32; -pub const MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1: u16 = 256; -pub const MAX_RELAY_OUTPUT_NAME_BYTES_V1: usize = 128; -pub const MAX_RELAY_OUTPUT_VALUE_BYTES_V1: u32 = 64 * 1024; - -// The platform decoder wraps consultation outputs in the Relay result root and -// its closed `outputs` object. The remaining fixed result fields consume 18 -// more nodes. Reserving all 20 fixed nodes here ensures an accepted authored -// contract cannot fail later when the complete result decoder is compiled. -const RELAY_OUTPUT_ROOT_DEPTH_V1: usize = 3; -const RELAY_RESULT_ENVELOPE_NODES_V1: usize = 20; - -fn default_claim_formats() -> Vec { - vec![FORMAT_CLAIM_RESULT_JSON.to_string()] -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimDefinition { - pub id: String, - pub title: String, - pub version: String, - pub subject_type: String, - /// Sealed registry provenance configuration. This field is intentionally - /// required so every claim names its compiler-pinned Relay consultation. - pub evidence_mode: ClaimEvidenceMode, - #[serde(default)] - pub value: ClaimValueConfig, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub semantics: Option, - #[serde(default)] - pub inputs: Vec, - #[serde(default)] - pub depends_on: Vec, - #[serde(default)] - pub purpose: Option, - /// Caller scopes checked before any registry consultation is dispatched. - #[serde(default)] - pub required_scopes: Vec, - pub rule: RuleConfig, - #[serde(default)] - pub operations: ClaimOperationsConfig, - #[serde(default)] - pub disclosure: DisclosureConfig, - /// Omitting this field keeps existing authored claims renderable using the - /// canonical claim-result representation. An explicitly empty list is - /// rejected during configuration validation. - #[serde(default = "default_claim_formats")] - pub formats: Vec, - #[serde(default)] - pub credential_profiles: Vec, - #[serde(default)] - pub cccev: Option, - #[serde(default)] - pub oots: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum ClaimEvidenceMode { - RegistryBacked { - consultations: BTreeMap, - }, - /// Uninhabited internal variant that keeps downstream destructuring - /// refutable without adding another wire-level evidence mode. - #[doc(hidden)] - #[serde(skip)] - Impossible { - impossible: std::convert::Infallible, - }, -} - -impl<'de> Deserialize<'de> for ClaimEvidenceMode { - fn deserialize(deserializer: Deserializer) -> Result - where - Deserializer: serde::Deserializer<'de>, - { - match SealedClaimEvidenceMode::deserialize(deserializer)? { - SealedClaimEvidenceMode::RegistryBacked { consultations } => { - Ok(Self::RegistryBacked { consultations }) - } - } - } -} - -/// The tagged configuration wire contract shared by deserialization and schema generation. -#[derive(Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum SealedClaimEvidenceMode { - RegistryBacked { - consultations: BTreeMap, - }, -} - -impl JsonSchema for ClaimEvidenceMode { - fn schema_name() -> Cow<'static, str> { - "ClaimEvidenceMode".into() - } - - fn json_schema(generator: &mut SchemaGenerator) -> Schema { - generator.subschema_for::() - } -} - -impl ClaimEvidenceMode { - #[must_use] - pub const fn name(&self) -> &'static str { - match self { - Self::RegistryBacked { .. } => "registry_backed", - Self::Impossible { impossible } => match *impossible {}, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConsultationConfig { - pub profile: RelayConsultationProfileRef, - #[schemars(with = "BTreeMap")] - pub inputs: BTreeMap, - /// Complete closed public output schema expected from the pinned profile. - #[serde(default)] - pub outputs: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConsultationProfileRef { - pub id: String, - pub contract_hash: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum RelayOutputContract { - Boolean { - #[serde(default)] - nullable: bool, - }, - Integer { - #[serde(default)] - nullable: bool, - minimum: i64, - maximum: i64, - }, - String { - #[serde(default)] - nullable: bool, - max_bytes: u32, - }, - Date { - #[serde(default)] - nullable: bool, - }, - Object { - #[serde(default)] - nullable: bool, - max_bytes: u32, - fields: BTreeMap, - }, - Array { - #[serde(default)] - nullable: bool, - max_bytes: u32, - max_items: u16, - items: Box, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayOutputObjectFieldContract { - pub required: bool, - pub schema: Box, -} - -impl RelayOutputContract { - #[must_use] - pub const fn nullable(&self) -> bool { - match self { - Self::Boolean { nullable } - | Self::Integer { nullable, .. } - | Self::String { nullable, .. } - | Self::Date { nullable } - | Self::Object { nullable, .. } - | Self::Array { nullable, .. } => *nullable, - } - } - - #[must_use] - pub const fn value_type(&self) -> &'static str { - match self { - Self::Boolean { .. } => "boolean", - Self::Integer { .. } => "integer", - Self::String { .. } => "string", - Self::Date { .. } => "date", - Self::Object { .. } => "object", - Self::Array { .. } => "array", - } - } - - #[must_use] - pub const fn is_scalar(&self) -> bool { - matches!( - self, - Self::Boolean { .. } | Self::Integer { .. } | Self::String { .. } | Self::Date { .. } - ) - } - - /// Validate one exact public Relay value without retaining a serialized - /// copy or exposing value-bearing error details. - #[must_use] - pub fn validates_value(&self, value: &serde_json::Value) -> bool { - if value.is_null() { - return self.nullable(); - } - match (self, value) { - (Self::Boolean { .. }, serde_json::Value::Bool(_)) => true, - ( - Self::Integer { - minimum, maximum, .. - }, - serde_json::Value::Number(value), - ) => exact_json_i64(value).is_some_and(|value| value >= *minimum && value <= *maximum), - (Self::String { max_bytes, .. }, serde_json::Value::String(value)) => { - value.len() <= *max_bytes as usize - } - (Self::Date { .. }, serde_json::Value::String(value)) => { - crate::is_rfc3339_full_date(value) - } - ( - Self::Object { - max_bytes, fields, .. - }, - serde_json::Value::Object(value), - ) => { - serialized_value_fits(value, *max_bytes) - && value.keys().all(|name| fields.contains_key(name)) - && fields.iter().all(|(name, field)| { - value - .get(name) - .is_some_and(|value| field.schema.validates_value(value)) - || !field.required && !value.contains_key(name) - }) - } - ( - Self::Array { - max_bytes, - max_items, - items, - .. - }, - serde_json::Value::Array(value), - ) => { - value.len() <= usize::from(*max_items) - && serialized_value_fits(value, *max_bytes) - && value.iter().all(|value| items.validates_value(value)) - } - _ => false, - } - } -} - -fn exact_json_i64(value: &serde_json::Number) -> Option { - value - .as_i64() - .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) -} - -fn serialized_value_fits(value: &T, max_bytes: u32) -> bool { - struct ByteLimitWriter { - remaining: usize, - } - - impl std::io::Write for ByteLimitWriter { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - if bytes.len() > self.remaining { - return Err(std::io::Error::other("serialized value exceeds its bound")); - } - self.remaining -= bytes.len(); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - serde_json::to_writer( - ByteLimitWriter { - remaining: max_bytes as usize, - }, - value, - ) - .is_ok() -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RequestVariableConfig { - pub from: String, - #[serde(rename = "type")] - pub value_type: RequestVariableType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RequestVariableType { - Date, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RelayConsultationInput { - TargetId, - TargetIdentifier(String), - TargetAttribute(String), - RequesterId, - RequesterIdentifier(String), -} - -impl RelayConsultationInput { - #[must_use] - pub fn request_path(&self) -> &str { - match self { - Self::TargetId => "target.id", - Self::TargetIdentifier(path) => path, - Self::TargetAttribute(path) => path, - Self::RequesterId => "request.requester.id", - Self::RequesterIdentifier(path) => path, - } - } - - #[must_use] - pub fn request_context_path(&self) -> &str { - self.request_path() - .strip_prefix("request.") - .unwrap_or(self.request_path()) - } - - #[must_use] - pub const fn is_requester_derived(&self) -> bool { - matches!(self, Self::RequesterId | Self::RequesterIdentifier(_)) - } - - #[must_use] - pub const fn is_target_derived(&self) -> bool { - matches!( - self, - Self::TargetId | Self::TargetIdentifier(_) | Self::TargetAttribute(_) - ) - } - - #[must_use] - pub const fn is_authenticated_target_identifier(&self) -> bool { - matches!(self, Self::TargetId | Self::TargetIdentifier(_)) - } -} - -impl Serialize for RelayConsultationInput { - fn serialize( - &self, - serializer: Serializer, - ) -> Result - where - Serializer: serde::Serializer, - { - serializer.serialize_str(self.request_path()) - } -} - -impl<'de> Deserialize<'de> for RelayConsultationInput { - fn deserialize(deserializer: Deserializer) -> Result - where - Deserializer: serde::Deserializer<'de>, - { - let mapping = String::deserialize(deserializer)?; - match mapping.as_str() { - "target.id" => Ok(Self::TargetId), - "request.requester.id" => Ok(Self::RequesterId), - _ if mapping - .strip_prefix("request.target.identifiers.") - .is_some_and(is_request_identifier_name) => - { - Ok(Self::TargetIdentifier(mapping)) - } - _ if mapping - .strip_prefix("request.target.attributes.") - .is_some_and(is_target_attribute_name) => - { - Ok(Self::TargetAttribute(mapping)) - } - _ if mapping - .strip_prefix("request.requester.identifiers.") - .is_some_and(is_request_identifier_name) => - { - Ok(Self::RequesterIdentifier(mapping)) - } - _ => Err(serde::de::Error::custom( - "unsupported consultation input mapping; v1 permits target.id, request.requester.id, request.target.identifiers., request.target.attributes., or request.requester.identifiers.", - )), - } - } -} - -pub(in crate::config) fn validate_claim_evidence_mode( - claim: &ClaimDefinition, - relay_configured: bool, -) -> Result<(), EvidenceConfigError> { - validate_claim_required_scopes(claim)?; - match &claim.evidence_mode { - ClaimEvidenceMode::RegistryBacked { consultations } => { - if !relay_configured { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires evidence.relay", - ); - } - if claim.purpose.as_deref().is_none_or(|purpose| { - purpose.is_empty() - || purpose.len() > 256 - || purpose.contains(',') - || purpose - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - }) { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires one explicit bounded purpose token", - ); - } - if claim.required_scopes.is_empty() { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires required_scopes to contain at least one entry", - ); - } - if consultations.len() != 1 { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires exactly one named consultation in v1", - ); - } - let (consultation_name, consultation) = consultations - .first_key_value() - .expect("exactly one consultation was checked above"); - validate_consultation(claim, consultation_name, consultation)?; - match &claim.rule { - RuleConfig::ConsultationOutput { - consultation: rule_consultation, - output, - } => { - if rule_consultation != consultation_name { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.consultation must match its consultation name", - ); - } - if !is_input_name(output) { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.output must be one top-level Relay output name", - ); - } - if let Some(output_config) = consultation.outputs.get(output) { - if claim.value.value_type != output_config.value_type() - || !claim.value.nullable - { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output claim value type must match its declared output and remain nullable for no_match", - ); - } - } else if consultation.outputs.is_empty() { - if claim.value.value_type != "string" { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output claim value.type must be string in v1 unless typed outputs are declared", - ); - } - } else { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.output must name a declared consultation output", - ); - } - } - RuleConfig::ConsultationMatched { - consultation: rule_consultation, - } => { - if rule_consultation != consultation_name { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_matched rule.consultation must match its consultation name", - ); - } - if claim.value.value_type != "boolean" { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_matched claim value.type must be boolean", - ); - } - } - RuleConfig::Cel { .. } => { - if consultation.outputs.is_empty() { - return invalid_claim_evidence_mode( - claim, - "registry_backed supports only consultation_matched and consultation_output rules in v1 unless a complete typed consultation output schema is declared", - ); - } - if !matches!( - claim.value.value_type.as_str(), - "boolean" | "integer" | "string" | "date" - ) { - return invalid_claim_evidence_mode( - claim, - "registry_backed CEL result type must be boolean, integer, string, or date; generic Number is not supported", - ); - } - } - } - } - ClaimEvidenceMode::Impossible { impossible } => match *impossible {}, - } - Ok(()) -} - -pub(in crate::config) fn validate_claim_value_config( - claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let Some(max_bytes) = claim.value.max_bytes else { - return Ok(()); - }; - if claim.value.value_type != "string" { - return Err(EvidenceConfigError::InvalidClaimValueConfig { - claim: claim.id.clone(), - reason: "value.max_bytes is allowed only when value.type is string".to_string(), - }); - } - if !(1..=MAX_CLAIM_VALUE_STRING_BYTES_V1).contains(&max_bytes) { - return Err(EvidenceConfigError::InvalidClaimValueConfig { - claim: claim.id.clone(), - reason: format!( - "value.max_bytes must be between 1 and {MAX_CLAIM_VALUE_STRING_BYTES_V1}" - ), - }); - } - Ok(()) -} - -pub(in crate::config) fn validate_relay_activation_shape( - claims: &[ClaimDefinition], -) -> Result<(), EvidenceConfigError> { - let mut outputs_by_client = BTreeMap::new(); - for claim in claims { - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - unreachable!("registry_backed is the only configured evidence mode"); - }; - let (_, consultation) = consultations - .first_key_value() - .expect("individual mode validation requires one consultation"); - let input_name = consultation - .inputs - .first_key_value() - .expect("individual mode validation requires one input") - .0; - let client_key = ( - consultation.profile.clone(), - claim - .purpose - .clone() - .expect("individual mode validation requires one purpose"), - input_name.clone(), - ); - let legacy_output = consultation - .outputs - .is_empty() - .then(|| match &claim.rule { - RuleConfig::ConsultationOutput { output, .. } => Some(output.clone()), - RuleConfig::ConsultationMatched { .. } => None, - RuleConfig::Cel { .. } => None, - }) - .flatten(); - match outputs_by_client.get_mut(&client_key) { - Some((expected_outputs, expected_legacy_output)) - if expected_outputs != &consultation.outputs => - { - return invalid_claim_evidence_mode( - claim, - "claims sharing one Relay profile, purpose, and input name must declare one identical result contract", - ); - } - Some((_, Some(expected))) - if legacy_output - .as_ref() - .is_some_and(|actual| actual != expected) => - { - return invalid_claim_evidence_mode( - claim, - "legacy claims sharing one Relay profile, purpose, and input name must select one shared string output", - ); - } - Some((_, expected @ None)) if legacy_output.is_some() => { - *expected = legacy_output; - } - None => { - outputs_by_client.insert(client_key, (consultation.outputs.clone(), legacy_output)); - } - Some(_) => {} - } - } - Ok(()) -} - -pub(in crate::config) fn validate_claim_dependency_bounds( - claims: &[ClaimDefinition], -) -> Result<(), EvidenceConfigError> { - if claims.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 { - return Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { - claim: "*".to_string(), - nodes: claims.len(), - edges: 0, - }); - } - for root in claims { - let mut pending = vec![root.id.as_str()]; - let mut visited = HashSet::new(); - let mut edges = 0usize; - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let Some(claim) = claims.iter().find(|candidate| candidate.id == claim_id) else { - continue; - }; - edges = edges.saturating_add(claim.depends_on.len()); - if visited.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 - || edges > MAX_CLAIM_DEPENDENCY_EDGES_V1 - { - return Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { - claim: root.id.clone(), - nodes: visited.len(), - edges, - }); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - } - Ok(()) -} - -fn validate_claim_required_scopes(claim: &ClaimDefinition) -> Result<(), EvidenceConfigError> { - if claim.required_scopes.len() > 16 { - return invalid_claim_evidence_mode( - claim, - "required_scopes cannot contain more than 16 entries", - ); - } - let mut seen = HashSet::new(); - for scope in &claim.required_scopes { - if scope.is_empty() - || scope.len() > 128 - || !scope - .bytes() - .all(|byte| matches!(byte, b'!' | b'#'..=b'[' | b']'..=b'~')) - { - return invalid_claim_evidence_mode( - claim, - "required_scopes entries must be bounded OAuth scope tokens", - ); - } - if !seen.insert(scope.as_str()) { - return invalid_claim_evidence_mode( - claim, - "required_scopes must not contain duplicate entries", - ); - } - } - Ok(()) -} - -fn validate_sha256_uri(value: &str) -> Result<(), &'static str> { - let Some(hex) = value.strip_prefix("sha256:") else { - return Err("must start with sha256:"); - }; - if hex.len() != 64 { - return Err("must contain 64 lowercase hex characters"); - } - if !hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err("must contain only lowercase hex characters"); - } - Ok(()) -} - -fn validate_consultation( - claim: &ClaimDefinition, - name: &str, - consultation: &RelayConsultationConfig, -) -> Result<(), EvidenceConfigError> { - if !is_stable_id(name) { - return invalid_claim_evidence_mode( - claim, - "consultation names must use the bounded Relay stable-id grammar", - ); - } - if !is_stable_id(&consultation.profile.id) { - return invalid_claim_evidence_mode( - claim, - "consultation profile.id must match [a-z][a-z0-9._-]{0,95}", - ); - } - validate_sha256_uri(&consultation.profile.contract_hash).map_err(|reason| { - EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: format!("consultation profile.contract_hash {reason}"), - } - })?; - if !(1..=16).contains(&consultation.inputs.len()) { - return invalid_claim_evidence_mode( - claim, - "consultation inputs must contain one to sixteen typed request mappings in v1", - ); - } - let mut request_paths = BTreeSet::new(); - for (input_name, input) in &consultation.inputs { - if !is_input_name(input_name) { - return invalid_claim_evidence_mode( - claim, - "consultation input names must match [a-z][a-z0-9_]{0,95}", - ); - } - if !request_paths.insert(input.request_path()) { - return invalid_claim_evidence_mode( - claim, - "consultation inputs must map injectively to request context paths", - ); - } - } - if !(1..=MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1).contains(&consultation.outputs.len()) { - return invalid_claim_evidence_mode( - claim, - "consultation outputs must contain one to 32 entries", - ); - } - let mut schema_nodes = RELAY_RESULT_ENVELOPE_NODES_V1; - let mut expanded_nodes = RELAY_RESULT_ENVELOPE_NODES_V1; - for (output_name, output) in &consultation.outputs { - if !is_input_name(output_name) || matches!(output_name.as_str(), "matched" | "outcome") { - return invalid_claim_evidence_mode( - claim, - "consultation output names must match [a-z][a-z0-9_]{0,95} and cannot be matched or outcome", - ); - } - let expanded = validate_relay_output_schema( - output, - RELAY_OUTPUT_ROOT_DEPTH_V1, - &mut schema_nodes, - ) - .ok_or_else(|| EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: "consultation output schema must be closed and remain within platform depth, field, item, node, name, numeric, and byte bounds".to_string(), - })?; - expanded_nodes = expanded_nodes.checked_add(expanded).ok_or_else(|| { - EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: "consultation output schema exceeds the platform expanded-node bound" - .to_string(), - } - })?; - if expanded_nodes > MAX_RELAY_OUTPUT_EXPANDED_NODES_V1 { - return invalid_claim_evidence_mode( - claim, - "consultation output schema exceeds the platform expanded-node bound", - ); - } - } - Ok(()) -} - -fn validate_relay_output_schema( - schema: &RelayOutputContract, - depth: usize, - nodes: &mut usize, -) -> Option { - *nodes = nodes.checked_add(1)?; - if depth > MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1 || *nodes > MAX_RELAY_OUTPUT_SCHEMA_NODES_V1 { - return None; - } - match schema { - RelayOutputContract::Boolean { .. } | RelayOutputContract::Date { .. } => Some(1), - RelayOutputContract::Integer { - minimum, maximum, .. - } if valid_json_integer_bounds(*minimum, *maximum) => Some(1), - RelayOutputContract::String { max_bytes, .. } - if (1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) => - { - Some(1) - } - RelayOutputContract::Object { - max_bytes, fields, .. - } => { - if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) - || !(1..=MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1).contains(&fields.len()) - { - return None; - } - let mut expanded = 1_usize; - for (name, field) in fields { - if !valid_relay_output_name(name) { - return None; - } - expanded = expanded.checked_add(validate_relay_output_schema( - &field.schema, - depth + 1, - nodes, - )?)?; - } - Some(expanded) - } - RelayOutputContract::Array { - max_bytes, - max_items, - items, - .. - } => { - if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) - || !(1..=MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1).contains(max_items) - { - return None; - } - validate_relay_output_schema(items, depth + 1, nodes)? - .checked_mul(usize::from(*max_items)) - .and_then(|expanded| expanded.checked_add(1)) - } - RelayOutputContract::Integer { .. } | RelayOutputContract::String { .. } => None, - } -} - -fn valid_json_integer_bounds(minimum: i64, maximum: i64) -> bool { - const MAX_SAFE_INTEGER: i64 = (1_i64 << 53) - 1; - minimum <= maximum && minimum >= -MAX_SAFE_INTEGER && maximum <= MAX_SAFE_INTEGER -} - -fn valid_relay_output_name(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_RELAY_OUTPUT_NAME_BYTES_V1 - && !value.chars().any(char::is_control) -} - -fn is_request_identifier_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| { - matches!( - byte, - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' - ) - }) -} - -fn is_target_attribute_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 64 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -fn is_stable_id(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_')) -} - -fn is_input_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -fn invalid_claim_evidence_mode( - claim: &ClaimDefinition, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: reason.into(), - }) -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimSemanticConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub concept: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub property: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vocabulary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub predicate: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub derived_from: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub value_mapping: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimValueConfig { - #[serde(rename = "type", default)] - pub value_type: String, - #[serde(default, skip_serializing_if = "is_false")] - pub nullable: bool, - /// Optional evaluated UTF-8 string byte ceiling. Absence preserves the - /// direct-configuration behavior that predates per-claim byte bounds. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 65536))] - pub max_bytes: Option, - #[serde(default)] - pub unit: Option, -} - -fn is_false(value: &bool) -> bool { - !*value -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimInputConfig { - pub name: String, - #[serde(rename = "type")] - pub input_type: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum RuleConfig { - ConsultationOutput { - consultation: String, - output: String, - }, - ConsultationMatched { - consultation: String, - }, - Cel { - expression: String, - }, -} - -pub(in crate::config) fn validate_claim_semantics( - claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let Some(semantics) = &claim.semantics else { - return Ok(()); - }; - let mut has_term = false; - for (field, value) in [ - ("concept", semantics.concept.as_deref()), - ("property", semantics.property.as_deref()), - ("vocabulary", semantics.vocabulary.as_deref()), - ("predicate", semantics.predicate.as_deref()), - ] { - let Some(value) = value else { - continue; - }; - has_term = true; - validate_semantic_reference(&claim.id, field, value)?; - } - for value in &semantics.derived_from { - has_term = true; - validate_semantic_reference(&claim.id, "derived_from", value)?; - } - if let Some(value_mapping) = semantics.value_mapping.as_deref() { - if value_mapping.trim().is_empty() { - return invalid_claim_semantics(&claim.id, "value_mapping must not be empty"); - } - } - if !has_term { - return invalid_claim_semantics( - &claim.id, - "at least one of concept, property, vocabulary, predicate, or derived_from must be set", - ); - } - if semantics.property.is_some() && semantics.predicate.is_some() { - return invalid_claim_semantics( - &claim.id, - "property and predicate are mutually exclusive; use derived_from for predicate inputs", - ); - } - Ok(()) -} - -pub(in crate::config) fn validate_semantic_reference( - claim_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - let value = value.trim(); - if value.is_empty() { - return invalid_claim_semantics(claim_id, format!("{field} must not be empty")); - } - if value.starts_with("https://") || value.starts_with("http://") || value.starts_with("urn:") { - return Ok(()); - } - invalid_claim_semantics( - claim_id, - format!("{field} must be an absolute http(s) URI or urn"), - ) -} - -pub(in crate::config) fn invalid_claim_semantics( - claim: &str, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidClaimSemantics { - claim: claim.to_string(), - reason: reason.into(), - }) -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimOperationsConfig { - #[serde(default = "default_enabled_operation")] - pub evaluate: OperationConfig, - #[serde(default)] - pub batch_evaluate: BatchOperationConfig, -} - -impl Default for ClaimOperationsConfig { - fn default() -> Self { - Self { - evaluate: OperationConfig { enabled: true }, - batch_evaluate: BatchOperationConfig::default(), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct OperationConfig { - #[serde(default)] - pub enabled: bool, -} - -pub(in crate::config) fn default_enabled_operation() -> OperationConfig { - OperationConfig { enabled: true } -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchOperationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_inline_batch_limit")] - #[schemars(range(min = 1, max = 100))] - pub max_subjects: usize, -} - -impl Default for BatchOperationConfig { - fn default() -> Self { - Self { - enabled: false, - max_subjects: default_inline_batch_limit(), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CccevConfig { - #[serde(default)] - pub requirement_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evidence_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evidence_type_iri: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct OotsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub requirement: Option, - #[serde(default)] - pub reference_framework: Option, - #[serde(default)] - pub evidence_type_classification: Option, - #[serde(default)] - pub evidence_type_list: Option, - #[serde(default)] - pub languages: Vec, - #[serde(default)] - pub authentication_level_of_assurance: Option, -} diff --git a/crates/registry-notary-core/src/config/evidence/disclosure.rs b/crates/registry-notary-core/src/config/evidence/disclosure.rs deleted file mode 100644 index f2ffd5ae2..000000000 --- a/crates/registry-notary-core/src/config/evidence/disclosure.rs +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Claim and credential disclosure configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DisclosureConfig { - #[serde(default = "default_disclosure_profile")] - pub default: String, - #[serde(default = "default_disclosure_allowed")] - pub allowed: Vec, - #[serde(default = "default_disclosure_downgrade")] - pub downgrade: String, -} - -impl Default for DisclosureConfig { - fn default() -> Self { - Self { - default: default_disclosure_profile(), - allowed: default_disclosure_allowed(), - downgrade: default_disclosure_downgrade(), - } - } -} - -pub(in crate::config) fn default_disclosure_profile() -> String { - "redacted".to_string() -} - -pub(in crate::config) fn default_disclosure_allowed() -> Vec { - vec!["redacted".to_string()] -} - -pub(in crate::config) fn default_disclosure_downgrade() -> String { - "deny".to_string() -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialDisclosureConfig { - #[serde(default)] - pub allowed: Vec, -} diff --git a/crates/registry-notary-core/src/config/evidence/limits.rs b/crates/registry-notary-core/src/config/evidence/limits.rs deleted file mode 100644 index 4632f83df..000000000 --- a/crates/registry-notary-core/src/config/evidence/limits.rs +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evaluation concurrency and machine quota configuration. - -use super::*; - -/// Per-request cap on concurrently evaluated subjects. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ConcurrencyConfig { - #[serde(default = "default_concurrency_subjects")] - pub subjects: usize, -} - -impl Default for ConcurrencyConfig { - fn default() -> Self { - Self { - subjects: default_concurrency_subjects(), - } - } -} - -impl ConcurrencyConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.subjects < 1 { - return Err(EvidenceConfigError::InvalidConcurrency); - } - Ok(()) - } -} - -const fn default_concurrency_subjects() -> usize { - 16 -} - -/// Per-principal quota for machine `evaluate`/`batch_evaluate` traffic. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MachineQuotaConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_machine_quota_subjects_per_minute")] - pub subjects_per_minute: u32, -} - -impl Default for MachineQuotaConfig { - fn default() -> Self { - Self { - enabled: false, - subjects_per_minute: default_machine_quota_subjects_per_minute(), - } - } -} - -impl MachineQuotaConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.enabled && self.subjects_per_minute == 0 { - return Err(EvidenceConfigError::InvalidMachineQuotaConfig { - reason: "subjects_per_minute must be greater than zero when enabled".to_string(), - }); - } - Ok(()) - } -} - -const fn default_machine_quota_subjects_per_minute() -> u32 { - 6000 -} diff --git a/crates/registry-notary-core/src/config/evidence/mod.rs b/crates/registry-notary-core/src/config/evidence/mod.rs deleted file mode 100644 index bda2e3d08..000000000 --- a/crates/registry-notary-core/src/config/evidence/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evidence, source, claim, disclosure, and signing configuration. - -use super::*; - -mod claims; -mod disclosure; -mod limits; -mod relay; -mod signing; - -pub use claims::*; -pub use disclosure::*; -pub use limits::*; -pub use relay::*; -pub use signing::*; - -/// Hard 1.0 platform ceiling for synchronous batch evaluation members. -/// -/// Operator settings may reduce this value, but never raise it. -pub const MAX_BATCH_EVALUATION_MEMBERS_V1: usize = 100; - -/// Registry Notary configuration. Disabled by default so existing -/// Registry Relay deployments load unchanged. -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_service_id")] - pub service_id: String, - #[serde(default = "default_api_version")] - pub api_version: String, - #[serde(default = "default_api_base_url")] - pub api_base_url: String, - #[serde(default = "default_claims_url")] - pub claims_url: String, - #[serde(default = "default_formats_url")] - pub formats_url: String, - #[serde(default = "default_inline_batch_limit")] - #[schemars(range(min = 1, max = 100))] - pub inline_batch_limit: usize, - #[serde(default = "default_max_credential_validity_seconds")] - pub max_credential_validity_seconds: u64, - #[serde(default)] - pub allowed_purposes: Vec, - /// Closed union of request variables declared by authored services. - #[serde(default)] - pub variables: BTreeMap, - #[serde(default)] - pub claims: Vec, - #[serde(default)] - pub signing_keys: BTreeMap, - #[serde(default)] - pub credential_profiles: BTreeMap, - /// The one Registry Relay connection available to registry-backed claims. - /// Authentication remains a reloadable local file reference; core never - /// loads the bearer token value. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay: Option, - /// Per-request cap for concurrently evaluated subjects. - #[serde(default)] - pub concurrency: ConcurrencyConfig, - /// Per-principal budget for machine `evaluate`/`batch_evaluate` traffic, - /// counted in subjects (a single evaluate consumes 1; a batch consumes - /// `items.len()`) over a fixed one-minute window. Disabled by default. - #[serde(default)] - pub machine_quota: MachineQuotaConfig, -} - -pub(in crate::config) const fn default_max_credential_validity_seconds() -> u64 { - 600 -} - -impl EvidenceConfig { - pub(in crate::config) fn validate_batch_limits(&self) -> Result<(), EvidenceConfigError> { - if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&self.inline_batch_limit) { - return Err(EvidenceConfigError::InvalidBatchConfig { - reason: format!( - "evidence.inline_batch_limit must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}" - ), - }); - } - for claim in &self.claims { - let max_subjects = claim.operations.batch_evaluate.max_subjects; - if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&max_subjects) { - return Err(EvidenceConfigError::InvalidBatchConfig { - reason: format!( - "claim '{}' operations.batch_evaluate.max_subjects must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}", - claim.id - ), - }); - } - } - Ok(()) - } - - pub(in crate::config) fn validate_signing_keys(&self) -> Result<(), EvidenceConfigError> { - let mut published_kids = HashSet::new(); - for (key_id, key) in &self.signing_keys { - validate_signing_key_id(key_id)?; - key.validate(key_id)?; - if key.status.may_publish() && !published_kids.insert(key.kid.as_str()) { - return Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.clone(), - reason: format!("duplicate published kid '{}'", key.kid), - }); - } - } - Ok(()) - } - - /// Validate resolved signing-capable key material after runtime providers - /// have loaded their public JWKs. Static config can compare ids and kids, - /// but only the resolved JWKs reveal whether different active entries reuse - /// the same key material under different ids or kids. - /// - /// The reuse comparison is confined to the separated EdDSA signing roles - /// issue #173 names: the access-token signing key and every credential - /// profile signing key, plus the federation signing key (the documented - /// separation boundary in `validate_signing_key_alg_usage` treats all three - /// as distinct EdDSA roles). `reuse_scoped_key_ids` carries exactly those - /// role keys. The eSignet pre-authorized-code RP client key is a separate, - /// relaxed role that is deliberately allowed to share material with the - /// credential issuer key, so callers must leave it out of - /// `reuse_scoped_key_ids`; resolved JWKs for keys outside the set are not - /// compared. - pub fn validate_resolved_signing_key_material<'a, I>( - &self, - resolved_public_jwks: I, - reuse_scoped_key_ids: &HashSet<&str>, - ) -> Result<(), EvidenceConfigError> - where - I: IntoIterator, - { - let mut thumbprints_by_key_id = BTreeMap::new(); - for (key_id, public_jwk) in resolved_public_jwks { - let Some(key) = self.signing_keys.get(key_id) else { - return invalid_signing_key( - key_id, - "resolved public JWK does not match a configured signing key", - ); - }; - if !key.status.may_sign() { - continue; - } - // Only the separated signing roles (#173) are compared against one - // another; keys outside that set (notably the eSignet RP client - // key) are allowed to reuse credential material by design. - if !reuse_scoped_key_ids.contains(key_id) { - continue; - } - let thumbprint = - public_jwk - .jkt() - .map_err(|_| EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: "resolved public JWK could not be thumbprinted".to_string(), - })?; - if let Some(previous_key_id) = - thumbprints_by_key_id.insert(thumbprint, key_id.to_string()) - { - return invalid_signing_key( - key_id, - format!("reuses public key material with signing key '{previous_key_id}'"), - ); - } - } - Ok(()) - } -} - -pub(in crate::config) fn default_service_id() -> String { - "registry-notary".to_string() -} - -pub(in crate::config) fn default_api_version() -> String { - "2026-05".to_string() -} - -pub(in crate::config) fn default_api_base_url() -> String { - "/".to_string() -} - -pub(in crate::config) fn default_claims_url() -> String { - "/v1/claims".to_string() -} - -pub(in crate::config) fn default_formats_url() -> String { - "/v1/formats".to_string() -} - -pub(in crate::config) const fn default_inline_batch_limit() -> usize { - MAX_BATCH_EVALUATION_MEMBERS_V1 -} diff --git a/crates/registry-notary-core/src/config/evidence/relay.rs b/crates/registry-notary-core/src/config/evidence/relay.rs deleted file mode 100644 index 859130269..000000000 --- a/crates/registry-notary-core/src/config/evidence/relay.rs +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Sealed Registry Relay connection configuration. - -use std::collections::BTreeSet; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::path::Path; - -use ipnet::IpNet; - -use super::*; - -const MAX_RELAY_BASE_URL_BYTES: usize = 2_048; -const MAX_RELAY_TOKEN_FILE_BYTES: usize = 4_096; -const MAX_RELAY_PRIVATE_CIDRS: usize = 16; -const MAX_RELAY_IN_FLIGHT: usize = 64; - -const fn default_relay_max_in_flight() -> usize { - 8 -} - -#[derive(Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConnectionConfig { - pub base_url: String, - pub workload_client_id: String, - pub token_file: PathBuf, - /// Optional private root bundle for the exact HTTPS Relay destination. - /// - /// The deployment must provide a bounded regular file owned by root or - /// the Notary effective user, readable by its owner, and inaccessible to - /// group and other users (0400 or 0600). - #[serde(default)] - pub root_certificate_path: Option, - #[serde(default)] - #[schemars(with = "Vec")] - pub allowed_private_cidrs: Vec, - #[serde(default)] - pub allow_insecure_localhost: bool, - /// Permit this signed Notary-to-Relay service hop to use application HTTP. - /// Runtime resolution remains confined to eligible private addresses. - #[serde(default)] - pub allow_insecure_private_network: bool, - #[serde(default = "default_relay_max_in_flight")] - pub max_in_flight: usize, -} - -impl std::fmt::Debug for RelayConnectionConfig { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RelayConnectionConfig") - .field("base_url", &"") - .field("token_file", &"") - .field( - "custom_root_certificate", - &self.root_certificate_path.is_some(), - ) - .field( - "allowed_private_cidr_count", - &self.allowed_private_cidrs.len(), - ) - .field("allow_insecure_localhost", &self.allow_insecure_localhost) - .field( - "allow_insecure_private_network", - &self.allow_insecure_private_network, - ) - .field("max_in_flight", &self.max_in_flight) - .finish() - } -} - -impl RelayConnectionConfig { - pub(in crate::config) fn validate( - &self, - deployment_profile: Option, - ) -> Result<(), EvidenceConfigError> { - if self.base_url.is_empty() - || self.base_url.len() > MAX_RELAY_BASE_URL_BYTES - || self.base_url.trim() != self.base_url - { - return invalid_relay("base_url must be a non-empty URL of bounded length"); - } - if !stable_workload_id(&self.workload_client_id) { - return invalid_relay( - "workload_client_id must be a stable lowercase workload identifier", - ); - } - let Some(origin) = parse_relay_origin(&self.base_url) else { - return invalid_relay( - "base_url must be an absolute HTTP(S) origin with path exactly / and no credentials, query, or fragment", - ); - }; - validate_private_cidrs(&self.allowed_private_cidrs)?; - if self.allow_insecure_localhost && self.allow_insecure_private_network { - return invalid_relay( - "allow_insecure_localhost and allow_insecure_private_network are mutually exclusive", - ); - } - if self.allow_insecure_private_network && origin.scheme() != "http" { - return invalid_relay("allow_insecure_private_network requires an HTTP base_url"); - } - match origin.scheme() { - "https" => {} - "http" if self.allow_insecure_localhost && is_loopback_origin(&origin) => {} - "http" - if self.allow_insecure_private_network - && matches!(origin.host(), Some(url::Host::Domain(_))) => {} - "http" - if deployment_profile == Some(crate::deployment::DeploymentProfile::Local) - && private_origin_has_exact_allowlist_entry( - &origin, - &self.allowed_private_cidrs, - ) => {} - "http" => { - return invalid_relay( - "base_url must use https unless an explicit loopback, private service, or local exact-private-IP HTTP profile applies", - ); - } - _ => return invalid_relay("base_url must use the http or https scheme"), - } - if !valid_token_file(&self.token_file) { - return invalid_relay("token_file must be a bounded absolute canonical file path"); - } - if self - .root_certificate_path - .as_ref() - .is_some_and(|path| !valid_token_file(path)) - { - return invalid_relay( - "root_certificate_path must be a bounded absolute canonical file path", - ); - } - if self.root_certificate_path.is_some() && origin.scheme() != "https" { - return invalid_relay("root_certificate_path requires an https base_url"); - } - if !(1..=MAX_RELAY_IN_FLIGHT).contains(&self.max_in_flight) { - return invalid_relay("max_in_flight must be between 1 and 64"); - } - Ok(()) - } - - #[must_use] - pub fn uses_insecure_url(&self) -> bool { - self.base_url.starts_with("http://") - } - - #[must_use] - pub fn uses_insecure_loopback_url(&self) -> bool { - parse_relay_origin(&self.base_url) - .is_some_and(|origin| origin.scheme() == "http" && is_loopback_origin(&origin)) - } - - #[must_use] - pub fn uses_insecure_private_network_url(&self) -> bool { - self.allow_insecure_private_network - && parse_relay_origin(&self.base_url).is_some_and(|origin| { - origin.scheme() == "http" && matches!(origin.host(), Some(url::Host::Domain(_))) - }) - } -} - -fn private_origin_has_exact_allowlist_entry(origin: &url::Url, cidrs: &[IpNet]) -> bool { - match origin.host() { - Some(url::Host::Ipv4(address)) => cidrs.iter().any(|cidr| { - matches!(cidr, IpNet::V4(cidr) if cidr.prefix_len() == 32 && cidr.network() == address) - }), - Some(url::Host::Ipv6(address)) => cidrs.iter().any(|cidr| { - matches!(cidr, IpNet::V6(cidr) if cidr.prefix_len() == 128 && cidr.network() == address) - }), - Some(url::Host::Domain(_)) | None => false, - } -} - -fn stable_workload_id(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-')) -} - -fn is_loopback_origin(origin: &url::Url) -> bool { - match origin.host() { - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - Some(url::Host::Domain(_)) | None => false, - } -} - -fn parse_relay_origin(value: &str) -> Option { - let (scheme, rest) = value.split_once("://")?; - if !matches!(scheme, "http" | "https") || rest.is_empty() || rest.contains(['?', '#']) { - return None; - } - // Check the raw shape before URL normalization so `/private/..` cannot - // normalize into the accepted root origin. - let authority = rest.strip_suffix('/').unwrap_or(rest); - if authority.is_empty() || authority.contains('/') { - return None; - } - let origin = url::Url::parse(value).ok()?; - (!origin.cannot_be_a_base() - && origin.host().is_some() - && origin.port() != Some(0) - && origin.username().is_empty() - && origin.password().is_none() - && origin.path() == "/" - && origin.query().is_none() - && origin.fragment().is_none()) - .then_some(origin) -} - -fn valid_token_file(path: &Path) -> bool { - let Some(text) = path.to_str() else { - return false; - }; - !text.is_empty() - && text.len() <= MAX_RELAY_TOKEN_FILE_BYTES - && text.starts_with('/') - && text != "/" - && !text.starts_with("//") - && !text.ends_with('/') - && !text.contains('\\') - && !text.bytes().any(|byte| byte.is_ascii_control()) - && !text - .split('/') - .skip(1) - .any(|component| component.is_empty() || matches!(component, "." | "..")) -} - -fn validate_private_cidrs(cidrs: &[IpNet]) -> Result<(), EvidenceConfigError> { - if cidrs.len() > MAX_RELAY_PRIVATE_CIDRS { - return invalid_relay("allowed_private_cidrs cannot contain more than 16 entries"); - } - let mut seen = BTreeSet::new(); - for cidr in cidrs { - if cidr.trunc() != *cidr - || !eligible_private_cidr(*cidr) - || metadata_singleton(*cidr) - || !seen.insert(*cidr) - { - return invalid_relay( - "allowed_private_cidrs must contain unique canonical RFC 1918, RFC 6598, or IPv6 ULA networks", - ); - } - } - Ok(()) -} - -fn eligible_private_cidr(cidr: IpNet) -> bool { - match cidr { - IpNet::V4(cidr) => { - let address = cidr.network(); - let prefix = cidr.prefix_len(); - (prefix >= 8 && ipv4_in_prefix(address, Ipv4Addr::new(10, 0, 0, 0), 8)) - || (prefix >= 12 && ipv4_in_prefix(address, Ipv4Addr::new(172, 16, 0, 0), 12)) - || (prefix >= 16 && ipv4_in_prefix(address, Ipv4Addr::new(192, 168, 0, 0), 16)) - || (prefix >= 10 && ipv4_in_prefix(address, Ipv4Addr::new(100, 64, 0, 0), 10)) - } - IpNet::V6(cidr) => { - cidr.prefix_len() >= 7 - && ipv6_in_prefix( - cidr.network(), - Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), - 7, - ) - } - } -} - -fn ipv4_in_prefix(address: Ipv4Addr, network: Ipv4Addr, prefix: u8) -> bool { - let mask = u32::MAX << (32 - prefix); - u32::from(address) & mask == u32::from(network) & mask -} - -fn ipv6_in_prefix(address: Ipv6Addr, network: Ipv6Addr, prefix: u8) -> bool { - let mask = u128::MAX << (128 - prefix); - u128::from(address) & mask == u128::from(network) & mask -} - -fn metadata_singleton(cidr: IpNet) -> bool { - match cidr { - IpNet::V4(cidr) if cidr.prefix_len() == 32 => { - IpAddr::V4(cidr.network()) == IpAddr::V4(Ipv4Addr::new(100, 100, 100, 200)) - } - IpNet::V6(cidr) if cidr.prefix_len() == 128 => { - IpAddr::V6(cidr.network()) - == IpAddr::V6(Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254)) - } - _ => false, - } -} - -fn invalid_relay(reason: &str) -> Result { - Err(EvidenceConfigError::InvalidRelayConfig { - reason: reason.to_string(), - }) -} diff --git a/crates/registry-notary-core/src/config/evidence/signing.rs b/crates/registry-notary-core/src/config/evidence/signing.rs deleted file mode 100644 index f0768d97c..000000000 --- a/crates/registry-notary-core/src/config/evidence/signing.rs +++ /dev/null @@ -1,323 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Credential profile and signing-key configuration. - -use super::*; - -pub(in crate::config) fn validate_credential_profile_validity( - profile_id: &str, - profile: &CredentialProfileConfig, - max_validity_seconds: u64, -) -> Result<(), EvidenceConfigError> { - if profile.validity_seconds <= 0 { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - }); - } - let validity_seconds = u64::try_from(profile.validity_seconds).map_err(|_| { - EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - } - })?; - if validity_seconds > max_validity_seconds { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - }); - } - Ok(()) -} - -pub fn signing_provider_uses_local_software_custody(provider: SigningKeyProviderConfig) -> bool { - matches!( - provider, - SigningKeyProviderConfig::LocalJwkEnv - | SigningKeyProviderConfig::FileWatch - | SigningKeyProviderConfig::LocalPkcs12File - ) -} - -pub fn signing_key_uses_local_software_custody(key: &SigningKeyConfig) -> bool { - key.status.may_sign() && signing_provider_uses_local_software_custody(key.provider) -} - -pub(in crate::config) fn validate_signing_key_id(key_id: &str) -> Result<(), EvidenceConfigError> { - if key_id.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: "signing key id must not be empty".to_string(), - }); - } - Ok(()) -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialProfileConfig { - pub format: String, - pub issuer: String, - pub signing_key: String, - pub vct: String, - #[serde(default = "default_credential_validity_seconds")] - pub validity_seconds: i64, - #[serde(default)] - pub holder_binding: HolderBindingConfig, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub disclosure: CredentialDisclosureConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SigningKeyConfig { - #[schemars(with = "schema::SigningKeyProviderSchema")] - pub provider: SigningKeyProviderConfig, - pub alg: String, - pub kid: String, - #[schemars(with = "schema::SigningKeyStatusSchema")] - pub status: SigningKeyStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub publish_until_unix_seconds: Option, - #[serde(default)] - pub private_jwk_env: String, - #[serde(default)] - pub public_jwk_env: String, - #[serde(default)] - pub module_path: String, - #[serde(default)] - pub token_label: String, - #[serde(default)] - pub pin_env: String, - #[serde(default)] - pub key_label: String, - #[serde(default)] - pub key_id_hex: String, - #[serde(default)] - pub path: String, - #[serde(default)] - pub password_env: String, -} - -impl SigningKeyConfig { - pub(super) fn validate(&self, key_id: &str) -> Result<(), EvidenceConfigError> { - validate_signing_key_non_empty(key_id, "alg", &self.alg)?; - if self.alg != CREDENTIAL_SIGNING_ALG_EDDSA - && self.alg != CREDENTIAL_SIGNING_ALG_ES256 - && self.alg != CLIENT_ASSERTION_SIGNING_ALG_RS256 - { - return invalid_signing_key( - key_id, - format!( - "alg must be {CREDENTIAL_SIGNING_ALG_EDDSA}, {CREDENTIAL_SIGNING_ALG_ES256}, or {CLIENT_ASSERTION_SIGNING_ALG_RS256}" - ), - ); - } - validate_signing_key_non_empty(key_id, "kid", &self.kid)?; - if self.publish_until_unix_seconds.is_some() - && !matches!(self.status, SigningKeyStatus::PublishOnly) - { - return invalid_signing_key( - key_id, - "publish_until_unix_seconds is valid only for publish_only signing keys", - ); - } - match self.provider { - SigningKeyProviderConfig::LocalJwkEnv => { - if self.status.may_sign() { - validate_signing_key_non_empty( - key_id, - "private_jwk_env", - &self.private_jwk_env, - )?; - } - if matches!(self.status, SigningKeyStatus::PublishOnly) { - validate_signing_key_non_empty(key_id, "public_jwk_env", &self.public_jwk_env)?; - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - } - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - validate_signing_key_absent(key_id, "path", &self.path)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::Pkcs11 => { - if self.alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_signing_key(key_id, "pkcs11 provider supports only EdDSA"); - } - if self.status.may_publish() { - validate_signing_key_non_empty(key_id, "public_jwk_env", &self.public_jwk_env)?; - } - if self.status.may_sign() { - validate_signing_key_non_empty(key_id, "module_path", &self.module_path)?; - if !std::path::Path::new(&self.module_path).is_absolute() { - return invalid_signing_key(key_id, "module_path must be absolute"); - } - validate_signing_key_non_empty(key_id, "token_label", &self.token_label)?; - validate_signing_key_non_empty(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_non_empty(key_id, "key_label", &self.key_label)?; - validate_signing_key_non_empty(key_id, "key_id_hex", &self.key_id_hex)?; - if !self.key_id_hex.len().is_multiple_of(2) - || !self.key_id_hex.chars().all(|ch| ch.is_ascii_hexdigit()) - { - return invalid_signing_key(key_id, "key_id_hex must be even-length hex"); - } - } - if matches!(self.status, SigningKeyStatus::PublishOnly) { - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - } - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - validate_signing_key_absent(key_id, "path", &self.path)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::FileWatch => { - if matches!(self.status, SigningKeyStatus::PublishOnly) { - return invalid_signing_key( - key_id, - "file_watch provider supports only active or disabled signing keys", - ); - } - if self.status.may_sign() { - validate_signing_key_non_empty(key_id, "path", &self.path)?; - } - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - validate_signing_key_absent(key_id, "public_jwk_env", &self.public_jwk_env)?; - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::LocalPkcs12File => { - invalid_signing_key( - key_id, - "local_pkcs12_file provider is intentionally not implemented yet", - )?; - } - _ => { - invalid_signing_key(key_id, "signing key provider is unsupported by this Notary")?; - } - } - Ok(()) - } - - pub fn may_publish_at(&self, now_unix_seconds: u64) -> bool { - if !self.status.may_publish() { - return false; - } - self.publish_until_unix_seconds - .is_none_or(|publish_until| now_unix_seconds <= publish_until) - } -} - -pub(in crate::config) fn validate_signing_key_non_empty( - key_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_signing_key(key_id, format!("{field} must not be empty")); - } - Ok(()) -} - -pub(in crate::config) fn validate_signing_key_absent( - key_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if !value.trim().is_empty() { - return invalid_signing_key( - key_id, - format!("{field} is not valid for this signing key provider"), - ); - } - Ok(()) -} - -pub(in crate::config) fn invalid_signing_key( - key_id: &str, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: reason.into(), - }) -} - -pub(in crate::config) fn validate_profile_signing_key_issuer_binding( - profile_id: &str, - profile: &CredentialProfileConfig, - key: &SigningKeyConfig, -) -> Result<(), EvidenceConfigError> { - if let Some(kid_did) = key - .kid - .split('#') - .next() - .filter(|did| did.starts_with("did:web:")) - { - if profile.issuer.starts_with("did:web:") && profile.issuer != kid_did { - return Err( - EvidenceConfigError::CredentialProfileSigningKeyIssuerMismatch { - profile: profile_id.to_string(), - key: profile.signing_key.clone(), - reason: "did:web issuer must match signing key kid DID".to_string(), - }, - ); - } - if profile.issuer.starts_with("https://") { - validate_did_web_https_issuer_binding(kid_did, &profile.issuer).map_err(|error| { - EvidenceConfigError::CredentialProfileSigningKeyIssuerMismatch { - profile: profile_id.to_string(), - key: profile.signing_key.clone(), - reason: error.to_string(), - } - })?; - } - } - Ok(()) -} - -pub(in crate::config) const fn default_credential_validity_seconds() -> i64 { - 600 -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct HolderBindingConfig { - #[serde(default = "default_holder_binding_mode")] - pub mode: String, - #[serde(default)] - pub proof_of_possession: Option, - #[serde(default = "default_holder_binding_allowed_did_methods")] - pub allowed_did_methods: Vec, -} - -impl Default for HolderBindingConfig { - fn default() -> Self { - Self { - mode: default_holder_binding_mode(), - proof_of_possession: None, - allowed_did_methods: default_holder_binding_allowed_did_methods(), - } - } -} - -pub(in crate::config) fn default_holder_binding_mode() -> String { - "did".to_string() -} - -pub(in crate::config) fn default_holder_binding_allowed_did_methods() -> Vec { - vec![SD_JWT_VC_HOLDER_BINDING_METHOD.to_string()] -} diff --git a/crates/registry-notary-core/src/config/federation.rs b/crates/registry-notary-core/src/config/federation.rs deleted file mode 100644 index 70375a07c..000000000 --- a/crates/registry-notary-core/src/config/federation.rs +++ /dev/null @@ -1,469 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Federated evaluation configuration. - -use super::*; - -pub const FEDERATION_PROTOCOL_V0_1: &str = "registry-notary-federation/v0.1"; -pub const FEDERATION_REQUEST_JWT_TYP: &str = "registry-notary-request+jwt"; -pub const FEDERATION_RESPONSE_JWT_TYP: &str = "registry-notary-response+jwt"; -pub const FEDERATION_SIGNING_ALG_EDDSA: &str = "EdDSA"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub node_id: String, - #[serde(default)] - pub issuer: String, - #[serde(default)] - pub jwks_uri: String, - #[serde(default)] - pub federation_api: String, - #[serde(default)] - pub supported_protocol_versions: Vec, - #[serde(default = "default_federation_inbound_body_limit_bytes")] - pub inbound_body_limit_bytes: usize, - #[serde(default = "default_federation_max_request_lifetime_seconds")] - pub max_request_lifetime_seconds: u64, - #[serde(default = "default_federation_clock_leeway_seconds")] - pub clock_leeway_seconds: u64, - #[serde(default)] - pub signing: FederationSigningConfig, - #[serde(default)] - pub pairwise_subject_hash: FederationPairwiseSubjectHashConfig, - #[serde(default)] - pub response_shaping: FederationResponseShapingConfig, - #[serde(default)] - pub emergency_denylist: FederationEmergencyDenylistConfig, - #[serde(default)] - pub peers: Vec, - #[serde(default)] - pub evaluation_profiles: Vec, -} - -impl Default for FederationConfig { - fn default() -> Self { - Self { - enabled: false, - node_id: String::new(), - issuer: String::new(), - jwks_uri: String::new(), - federation_api: String::new(), - supported_protocol_versions: Vec::new(), - inbound_body_limit_bytes: default_federation_inbound_body_limit_bytes(), - max_request_lifetime_seconds: default_federation_max_request_lifetime_seconds(), - clock_leeway_seconds: default_federation_clock_leeway_seconds(), - signing: FederationSigningConfig::default(), - pairwise_subject_hash: FederationPairwiseSubjectHashConfig::default(), - response_shaping: FederationResponseShapingConfig::default(), - emergency_denylist: FederationEmergencyDenylistConfig::default(), - peers: Vec::new(), - evaluation_profiles: Vec::new(), - } - } -} - -pub(super) fn federation_config_is_default(config: &FederationConfig) -> bool { - config == &FederationConfig::default() -} - -pub(super) const fn default_federation_inbound_body_limit_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_federation_max_request_lifetime_seconds() -> u64 { - 300 -} - -pub(super) const fn default_federation_clock_leeway_seconds() -> u64 { - 60 -} - -impl FederationConfig { - pub(super) fn validate(&self, evidence: &EvidenceConfig) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - validate_federation_non_empty("federation.node_id", &self.node_id)?; - validate_federation_non_empty("federation.issuer", &self.issuer)?; - validate_federation_https_url("federation.issuer", &self.issuer)?; - validate_federation_https_url("federation.jwks_uri", &self.jwks_uri)?; - validate_federation_https_url("federation.federation_api", &self.federation_api)?; - validate_did_web_https_issuer_binding(&self.node_id, &self.issuer).map_err(|error| { - EvidenceConfigError::InvalidFederationConfig { - reason: format!("federation.node_id must bind to federation.issuer: {error}"), - } - })?; - if !self - .supported_protocol_versions - .iter() - .any(|version| version == FEDERATION_PROTOCOL_V0_1) - { - return invalid_federation("federation.supported_protocol_versions must include registry-notary-federation/v0.1"); - } - if self.inbound_body_limit_bytes == 0 { - return invalid_federation( - "federation.inbound_body_limit_bytes must be greater than zero", - ); - } - if self.max_request_lifetime_seconds == 0 { - return invalid_federation( - "federation.max_request_lifetime_seconds must be greater than zero", - ); - } - validate_federation_non_empty("federation.signing.signing_key", &self.signing.signing_key)?; - let signing_key = evidence - .signing_keys - .get(self.signing.signing_key.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation.signing.signing_key references unknown signing key '{}'", - self.signing.signing_key - ), - })?; - if !signing_key.status.may_sign() { - return invalid_federation( - "federation.signing.signing_key must reference an active signing key", - ); - } - validate_federation_non_empty( - "federation.pairwise_subject_hash.secret_env", - &self.pairwise_subject_hash.secret_env, - )?; - if self.peers.is_empty() { - return invalid_federation("federation.peers must list at least one peer"); - } - if self.evaluation_profiles.is_empty() { - return invalid_federation( - "federation.evaluation_profiles must list at least one profile", - ); - } - let mut profile_ids = HashSet::new(); - for profile in &self.evaluation_profiles { - validate_federation_non_empty("federation.evaluation_profiles[].id", &profile.id)?; - if !profile_ids.insert(profile.id.as_str()) { - return invalid_federation("federation.evaluation_profiles contains duplicate id"); - } - validate_federation_non_empty( - "federation.evaluation_profiles[].ruleset", - &profile.ruleset, - )?; - validate_federation_non_empty( - "federation.evaluation_profiles[].claim_id", - &profile.claim_id, - )?; - validate_federation_non_empty( - "federation.evaluation_profiles[].subject_id_type", - &profile.subject_id_type, - )?; - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == profile.claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: - "federation.evaluation_profiles[].claim_id must reference an evidence claim" - .to_string(), - })?; - validate_federation_claim_inputs(evidence, profile, claim)?; - if let Some(disclosure) = profile.disclosure.as_deref() { - if DisclosureProfile::parse(disclosure).is_none() { - return invalid_federation( - "federation.evaluation_profiles[].disclosure must be value, predicate, or redacted", - ); - } - } - } - let mut peer_nodes = HashSet::new(); - for peer in &self.peers { - validate_federation_non_empty("federation.peers[].node_id", &peer.node_id)?; - validate_federation_non_empty("federation.peers[].issuer", &peer.issuer)?; - validate_federation_https_url("federation.peers[].issuer", &peer.issuer)?; - if peer.allow_insecure_private_network { - validate_federation_http_or_https_url( - "federation.peers[].jwks_uri", - &peer.jwks_uri, - )?; - } else if peer.allow_insecure_localhost { - validate_federation_localhost_or_https_url( - "federation.peers[].jwks_uri", - &peer.jwks_uri, - )?; - } else { - validate_federation_https_url("federation.peers[].jwks_uri", &peer.jwks_uri)?; - } - validate_did_web_https_issuer_binding(&peer.node_id, &peer.issuer).map_err( - |error| EvidenceConfigError::InvalidFederationConfig { - reason: format!("federation.peers[].node_id must bind to issuer: {error}"), - }, - )?; - if !peer_nodes.insert(peer.node_id.as_str()) { - return invalid_federation("federation.peers contains duplicate node_id"); - } - if !peer - .allowed_protocol_versions - .iter() - .any(|version| version == FEDERATION_PROTOCOL_V0_1) - { - return invalid_federation( - "federation.peers[].allowed_protocol_versions must include registry-notary-federation/v0.1", - ); - } - for purpose in &peer.allowed_purposes { - validate_federation_https_url("federation.peers[].allowed_purposes[]", purpose)?; - } - for profile in &peer.allowed_profiles { - let profile_config = self - .evaluation_profiles - .iter() - .find(|candidate| candidate.id == profile.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: "federation.peers[].allowed_profiles must reference an evaluation profile" - .to_string(), - })?; - validate_federation_peer_profile_scopes(evidence, peer, profile_config)?; - } - } - Ok(()) - } -} - -fn validate_federation_claim_inputs( - evidence: &EvidenceConfig, - profile: &FederationEvaluationProfileConfig, - root: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let expected_path = format!("request.target.identifiers.{}", profile.subject_id_type); - let mut pending = vec![root.id.as_str()]; - let mut visited = HashSet::new(); - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation evaluation profile '{}' references an incomplete claim dependency closure", - profile.id - ), - })?; - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return invalid_federation(format!( - "federation evaluation profile '{}' must select only registry_backed claims", - profile.id - )); - }; - if consultations - .values() - .flat_map(|consultation| consultation.inputs.values()) - .any(|input| { - !matches!( - input, - RelayConsultationInput::TargetIdentifier(path) if path == &expected_path - ) - }) - { - return invalid_federation(format!( - "federation evaluation profile '{}' claim '{}' Relay inputs must derive from {}", - profile.id, claim.id, expected_path - )); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - Ok(()) -} - -fn validate_federation_peer_profile_scopes( - evidence: &EvidenceConfig, - peer: &FederationPeerConfig, - profile: &FederationEvaluationProfileConfig, -) -> Result<(), EvidenceConfigError> { - let available_scopes: HashSet<&str> = - peer.evaluation_scopes.iter().map(String::as_str).collect(); - let mut pending = vec![profile.claim_id.as_str()]; - let mut visited = HashSet::new(); - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation evaluation profile '{}' references an incomplete claim dependency closure", - profile.id - ), - })?; - if let Some(scope) = claim - .required_scopes - .iter() - .find(|scope| !available_scopes.contains(scope.as_str())) - { - return invalid_federation(format!( - "federation peer '{}' evaluation_scopes must include required scope '{}' for profile '{}' claim '{}'", - peer.node_id, scope, profile.id, claim.id - )); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - Ok(()) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationSigningConfig { - pub signing_key: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationPairwiseSubjectHashConfig { - #[serde(default)] - pub secret_env: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationResponseShapingConfig { - #[serde(default = "default_minimum_denial_latency_ms")] - pub minimum_denial_latency_ms: u64, -} - -impl Default for FederationResponseShapingConfig { - fn default() -> Self { - Self { - minimum_denial_latency_ms: default_minimum_denial_latency_ms(), - } - } -} - -pub(super) const fn default_minimum_denial_latency_ms() -> u64 { - 250 -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationEmergencyDenylistConfig { - #[serde(default)] - pub node_ids: Vec, - #[serde(default)] - pub kids: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationPeerConfig { - pub node_id: String, - pub issuer: String, - pub jwks_uri: String, - #[serde(default)] - pub allow_insecure_localhost: bool, - #[serde(default)] - pub allow_insecure_private_network: bool, - #[serde(default)] - pub allowed_protocol_versions: Vec, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_profiles: Vec, - #[serde(default)] - pub evaluation_scopes: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationEvaluationProfileConfig { - pub id: String, - pub ruleset: String, - pub claim_id: String, - pub subject_id_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disclosure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_claim_result_age_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub legal_basis_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consent_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance_level: Option, -} - -pub(super) fn invalid_federation(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidFederationConfig { - reason: reason.into(), - }) -} - -pub(super) fn validate_federation_non_empty( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_federation(format!("{field} must not be empty")); - } - Ok(()) -} - -pub(super) fn validate_federation_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_federation_non_empty(field, value)?; - let Some(rest) = value.strip_prefix("https://") else { - return invalid_federation(format!("{field} must be an HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_federation(format!("{field} must include a valid host")); - } - Ok(()) -} - -pub(super) fn validate_federation_localhost_or_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.starts_with("https://") { - return validate_federation_https_url(field, value); - } - let Some(rest) = value.strip_prefix("http://") else { - return invalid_federation(format!("{field} must be HTTPS or localhost HTTP")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.starts_with("127.0.0.1:") - || host == "127.0.0.1" - || host.starts_with("localhost:") - || host == "localhost" - { - Ok(()) - } else { - invalid_federation(format!("{field} permits HTTP only for localhost")) - } -} - -pub(super) fn validate_federation_http_or_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_federation_non_empty(field, value)?; - let Some(rest) = value - .strip_prefix("https://") - .or_else(|| value.strip_prefix("http://")) - else { - return invalid_federation(format!("{field} must be an HTTP or HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_federation(format!("{field} must include a valid host")); - } - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/http.rs b/crates/registry-notary-core/src/config/http.rs deleted file mode 100644 index 557a47ada..000000000 --- a/crates/registry-notary-core/src/config/http.rs +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! HTTP listener configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryHttpConfig { - #[serde(default = "default_bind_addr")] - #[schemars(with = "schema::SocketAddrSchema")] - pub bind: SocketAddr, - #[serde( - default = "default_openapi_requires_auth", - skip_serializing_if = "openapi_requires_auth_is_default" - )] - pub openapi_requires_auth: bool, - #[serde(default, skip_serializing_if = "admin_listener_config_is_default")] - pub admin_listener: RegistryNotaryAdminListenerConfig, - #[serde(default)] - pub cors: RegistryNotaryCorsConfig, - #[serde(default = "default_request_timeout", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub request_timeout: Duration, - #[serde(default = "default_request_body_timeout", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub request_body_timeout: Duration, - #[serde( - default = "default_http1_header_read_timeout", - with = "humantime_serde" - )] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub http1_header_read_timeout: Duration, - #[serde(default = "default_max_connections")] - pub max_connections: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub trusted_proxy_ips: Vec, -} - -impl Default for RegistryNotaryHttpConfig { - fn default() -> Self { - Self { - bind: default_bind_addr(), - openapi_requires_auth: default_openapi_requires_auth(), - admin_listener: RegistryNotaryAdminListenerConfig::default(), - cors: RegistryNotaryCorsConfig::default(), - request_timeout: default_request_timeout(), - request_body_timeout: default_request_body_timeout(), - http1_header_read_timeout: default_http1_header_read_timeout(), - max_connections: default_max_connections(), - trusted_proxy_ips: Vec::new(), - } - } -} - -impl RegistryNotaryHttpConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.request_timeout.is_zero() - || self.request_body_timeout.is_zero() - || self.http1_header_read_timeout.is_zero() - || self.max_connections == 0 - { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: - "server timeouts must be non-zero and max_connections must be greater than zero" - .to_string(), - }); - } - Ok(()) - } -} - -pub(super) fn default_bind_addr() -> SocketAddr { - // SAFETY: the literal is a valid loopback socket address. - "127.0.0.1:8081" - .parse() - .expect("default bind address is valid") -} - -pub(super) fn default_openapi_requires_auth() -> bool { - true -} - -pub(super) fn openapi_requires_auth_is_default(value: &bool) -> bool { - *value == default_openapi_requires_auth() -} - -pub(super) fn default_request_timeout() -> Duration { - Duration::from_secs(30) -} - -pub(super) fn default_request_body_timeout() -> Duration { - Duration::from_secs(10) -} - -pub(super) fn default_http1_header_read_timeout() -> Duration { - Duration::from_secs(10) -} - -pub(super) fn default_max_connections() -> usize { - 1024 -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RegistryNotaryAdminListenerMode { - SharedWithPublic, - Dedicated, - #[default] - Disabled, -} - -impl RegistryNotaryAdminListenerMode { - pub fn as_str(self) -> &'static str { - match self { - Self::SharedWithPublic => "shared_with_public", - Self::Dedicated => "dedicated", - Self::Disabled => "disabled", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryAdminListenerConfig { - #[serde(default, skip_serializing_if = "admin_listener_mode_is_default")] - pub mode: RegistryNotaryAdminListenerMode, - #[serde(default = "default_admin_bind_addr")] - #[schemars(with = "schema::SocketAddrSchema")] - pub bind: SocketAddr, -} - -impl RegistryNotaryAdminListenerConfig { - pub(super) fn validate( - &self, - public_bind: SocketAddr, - governed_config_enabled: bool, - ) -> Result<(), EvidenceConfigError> { - if governed_config_enabled && self.mode != RegistryNotaryAdminListenerMode::Dedicated { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: "config_trust requires server.admin_listener.mode = dedicated".to_string(), - }); - } - if self.mode == RegistryNotaryAdminListenerMode::Dedicated && self.bind == public_bind { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: "server.admin_listener.bind must differ from server.bind in dedicated mode" - .to_string(), - }); - } - Ok(()) - } -} - -impl Default for RegistryNotaryAdminListenerConfig { - fn default() -> Self { - Self { - mode: RegistryNotaryAdminListenerMode::Disabled, - bind: default_admin_bind_addr(), - } - } -} - -pub(super) fn default_admin_bind_addr() -> SocketAddr { - // SAFETY: the literal is a valid loopback socket address. - "127.0.0.1:8082" - .parse() - .expect("default admin bind address is valid") -} - -pub(super) fn admin_listener_config_is_default(config: &RegistryNotaryAdminListenerConfig) -> bool { - config == &RegistryNotaryAdminListenerConfig::default() -} - -pub(super) fn admin_listener_mode_is_default(mode: &RegistryNotaryAdminListenerMode) -> bool { - mode == &RegistryNotaryAdminListenerMode::default() -} diff --git a/crates/registry-notary-core/src/config/oid4vci.rs b/crates/registry-notary-core/src/config/oid4vci.rs deleted file mode 100644 index f569744fb..000000000 --- a/crates/registry-notary-core/src/config/oid4vci.rs +++ /dev/null @@ -1,1340 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI issuer configuration. - -use super::*; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub credential_issuer: String, - #[serde(default)] - pub authorization_servers: Vec, - #[serde(default)] - pub accepted_token_audiences: Vec, - #[serde(default)] - pub credential_endpoint: String, - #[serde(default)] - pub offer_endpoint: String, - #[serde(default)] - pub nonce_endpoint: Option, - #[serde(default)] - pub nonce: Oid4vciNonceConfig, - #[serde(default)] - pub authorization: Oid4vciAuthorizationConfig, - #[serde(default)] - pub proof: Oid4vciProofConfig, - /// Issuer-initiated pre-authorized-code flow settings. This is the only - /// wallet-facing issuance grant in the 1.0 profile. - #[serde(default)] - pub pre_authorized_code: Oid4vciPreAuthorizedCodeConfig, - #[serde(default)] - pub display: Vec, - #[serde(default)] - pub credential_configurations: BTreeMap, -} - -pub(super) fn oid4vci_config_is_default(config: &Oid4vciConfig) -> bool { - config == &Oid4vciConfig::default() -} - -impl Oid4vciConfig { - pub(super) fn validate( - &self, - subject_access: &SubjectAccessConfig, - evidence: &EvidenceConfig, - credential_status: &CredentialStatusConfig, - ) -> Result<(), EvidenceConfigError> { - // The pre-authorized-code block is validated regardless of the oid4vci - // enable toggle so a partially-configured flow is rejected, but the - // flow is an OID4VCI grant and so requires oid4vci itself enabled. - if self.pre_authorized_code.enabled && !self.enabled { - return invalid_oid4vci( - "pre_authorized_code.enabled = true requires oid4vci.enabled = true", - ); - } - self.pre_authorized_code.validate()?; - // The eSignet RP client assertion is signed with this key, so it must - // resolve to an active signing key. Surface that at config time rather - // than as a startup failure when the pre-auth flow is first built. - if self.pre_authorized_code.enabled { - let key_id = self - .pre_authorized_code - .esignet - .client_signing_key_id - .as_str(); - let key = evidence.signing_keys.get(key_id).ok_or_else(|| { - EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "pre_authorized_code.esignet.client_signing_key_id '{key_id}' must reference an evidence.signing_keys entry" - ), - } - })?; - if !key.status.may_sign() { - return invalid_oid4vci(format!( - "pre_authorized_code.esignet.client_signing_key_id '{key_id}' must reference an active signing key" - )); - } - } - // The pre-auth callback resolves the subject-binding claim from the - // eSignet userinfo endpoint when the claim is userinfo-sourced, so the - // endpoint must be configured for that path to work. - if self.pre_authorized_code.enabled - && subject_access.subject_binding.claim_source == SubjectAccessClaimSource::Userinfo - && self - .pre_authorized_code - .esignet - .userinfo_url - .trim() - .is_empty() - { - return invalid_oid4vci( - "pre_authorized_code.esignet.userinfo_url must be set when subject_access.subject_binding.claim_source = userinfo", - ); - } - if self.pre_authorized_code.enabled - && self.pre_authorized_code.tx_code.required - && subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute - == 0 - { - return invalid_oid4vci( - "subject_access.rate_limits.tx_code_attempts_per_code_per_minute must be greater than zero when pre_authorized_code.enabled = true and tx_code.required = true", - ); - } - if !self.enabled { - return Ok(()); - } - if !self.pre_authorized_code.enabled { - return invalid_oid4vci( - "enabled oid4vci requires pre_authorized_code.enabled = true; wallet-facing authorization_code issuance is not supported in 1.0", - ); - } - if !subject_access.enabled { - return invalid_oid4vci("enabled oid4vci requires subject_access.enabled = true"); - } - validate_oid4vci_public_url("oid4vci.credential_issuer", &self.credential_issuer)?; - validate_oid4vci_endpoint_url( - "oid4vci.credential_endpoint", - &self.credential_endpoint, - &self.credential_issuer, - )?; - if !self.offer_endpoint.trim().is_empty() { - validate_oid4vci_endpoint_url( - "oid4vci.offer_endpoint", - &self.offer_endpoint, - &self.credential_issuer, - )?; - } - validate_oid4vci_non_empty_entries( - "oid4vci.authorization_servers", - &self.authorization_servers, - )?; - for server in &self.authorization_servers { - validate_oid4vci_public_url("oid4vci.authorization_servers", server)?; - } - validate_oid4vci_non_empty_entries( - "oid4vci.accepted_token_audiences", - &self.accepted_token_audiences, - )?; - if self.credential_configurations.is_empty() { - return invalid_oid4vci("credential_configurations must not be empty"); - } - if !self.nonce.enabled { - return invalid_oid4vci( - "oid4vci.nonce.enabled must be true for the transaction-scoped token nonce", - ); - } - if self.nonce_endpoint.is_some() { - return invalid_oid4vci( - "oid4vci.nonce_endpoint must be omitted; 1.0 has no public unbound nonce endpoint", - ); - } - self.nonce.validate()?; - self.authorization.validate()?; - self.proof.validate()?; - for display in &self.display { - display.validate("oid4vci.display")?; - } - - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let allowed_claim_ids: HashSet<&str> = subject_access - .allowed_claims - .iter() - .map(String::as_str) - .collect(); - let allowed_profiles: HashSet<&str> = subject_access - .credential_profiles - .iter() - .map(String::as_str) - .collect(); - - let mut configured_vcts = HashSet::new(); - let validation = Oid4vciCredentialValidationContext { - credential_issuer: &self.credential_issuer, - subject_access, - evidence, - credential_status, - claim_ids: &claim_ids, - allowed_claim_ids: &allowed_claim_ids, - allowed_profiles: &allowed_profiles, - }; - for (configuration_id, configuration) in &self.credential_configurations { - configuration.validate(configuration_id, &validation)?; - if !configured_vcts.insert(configuration.vct.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct must be unique" - )); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciNonceConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_oid4vci_nonce_ttl_seconds")] - pub ttl_seconds: u64, -} - -impl Default for Oid4vciNonceConfig { - fn default() -> Self { - Self { - enabled: false, - ttl_seconds: default_oid4vci_nonce_ttl_seconds(), - } - } -} - -impl Oid4vciNonceConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.ttl_seconds == 0 || self.ttl_seconds > 600 { - return invalid_oid4vci("nonce.ttl_seconds must be between 1 and 600"); - } - Ok(()) - } -} - -pub(super) const fn default_oid4vci_nonce_ttl_seconds() -> u64 { - 300 -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciAuthorizationConfig { - #[serde(default = "default_oid4vci_pkce_method")] - pub require_pkce_method: String, -} - -impl Default for Oid4vciAuthorizationConfig { - fn default() -> Self { - Self { - require_pkce_method: default_oid4vci_pkce_method(), - } - } -} - -impl Oid4vciAuthorizationConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.require_pkce_method != PKCE_METHOD_S256 { - return invalid_oid4vci("authorization.require_pkce_method must be S256"); - } - Ok(()) - } -} - -pub(super) fn default_oid4vci_pkce_method() -> String { - PKCE_METHOD_S256.to_string() -} - -/// Pre-authorized-code flow configuration. -/// -/// All fields default so existing configs that omit this block load unchanged -/// with the flow disabled. When `enabled`, the eSignet RP login settings, the -/// callback redirect, and the TTLs become required (validated cross-block). -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciPreAuthorizedCodeConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub tx_code: Oid4vciTxCodeConfig, - /// eSignet RP settings for the citizen login leg. - #[serde(default)] - pub esignet: Oid4vciEsignetRpConfig, - /// Pre-authorized-code lifetime in seconds. - #[serde(default = "default_pre_authorized_code_ttl_seconds")] - pub pre_authorized_code_ttl_seconds: u64, -} - -impl Default for Oid4vciPreAuthorizedCodeConfig { - fn default() -> Self { - Self { - enabled: false, - tx_code: Oid4vciTxCodeConfig::default(), - esignet: Oid4vciEsignetRpConfig::default(), - pre_authorized_code_ttl_seconds: default_pre_authorized_code_ttl_seconds(), - } - } -} - -impl Oid4vciPreAuthorizedCodeConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - self.tx_code.validate()?; - self.esignet.validate()?; - if self.pre_authorized_code_ttl_seconds == 0 || self.pre_authorized_code_ttl_seconds > 600 { - return invalid_oid4vci( - "pre_authorized_code.pre_authorized_code_ttl_seconds must be between 1 and 600", - ); - } - if !self.tx_code.required - && self.pre_authorized_code_ttl_seconds > MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS - { - return invalid_oid4vci( - "pre_authorized_code.pre_authorized_code_ttl_seconds must be between 1 and 300 when pre_authorized_code.tx_code.required = false", - ); - } - Ok(()) - } -} - -pub const MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS: u64 = 300; - -pub(super) const fn default_pre_authorized_code_ttl_seconds() -> u64 { - 300 -} - -/// `tx_code` (PIN) policy for the pre-authorized-code grant. A `tx_code` is -/// required by default because a code without a PIN is a bearer credential. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciTxCodeConfig { - #[serde(default = "default_tx_code_required")] - pub required: bool, - #[serde(default = "default_tx_code_input_mode")] - pub input_mode: String, - #[serde(default = "default_tx_code_length")] - pub length: u64, -} - -impl Default for Oid4vciTxCodeConfig { - fn default() -> Self { - Self { - required: default_tx_code_required(), - input_mode: default_tx_code_input_mode(), - length: default_tx_code_length(), - } - } -} - -impl Oid4vciTxCodeConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.required { - return Ok(()); - } - if self.input_mode != TX_CODE_INPUT_MODE_NUMERIC { - return invalid_oid4vci("pre_authorized_code.tx_code.input_mode must be numeric"); - } - if !(4..=12).contains(&self.length) { - return invalid_oid4vci("pre_authorized_code.tx_code.length must be between 4 and 12"); - } - Ok(()) - } -} - -pub(super) const fn default_tx_code_required() -> bool { - true -} - -pub(super) fn default_tx_code_input_mode() -> String { - TX_CODE_INPUT_MODE_NUMERIC.to_string() -} - -pub(super) const fn default_tx_code_length() -> u64 { - 6 -} - -/// eSignet relying-party settings for the citizen login leg of the -/// pre-authorized-code flow. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciEsignetRpConfig { - /// Confidential client id the Notary presents to eSignet. - #[serde(default)] - pub client_id: String, - /// `evidence.signing_keys` entry used to sign the eSignet - /// `private_key_jwt` client assertion. - #[serde(default)] - pub client_signing_key_id: String, - /// Notary callback the citizen browser is redirected back to. - #[serde(default)] - pub redirect_uri: String, - /// eSignet authorize endpoint. - #[serde(default)] - pub authorize_url: String, - /// eSignet token endpoint. - #[serde(default)] - pub token_url: String, - /// eSignet OIDC issuer, pinned when validating the returned `id_token`. - #[serde(default)] - pub issuer: String, - /// eSignet JWKS URI, used to resolve the `id_token` signing key by `kid`. - #[serde(default)] - pub jwks_uri: String, - /// eSignet userinfo endpoint. Required when the subject-binding claim is - /// sourced from userinfo rather than the `id_token`; the callback fetches - /// the userinfo JWS with the eSignet access token and reads the binding - /// claim from it. - #[serde(default)] - pub userinfo_url: String, - /// OAuth scopes requested at eSignet. - #[serde(default)] - pub scopes: Vec, - /// Lifetime of the short-lived login state (PKCE verifier + nonce + - /// selection) reserved between `offer/start` and `offer/callback`. - #[serde(default = "default_login_state_ttl_seconds")] - pub login_state_ttl_seconds: u64, - /// Allow `http` loopback URLs for the eSignet endpoints and JWKS transport. - /// For local development and tests only. - #[serde(default)] - pub allow_insecure_localhost: bool, -} - -impl Oid4vciEsignetRpConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.client_id.trim().is_empty() { - return invalid_oid4vci("pre_authorized_code.esignet.client_id must not be empty"); - } - if self.client_signing_key_id.trim().is_empty() { - return invalid_oid4vci( - "pre_authorized_code.esignet.client_signing_key_id must not be empty", - ); - } - validate_oid4vci_public_url( - "pre_authorized_code.esignet.redirect_uri", - &self.redirect_uri, - )?; - validate_oid4vci_public_url( - "pre_authorized_code.esignet.authorize_url", - &self.authorize_url, - )?; - validate_oid4vci_public_url("pre_authorized_code.esignet.token_url", &self.token_url)?; - validate_oid4vci_public_url("pre_authorized_code.esignet.issuer", &self.issuer)?; - validate_oid4vci_public_url("pre_authorized_code.esignet.jwks_uri", &self.jwks_uri)?; - if !self.userinfo_url.trim().is_empty() { - validate_oid4vci_public_url( - "pre_authorized_code.esignet.userinfo_url", - &self.userinfo_url, - )?; - } - validate_oid4vci_non_empty_entries("pre_authorized_code.esignet.scopes", &self.scopes)?; - if self.login_state_ttl_seconds == 0 || self.login_state_ttl_seconds > 600 { - return invalid_oid4vci( - "pre_authorized_code.esignet.login_state_ttl_seconds must be between 1 and 600", - ); - } - Ok(()) - } -} - -pub(super) const fn default_login_state_ttl_seconds() -> u64 { - 300 -} - -const TX_CODE_INPUT_MODE_NUMERIC: &str = "numeric"; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciProofConfig { - #[serde(default = "default_oid4vci_proof_max_age_seconds")] - pub max_age_seconds: u64, - #[serde(default = "default_oid4vci_proof_max_clock_skew_seconds")] - pub max_clock_skew_seconds: u64, -} - -impl Default for Oid4vciProofConfig { - fn default() -> Self { - Self { - max_age_seconds: default_oid4vci_proof_max_age_seconds(), - max_clock_skew_seconds: default_oid4vci_proof_max_clock_skew_seconds(), - } - } -} - -impl Oid4vciProofConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.max_age_seconds == 0 || self.max_age_seconds > 600 { - return invalid_oid4vci("proof.max_age_seconds must be between 1 and 600"); - } - if self.max_clock_skew_seconds > 60 { - return invalid_oid4vci("proof.max_clock_skew_seconds must be at most 60"); - } - Ok(()) - } -} - -pub(super) const fn default_oid4vci_proof_max_age_seconds() -> u64 { - 300 -} - -pub(super) const fn default_oid4vci_proof_max_clock_skew_seconds() -> u64 { - 60 -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialConfigurationConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claim_id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claims: Vec, - pub credential_profile: String, - pub format: String, - pub scope: String, - pub vct: String, - pub display_name: String, - #[serde(default)] - pub display: Oid4vciCredentialDisplayConfig, - #[serde(default = "default_oid4vci_proof_signing_alg_values_supported")] - pub proof_signing_alg_values_supported: Vec, - #[serde(default = "default_oid4vci_cryptographic_binding_methods_supported")] - pub cryptographic_binding_methods_supported: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub representative_issuance: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciRepresentativeIssuanceConfig { - pub ceremony: Oid4vciRepresentativeIssuanceCeremony, - pub relationship: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Oid4vciRepresentativeIssuanceCeremony { - DigitallyAuthenticatedRepresentative, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialClaimConfig { - pub id: String, - pub output_path: Vec, - pub display_name: String, - pub sd: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Oid4vciCredentialClaimMode<'a> { - LegacyClaimWrapper { - claim_id: &'a str, - }, - FieldProjection { - entries: &'a [Oid4vciCredentialClaimConfig], - }, -} - -impl Oid4vciCredentialClaimMode<'_> { - #[must_use] - pub fn is_field_projection(&self) -> bool { - matches!(self, Self::FieldProjection { .. }) - } -} - -impl Oid4vciCredentialConfigurationConfig { - #[must_use] - pub fn credential_claim_mode(&self) -> Oid4vciCredentialClaimMode<'_> { - if let Some(claim_id) = self.claim_id.as_deref() { - Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id } - } else { - Oid4vciCredentialClaimMode::FieldProjection { - entries: &self.claims, - } - } - } - - #[must_use] - pub fn credential_claim_ids(&self) -> Vec { - match self.credential_claim_mode() { - Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id } => { - vec![claim_id.to_string()] - } - Oid4vciCredentialClaimMode::FieldProjection { entries } => { - entries.iter().map(|entry| entry.id.clone()).collect() - } - } - } - - fn validate( - &self, - configuration_id: &str, - context: &Oid4vciCredentialValidationContext<'_>, - ) -> Result<(), EvidenceConfigError> { - let Oid4vciCredentialValidationContext { - credential_issuer, - subject_access, - evidence, - credential_status, - claim_ids, - allowed_claim_ids, - allowed_profiles, - } = context; - if configuration_id.trim().is_empty() { - return invalid_oid4vci("credential_configurations must not contain a blank id"); - } - let claim_mode = self.validate_claim_mode(configuration_id)?; - validate_oid4vci_non_empty_value( - "credential_configurations.credential_profile", - &self.credential_profile, - )?; - validate_oid4vci_non_empty_value("credential_configurations.scope", &self.scope)?; - validate_oid4vci_non_empty_value( - "credential_configurations.display_name", - &self.display_name, - )?; - self.display.validate("credential_configurations.display")?; - let profile = evidence - .credential_profiles - .get(&self.credential_profile) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' references unknown credential profile '{}'", - self.credential_profile - ), - })?; - if !allowed_profiles.contains(self.credential_profile.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references credential profile '{}' outside subject_access.credential_profiles", - self.credential_profile - )); - } - if self.format != OID4VCI_SD_JWT_VC_FORMAT { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' format must be dc+sd-jwt" - )); - } - if profile.format != FORMAT_SD_JWT_VC { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references credential profile '{}' with unsupported format '{}'", - self.credential_profile, profile.format - )); - } - let mut projection_purpose = None; - for claim_id in self.credential_claim_ids() { - let claim = validate_oid4vci_credential_claim_reference( - configuration_id, - &claim_id, - &self.credential_profile, - evidence, - profile, - claim_ids, - self.representative_issuance - .is_none() - .then_some(*allowed_claim_ids), - )?; - if claim_mode.is_field_projection() { - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' field projection claim '{claim_id}' must define purpose" - ), - } - })?; - if let Some(previous) = projection_purpose { - if previous != purpose { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' field projection claims must share one purpose" - )); - } - } else { - projection_purpose = Some(purpose); - } - if claim.disclosure.default != "value" { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' field projection claim '{claim_id}' must use value as the default disclosure" - )); - } - } - } - if self.vct != profile.vct { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct must match credential profile '{}'", - self.credential_profile - )); - } - validate_oid4vci_public_url("credential_configurations.vct", &self.vct)?; - validate_oid4vci_endpoint_url( - "credential_configurations.vct", - &self.vct, - credential_issuer, - )?; - let Some((_, _, vct_path)) = split_absolute_url(&self.vct) else { - return invalid_oid4vci("credential_configurations.vct must be an absolute URL"); - }; - let Some(expected_vct_prefix) = oid4vci_credentials_path_prefix(credential_issuer) else { - return invalid_oid4vci("oid4vci.credential_issuer must be an absolute URL"); - }; - if !vct_path.starts_with(&expected_vct_prefix) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct path must start with {expected_vct_prefix}" - )); - } - validate_oid4vci_non_empty_entries( - "credential_configurations.proof_signing_alg_values_supported", - &self.proof_signing_alg_values_supported, - )?; - for alg in &self.proof_signing_alg_values_supported { - if alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' supports unsupported proof signing algorithm '{alg}'" - )); - } - } - validate_oid4vci_non_empty_entries( - "credential_configurations.cryptographic_binding_methods_supported", - &self.cryptographic_binding_methods_supported, - )?; - for method in &self.cryptographic_binding_methods_supported { - if method != CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' supports unsupported binding method '{method}'" - )); - } - } - self.validate_representative_issuance( - configuration_id, - subject_access, - evidence, - credential_status, - )?; - Ok(()) - } - - fn validate_representative_issuance( - &self, - configuration_id: &str, - subject_access: &SubjectAccessConfig, - evidence: &EvidenceConfig, - credential_status: &CredentialStatusConfig, - ) -> Result<(), EvidenceConfigError> { - let Some(representative) = self.representative_issuance.as_ref() else { - return Ok(()); - }; - if !subject_access.delegation.enabled { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.delegation.enabled = true" - )); - } - if !subject_access.allowed_operations.evaluate { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.allowed_operations.evaluate = true" - )); - } - if !subject_access.allowed_operations.issue_credential { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.allowed_operations.issue_credential = true" - )); - } - if !credential_status.enabled { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires credential_status.enabled = true" - )); - } - if representative.relationship.trim().is_empty() { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance.relationship must not be empty" - )); - } - let relationship = subject_access - .delegation - .relationship(&representative.relationship) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown relationship '{}'", - representative.relationship - ), - })?; - let claim_ids = self.credential_claim_ids(); - let [claim_id] = claim_ids.as_slice() else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires exactly one credential claim root" - )); - }; - if subject_access - .allowed_claims - .iter() - .any(|allowed| allowed == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' must not appear in subject_access.allowed_claims; representative roots are delegated-only" - )); - } - if !relationship - .allowed_claims - .iter() - .any(|allowed| allowed == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' is not allowed by relationship '{}'", - relationship.relationship_type - )); - } - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown claim '{claim_id}'" - ), - })?; - if !claim - .depends_on - .iter() - .any(|dependency| dependency == &relationship.proof_claim) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' must depend_on proof claim '{}'", - relationship.proof_claim - )); - } - let requester_id_type = subject_access.subject_binding.id_type.as_str(); - let target_id_type = relationship - .target_id_type - .as_deref() - .unwrap_or(requester_id_type); - let requester_path = format!("requester.identifiers.{requester_id_type}"); - let target_path = format!("target.identifiers.{target_id_type}"); - validate_representative_credential_closure_inputs( - configuration_id, - claim, - &relationship.proof_claim, - evidence, - &requester_path, - &target_path, - )?; - let proof_claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == relationship.proof_claim) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown proof claim '{}'", - relationship.proof_claim - ), - })?; - let ClaimEvidenceMode::RegistryBacked { consultations } = &proof_claim.evidence_mode else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' must be registry_backed", - relationship.proof_claim - )); - }; - let Some((_, consultation)) = consultations - .first_key_value() - .filter(|_| consultations.len() == 1) - else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' must declare exactly one Relay consultation", - relationship.proof_claim - )); - }; - let expected_inputs = BTreeSet::from([requester_path, target_path]); - let actual_inputs = consultation - .inputs - .values() - .map(|input| input.request_context_path().to_string()) - .collect::>(); - if actual_inputs != expected_inputs { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' consultation must map exactly the authenticated requester identifier and selected target identifier", - relationship.proof_claim - )); - } - Ok(()) - } - - fn validate_claim_mode( - &self, - configuration_id: &str, - ) -> Result, EvidenceConfigError> { - match (self.claim_id.as_deref(), self.claims.is_empty()) { - (Some(claim_id), true) => { - validate_oid4vci_non_empty_value("credential_configurations.claim_id", claim_id)?; - Ok(Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id }) - } - (None, false) => { - validate_oid4vci_projection_claims(configuration_id, &self.claims)?; - Ok(Oid4vciCredentialClaimMode::FieldProjection { - entries: &self.claims, - }) - } - (Some(_), false) => invalid_oid4vci(format!( - "credential configuration '{configuration_id}' must set exactly one of claim_id or claims" - )), - (None, true) => invalid_oid4vci(format!( - "credential configuration '{configuration_id}' must set exactly one of claim_id or claims" - )), - } - } -} - -fn validate_representative_credential_closure_inputs( - configuration_id: &str, - root: &ClaimDefinition, - proof_claim_id: &str, - evidence: &EvidenceConfig, - requester_path: &str, - target_path: &str, -) -> Result<(), EvidenceConfigError> { - let mut pending = vec![root]; - let mut visited = BTreeSet::new(); - while let Some(claim) = pending.pop() { - if !visited.insert(claim.id.as_str()) || claim.id == proof_claim_id { - continue; - } - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' must be registry_backed", - claim.id - )); - }; - for (consultation_name, consultation) in consultations { - let mut consumes_target = false; - for (input_name, input) in &consultation.inputs { - let path = input.request_context_path(); - if path != requester_path && path != target_path { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' consultation '{consultation_name}' input '{input_name}' maps '{path}' outside the representative ceremony; expected '{requester_path}' or '{target_path}'", - claim.id - )); - } - consumes_target |= path == target_path; - } - if !consumes_target { - let actual_inputs = consultation - .inputs - .iter() - .map(|(input_name, input)| { - format!("'{input_name}' maps '{}'", input.request_context_path()) - }) - .collect::>() - .join(", "); - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' consultation '{consultation_name}' does not consume the selected target; {actual_inputs}; required target path is '{target_path}' and the only additional allowed canonical path is '{requester_path}'", - claim.id - )); - } - } - for dependency_id in &claim.depends_on { - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == *dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' dependency closure references unknown claim '{dependency_id}'", - root.id - ), - })?; - pending.push(dependency); - } - } - Ok(()) -} - -pub(super) fn validate_oid4vci_projection_claims( - configuration_id: &str, - claims: &[Oid4vciCredentialClaimConfig], -) -> Result<(), EvidenceConfigError> { - let mut ids = BTreeSet::new(); - let mut paths = BTreeSet::new(); - for claim in claims { - validate_oid4vci_non_empty_value("credential_configurations.claims[].id", &claim.id)?; - if !ids.insert(claim.id.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' contains duplicate claims[].id" - )); - } - validate_oid4vci_non_empty_value( - "credential_configurations.claims[].display_name", - &claim.display_name, - )?; - validate_oid4vci_non_empty_value("credential_configurations.claims[].sd", &claim.sd)?; - if claim.sd != "always" { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].sd must be always" - )); - } - if claim.output_path.is_empty() { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path must not be empty" - )); - } - if claim.output_path.len() != 1 { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path must be a single segment in V1" - )); - } - let segment = &claim.output_path[0]; - validate_oid4vci_non_empty_value( - "credential_configurations.claims[].output_path", - segment, - )?; - if is_reserved_oid4vci_projection_output_name(segment) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path uses reserved claim name '{segment}'" - )); - } - if !paths.insert(segment.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' contains duplicate claims[].output_path" - )); - } - } - Ok(()) -} - -struct Oid4vciCredentialValidationContext<'a> { - credential_issuer: &'a str, - subject_access: &'a SubjectAccessConfig, - evidence: &'a EvidenceConfig, - credential_status: &'a CredentialStatusConfig, - claim_ids: &'a HashSet<&'a str>, - allowed_claim_ids: &'a HashSet<&'a str>, - allowed_profiles: &'a HashSet<&'a str>, -} - -pub(super) fn validate_oid4vci_credential_claim_reference<'a>( - configuration_id: &str, - claim_id: &str, - credential_profile_id: &str, - evidence: &'a EvidenceConfig, - profile: &CredentialProfileConfig, - claim_ids: &HashSet<&str>, - allowed_claim_ids: Option<&HashSet<&str>>, -) -> Result<&'a ClaimDefinition, EvidenceConfigError> { - if !claim_ids.contains(claim_id) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references unknown claim '{claim_id}'" - )); - } - if allowed_claim_ids.is_some_and(|allowed| !allowed.contains(claim_id)) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references claim '{claim_id}' outside subject_access.allowed_claims" - )); - } - if !profile - .allowed_claims - .iter() - .any(|allowed_claim_id| allowed_claim_id == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' maps claim '{claim_id}' to credential profile '{credential_profile_id}' but the profile does not allow that claim" - )); - } - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' references unknown claim '{claim_id}'" - ), - })?; - if !claim - .credential_profiles - .iter() - .any(|profile_id| profile_id == credential_profile_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' maps claim '{claim_id}' to credential profile '{credential_profile_id}' but the claim does not reference that profile" - )); - } - Ok(claim) -} - -pub(super) fn is_reserved_oid4vci_projection_output_name(value: &str) -> bool { - const RESERVED: [&str; 17] = [ - "iss", - "sub", - "aud", - "iat", - "nbf", - "exp", - "vct", - "vct#integrity", - "id", - "jti", - "_sd", - "_sd_alg", - "cnf", - "status", - "issuanceDate", - "expirationDate", - "credential_configuration_id", - ]; - RESERVED.contains(&value) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciIssuerDisplayConfig { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub locale: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, -} - -impl Oid4vciIssuerDisplayConfig { - fn validate(&self, name: &str) -> Result<(), EvidenceConfigError> { - validate_oid4vci_non_empty_value(&format!("{name}.name"), &self.name)?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.locale"), - self.locale.as_deref(), - )?; - validate_oid4vci_display_image(&format!("{name}.logo"), &self.logo) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialDisplayConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub locale: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub text_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub secondary_image: Option, -} - -impl Oid4vciCredentialDisplayConfig { - fn validate(&self, name: &str) -> Result<(), EvidenceConfigError> { - validate_optional_oid4vci_non_empty_value( - &format!("{name}.locale"), - self.locale.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.description"), - self.description.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.background_color"), - self.background_color.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.text_color"), - self.text_color.as_deref(), - )?; - validate_oid4vci_display_image(&format!("{name}.logo"), &self.logo)?; - validate_oid4vci_display_image( - &format!("{name}.background_image"), - &self.background_image, - )?; - validate_oid4vci_display_image(&format!("{name}.secondary_image"), &self.secondary_image) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciDisplayImageConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub uri: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub alt_text: Option, -} - -pub(super) fn default_oid4vci_proof_signing_alg_values_supported() -> Vec { - vec![CREDENTIAL_SIGNING_ALG_EDDSA.to_string()] -} - -pub(super) fn default_oid4vci_cryptographic_binding_methods_supported() -> Vec { - vec![CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK.to_string()] -} - -pub(super) fn validate_oid4vci_public_url( - name: &str, - url: &str, -) -> Result<(), EvidenceConfigError> { - let url = url.trim(); - let (scheme, authority, _) = - split_absolute_url(url).ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!("{name} must be an absolute URL"), - })?; - if scheme != "https" && !(scheme == "http" && is_insecure_localhost_url(url)) { - return invalid_oid4vci(format!( - "{name} must use https unless it is an http loopback URL" - )); - } - if authority.is_empty() { - return invalid_oid4vci(format!("{name} must include a host")); - } - if url.contains('#') { - return invalid_oid4vci(format!("{name} must not include a fragment")); - } - Ok(()) -} - -pub(super) fn validate_oid4vci_endpoint_url( - name: &str, - url: &str, - credential_issuer: &str, -) -> Result<(), EvidenceConfigError> { - validate_oid4vci_public_url(name, url)?; - let Some((_, _, path)) = split_absolute_url(url) else { - return invalid_oid4vci(format!("{name} must be an absolute URL")); - }; - if path.is_empty() || path == "/" { - return invalid_oid4vci(format!("{name} must include an endpoint path")); - } - if url.contains('?') { - return invalid_oid4vci(format!("{name} must not include a query string")); - } - let issuer_prefix = credential_issuer.trim().trim_end_matches('/'); - if !url.trim().starts_with(&format!("{issuer_prefix}/")) { - return invalid_oid4vci(format!("{name} must be under oid4vci.credential_issuer")); - } - Ok(()) -} - -pub(super) fn oid4vci_credentials_path_prefix(credential_issuer: &str) -> Option { - let (_, _, issuer_path) = split_absolute_url(credential_issuer.trim())?; - let issuer_path = issuer_path.trim_end_matches('/'); - if issuer_path.is_empty() { - Some("/credentials/".to_string()) - } else { - Some(format!("{issuer_path}/credentials/")) - } -} - -pub(super) fn split_absolute_url(url: &str) -> Option<(&str, &str, &str)> { - let (scheme, rest) = url.split_once("://")?; - if scheme.is_empty() || rest.is_empty() { - return None; - } - let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - let authority = &rest[..authority_end]; - if authority.is_empty() { - return None; - } - let path = if rest[authority_end..].starts_with('/') { - rest[authority_end..] - .split(['?', '#']) - .next() - .unwrap_or_default() - } else { - "" - }; - Some((scheme, authority, path)) -} - -pub(super) fn validate_oid4vci_non_empty_entries( - name: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_oid4vci(format!("{name} must not be empty")); - } - for value in values { - validate_oid4vci_non_empty_value(name, value)?; - } - Ok(()) -} - -pub(super) fn validate_oid4vci_non_empty_value( - name: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_oid4vci(format!("{name} must not contain blank entries")); - } - Ok(()) -} - -pub(super) fn validate_optional_oid4vci_non_empty_value( - name: &str, - value: Option<&str>, -) -> Result<(), EvidenceConfigError> { - if let Some(value) = value { - validate_oid4vci_non_empty_value(name, value)?; - } - Ok(()) -} - -pub(super) fn validate_oid4vci_display_image( - name: &str, - image: &Option, -) -> Result<(), EvidenceConfigError> { - let Some(image) = image else { - return Ok(()); - }; - validate_optional_oid4vci_non_empty_value(&format!("{name}.uri"), image.uri.as_deref())?; - validate_optional_oid4vci_non_empty_value(&format!("{name}.url"), image.url.as_deref())?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.alt_text"), - image.alt_text.as_deref(), - )?; - match (image.uri.as_deref(), image.url.as_deref()) { - (None, None) => invalid_oid4vci(format!("{name} must include uri or url")), - (uri, url) => { - if let Some(uri) = uri { - validate_oid4vci_public_url(&format!("{name}.uri"), uri)?; - } - if let Some(url) = url { - validate_oid4vci_public_url(&format!("{name}.url"), url)?; - } - Ok(()) - } - } -} - -pub(super) fn invalid_oid4vci(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidOid4vciConfig { - reason: reason.into(), - }) -} - -pub(super) fn invalid_access_token_signing( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: reason.into(), - }) -} - -pub(super) fn validate_access_token_signing_entries( - field: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_access_token_signing(format!("{field} must not be empty when enabled")); - } - if values.iter().any(|value| value.trim().is_empty()) { - return invalid_access_token_signing(format!("{field} must not contain blank entries")); - } - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/root.rs b/crates/registry-notary-core/src/config/root.rs deleted file mode 100644 index dec0d6c4a..000000000 --- a/crates/registry-notary-core/src/config/root.rs +++ /dev/null @@ -1,813 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Root Registry Notary configuration and cross-domain validation. - -use super::*; -use registry_platform_httputil::destination::MAX_SERVICE_HOP_OPERATION_TIMEOUT; - -pub(super) const PKCE_METHOD_S256: &str = "S256"; -const RELAY_SERVICE_HOP_REQUEST_RESERVE: Duration = Duration::from_secs(5); -const MIN_RELAY_OUTER_REQUEST_TIMEOUT: Duration = Duration::from_secs( - MAX_SERVICE_HOP_OPERATION_TIMEOUT.as_secs() + RELAY_SERVICE_HOP_REQUEST_RESERVE.as_secs(), -); - -/// Non-EdDSA signing algorithms accepted for credential-profile signing. -/// Access-token and federation signing stay EdDSA; `validate_signing_key_alg_usage` -/// enforces that separation. -pub const CREDENTIAL_SIGNING_ALG_ES256: &str = "ES256"; -pub const CLIENT_ASSERTION_SIGNING_ALG_RS256: &str = "RS256"; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StandaloneRegistryNotaryConfig { - #[serde(default, skip_serializing_if = "instance_config_is_default")] - pub instance: NotaryInstanceConfig, - #[serde(default)] - pub server: RegistryNotaryHttpConfig, - pub evidence: EvidenceConfig, - pub auth: EvidenceAuthConfig, - #[serde(default)] - pub audit: EvidenceAuditConfig, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_trust: Option, - #[serde(default, skip_serializing_if = "state_config_is_default")] - pub state: StateConfig, - #[serde(default, skip_serializing_if = "credential_status_config_is_default")] - pub credential_status: CredentialStatusConfig, - #[serde(default, skip_serializing_if = "registry_notary_cel_config_is_default")] - pub cel: RegistryNotaryCelConfig, - #[serde(default, skip_serializing_if = "subject_access_config_is_default")] - pub subject_access: SubjectAccessConfig, - #[serde(default, skip_serializing_if = "oid4vci_config_is_default")] - pub oid4vci: Oid4vciConfig, - #[serde(default, skip_serializing_if = "federation_config_is_default")] - pub federation: FederationConfig, - #[serde(default, skip_serializing_if = "DeploymentConfig::is_default")] - pub deployment: DeploymentConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NotaryInstanceConfig { - #[serde(default = "default_instance_id")] - pub id: String, - #[serde(default = "default_instance_environment")] - pub environment: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub owner: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub public_base_url: Option, -} - -/// Optional governed-configuration local trust state. -/// -/// Simple local deployments omit this block. Signed/governed apply requires it -/// so anti-rollback state lives in an explicit durable location. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ConfigTrustConfig { - pub trust_anchor_path: PathBuf, - pub bundle_path: PathBuf, - pub antirollback_state_path: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_override_path: Option, -} - -impl Default for NotaryInstanceConfig { - fn default() -> Self { - Self { - id: default_instance_id(), - environment: default_instance_environment(), - owner: None, - jurisdiction: None, - public_base_url: None, - } - } -} - -pub(super) fn instance_config_is_default(config: &NotaryInstanceConfig) -> bool { - config == &NotaryInstanceConfig::default() -} - -pub(super) fn default_instance_id() -> String { - "registry-notary-standalone".to_string() -} - -pub(super) fn default_instance_environment() -> String { - "development".to_string() -} - -impl StandaloneRegistryNotaryConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.evidence.enabled { - return Err(EvidenceConfigError::EvidenceDisabled); - } - self.server.validate()?; - self.server - .admin_listener - .validate(self.server.bind, self.config_trust.is_some())?; - if let Some(config_trust) = &self.config_trust { - if config_trust.trust_anchor_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.trust_anchor_path must not be empty".to_string(), - }); - } - if config_trust.bundle_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.bundle_path must not be empty".to_string(), - }); - } - if config_trust.antirollback_state_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.antirollback_state_path must not be empty".to_string(), - }); - } - if config_trust - .break_glass_override_path - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.break_glass_override_path must not be empty when set" - .to_string(), - }); - } - } - self.state - .validate(&self.deployment, self.oid4vci.pre_authorized_code.enabled)?; - validate_static_credential_ids(&self.auth.api_keys, &self.auth.bearer_tokens)?; - if self.auth.api_keys.is_empty() - && self.auth.bearer_tokens.is_empty() - && self.auth.oidc.is_none() - { - return Err(EvidenceConfigError::NoCredentialsConfigured); - } - if let Some(oidc) = &self.auth.oidc { - if !self.auth.bearer_tokens.is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "auth.bearer_tokens cannot be combined with auth.oidc because both use Authorization: Bearer" - .to_string(), - }); - } - oidc.validate()?; - } - self.evidence.concurrency.validate()?; - self.evidence.machine_quota.validate()?; - self.evidence.validate_batch_limits()?; - if let Some(relay) = &self.evidence.relay { - relay.validate(self.deployment.profile)?; - if self.evidence.claims.is_empty() { - return Err(EvidenceConfigError::InvalidRelayConfig { - reason: "evidence.relay requires at least one registry_backed claim" - .to_string(), - }); - } - if self.server.request_timeout < MIN_RELAY_OUTER_REQUEST_TIMEOUT { - return Err(EvidenceConfigError::InvalidRelayConfig { - reason: "server.request_timeout must be at least 30 seconds for registry_backed claims, reserving 5 seconds outside the fixed 25-second Relay service hop" - .to_string(), - }); - } - } - self.cel.validate()?; - if self.evidence.max_credential_validity_seconds == 0 { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: "*".to_string(), - validity_seconds: self.evidence.max_credential_validity_seconds as i64, - max_validity_seconds: self.evidence.max_credential_validity_seconds, - }); - } - if self - .evidence - .allowed_purposes - .iter() - .any(|purpose| purpose.trim().is_empty()) - { - return Err(EvidenceConfigError::InvalidPurpose); - } - if self.evidence.variables.len() > MAX_REQUEST_VARIABLES_V1 { - return Err(EvidenceConfigError::InvalidRequestVariableConfig { - reason: format!( - "at most {MAX_REQUEST_VARIABLES_V1} request variables may be declared" - ), - }); - } - for (name, variable) in &self.evidence.variables { - if !is_request_variable_name(name) - || variable.from != format!("request.variables.{name}") - || variable.value_type != RequestVariableType::Date - { - return Err(EvidenceConfigError::InvalidRequestVariableConfig { - reason: "v1 variables must use a stable name, the exact matching request.variables path, and type date" - .to_string(), - }); - } - } - self.credential_status.validate()?; - validate_claim_dependency_bounds(&self.evidence.claims)?; - let mut seen_claim_ids: HashSet<&str> = HashSet::new(); - for claim in &self.evidence.claims { - if claim.id.trim().is_empty() { - return Err(EvidenceConfigError::InvalidClaim); - } - // REQ-DM-CLAIM-001: reject a duplicate claim id at load rather - // than letting a later claim silently shadow an earlier one. - if !seen_claim_ids.insert(claim.id.as_str()) { - return Err(EvidenceConfigError::DuplicateClaimId { - claim: claim.id.clone(), - }); - } - validate_claim_semantics(claim)?; - validate_claim_value_config(claim)?; - validate_claim_evidence_mode(claim, self.evidence.relay.is_some())?; - // REQ-DM-CLAIM-008: reject a disclosure default outside the - // allowed set at load; this is the most consequential of the - // three RS-DM-CLAIM Section 10 gaps because a privacy-sensitive - // claim could otherwise load with an internally inconsistent - // disclosure policy that only fails on first render. - if !claim - .disclosure - .allowed - .iter() - .any(|mode| mode == &claim.disclosure.default) - { - return Err(EvidenceConfigError::ClaimDisclosureDefaultNotAllowed { - claim: claim.id.clone(), - default: claim.disclosure.default.clone(), - allowed: claim.disclosure.allowed.clone(), - }); - } - // REQ-DM-CLAIM-009: omitted formats deserialize to the canonical - // claim-result representation, but an authored empty list cannot - // render any response and must fail before startup. The response - // format set is closed: credential issuance formats do not render - // evaluation responses. - if claim.formats.is_empty() { - return Err(EvidenceConfigError::EmptyClaimFormats { - claim: claim.id.clone(), - }); - } - for format in &claim.formats { - if format != FORMAT_CLAIM_RESULT_JSON && format != FORMAT_CCCEV_JSONLD { - return Err(EvidenceConfigError::UnsupportedClaimFormat { - claim: claim.id.clone(), - format: format.clone(), - }); - } - } - if !claim - .formats - .iter() - .any(|format| format == FORMAT_CLAIM_RESULT_JSON) - { - return Err(EvidenceConfigError::MissingCanonicalClaimFormat { - claim: claim.id.clone(), - }); - } - } - // Registry Notary currently resolves holder material only from - // did:jwk. Reject any other configured method so discovery metadata - // cannot advertise support that issuance cannot satisfy. - self.evidence.validate_signing_keys()?; - for (profile_id, profile) in &self.evidence.credential_profiles { - validate_credential_profile_validity( - profile_id, - profile, - self.evidence.max_credential_validity_seconds, - )?; - if profile.format != FORMAT_SD_JWT_VC { - return Err(EvidenceConfigError::UnsupportedCredentialProfileFormat { - profile: profile_id.clone(), - format: profile.format.clone(), - }); - } - let unsupported: Vec = profile - .holder_binding - .allowed_did_methods - .iter() - .filter(|m| m.as_str() != SD_JWT_VC_HOLDER_BINDING_METHOD) - .cloned() - .collect(); - if !unsupported.is_empty() { - return Err( - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { - profile: profile_id.clone(), - methods: unsupported, - }, - ); - } - // An empty allowed_claims short-circuits the issuance-time filter - // in api.rs (`is_empty()` means "any claim allowed"). Require - // operators to enumerate the claims a profile may bind to. A list - // composed only of blank entries is treated the same as empty so - // operators cannot trip the short-circuit via `[""]`. - if profile - .allowed_claims - .iter() - .all(|claim| claim.trim().is_empty()) - { - return Err(EvidenceConfigError::EmptyAllowedClaims { - profile: profile_id.clone(), - }); - } - let key = self - .evidence - .signing_keys - .get(profile.signing_key.as_str()) - .ok_or_else(|| EvidenceConfigError::UnknownCredentialProfileSigningKey { - profile: profile_id.clone(), - key: profile.signing_key.clone(), - })?; - if !key.status.may_sign() { - return Err(EvidenceConfigError::CredentialProfileSigningKeyNotActive { - profile: profile_id.clone(), - key: profile.signing_key.clone(), - }); - } - validate_profile_signing_key_issuer_binding(profile_id, profile, key)?; - } - // Finding 8: detect cycles in the depends_on graph using DFS with - // grey (in-progress) and black (done) sets. - let claim_ids: HashSet<&str> = self.evidence.claims.iter().map(|c| c.id.as_str()).collect(); - for claim in &self.evidence.claims { - for dep in &claim.depends_on { - if !claim_ids.contains(dep.as_str()) { - return Err(EvidenceConfigError::DependsOnUnknownClaim { - claim: claim.id.clone(), - unknown: dep.clone(), - }); - } - } - } - let mut grey: HashSet = HashSet::new(); - let mut black: HashSet = HashSet::new(); - for claim in &self.evidence.claims { - if !black.contains(&claim.id) { - detect_depends_on_cycle( - &self.evidence.claims, - &claim.id, - &mut grey, - &mut black, - &mut Vec::new(), - )?; - } - } - validate_relay_activation_shape(&self.evidence.claims)?; - self.subject_access.validate(&self.auth, &self.evidence)?; - self.validate_oid4vci_cross_block()?; - validate_credential_claim_bindings(&self.evidence)?; - self.validate_access_token_signing_cross_block()?; - self.federation.validate(&self.evidence)?; - self.validate_signing_key_alg_usage()?; - self.deployment.validate().map_err(|error| { - EvidenceConfigError::InvalidDeploymentConfig { - reason: error.to_string(), - } - })?; - self.validate_audit_ack_cursor()?; - Ok(()) - } - - /// Validate the off-host ack cursor configuration against the audit sink. - /// - /// A freshness window with no cursor to read is meaningless, and pointing a - /// cursor at a local file sink that does not declare off-host shipping - /// asserts observed shipping that the operator never attested. Both are - /// config errors so the contradiction is caught at load, not papered over. - fn validate_audit_ack_cursor(&self) -> Result<(), EvidenceConfigError> { - let evidence = &self.deployment.evidence; - if evidence.audit_ack_max_age_secs.is_some() && evidence.audit_ack_cursor_path.is_none() { - return Err(EvidenceConfigError::AuditAckMaxAgeWithoutCursor); - } - if evidence.audit_ack_cursor_path.is_some() - && matches!(self.audit.sink.as_str(), "file" | "jsonl") - && !evidence.audit_offhost_shipping - { - return Err(EvidenceConfigError::AuditAckCursorWithoutShippingDeclared); - } - Ok(()) - } - - pub fn validate_governed_runtime(&self) -> Result<(), EvidenceConfigError> { - self.validate()?; - self.server.admin_listener.validate(self.server.bind, true) - } - - /// Snapshot the configuration facts the deployment gate engine reads. - /// - /// Boot-time projection is configuration-only. A configured cursor clears - /// the static shipping-unverified gate, while runtime readiness and posture - /// must sample and bind it before shipping-stale clears. Keeping filesystem - /// I/O out of this path prevents startup from blocking on a stalled mount. - pub fn gate_input(&self) -> crate::deployment::GateInput { - self.gate_input_with_ack_observation(®istry_platform_ops::AckObservation::unverified()) - } - - /// Read the current off-host shipping cursor once for callers that need to - /// project both deployment gates and posture from the same observation. - pub fn audit_ack_observation(&self) -> registry_platform_ops::AckObservation { - self.audit_ack_observation_at(SystemTime::now()) - } - - /// Deterministic form of [`Self::audit_ack_observation`] for tests. - pub fn audit_ack_observation_at( - &self, - now: SystemTime, - ) -> registry_platform_ops::AckObservation { - registry_platform_ops::evaluate_ack_health( - self.deployment.evidence.audit_ack_cursor_path(), - now, - self.deployment.evidence.audit_ack_max_age(), - ) - } - - /// Snapshot gate facts as of `now`, including a synchronous cursor read. - /// - /// `now` is threaded through so cursor contract tests and offline commands - /// can evaluate freshness deterministically. Public runtime handlers use a - /// bounded async worker instead of this synchronous path. - pub fn gate_input_at(&self, now: SystemTime) -> crate::deployment::GateInput { - let ack_observation = self.audit_ack_observation_at(now); - self.gate_input_with_ack_observation(&ack_observation) - } - - /// Project gate facts using an already sampled shipping observation. - /// Keeping filesystem I/O outside the pure projection lets one HTTP response - /// use a single cursor snapshot for its gate and posture fields. - pub fn gate_input_with_ack_observation( - &self, - ack_observation: ®istry_platform_ops::AckObservation, - ) -> crate::deployment::GateInput { - crate::deployment::GateInput { - state_in_memory: self.state.storage == STATE_STORAGE_IN_MEMORY, - federation_enabled: self.federation.enabled, - oid4vci_preauth_enabled: self.oid4vci.enabled - && self.oid4vci.pre_authorized_code.enabled, - holder_proof_required: self.evidence.credential_profiles.values().any(|profile| { - profile.holder_binding.proof_of_possession.as_deref() == Some("required") - }), - wallet_facing: self.subject_access.enabled, - multi_instance: self.deployment.multi_instance, - audit_sink_class_durable: audit_sink_is_durable(&self.audit), - // A local file sink caps retention to whatever the host disk - // holds; an attacker with host access can destroy it. stdout and - // syslog are exempt: their retention is owned by the orchestrator - // log pipeline or the syslog daemon's own forwarding surface. - audit_retention_local_only: matches!(self.audit.sink.as_str(), "file" | "jsonl") - && !self.deployment.evidence.audit_offhost_shipping, - audit_shipping_target_configured: matches!( - self.audit.sink.as_str(), - "stdout" | "syslog" - ) || (matches!( - self.audit.sink.as_str(), - "file" | "jsonl" - ) && self - .deployment - .evidence - .audit_offhost_shipping), - audit_ack_cursor_configured: self.deployment.evidence.audit_ack_cursor_path().is_some(), - audit_ack_health_ok: ack_observation.health == registry_platform_ops::AckHealth::Ok, - admin_shared_exposure: self.server.admin_listener.mode - == RegistryNotaryAdminListenerMode::SharedWithPublic, - openapi_public: !self.server.openapi_requires_auth, - config_unsigned: self.config_trust.is_none(), - subject_access_enabled: self.subject_access.enabled, - transaction_token_anchor_configured: self.auth.access_token_signing.enabled, - // DPoP/mTLS proof validation for transaction tokens is not yet - // implemented. Keep this explicit so production/evidence profiles - // surface the missing sender-constraint assurance. - transaction_token_sender_constrained: false, - signer_without_custody_approval: !self.deployment.evidence.signer_custody_approved - && self.custody_scoped_signing_key_ids().iter().any(|key_id| { - self.evidence - .signing_keys - .get(*key_id) - .is_some_and(|key| key.status.may_sign()) - }), - } - } - - /// Signing-key ids used to issue credentials or access tokens, or to sign - /// federation responses. These are the custody-relevant Notary roles. The - /// eSignet RP client key is intentionally excluded because it signs an - /// outbound client assertion rather than a Notary-issued artifact. - pub fn custody_scoped_signing_key_ids(&self) -> HashSet<&str> { - let mut scoped: HashSet<&str> = self - .evidence - .credential_profiles - .values() - .map(|profile| profile.signing_key.as_str()) - .collect(); - if self.auth.access_token_signing.enabled { - let access_token_key = self.auth.access_token_signing.signing_key_id.as_str(); - if !access_token_key.is_empty() { - scoped.insert(access_token_key); - } - } - if self.federation.enabled { - let federation_key = self.federation.signing.signing_key.as_str(); - if !federation_key.is_empty() { - scoped.insert(federation_key); - } - } - scoped - } - - /// Signing-key ids whose resolved public material must not be shared, per - /// issue #173. These are the separated signing roles: every credential - /// profile signing key, the access-token signing key (when enabled), and the - /// federation signing key (when enabled). The eSignet pre-authorized-code RP - /// client key is intentionally excluded: it is a separate role that is - /// allowed to reuse the credential issuer's key material. - pub fn reuse_scoped_signing_key_ids(&self) -> HashSet<&str> { - self.custody_scoped_signing_key_ids() - } - - /// Confine ES256 signing keys to credential profiles and confine RS256 to - /// the eSignet pre-authorized-code RP client assertion. Access-token - /// signing and federation signing must reference EdDSA keys. - fn validate_signing_key_alg_usage(&self) -> Result<(), EvidenceConfigError> { - for (key_id, key) in &self.evidence.signing_keys { - if key.alg == CREDENTIAL_SIGNING_ALG_EDDSA { - continue; - } - if self.auth.access_token_signing.signing_key_id == *key_id { - return invalid_signing_key( - key_id, - "non-EdDSA signing key is used as the access-token signing key \ - (auth.access_token_signing.signing_key_id); non-EdDSA signing keys may only \ - be used by credential profiles or as the eSignet pre-authorized-code RP \ - client assertion key (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - if self.federation.signing.signing_key == *key_id { - return invalid_signing_key( - key_id, - "non-EdDSA signing key is used as the federation signing key \ - (federation.signing.signing_key); non-EdDSA signing keys may only be used by \ - credential profiles or as the eSignet pre-authorized-code RP client assertion \ - key (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - if key.alg == CLIENT_ASSERTION_SIGNING_ALG_RS256 - && self - .evidence - .credential_profiles - .values() - .any(|profile| profile.signing_key == *key_id) - { - return invalid_signing_key( - key_id, - "RS256 signing key is used by a credential profile; credential profile \ - signing keys must use EdDSA or ES256, and RS256 is reserved for the eSignet \ - pre-authorized-code RP client assertion key \ - (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - } - Ok(()) - } - - fn validate_oid4vci_cross_block(&self) -> Result<(), EvidenceConfigError> { - self.oid4vci.validate( - &self.subject_access, - &self.evidence, - &self.credential_status, - ) - } - - fn validate_access_token_signing_cross_block(&self) -> Result<(), EvidenceConfigError> { - let signing = &self.auth.access_token_signing; - if !signing.enabled { - return Ok(()); - } - if signing.issuer.trim().is_empty() { - return invalid_access_token_signing("issuer must not be empty when enabled"); - } - validate_access_token_signing_entries("audiences", &signing.audiences)?; - if signing.allowed_algorithms.is_empty() - || signing - .allowed_algorithms - .iter() - .any(|alg| alg != CREDENTIAL_SIGNING_ALG_EDDSA) - { - return invalid_access_token_signing(format!( - "allowed_algorithms must list only {CREDENTIAL_SIGNING_ALG_EDDSA}" - )); - } - if signing.token_typ.trim().is_empty() { - return invalid_access_token_signing("token_typ must not be empty when enabled"); - } - // The access-token `typ` must differ from the pre-authorized-code `typ`, - // or a pre-authorized code would also verify as an access token (the two - // are distinguished only by header `typ`). - if signing.token_typ == crate::tokens::PRE_AUTHORIZED_CODE_JWT_TYP { - return invalid_access_token_signing(format!( - "token_typ must not equal the pre-authorized-code typ '{}'", - crate::tokens::PRE_AUTHORIZED_CODE_JWT_TYP - )); - } - if signing.access_token_ttl_seconds == 0 || signing.access_token_ttl_seconds > 600 { - return invalid_access_token_signing( - "access_token_ttl_seconds must be between 1 and 600", - ); - } - if signing.signing_key_id.trim().is_empty() { - return invalid_access_token_signing("signing_key_id must not be empty when enabled"); - } - let key = self - .evidence - .signing_keys - .get(signing.signing_key_id.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: format!( - "signing_key_id '{}' must reference an evidence.signing_keys entry", - signing.signing_key_id - ), - })?; - if !key.status.may_sign() { - return invalid_access_token_signing(format!( - "signing_key_id '{}' must be an active signing key", - signing.signing_key_id - )); - } - // The access-token key MUST be distinct from every credential-signing - // key so a confusion or compromise of one is not the other. - for (profile_id, profile) in &self.evidence.credential_profiles { - if profile.signing_key == signing.signing_key_id { - return invalid_access_token_signing(format!( - "signing_key_id '{}' must be distinct from credential profile '{profile_id}' signing key", - signing.signing_key_id - )); - } - } - let mut verification_keys = std::collections::BTreeSet::new(); - for key_id in &signing.verification_key_ids { - if key_id.trim().is_empty() { - return invalid_access_token_signing( - "verification_key_ids must not contain blank entries", - ); - } - if key_id == &signing.signing_key_id { - return invalid_access_token_signing(format!( - "verification_key_ids must not repeat active signing_key_id '{}'", - signing.signing_key_id - )); - } - if !verification_keys.insert(key_id.as_str()) { - return invalid_access_token_signing(format!( - "verification_key_ids contains duplicate key '{key_id}'" - )); - } - let key = self.evidence.signing_keys.get(key_id).ok_or_else(|| { - EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: format!( - "verification_key_ids entry '{key_id}' must reference an evidence.signing_keys entry" - ), - } - })?; - if !key.status.may_publish() || key.status.may_sign() { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must be a publish_only signing key" - )); - } - if key.alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must use {CREDENTIAL_SIGNING_ALG_EDDSA}" - )); - } - for (profile_id, profile) in &self.evidence.credential_profiles { - if profile.signing_key == *key_id { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must be distinct from credential profile '{profile_id}' signing key" - )); - } - } - } - Ok(()) - } -} - -/// Close both sides of every credential claim/profile binding at load time. -/// -/// A credential profile is a signing capability. Keeping this validation at -/// the shared root prevents direct issuance, subject-access issuance, and -/// OID4VCI from interpreting a one-sided binding differently. -fn validate_credential_claim_bindings( - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - for (profile_id, profile) in &evidence.credential_profiles { - for claim_id in &profile.allowed_claims { - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' allowed_claims references unknown claim '{claim_id}'" - ), - })?; - if !claim - .credential_profiles - .iter() - .any(|candidate| candidate == profile_id) - { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' allows claim '{claim_id}', but the claim does not reference that profile" - ), - }); - } - let mut pending = claim - .depends_on - .iter() - .map(String::as_str) - .collect::>(); - let mut visited = HashSet::new(); - while let Some(dependency_id) = pending.pop() { - if !visited.insert(dependency_id) { - continue; - } - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' claim '{claim_id}' dependency closure references unknown claim '{dependency_id}'" - ), - })?; - if dependency.purpose != claim.purpose { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' claim '{claim_id}' dependency '{dependency_id}' must declare the same canonical purpose" - ), - }); - } - pending.extend(dependency.depends_on.iter().map(String::as_str)); - } - } - } - - for claim in &evidence.claims { - for profile_id in &claim.credential_profiles { - let profile = evidence - .credential_profiles - .get(profile_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "claim '{}' references unknown credential profile '{profile_id}'", - claim.id - ), - })?; - if !profile - .allowed_claims - .iter() - .any(|candidate| candidate == &claim.id) - { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "claim '{}' references credential profile '{profile_id}', but the profile allowed_claims does not include that claim", - claim.id - ), - }); - } - } - } - Ok(()) -} - -pub(super) fn validate_static_credential_ids( - api_keys: &[EvidenceCredentialConfig], - bearer_tokens: &[EvidenceCredentialConfig], -) -> Result<(), EvidenceConfigError> { - let mut ids = HashSet::with_capacity(api_keys.len() + bearer_tokens.len()); - for (field, credentials) in [ - ("auth.api_keys", api_keys), - ("auth.bearer_tokens", bearer_tokens), - ] { - for credential in credentials { - if ids.insert(credential.id.as_str()) { - continue; - } - return Err(EvidenceConfigError::InvalidAuthConfig { - reason: format!("{field} contains duplicate id '{}'", credential.id), - }); - } - } - Ok(()) -} - -pub fn deprecated_config_fields() -> Vec { - vec![ - DeprecatedConfigField::renamed("auth.oidc.jwks_uri", "auth.oidc.jwks_url"), - DeprecatedConfigField::renamed("auth.oidc.leeway_seconds", "auth.oidc.leeway"), - DeprecatedConfigField::renamed("auth.oidc.allowed_typ", "auth.oidc.allowed_token_types"), - DeprecatedConfigField::renamed("audit.max_size_bytes", "audit.max_size_mb"), - DeprecatedConfigField::removed( - "server.cors.allow_credentials", - "Notary now always disables credentialed CORS; remove the field", - ), - ] -} diff --git a/crates/registry-notary-core/src/config/schema.rs b/crates/registry-notary-core/src/config/schema.rs deleted file mode 100644 index 16fa870f2..000000000 --- a/crates/registry-notary-core/src/config/schema.rs +++ /dev/null @@ -1,204 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Reproducible Draft 2020-12 schema for the complete Notary runtime config. - -#![allow( - dead_code, - reason = "schema adapter types are compile-time descriptions and are never constructed" -)] - -use std::borrow::Cow; - -use registry_platform_authcommon::CredentialFingerprintProvider; -use schemars::{generate::SchemaSettings, json_schema, JsonSchema, Schema, SchemaGenerator}; -use serde_json::{json, Value}; - -use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig}; - -/// Stable identifier for the product-owned Notary runtime configuration schema. -pub const CONFIG_SCHEMA_ID: &str = - "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; - -/// Schema-only deployment-waiver reference contract shared with posture. -pub(crate) struct DeploymentWaiverReferenceSchema; - -impl JsonSchema for DeploymentWaiverReferenceSchema { - fn schema_name() -> Cow<'static, str> { - "DeploymentWaiverReference".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - registry_platform_ops::deployment_waiver_reference_schema_fragment() - .try_into() - .expect("the shared waiver-reference fragment is a valid JSON Schema") - } -} - -/// Schema-only structural deployment-waiver summary contract shared with posture. -pub(crate) struct DeploymentWaiverSummarySchema; - -impl JsonSchema for DeploymentWaiverSummarySchema { - fn schema_name() -> Cow<'static, str> { - "DeploymentWaiverSummary".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - registry_platform_ops::deployment_waiver_summary_schema_fragment() - .try_into() - .expect("the shared waiver-summary fragment is a valid JSON Schema") - } -} - -/// Schema-only contract for values parsed by `humantime_serde`. -pub(crate) struct HumantimeDurationSchema; - -impl JsonSchema for HumantimeDurationSchema { - fn schema_name() -> Cow<'static, str> { - "HumantimeDuration".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A humantime duration string. The runtime parser remains authoritative for its complete grammar.", - "type": "string" - }) - } -} - -/// Schema-only contract for YAML socket addresses parsed by `SocketAddr`. -pub(crate) struct SocketAddrSchema; - -impl JsonSchema for SocketAddrSchema { - fn schema_name() -> Cow<'static, str> { - "SocketAddr".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A Rust SocketAddr string. The runtime parser remains authoritative for address and port validity.", - "type": "string" - }) - } -} - -/// Schema-only contract for `IpNet` CIDR values. -pub(crate) struct IpNetSchema; - -impl JsonSchema for IpNetSchema { - fn schema_name() -> Cow<'static, str> { - "IpNet".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "An IP network CIDR string. The runtime parser remains authoritative for address and prefix validity.", - "type": "string" - }) - } -} - -pub(crate) struct CredentialFingerprintSchema; - -impl JsonSchema for CredentialFingerprintSchema { - fn schema_name() -> Cow<'static, str> { - "CredentialFingerprintRef".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - // `CredentialFingerprintRef` has a custom deserializer. It accepts - // either provider together with zero, one, or both optional references; - // doctor/runtime validation decides whether the selected provider has a - // usable, non-ambiguous reference. Keep that division of responsibility - // instead of making the schema stricter than deserialization. - json_schema!({ - "type": "object", - "additionalProperties": false, - "required": ["provider"], - "properties": { - "provider": string_enum(CredentialFingerprintProvider::ALL.iter().map(|provider| provider.as_str())), - "name": { "type": ["string", "null"] }, - "path": { "type": ["string", "null"] } - } - }) - } -} - -pub(crate) struct SigningKeyProviderSchema; - -impl JsonSchema for SigningKeyProviderSchema { - fn schema_name() -> Cow<'static, str> { - "SigningKeyProviderSchema".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - string_enum( - SigningKeyProviderConfig::ALL - .iter() - .map(|provider| provider.as_str()), - ) - } -} - -pub(crate) struct SigningKeyStatusSchema; - -impl JsonSchema for SigningKeyStatusSchema { - fn schema_name() -> Cow<'static, str> { - "SigningKeyStatusSchema".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - string_enum(SigningKeyStatus::ALL.iter().map(|status| status.as_str())) - } -} - -fn string_enum(labels: impl Iterator) -> Schema { - json!({ - "type": "string", - "enum": labels.collect::>() - }) - .try_into() - .expect("a JSON object is always a valid JSON Schema") -} - -/// Schema-only representation of the string-only consultation-input deserializer. -pub(crate) struct RelayConsultationInputSchema; - -impl JsonSchema for RelayConsultationInputSchema { - fn schema_name() -> Cow<'static, str> { - "RelayConsultationInput".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A supported consultation input path. The runtime parser remains authoritative for exact stable-name bounds.", - "type": "string", - "minLength": 1 - }) - } -} - -/// Generate the deserialization contract for [`StandaloneRegistryNotaryConfig`]. -#[must_use] -pub fn document() -> Value { - let schema = SchemaSettings::draft2020_12() - .into_generator() - .into_root_schema_for::(); - let mut value = serde_json::to_value(schema).expect("JSON Schema serializes to JSON"); - let root = value.as_object_mut().expect("root schema is an object"); - root.insert( - "$id".to_string(), - Value::String(CONFIG_SCHEMA_ID.to_string()), - ); - root.insert( - "title".to_string(), - Value::String("Registry Notary config".to_string()), - ); - value -} - -/// Serialize the generated schema deterministically with exactly one trailing LF. -#[must_use] -pub fn document_json() -> String { - let mut output = serde_json::to_string_pretty(&document()).expect("JSON Schema serializes"); - output.push('\n'); - output -} diff --git a/crates/registry-notary-core/src/config/state.rs b/crates/registry-notary-core/src/config/state.rs deleted file mode 100644 index b08e5dcbe..000000000 --- a/crates/registry-notary-core/src/config/state.rs +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary correctness-state storage configuration. - -use super::*; - -pub const STATE_STORAGE_IN_MEMORY: &str = "in_memory"; -pub const STATE_STORAGE_POSTGRESQL: &str = "postgresql"; -pub const STATE_POSTGRESQL_MAX_CONNECTIONS: usize = 256; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StateConfig { - #[serde(default = "default_state_storage")] - pub storage: String, - #[serde(default, skip_serializing_if = "state_postgresql_config_is_default")] - pub postgresql: StatePostgresqlConfig, -} - -impl Default for StateConfig { - fn default() -> Self { - Self { - storage: default_state_storage(), - postgresql: StatePostgresqlConfig::default(), - } - } -} - -impl StateConfig { - pub(super) fn validate( - &self, - deployment: &DeploymentConfig, - preauthorization_enabled: bool, - ) -> Result<(), EvidenceConfigError> { - match self.storage.as_str() { - STATE_STORAGE_POSTGRESQL => { - validate_state_non_empty("state.postgresql.url_env", &self.postgresql.url_env)?; - if self.postgresql.connect_timeout_ms == 0 { - return invalid_state( - "state.postgresql.connect_timeout_ms must be greater than zero", - ); - } - if self.postgresql.operation_timeout_ms == 0 { - return invalid_state( - "state.postgresql.operation_timeout_ms must be greater than zero", - ); - } - if !(1..=STATE_POSTGRESQL_MAX_CONNECTIONS) - .contains(&self.postgresql.max_connections) - { - return invalid_state( - "state.postgresql.max_connections must be between 1 and 256", - ); - } - if self - .postgresql - .root_certificate_path - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return invalid_state( - "state.postgresql.root_certificate_path must not be empty when set", - ); - } - if preauthorization_enabled { - validate_state_non_empty( - "state.postgresql.sensitive_state_key_env", - &self.postgresql.sensitive_state_key_env, - )?; - } - Ok(()) - } - STATE_STORAGE_IN_MEMORY => { - if deployment.profile != Some(crate::deployment::DeploymentProfile::Local) { - return invalid_state( - "state.storage = in_memory requires deployment.profile = local", - ); - } - if deployment.multi_instance { - return invalid_state( - "state.storage = in_memory requires deployment.multi_instance = false", - ); - } - Ok(()) - } - _ => invalid_state("state.storage must be postgresql or in_memory"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StatePostgresqlConfig { - #[serde(default = "default_state_postgresql_url_env")] - pub url_env: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub root_certificate_path: Option, - #[serde(default = "default_state_postgresql_connect_timeout_ms")] - pub connect_timeout_ms: u64, - #[serde(default = "default_state_postgresql_operation_timeout_ms")] - pub operation_timeout_ms: u64, - #[serde(default = "default_state_postgresql_max_connections")] - pub max_connections: usize, - #[serde(default = "default_sensitive_state_key_env")] - pub sensitive_state_key_env: String, -} - -impl Default for StatePostgresqlConfig { - fn default() -> Self { - Self { - url_env: default_state_postgresql_url_env(), - root_certificate_path: None, - connect_timeout_ms: default_state_postgresql_connect_timeout_ms(), - operation_timeout_ms: default_state_postgresql_operation_timeout_ms(), - max_connections: default_state_postgresql_max_connections(), - sensitive_state_key_env: default_sensitive_state_key_env(), - } - } -} - -pub(super) fn state_config_is_default(config: &StateConfig) -> bool { - config == &StateConfig::default() -} - -pub(super) fn state_postgresql_config_is_default(config: &StatePostgresqlConfig) -> bool { - config == &StatePostgresqlConfig::default() -} - -pub(super) fn default_state_storage() -> String { - STATE_STORAGE_POSTGRESQL.to_string() -} - -pub(super) fn default_state_postgresql_url_env() -> String { - "REGISTRY_NOTARY_POSTGRES_URL".to_string() -} - -pub(super) const fn default_state_postgresql_connect_timeout_ms() -> u64 { - 5_000 -} - -pub(super) const fn default_state_postgresql_operation_timeout_ms() -> u64 { - 2_000 -} - -pub(super) const fn default_state_postgresql_max_connections() -> usize { - 16 -} - -pub(super) fn default_sensitive_state_key_env() -> String { - "REGISTRY_NOTARY_SENSITIVE_STATE_KEY".to_string() -} - -fn validate_state_non_empty(field: &str, value: &str) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_state(format!("{field} must not be empty")); - } - Ok(()) -} - -fn invalid_state(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidStateConfig { - reason: reason.into(), - }) -} diff --git a/crates/registry-notary-core/src/config/subject_access.rs b/crates/registry-notary-core/src/config/subject_access.rs deleted file mode 100644 index be9bc802a..000000000 --- a/crates/registry-notary-core/src/config/subject_access.rs +++ /dev/null @@ -1,1204 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Subject-bound and delegated subject-access configuration. - -use super::*; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub subject_binding: SubjectAccessSubjectBindingConfig, - #[serde(default)] - pub citizen_clients: SubjectAccessCitizenClientsConfig, - #[serde(default)] - pub token_policy: SubjectAccessTokenPolicyConfig, - #[serde(default)] - pub allowed_operations: SubjectAccessOperationsConfig, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub allowed_formats: Vec, - #[serde(default)] - pub allowed_disclosures: Vec, - #[serde(default)] - pub scope_policy: SubjectAccessScopePolicy, - #[serde(default)] - pub required_scopes: Vec, - #[serde(default)] - pub allowed_wallet_origins: Vec, - #[serde(default)] - pub credential_profiles: Vec, - #[serde(default)] - pub delegation: SubjectAccessDelegationConfig, - #[serde(default)] - pub rate_limits: SubjectAccessRateLimitsConfig, -} - -pub(super) fn subject_access_config_is_default(config: &SubjectAccessConfig) -> bool { - config == &SubjectAccessConfig::default() -} - -impl SubjectAccessConfig { - pub(super) fn validate( - &self, - auth: &EvidenceAuthConfig, - evidence: &EvidenceConfig, - ) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - let oidc = - auth.oidc - .as_ref() - .ok_or_else(|| EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "enabled subject_access requires auth.oidc".to_string(), - })?; - - self.subject_binding.validate()?; - self.citizen_clients.validate(oidc)?; - self.token_policy.validate(oidc)?; - if self.subject_binding.claim_source == SubjectAccessClaimSource::Userinfo - && oidc - .userinfo_endpoint - .as_deref() - .unwrap_or_default() - .is_empty() - { - return self.invalid( - "subject_binding.claim_source = userinfo requires auth.oidc.userinfo_endpoint", - ); - } - self.allowed_operations.validate()?; - self.delegation - .validate(evidence, &self.subject_binding.id_type)?; - if self.allowed_claims.is_empty() { - if !self.delegation.enabled { - return self.invalid( - "subject_access.allowed_claims must not be empty unless delegation is enabled", - ); - } - if !self.allowed_purposes.is_empty() - || !self.allowed_formats.is_empty() - || !self.allowed_disclosures.is_empty() - { - return self.invalid( - "delegation-only subject_access requires allowed_purposes, allowed_formats, and allowed_disclosures to be empty; configure them on each delegated relationship", - ); - } - } else { - validate_non_empty_entries("subject_access.allowed_purposes", &self.allowed_purposes)?; - validate_non_empty_entries("subject_access.allowed_formats", &self.allowed_formats)?; - validate_non_empty_entries( - "subject_access.allowed_disclosures", - &self.allowed_disclosures, - )?; - } - validate_entries("subject_access.allowed_claims", &self.allowed_claims)?; - if self.scope_policy != SubjectAccessScopePolicy::Disabled - && self.required_scopes.is_empty() - { - return self.invalid("scope_policy requires required_scopes unless it is disabled"); - } - if self.scope_policy == SubjectAccessScopePolicy::Disabled - && !self.required_scopes.is_empty() - { - return self.invalid("scope_policy = disabled requires required_scopes to be empty"); - } - if self.scope_policy != SubjectAccessScopePolicy::Disabled { - validate_non_empty_entries("subject_access.required_scopes", &self.required_scopes)?; - } else { - validate_entries("subject_access.required_scopes", &self.required_scopes)?; - } - validate_non_empty_entries( - "subject_access.credential_profiles", - &self.credential_profiles, - )?; - for relationship in &self.delegation.allowed_relationships { - if relationship.max_proof_age_seconds > self.token_policy.max_evaluation_age_seconds { - return self.invalid(format!( - "subject_access.delegation relationship '{}' max_proof_age_seconds must not exceed token_policy.max_evaluation_age_seconds", - relationship.relationship_type - )); - } - } - self.rate_limits.validate()?; - validate_exact_wallet_origins(&self.allowed_wallet_origins)?; - - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let allowed_claim_ids: HashSet<&str> = - self.allowed_claims.iter().map(String::as_str).collect(); - let subject_access_claim_ids: HashSet<&str> = allowed_claim_ids - .iter() - .copied() - .chain( - self.delegation - .allowed_relationships - .iter() - .flat_map(|relationship| { - relationship.allowed_claims.iter().map(String::as_str) - }), - ) - .collect(); - let allowed_purposes: HashSet<&str> = - self.allowed_purposes.iter().map(String::as_str).collect(); - let allowed_formats: HashSet<&str> = - self.allowed_formats.iter().map(String::as_str).collect(); - let allowed_disclosures: HashSet<&str> = self - .allowed_disclosures - .iter() - .map(String::as_str) - .collect(); - let allowed_profiles: HashSet<&str> = self - .credential_profiles - .iter() - .map(String::as_str) - .collect(); - - for claim_id in &self.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("allowed_claims references unknown claim '{claim_id}'"), - }); - } - } - - for profile_id in &self.credential_profiles { - if !evidence.credential_profiles.contains_key(profile_id) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "credential_profiles references unknown profile '{profile_id}'" - ), - }); - } - } - - for profile_id in &self.credential_profiles { - let profile = evidence - .credential_profiles - .get(profile_id) - // SAFETY: the preceding credential_profiles loop rejects - // every profile id missing from evidence.credential_profiles. - .expect("profile id was checked above"); - validate_subject_access_profile( - profile_id, - profile, - &claim_ids, - &subject_access_claim_ids, - self.token_policy.max_credential_validity_seconds, - )?; - } - - for claim_id in &self.allowed_claims { - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - // SAFETY: the preceding allowed_claims loop rejects every - // claim id missing from evidence.claims. - .expect("claim id was checked above"); - validate_subject_access_claim( - claim, - &allowed_purposes, - &allowed_formats, - &allowed_disclosures, - &allowed_profiles, - self.allowed_operations.issue_credential, - )?; - validate_subject_bound_registry_inputs(claim, &self.subject_binding.id_type)?; - } - - validate_subject_access_allow_lists_are_supported(self, evidence)?; - if self.scope_policy != SubjectAccessScopePolicy::Disabled { - validate_required_scope_mappings(self, oidc)?; - } - Ok(()) - } - - fn invalid(&self, reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: reason.into(), - }) - } -} - -fn validate_subject_bound_registry_inputs( - claim: &ClaimDefinition, - subject_id_type: &str, -) -> Result<(), EvidenceConfigError> { - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return Ok(()); - }; - let target_path = format!("target.identifiers.{subject_id_type}"); - let requester_path = format!("requester.identifiers.{subject_id_type}"); - for consultation in consultations.values() { - for input in consultation.inputs.values() { - let path = input.request_context_path(); - if path != target_path && path != requester_path { - return invalid_subject_access(format!( - "allowed registry_backed claim '{}' maps Relay input '{}' outside the authenticated subject binding; expected '{}' or '{}'", - claim.id, path, target_path, requester_path - )); - } - } - } - Ok(()) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessDelegationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub allowed_relationships: Vec, -} - -impl SubjectAccessDelegationConfig { - fn validate( - &self, - evidence: &EvidenceConfig, - requester_id_type: &str, - ) -> Result<(), EvidenceConfigError> { - if !self.enabled { - if !self.allowed_relationships.is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: - "subject_access.delegation.enabled=false requires allowed_relationships to be empty" - .to_string(), - }); - } - return Ok(()); - } - if self.allowed_relationships.is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "subject_access.delegation.enabled requires allowed_relationships" - .to_string(), - }); - } - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let mut relationship_types = HashSet::new(); - for relationship in &self.allowed_relationships { - relationship.validate(evidence, &claim_ids, requester_id_type)?; - if !relationship_types.insert(relationship.relationship_type.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation.allowed_relationships contains duplicate relationship_type '{}'", - relationship.relationship_type - ), - }); - } - } - Ok(()) - } - - #[must_use] - pub fn relationship( - &self, - relationship_type: &str, - ) -> Option<&SubjectAccessDelegatedRelationshipConfig> { - self.allowed_relationships - .iter() - .find(|relationship| relationship.relationship_type == relationship_type) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessDelegatedRelationshipConfig { - pub relationship_type: String, - pub proof_claim: String, - #[serde(default)] - pub target_id_type: Option, - #[serde(default = "default_delegated_max_proof_age_seconds")] - #[schemars(range(min = 1, max = 600))] - pub max_proof_age_seconds: u64, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_formats: Vec, - #[serde(default)] - pub allowed_disclosures: Vec, -} - -const fn default_delegated_max_proof_age_seconds() -> u64 { - 300 -} - -impl SubjectAccessDelegatedRelationshipConfig { - fn validate( - &self, - evidence: &EvidenceConfig, - claim_ids: &HashSet<&str>, - requester_id_type: &str, - ) -> Result<(), EvidenceConfigError> { - if self.relationship_type.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: - "subject_access.delegation.allowed_relationships.relationship_type is required" - .to_string(), - }); - } - if self.proof_claim.trim().is_empty() || !claim_ids.contains(self.proof_claim.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation proof_claim references unknown claim '{}'", - self.proof_claim - ), - }); - } - let Some(proof_claim) = evidence - .claims - .iter() - .find(|claim| claim.id == self.proof_claim) - else { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation proof_claim references unknown claim '{}'", - self.proof_claim - ), - }); - }; - validate_delegated_proof_claim_binding(self, proof_claim)?; - if let Some(target_id_type) = self.target_id_type.as_deref() { - if target_id_type.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "subject_access.delegation target_id_type must not be blank" - .to_string(), - }); - } - } - if self.max_proof_age_seconds == 0 || self.max_proof_age_seconds > 600 { - return invalid_subject_access( - "subject_access.delegation.allowed_relationships.max_proof_age_seconds must be between 1 and 600", - ); - } - validate_non_empty_entries( - "subject_access.delegation.allowed_claims", - &self.allowed_claims, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_purposes", - &self.allowed_purposes, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_formats", - &self.allowed_formats, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_disclosures", - &self.allowed_disclosures, - )?; - let allowed_purposes: HashSet<&str> = - self.allowed_purposes.iter().map(String::as_str).collect(); - let allowed_formats: HashSet<&str> = - self.allowed_formats.iter().map(String::as_str).collect(); - let allowed_disclosures: HashSet<&str> = self - .allowed_disclosures - .iter() - .map(String::as_str) - .collect(); - for claim_id in &self.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation allowed_claims references unknown claim '{claim_id}'" - ), - }); - } - let Some(claim) = evidence.claims.iter().find(|claim| claim.id == *claim_id) else { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation allowed_claims references unknown claim '{claim_id}'" - ), - }); - }; - if !claim.depends_on.iter().any(|dep| dep == &self.proof_claim) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "delegated claim '{claim_id}' must depend_on proof_claim '{}'", - self.proof_claim - ), - }); - } - if claim.purpose != proof_claim.purpose { - return invalid_subject_access(format!( - "delegated claim '{claim_id}' and proof_claim '{}' must declare the same purpose", - self.proof_claim - )); - } - validate_delegated_subject_access_claim( - claim, - &allowed_purposes, - &allowed_formats, - &allowed_disclosures, - )?; - validate_delegated_registry_closure_inputs( - &self.relationship_type, - claim, - &self.proof_claim, - evidence, - requester_id_type, - self.target_id_type.as_deref().unwrap_or(requester_id_type), - )?; - } - validate_delegated_subject_access_allow_lists_are_supported(self, evidence)?; - Ok(()) - } -} - -fn validate_delegated_registry_closure_inputs( - relationship_type: &str, - root: &ClaimDefinition, - proof_claim_id: &str, - evidence: &EvidenceConfig, - requester_id_type: &str, - target_id_type: &str, -) -> Result<(), EvidenceConfigError> { - let requester_path = format!("requester.identifiers.{requester_id_type}"); - let target_path = format!("target.identifiers.{target_id_type}"); - let mut pending = vec![root]; - let mut visited = BTreeSet::new(); - - while let Some(claim) = pending.pop() { - if !visited.insert(claim.id.as_str()) || claim.id == proof_claim_id { - continue; - } - if let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode { - for (consultation_name, consultation) in consultations { - let mut consumes_target = false; - for (input_name, input) in &consultation.inputs { - let path = input.request_context_path(); - if path != requester_path && path != target_path { - return invalid_subject_access(format!( - "delegated relationship '{relationship_type}' allowed claim '{}' closure claim '{}' consultation '{consultation_name}' input '{input_name}' maps non-canonical path '{path}'; allowed canonical paths are '{requester_path}' and '{target_path}'", - root.id, claim.id - )); - } - consumes_target |= path == target_path; - } - if !consumes_target { - let actual_inputs = consultation - .inputs - .iter() - .map(|(input_name, input)| { - format!("'{input_name}' maps '{}'", input.request_context_path()) - }) - .collect::>() - .join(", "); - return invalid_subject_access(format!( - "delegated relationship '{relationship_type}' allowed claim '{}' closure claim '{}' consultation '{consultation_name}' does not consume the selected target; {actual_inputs}; required target path is '{target_path}' and the only additional allowed canonical path is '{requester_path}'", - root.id, claim.id - )); - } - } - } - for dependency_id in &claim.depends_on { - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == *dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "delegated relationship '{relationship_type}' allowed claim '{}' dependency closure references unknown claim '{dependency_id}'", - root.id - ), - })?; - pending.push(dependency); - } - } - Ok(()) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessScopePolicy { - #[default] - Required, - Optional, - Disabled, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessSubjectBindingConfig { - #[serde(default)] - pub token_claim: String, - #[serde(default)] - pub claim_source: SubjectAccessClaimSource, - #[serde(default)] - pub request_field: SubjectId, - #[serde(default)] - pub id_type: String, - #[serde(default)] - pub normalize: SubjectBindingNormalize, - #[serde(default)] - pub allow_sub_as_civil_id: bool, -} - -impl SubjectAccessSubjectBindingConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.token_claim.is_empty() { - return invalid_subject_access("subject_binding.token_claim must not be empty"); - } - if !self - .token_claim - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | ':' | '/' | '.' | '-')) - { - return invalid_subject_access( - "subject_binding.token_claim must match [A-Za-z0-9_:/\\.\\-]+", - ); - } - if self.token_claim == "sub" && !self.allow_sub_as_civil_id { - return invalid_subject_access( - "subject_binding.token_claim = sub requires allow_sub_as_civil_id = true", - ); - } - if self.id_type.trim().is_empty() { - return invalid_subject_access("subject_binding.id_type must not be empty"); - } - if self.normalize != SubjectBindingNormalize::Exact { - return invalid_subject_access("subject_binding.normalize must be exact"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessClaimSource { - #[default] - AccessToken, - Userinfo, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub enum SubjectId { - #[default] - SubjectId, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectBindingNormalize { - #[default] - Exact, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessCitizenClientsConfig { - #[serde(default)] - pub allowed_client_ids: Vec, - #[serde(default)] - pub allowed_audiences: Vec, -} - -impl SubjectAccessCitizenClientsConfig { - fn validate(&self, oidc: &EvidenceOidcAuthConfig) -> Result<(), EvidenceConfigError> { - if self.allowed_client_ids.is_empty() && self.allowed_audiences.is_empty() { - return invalid_subject_access( - "citizen_clients must list at least one allowed client id or audience", - ); - } - validate_entries( - "subject_access.citizen_clients.allowed_client_ids", - &self.allowed_client_ids, - )?; - validate_entries( - "subject_access.citizen_clients.allowed_audiences", - &self.allowed_audiences, - )?; - for audience in &self.allowed_audiences { - if !oidc.audiences.iter().any(|accepted| accepted == audience) { - return invalid_subject_access(format!( - "citizen audience '{audience}' is not listed in auth.oidc.audiences" - )); - } - } - if !oidc.allowed_clients.is_empty() { - for client_id in &self.allowed_client_ids { - if !oidc - .allowed_clients - .iter() - .any(|accepted| accepted == client_id) - { - return invalid_subject_access(format!( - "citizen client '{client_id}' is not listed in auth.oidc.allowed_clients" - )); - } - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessTokenPolicyConfig { - #[serde(default)] - pub required_acr_values: Vec, - #[serde(default)] - pub assurance_claim_source: SubjectAccessAssuranceClaimSource, - #[serde(default)] - pub max_auth_age_seconds: u64, - #[serde(default)] - pub max_access_token_lifetime_seconds: u64, - #[serde(default)] - pub max_evaluation_age_seconds: u64, - #[serde(default)] - pub max_credential_validity_seconds: u64, - #[serde(default)] - pub max_clock_leeway_seconds: u64, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessAssuranceClaimSource { - #[default] - AccessToken, - IdToken, -} - -impl SubjectAccessTokenPolicyConfig { - fn validate(&self, oidc: &EvidenceOidcAuthConfig) -> Result<(), EvidenceConfigError> { - validate_entries( - "subject_access.token_policy.required_acr_values", - &self.required_acr_values, - )?; - if self.max_auth_age_seconds == 0 { - return invalid_subject_access( - "token_policy.max_auth_age_seconds must be greater than zero", - ); - } - if self.max_access_token_lifetime_seconds == 0 { - return invalid_subject_access( - "token_policy.max_access_token_lifetime_seconds must be greater than zero", - ); - } - if self.max_evaluation_age_seconds == 0 || self.max_evaluation_age_seconds > 600 { - return invalid_subject_access( - "token_policy.max_evaluation_age_seconds must be between 1 and 600", - ); - } - if self.max_credential_validity_seconds == 0 { - return invalid_subject_access( - "token_policy.max_credential_validity_seconds must be greater than zero", - ); - } - if self.max_clock_leeway_seconds == 0 || self.max_clock_leeway_seconds > 60 { - return invalid_subject_access( - "token_policy.max_clock_leeway_seconds must be between 1 and 60", - ); - } - if oidc.leeway > Duration::from_secs(self.max_clock_leeway_seconds) { - return invalid_subject_access( - "auth.oidc.leeway must not exceed token_policy.max_clock_leeway_seconds", - ); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessOperationsConfig { - #[serde(default)] - pub evaluate: bool, - #[serde(default)] - pub render: bool, - #[serde(default)] - pub issue_credential: bool, - #[serde(default)] - pub batch_evaluate: bool, -} - -impl SubjectAccessOperationsConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.batch_evaluate { - return invalid_subject_access("allowed_operations.batch_evaluate must be false in v1"); - } - if !self.evaluate && !self.render && !self.issue_credential { - return invalid_subject_access( - "allowed_operations must enable at least one subject-access operation", - ); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessRateLimitsConfig { - #[serde(default)] - pub invalid_token_per_client_address_per_minute: u32, - #[serde(default)] - pub per_principal_per_minute: u32, - #[serde(default)] - pub subject_mismatch_per_principal_per_hour: u32, - #[serde(default)] - pub per_holder_per_hour: u32, - #[serde(default)] - pub credential_issuance_per_principal_per_hour: u32, - /// Per-minute cap on `tx_code` attempts against a single - /// `pre-authorized_code`. Bounds brute force of the numeric PIN at the - /// pre-authorized-code token endpoint. Defaults to zero so existing - /// configs that do not enable pre-auth still validate; it must be greater - /// than zero only when the pre-authorized-code flow is enabled. - #[serde(default)] - pub tx_code_attempts_per_code_per_minute: u32, -} - -impl SubjectAccessRateLimitsConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.invalid_token_per_client_address_per_minute == 0 - || self.per_principal_per_minute == 0 - || self.subject_mismatch_per_principal_per_hour == 0 - || self.per_holder_per_hour == 0 - || self.credential_issuance_per_principal_per_hour == 0 - { - return invalid_subject_access("rate_limits values must all be greater than zero"); - } - Ok(()) - } -} - -pub(super) fn validate_non_empty_entries( - name: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_subject_access(format!("{name} must not be empty")); - } - validate_entries(name, values) -} - -pub(super) fn validate_entries(name: &str, values: &[String]) -> Result<(), EvidenceConfigError> { - if values.iter().any(|value| value.trim().is_empty()) { - return invalid_subject_access(format!("{name} must not contain blank entries")); - } - Ok(()) -} - -pub(super) fn validate_exact_wallet_origins(origins: &[String]) -> Result<(), EvidenceConfigError> { - for origin in origins { - if origin == "*" || origin.contains('*') { - return invalid_subject_access( - "allowed_wallet_origins must contain exact origins, not wildcards", - ); - } - if !origin.starts_with("https://") { - return invalid_subject_access("allowed_wallet_origins must use https origins"); - } - } - Ok(()) -} - -pub(super) fn validate_subject_access_claim( - claim: &ClaimDefinition, - allowed_purposes: &HashSet<&str>, - allowed_formats: &HashSet<&str>, - allowed_disclosures: &HashSet<&str>, - allowed_profiles: &HashSet<&str>, - issue_credential: bool, -) -> Result<(), EvidenceConfigError> { - if !claim.operations.evaluate.enabled { - return invalid_subject_access(format!( - "allowed claim '{}' must enable evaluate", - claim.id - )); - } - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("allowed claim '{}' must declare purpose", claim.id), - } - })?; - if !allowed_purposes.contains(purpose) { - return invalid_subject_access(format!( - "allowed claim '{}' declares unallowed purpose '{}'", - claim.id, purpose - )); - } - if !claim - .formats - .iter() - .any(|format| allowed_formats.contains(format.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must support at least one allowed format", - claim.id - )); - } - if !claim - .disclosure - .allowed - .iter() - .any(|disclosure| allowed_disclosures.contains(disclosure.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must support at least one allowed disclosure", - claim.id - )); - } - if issue_credential - && !claim - .credential_profiles - .iter() - .any(|profile| allowed_profiles.contains(profile.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must reference an allowed credential profile", - claim.id - )); - } - Ok(()) -} - -pub(super) fn validate_subject_access_profile( - profile_id: &str, - profile: &CredentialProfileConfig, - claim_ids: &HashSet<&str>, - allowed_claim_ids: &HashSet<&str>, - max_credential_validity_seconds: u64, -) -> Result<(), EvidenceConfigError> { - if profile.validity_seconds <= 0 { - return invalid_subject_access(format!( - "credential profile '{profile_id}' validity_seconds must be greater than zero" - )); - } - let validity_seconds = u64::try_from(profile.validity_seconds).map_err(|_| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "credential profile '{profile_id}' validity_seconds must be greater than zero" - ), - } - })?; - if validity_seconds > max_credential_validity_seconds { - return invalid_subject_access(format!( - "credential profile '{profile_id}' validity_seconds must not exceed the subject-access ceiling" - )); - } - if profile.holder_binding.mode != "did" { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.mode must be did" - )); - } - if profile.holder_binding.proof_of_possession.as_deref() != Some("required") { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.proof_of_possession must be required" - )); - } - if profile.holder_binding.allowed_did_methods.is_empty() - || profile - .holder_binding - .allowed_did_methods - .iter() - .any(|method| method != SD_JWT_VC_HOLDER_BINDING_METHOD) - { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.allowed_did_methods must only contain did:jwk" - )); - } - for claim_id in &profile.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return invalid_subject_access(format!( - "credential profile '{profile_id}' references unknown claim '{claim_id}'" - )); - } - } - if !profile - .allowed_claims - .iter() - .any(|claim_id| allowed_claim_ids.contains(claim_id.as_str())) - { - return invalid_subject_access(format!( - "credential profile '{profile_id}' must allow at least one subject-access claim" - )); - } - Ok(()) -} - -pub(super) fn validate_subject_access_allow_lists_are_supported( - config: &SubjectAccessConfig, - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - let allowed_claims: Vec<&ClaimDefinition> = config - .allowed_claims - .iter() - .filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id)) - .collect(); - let allowed_profiles: Vec<&CredentialProfileConfig> = config - .credential_profiles - .iter() - .filter_map(|profile_id| evidence.credential_profiles.get(profile_id)) - .collect(); - - for purpose in &config.allowed_purposes { - if !allowed_claims - .iter() - .any(|claim| claim.purpose.as_deref() == Some(purpose.as_str())) - { - return invalid_subject_access(format!( - "allowed_purposes entry '{purpose}' is not used by any allowed claim" - )); - } - } - - for format in &config.allowed_formats { - let supported_by_claim = allowed_claims - .iter() - .any(|claim| claim.formats.iter().any(|candidate| candidate == format)); - if !supported_by_claim { - return invalid_subject_access(format!( - "allowed_formats entry '{format}' is not supported by any allowed claim" - )); - } - } - - for disclosure in &config.allowed_disclosures { - let supported_by_claim = allowed_claims.iter().any(|claim| { - claim - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - let supported_by_profile = allowed_profiles.iter().any(|profile| { - profile - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - if !supported_by_claim && !supported_by_profile { - return invalid_subject_access(format!( - "allowed_disclosures entry '{disclosure}' is not supported by any allowed claim or profile" - )); - } - } - - Ok(()) -} - -pub(super) fn validate_delegated_proof_claim_binding( - relationship: &SubjectAccessDelegatedRelationshipConfig, - proof_claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - if !proof_claim.depends_on.is_empty() { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must not depend_on other claims so the relationship is proven before the delegated claim closure runs", - relationship.proof_claim - )); - } - let ClaimEvidenceMode::RegistryBacked { consultations } = &proof_claim.evidence_mode else { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must be registry_backed", - relationship.proof_claim - )); - }; - let Some((_, consultation)) = consultations - .first_key_value() - .filter(|_| consultations.len() == 1) - else { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must declare exactly one Relay consultation", - relationship.proof_claim - )); - }; - let has_requester = consultation - .inputs - .values() - .any(RelayConsultationInput::is_requester_derived); - let has_target = consultation - .inputs - .values() - .any(RelayConsultationInput::is_authenticated_target_identifier); - if !has_requester || !has_target { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must map both a requester-derived input and an authenticated target identifier", - relationship.proof_claim - )); - } - if proof_claim.value.value_type != "boolean" { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must produce a boolean result", - relationship.proof_claim - )); - } - if proof_claim.purpose.as_deref().is_none() { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must declare purpose", - relationship.proof_claim - )); - } - Ok(()) -} - -pub(super) fn validate_delegated_subject_access_claim( - claim: &ClaimDefinition, - allowed_purposes: &HashSet<&str>, - allowed_formats: &HashSet<&str>, - allowed_disclosures: &HashSet<&str>, -) -> Result<(), EvidenceConfigError> { - if !claim.operations.evaluate.enabled { - return invalid_subject_access(format!( - "delegated claim '{}' must enable evaluate", - claim.id - )); - } - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("delegated claim '{}' must declare purpose", claim.id), - } - })?; - if !allowed_purposes.contains(purpose) { - return invalid_subject_access(format!( - "delegated claim '{}' declares unallowed purpose '{}'", - claim.id, purpose - )); - } - if !claim - .formats - .iter() - .any(|format| allowed_formats.contains(format.as_str())) - { - return invalid_subject_access(format!( - "delegated claim '{}' must support at least one allowed format", - claim.id - )); - } - if !claim - .disclosure - .allowed - .iter() - .any(|disclosure| allowed_disclosures.contains(disclosure.as_str())) - { - return invalid_subject_access(format!( - "delegated claim '{}' must support at least one allowed disclosure", - claim.id - )); - } - Ok(()) -} - -pub(super) fn validate_delegated_subject_access_allow_lists_are_supported( - relationship: &SubjectAccessDelegatedRelationshipConfig, - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - let allowed_claims: Vec<&ClaimDefinition> = relationship - .allowed_claims - .iter() - .filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id)) - .collect(); - for purpose in &relationship.allowed_purposes { - if !allowed_claims - .iter() - .any(|claim| claim.purpose.as_deref() == Some(purpose.as_str())) - { - return invalid_subject_access(format!( - "subject_access.delegation allowed_purposes entry '{purpose}' is not used by any allowed claim" - )); - } - } - - for format in &relationship.allowed_formats { - let supported_by_claim = allowed_claims - .iter() - .any(|claim| claim.formats.iter().any(|candidate| candidate == format)); - if !supported_by_claim { - return invalid_subject_access(format!( - "subject_access.delegation allowed_formats entry '{format}' is not supported by any allowed claim" - )); - } - } - - for disclosure in &relationship.allowed_disclosures { - let supported_by_claim = allowed_claims.iter().any(|claim| { - claim - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - if !supported_by_claim { - return invalid_subject_access(format!( - "subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim" - )); - } - } - - Ok(()) -} - -pub(super) fn validate_required_scope_mappings( - config: &SubjectAccessConfig, - oidc: &EvidenceOidcAuthConfig, -) -> Result<(), EvidenceConfigError> { - let required_scopes: HashSet<&str> = - config.required_scopes.iter().map(String::as_str).collect(); - for scope in &required_scopes { - if !oidc - .scope_map - .values() - .any(|mapped_scopes| mapped_scopes.iter().any(|mapped| mapped == scope)) - { - return invalid_subject_access(format!( - "required scope '{scope}' must be present in auth.oidc.scope_map" - )); - } - } - - Ok(()) -} - -pub(super) fn invalid_subject_access( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: reason.into(), - }) -} - -pub(super) fn detect_depends_on_cycle( - claims: &[ClaimDefinition], - claim_id: &str, - grey: &mut HashSet, - black: &mut HashSet, - path: &mut Vec, -) -> Result<(), EvidenceConfigError> { - grey.insert(claim_id.to_string()); - path.push(claim_id.to_string()); - let claim = claims.iter().find(|c| c.id == claim_id); - if let Some(claim) = claim { - for dep in &claim.depends_on { - if grey.contains(dep.as_str()) { - // Back edge found: build the cycle path from where dep appears. - let cycle_start = path.iter().position(|id| id == dep).unwrap_or(0); - let mut cycle = path[cycle_start..].to_vec(); - cycle.push(dep.clone()); - return Err(EvidenceConfigError::DependsOnCycle { cycle }); - } - if !black.contains(dep.as_str()) { - detect_depends_on_cycle(claims, dep, grey, black, path)?; - } - } - } - path.pop(); - grey.remove(claim_id); - black.insert(claim_id.to_string()); - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/tests.rs b/crates/registry-notary-core/src/config/tests.rs deleted file mode 100644 index 409c050b9..000000000 --- a/crates/registry-notary-core/src/config/tests.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub(super) use super::*; -mod auth; -mod credentials; -mod infrastructure; -mod issuance; -mod preauth; -mod relay; -mod root; -mod support; diff --git a/crates/registry-notary-core/src/config/tests/auth.rs b/crates/registry-notary-core/src/config/tests/auth.rs deleted file mode 100644 index 1ca032028..000000000 --- a/crates/registry-notary-core/src/config/tests/auth.rs +++ /dev/null @@ -1,319 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{credentials::*, infrastructure::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn at_least_one_authenticator_is_required() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.bearer_tokens.clear(); - config.auth.oidc = None; - - let err = config - .validate() - .expect_err("an empty auth configuration must fail"); - - assert!(matches!(err, EvidenceConfigError::NoCredentialsConfigured)); -} - -#[test] -pub(super) fn oidc_auth_validates_required_settings() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "https://issuer.example/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn duplicate_static_credential_api_key_id_rejected() { - let mut config = minimal_config(); - let duplicate = config.auth.api_keys[0].clone(); - config.auth.api_keys.push(duplicate); - - let reason = match config - .validate() - .expect_err("duplicate API key id must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.api_keys") && reason.contains("test-key"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn duplicate_static_credential_bearer_token_id_rejected() { - let mut config = minimal_config(); - let mut token = config.auth.api_keys[0].clone(); - token.id = "shared-bearer-token".to_string(); - config.auth.bearer_tokens.push(token.clone()); - config.auth.bearer_tokens.push(token); - - let reason = match config - .validate() - .expect_err("duplicate bearer token id must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.bearer_tokens") && reason.contains("shared-bearer-token"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn duplicate_static_credential_id_across_api_key_and_bearer_token_rejected() { - let mut config = minimal_config(); - config - .auth - .bearer_tokens - .push(config.auth.api_keys[0].clone()); - - let reason = match config - .validate() - .expect_err("duplicate static credential id across types must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.bearer_tokens") && reason.contains("test-key"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn oidc_jwks_url_must_use_https() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "http://issuer.example/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - let err = config - .validate() - .expect_err("remote http jwks_url must fail validation"); - match err { - EvidenceConfigError::InvalidOidcConfig { reason } => { - assert!( - reason.contains("jwks_url must use https"), - "unexpected: {reason}" - ); - } - other => panic!("unexpected error variant: {other}"), - } - - config - .auth - .oidc - .as_mut() - .expect("oidc config exists") - .allow_insecure_localhost = true; - let err = config - .validate() - .expect_err("allow_insecure_localhost must not permit remote http"); - assert!(matches!(err, EvidenceConfigError::InvalidOidcConfig { .. })); -} - -#[test] -pub(super) fn oidc_jwks_url_allows_insecure_localhost_only_when_enabled() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "http://127.0.0.1:8080/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - let err = config - .validate() - .expect_err("localhost http jwks_url needs explicit opt-in"); - assert!(matches!(err, EvidenceConfigError::InvalidOidcConfig { .. })); - - config - .auth - .oidc - .as_mut() - .expect("oidc config exists") - .allow_insecure_localhost = true; - config - .validate() - .expect("localhost http jwks_url is allowed only with the opt-in"); -} - -#[test] -pub(super) fn api_key_plaintext_is_never_loaded_only_fingerprint() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - api_keys: - - id: test-key - token_env: TEST_TOKEN -"#, - ) - .expect_err("plaintext token_env is not part of the credential schema"); - - assert!( - err.to_string().contains("unknown field `token_env`"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn legacy_api_key_fingerprint_commitment_rejected() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH - commitment: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -"#, - ) - .expect_err("legacy fingerprint commitment must fail deserialization"); - - assert!( - err.to_string() - .contains("fingerprint.commitment was removed"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn legacy_bearer_token_fingerprint_commitment_rejected() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - bearer_tokens: - - id: test-bearer - fingerprint: - provider: env - name: TEST_BEARER_HASH - commitment: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -"#, - ) - .expect_err("legacy bearer token fingerprint commitment must fail deserialization"); - - assert!( - err.to_string() - .contains("fingerprint.commitment was removed"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn removed_auth_mode_is_rejected_at_parse_time() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - mode: oauth2 -"#, - ) - .expect_err("the removed auth mode field must fail deserialization"); - - let message = err.to_string(); - assert!( - message.contains("mode") || message.contains("unknown field"), - "unexpected error: {message}" - ); -} - -#[test] -pub(super) fn oidc_auth_allows_api_key_credentials() { - let mut config = valid_subject_access_config(); - config.auth.api_keys.push(EvidenceCredentialConfig { - id: "legacy-api-key".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("LEGACY_API_KEY_HASH".to_string()), - path: None, - }, - scopes: vec!["subject_access".to_string()], - authorization_details: None, - }); - - config - .validate() - .expect("OIDC and API-key authenticators use distinct carriers"); -} - -#[test] -pub(super) fn oidc_auth_rejects_static_bearer_credentials() { - let mut config = valid_subject_access_config(); - config.auth.bearer_tokens.push(EvidenceCredentialConfig { - id: "ambiguous-bearer".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("AMBIGUOUS_BEARER_HASH".to_string()), - path: None, - }, - scopes: vec!["subject_access".to_string()], - authorization_details: None, - }); - - let reason = match config - .validate() - .expect_err("OIDC and static bearer tokens share one carrier") - { - EvidenceConfigError::InvalidOidcConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("Authorization: Bearer")); -} diff --git a/crates/registry-notary-core/src/config/tests/credentials.rs b/crates/registry-notary-core/src/config/tests/credentials.rs deleted file mode 100644 index 29dc25eed..000000000 --- a/crates/registry-notary-core/src/config/tests/credentials.rs +++ /dev/null @@ -1,1111 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, infrastructure::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn proof_of_possession_required_with_only_did_jwk_is_valid() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - assert!( - config.validate().is_ok(), - "did:jwk only should pass validation" - ); -} - -#[test] -pub(super) fn credential_profile_format_must_use_current_sd_jwt_vc_media_type() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: sd_jwt_vc -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("legacy-alias".to_string(), profile); - - let err = config - .validate() - .expect_err("legacy profile format alias must fail validation"); - match err { - EvidenceConfigError::UnsupportedCredentialProfileFormat { profile, format } => { - assert_eq!(profile, "legacy-alias"); - assert_eq!(format, "sd_jwt_vc"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn credential_profile_default_validity_is_short_lived() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.validity_seconds, 600); -} - -#[test] -pub(super) fn credential_profile_default_holder_binding_is_did_jwk() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.holder_binding.mode, "did"); - assert_eq!( - profile.holder_binding.allowed_did_methods, - vec!["did:jwk".to_string()] - ); - assert!(profile.holder_binding.proof_of_possession.is_none()); -} - -#[test] -pub(super) fn credential_profile_can_explicitly_opt_out_of_holder_binding() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: none -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.holder_binding.mode, "none"); - assert_eq!( - profile.holder_binding.allowed_did_methods, - vec!["did:jwk".to_string()] - ); -} - -#[test] -pub(super) fn credential_profile_explicit_validity_is_honored() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -validity_seconds: 300 -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.validity_seconds, 300); -} - -#[test] -pub(super) fn credential_profile_validity_above_general_ceiling_is_rejected() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://vct.example/test -validity_seconds: 601 -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("long-lived".to_string(), profile); - - let err = config - .validate() - .expect_err("over-ceiling credential validity must fail"); - assert!(matches!( - err, - EvidenceConfigError::InvalidCredentialProfileValidity { - profile, - validity_seconds: 601, - max_validity_seconds: 600 - } if profile == "long-lived" - )); -} - -#[test] -pub(super) fn credential_profile_non_positive_validity_is_rejected() { - for invalid in [0, -1] { - let mut config = minimal_config(); - let mut profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - profile.validity_seconds = invalid; - config - .evidence - .credential_profiles - .insert("invalid-validity".to_string(), profile); - - let err = config - .validate() - .expect_err("non-positive credential validity must fail"); - assert!(matches!( - err, - EvidenceConfigError::InvalidCredentialProfileValidity { .. } - )); - } -} - -#[test] -pub(super) fn signing_keys_are_configured_separately_from_credential_profiles() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2026: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2026 - status: active -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-2026 -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config - .validate() - .expect("profile may reference an active signing key"); -} - -#[test] -pub(super) fn credential_profiles_must_reference_active_signing_keys() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-2025 -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - - let err = config - .validate() - .expect_err("publish-only keys must not be used for new issuance"); - match err { - EvidenceConfigError::CredentialProfileSigningKeyNotActive { profile, key } => { - assert_eq!(profile, "test-profile"); - assert_eq!(key, "issuer-2025"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn publish_only_local_jwk_uses_public_jwk_env_only() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - private_jwk_env: OLD_ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("publish-only local keys must not require private material"); - assert!( - err.to_string().contains("public_jwk_env must not be empty"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn publish_only_signing_key_accepts_bounded_publication_window() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only - publish_until_unix_seconds: 1893456000 -"#, - ) - .expect("signing key YAML is valid"); - - let key = config - .evidence - .signing_keys - .get("issuer-2025") - .expect("publish-only key exists"); - assert_eq!(key.publish_until_unix_seconds, Some(1_893_456_000)); - assert!(key.may_publish_at(1_893_456_000)); - assert!(!key.may_publish_at(1_893_456_001)); - config - .validate() - .expect("publish-only key may carry a publication deadline"); -} - -#[test] -pub(super) fn active_signing_key_rejects_publication_window() { - let mut config = minimal_config(); - let active = config - .evidence - .signing_keys - .values_mut() - .find(|key| key.status == SigningKeyStatus::Active) - .expect("minimal config has an active key"); - active.publish_until_unix_seconds = Some(1_893_456_000); - - let err = config - .validate() - .expect_err("active signing keys cannot carry a publication deadline"); - assert!( - err.to_string() - .contains("publish_until_unix_seconds is valid only for publish_only signing keys"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_shape_validates_without_loading_module() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-hsm -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config.validate().expect("PKCS#11 key shape validates"); -} - -#[test] -pub(super) fn file_watch_signing_key_shape_validates_without_secret_material_in_config() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - path: /run/secrets/issuer.jwk - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-file -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config.validate().expect("file-watch key shape validates"); -} - -#[test] -pub(super) fn file_watch_signing_key_rejects_secret_fields_and_missing_path() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - private_jwk_env: REGISTRY_NOTARY_ISSUER_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("file-watch key must use a local path"); - assert!( - err.to_string().contains("path must not be empty"), - "unexpected error: {err}" - ); - - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - path: /run/secrets/issuer.jwk - private_jwk_env: REGISTRY_NOTARY_ISSUER_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("file-watch key must not carry env-backed private material"); - assert!( - err.to_string() - .contains("private_jwk_env is not valid for this signing key provider"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_requires_absolute_module_path() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("relative module path must fail validation"); - assert!( - err.to_string().contains("module_path must be absolute"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_rejects_rs256_algorithm() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: RS256 - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("PKCS#11 signing only supports EdDSA"); - assert!( - err.to_string() - .contains("pkcs11 provider supports only EdDSA"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn publish_only_pkcs11_key_uses_public_jwk_env_only() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm-old: - provider: pkcs11 - public_jwk_env: REGISTRY_NOTARY_OLD_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm-old - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - - config - .validate() - .expect("publish-only PKCS#11 key needs only public metadata"); - - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm-old: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - public_jwk_env: REGISTRY_NOTARY_OLD_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm-old - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("publish-only PKCS#11 key must not require HSM access"); - assert!( - err.to_string() - .contains("module_path is not valid for this signing key provider"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn local_pkcs12_file_provider_is_deferred_without_partial_support() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-p12: - provider: local_pkcs12_file - path: /run/secrets/issuer.p12 - password_env: REGISTRY_NOTARY_P12_PASSWORD - alg: EdDSA - kid: did:web:issuer.example#issuer-p12 - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("PKCS#12 support must fail closed until it is implemented"); - assert!( - err.to_string() - .contains("local_pkcs12_file provider is intentionally not implemented yet"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn proof_of_possession_required_with_non_jwk_method_is_rejected() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk - - did:key -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - - let err = config - .validate() - .expect_err("did:key with proof_of_possession required must fail"); - match &err { - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { profile, methods } => { - assert_eq!(profile, "test-profile"); - assert!( - methods.contains(&"did:key".to_string()), - "error must name did:key, got: {methods:?}" - ); - assert!( - !methods.contains(&"did:jwk".to_string()), - "did:jwk must not appear in the unsupported list" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn non_jwk_methods_are_rejected_even_without_proof_of_possession() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - allowed_did_methods: - - did:jwk - - did:key - - did:web -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - let err = config - .validate() - .expect_err("non-did:jwk holder methods must fail validation"); - match &err { - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { profile, methods } => { - assert_eq!(profile, "test-profile"); - assert_eq!(methods, &vec!["did:key".to_string(), "did:web".to_string()]); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// Finding 8: depends_on cycle detection -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn valid_dag_passes_cycle_detection() { - // A -> B -> C (no cycle) - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-b".to_string()]; - let mut claim_b = minimal_claim("claim-b"); - claim_b.depends_on = vec!["claim-c".to_string()]; - let claim_c = minimal_claim("claim-c"); - config.evidence.claims = vec![claim_a, claim_b, claim_c]; - assert!(config.validate().is_ok(), "A->B->C DAG should pass"); -} - -#[test] -pub(super) fn two_node_cycle_is_detected() { - // A -> B -> A - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-b".to_string()]; - let mut claim_b = minimal_claim("claim-b"); - claim_b.depends_on = vec!["claim-a".to_string()]; - config.evidence.claims = vec![claim_a, claim_b]; - - let err = config - .validate() - .expect_err("A->B->A cycle must fail validation"); - match &err { - EvidenceConfigError::DependsOnCycle { cycle } => { - assert!( - cycle.contains(&"claim-a".to_string()), - "cycle must mention claim-a, got: {cycle:?}" - ); - assert!( - cycle.contains(&"claim-b".to_string()), - "cycle must mention claim-b, got: {cycle:?}" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn self_loop_is_detected() { - // A -> A - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-a".to_string()]; - config.evidence.claims = vec![claim_a]; - - let err = config - .validate() - .expect_err("self-loop must fail validation"); - match &err { - EvidenceConfigError::DependsOnCycle { cycle } => { - assert!( - cycle.contains(&"claim-a".to_string()), - "cycle must mention claim-a, got: {cycle:?}" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn unknown_depends_on_is_rejected() { - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-nonexistent".to_string()]; - config.evidence.claims = vec![claim_a]; - - let err = config - .validate() - .expect_err("depends_on unknown claim must fail validation"); - match &err { - EvidenceConfigError::DependsOnUnknownClaim { claim, unknown } => { - assert_eq!(claim, "claim-a"); - assert_eq!(unknown, "claim-nonexistent"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// GH#170 / RS-DM-CLAIM Section 10: load-time validation for invariants -// the loader previously deferred to request/evaluation time. -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn duplicate_claim_id_is_rejected() { - // REQ-DM-CLAIM-001: two claims sharing an id previously loaded - // cleanly; the loader must now reject it. - let mut config = minimal_config(); - let claim_a = minimal_claim("repeated-id"); - let claim_b = minimal_claim("repeated-id"); - config.evidence.claims = vec![claim_a, claim_b]; - - let err = config - .validate() - .expect_err("duplicate claim id must fail validation"); - match &err { - EvidenceConfigError::DuplicateClaimId { claim } => { - assert_eq!(claim, "repeated-id"); - } - other => panic!("unexpected error variant: {other}"), - } - assert!( - err.to_string().contains("repeated-id"), - "error must name the offending claim id: {err}" - ); -} - -#[test] -pub(super) fn disclosure_default_outside_allowed_is_rejected() { - // REQ-DM-CLAIM-008: a disclosure default outside the allowed set - // previously surfaced only when a result was rendered. This is the - // most consequential of the three Section 10 gaps: a - // privacy-sensitive claim could otherwise ship an internally - // inconsistent disclosure policy that only fails on first render. - let mut config = minimal_config(); - let mut claim = minimal_claim("residency-status"); - claim.disclosure = DisclosureConfig { - default: "value".to_string(), - allowed: vec!["redacted".to_string()], - downgrade: "deny".to_string(), - }; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("disclosure default outside allowed must fail validation"); - match &err { - EvidenceConfigError::ClaimDisclosureDefaultNotAllowed { - claim, - default, - allowed, - } => { - assert_eq!(claim, "residency-status"); - assert_eq!(default, "value"); - assert_eq!(allowed, &vec!["redacted".to_string()]); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("residency-status") && message.contains("disclosure"), - "error must name the offending claim id and field: {message}" - ); -} - -#[test] -pub(super) fn omitted_claim_formats_default_to_claim_result_json() { - let claim = minimal_claim("default-format"); - - assert_eq!( - claim.formats, - vec![FORMAT_CLAIM_RESULT_JSON.to_string()], - "omitted formats must retain the canonical evaluation representation" - ); -} - -#[test] -pub(super) fn explicit_empty_claim_formats_are_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("empty-format"); - claim.formats.clear(); - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("an explicitly empty formats list must fail validation"); - match &err { - EvidenceConfigError::EmptyClaimFormats { claim } => { - assert_eq!(claim, "empty-format"); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("empty-format") && message.contains("omit formats"), - "error must name the claim and explain how to use the default: {message}" - ); -} - -#[test] -pub(super) fn canonical_claim_format_is_valid() { - let mut config = minimal_config(); - let mut claim = minimal_claim("canonical-format"); - claim.formats = vec![FORMAT_CLAIM_RESULT_JSON.to_string()]; - config.evidence.claims = vec![claim]; - - config - .validate() - .expect("the canonical evaluation format must validate"); -} - -#[test] -pub(super) fn canonical_and_cccev_claim_formats_are_valid() { - let mut config = minimal_config(); - let mut claim = minimal_claim("canonical-and-cccev"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - FORMAT_CCCEV_JSONLD.to_string(), - ]; - config.evidence.claims = vec![claim]; - - config - .validate() - .expect("the canonical and CCCEV evaluation formats must validate"); -} - -#[test] -pub(super) fn cccev_without_canonical_claim_format_is_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("cccev-only"); - claim.formats = vec![FORMAT_CCCEV_JSONLD.to_string()]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("CCCEV-only formats must fail validation"); - match &err { - EvidenceConfigError::MissingCanonicalClaimFormat { claim } => { - assert_eq!(claim, "cccev-only"); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("cccev-only") && message.contains(FORMAT_CLAIM_RESULT_JSON), - "error must name the claim and canonical format: {message}" - ); -} - -#[test] -pub(super) fn sd_jwt_vc_claim_format_is_rejected_before_canonical_omission() { - let mut config = minimal_config(); - let mut claim = minimal_claim("sd-jwt-only"); - claim.formats = vec![FORMAT_SD_JWT_VC.to_string()]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("SD-JWT VC is not an evaluation response format"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "sd-jwt-only"); - assert_eq!(format, FORMAT_SD_JWT_VC); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("credential_profiles") && message.contains(FORMAT_SD_JWT_VC), - "error must identify SD-JWT VC and its configuration home: {message}" - ); -} - -#[test] -pub(super) fn mixed_claim_formats_reject_the_first_unsupported_format() { - let mut config = minimal_config(); - let mut claim = minimal_claim("mixed-formats"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - FORMAT_SD_JWT_VC.to_string(), - "application/example+json".to_string(), - ]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("mixed formats must reject the unsupported SD-JWT VC entry"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "mixed-formats"); - assert_eq!(format, FORMAT_SD_JWT_VC); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn unknown_claim_format_is_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("unknown-format"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - "application/example+json".to_string(), - ]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("unknown evaluation formats must fail validation"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "unknown-format"); - assert_eq!(format, "application/example+json"); - } - other => panic!("unexpected error variant: {other}"), - } - assert!( - err.to_string().contains("application/example+json"), - "error must name the offending format: {err}" - ); -} - -#[test] -pub(super) fn empty_allowed_claims_is_rejected() { - // A credential profile with an empty allowed_claims would silently - // accept every claim at issue time (see api.rs `is_empty()` short - // circuit). Reject at config-load time so the operator must opt in. - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("the_profile_id".to_string(), profile); - - let err = config - .validate() - .expect_err("empty allowed_claims must fail validation"); - match &err { - EvidenceConfigError::EmptyAllowedClaims { profile } => { - assert_eq!(profile, "the_profile_id"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// Stage 1: concurrency config and the kill-switch -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn default_concurrency_has_documented_defaults() { - let cfg = ConcurrencyConfig::default(); - assert_eq!(cfg.subjects, 16); - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn concurrency_zero_subjects_is_rejected() { - let mut config = minimal_config(); - config.evidence.concurrency = ConcurrencyConfig { subjects: 0 }; - let err = config - .validate() - .expect_err("subjects=0 must fail validation"); - assert!(matches!(err, EvidenceConfigError::InvalidConcurrency)); -} - -#[test] -pub(super) fn concurrency_subjects_one_validates() { - let mut config = minimal_config(); - config.evidence.concurrency = ConcurrencyConfig { subjects: 1 }; - assert!(config.validate().is_ok()); -} - -// ----------------------------------------------------------------------- -// Machine quota config -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn machine_quota_defaults_to_disabled_with_documented_limit() { - let cfg = MachineQuotaConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.subjects_per_minute, 6000); - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn machine_quota_disabled_zero_limit_still_validates() { - // A zero subjects_per_minute is only invalid once the quota is - // enabled; an operator-provided but unused value must not block - // deployments that leave the quota off. - let cfg = MachineQuotaConfig { - enabled: false, - subjects_per_minute: 0, - }; - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn machine_quota_enabled_zero_limit_is_rejected() { - let mut config = minimal_config(); - config.evidence.machine_quota = MachineQuotaConfig { - enabled: true, - subjects_per_minute: 0, - }; - let err = config - .validate() - .expect_err("enabled machine_quota with subjects_per_minute=0 must fail validation"); - match &err { - EvidenceConfigError::InvalidMachineQuotaConfig { reason } => { - assert!(reason.contains("subjects_per_minute")); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn machine_quota_enabled_with_positive_limit_validates() { - let mut config = minimal_config(); - config.evidence.machine_quota = MachineQuotaConfig { - enabled: true, - subjects_per_minute: 1, - }; - assert!(config.validate().is_ok()); -} diff --git a/crates/registry-notary-core/src/config/tests/infrastructure.rs b/crates/registry-notary-core/src/config/tests/infrastructure.rs deleted file mode 100644 index 8c705242b..000000000 --- a/crates/registry-notary-core/src/config/tests/infrastructure.rs +++ /dev/null @@ -1,508 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn state_postgresql_config_parses_and_validates() { - let mut config = minimal_config(); - config.state = serde_norway::from_str( - r#" -storage: postgresql -postgresql: - url_env: REGISTRY_NOTARY_POSTGRES_URL - root_certificate_path: /run/secrets/notary-postgres-ca.pem - connect_timeout_ms: 5000 - operation_timeout_ms: 2000 - max_connections: 12 - sensitive_state_key_env: REGISTRY_NOTARY_SENSITIVE_STATE_KEY -"#, - ) - .expect("PostgreSQL state config parses"); - - config - .validate() - .expect("PostgreSQL state config validates"); - assert_eq!(config.state.storage, STATE_STORAGE_POSTGRESQL); - assert_eq!( - config.state.postgresql.root_certificate_path.as_deref(), - Some(std::path::Path::new("/run/secrets/notary-postgres-ca.pem")) - ); -} - -#[test] -pub(super) fn state_defaults_to_postgresql_contract() { - let config = minimal_config(); - - assert_eq!(config.state.storage, STATE_STORAGE_POSTGRESQL); - assert_eq!( - config.state.postgresql.url_env, - "REGISTRY_NOTARY_POSTGRES_URL" - ); - assert_eq!(config.state.postgresql.connect_timeout_ms, 5_000); - assert_eq!(config.state.postgresql.operation_timeout_ms, 2_000); - assert_eq!(config.state.postgresql.max_connections, 16); - assert_eq!( - config.state.postgresql.sensitive_state_key_env, - "REGISTRY_NOTARY_SENSITIVE_STATE_KEY" - ); - config.validate().expect("default state config validates"); -} - -#[test] -pub(super) fn state_postgresql_config_rejects_invalid_connection_shape() { - let mut config = minimal_config(); - config.state.postgresql.url_env.clear(); - let reason = expect_state_error(&config); - assert!( - reason.contains("state.postgresql.url_env"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.connect_timeout_ms = 0; - let reason = expect_state_error(&config); - assert!( - reason.contains("connect_timeout_ms"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.operation_timeout_ms = 0; - let reason = expect_state_error(&config); - assert!( - reason.contains("operation_timeout_ms"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.max_connections = 0; - let reason = expect_state_error(&config); - assert!(reason.contains("max_connections"), "unexpected: {reason}"); - - config = minimal_config(); - config.state.postgresql.max_connections = STATE_POSTGRESQL_MAX_CONNECTIONS + 1; - let reason = expect_state_error(&config); - assert!(reason.contains("max_connections"), "unexpected: {reason}"); - - config = minimal_config(); - config.state.postgresql.root_certificate_path = Some(std::path::PathBuf::new()); - let reason = expect_state_error(&config); - assert!( - reason.contains("root_certificate_path"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn state_in_memory_is_limited_to_local_single_instance() { - let mut config = minimal_config(); - config.state.storage = STATE_STORAGE_IN_MEMORY.to_string(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.profile = local"), - "unexpected: {reason}" - ); - - config.deployment.profile = Some(crate::deployment::DeploymentProfile::HostedLab); - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.profile = local"), - "unexpected: {reason}" - ); - - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config.deployment.multi_instance = true; - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.multi_instance = false"), - "unexpected: {reason}" - ); - - config.deployment.multi_instance = false; - config - .validate() - .expect("local single-instance in-memory state validates"); -} - -#[test] -pub(super) fn state_rejects_unknown_storage() { - let mut config = minimal_config(); - config.state.storage = "redis".to_string(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("postgresql or in_memory"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn state_postgresql_requires_sensitive_key_env_for_preauthorization() { - let mut config = valid_pre_auth_config(); - config.state.postgresql.sensitive_state_key_env.clear(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("sensitive_state_key_env"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn removed_per_domain_storage_selectors_are_rejected() { - let replay = serde_norway::from_str::( - r#" -evidence: - enabled: true - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -replay: - storage: redis -"#, - ) - .expect_err("top-level replay config was removed"); - assert!(replay.to_string().contains("replay")); - - let credential_status = serde_norway::from_str::( - r#" -enabled: true -base_url: https://issuer.example -storage: redis -redis: - url_env: REGISTRY_NOTARY_STATUS_REDIS_URL -"#, - ) - .expect_err("credential-status storage selectors were removed"); - let error = credential_status.to_string(); - assert!(error.contains("storage") || error.contains("redis")); -} - -#[test] -pub(super) fn credential_status_config_requires_base_url_when_enabled() { - let mut config = minimal_config(); - config.credential_status = serde_norway::from_str( - r#" -enabled: true -base_url: "" -"#, - ) - .expect("credential status config parses"); - - let reason = expect_credential_status_error(&config); - assert!( - reason.contains("credential_status.base_url"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn audit_config_deserializes_rotation_and_syslog_fields() { - let file: EvidenceAuditConfig = serde_norway::from_str( - r#" -sink: file -path: /var/log/registry-notary/audit.jsonl -hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -max_size_mb: 4 -max_files: 3 -"#, - ) - .expect("file audit config is valid YAML"); - - assert_eq!(file.sink, "file"); - assert_eq!( - file.path.as_deref(), - Some("/var/log/registry-notary/audit.jsonl") - ); - assert_eq!(file.max_size_bytes(), 4 * 1024 * 1024); - assert_eq!(file.max_files(), 3); - assert_eq!(file.syslog_socket_path, None); - - let syslog: EvidenceAuditConfig = serde_norway::from_str( - r#" -sink: syslog -hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -syslog_socket_path: /dev/log -"#, - ) - .expect("syslog audit config is valid YAML"); - - assert_eq!(syslog.sink, "syslog"); - assert_eq!(syslog.path, None); - assert_eq!(syslog.max_size_bytes(), 100 * 1024 * 1024); - assert_eq!(syslog.max_files(), 14); - assert_eq!(syslog.syslog_socket_path.as_deref(), Some("/dev/log")); -} - -pub(super) fn valid_federation_config() -> StandaloneRegistryNotaryConfig { - let mut config = minimal_config(); - let mut claim = minimal_claim("disability-status"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("minimal claim is registry backed") - }; - consultations - .get_mut("test_source") - .expect("minimal consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.national_id".to_string(), - ), - ); - config.evidence.claims = vec![claim]; - config.federation = FederationConfig { - enabled: true, - node_id: "did:web:agency-a.example.gov".to_string(), - issuer: "https://agency-a.example.gov".to_string(), - jwks_uri: "https://agency-a.example.gov/federation/jwks.json".to_string(), - federation_api: "https://agency-a.example.gov/federation/v1".to_string(), - supported_protocol_versions: vec![FEDERATION_PROTOCOL_V0_1.to_string()], - signing: FederationSigningConfig { - signing_key: "federation-key".to_string(), - }, - pairwise_subject_hash: FederationPairwiseSubjectHashConfig { - secret_env: "FEDERATION_PAIRWISE_SECRET".to_string(), - }, - peers: vec![FederationPeerConfig { - node_id: "did:web:agency-b.example.gov".to_string(), - issuer: "https://agency-b.example.gov".to_string(), - jwks_uri: "https://agency-b.example.gov/federation/jwks.json".to_string(), - allowed_protocol_versions: vec![FEDERATION_PROTOCOL_V0_1.to_string()], - allowed_purposes: vec![ - "https://purpose.example.gov/social-protection/service-delivery".to_string(), - ], - allowed_profiles: vec!["disability_status_predicate".to_string()], - evaluation_scopes: vec!["registry:consult:test-source".to_string()], - ..FederationPeerConfig::default() - }], - evaluation_profiles: vec![FederationEvaluationProfileConfig { - id: "disability_status_predicate".to_string(), - ruleset: "disability-status-v1".to_string(), - claim_id: "disability-status".to_string(), - subject_id_type: "national_id".to_string(), - disclosure: Some("predicate".to_string()), - max_claim_result_age_seconds: Some(300), - ..FederationEvaluationProfileConfig::default() - }], - ..FederationConfig::default() - }; - config.evidence.signing_keys.insert( - "federation-key".to_string(), - SigningKeyConfig { - provider: SigningKeyProviderConfig::LocalJwkEnv, - alg: FEDERATION_SIGNING_ALG_EDDSA.to_string(), - kid: "agency-a-fed-1".to_string(), - status: SigningKeyStatus::Active, - publish_until_unix_seconds: None, - private_jwk_env: "FEDERATION_SIGNING_KEY".to_string(), - public_jwk_env: String::new(), - module_path: String::new(), - token_label: String::new(), - pin_env: String::new(), - key_label: String::new(), - key_id_hex: String::new(), - path: String::new(), - password_env: String::new(), - }, - ); - config -} - -#[test] -pub(super) fn federation_config_validates_enabled_mvp_shape() { - valid_federation_config() - .validate() - .expect("federation config validates"); -} - -#[test] -pub(super) fn federation_profile_rejects_relay_inputs_unavailable_from_subject() { - let mut config = valid_federation_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("federation claim is registry backed") - }; - consultations - .get_mut("test_source") - .expect("federation consultation exists") - .inputs - .insert("subject_id".to_string(), RelayConsultationInput::TargetId); - - let reason = expect_federation_error(&config); - - assert!( - reason.contains("Relay inputs must derive from request.target.identifiers.national_id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_peer_rejects_profile_with_missing_dependency_scope() { - let mut config = valid_federation_config(); - let mut dependency = config.evidence.claims[0].clone(); - dependency.id = "disability-status-dependency".to_string(); - dependency.required_scopes = vec!["registry:consult:dependency".to_string()]; - config.evidence.claims[0].depends_on = vec![dependency.id.clone()]; - config.evidence.claims.push(dependency); - - let reason = expect_federation_error(&config); - - assert!( - reason.contains( - "evaluation_scopes must include required scope 'registry:consult:dependency'" - ), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_signing_key_must_reference_active_named_signing_key() { - let mut config = valid_federation_config(); - config.federation.signing.signing_key = "missing-key".to_string(); - let reason = expect_federation_error(&config); - assert!( - reason.contains("unknown signing key 'missing-key'"), - "unexpected: {reason}" - ); - - config = valid_federation_config(); - let federation_key = config - .evidence - .signing_keys - .get_mut("federation-key") - .expect("federation signing key exists"); - federation_key.status = SigningKeyStatus::PublishOnly; - federation_key.private_jwk_env = String::new(); - federation_key.public_jwk_env = "FEDERATION_SIGNING_PUBLIC_KEY".to_string(); - let reason = expect_federation_error(&config); - assert!( - reason.contains("must reference an active signing key"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_replay_storage_selector_is_rejected() { - let error = serde_norway::from_str::( - r#" -replay: - storage: redis -"#, - ) - .expect_err("federation replay storage selection was removed"); - assert!(error.to_string().contains("replay")); -} - -#[test] -pub(super) fn federation_peer_private_network_jwks_escape_hatch_deserializes_and_validates() { - let mut config = valid_federation_config(); - let peer: FederationPeerConfig = serde_norway::from_str( - r#" -node_id: did:web:agency-b.example.gov -issuer: https://agency-b.example.gov -jwks_uri: http://federation-peer-jwks:8080/jwks.json -allow_insecure_private_network: true -allowed_protocol_versions: - - registry-notary-federation/v0.1 -allowed_purposes: - - https://purpose.example.gov/social-protection/service-delivery -allowed_profiles: - - disability_status_predicate -evaluation_scopes: - - registry:consult:test-source -"#, - ) - .expect("private-network peer YAML parses"); - assert!(peer.allow_insecure_private_network); - config.federation.peers = vec![peer]; - config - .validate() - .expect("private-network peer JWKS is accepted only with explicit opt-in"); -} - -#[test] -pub(super) fn federation_rejects_removed_source_named_fields() { - let peer_error = serde_norway::from_str::( - r#" -node_id: did:web:agency-b.example.gov -issuer: https://agency-b.example.gov -jwks_uri: https://agency-b.example.gov/.well-known/jwks.json -source_scopes: - - civil_registry:evidence_verification -"#, - ) - .expect_err("the unreleased source_scopes field must not remain an alias"); - assert!(peer_error.to_string().contains("source_scopes")); - - let profile_error = serde_norway::from_str::( - r#" -id: disability_status_predicate -ruleset: disability-status-v1 -claim_id: disability-status -subject_id_type: national_id -max_source_observed_age_seconds: 300 -"#, - ) - .expect_err("the unreleased source-age field must not remain an alias"); - assert!(profile_error - .to_string() - .contains("max_source_observed_age_seconds")); -} - -#[test] -pub(super) fn federation_peer_http_private_network_jwks_requires_escape_hatch() { - let mut config = valid_federation_config(); - config.federation.peers[0].jwks_uri = "http://federation-peer-jwks:8080/jwks.json".to_string(); - let reason = expect_federation_error(&config); - assert!(reason.contains("jwks_uri must be an HTTPS URL")); -} - -#[test] -pub(super) fn federation_config_rejects_bad_did_issuer_binding() { - let mut config = valid_federation_config(); - config.federation.issuer = "https://other-agency.example.gov".to_string(); - let reason = expect_federation_error(&config); - assert!(reason.contains("node_id must bind")); -} - -#[test] -pub(super) fn federation_config_rejects_missing_protocol_and_bad_profile_reference() { - let mut missing_protocol = valid_federation_config(); - missing_protocol - .federation - .supported_protocol_versions - .clear(); - let reason = expect_federation_error(&missing_protocol); - assert!(reason.contains("supported_protocol_versions")); - - let mut bad_profile = valid_federation_config(); - bad_profile.federation.evaluation_profiles[0].claim_id = "unknown".to_string(); - let reason = expect_federation_error(&bad_profile); - assert!(reason.contains("claim_id must reference")); -} - -#[test] -pub(super) fn federation_profile_disclosure_must_be_known_profile() { - let mut config = valid_federation_config(); - config.federation.evaluation_profiles[0].disclosure = Some("raw".to_string()); - let reason = expect_federation_error(&config); - assert!(reason.contains("disclosure must be value, predicate, or redacted")); -} - -// ----------------------------------------------------------------------- -// Finding 3: holder binding / did-method mismatch -// ----------------------------------------------------------------------- diff --git a/crates/registry-notary-core/src/config/tests/issuance.rs b/crates/registry-notary-core/src/config/tests/issuance.rs deleted file mode 100644 index b69661571..000000000 --- a/crates/registry-notary-core/src/config/tests/issuance.rs +++ /dev/null @@ -1,1532 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, preauth::*, root::*}; - -#[test] -pub(super) fn subject_access_is_disabled_by_default() { - let config = minimal_config(); - assert!(!config.subject_access.enabled); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn oid4vci_is_disabled_by_default() { - let config = minimal_config(); - assert!(!config.oid4vci.enabled); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn disabled_default_subject_access_is_omitted_from_serialized_config() { - let config = minimal_config(); - let serialized = serde_json::to_value(&config).expect("config serializes as JSON"); - - assert!( - serialized.get("subject_access").is_none(), - "disabled default subject_access must stay compact when serialized: {serialized}", - ); -} - -#[test] -pub(super) fn disabled_default_oid4vci_is_omitted_from_serialized_config() { - let config = minimal_config(); - let serialized = serde_json::to_value(&config).expect("config serializes as JSON"); - - assert!( - serialized.get("oid4vci").is_none(), - "disabled default oid4vci must stay compact when serialized: {serialized}", - ); -} - -#[test] -pub(super) fn valid_subject_access_config_passes_validation() { - let config = valid_subject_access_config(); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn delegated_subject_access_accepts_compiler_pinned_relay_proofs() { - let config = valid_delegated_subject_access_config(); - config - .validate() - .expect("Relay-backed delegated proof config validates"); -} - -#[test] -pub(super) fn valid_oid4vci_config_passes_validation() { - let config = valid_oid4vci_config(); - assert!(config.validate().is_ok()); -} - -fn valid_representative_oid4vci_config() -> StandaloneRegistryNotaryConfig { - let oid4vci_base = valid_oid4vci_config(); - let mut config = valid_delegated_subject_access_config(); - config.oid4vci = oid4vci_base.oid4vci; - config.auth.access_token_signing = oid4vci_base.auth.access_token_signing; - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config.evidence.signing_keys.insert( - "access-token-key".to_string(), - oid4vci_base - .evidence - .signing_keys - .get("access-token-key") - .expect("access token key exists") - .clone(), - ); - let profile = config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("credential profile exists"); - profile.vct = "http://127.0.0.1:4325/credentials/civil-status".to_string(); - profile - .allowed_claims - .push("dependent-date-of-birth".to_string()); - - let mut dependent = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "date-of-birth") - .expect("registry-backed source claim exists") - .clone(); - dependent.id = "dependent-date-of-birth".to_string(); - dependent.title = "Dependent date of birth".to_string(); - dependent.purpose = Some("dependent_attestation".to_string()); - dependent.depends_on = vec!["guardian-link".to_string()]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependent.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.remove("national_id"); - inputs.insert( - "civil_registration_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - *config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") = dependent; - config.subject_access.allowed_claims.clear(); - config.subject_access.allowed_purposes.clear(); - config.subject_access.allowed_formats.clear(); - config.subject_access.allowed_disclosures.clear(); - - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists"); - credential.claim_id = Some("dependent-date-of-birth".to_string()); - credential.representative_issuance = Some(Oid4vciRepresentativeIssuanceConfig { - ceremony: Oid4vciRepresentativeIssuanceCeremony::DigitallyAuthenticatedRepresentative, - relationship: "guardian".to_string(), - }); - config.credential_status = CredentialStatusConfig { - enabled: true, - base_url: "http://127.0.0.1:4325".to_string(), - ..CredentialStatusConfig::default() - }; - config -} - -#[test] -fn representative_oid4vci_accepts_a_delegation_only_target_contract() { - valid_representative_oid4vci_config() - .validate() - .expect("representative issuance configuration validates"); -} - -#[test] -fn representative_oid4vci_rejects_a_requester_only_credential_root() { - let mut config = valid_representative_oid4vci_config(); - let root = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut root.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.clear(); - inputs.insert( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("does not consume the selected target") - && reason.contains("'requester_id' maps 'requester.identifiers.national_id'") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_a_requester_only_credential_dependency() { - let mut config = valid_representative_oid4vci_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("representative dependency is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.clear(); - inputs.insert( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("does not consume the selected target") - && reason.contains("'requester_id' maps 'requester.identifiers.national_id'") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_delegated_evaluation() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_operations.evaluate = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("allowed_operations.evaluate = true"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_credential_issuance() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_operations.issue_credential = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("allowed_operations.issue_credential = true"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_a_subject_bound_root() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_claims = vec!["dependent-date-of-birth".to_string()]; - config.subject_access.allowed_purposes = vec!["dependent_attestation".to_string()]; - config.subject_access.allowed_formats = - vec!["application/vnd.registry-notary.claim-result+json".to_string()]; - config.subject_access.allowed_disclosures = vec!["value".to_string()]; - config.subject_access.delegation.allowed_relationships[0].target_id_type = - Some("national_id".to_string()); - for claim_id in ["dependent-date-of-birth", "guardian-link"] { - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == claim_id) - .expect("representative claim exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("representative claim is registry-backed"); - }; - for input in consultations - .values_mut() - .flat_map(|consultation| consultation.inputs.values_mut()) - { - if let RelayConsultationInput::TargetIdentifier(path) = input { - *path = "request.target.identifiers.national_id".to_string(); - } - } - } - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains( - "representative_issuance claim 'dependent-date-of-birth' must not appear in subject_access.allowed_claims" - ), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_status_and_an_exact_relationship() { - let mut without_status = valid_representative_oid4vci_config(); - without_status.credential_status.enabled = false; - let reason = expect_oid4vci_error(&without_status); - assert!(reason.contains("credential_status.enabled = true")); - - let mut unknown_relationship = valid_representative_oid4vci_config(); - unknown_relationship - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists") - .representative_issuance - .as_mut() - .expect("representative issuance exists") - .relationship = "unknown".to_string(); - let reason = expect_oid4vci_error(&unknown_relationship); - assert!(reason.contains("unknown relationship 'unknown'")); - - let mut proof_outlives_evaluation = valid_representative_oid4vci_config(); - let proof_age = proof_outlives_evaluation - .subject_access - .delegation - .allowed_relationships[0] - .max_proof_age_seconds; - proof_outlives_evaluation - .subject_access - .token_policy - .max_evaluation_age_seconds = proof_age - 1; - let error = proof_outlives_evaluation - .validate() - .expect_err("relationship proof freshness cannot outlive stored evaluation authority"); - assert!( - error - .to_string() - .contains("must not exceed token_policy.max_evaluation_age_seconds"), - "unexpected error: {error}" - ); -} - -#[test] -fn representative_oid4vci_requires_an_atomic_relationship_proof() { - let mut config = valid_representative_oid4vci_config(); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("relationship proof exists") - .depends_on = vec!["date-of-birth".to_string()]; - - let error = config - .validate() - .expect_err("a relationship proof with dependencies must be rejected"); - assert!( - error - .to_string() - .contains("must not depend_on other claims so the relationship is proven before"), - "unexpected error: {error}" - ); -} - -#[test] -fn representative_oid4vci_rejects_proof_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let proof = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("relationship proof exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut proof.evidence_mode else { - panic!("relationship proof is registry-backed"); - }; - consultations - .first_entry() - .expect("relationship proof consultation exists") - .get_mut() - .inputs - .insert( - "relationship_kind".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.relationship_kind".to_string(), - ), - ); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains( - "must map exactly the authenticated requester identifier and selected target identifier" - ), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_credential_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let root = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut root.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "case_reference".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.case_reference".to_string(), - ), - ); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'case_reference'") - && reason.contains("target.identifiers.case_reference") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_transitive_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("representative dependency is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "case_reference".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.case_reference".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'case_reference'") - && reason.contains("target.identifiers.case_reference") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_wallet_authorization_code_profile() { - let mut config = valid_oid4vci_config(); - config.oid4vci.pre_authorized_code = Oid4vciPreAuthorizedCodeConfig::default(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("pre_authorized_code.enabled = true") - && reason.contains("authorization_code issuance is not supported"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn valid_oid4vci_projection_config_passes_validation() { - let config = valid_oid4vci_projection_config(); - config - .validate() - .expect("projection credential config validates"); -} - -#[test] -pub(super) fn credential_claim_and_profile_bindings_must_be_mutual() { - let mut config = valid_subject_access_config(); - config.subject_access = SubjectAccessConfig::default(); - config.oid4vci = Oid4vciConfig::default(); - config.evidence.claims[0].credential_profiles.clear(); - - let error = config - .validate() - .expect_err("one-sided profile binding must fail startup"); - assert!(matches!( - error, - EvidenceConfigError::InvalidCredentialClaimBinding { ref reason } - if reason.contains("does not reference") - )); -} - -#[test] -pub(super) fn credential_root_accepts_registry_backed_dependency_closure() { - let mut config = valid_subject_access_config(); - let root = config.evidence.claims[0].clone(); - let mut dependency = root.clone(); - dependency.id = "civil-status-source-record".to_string(); - dependency.title = "Civil status source record".to_string(); - dependency.credential_profiles.clear(); - config.evidence.claims[0] - .depends_on - .push(dependency.id.clone()); - config.evidence.claims.push(dependency); - - config - .validate() - .expect("credential root may retain an exact registry-backed dependency closure"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_claim_id_and_claims_together() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claim_id = Some("date-of-birth".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("exactly one of claim_id or claims"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_missing_claim_mode() { - let mut config = valid_oid4vci_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claim_id = None; - credential.claims.clear(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("exactly one of claim_id or claims"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_duplicate_output_paths() { - let mut config = valid_oid4vci_projection_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claims[1].output_path = vec!["birth_date".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("duplicate") && reason.contains("output_path"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_duplicate_claim_ids() { - let mut config = valid_oid4vci_projection_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claims[1].id = "date-of-birth".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("duplicate") && reason.contains("claims[].id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_reserved_output_paths() { - for reserved in [ - "iss", - "sub", - "aud", - "iat", - "nbf", - "exp", - "vct", - "vct#integrity", - "id", - "jti", - "_sd", - "_sd_alg", - "cnf", - "status", - "issuanceDate", - "expirationDate", - ] { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .output_path = vec![reserved.to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("reserved") && reason.contains(reserved), - "unexpected for {reserved}: {reason}" - ); - } -} - -#[test] -pub(super) fn oid4vci_projection_rejects_nested_output_paths_in_v1() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .output_path = vec!["birth".to_string(), "date".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("single segment"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_unknown_claim_reference() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .id = "missing-claim".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_claim_outside_profile_allow_list() { - let mut config = valid_oid4vci_projection_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .allowed_claims - .retain(|claim_id| claim_id != "birth-place"); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("profile") && reason.contains("does not allow"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_mixed_claim_purposes() { - let mut config = valid_oid4vci_projection_config(); - config - .subject_access - .allowed_purposes - .push("other_purpose".to_string()); - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "birth-place") - .expect("projection claim exists"); - claim.purpose = Some("other_purpose".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("share one purpose"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_non_value_default_disclosure() { - let mut config = valid_oid4vci_projection_config(); - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "birth-place") - .expect("projection claim exists"); - claim.disclosure.default = "redacted".to_string(); - claim.disclosure.allowed = vec!["redacted".to_string(), "value".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("must use value as the default disclosure"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_accepts_vct_under_path_prefixed_credential_issuer() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_issuer = "http://127.0.0.1:4325/notary".to_string(); - config.oid4vci.credential_endpoint = - "http://127.0.0.1:4325/notary/oid4vci/credential".to_string(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/notary/credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/notary/credentials/civil-status".to_string(); - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn oid4vci_deserializes_absent_block_with_default() { - let config = valid_subject_access_config(); - assert_eq!(config.oid4vci, Oid4vciConfig::default()); -} - -#[test] -pub(super) fn oid4vci_requires_enabled_subject_access() { - let mut config = valid_oid4vci_config(); - config.subject_access.enabled = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("subject_access.enabled"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_missing_accepted_audiences() { - let mut config = valid_oid4vci_config(); - config.oid4vci.accepted_token_audiences.clear(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("accepted_token_audiences"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_unknown_claim_reference() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claim_id = Some("missing-claim".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_unknown_credential_profile_reference() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .credential_profile = "missing-profile".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown credential profile 'missing-profile'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_non_loopback_http_urls() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_issuer = "http://issuer.example".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("https") && reason.contains("loopback"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_endpoint_without_path() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_endpoint = "http://127.0.0.1:4325".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("endpoint path"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_rejects_vct_outside_credential_issuer() { - let mut config = valid_oid4vci_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "https://vct.example/credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "https://vct.example/credentials/civil-status".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("credential_configurations.vct") - && reason.contains("oid4vci.credential_issuer"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_vct_outside_credentials_path() { - let mut config = valid_oid4vci_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/not-credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/not-credentials/civil-status".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("vct path") && reason.contains("/credentials/"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_duplicate_credential_configuration_vct() { - let mut config = valid_oid4vci_config(); - let duplicate = config - .oid4vci - .credential_configurations - .get("date_of_birth_sd_jwt") - .unwrap() - .clone(); - config - .oid4vci - .credential_configurations - .insert("duplicate_sd_jwt".to_string(), duplicate); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("vct") && reason.contains("unique"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_public_nonce_endpoint() { - let mut config = valid_oid4vci_config(); - config.oid4vci.nonce_endpoint = Some("http://127.0.0.1:4325/oid4vci/nonce".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("nonce_endpoint") && reason.contains("must be omitted"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_nonce_and_proof_timing_bounds() { - let mut config = valid_oid4vci_config(); - config.oid4vci.nonce.ttl_seconds = 0; - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("nonce.ttl_seconds"), "unexpected: {reason}"); - - config.oid4vci.nonce.ttl_seconds = 300; - config.oid4vci.proof.max_age_seconds = 601; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("proof.max_age_seconds"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_algorithm_lists() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .proof_signing_alg_values_supported - .push("ES256".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("ES256"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_binding_methods() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .cryptographic_binding_methods_supported - .push("did:key".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("did:key"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_oidc_authenticator() { - let mut config = valid_subject_access_config(); - config.auth.oidc = None; - config.auth.api_keys.push(EvidenceCredentialConfig { - id: "api".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("API_HASH".to_string()), - path: None, - }, - scopes: Vec::new(), - authorization_details: None, - }); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("auth.oidc"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_unsafe_subject_claim_names() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "national id".to_string(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("token_claim"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_sub_without_explicit_civil_id_opt_in() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "sub".to_string(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allow_sub_as_civil_id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_allows_sub_with_explicit_civil_id_opt_in() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "sub".to_string(); - config.subject_access.subject_binding.allow_sub_as_civil_id = true; - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn subject_access_subject_request_field_only_accepts_subject_id() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/keys - audiences: - - registry-notary-citizen -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectHeader - id_type: national_id -"#, - ) - .expect_err("unsupported request_field variant must fail deserialization"); - let msg = err.to_string(); - assert!( - msg.contains("SubjectHeader") || msg.contains("unknown variant"), - "unexpected error: {msg}" - ); -} - -#[test] -pub(super) fn shared_canonical_oidc_fixture_parses() { - let config = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - audiences: - - registry-notary - jwks_url: https://id.example.gov/oauth/v2/keys - allowed_algorithms: - - EdDSA - allowed_token_types: - - JWT - leeway: 30s -"#, - ) - .expect("shared canonical OIDC fixture parses"); - let oidc = config.auth.oidc.expect("oidc config"); - - assert_eq!(oidc.issuer, "https://id.example.gov"); - assert_eq!(oidc.audiences, vec!["registry-notary"]); - assert_eq!(oidc.jwks_url, "https://id.example.gov/oauth/v2/keys"); - assert_eq!(oidc.allowed_algorithms, vec!["EdDSA"]); - assert_eq!(oidc.allowed_token_types, vec!["JWT"]); - assert_eq!(oidc.leeway, Duration::from_secs(30)); -} - -#[test] -pub(super) fn subject_access_rejects_non_exact_normalization() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/keys - audiences: - - registry-notary-citizen -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectId - id_type: national_id - normalize: lowercase -"#, - ) - .expect_err("unsupported normalize variant must fail deserialization"); - let msg = err.to_string(); - assert!( - msg.contains("lowercase") || msg.contains("unknown variant"), - "unexpected error: {msg}" - ); -} - -#[test] -pub(super) fn subject_access_requires_nonempty_allow_lists() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_claims.clear(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allowed_claims must not be empty"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_unused_allow_list_entries() { - let mut config = valid_subject_access_config(); - config - .subject_access - .allowed_formats - .push("application/unsupported".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allowed_formats entry"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_batch_evaluate_operation() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_operations.batch_evaluate = true; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("batch_evaluate"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_wildcard_wallet_origins() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_wallet_origins = vec!["*".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("wildcards"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_allows_empty_wallet_origins_for_non_browser_flows() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_wallet_origins.clear(); - - config - .validate() - .expect("wallet origins are optional for CLI and server-side flows"); -} - -#[test] -pub(super) fn subject_access_rejects_zero_rate_limits() { - let mut config = valid_subject_access_config(); - config.subject_access.rate_limits.per_principal_per_minute = 0; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("rate_limits"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_allowed_client_or_audience() { - let mut config = valid_subject_access_config(); - config - .subject_access - .citizen_clients - .allowed_client_ids - .clear(); - config - .subject_access - .citizen_clients - .allowed_audiences - .clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("citizen_clients"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_scopes_to_be_mapped() { - let mut config = valid_subject_access_config(); - config.auth.oidc.as_mut().unwrap().scope_map.clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("scope_map"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_required_scope_policy_requires_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.required_scopes.clear(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("scope_policy requires required_scopes"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_optional_scope_policy_still_requires_scope_mapping() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Optional; - config.auth.oidc.as_mut().unwrap().scope_map.clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("scope_map"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_optional_scope_policy_passes_with_required_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Optional; - - config - .validate() - .expect("optional scope policy uses configured subject-access scopes"); -} - -#[test] -pub(super) fn subject_access_disabled_scope_policy_rejects_required_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Disabled; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("scope_policy = disabled"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_leeway_above_token_policy() { - let mut config = valid_subject_access_config(); - config.auth.oidc.as_mut().unwrap().leeway = Duration::from_secs(61); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("leeway"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_unknown_claim_references() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_claims = vec!["missing-claim".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_unallowed_claim_purpose() { - let mut config = valid_subject_access_config(); - config.evidence.claims[0].purpose = Some("machine_verification".to_string()); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("unallowed purpose"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_claim_without_purpose() { - let mut config = valid_subject_access_config(); - config.evidence.claims[0].purpose = None; - - let error = config - .validate() - .expect_err("every registry-backed claim requires a purpose"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimEvidenceMode { ref reason, .. } - if reason.contains("explicit bounded purpose") - )); -} - -#[test] -pub(super) fn subject_access_rejects_unknown_profile_references() { - let mut config = valid_subject_access_config(); - config.subject_access.credential_profiles = vec!["missing-profile".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("unknown profile 'missing-profile'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_citizen_profile_validity_above_ceiling() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .validity_seconds = 601; - - let error = config - .validate() - .expect_err("validity above general ceiling is rejected"); - assert!(matches!( - error, - EvidenceConfigError::InvalidCredentialProfileValidity { - profile, - validity_seconds: 601, - max_validity_seconds: 600, - } if profile == "civil_status_sd_jwt" - )); -} - -#[test] -pub(super) fn subject_access_accepts_citizen_profile_validity_at_configured_ceiling() { - const AGENCY_CREDENTIAL_VALIDITY_SECONDS: u64 = 31_536_000; - let mut config = valid_subject_access_config(); - config.evidence.max_credential_validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS; - config - .subject_access - .token_policy - .max_credential_validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS; - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS as i64; - - config - .validate() - .expect("wallet-held credential validity may reach the configured ceiling"); -} - -#[test] -pub(super) fn subject_access_profile_without_validity_uses_default_under_ceiling() { - let mut config = valid_subject_access_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://issuer.example/credentials/civil-status -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk -allowed_claims: - - date-of-birth -disclosure: - allowed: - - value -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("civil_status_sd_jwt".to_string(), profile); - - config - .validate() - .expect("omitted credential validity defaults under subject-access ceiling"); - assert_eq!( - config - .evidence - .credential_profiles - .get("civil_status_sd_jwt") - .unwrap() - .validity_seconds, - 600 - ); -} - -#[test] -pub(super) fn subject_access_rejects_profile_without_did_holder_binding() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .mode = "none".to_string(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("holder_binding.mode must be did"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_profile_without_required_holder_proof() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .proof_of_possession = None; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("holder_binding.proof_of_possession must be required"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_keeps_did_jwk_proof_of_possession_validation() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .allowed_did_methods - .push("did:key".to_string()); - - let err = config - .validate() - .expect_err("did:key must still fail proof-of-possession validation"); - assert!(matches!( - err, - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { .. } - )); -} - -pub(super) fn second_signing_key() -> SigningKeyConfig { - serde_norway::from_str( - r#" -provider: local_jwk_env -private_jwk_env: ACCESS_TOKEN_KEY -alg: EdDSA -kid: did:web:issuer.example#access-token-key -status: active -"#, - ) - .expect("access-token signing key is valid YAML") -} - -pub(super) fn publish_only_access_token_verification_key(kid: &str) -> SigningKeyConfig { - let mut key = second_signing_key(); - key.kid = kid.to_string(); - key.status = SigningKeyStatus::PublishOnly; - key.private_jwk_env = String::new(); - key.public_jwk_env = "ACCESS_TOKEN_PUBLIC_KEY".to_string(); - key -} - -pub(super) fn test_public_jwk(kid: &str, x: &str) -> PublicJwk { - PublicJwk::parse( - &serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "x": x, - "alg": "EdDSA", - "kid": kid, - }) - .to_string(), - ) - .expect("test public JWK parses") -} - -/// A pre-auth-enabled oid4vci config with a dedicated access-token signing -/// key, distinct from the credential-signing key. -pub(super) fn valid_pre_auth_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_oid4vci_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config - .evidence - .signing_keys - .insert("access-token-key".to_string(), second_signing_key()); - config.oid4vci.pre_authorized_code = serde_norway::from_str( - r#" -enabled: true -tx_code: - required: true - input_mode: numeric - length: 6 -esignet: - client_id: registry-lab-live-client - client_signing_key_id: issuer-key - redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback - authorize_url: https://id.example.gov/authorize - token_url: https://id.example.gov/oauth/v2/token - issuer: https://id.example.gov - jwks_uri: https://id.example.gov/oauth/.well-known/jwks.json - scopes: - - openid -pre_authorized_code_ttl_seconds: 300 -"#, - ) - .expect("pre-auth config is valid YAML"); - config.auth.access_token_signing = serde_norway::from_str( - r#" -enabled: true -issuer: http://127.0.0.1:4325 -audiences: - - http://127.0.0.1:4325 -allowed_algorithms: - - EdDSA -token_typ: registry-notary-access+jwt -signing_key_id: access-token-key -access_token_ttl_seconds: 300 -"#, - ) - .expect("access-token signing config is valid YAML"); - config -} - -pub(super) fn expect_access_token_signing_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("access-token signing config must fail validation") - { - EvidenceConfigError::InvalidAccessTokenSigningConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} diff --git a/crates/registry-notary-core/src/config/tests/preauth.rs b/crates/registry-notary-core/src/config/tests/preauth.rs deleted file mode 100644 index dd5b7ad3e..000000000 --- a/crates/registry-notary-core/src/config/tests/preauth.rs +++ /dev/null @@ -1,561 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, issuance::*, root::*}; - -#[test] -pub(super) fn pre_auth_and_access_token_signing_are_disabled_by_default() { - let config = minimal_config(); - assert!(!config.oid4vci.pre_authorized_code.enabled); - assert!(!config.auth.access_token_signing.enabled); - config - .validate() - .expect("a config that omits the pre-auth blocks still validates"); -} - -#[test] -pub(super) fn omitted_pre_auth_blocks_use_safe_defaults() { - let config = minimal_config(); - let tx_code = &config.oid4vci.pre_authorized_code.tx_code; - assert!(tx_code.required, "tx_code is required by default"); - assert_eq!(tx_code.input_mode, "numeric"); - let signing = &config.auth.access_token_signing; - assert_eq!(signing.allowed_algorithms, vec!["EdDSA".to_string()]); - assert_eq!(signing.token_typ, "registry-notary-access+jwt"); -} - -#[test] -pub(super) fn valid_pre_auth_config_validates() { - valid_pre_auth_config() - .validate() - .expect("a fully-configured pre-auth config validates"); -} - -#[test] -pub(super) fn access_token_signing_enabled_requires_issuer() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.issuer = String::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("issuer")); -} - -#[test] -pub(super) fn access_token_signing_enabled_requires_audiences() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.audiences = Vec::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("audiences")); -} - -#[test] -pub(super) fn access_token_signing_requires_known_signing_key() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.signing_key_id = "missing-key".to_string(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("evidence.signing_keys")); -} - -#[test] -pub(super) fn access_token_signing_key_must_be_distinct_from_credential_key() { - let mut config = valid_pre_auth_config(); - // Point the access-token key at the credential-signing key. - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("distinct from credential profile")); -} - -#[test] -pub(super) fn resolved_signing_key_material_must_not_be_reused_under_distinct_kids() { - let config = valid_pre_auth_config(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let access_token_public_jwk = test_public_jwk( - "did:web:issuer.example#access-token-key", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - let err = config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("access-token-key", &access_token_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect_err("same public key material under different kids must fail"); - - match err { - EvidenceConfigError::InvalidSigningKeyConfig { key, reason } => { - assert_eq!(key, "access-token-key"); - assert!(reason.contains("reuses public key material")); - assert!(reason.contains("issuer-key")); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn resolved_signing_key_material_accepts_distinct_public_keys() { - let config = valid_pre_auth_config(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let access_token_public_jwk = test_public_jwk( - "did:web:issuer.example#access-token-key", - "pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("access-token-key", &access_token_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect("distinct public key material is valid"); -} - -#[test] -pub(super) fn resolved_signing_key_material_allows_esignet_rp_key_to_reuse_credential_material() { - // Issue #173 confines reuse detection to the separated EdDSA roles. The - // eSignet pre-authorized-code RP client key is a relaxed role that is - // deliberately allowed to share material with the credential issuer key, - // so it must not appear in the reuse-scoped set and must not trip the - // detector even when its resolved material matches a credential key. - let mut config = valid_pre_auth_config(); - let mut esignet_key = second_signing_key(); - esignet_key.kid = "did:web:rp.example#esignet-rp-key".to_string(); - config - .evidence - .signing_keys - .insert("esignet-rp-key".to_string(), esignet_key); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let esignet_public_jwk = test_public_jwk( - "did:web:rp.example#esignet-rp-key", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - assert!( - !reuse_scoped_key_ids.contains("esignet-rp-key"), - "the eSignet RP client key must be excluded from reuse-scoped roles" - ); - - config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("esignet-rp-key", &esignet_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect("eSignet RP key may reuse credential key material"); -} - -#[test] -pub(super) fn access_token_signing_key_must_be_active() { - let mut config = valid_pre_auth_config(); - config - .evidence - .signing_keys - .get_mut("access-token-key") - .expect("access-token key exists") - .status = SigningKeyStatus::PublishOnly; - // PublishOnly requires public_jwk_env and no private_jwk_env. - let key = config - .evidence - .signing_keys - .get_mut("access-token-key") - .expect("access-token key exists"); - key.public_jwk_env = "ACCESS_TOKEN_PUBLIC_KEY".to_string(); - key.private_jwk_env = String::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("active signing key")); -} - -#[test] -pub(super) fn access_token_signing_rejects_non_eddsa_algorithms() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.allowed_algorithms = vec!["RS256".to_string()]; - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("EdDSA")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_accept_publish_only_old_keys() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "access-token-key-old".to_string(), - publish_only_access_token_verification_key("did:web:issuer.example#access-token-key-old"), - ); - config.auth.access_token_signing.verification_key_ids = - vec!["access-token-key-old".to_string()]; - - config - .validate() - .expect("publish-only verification keys are valid during rotation"); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_not_repeat_active_key() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.verification_key_ids = vec!["access-token-key".to_string()]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("must not repeat active signing_key_id")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_be_unique() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "access-token-key-old".to_string(), - publish_only_access_token_verification_key("did:web:issuer.example#access-token-key-old"), - ); - config.auth.access_token_signing.verification_key_ids = vec![ - "access-token-key-old".to_string(), - "access-token-key-old".to_string(), - ]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("duplicate key")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_be_publish_only() { - let mut config = valid_pre_auth_config(); - let mut active_old_key = second_signing_key(); - active_old_key.kid = "did:web:issuer.example#access-token-key-old".to_string(); - config - .evidence - .signing_keys - .insert("access-token-key-old".to_string(), active_old_key); - config.auth.access_token_signing.verification_key_ids = - vec!["access-token-key-old".to_string()]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("publish_only")); -} - -/// A local JWK signing key entry. Config validation only checks the alg -/// string and the per-provider fields, so a dummy private_jwk_env name -/// suffices; the JWK itself is not decoded at validation time. -pub(super) fn local_jwk_signing_key_with_alg( - alg: &str, - private_jwk_env: &str, - kid: &str, -) -> SigningKeyConfig { - SigningKeyConfig { - provider: SigningKeyProviderConfig::LocalJwkEnv, - alg: alg.to_string(), - kid: kid.to_string(), - status: SigningKeyStatus::Active, - publish_until_unix_seconds: None, - private_jwk_env: private_jwk_env.to_string(), - public_jwk_env: String::new(), - module_path: String::new(), - token_label: String::new(), - pin_env: String::new(), - key_label: String::new(), - key_id_hex: String::new(), - path: String::new(), - password_env: String::new(), - } -} - -pub(super) fn rs256_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { - local_jwk_signing_key_with_alg(CLIENT_ASSERTION_SIGNING_ALG_RS256, private_jwk_env, kid) -} - -pub(super) fn es256_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { - local_jwk_signing_key_with_alg(CREDENTIAL_SIGNING_ALG_ES256, private_jwk_env, kid) -} - -pub(super) fn expect_signing_key_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("signing-key config must fail validation") - { - EvidenceConfigError::InvalidSigningKeyConfig { reason, .. } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn esignet_rp_client_assertion_key_may_be_rs256() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - config - .validate() - .expect("an RS256 eSignet RP client-assertion key validates"); -} - -#[test] -pub(super) fn pre_auth_client_signing_key_must_exist() { - let mut config = valid_pre_auth_config(); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "missing-rp-key".to_string(); - let reason = match config - .validate() - .expect_err("a missing RP client-assertion key must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("client_signing_key_id")); - assert!(reason.contains("evidence.signing_keys")); -} - -#[test] -pub(super) fn pre_auth_client_signing_key_must_be_active() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - // PublishOnly cannot sign; it requires public_jwk_env and no private_jwk_env. - let key = config - .evidence - .signing_keys - .get_mut("esignet-rp-key") - .expect("esignet rp key exists"); - key.status = SigningKeyStatus::PublishOnly; - key.public_jwk_env = "ESIGNET_RP_PUBLIC_KEY".to_string(); - key.private_jwk_env = String::new(); - let reason = match config - .validate() - .expect_err("an inactive RP client-assertion key must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("active signing key")); -} - -#[test] -pub(super) fn rs256_signing_key_rejected_as_credential_profile_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:issuer.example#esignet-rp-key"), - ); - // Point a credential profile at the RS256 key. - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .signing_key = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("RS256")); - assert!(reason.contains("credential profile")); -} - -#[test] -pub(super) fn es256_signing_key_may_be_credential_profile_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "issuer-p256-key".to_string(), - es256_signing_key("ISSUER_P256_KEY", "did:web:issuer.example#p256-key"), - ); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .signing_key = "issuer-p256-key".to_string(); - config - .validate() - .expect("an ES256 credential profile signing key validates"); -} - -#[test] -pub(super) fn non_eddsa_signing_key_rejected_as_access_token_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - es256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config.auth.access_token_signing.signing_key_id = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("client_signing_key_id")); -} - -#[test] -pub(super) fn non_eddsa_signing_key_rejected_as_federation_key() { - let mut config = valid_federation_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - es256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config.federation.signing.signing_key = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("client_signing_key_id")); -} - -#[test] -pub(super) fn signing_key_alg_must_be_eddsa_es256_or_rs256() { - let mut config = valid_pre_auth_config(); - config - .evidence - .signing_keys - .get_mut("issuer-key") - .expect("issuer-key exists") - .alg = "PS256".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains(CREDENTIAL_SIGNING_ALG_EDDSA)); - assert!(reason.contains(CREDENTIAL_SIGNING_ALG_ES256)); - assert!(reason.contains(CLIENT_ASSERTION_SIGNING_ALG_RS256)); -} - -#[test] -pub(super) fn pre_auth_enabled_requires_oid4vci_enabled() { - let mut config = valid_pre_auth_config(); - config.oid4vci.enabled = false; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("requires oid4vci.enabled = true")); -} - -#[test] -pub(super) fn pre_auth_allows_optional_tx_code() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .validate() - .expect("operators may explicitly disable tx_code when required for wallet interop"); -} - -#[test] -pub(super) fn pre_auth_optional_tx_code_caps_bearer_offer_ttl() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS + 1; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("tx_code.required = false")); - - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .validate() - .expect("bearer-offer mode validates at the explicit cap"); -} - -#[test] -pub(super) fn pre_auth_requires_esignet_client_id() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.esignet.client_id = String::new(); - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("esignet.client_id")); -} - -#[test] -pub(super) fn pre_auth_rejects_out_of_range_code_ttl() { - let mut config = valid_pre_auth_config(); - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = 0; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("pre_authorized_code_ttl_seconds")); -} - -#[test] -pub(super) fn pre_auth_requires_tx_code_rate_limit() { - let mut config = valid_pre_auth_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 0; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("tx_code_attempts_per_code_per_minute")); -} - -#[test] -pub(super) fn pre_auth_optional_tx_code_does_not_require_tx_code_rate_limit() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 0; - config - .validate() - .expect("tx_code attempt limits are only required when tx_code is required"); -} - -#[test] -pub(super) fn pre_auth_userinfo_binding_requires_esignet_userinfo_url() { - let mut config = valid_pre_auth_config(); - config.subject_access.subject_binding.claim_source = SubjectAccessClaimSource::Userinfo; - // Satisfy the resource-server userinfo rule so the failure is - // specifically the missing pre-auth eSignet userinfo endpoint. - if let Some(oidc) = config.auth.oidc.as_mut() { - oidc.userinfo_endpoint = Some("https://id.example.gov/userinfo".to_string()); - } - config.oid4vci.pre_authorized_code.esignet.userinfo_url = String::new(); - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("esignet.userinfo_url"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn pre_auth_userinfo_binding_accepts_configured_userinfo_url() { - let mut config = valid_pre_auth_config(); - config.subject_access.subject_binding.claim_source = SubjectAccessClaimSource::Userinfo; - if let Some(oidc) = config.auth.oidc.as_mut() { - oidc.userinfo_endpoint = Some("https://id.example.gov/userinfo".to_string()); - } - config.oid4vci.pre_authorized_code.esignet.userinfo_url = - "https://id.example.gov/userinfo".to_string(); - config - .validate() - .expect("userinfo-sourced pre-auth binding validates with a userinfo_url"); -} diff --git a/crates/registry-notary-core/src/config/tests/relay.rs b/crates/registry-notary-core/src/config/tests/relay.rs deleted file mode 100644 index 6880695a3..000000000 --- a/crates/registry-notary-core/src/config/tests/relay.rs +++ /dev/null @@ -1,1789 +0,0 @@ -use super::root::{ - expect_subject_access_error, minimal_claim, valid_delegated_subject_access_config, - valid_subject_access_config, -}; -use super::support::minimal_config; -use super::*; - -const CONTRACT_HASH: &str = - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - -fn relay_connection() -> RelayConnectionConfig { - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses") -} - -fn registry_mode(consultation_name: &str) -> ClaimEvidenceMode { - ClaimEvidenceMode::RegistryBacked { - consultations: std::collections::BTreeMap::from([( - consultation_name.to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.person-status.exact".to_string(), - contract_hash: CONTRACT_HASH.to_string(), - }, - inputs: std::collections::BTreeMap::from([( - "subject_id".to_string(), - RelayConsultationInput::TargetId, - )]), - outputs: std::collections::BTreeMap::from([( - "registration_status".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 64, - }, - )]), - }, - )]), - } -} - -fn make_registry_backed(claim: &mut ClaimDefinition, consultation_name: &str) { - claim.evidence_mode = registry_mode(consultation_name); - claim.purpose = Some("benefit-verification".to_string()); - claim.required_scopes = vec!["registry:consult:person-status".to_string()]; - claim.value.value_type = "string".to_string(); - claim.value.nullable = true; - claim.rule = RuleConfig::ConsultationOutput { - consultation: consultation_name.to_string(), - output: "registration_status".to_string(), - }; -} - -fn valid_registry_backed_config() -> StandaloneRegistryNotaryConfig { - let mut config = minimal_config(); - config.evidence.relay = Some(relay_connection()); - let mut claim = minimal_claim("person-status-known"); - make_registry_backed(&mut claim, "person_status"); - config.evidence.claims = vec![claim]; - config -} - -fn registry_backed_config_with_output( - output_name: &str, - output: RelayOutputContract, -) -> StandaloneRegistryNotaryConfig { - let mut config = valid_registry_backed_config(); - let value_type = output.value_type().to_string(); - let claim = &mut config.evidence.claims[0]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation exists") - .outputs = BTreeMap::from([(output_name.to_string(), output)]); - claim.rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: output_name.to_string(), - }; - claim.value.value_type = value_type; - claim.value.nullable = true; - config -} - -fn expect_mode_error(config: &StandaloneRegistryNotaryConfig, expected: &str) { - let error = config - .validate() - .expect_err("invalid claim evidence mode must fail validation"); - assert!( - matches!( - error, - EvidenceConfigError::InvalidClaimEvidenceMode { ref reason, .. } - if reason.contains(expected) - ), - "unexpected error: {error:?}" - ); -} - -#[test] -fn consultation_rules_reject_removed_source_named_variants() { - let output: RuleConfig = serde_norway::from_str( - r#" -type: consultation_output -consultation: person_status -output: registration_status -"#, - ) - .expect("consultation output rule parses"); - assert!(matches!(output, RuleConfig::ConsultationOutput { .. })); - - let matched: RuleConfig = serde_norway::from_str( - r#" -type: consultation_matched -consultation: person_status -"#, - ) - .expect("consultation matched rule parses"); - assert!(matches!(matched, RuleConfig::ConsultationMatched { .. })); - - for removed in [ - "type: extract\nsource: person_status\nfield: registration_status\n", - "type: exists\nsource: person_status\n", - ] { - serde_norway::from_str::(removed) - .expect_err("unreleased source-named rule variants must not remain aliases"); - } -} - -#[test] -fn claim_evidence_mode_is_required_and_closed() { - let missing = serde_norway::from_str::( - r#" -id: missing-mode -title: Missing mode -version: "1" -subject_type: person -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("missing evidence_mode must fail deserialization"); - assert!(missing.to_string().contains("evidence_mode")); - - serde_norway::from_str::( - r#" -id: unknown-mode -title: Unknown mode -version: "1" -subject_type: person -evidence_mode: - type: inferred -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("unknown evidence_mode must fail deserialization"); - - serde_norway::from_str::( - r#" -id: removed-mode -title: Removed mode -version: "1" -subject_type: person -evidence_mode: - type: self_attested -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("the removed self_attested evidence mode must remain rejected"); -} - -#[test] -fn consultation_shape_rejects_native_capabilities_and_redacts_bad_target_mapping() { - serde_norway::from_str::( - r#" -id: native-route -title: Native route -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - person_status: - profile: - id: example.person-status.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - route: /private/person -rule: - type: consultation_matched - consultation: person_status -"#, - ) - .expect_err("native routes must not fit the closed consultation schema"); - - let sensitive_target = "actual-person-identifier"; - let error = serde_norway::from_str::(&format!( - r#" -id: bad-input -title: Bad input -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - person_status: - profile: - id: example.person-status.exact - contract_hash: {CONTRACT_HASH} - inputs: - subject_id: {sensitive_target} -rule: - type: consultation_matched - consultation: person_status -"# - )) - .expect_err("only a closed symbolic target mapping is accepted"); - assert!(!error.to_string().contains(sensitive_target)); -} - -#[test] -fn consultation_accepts_bounded_named_target_identifiers() { - let claim: ClaimDefinition = serde_norway::from_str( - r#" -id: named-identifier -title: Named identifier -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - birth_record: - profile: - id: example.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - uin: request.target.identifiers.UIN -purpose: civil-registration-verification -required_scopes: [registry:consult:birth-record] -value: - type: boolean -rule: - type: consultation_matched - consultation: birth_record -"#, - ) - .expect("named target identifier mapping parses"); - - let ClaimEvidenceMode::RegistryBacked { consultations } = claim.evidence_mode; - let mapping = consultations["birth_record"].inputs["uin"].clone(); - assert_eq!(mapping.request_path(), "request.target.identifiers.UIN"); - assert_eq!(mapping.request_context_path(), "target.identifiers.UIN"); - assert_eq!( - serde_json::to_value(mapping).expect("mapping serializes"), - "request.target.identifiers.UIN" - ); - - for invalid in [ - "request.target.identifiers.", - "request.target.identifiers.1UIN", - "request.target.identifiers.UIN/other", - "request.target.identifiers.UIN value", - ] { - let yaml = format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - uin: {invalid} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("invalid target identifier mapping is rejected"); - } -} - -#[test] -fn consultation_accepts_closed_target_attributes() { - let consultation: RelayConsultationConfig = serde_norway::from_str(&format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - given_name: request.target.attributes.given_name - family_name: request.target.attributes.family_name - birthdate: request.target.attributes.birthdate -outputs: - exists: {{ type: boolean, nullable: false }} -"#, - )) - .expect("closed target attribute mappings parse"); - assert!(consultation.inputs["given_name"].is_target_derived()); - assert!( - !consultation.inputs["given_name"].is_authenticated_target_identifier(), - "caller-supplied target attributes are not authenticated identifiers" - ); - assert_eq!( - consultation.inputs["birthdate"].request_context_path(), - "target.attributes.birthdate" - ); - - for invalid in [ - "request.target.attributes.", - "request.target.attributes.GivenName", - "request.target.attributes.family-name", - "request.target.attributes.name.given", - "request.target.attributes.given name", - ] { - let yaml = format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - value: {invalid} -outputs: - exists: {{ type: boolean, nullable: false }} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("open target attribute mapping is rejected"); - } -} - -#[test] -fn consultation_accepts_only_closed_requester_identifiers() { - let consultation: RelayConsultationConfig = serde_norway::from_str(&format!( - r#" -profile: - id: example.guardian-link.exact - contract_hash: {CONTRACT_HASH} -inputs: - requester_id: request.requester.id - requester_national_id: request.requester.identifiers.national_id -outputs: - established: {{ type: boolean, nullable: true }} -"#, - )) - .expect("closed requester mappings parse"); - assert!(consultation.inputs["requester_id"].is_requester_derived()); - assert_eq!( - consultation.inputs["requester_national_id"].request_context_path(), - "requester.identifiers.national_id" - ); - - for invalid in [ - "requester.id", - "request.requester.identifiers.", - "request.requester.identifiers.1national_id", - "request.requester.attributes.national_id", - ] { - let yaml = format!( - r#" -profile: - id: example.guardian-link.exact - contract_hash: {CONTRACT_HASH} -inputs: - requester: {invalid} -outputs: - established: {{ type: boolean, nullable: true }} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("open requester mapping is rejected"); - } -} - -#[test] -fn registry_backed_claim_accepts_one_pinned_consultation() { - let config = valid_registry_backed_config(); - config - .validate() - .expect("one pinned Relay consultation validates"); - - let serialized = - serde_json::to_value(&config.evidence.claims[0].evidence_mode).expect("mode serializes"); - assert_eq!(serialized["type"], "registry_backed"); - assert_eq!( - serialized["consultations"]["person_status"]["inputs"]["subject_id"], - "target.id" - ); -} - -#[test] -fn relay_activation_allows_independent_profiles_purposes_inputs_and_outputs() { - let mut config = valid_registry_backed_config(); - let mut exists = config.evidence.claims[0].clone(); - exists.id = "person-status-present".to_string(); - exists.title = "Person status known".to_string(); - exists.rule = RuleConfig::ConsultationMatched { - consultation: "person_status".to_string(), - }; - exists.value.value_type = "boolean".to_string(); - config.evidence.claims.push(exists); - config - .validate() - .expect("consultation rules may share one pinned consultation"); - - let mut exists_only = config.clone(); - exists_only.evidence.claims.remove(0); - exists_only - .validate() - .expect("an exists-only journey retains the declared Relay output contract"); - - let mut independent = config.clone(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut independent.evidence.claims[1].evidence_mode - else { - panic!("registry-backed mode") - }; - let consultation = consultations - .get_mut("person_status") - .expect("consultation"); - consultation.profile.id = "example.other-status.exact".to_string(); - consultation.inputs = BTreeMap::from([( - "uin".to_string(), - RelayConsultationInput::TargetIdentifier("request.target.identifiers.UIN".to_string()), - )]); - consultation.outputs = BTreeMap::from([( - "other_status".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 64, - }, - )]); - independent.evidence.claims[1].purpose = Some("civil-registration-verification".to_string()); - independent.evidence.claims[1].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "other_status".to_string(), - }; - independent.evidence.claims[1].value.value_type = "string".to_string(); - independent - .validate() - .expect("independent Relay client identities may coexist"); - - let mut different_output = config.clone(); - different_output.evidence.claims[1].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "other_status".to_string(), - }; - different_output.evidence.claims[1].value.value_type = "string".to_string(); - expect_mode_error( - &different_output, - "must name a declared consultation output", - ); -} - -#[test] -fn registry_backed_consultation_accepts_one_to_sixteen_injective_inputs() { - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs = BTreeMap::from([ - ("subject_id".to_string(), RelayConsultationInput::TargetId), - ( - "birth_date".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.birth_date".to_string(), - ), - ), - ( - "country_code".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.country_code".to_string(), - ), - ), - ( - "registry_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.registry_id".to_string(), - ), - ), - ]); - config.validate().expect("four typed inputs are valid"); - - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - let inputs = &mut consultations - .get_mut("person_status") - .expect("consultation") - .inputs; - for index in 5..=16 { - inputs.insert( - format!("input_{index}"), - RelayConsultationInput::TargetIdentifier(format!( - "request.target.identifiers.input_{index}" - )), - ); - } - config.validate().expect("sixteen typed inputs are valid"); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .insert( - "input_17".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.input_17".to_string(), - ), - ); - expect_mode_error(&config, "one to sixteen"); - - let mut duplicate_mapping = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut duplicate_mapping.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .insert( - "duplicate_subject".to_string(), - RelayConsultationInput::TargetId, - ); - expect_mode_error(&duplicate_mapping, "injectively"); -} - -#[test] -fn registry_backed_claim_requires_relay_connection() { - let mut config = valid_registry_backed_config(); - config.evidence.relay = None; - expect_mode_error(&config, "requires evidence.relay"); -} - -#[test] -fn registry_backed_claim_enforces_gates_cardinality_and_rule_binding() { - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].purpose = None; - expect_mode_error(&config, "explicit bounded purpose token"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].required_scopes.clear(); - expect_mode_error(&config, "required_scopes"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].operations.batch_evaluate.enabled = true; - config - .validate() - .expect("registry-backed claims may enable the pre-1.0 batch contract"); - - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - let duplicate = consultations - .first_key_value() - .expect("consultation") - .1 - .clone(); - consultations.insert("other_status".to_string(), duplicate); - expect_mode_error(&config, "exactly one named consultation"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationMatched { - consultation: "other_status".to_string(), - }; - expect_mode_error(&config, "rule.consultation must match"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::Cel { - expression: "true".to_string(), - }; - config - .validate() - .expect("registry-backed CEL may evaluate the declared output namespace"); -} - -#[test] -fn registry_backed_claim_matches_relay_identifier_and_scalar_contract() { - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .profile - .id = "Uppercase.profile".to_string(); - expect_mode_error(&config, "profile.id"); - - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .clear(); - expect_mode_error(&config, "one to sixteen typed request mappings"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationMatched { - consultation: "person_status".to_string(), - }; - expect_mode_error( - &config, - "consultation_matched claim value.type must be boolean", - ); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "nested.status".to_string(), - }; - config.evidence.claims[0].value.value_type = "string".to_string(); - expect_mode_error(&config, "one top-level Relay output name"); - - for unsupported in ["boolean", "integer", "number", "object"] { - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "registration_status".to_string(), - }; - config.evidence.claims[0].value.value_type = unsupported.to_string(); - expect_mode_error(&config, "must match its declared output"); - } -} - -#[test] -fn relay_connection_is_single_closed_bounded_and_redacted() { - let relay: RelayConnectionConfig = serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/private-relay.jwt -root_certificate_path: /run/secrets/private-relay-ca.pem -allowed_private_cidrs: [10.42.0.0/16, fd42::/64] -"#, - ) - .expect("Relay connection parses"); - relay.validate(None).expect("Relay connection validates"); - assert_eq!(relay.allowed_private_cidrs.len(), 2); - assert_eq!(relay.max_in_flight, 8); - let debug = format!("{relay:?}"); - assert!(!debug.contains("relay.internal.example")); - assert!(!debug.contains("private-relay.jwt")); - assert!(!debug.contains("private-relay-ca.pem")); - assert!(debug.contains("custom_root_certificate: true")); - assert!(!debug.contains("10.42.0.0/16")); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/relay.jwt -retry_on_5xx: true -"#, - ) - .expect_err("retry controls are not part of the closed Relay connection"); - - for workload_client_id in ["", "Registry-Notary", "registry:notary"] { - let mut relay = relay_connection(); - relay.workload_client_id = workload_client_id.to_string(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("workload_client_id") - )); - } -} - -#[test] -fn relay_custom_root_requires_an_https_origin_and_canonical_absolute_path() { - let mut relay = relay_connection(); - relay.root_certificate_path = Some(PathBuf::from("relay-ca.pem")); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("root_certificate_path") - )); - - relay.root_certificate_path = Some(PathBuf::from("/run/secrets/relay-ca.pem")); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("requires an https") - )); -} - -#[test] -fn relay_token_file_and_private_cidrs_are_exact_and_bounded() { - relay_connection() - .validate(None) - .expect("target POSIX token path is valid on every configuration host"); - for token_file in [ - PathBuf::from("relative/relay.jwt"), - PathBuf::from("/run/secrets/../relay.jwt"), - PathBuf::from("/run/./secrets/relay.jwt"), - PathBuf::from("//run/secrets/relay.jwt"), - PathBuf::from("/run/secrets/relay.jwt/"), - PathBuf::from("/run\\secrets\\relay.jwt"), - PathBuf::from("C:\\run\\secrets\\relay.jwt"), - PathBuf::from("/"), - ] { - let mut relay = relay_connection(); - relay.token_file = token_file; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("token_file") - )); - } - - for cidrs in [ - vec!["10.42.0.1/16"], - vec!["93.184.216.0/24"], - vec!["100.100.100.200/32"], - vec!["fd00:ec2::254/128"], - vec!["10.42.0.0/16", "10.42.0.0/16"], - ] { - let mut relay = relay_connection(); - relay.allowed_private_cidrs = cidrs - .into_iter() - .map(|cidr| cidr.parse().expect("test CIDR parses")) - .collect(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("allowed_private_cidrs") - )); - } - - let mut relay = relay_connection(); - relay.allowed_private_cidrs = (0..=16) - .map(|index| { - format!("10.{index}.0.0/16") - .parse() - .expect("test CIDR parses") - }) - .collect(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("more than 16") - )); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_env: REMOVED_RELAY_TOKEN -"#, - ) - .expect_err("the removed static environment-token mode is rejected"); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/relay.jwt -token_issuer: REMOVED_DUPLICATE_IDENTITY -"#, - ) - .expect_err("duplicated local workload-token semantics are rejected"); -} - -#[test] -fn relay_connection_concurrency_is_operator_bounded() { - for value in [0, 65] { - let mut relay = relay_connection(); - relay.max_in_flight = value; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("max_in_flight") - )); - } - - let mut relay = relay_connection(); - relay.max_in_flight = 64; - relay.validate(None).expect("hard ceiling is accepted"); -} - -#[test] -fn relay_connection_is_rejected_when_no_registry_backed_claim_uses_it() { - let mut config = minimal_config(); - config.evidence.claims.clear(); - config.evidence.relay = Some(relay_connection()); - let error = config - .validate() - .expect_err("an unused Relay connection must not be silently accepted"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { ref reason } - if reason.contains("at least one registry_backed claim") - )); -} - -#[test] -fn registry_backed_notary_reserves_five_seconds_around_the_service_hop() { - let mut config = valid_registry_backed_config(); - config.server.request_timeout = Duration::from_secs(29) + Duration::from_millis(999); - let error = config - .validate() - .expect_err("the request timeout must not expire before the Relay service hop"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { ref reason } - if reason.contains("at least 30 seconds") - )); - - config.server.request_timeout = Duration::from_secs(30); - config - .validate() - .expect("the outer request minimum reserves five seconds around the service hop"); -} - -#[test] -fn relay_connection_requires_https_origin_or_explicit_loopback() { - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://relay.internal.example".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - let error = config - .validate() - .expect_err("remote HTTP must fail despite localhost escape"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { .. } - )); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config - .validate() - .expect("explicit HTTP loopback is permitted for a colocated Relay"); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://localhost:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config - .validate() - .expect_err("local development HTTP requires a literal loopback origin"); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::HostedLab); - config - .validate() - .expect("hosted Notary can use the paired Relay through its loopback namespace"); - - let sensitive_path = "private-route"; - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = format!("https://relay.internal.example/{sensitive_path}"); - config.evidence.relay = Some(relay); - let error = config - .validate() - .expect_err("Relay base URL must be an origin in v1"); - let rendered = error.to_string(); - assert!(rendered.contains("path exactly /")); - assert!(!rendered.contains("relay.internal.example")); - assert!(!rendered.contains(sensitive_path)); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "https://relay.internal.example/private/..".to_string(); - config.evidence.relay = Some(relay); - config - .validate() - .expect_err("a resource path must fail before URL normalization"); -} - -#[test] -fn local_private_http_relay_requires_an_exact_ip_allowlist_entry() { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://10.89.0.4:8080".to_string(); - relay.allowed_private_cidrs = vec!["10.89.0.4/32".parse().expect("test CIDR parses")]; - config - .validate() - .expect("local profile accepts the exact private Relay IP and singleton CIDR"); - - for (base_url, cidrs) in [ - ("http://10.89.0.4:8080", vec![]), - ("http://10.89.0.4:8080", vec!["10.89.0.0/24"]), - ("http://10.89.0.4:8080", vec!["10.89.0.5/32"]), - ("http://relay.internal.example:8080", vec!["10.89.0.4/32"]), - ("http://169.254.169.254:8080", vec!["169.254.169.254/32"]), - ("http://100.100.100.200:8080", vec!["100.100.100.200/32"]), - ("http://0.0.0.0:8080", vec!["0.0.0.0/32"]), - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = base_url.to_string(); - relay.allowed_private_cidrs = cidrs - .into_iter() - .map(|cidr| cidr.parse().expect("test CIDR parses")) - .collect(); - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn private_http_relay_is_rejected_outside_the_local_profile() { - for profile in [ - None, - Some(crate::deployment::DeploymentProfile::HostedLab), - Some(crate::deployment::DeploymentProfile::Production), - Some(crate::deployment::DeploymentProfile::EvidenceGrade), - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = profile; - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://10.89.0.4:8080".to_string(); - relay.allowed_private_cidrs = vec!["10.89.0.4/32".parse().expect("test CIDR parses")]; - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn signed_private_service_http_relay_is_profile_independent_and_explicit() { - for profile in [ - crate::deployment::DeploymentProfile::Local, - crate::deployment::DeploymentProfile::HostedLab, - crate::deployment::DeploymentProfile::Production, - crate::deployment::DeploymentProfile::EvidenceGrade, - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(profile); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://registry-relay-consultation:8080".to_string(); - relay.allow_insecure_private_network = true; - config - .validate() - .expect("signed private service HTTP is valid in every deployment profile"); - } - - for base_url in [ - "http://registry-relay-consultation:8080", - "http://127.0.0.1:8080", - "https://registry-relay-consultation:8080", - ] { - let mut config = valid_registry_backed_config(); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = base_url.to_string(); - relay.allow_insecure_private_network = - base_url != "http://registry-relay-consultation:8080"; - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn removed_plugin_rule_is_rejected_during_deserialization() { - let error = serde_norway::from_str::("type: plugin\nplugin: unavailable\n") - .expect_err("the unreleased plugin rule must not remain accepted"); - assert!(error.to_string().contains("plugin")); -} - -#[test] -fn subject_access_allows_exact_subject_bound_registry_claims() { - let mut config = valid_subject_access_config(); - config.evidence.relay = Some(relay_connection()); - make_registry_backed(&mut config.evidence.claims[0], "civil_status"); - config.evidence.claims[0].purpose = Some("citizen_subject_access".to_string()); - config.evidence.claims[0].required_scopes = vec!["subject_access".to_string()]; - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("claim is registry backed"); - }; - consultations - .get_mut("civil_status") - .expect("consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier("target.identifiers.national_id".to_string()), - ); - config - .validate() - .expect("the exact authenticated subject identifier may be consulted"); - - let mut wrong_binding = config.clone(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut wrong_binding.evidence.claims[0].evidence_mode - else { - panic!("claim is registry backed"); - }; - consultations - .get_mut("civil_status") - .expect("consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier("target.identifiers.other_person".to_string()), - ); - let reason = expect_subject_access_error(&wrong_binding); - assert!(reason.contains("outside the authenticated subject binding")); -} - -#[test] -fn registry_backed_claim_accepts_registry_backed_dependency() { - let mut config = valid_registry_backed_config(); - let mut dependency = minimal_claim("registry-dependency"); - make_registry_backed(&mut dependency, "registry_dependency"); - config.evidence.claims[0] - .depends_on - .push(dependency.id.clone()); - config.evidence.claims.push(dependency); - config - .validate() - .expect("registry-backed claim dependency is supported"); -} - -#[test] -fn claim_dependency_graph_has_fixed_v1_node_and_edge_bounds() { - let mut too_many_nodes = minimal_config(); - too_many_nodes.evidence.claims = (0..=MAX_CLAIM_DEPENDENCY_NODES_V1) - .map(|index| minimal_claim(&format!("claim-{index}"))) - .collect(); - assert!(matches!( - too_many_nodes.validate(), - Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { nodes, .. }) - if nodes == MAX_CLAIM_DEPENDENCY_NODES_V1 + 1 - )); - - let mut too_many_edges = minimal_config(); - for index in 0..24 { - let mut claim = minimal_claim(&format!("claim-{index}")); - claim.depends_on = (0..index) - .map(|dependency| format!("claim-{dependency}")) - .collect(); - too_many_edges.evidence.claims.push(claim); - } - assert!(matches!( - too_many_edges.validate(), - Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { edges, .. }) - if edges > MAX_CLAIM_DEPENDENCY_EDGES_V1 - )); -} - -#[test] -fn delegated_subject_access_allows_registry_backed_dependency_closures() { - let config = valid_delegated_subject_access_config(); - config - .validate() - .expect("configured delegated Relay proof edge validates"); - - let mut registry_dependent = config; - let delegated = registry_dependent - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated claim"); - make_registry_backed(delegated, "civil_status"); - delegated.purpose = Some("dependent_attestation".to_string()); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut delegated.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - registry_dependent - .validate() - .expect("a delegated root may use its committed registry-backed dependency closure"); -} - -#[test] -fn delegated_subject_access_rejects_non_canonical_root_inputs() { - let cases = [ - RelayConsultationInput::TargetAttribute("request.target.attributes.birthdate".to_string()), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.national_id".to_string(), - ), - ]; - - for input in cases { - let mut config = valid_delegated_subject_access_config(); - delegated_claim_consultation_mut(&mut config) - .inputs - .insert("national_id".to_string(), input); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'national_id'") - && reason.contains("target.identifiers.civil_registration_id") - && reason.contains("requester.identifiers.national_id"), - "unexpected error: {reason}" - ); - } -} - -#[test] -fn delegated_subject_access_rejects_non_canonical_transitive_inputs() { - let mut config = valid_delegated_subject_access_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("delegated dependency is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "national_id".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.birthdate".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'national_id'") - && reason.contains("target.attributes.birthdate") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn delegated_relay_proof_requires_requester_target_boolean_and_purpose_alignment() { - let mut missing_requester = valid_delegated_subject_access_config(); - delegated_proof_consultation_mut(&mut missing_requester) - .inputs - .retain(|_, input| input.is_target_derived()); - let reason = expect_subject_access_error(&missing_requester); - assert!(reason.contains("authenticated target identifier")); - - let mut missing_target = valid_delegated_subject_access_config(); - delegated_proof_consultation_mut(&mut missing_target) - .inputs - .retain(|_, input| input.is_requester_derived()); - let reason = expect_subject_access_error(&missing_target); - assert!(reason.contains("authenticated target identifier")); - - let mut caller_supplied_attribute = valid_delegated_subject_access_config(); - let consultation = delegated_proof_consultation_mut(&mut caller_supplied_attribute); - consultation.inputs.remove("target_id"); - consultation.inputs.insert( - "target_context".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.person_sequence".to_string(), - ), - ); - let reason = expect_subject_access_error(&caller_supplied_attribute); - assert!(reason.contains("authenticated target identifier")); - - let mut non_boolean = valid_delegated_subject_access_config(); - let proof = non_boolean - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim"); - proof.value.value_type = "string".to_string(); - delegated_proof_consultation_mut(&mut non_boolean).outputs = BTreeMap::from([( - "established".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 16, - }, - )]); - let reason = expect_subject_access_error(&non_boolean); - assert!(reason.contains("must produce a boolean result")); - - let mut wrong_purpose = valid_delegated_subject_access_config(); - wrong_purpose - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim") - .purpose = Some("different-purpose".to_string()); - let reason = expect_subject_access_error(&wrong_purpose); - assert!(reason.contains("must declare the same purpose")); -} - -fn delegated_proof_consultation_mut( - config: &mut StandaloneRegistryNotaryConfig, -) -> &mut RelayConsultationConfig { - let proof = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut proof.evidence_mode else { - panic!("delegated proof is registry backed") - }; - consultations - .get_mut("guardian_link") - .expect("delegated proof consultation") -} - -fn delegated_claim_consultation_mut( - config: &mut StandaloneRegistryNotaryConfig, -) -> &mut RelayConsultationConfig { - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated claim exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("delegated consultation exists") -} - -#[test] -fn registry_backed_cel_accepts_one_complete_typed_output_map_and_full_date_variable() { - let mut config = valid_registry_backed_config(); - config.evidence.variables.insert( - "as_of_date".to_string(), - RequestVariableConfig { - from: "request.variables.as_of_date".to_string(), - value_type: RequestVariableType::Date, - }, - ); - let claim = &mut config.evidence.claims[0]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("registry-backed mode") - }; - let consultation = consultations - .get_mut("person_status") - .expect("consultation exists"); - consultation.outputs = BTreeMap::from([ - ( - "date_of_birth".to_string(), - RelayOutputContract::Date { nullable: true }, - ), - ( - "sequence".to_string(), - RelayOutputContract::Integer { - nullable: false, - minimum: 0, - maximum: 9_007_199_254_740_991, - }, - ), - ( - "status".to_string(), - RelayOutputContract::String { - nullable: false, - max_bytes: 64, - }, - ), - ]); - claim.rule = RuleConfig::Cel { - expression: "person_status.matched && person_status.date_of_birth != null ? date.age_on(person_status.date_of_birth, as_of_date) >= 18 : false".to_string(), - }; - claim.value.value_type = "boolean".to_string(); - claim.value.nullable = false; - config - .validate() - .expect("typed registry CEL config validates"); - - let mut generic_number = config.clone(); - generic_number.evidence.claims[0].value.value_type = "number".to_string(); - expect_mode_error(&generic_number, "generic Number is not supported"); - - config - .evidence - .variables - .get_mut("as_of_date") - .expect("variable exists") - .from = "request.target.attributes.as_of_date".to_string(); - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRequestVariableConfig { .. }) - )); -} - -#[test] -fn authored_typed_output_and_variable_yaml_shape_is_closed() { - let evidence: EvidenceConfig = serde_norway::from_str( - r#" -enabled: true -variables: - as_of_date: - from: request.variables.as_of_date - type: date -"#, - ) - .expect("authored request-variable union parses"); - assert_eq!( - evidence.variables["as_of_date"].value_type, - RequestVariableType::Date - ); - - let consultation: RelayConsultationConfig = serde_norway::from_str( - r#" -profile: - id: opencrvs.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -inputs: - uin: request.target.identifiers.UIN -outputs: - active: { type: boolean, nullable: false } - date_of_birth: { type: date, nullable: true } - sequence: { type: integer, nullable: false, minimum: 0, maximum: 9007199254740991 } - given_name: { type: string, nullable: true, max_bytes: 128 } - parents: - type: array - nullable: false - max_bytes: 4096 - max_items: 2 - items: - type: object - nullable: false - max_bytes: 2048 - fields: - type: - required: true - schema: { type: string, nullable: false, max_bytes: 16 } - name: - required: true - schema: { type: string, nullable: false, max_bytes: 256 } - identifier: - required: false - schema: { type: string, nullable: true, max_bytes: 128 } -"#, - ) - .expect("authored typed consultation parses"); - assert_eq!(consultation.outputs.len(), 5); - assert!(matches!( - consultation.outputs.get("date_of_birth"), - Some(RelayOutputContract::Date { nullable: true }) - )); - let parents = consultation - .outputs - .get("parents") - .expect("parents contract exists"); - let RelayOutputContract::Array { - nullable, - max_bytes, - max_items, - items, - } = parents - else { - panic!("parents is an array contract") - }; - assert!(!nullable); - assert_eq!(*max_bytes, 4096); - assert_eq!(*max_items, 2); - assert!(matches!( - items.as_ref(), - RelayOutputContract::Object { fields, .. } - if !fields["identifier"].required - && matches!( - fields["name"].schema.as_ref(), - RelayOutputContract::String { - nullable: false, - max_bytes: 256 - } - ) - )); - - assert!(serde_norway::from_str::( - r#" -profile: - id: opencrvs.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -inputs: { uin: request.target.identifiers.UIN } -outputs: - score: { type: number, nullable: false } -"#, - ) - .is_err()); -} - -#[test] -fn recursive_output_contract_serde_is_closed_and_preserves_scalar_shapes() { - let wire = serde_json::json!({ - "type": "object", - "nullable": true, - "max_bytes": 1024, - "fields": { - "active": { - "required": true, - "schema": { "type": "boolean", "nullable": false } - }, - "children": { - "required": false, - "schema": { - "type": "array", - "nullable": true, - "max_bytes": 512, - "max_items": 4, - "items": { "type": "integer", "nullable": false, "minimum": 0, "maximum": 9 } - } - } - } - }); - let contract: RelayOutputContract = - serde_json::from_value(wire.clone()).expect("recursive contract parses"); - assert_eq!( - serde_json::to_value(contract).expect("recursive contract serializes"), - wire - ); - - for invalid in [ - serde_json::json!({ - "type": "object", - "nullable": false, - "max_bytes": 64, - "fields": {}, - "open": true - }), - serde_json::json!({ - "type": "object", - "nullable": false, - "max_bytes": 64, - "fields": { - "value": { - "required": true, - "schema": { "type": "string", "nullable": false, "max_bytes": 8 }, - "default": "secret" - } - } - }), - serde_json::json!({ - "type": "array", - "nullable": false, - "max_bytes": 64, - "max_items": 2, - "items": { - "type": "string", - "nullable": false, - "max_bytes": 8, - "pattern": ".*" - } - }), - ] { - serde_json::from_value::(invalid) - .expect_err("recursive contract variants must remain closed"); - } -} - -#[test] -fn recursive_output_contract_validates_exact_values_and_composite_byte_bounds() { - let parent = RelayOutputContract::Object { - nullable: false, - max_bytes: 128, - fields: BTreeMap::from([ - ( - "birth_date".to_string(), - RelayOutputObjectFieldContract { - required: false, - schema: Box::new(RelayOutputContract::Date { nullable: false }), - }, - ), - ( - "name".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 8, - }), - }, - ), - ( - "sequence".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Integer { - nullable: false, - minimum: 0, - maximum: 2, - }), - }, - ), - ]), - }; - let parents = RelayOutputContract::Array { - nullable: true, - max_bytes: 256, - max_items: 2, - items: Box::new(parent), - }; - - assert!(parents.validates_value(&serde_json::Value::Null)); - assert!(parents.validates_value(&serde_json::json!([ - { "name": "Ada", "sequence": 0, "birth_date": "2000-02-29" }, - { "name": "Grace", "sequence": 2 } - ]))); - for invalid in [ - serde_json::json!({ "name": "Ada", "sequence": 0 }), - serde_json::json!([{ "sequence": 0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "unknown": true }]), - serde_json::json!([{ "name": "too-long-name", "sequence": 0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 3 }]), - serde_json::json!([{ "name": "Ada", "sequence": 1.0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "birth_date": null }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "birth_date": "2001-02-29" }]), - serde_json::json!([ - { "name": "Ada", "sequence": 0 }, - { "name": "Grace", "sequence": 1 }, - { "name": "Linus", "sequence": 2 } - ]), - ] { - assert!( - !parents.validates_value(&invalid), - "wrong type, shape, or bound must fail" - ); - } - - let byte_bounded = RelayOutputContract::Object { - nullable: false, - max_bytes: 8, - fields: BTreeMap::from([( - "a".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 64, - }), - }, - )]), - }; - assert!( - !byte_bounded.validates_value(&serde_json::json!({ "a": "x" })), - "compact serialized object is nine bytes" - ); - - let nested_byte_bounded = RelayOutputContract::Object { - nullable: false, - max_bytes: 64, - fields: BTreeMap::from([( - "inner".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(byte_bounded), - }, - )]), - }; - assert!( - !nested_byte_bounded.validates_value(&serde_json::json!({ "inner": { "a": "x" } })), - "every composite enforces its own serialized byte bound" - ); -} - -#[test] -fn direct_consultation_output_accepts_object_and_array_claim_types_but_cel_stays_scalar() { - let parent_fields = BTreeMap::from([( - "name".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 128, - }), - }, - )]); - let object = RelayOutputContract::Object { - nullable: false, - max_bytes: 1024, - fields: parent_fields.clone(), - }; - registry_backed_config_with_output("parent", object) - .validate() - .expect("direct object-valued consultation output validates"); - - let array = RelayOutputContract::Array { - nullable: false, - max_bytes: 4096, - max_items: 2, - items: Box::new(RelayOutputContract::Object { - nullable: false, - max_bytes: 1024, - fields: parent_fields, - }), - }; - let config = registry_backed_config_with_output("parents", array); - config - .validate() - .expect("direct array-valued consultation output validates"); - - let mut wrong_claim_type = config.clone(); - wrong_claim_type.evidence.claims[0].value.value_type = "object".to_string(); - expect_mode_error(&wrong_claim_type, "must match its declared output"); - - let mut cel = config; - cel.evidence.claims[0].rule = RuleConfig::Cel { - expression: "true".to_string(), - }; - cel.evidence.claims[0].value.value_type = "boolean".to_string(); - cel.evidence.claims[0].value.nullable = false; - cel.validate() - .expect("a scalar CEL claim may share a consultation with direct composite outputs"); -} - -#[test] -fn recursive_output_schema_enforces_envelope_compatible_platform_bounds() { - fn array_layers(depth: usize) -> RelayOutputContract { - if depth == 1 { - RelayOutputContract::Boolean { nullable: false } - } else { - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 1, - items: Box::new(array_layers(depth - 1)), - } - } - } - - registry_backed_config_with_output("value", array_layers(6)) - .validate() - .expect("six output-schema levels fit beneath the two-level Relay envelope"); - - for output in [ - array_layers(7), - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1 + 1, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1 + 1, - max_items: 1, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 256, - items: Box::new(RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 16, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..33) - .map(|index| { - ( - format!("field_{index}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - ) - }) - .collect(), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: BTreeMap::from([( - "x".repeat(MAX_RELAY_OUTPUT_NAME_BYTES_V1 + 1), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - )]), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..8) - .map(|outer| { - ( - format!("outer_{outer}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..32) - .map(|inner| { - ( - format!("inner_{inner}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { - nullable: false, - }), - }, - ) - }) - .collect(), - }), - }, - ) - }) - .collect(), - }, - ] { - expect_mode_error( - ®istry_backed_config_with_output("value", output), - "consultation output schema", - ); - } -} diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs deleted file mode 100644 index aed0bc237..000000000 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ /dev/null @@ -1,1279 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, issuance::*, preauth::*}; - -#[test] -pub(super) fn batch_limits_accept_only_lower_or_equal_platform_overrides() { - let mut config = valid_subject_access_config(); - config.evidence.inline_batch_limit = MAX_BATCH_EVALUATION_MEMBERS_V1; - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = MAX_BATCH_EVALUATION_MEMBERS_V1; - config - .validate() - .expect("the hard platform ceiling is a valid operator value"); - - config.evidence.inline_batch_limit = 17; - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = 9; - config - .validate() - .expect("operators may lower either batch limit"); -} - -#[test] -pub(super) fn batch_limits_reject_zero_and_values_above_the_platform_ceiling() { - for invalid in [0, MAX_BATCH_EVALUATION_MEMBERS_V1 + 1] { - let mut config = valid_subject_access_config(); - config.evidence.inline_batch_limit = invalid; - let error = config - .validate() - .expect_err("the global batch override must stay within the platform range"); - assert!(matches!( - error, - EvidenceConfigError::InvalidBatchConfig { .. } - )); - - let mut config = valid_subject_access_config(); - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = invalid; - let error = config - .validate() - .expect_err("the claim batch override must stay within the platform range"); - assert!(matches!( - error, - EvidenceConfigError::InvalidBatchConfig { .. } - )); - } -} - -#[test] -pub(super) fn gate_input_defaults_are_low_risk_for_minimal_config() { - let config = minimal_config(); - let input = config.gate_input(); - // A minimal config uses PostgreSQL state and stdout audit by default. - assert!(!input.state_in_memory); - assert!(!input.audit_sink_class_durable); - // No high-risk modes are declared. - assert!(!input.requires_shared_state()); - // Local YAML config without config_trust is unsigned. - assert!(input.config_unsigned); - // Admin listener is disabled by default, so no shared exposure. - assert!(!input.admin_shared_exposure); - // OpenAPI requires auth by default. - assert!(!input.openapi_public); - // An active but unreferenced key is not a Notary signing role. - assert!(!input.signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_reports_federation_as_high_risk() { - let mut config = minimal_config(); - config.federation.enabled = true; - assert!(config.gate_input().requires_shared_state()); -} - -#[test] -pub(super) fn gate_input_reports_durable_audit_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(config.gate_input().audit_sink_class_durable); -} - -#[test] -pub(super) fn gate_input_reports_audit_retention_local_only_for_file_sink_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_reports_audit_retention_local_only_for_jsonl_sink_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "jsonl".to_string(); - assert!(config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_when_attested() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(!config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_requires_custody_approval_for_referenced_signer() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - - assert!(config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_does_not_treat_pkcs11_as_custody_approval() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - config - .evidence - .signing_keys - .get_mut("issuer-key") - .expect("issuer key exists") - .provider = SigningKeyProviderConfig::Pkcs11; - - assert!(config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_clears_signer_custody_when_approved() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - config.deployment.evidence.signer_custody_approved = true; - - assert!(!config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_for_stdout_sink() { - // Minimal config defaults to the stdout sink. - let config = minimal_config(); - assert!(!config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_for_syslog_sink() { - let mut config = minimal_config(); - config.audit.sink = "syslog".to_string(); - assert!(!config.gate_input().audit_retention_local_only); -} - -/// The fixture ack cursor's `acked_at` (`2026-06-04T09:59:00Z`) as a -/// `SystemTime`, so tests can pin `now` relative to it deterministically. -pub(super) fn fixture_acked_at() -> SystemTime { - let acked = time::OffsetDateTime::parse( - "2026-06-04T09:59:00Z", - &time::format_description::well_known::Rfc3339, - ) - .expect("fixture acked_at parses"); - SystemTime::from(acked) -} - -pub(super) fn write_ack_cursor(dir: &std::path::Path, contents: &str) -> std::path::PathBuf { - let path = dir.join("ack-cursor.json"); - std::fs::write(&path, contents).expect("ack cursor writes"); - path -} - -#[test] -pub(super) fn gate_input_reports_shipping_declared_external_for_attested_file_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_clears_shipping_declared_external_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(!config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_reports_shipping_target_for_stdout_sink() { - let mut config = minimal_config(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_reports_ack_cursor_configured_when_path_set() { - let mut config = minimal_config(); - assert!(!config.gate_input().audit_ack_cursor_configured); - config.deployment.evidence.audit_ack_cursor_path = - Some(std::path::PathBuf::from("/nonexistent/ack-cursor.json")); - assert!(config.gate_input().audit_ack_cursor_configured); -} - -#[test] -pub(super) fn gate_input_reports_ack_health_ok_only_after_fresh_cursor_binds_to_tail() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - // now is 60s after the cursor's acked_at, well within the 900s window. - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); - let observation = config - .audit_ack_observation_at(now) - .bind_to_audit_tail(Some([0x44; 32])); - assert!( - config - .gate_input_with_ack_observation(&observation) - .audit_ack_health_ok - ); -} - -#[test] -pub(super) fn gate_input_at_reports_ack_health_not_ok_for_stale_cursor() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - // now is 901s after acked_at, one second past the default window. - let now = fixture_acked_at() + Duration::from_secs(901); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn gate_input_at_reports_ack_health_not_ok_for_missing_cursor() { - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = - Some(std::path::PathBuf::from("/nonexistent/ack-cursor.json")); - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn gate_input_at_honors_custom_max_age_window() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - config.deployment.evidence.audit_ack_max_age_secs = Some(30); - // 60s after acked_at is stale under a 30s window. - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn validate_rejects_ack_max_age_without_cursor() { - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_max_age_secs = Some(600); - let error = config - .validate() - .expect_err("max age without cursor rejected"); - assert!(matches!( - error, - EvidenceConfigError::AuditAckMaxAgeWithoutCursor - )); -} - -#[test] -pub(super) fn validate_rejects_ack_cursor_on_local_file_sink_without_shipping_declared() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - let error = config - .validate() - .expect_err("cursor on undeclared local file sink rejected"); - assert!(matches!( - error, - EvidenceConfigError::AuditAckCursorWithoutShippingDeclared - )); -} - -#[test] -pub(super) fn validate_allows_ack_cursor_on_attested_local_file_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.audit.path = Some("/var/log/registry/audit.jsonl".to_string()); - config.audit.hash_secret_env = Some("TEST_TOKEN_HASH".to_string()); - config.deployment.evidence.audit_offhost_shipping = true; - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - config - .validate() - .expect("cursor on attested local file sink is valid"); -} - -#[test] -pub(super) fn validate_allows_ack_cursor_on_stdout_sink_without_shipping_declared() { - // stdout retention is owned off-box, so a cursor there does not require - // the off-host shipping attestation. - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - config - .validate() - .expect("cursor on stdout sink is valid without attestation"); -} - -#[test] -pub(super) fn gate_input_reports_assisted_access_transaction_token_posture() { - let mut config = minimal_config(); - config.subject_access.enabled = true; - let input_without_anchor = config.gate_input(); - assert!(input_without_anchor.subject_access_enabled); - assert!(!input_without_anchor.transaction_token_anchor_configured); - - config.auth.access_token_signing.enabled = true; - let input_with_anchor = config.gate_input(); - assert!(input_with_anchor.transaction_token_anchor_configured); - assert!( - !input_with_anchor.transaction_token_sender_constrained, - "DPoP/mTLS proof validation is not implemented yet" - ); -} - -#[test] -pub(super) fn gate_input_reports_admin_shared_exposure() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; - assert!(config.gate_input().admin_shared_exposure); -} - -#[test] -pub(super) fn gate_input_clears_admin_shared_exposure_when_listener_disabled() { - let config = minimal_config(); - // Default admin listener mode is Disabled; shared exposure must be false. - assert!(!config.gate_input().admin_shared_exposure); -} - -#[test] -pub(super) fn gate_input_reports_openapi_public() { - let mut config = minimal_config(); - config.server.openapi_requires_auth = false; - assert!(config.gate_input().openapi_public); -} - -#[test] -pub(super) fn gate_input_clears_openapi_public_when_auth_required() { - let config = minimal_config(); - // Default requires auth; openapi_public must be false. - assert!(!config.gate_input().openapi_public); -} - -#[test] -pub(super) fn gate_input_clears_config_unsigned_when_config_trust_configured() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config.config_trust = Some(valid_config_trust()); - assert!(!config.gate_input().config_unsigned); -} - -#[test] -pub(super) fn gate_input_reports_config_unsigned_without_trust() { - let config = minimal_config(); - // Minimal config has no config_trust block; must project as unsigned. - assert!(config.gate_input().config_unsigned); -} - -#[test] -pub(super) fn deployment_block_round_trips_through_yaml() { - let mut config = minimal_config(); - config.deployment = serde_norway::from_str( - r#" -profile: production -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - summary: Synthetic partner integration waiver - expires: 2099-09-30 -"#, - ) - .expect("deployment block parses"); - assert_eq!( - config.deployment.profile, - Some(crate::deployment::DeploymentProfile::Production) - ); - config - .validate() - .expect("production config with waivable waiver validates"); -} - -#[test] -pub(super) fn deployment_waiver_rejects_legacy_reason_as_unknown() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - reason: legacy waiver text - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "the removed reason field must fail strict deserialization" - ); -} - -#[test] -pub(super) fn deployment_waiver_rejects_missing_reference() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "a waiver without the required reference must fail deserialization" - ); -} - -#[test] -pub(super) fn deployment_waiver_rejects_explicit_null_summary() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - summary: null - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "summary must be a string when present, not null: {result:?}" - ); -} - -#[test] -pub(super) fn deployment_evidence_block_round_trips_through_yaml() { - let mut config = minimal_config(); - config.deployment = serde_norway::from_str( - r#" -profile: production -evidence: - audit_offhost_shipping: true -"#, - ) - .expect("deployment evidence block parses"); - assert!(config.deployment.evidence.audit_offhost_shipping); -} - -#[test] -pub(super) fn deployment_evidence_rejects_unknown_field_through_yaml() { - let result: Result = serde_norway::from_str( - r#" -profile: production -evidence: - audit_offhost_shipping: true - made_up_field: true -"#, - ); - assert!( - result.is_err(), - "unknown field inside deployment.evidence must fail deserialization" - ); -} - -#[test] -pub(super) fn invalid_profile_value_fails_config_load() { - let result: Result = serde_norway::from_str( - r#" -evidence: - enabled: true -auth: -deployment: - profile: prod -"#, - ); - assert!( - result.is_err(), - "an invalid profile string must fail to load" - ); -} - -pub(super) fn use_dedicated_admin_listener(config: &mut StandaloneRegistryNotaryConfig) { - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; -} - -pub(super) fn valid_config_trust() -> ConfigTrustConfig { - ConfigTrustConfig { - trust_anchor_path: PathBuf::from("/etc/registry-notary/config-anchor.json"), - bundle_path: PathBuf::from("/etc/registry-notary/config-bundle"), - antirollback_state_path: PathBuf::from( - "/var/lib/registry-notary/config-state/antirollback.json", - ), - break_glass_override_path: None, - } -} - -pub(super) fn minimal_claim(id: &str) -> ClaimDefinition { - serde_norway::from_str(&format!( - r#" -id: {id} -title: Test Claim -version: "1.0" -subject_type: person -purpose: test-purpose -required_scopes: - - registry:consult:test-source -evidence_mode: - type: registry_backed - consultations: - test_source: - profile: - id: example.test-source.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - outputs: - registration_found: - type: boolean - nullable: false -rule: - type: consultation_matched - consultation: test_source -value: - type: boolean -"# - )) - .expect("minimal claim is valid YAML") -} - -#[test] -pub(super) fn claim_value_max_bytes_is_optional_for_direct_configs() { - let value: ClaimValueConfig = serde_norway::from_str("type: string\n") - .expect("legacy direct claim value config remains valid"); - assert_eq!(value.max_bytes, None); - assert!(!serde_norway::to_string(&value) - .expect("claim value config serializes") - .contains("max_bytes")); -} - -#[test] -pub(super) fn string_claim_value_max_bytes_accepts_the_closed_platform_range() { - for max_bytes in [1, MAX_CLAIM_VALUE_STRING_BYTES_V1] { - let mut config = minimal_config(); - let mut claim = minimal_claim("bounded-string"); - claim.value.value_type = "string".to_string(); - claim.value.max_bytes = Some(max_bytes); - claim.rule = RuleConfig::Cel { - expression: r#""value""#.to_string(), - }; - config.evidence.claims.push(claim); - config - .validate() - .unwrap_or_else(|error| panic!("max_bytes {max_bytes} must validate: {error}")); - } -} - -#[test] -pub(super) fn claim_value_max_bytes_rejects_out_of_range_and_non_string_use() { - for max_bytes in [0, MAX_CLAIM_VALUE_STRING_BYTES_V1 + 1] { - let mut config = minimal_config(); - let mut claim = minimal_claim("invalid-bound"); - claim.value.value_type = "string".to_string(); - claim.value.max_bytes = Some(max_bytes); - config.evidence.claims.push(claim); - let error = config - .validate() - .expect_err("out-of-range max_bytes must fail configuration validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimValueConfig { ref claim, ref reason } - if claim == "invalid-bound" - && reason.contains("between 1 and 65536") - )); - } - - let mut config = minimal_config(); - let mut claim = minimal_claim("non-string-bound"); - claim.value.value_type = "boolean".to_string(); - claim.value.max_bytes = Some(16); - config.evidence.claims.push(claim); - let error = config - .validate() - .expect_err("non-string max_bytes must fail configuration validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimValueConfig { ref claim, ref reason } - if claim == "non-string-bound" - && reason.contains("only when value.type is string") - )); -} - -pub(super) fn add_registry_credential_claim( - config: &mut StandaloneRegistryNotaryConfig, - claim_id: &str, - profile_id: &str, -) { - config.evidence.relay = Some( - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses"), - ); - config.evidence.allowed_purposes = vec!["credential-test".to_string()]; - let mut claim = minimal_claim(claim_id); - claim.purpose = Some("credential-test".to_string()); - claim.required_scopes = vec!["credential:test".to_string()]; - claim.value.value_type = "boolean".to_string(); - claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { - consultations: BTreeMap::from([( - "credential_test".to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.credential-test.exact".to_string(), - contract_hash: - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }, - inputs: BTreeMap::from([( - "subject_id".to_string(), - RelayConsultationInput::TargetId, - )]), - outputs: BTreeMap::from([( - "active".to_string(), - RelayOutputContract::Boolean { nullable: false }, - )]), - }, - )]), - }; - claim.rule = RuleConfig::ConsultationMatched { - consultation: "credential_test".to_string(), - }; - claim.credential_profiles = vec![profile_id.to_string()]; - config.evidence.claims = vec![claim]; -} - -#[test] -pub(super) fn config_trust_is_optional_but_requires_explicit_antirollback_path() { - let mut config = minimal_config(); - assert!(config.config_trust.is_none()); - config.validate().expect("simple local config validates"); - use_dedicated_admin_listener(&mut config); - - let mut trust = valid_config_trust(); - trust.trust_anchor_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty trust-anchor path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.bundle_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty bundle path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.antirollback_state_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty anti-rollback path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.break_glass_override_path = Some(PathBuf::from("")); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty break-glass override path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - config.config_trust = Some(valid_config_trust()); - config - .validate() - .expect("explicit config bundle trust paths validate"); -} - -#[test] -pub(super) fn cel_config_defaults_and_validates_operator_limits() { - let config = minimal_config(); - assert_eq!(config.cel.mode, "worker"); - assert_eq!(config.cel.worker_count, 2); - assert!(!config.cel.allow_regex); - config.validate().expect("default CEL config validates"); -} - -#[test] -pub(super) fn cel_config_rejects_removed_queue_max() { - let error = serde_norway::from_str::("queue_max: 0\n") - .expect_err("the unreleased CEL queue setting must not remain accepted"); - assert!(error.to_string().contains("queue_max")); -} - -#[test] -pub(super) fn cel_config_deserializes_production_surface() { - let config: StandaloneRegistryNotaryConfig = serde_norway::from_str( - r#" -evidence: - enabled: true - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -cel: - mode: worker - worker_count: 4 - eval_timeout_ms: 1500 - allow_regex: false - max_expression_bytes: 4096 - max_binding_json_bytes: 32768 - max_result_json_bytes: 8192 - max_string_bytes: 4096 - max_list_items: 128 - max_object_depth: 8 - max_object_keys: 64 - worker_memory_bytes: 67108864 - worker_stderr_bytes: 512 -"#, - ) - .expect("CEL config deserializes"); - - assert_eq!(config.cel.worker_count, 4); - assert_eq!(config.cel.eval_timeout_ms, 1500); - assert_eq!(config.cel.max_result_json_bytes, 8192); - config.validate().expect("CEL config validates"); -} - -pub(super) fn valid_subject_access_config() -> StandaloneRegistryNotaryConfig { - serde_norway::from_str( - r#" -evidence: - enabled: true - relay: - base_url: https://relay.internal.example - workload_client_id: registry-notary - token_file: /run/secrets/registry-notary-relay.jwt - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active - credential_profiles: - civil_status_sd_jwt: - format: application/dc+sd-jwt - issuer: did:web:issuer.example - signing_key: issuer-key - vct: https://issuer.example/credentials/civil-status - validity_seconds: 600 - holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk - allowed_claims: - - date-of-birth - disclosure: - allowed: - - value - claims: - - id: date-of-birth - title: Date of birth - version: "1.0" - subject_type: person - evidence_mode: - type: registry_backed - consultations: - civil_status: - profile: - id: example.civil-status.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - national_id: request.target.identifiers.national_id - outputs: - value: - type: boolean - nullable: true - value: - type: boolean - nullable: true - purpose: citizen_subject_access - required_scopes: - - subject_access - rule: - type: consultation_output - consultation: civil_status - output: value - disclosure: - default: value - allowed: - - value - formats: - - application/vnd.registry-notary.claim-result+json - credential_profiles: - - civil_status_sd_jwt -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/oauth/v2/keys - audiences: - - registry-notary-citizen - allowed_clients: - - citizen-portal - scope_claim: scope - scope_map: - citizen_subject_access: - - subject_access - leeway: 30s -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectId - id_type: national_id - normalize: exact - allow_sub_as_civil_id: false - citizen_clients: - allowed_client_ids: - - citizen-portal - allowed_audiences: - - registry-notary-citizen - token_policy: - required_acr_values: - - urn:example:loa:substantial - max_auth_age_seconds: 900 - max_access_token_lifetime_seconds: 900 - max_evaluation_age_seconds: 600 - max_credential_validity_seconds: 600 - max_clock_leeway_seconds: 60 - allowed_operations: - evaluate: true - render: true - issue_credential: true - batch_evaluate: false - allowed_purposes: - - citizen_subject_access - allowed_claims: - - date-of-birth - allowed_formats: - - application/vnd.registry-notary.claim-result+json - allowed_disclosures: - - value - required_scopes: - - subject_access - allowed_wallet_origins: - - https://wallet.example.gov - credential_profiles: - - civil_status_sd_jwt - rate_limits: - invalid_token_per_client_address_per_minute: 20 - per_principal_per_minute: 10 - subject_mismatch_per_principal_per_hour: 5 - per_holder_per_hour: 10 - credential_issuance_per_principal_per_hour: 5 -"#, - ) - .expect("subject-access config is valid YAML") -} - -pub(super) fn valid_delegated_subject_access_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_subject_access_config(); - config.evidence.relay = Some( - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses"), - ); - let mut proof = config.evidence.claims[0].clone(); - proof.id = "guardian-link".to_string(); - proof.title = "Guardian link".to_string(); - proof.subject_type = "relationship".to_string(); - proof.purpose = Some("dependent_attestation".to_string()); - proof.required_scopes = vec!["subject_access".to_string()]; - proof.evidence_mode = ClaimEvidenceMode::RegistryBacked { - consultations: BTreeMap::from([( - "guardian_link".to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.guardian-link.exact".to_string(), - contract_hash: - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }, - inputs: BTreeMap::from([ - ( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ), - ( - "target_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ), - ]), - outputs: BTreeMap::from([( - "established".to_string(), - RelayOutputContract::Boolean { nullable: true }, - )]), - }, - )]), - }; - proof.value.nullable = true; - proof.credential_profiles.clear(); - proof.rule = RuleConfig::ConsultationOutput { - consultation: "guardian_link".to_string(), - output: "established".to_string(), - }; - - let mut dependent = config.evidence.claims[0].clone(); - dependent.id = "dependent-date-of-birth".to_string(); - dependent.title = "Dependent date of birth".to_string(); - dependent.purpose = Some("dependent_attestation".to_string()); - dependent.depends_on = vec!["guardian-link".to_string()]; - dependent.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependent.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "national_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - - config.evidence.claims.push(proof); - config.evidence.claims.push(dependent); - config.subject_access.delegation = SubjectAccessDelegationConfig { - enabled: true, - allowed_relationships: vec![SubjectAccessDelegatedRelationshipConfig { - relationship_type: "guardian".to_string(), - proof_claim: "guardian-link".to_string(), - target_id_type: Some("civil_registration_id".to_string()), - max_proof_age_seconds: 300, - allowed_claims: vec!["dependent-date-of-birth".to_string()], - allowed_purposes: vec!["dependent_attestation".to_string()], - allowed_formats: vec!["application/vnd.registry-notary.claim-result+json".to_string()], - allowed_disclosures: vec!["value".to_string()], - }], - }; - config -} - -pub(super) fn valid_oid4vci_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_subject_access_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config - .evidence - .signing_keys - .insert("access-token-key".to_string(), second_signing_key()); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .vct = "http://127.0.0.1:4325/credentials/civil-status".to_string(); - config.oid4vci = serde_norway::from_str( - r#" -enabled: true -credential_issuer: http://127.0.0.1:4325 -authorization_servers: - - http://localhost:8088/v1/esignet -accepted_token_audiences: - - http://127.0.0.1:4325 -credential_endpoint: http://127.0.0.1:4325/oid4vci/credential -nonce: - enabled: true - ttl_seconds: 300 -authorization: - require_pkce_method: S256 -proof: - max_age_seconds: 300 - max_clock_skew_seconds: 30 -credential_configurations: - date_of_birth_sd_jwt: - claim_id: date-of-birth - credential_profile: civil_status_sd_jwt - format: dc+sd-jwt - scope: date-of-birth - vct: http://127.0.0.1:4325/credentials/civil-status - display_name: Date of birth - proof_signing_alg_values_supported: - - EdDSA - cryptographic_binding_methods_supported: - - did:jwk -pre_authorized_code: - enabled: true - tx_code: - required: true - input_mode: numeric - length: 6 - esignet: - client_id: registry-lab-live-client - client_signing_key_id: issuer-key - redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback - authorize_url: https://id.example.gov/authorize - token_url: https://id.example.gov/oauth/v2/token - issuer: https://id.example.gov - jwks_uri: https://id.example.gov/oauth/.well-known/jwks.json - scopes: - - openid - pre_authorized_code_ttl_seconds: 300 -"#, - ) - .expect("oid4vci config is valid YAML"); - config.auth.access_token_signing = serde_norway::from_str( - r#" -enabled: true -issuer: http://127.0.0.1:4325 -audiences: - - http://127.0.0.1:4325 -allowed_algorithms: - - EdDSA -token_typ: registry-notary-access+jwt -signing_key_id: access-token-key -access_token_ttl_seconds: 300 -"#, - ) - .expect("access-token signing config is valid YAML"); - config -} - -pub(super) fn add_oid4vci_projection_claim( - config: &mut StandaloneRegistryNotaryConfig, - claim_id: &str, - title: &str, -) { - let mut claim = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "date-of-birth") - .expect("base claim exists") - .clone(); - claim.id = claim_id.to_string(); - claim.title = title.to_string(); - claim.credential_profiles = vec!["civil_status_sd_jwt".to_string()]; - config.evidence.claims.push(claim); - config - .subject_access - .allowed_claims - .push(claim_id.to_string()); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("profile exists") - .allowed_claims - .push(claim_id.to_string()); -} - -pub(super) fn valid_oid4vci_projection_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_oid4vci_config(); - add_oid4vci_projection_claim(&mut config, "birth-place", "Birth place"); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists"); - credential.claim_id = None; - credential.claims = vec![ - Oid4vciCredentialClaimConfig { - id: "date-of-birth".to_string(), - output_path: vec!["birth_date".to_string()], - display_name: "Date of birth".to_string(), - sd: "always".to_string(), - }, - Oid4vciCredentialClaimConfig { - id: "birth-place".to_string(), - output_path: vec!["birth_place_name".to_string()], - display_name: "Birth place".to_string(), - sd: "always".to_string(), - }, - ]; - config -} - -pub(super) fn expect_subject_access_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("subject-access config must fail validation") - { - EvidenceConfigError::InvalidSubjectAccessConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_oid4vci_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("oid4vci config must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_federation_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("federation config must fail validation") - { - EvidenceConfigError::InvalidFederationConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_state_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("state config must fail validation") - { - EvidenceConfigError::InvalidStateConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_credential_status_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("credential status config must fail validation") - { - EvidenceConfigError::InvalidCredentialStatusConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn admin_listener_defaults_to_disabled_for_simple_local_config() { - let config = minimal_config(); - - assert_eq!( - config.server.admin_listener.mode, - RegistryNotaryAdminListenerMode::Disabled - ); - config - .validate() - .expect("simple local config may disable admin listener by default"); -} - -#[test] -pub(super) fn server_limits_default_to_relay_parity_values() { - let config = minimal_config(); - assert_eq!(config.server.request_timeout, Duration::from_secs(30)); - assert_eq!(config.server.request_body_timeout, Duration::from_secs(10)); - assert_eq!( - config.server.http1_header_read_timeout, - Duration::from_secs(10) - ); - assert_eq!(config.server.max_connections, 1024); -} - -#[test] -pub(super) fn cel_evaluation_timeout_keeps_the_fail_closed_product_default() { - let config = minimal_config(); - assert_eq!(config.cel.eval_timeout_ms, 2_000); -} - -#[test] -pub(super) fn server_limits_must_be_nonzero() { - let mut config = minimal_config(); - config.server.request_timeout = Duration::ZERO; - config.server.request_body_timeout = Duration::ZERO; - config.server.http1_header_read_timeout = Duration::ZERO; - config.server.max_connections = 0; - - let err = config - .validate() - .expect_err("zero server limits must fail validation"); - match err { - EvidenceConfigError::InvalidServerConfig { reason } => { - assert!(reason.contains("server timeouts must be non-zero")); - assert!(reason.contains("max_connections")); - } - other => panic!("unexpected error: {other:?}"), - } -} - -#[test] -pub(super) fn governed_config_requires_dedicated_admin_listener() { - let mut config = minimal_config(); - config.config_trust = Some(valid_config_trust()); - - let error = config - .validate() - .expect_err("governed config must not default to shared admin listener"); - match error { - EvidenceConfigError::InvalidServerConfig { reason } => { - assert!(reason.contains("server.admin_listener.mode = dedicated")); - } - other => panic!("unexpected error variant: {other}"), - } - - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config - .validate() - .expect("governed config validates with dedicated admin listener"); -} - -#[test] -pub(super) fn dedicated_admin_listener_must_not_reuse_public_bind() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config.server.admin_listener.bind = config.server.bind; - - let error = config - .validate() - .expect_err("dedicated admin bind must differ from public bind"); - assert!(matches!( - error, - EvidenceConfigError::InvalidServerConfig { .. } - )); -} diff --git a/crates/registry-notary-core/src/config/tests/support.rs b/crates/registry-notary-core/src/config/tests/support.rs deleted file mode 100644 index f4f09f2c4..000000000 --- a/crates/registry-notary-core/src/config/tests/support.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::*; -/// Builds a minimal valid config from which individual tests can deviate. -pub(super) fn minimal_config() -> StandaloneRegistryNotaryConfig { - serde_norway::from_str( - r#" -evidence: - enabled: true - claims: - - id: test-claim - title: Test Claim - version: "1.0" - subject_type: person - purpose: test-purpose - required_scopes: - - registry:consult:test-source - evidence_mode: - type: registry_backed - consultations: - test_source: - profile: - id: example.test-source.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - outputs: - registration_found: - type: boolean - nullable: false - rule: - type: consultation_matched - consultation: test_source - value: - type: boolean - relay: - base_url: https://relay.internal.example - workload_client_id: registry-notary - token_file: /run/secrets/registry-notary-relay.jwt - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -"#, - ) - .expect("minimal config is valid YAML") -} diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs deleted file mode 100644 index 8b5e6ca33..000000000 --- a/crates/registry-notary-core/src/deployment.rs +++ /dev/null @@ -1,1660 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Operator-declared deployment profile and gate evaluation. -//! -//! A deployment profile is an explicit operator declaration of how a Notary -//! instance is deployed. It is never inferred from the environment label, the -//! hostname, or the network position. The profile binds a set of gates; each -//! gate inspects the running configuration and reports an effect at a defined -//! severity. An undeclared deployment is a startup failure; `local` is the -//! explicit opt-out for development. - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// The set of deployment profiles an operator can declare. -/// -/// Frozen at introduction; new profiles may be added but existing ones never -/// change meaning. Deserialization is strict: an unknown profile string fails, -/// which surfaces as a startup error. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentProfile { - Local, - HostedLab, - Production, - EvidenceGrade, -} - -impl DeploymentProfile { - pub const fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::HostedLab => "hosted_lab", - Self::Production => "production", - Self::EvidenceGrade => "evidence_grade", - } - } -} - -/// Severity vocabulary shared across products. -/// -/// `startup_fail` and `readiness_fail` are hard gates and bind only on declared -/// profiles. `finding_error` and `finding_warn` surface as posture findings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum GateSeverity { - StartupFail, - ReadinessFail, - FindingError, - FindingWarn, -} - -impl GateSeverity { - pub const fn as_str(self) -> &'static str { - match self { - Self::StartupFail => "startup_fail", - Self::ReadinessFail => "readiness_fail", - Self::FindingError => "finding_error", - Self::FindingWarn => "finding_warn", - } - } - - /// Hard deployment gates cannot be waived. `startup_fail` means running at - /// all would falsify the profile claim; `readiness_fail` means the process - /// may run but must not report ready until the condition is cleared. - pub const fn is_waivable(self) -> bool { - matches!(self, Self::FindingError | Self::FindingWarn) - } -} - -/// Status of a finding in posture output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentFindingStatus { - Active, - Waived, -} - -impl DeploymentFindingStatus { - pub const fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Waived => "waived", - } - } -} - -/// The operator-declared `deployment` config block. -/// -/// An absent profile means an undeclared deployment, which refuses startup. The -/// `multi_instance` flag is an operator declaration that the instance is one of -/// several sharing the same workload; it is never inferred. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DeploymentConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - #[serde(default)] - pub multi_instance: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub waivers: Vec, - /// Operator declarations of assurance evidence the runtime cannot observe - /// for itself. Absent declarations leave the corresponding gates active. - #[serde(default)] - pub evidence: DeploymentEvidenceConfig, -} - -impl DeploymentConfig { - pub fn is_default(&self) -> bool { - self == &Self::default() - } -} - -/// Operator-asserted assurance evidence for conditions the runtime cannot -/// observe directly. Each flag defaults to `false`, meaning "no evidence -/// declared", which keeps the corresponding gate active until the operator -/// asserts the control is in place out of band. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DeploymentEvidenceConfig { - /// Operator asserts audit log events are shipped off-host (for example to - /// a log aggregator or SIEM) so a local file sink does not cap retention. - #[serde(default)] - pub audit_offhost_shipping: bool, - /// Optional path to a `registry.audit.ack_cursor.v1` file maintained by - /// whatever ships audit events off-host. Runtime health requires both a - /// fresh timestamp and a watermark equal to the live keyed audit-chain tail. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audit_ack_cursor_path: Option, - /// Optional freshness window, in seconds, for the off-host ack cursor. - /// Unset defaults to [`DEFAULT_AUDIT_ACK_MAX_AGE`] (900s). Meaningless - /// without `audit_ack_cursor_path`; config load rejects that combination. - /// - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`]: registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audit_ack_max_age_secs: Option, - /// Operator asserts a production review has approved signer custody for - /// this deployment. Provider kind is not proof of custody: PKCS#11 modules - /// can be backed by either hardware or software tokens. - #[serde(default)] - pub signer_custody_approved: bool, -} - -impl DeploymentEvidenceConfig { - /// The off-host ack cursor path, if the operator configured one. - pub fn audit_ack_cursor_path(&self) -> Option<&Path> { - self.audit_ack_cursor_path.as_deref() - } - - /// Freshness window for the off-host ack cursor, defaulting to - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`] when `audit_ack_max_age_secs` is unset. - /// - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`]: registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE - pub fn audit_ack_max_age(&self) -> Duration { - self.audit_ack_max_age_secs - .map(Duration::from_secs) - .unwrap_or(registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE) - } -} - -/// One operator-configured waiver. -/// -/// A waiver names exactly one finding id, a required operator reference, an -/// optional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared -/// operations contract validates metadata before it can reach posture or logs. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] -#[schemars(!from)] -pub struct DeploymentWaiverConfig { - pub finding: String, - #[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")] - pub reference: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")] - pub summary: Option, - pub expires: String, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct DeploymentWaiverConfigDocument { - finding: String, - reference: String, - #[serde(default)] - summary: registry_platform_ops::OptionalDeploymentWaiverSummary, - expires: String, -} - -impl From for DeploymentWaiverConfig { - fn from(value: DeploymentWaiverConfigDocument) -> Self { - Self { - finding: value.finding, - reference: value.reference, - summary: value.summary.into(), - expires: value.expires, - } - } -} - -/// Errors raised while validating the deployment block at config load. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum DeploymentConfigError { - #[error("deployment.waivers[{index}].finding must not be empty")] - EmptyWaiverFinding { index: usize }, - #[error("deployment.waivers[{index}].{field} is invalid: {error}")] - InvalidWaiverMetadata { - index: usize, - field: &'static str, - error: registry_platform_ops::DeploymentWaiverMetadataError, - }, - #[error("deployment.waivers[{index}].expires must be a YYYY-MM-DD date")] - InvalidWaiverExpiry { index: usize }, - #[error( - "deployment.waivers[{index}] waives finding '{finding}', a hard deployment gate that cannot be waived; remove the waiver and fix the underlying condition it reports (audit shipping findings require deployment.evidence.audit_ack_cursor_path to name the shipper's fresh cursor and require its last_acked_hash to match the live keyed audit-chain tail, including for stdout and syslog sinks)" - )] - HardGateNotWaivable { index: usize, finding: String }, - #[error( - "deployment.waivers[{index}] waives unknown finding id '{finding}'; check the catalog" - )] - UnknownWaivedFinding { index: usize, finding: String }, -} - -impl DeploymentConfig { - /// Validate the deployment block at config load. - /// - /// This checks waiver shape (shared metadata contract, parseable expiry) - /// and the hard rule that `startup_fail` and `readiness_fail` gates can - /// never be waived under the declared profile. An undeclared profile still - /// validates waiver shape here so typos are caught early; startup refusal - /// is handled by gate evaluation. - pub fn validate(&self) -> Result<(), DeploymentConfigError> { - for (index, waiver) in self.waivers.iter().enumerate() { - if waiver.finding.trim().is_empty() { - return Err(DeploymentConfigError::EmptyWaiverFinding { index }); - } - registry_platform_ops::validate_deployment_waiver_metadata( - &waiver.reference, - waiver.summary.as_deref(), - ) - .map_err(|error| DeploymentConfigError::InvalidWaiverMetadata { - index, - field: error.field(), - error, - })?; - if parse_iso_date(&waiver.expires).is_none() { - return Err(DeploymentConfigError::InvalidWaiverExpiry { index }); - } - let Some(gate) = gate_catalog().iter().find(|gate| gate.id == waiver.finding) else { - return Err(DeploymentConfigError::UnknownWaivedFinding { - index, - finding: waiver.finding.clone(), - }); - }; - if let Some(profile) = self.profile { - if let Some(severity) = gate.severity_for(profile) { - if !severity.is_waivable() { - return Err(DeploymentConfigError::HardGateNotWaivable { - index, - finding: waiver.finding.clone(), - }); - } - } - } - } - Ok(()) - } -} - -/// Snapshot of the configuration facts every gate predicate reads. -/// -/// Building this once keeps gate predicates pure and free of config-shape -/// knowledge, which makes the catalog easy to read and test. -#[derive(Debug, Clone, Default)] -pub struct GateInput { - pub state_in_memory: bool, - pub federation_enabled: bool, - pub oid4vci_preauth_enabled: bool, - pub holder_proof_required: bool, - pub wallet_facing: bool, - pub multi_instance: bool, - pub audit_sink_class_durable: bool, - pub audit_retention_local_only: bool, - /// A shipping target is configured (stdout, syslog, or a local file with - /// `audit_offhost_shipping` attested) and therefore needs a bound ack cursor - /// under evidence-grade policy. - pub audit_shipping_target_configured: bool, - /// An off-host ack cursor path is configured, so shipping freshness is - /// observed rather than merely declared. - pub audit_ack_cursor_configured: bool, - /// The configured ack cursor's observed health is `ok` (fresh). False for - /// every other observation (stale, missing, invalid) and when no cursor is - /// configured: the shipping-stale gate fails closed on anything but `ok`. - pub audit_ack_health_ok: bool, - pub admin_shared_exposure: bool, - pub openapi_public: bool, - pub config_unsigned: bool, - pub subject_access_enabled: bool, - pub transaction_token_anchor_configured: bool, - pub transaction_token_sender_constrained: bool, - pub signer_without_custody_approval: bool, -} - -impl GateInput { - /// True when a declared mode relies on shared, durable correctness state. - pub fn requires_shared_state(&self) -> bool { - self.federation_enabled - || self.oid4vci_preauth_enabled - || self.holder_proof_required - || self.wallet_facing - || self.multi_instance - } -} - -/// A finding row in the catalog: an id and its severity under each profile that -/// binds it. A profile with no entry leaves the gate unbound. -struct Gate { - id: &'static str, - hosted_lab: Option, - production: Option, - evidence_grade: Option, - /// Predicate over the gate input; true means the gate condition is met. - condition: fn(&GateInput) -> bool, -} - -impl Gate { - fn severity_for(&self, profile: DeploymentProfile) -> Option { - match profile { - DeploymentProfile::Local => None, - DeploymentProfile::HostedLab => self.hosted_lab, - DeploymentProfile::Production => self.production, - DeploymentProfile::EvidenceGrade => self.evidence_grade, - } - } -} - -// Finding ids. Stable once shipped; consumers treat unknown ids as opaque. -pub const FINDING_STATE_IN_MEMORY_HIGH_RISK: &str = "notary.state.in_memory_high_risk"; -pub const FINDING_AUDIT_SINK_MISSING: &str = "notary.audit.sink_missing"; -pub const FINDING_AUDIT_RETENTION_LOCAL_ONLY: &str = "notary.audit.retention_local_only"; -pub const FINDING_AUDIT_SHIPPING_UNVERIFIED: &str = "notary.audit.shipping_unverified"; -pub const FINDING_AUDIT_SHIPPING_STALE: &str = "notary.audit.shipping_stale"; -pub const FINDING_ADMIN_SHARED_EXPOSURE: &str = "notary.admin.shared_exposure"; -pub const FINDING_OPENAPI_PUBLIC: &str = "notary.openapi.public"; -pub const FINDING_CONFIG_UNSIGNED: &str = "notary.config.unsigned"; -pub const FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING: &str = - "notary.assisted_access.transaction_token_anchor_missing"; -pub const FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING: &str = - "notary.assisted_access.sender_constraint_missing"; -pub const FINDING_SIGNER_CUSTODY_UNAPPROVED: &str = "notary.signer_custody.unapproved"; - -// Diagnostic finding ids emitted by the framework itself. -pub const FINDING_PROFILE_UNDECLARED: &str = "deployment.profile_undeclared"; -pub const FINDING_WAIVER_EXPIRED: &str = "deployment.waiver_expired"; - -/// The severity `gate_id` binds under `profile`, or `None` if the gate is -/// unbound at that profile (including an undeclared profile) or `gate_id` is -/// unknown. Lets callers outside the gate-evaluation path (e.g. doctor -/// diagnostics) check whether a gate already covers a finding before also -/// reporting it explicitly. -pub fn gate_severity_for_profile( - gate_id: &str, - profile: Option, -) -> Option { - let profile = profile?; - gate_catalog() - .iter() - .find(|gate| gate.id == gate_id) - .and_then(|gate| gate.severity_for(profile)) -} - -fn gate_catalog() -> &'static [Gate] { - use GateSeverity::{FindingError, FindingWarn, ReadinessFail, StartupFail}; - &[ - // notary.state.in_memory_high_risk: process-local correctness state - // while a mode requiring shared state is declared. (#206) - Gate { - id: FINDING_STATE_IN_MEMORY_HIGH_RISK, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.state_in_memory && input.requires_shared_state(), - }, - // notary.audit.sink_missing: no durable, retained audit sink. (#207) - Gate { - id: FINDING_AUDIT_SINK_MISSING, - hosted_lab: Some(FindingError), - production: Some(StartupFail), - evidence_grade: Some(StartupFail), - condition: |input| !input.audit_sink_class_durable, - }, - // notary.audit.retention_local_only: a local file sink caps retention - // and an attacker with host access can destroy audit evidence; the - // audit hash chain also cannot detect leading or trailing truncation - // of a local-only log, so off-host shipping (plus its attestation) is - // the completeness evidence that clears this gate. Under production - // this is only a warn, so the warning is the operator's single signal. - // stdout and syslog are exempt: their retention is owned by the - // orchestrator log pipeline or the syslog daemon's own forwarding - // surface. - Gate { - id: FINDING_AUDIT_RETENTION_LOCAL_ONLY, - hosted_lab: None, - production: Some(FindingWarn), - evidence_grade: Some(StartupFail), - condition: |input| input.audit_retention_local_only, - }, - // notary.audit.shipping_unverified: a shipping target is configured but - // no ack cursor is configured. This warns under production and refuses - // evidence-grade startup because the static observation capability is - // absent. Runtime loss or lag is handled by shipping_stale below. - Gate { - id: FINDING_AUDIT_SHIPPING_UNVERIFIED, - hosted_lab: None, - production: Some(FindingWarn), - evidence_grade: Some(StartupFail), - condition: |input| { - input.audit_shipping_target_configured && !input.audit_ack_cursor_configured - }, - }, - // notary.audit.shipping_stale: an ack cursor is configured but its - // observed health is not ok. Fail closed: stale, missing, unsafe, - // malformed, and chain-mismatched cursors all count. Escalates to readiness_fail under - // evidence_grade so the instance refuses to report ready. - Gate { - id: FINDING_AUDIT_SHIPPING_STALE, - hosted_lab: None, - production: Some(FindingError), - evidence_grade: Some(ReadinessFail), - condition: |input| input.audit_ack_cursor_configured && !input.audit_ack_health_ok, - }, - // Risky-but-legal defaults, surfaced as profile-bound findings. (#208) - Gate { - id: FINDING_ADMIN_SHARED_EXPOSURE, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.admin_shared_exposure, - }, - Gate { - id: FINDING_OPENAPI_PUBLIC, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(FindingError), - condition: |input| input.openapi_public, - }, - Gate { - id: FINDING_CONFIG_UNSIGNED, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(StartupFail), - condition: |input| input.config_unsigned, - }, - Gate { - id: FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| { - input.subject_access_enabled && !input.transaction_token_anchor_configured - }, - }, - Gate { - id: FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(ReadinessFail), - condition: |input| { - input.transaction_token_anchor_configured - && !input.transaction_token_sender_constrained - }, - }, - // notary.signer_custody.unapproved: provider kind cannot prove custody - // because PKCS#11 can be hardware- or software-backed. Production and - // evidence-grade deployments therefore require explicit custody - // approval for each configured signing role. - Gate { - id: FINDING_SIGNER_CUSTODY_UNAPPROVED, - hosted_lab: None, - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.signer_without_custody_approval, - }, - ] -} - -/// A finding produced by gate evaluation, ready to render into posture. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvaluatedFinding { - pub id: String, - pub severity: GateSeverity, - pub status: DeploymentFindingStatus, - pub waiver: Option, -} - -/// An active waiver echoed into posture so Trust Operations can review it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvaluatedWaiver { - pub finding: String, - pub reference: String, - pub summary: Option, - pub expires: String, -} - -/// The full result of evaluating gates for a declared profile. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct GateEvaluation { - /// Finding ids whose effect is `startup_fail` (never waived). A non-empty - /// list means the process must refuse to start. - pub startup_failures: Vec, - /// Finding ids whose effect is `readiness_fail`. The process runs but - /// readiness reports not-ready. - pub readiness_failures: Vec, - /// Findings to render into posture, both active and waived. - pub findings: Vec, - /// Active waivers, including ones whose gate is not currently triggered. - pub active_waivers: Vec, -} - -/// Evaluate the gate catalog for a configuration snapshot. -/// -/// `today` is the date used to decide whether a waiver has expired, passed in -/// so callers and tests can be deterministic. An undeclared profile (`None`) -/// emits `deployment.profile_undeclared` as a startup failure. -pub fn evaluate_gates( - profile: Option, - input: &GateInput, - waivers: &[DeploymentWaiverConfig], - today: &str, -) -> GateEvaluation { - let Some(profile) = profile else { - return GateEvaluation { - startup_failures: vec![FINDING_PROFILE_UNDECLARED.to_string()], - readiness_failures: Vec::new(), - findings: vec![EvaluatedFinding { - id: FINDING_PROFILE_UNDECLARED.to_string(), - severity: GateSeverity::StartupFail, - status: DeploymentFindingStatus::Active, - waiver: None, - }], - active_waivers: Vec::new(), - }; - }; - - let mut evaluation = GateEvaluation::default(); - let mut waived_findings: Vec<&DeploymentWaiverConfig> = Vec::new(); - - // An expired waiver stops suppressing its finding and additionally emits a - // diagnostic error finding so Trust Operations sees the lapse. - for waiver in waivers { - if waiver_is_expired(&waiver.expires, today) { - evaluation.findings.push(EvaluatedFinding { - id: FINDING_WAIVER_EXPIRED.to_string(), - severity: GateSeverity::FindingError, - status: DeploymentFindingStatus::Active, - waiver: Some(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }), - }); - } else { - let Some(severity) = gate_catalog() - .iter() - .find(|gate| gate.id == waiver.finding) - .and_then(|gate| gate.severity_for(profile)) - else { - continue; - }; - if !severity.is_waivable() { - continue; - } - waived_findings.push(waiver); - evaluation.active_waivers.push(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }); - } - } - - for gate in gate_catalog() { - let Some(severity) = gate.severity_for(profile) else { - continue; - }; - if !(gate.condition)(input) { - continue; - } - - // A waiver only suppresses waivable severities. startup_fail is never - // waivable, so even an active waiver leaves it as a hard failure. - let active_waiver = if severity.is_waivable() { - waived_findings - .iter() - .find(|waiver| waiver.finding == gate.id) - .copied() - } else { - None - }; - - if let Some(waiver) = active_waiver { - evaluation.findings.push(EvaluatedFinding { - id: gate.id.to_string(), - severity, - status: DeploymentFindingStatus::Waived, - waiver: Some(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }), - }); - continue; - } - - match severity { - GateSeverity::StartupFail => evaluation.startup_failures.push(gate.id.to_string()), - GateSeverity::ReadinessFail => evaluation.readiness_failures.push(gate.id.to_string()), - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - evaluation.findings.push(EvaluatedFinding { - id: gate.id.to_string(), - severity, - status: DeploymentFindingStatus::Active, - waiver: None, - }); - } - - evaluation -} - -/// Parse a strict `YYYY-MM-DD` date into a comparable tuple. -/// -/// Lexicographic string comparison of `YYYY-MM-DD` dates is equivalent to -/// chronological order, so callers compare the raw strings; this function only -/// validates the shape and ranges. -fn parse_iso_date(value: &str) -> Option<(u16, u8, u8)> { - let bytes = value.as_bytes(); - if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { - return None; - } - let year: u16 = value.get(0..4)?.parse().ok()?; - let month: u8 = value.get(5..7)?.parse().ok()?; - let day: u8 = value.get(8..10)?.parse().ok()?; - if !(1..=12).contains(&month) || !(1..=31).contains(&day) { - return None; - } - Some((year, month, day)) -} - -/// A waiver is expired once its expiry date is strictly before today. -fn waiver_is_expired(expires: &str, today: &str) -> bool { - match (parse_iso_date(expires), parse_iso_date(today)) { - (Some(_), Some(_)) => expires < today, - // An unparseable expiry was rejected at config load; treat it as - // expired here so a bad value never silently suppresses a finding. - _ => true, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn waiver(finding: &str, expires: &str) -> DeploymentWaiverConfig { - DeploymentWaiverConfig { - finding: finding.to_string(), - reference: "OPS-TEST-DEPLOYMENT".to_string(), - summary: Some("Synthetic test waiver summary".to_string()), - expires: expires.to_string(), - } - } - - fn high_risk_in_memory_input() -> GateInput { - GateInput { - state_in_memory: true, - federation_enabled: true, - audit_sink_class_durable: true, - ..GateInput::default() - } - } - - #[test] - fn undeclared_profile_is_startup_failure() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates(None, &input, &[], "2026-06-13"); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_PROFILE_UNDECLARED.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - assert_eq!(evaluation.findings.len(), 1); - assert_eq!(evaluation.findings[0].id, FINDING_PROFILE_UNDECLARED); - assert_eq!(evaluation.findings[0].severity, GateSeverity::StartupFail); - } - - #[test] - fn local_profile_binds_no_gates() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates(Some(DeploymentProfile::Local), &input, &[], "2026-06-13"); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - assert!(evaluation.findings.is_empty()); - } - - #[test] - fn evidence_grade_in_memory_high_risk_is_startup_fail() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - } - - #[test] - fn production_in_memory_high_risk_is_readiness_fail() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - assert!(evaluation.startup_failures.is_empty()); - } - - #[test] - fn hosted_lab_high_risk_is_waivable_finding_error() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::HostedLab), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("high-risk finding present"); - assert_eq!(finding.severity, GateSeverity::FindingError); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - } - - #[test] - fn waiver_suppresses_waivable_finding_and_reports_waived() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::HostedLab), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("waived finding present"); - assert_eq!(finding.status, DeploymentFindingStatus::Waived); - assert!(finding.waiver.is_some()); - assert_eq!(evaluation.active_waivers.len(), 1); - } - - #[test] - fn expired_waiver_re_triggers_finding_and_emits_waiver_expired() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2020-01-01")], - "2026-06-13", - ); - // The gate re-triggers at full severity. - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - // The expiry diagnostic is emitted. - assert!(evaluation - .findings - .iter() - .any(|f| f.id == FINDING_WAIVER_EXPIRED && f.severity == GateSeverity::FindingError)); - // The expired waiver is not active. - assert!(evaluation.active_waivers.is_empty()); - } - - #[test] - fn startup_fail_gate_is_not_waivable_even_with_active_waiver() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - } - - #[test] - fn readiness_fail_gate_is_not_waivable_even_with_active_waiver() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - let finding = evaluation - .findings - .iter() - .find(|finding| finding.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("high-risk replay finding exists"); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.active_waivers.is_empty()); - } - - #[test] - fn validate_rejects_waiver_for_hard_startup_gate() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SINK_MISSING, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("startup_fail waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_waiver_for_hard_readiness_gate() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_unknown_waived_finding() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver("notary.made.up", "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("unknown finding rejected"); - assert!(matches!( - error, - DeploymentConfigError::UnknownWaivedFinding { .. } - )); - } - - #[test] - fn validate_accepts_absent_summary_and_ordinary_metadata() { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = "OPS-2026:INC_42".to_string(); - waiver_config.summary = None; - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver_config], - evidence: DeploymentEvidenceConfig::default(), - }; - config - .validate() - .expect("ordinary reference with absent summary is valid"); - - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = - Some("Public API catalog approved in the operations ticket".to_string()); - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver_config], - evidence: DeploymentEvidenceConfig::default(), - }; - config.validate().expect("ordinary short summary is valid"); - } - - #[test] - fn validate_rejects_invalid_waiver_metadata_with_field_and_limit() { - let mut cases = Vec::new(); - for reference in ["", " OPS-42", "OPS/42"] { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = reference.to_string(); - cases.push((waiver_config, "reference", "128")); - } - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = "x".repeat(129); - cases.push((waiver_config, "reference", "128")); - - for summary in [ - "", - " summary", - "summary\ncontinued", - "Bearer credential-value", - "Basic credential-value", - "rotated leaked Bearer abcdef", - "-----BEGIN OPENSSH PRIVATE KEY-----", - concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), - ] { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = Some(summary.to_string()); - cases.push((waiver_config, "summary", "256")); - } - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = Some("x".repeat(257)); - cases.push((waiver_config, "summary", "256")); - - for (waiver, field, limit) in cases { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("invalid metadata rejected"); - let rendered = error.to_string(); - assert!(rendered.contains(&format!("deployment.waivers[0].{field}"))); - assert!(rendered.contains(limit), "limit missing from: {rendered}"); - } - } - - #[test] - fn validate_rejects_missing_or_malformed_expiry() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_OPENAPI_PUBLIC, "not-a-date")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("malformed expiry rejected"); - assert!(matches!( - error, - DeploymentConfigError::InvalidWaiverExpiry { .. } - )); - } - - #[test] - fn audit_sink_missing_binds_startup_fail_under_production() { - let input = GateInput { - audit_sink_class_durable: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_AUDIT_SINK_MISSING.to_string())); - } - - #[test] - fn audit_sink_durable_clears_the_gate() { - let input = GateInput { - audit_sink_class_durable: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.findings.is_empty()); - } - - #[test] - fn audit_retention_local_only_binds_finding_warn_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("retention finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingWarn); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_retention_local_only_binds_startup_fail_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("retention finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::StartupFail); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_AUDIT_RETENTION_LOCAL_ONLY.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_retention_local_only_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY), - "retention finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_retention_local_only_absent_when_condition_not_met() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: false, - ..GateInput::default() - }; - for profile in [ - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY), - "retention finding must be absent under profile '{}' when unattested sink is not local-only", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_unverified_warns_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED) - .expect("shipping_unverified finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingWarn); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_unverified_refuses_startup_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED) - .expect("shipping_unverified finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::StartupFail); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_AUDIT_SHIPPING_UNVERIFIED.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_unverified_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "shipping_unverified finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_unverified_absent_when_cursor_configured() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "a configured ack cursor clears shipping_unverified" - ); - } - - #[test] - fn audit_shipping_unverified_absent_without_declared_external() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: false, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "shipping_unverified only binds when the target is declared_external" - ); - } - - #[test] - fn audit_shipping_stale_binds_finding_error_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_STALE) - .expect("shipping_stale finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingError); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_stale_binds_readiness_fail_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_STALE) - .expect("shipping_stale finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::ReadinessFail); - assert_eq!( - evaluation.readiness_failures, - vec![FINDING_AUDIT_SHIPPING_STALE.to_string()] - ); - assert!(evaluation.startup_failures.is_empty()); - } - - #[test] - fn audit_shipping_stale_cleared_when_health_ok() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: true, - ..GateInput::default() - }; - for profile in [ - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_STALE), - "a fresh cursor clears shipping_stale under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_stale_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_STALE), - "shipping_stale finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn validate_rejects_waiver_for_shipping_stale_under_evidence_grade() { - // shipping_stale is readiness_fail under evidence_grade, so it is a hard - // gate that cannot be waived. A waiver naming it must be rejected at load. - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_STALE, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail shipping_stale waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_waiver_for_shipping_unverified_under_evidence_grade() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_UNVERIFIED, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail shipping_unverified waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_allows_waiver_for_shipping_stale_under_production() { - // shipping_stale is finding_error (waivable) under production, so a - // waiver naming it is accepted at load. - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_STALE, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - config - .validate() - .expect("finding_error shipping_stale waiver accepted under production"); - } - - #[test] - fn audit_retention_local_only_waiver_suppresses_production_finding() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_AUDIT_RETENTION_LOCAL_ONLY, "2099-01-01")], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("waived retention finding present"); - assert_eq!(finding.status, DeploymentFindingStatus::Waived); - assert!(finding.waiver.is_some()); - } - - #[test] - fn deployment_evidence_rejects_unknown_field() { - let result: Result = serde_json::from_str( - r#"{ "evidence": { "audit_offhost_shipping": true, "made_up": true } }"#, - ); - assert!(result.is_err()); - } - - // Gate-binding tests for the #208 risky-but-legal findings. - // - // Each case pairs a triggering GateInput with the expected severity per - // profile, and a non-triggering GateInput that must produce no finding. - // All three bound profiles (hosted_lab, production, evidence_grade) are - // checked; local is skipped because it binds no gates. - - struct GateCase { - id: &'static str, - triggering: GateInput, - non_triggering: GateInput, - hosted_lab: GateSeverity, - production: GateSeverity, - evidence_grade: GateSeverity, - } - - fn gate_cases() -> Vec { - vec![ - GateCase { - id: FINDING_ADMIN_SHARED_EXPOSURE, - triggering: GateInput { - admin_shared_exposure: true, - ..GateInput::default() - }, - non_triggering: GateInput { - admin_shared_exposure: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingError, - production: GateSeverity::ReadinessFail, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_OPENAPI_PUBLIC, - triggering: GateInput { - openapi_public: true, - ..GateInput::default() - }, - non_triggering: GateInput { - openapi_public: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::FindingError, - }, - GateCase { - id: FINDING_CONFIG_UNSIGNED, - triggering: GateInput { - config_unsigned: true, - ..GateInput::default() - }, - non_triggering: GateInput { - config_unsigned: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING, - triggering: GateInput { - subject_access_enabled: true, - transaction_token_anchor_configured: false, - ..GateInput::default() - }, - non_triggering: GateInput { - subject_access_enabled: true, - transaction_token_anchor_configured: true, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingError, - production: GateSeverity::ReadinessFail, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING, - triggering: GateInput { - transaction_token_anchor_configured: true, - transaction_token_sender_constrained: false, - ..GateInput::default() - }, - non_triggering: GateInput { - transaction_token_anchor_configured: true, - transaction_token_sender_constrained: true, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::ReadinessFail, - }, - ] - } - - #[test] - fn risky_default_findings_bind_correct_severity_per_profile() { - for case in gate_cases() { - for (profile, expected_severity) in [ - (DeploymentProfile::HostedLab, case.hosted_lab), - (DeploymentProfile::Production, case.production), - (DeploymentProfile::EvidenceGrade, case.evidence_grade), - ] { - let evaluation = evaluate_gates(Some(profile), &case.triggering, &[], "2026-06-13"); - - // For startup_fail findings the finding also lands in - // startup_failures; for readiness_fail it lands in - // readiness_failures. Both paths still push into findings. - let found = evaluation - .findings - .iter() - .find(|f| f.id == case.id) - .unwrap_or_else(|| { - panic!( - "expected finding '{}' under profile '{}' (triggering input)", - case.id, - profile.as_str() - ) - }); - assert_eq!( - found.severity, - expected_severity, - "finding '{}' under profile '{}': expected severity {:?}, got {:?}", - case.id, - profile.as_str(), - expected_severity, - found.severity - ); - - // startup_fail and readiness_fail findings must also appear - // in their respective hard-gate lists. - match expected_severity { - GateSeverity::StartupFail => { - assert!( - evaluation.startup_failures.contains(&case.id.to_string()), - "finding '{}' under profile '{}' must be in startup_failures", - case.id, - profile.as_str() - ); - } - GateSeverity::ReadinessFail => { - assert!( - evaluation.readiness_failures.contains(&case.id.to_string()), - "finding '{}' under profile '{}' must be in readiness_failures", - case.id, - profile.as_str() - ); - } - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - } - } - } - - #[test] - fn risky_default_findings_absent_when_condition_not_met() { - for case in gate_cases() { - for profile in [ - DeploymentProfile::HostedLab, - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = - evaluate_gates(Some(profile), &case.non_triggering, &[], "2026-06-13"); - - // The non-triggering input must not produce the finding. - assert!( - !evaluation.findings.iter().any(|f| f.id == case.id), - "finding '{}' must be absent under profile '{}' with non-triggering input", - case.id, - profile.as_str() - ); - assert!( - !evaluation.startup_failures.contains(&case.id.to_string()), - "finding '{}' must not be in startup_failures under profile '{}' (non-triggering)", - case.id, - profile.as_str() - ); - assert!( - !evaluation.readiness_failures.contains(&case.id.to_string()), - "finding '{}' must not be in readiness_failures under profile '{}' (non-triggering)", - case.id, - profile.as_str() - ); - } - } - } - - #[test] - fn signer_custody_gate_rejects_unapproved_production_custody() { - let triggering = GateInput { - signer_without_custody_approval: true, - ..GateInput::default() - }; - let non_triggering = GateInput { - signer_without_custody_approval: false, - ..GateInput::default() - }; - let cases = [ - (DeploymentProfile::Local, None), - (DeploymentProfile::HostedLab, None), - ( - DeploymentProfile::Production, - Some(GateSeverity::ReadinessFail), - ), - ( - DeploymentProfile::EvidenceGrade, - Some(GateSeverity::StartupFail), - ), - ]; - for (profile, expected_severity) in cases { - let evaluation = evaluate_gates(Some(profile), &triggering, &[], "2026-06-13"); - let found = evaluation - .findings - .iter() - .find(|finding| finding.id == FINDING_SIGNER_CUSTODY_UNAPPROVED); - match expected_severity { - Some(severity) => { - let finding = found.unwrap_or_else(|| { - panic!( - "expected finding '{}' under profile '{}'", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ) - }); - assert_eq!(finding.severity, severity); - match severity { - GateSeverity::StartupFail => assert!(evaluation - .startup_failures - .contains(&FINDING_SIGNER_CUSTODY_UNAPPROVED.to_string())), - GateSeverity::ReadinessFail => assert!(evaluation - .readiness_failures - .contains(&FINDING_SIGNER_CUSTODY_UNAPPROVED.to_string())), - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - } - None => assert!( - found.is_none(), - "finding '{}' must be unbound under profile '{}'", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ), - } - - let clear_evaluation = - evaluate_gates(Some(profile), &non_triggering, &[], "2026-06-13"); - assert!( - !clear_evaluation - .findings - .iter() - .any(|finding| finding.id == FINDING_SIGNER_CUSTODY_UNAPPROVED), - "finding '{}' must be absent under profile '{}' with non-triggering input", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ); - } - } - - #[test] - fn invalid_profile_string_fails_deserialization() { - let result: Result = serde_json::from_str(r#"{ "profile": "prod" }"#); - assert!(result.is_err()); - } - - #[test] - fn iso_date_parser_accepts_valid_and_rejects_invalid() { - assert!(parse_iso_date("2026-06-13").is_some()); - assert!(parse_iso_date("2026-13-01").is_none()); - assert!(parse_iso_date("2026-06-32").is_none()); - assert!(parse_iso_date("2026/06/13").is_none()); - assert!(parse_iso_date("26-06-13").is_none()); - } -} diff --git a/crates/registry-notary-core/src/error.rs b/crates/registry-notary-core/src/error.rs deleted file mode 100644 index a327d5d31..000000000 --- a/crates/registry-notary-core/src/error.rs +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary stable error taxonomy. - -use crate::model::SubjectAccessDenialCode; - -use thiserror::Error; - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum EvidenceError { - #[error("evidence server is disabled")] - ServerDisabled, - #[error("claim was not found")] - ClaimNotFound, - #[error("claim version was not found")] - ClaimVersionNotFound, - #[error("claim operation is unsupported")] - OperationUnsupported, - #[error("evidence request is invalid")] - InvalidRequest, - #[error("registry-backed batch consultation request is invalid")] - ConsultationInvalidRequest, - #[error("requested disclosure is not allowed")] - DisclosureNotAllowed, - #[error("purpose is not allowed")] - PurposeNotAllowed, - #[error("policy decision denied the request: {code}")] - PolicyDenied { - code: &'static str, - policy_id: Option, - policy_hash: Option, - evaluated_rule_ids: Vec, - }, - #[error("evidence request profile is unsupported")] - ProfileUnsupported, - #[error("evidence is not available")] - EvidenceNotAvailable, - #[error("batch request is too large")] - BatchTooLarge, - #[error("evaluation was not found")] - EvaluationNotFound, - #[error("evaluation binding mismatch")] - EvaluationBindingMismatch, - #[error("format is unsupported")] - FormatUnsupported, - #[error("credential issuer is not configured")] - CredentialIssuerNotConfigured, - #[error("holder proof is required")] - HolderProofRequired, - #[error("holder proof has already been used")] - HolderProofReplay, - #[error("credential issuance failed")] - CredentialIssuanceFailed, - #[error("claim rule evaluation failed")] - RuleEvaluationFailed, - #[error("idempotency key was reused with a different request")] - IdempotencyConflict, - #[error("purpose is required")] - PurposeRequired, - #[error("credential is missing")] - MissingCredential, - #[error("multiple authentication credentials were provided")] - MultipleCredentials, - #[error("required scope is missing")] - ScopeDenied { required: String }, - #[error("subject-access request is denied")] - SubjectAccessDenied { reason: SubjectAccessDenialCode }, - #[error("subject-access request is rate limited")] - SubjectAccessRateLimited, - #[error("subject-access token is invalid")] - SubjectAccessInvalidToken, - #[error("subject-access assurance policy denied the request")] - SubjectAccessAssuranceDenied, - #[error("machine evaluation quota was exceeded")] - MachineQuotaExceeded { retry_after_seconds: u64 }, -} - -impl EvidenceError { - #[must_use] - pub fn code(&self) -> &'static str { - match self { - Self::ServerDisabled => "evidence.server_disabled", - Self::ClaimNotFound => "claim.not_found", - Self::ClaimVersionNotFound => "claim.version_not_found", - Self::OperationUnsupported => "claim.operation_unsupported", - Self::InvalidRequest => "request.invalid", - Self::ConsultationInvalidRequest => "consultation.invalid_request", - Self::DisclosureNotAllowed => "claim.disclosure_not_allowed", - Self::PurposeNotAllowed => "purpose.not_allowed", - Self::PolicyDenied { code, .. } => code, - Self::ProfileUnsupported => "profile.unsupported", - Self::EvidenceNotAvailable => "evidence.not_available", - Self::BatchTooLarge => "batch.too_large", - Self::EvaluationNotFound => "evaluation.not_found", - Self::EvaluationBindingMismatch => "evaluation.binding_mismatch", - Self::FormatUnsupported => "claim.format_not_supported", - Self::CredentialIssuerNotConfigured => "credential.issuer_not_configured", - Self::HolderProofRequired => "credential.holder_proof_required", - Self::HolderProofReplay => "credential.holder_proof_replay", - Self::CredentialIssuanceFailed => "credential.issuance_failed", - Self::RuleEvaluationFailed => "claim.rule_evaluation_failed", - Self::IdempotencyConflict => "idempotency.conflict", - Self::PurposeRequired => "auth.purpose_required", - Self::MissingCredential => "auth.missing_credential", - Self::MultipleCredentials => "auth.multiple_credentials", - Self::ScopeDenied { .. } => "auth.scope_denied", - Self::SubjectAccessDenied { .. } => "subject_access.denied", - Self::SubjectAccessRateLimited => "subject_access.rate_limited", - Self::SubjectAccessInvalidToken | Self::SubjectAccessAssuranceDenied => { - "subject_access.denied" - } - Self::MachineQuotaExceeded { .. } => "evaluation.quota_exceeded", - } - } - - #[must_use] - pub fn audit_code(&self) -> &'static str { - match self { - Self::SubjectAccessDenied { reason } => reason.as_str(), - Self::SubjectAccessInvalidToken => SubjectAccessDenialCode::InvalidToken.as_str(), - Self::SubjectAccessAssuranceDenied => SubjectAccessDenialCode::AssuranceDenied.as_str(), - Self::PolicyDenied { code, .. } => code, - _ => self.code(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn subject_access_denial_keeps_generic_public_code_and_specific_audit_code() { - let error = EvidenceError::SubjectAccessDenied { - reason: SubjectAccessDenialCode::SubjectMismatch, - }; - - assert_eq!(error.code(), "subject_access.denied"); - assert_eq!(error.audit_code(), "subject_access.subject_mismatch"); - } - - #[test] - fn subject_access_specific_errors_have_stable_codes() { - assert_eq!( - EvidenceError::SubjectAccessRateLimited.code(), - "subject_access.rate_limited" - ); - assert_eq!( - EvidenceError::SubjectAccessInvalidToken.code(), - "subject_access.denied" - ); - assert_eq!( - EvidenceError::SubjectAccessInvalidToken.audit_code(), - "subject_access.invalid_token" - ); - assert_eq!( - EvidenceError::SubjectAccessAssuranceDenied.code(), - "subject_access.denied" - ); - assert_eq!( - EvidenceError::SubjectAccessAssuranceDenied.audit_code(), - "subject_access.assurance_denied" - ); - } - - #[test] - fn machine_quota_exceeded_has_stable_code() { - let error = EvidenceError::MachineQuotaExceeded { - retry_after_seconds: 42, - }; - - assert_eq!(error.code(), "evaluation.quota_exceeded"); - assert_eq!(error.audit_code(), "evaluation.quota_exceeded"); - } - - #[test] - fn policy_denials_keep_stable_pdp_code() { - let error = EvidenceError::PolicyDenied { - code: "pdp.assurance_insufficient", - policy_id: None, - policy_hash: None, - evaluated_rule_ids: Vec::new(), - }; - - assert_eq!(error.code(), "pdp.assurance_insufficient"); - assert_eq!(error.audit_code(), "pdp.assurance_insufficient"); - } -} diff --git a/crates/registry-notary-core/src/lib.rs b/crates/registry-notary-core/src/lib.rs deleted file mode 100644 index 3c796a9d0..000000000 --- a/crates/registry-notary-core/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Shared Registry Notary domain model and credential primitives. - -pub mod config; -pub mod deployment; -pub mod error; -pub mod model; -pub mod sd_jwt; -pub mod tokens; - -pub use config::*; -pub use deployment::*; -pub use error::EvidenceError; -pub use model::*; diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs deleted file mode 100644 index ce2af6467..000000000 --- a/crates/registry-notary-core/src/model.rs +++ /dev/null @@ -1,2818 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary request, response, and view types. - -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::marker::PhantomData; - -use schemars::{JsonSchema, Schema, SchemaGenerator}; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; - -pub const FORMAT_CLAIM_RESULT_JSON: &str = "application/vnd.registry-notary.claim-result+json"; -pub const FORMAT_CCCEV_JSONLD: &str = "application/ld+json; profile=\"cccev\""; -pub const FORMAT_SD_JWT_VC: &str = "application/dc+sd-jwt"; -pub const SD_JWT_VC_JWT_TYP: &str = "dc+sd-jwt"; -pub const SD_JWT_VC_SIGNING_ALG: &str = "EdDSA"; -pub const SD_JWT_VC_ISSUER_KEY_TYPE: &str = "OKP/Ed25519"; -pub const SD_JWT_VC_HOLDER_BINDING_METHOD: &str = "did:jwk"; -pub const MAX_BOUNDED_CLAIM_ID_LEN: usize = 128; -pub const MAX_CONFIG_METADATA_LEN: usize = 256; -pub const MAX_CORRELATION_ID_LEN: usize = 128; -pub const MAX_POLICY_ID_LEN: usize = 128; -pub const MAX_RATE_LIMIT_BUCKET_LEN: usize = 128; -pub const MAX_TOKEN_CLAIM_VALUE_LEN: usize = 512; -pub const MAX_VERIFIED_CLAIM_NAME_LEN: usize = 256; -pub const MAX_VERIFIED_CLAIM_VALUE_LEN: usize = 512; -pub const MAX_REQUEST_VARIABLES_V1: usize = 16; -pub const MAX_REQUEST_VARIABLE_NAME_BYTES_V1: usize = 96; - -pub type BoundedClaimId = Bounded; -pub type BoundedCorrelationId = Bounded; -pub type BoundedPolicyId = Bounded; -pub type ConfigMetadata = Bounded; -pub type RateLimitBucket = Bounded; -pub type VerifiedClaimName = Bounded; -pub type VerifiedClaimValue = Bounded; - -/// Closed v1 request variables. The first contract admits only named RFC 3339 -/// full-date strings, with service configuration deciding which names exist. -#[derive(Clone, Default, PartialEq, Eq, Serialize, utoipa::ToSchema)] -#[serde(transparent)] -pub struct RequestVariables(BTreeMap); - -impl fmt::Debug for RequestVariables { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("RequestVariables([REDACTED])") - } -} - -impl RequestVariables { - pub fn try_new(values: BTreeMap) -> Result { - if values.len() > MAX_REQUEST_VARIABLES_V1 - || values.iter().any(|(name, value)| { - !is_request_variable_name(name) || !is_rfc3339_full_date(value) - }) - { - return Err("request variables must be bounded named RFC 3339 full-date strings"); - } - Ok(Self(values)) - } - - #[must_use] - pub fn get(&self, name: &str) -> Option<&str> { - self.0.get(name).map(String::as_str) - } - - pub fn iter(&self) -> impl ExactSizeIterator { - self.0 - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl<'de> Deserialize<'de> for RequestVariables { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let values = BTreeMap::::deserialize(deserializer)?; - Self::try_new(values).map_err(de::Error::custom) - } -} - -#[must_use] -pub fn is_request_variable_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= MAX_REQUEST_VARIABLE_NAME_BYTES_V1 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -#[must_use] -pub fn is_rfc3339_full_date(value: &str) -> bool { - let bytes = value.as_bytes(); - if bytes.len() != 10 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) - { - return false; - } - let number = |range: std::ops::Range| value[range].parse::().ok(); - let (Some(year), Some(month), Some(day)) = (number(0..4), number(5..7), number(8..10)) else { - return false; - }; - let Ok(month) = time::Month::try_from(u8::try_from(month).unwrap_or(0)) else { - return false; - }; - time::Date::from_calendar_date(i32::from(year), month, u8::try_from(day).unwrap_or(0)).is_ok() -} - -/// The authentication trust profile that produced an [`EvidencePrincipal`]. -/// -/// These identifiers are deliberately closed and credential-independent so -/// they can safely participate in caller binding without incorporating raw -/// API keys, bearer tokens, or attacker-controlled token claims. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum EvidenceAuthProfileId { - StaticApiKey, - StaticBearer, - ExternalOidc, - NotaryAccessToken, - Federation, -} - -impl EvidenceAuthProfileId { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::StaticApiKey => "static_api_key", - Self::StaticBearer => "static_bearer", - Self::ExternalOidc => "external_oidc", - Self::NotaryAccessToken => "notary_access_token", - Self::Federation => "federation", - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum AccessMode { - Unknown, - #[default] - MachineClient, - SubjectBound, - DelegatedSubjectAccess, -} - -impl AccessMode { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Unknown => "unknown", - Self::MachineClient => "machine_client", - Self::SubjectBound => "subject_bound", - Self::DelegatedSubjectAccess => "delegated_subject_access", - } - } - - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "unknown" => Some(Self::Unknown), - "machine_client" => Some(Self::MachineClient), - "subject_bound" => Some(Self::SubjectBound), - "delegated_subject_access" => Some(Self::DelegatedSubjectAccess), - _ => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubjectAccessDenialCode { - Disabled, - OperationDenied, - ClaimDenied, - DisclosureDenied, - FormatDenied, - ProfileDenied, - SubjectClaimMissing, - SubjectMismatch, - RateLimited, - InvalidToken, - AssuranceDenied, - BatchDenied, - DelegatedRelationshipUnproven, - DelegatedRelationshipNotAllowed, - DelegatedClaimDenied, - DelegatedSubjectNotPermitted, - DelegatedProofDenied, -} - -impl SubjectAccessDenialCode { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Disabled => "subject_access.disabled", - Self::OperationDenied => "subject_access.operation_denied", - Self::ClaimDenied => "subject_access.claim_denied", - Self::DisclosureDenied => "subject_access.disclosure_denied", - Self::FormatDenied => "subject_access.format_denied", - Self::ProfileDenied => "subject_access.profile_denied", - Self::SubjectClaimMissing => "subject_access.subject_claim_missing", - Self::SubjectMismatch => "subject_access.subject_mismatch", - Self::RateLimited => "subject_access.rate_limited", - Self::InvalidToken => "subject_access.invalid_token", - Self::AssuranceDenied => "subject_access.assurance_denied", - Self::BatchDenied => "subject_access.batch_denied", - Self::DelegatedRelationshipUnproven => "delegated.relationship_unproven", - Self::DelegatedRelationshipNotAllowed => "delegated.relationship_not_allowed", - Self::DelegatedClaimDenied => "delegated.claim_denied", - Self::DelegatedSubjectNotPermitted => "delegated.subject_not_permitted", - Self::DelegatedProofDenied => "delegated.proof_denied", - } - } - - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "subject_access.disabled" => Some(Self::Disabled), - "subject_access.operation_denied" => Some(Self::OperationDenied), - "subject_access.claim_denied" => Some(Self::ClaimDenied), - "subject_access.disclosure_denied" => Some(Self::DisclosureDenied), - "subject_access.format_denied" => Some(Self::FormatDenied), - "subject_access.profile_denied" => Some(Self::ProfileDenied), - "subject_access.subject_claim_missing" => Some(Self::SubjectClaimMissing), - "subject_access.subject_mismatch" => Some(Self::SubjectMismatch), - "subject_access.rate_limited" => Some(Self::RateLimited), - "subject_access.invalid_token" => Some(Self::InvalidToken), - "subject_access.assurance_denied" => Some(Self::AssuranceDenied), - "subject_access.batch_denied" => Some(Self::BatchDenied), - "delegated.relationship_unproven" => Some(Self::DelegatedRelationshipUnproven), - "delegated.relationship_not_allowed" => Some(Self::DelegatedRelationshipNotAllowed), - "delegated.claim_denied" => Some(Self::DelegatedClaimDenied), - "delegated.subject_not_permitted" => Some(Self::DelegatedSubjectNotPermitted), - "delegated.proof_denied" => Some(Self::DelegatedProofDenied), - _ => None, - } - } -} - -impl Serialize for SubjectAccessDenialCode { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for SubjectAccessDenialCode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).ok_or_else(|| de::Error::custom("unknown subject-access denial code")) - } -} - -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Bounded(String); - -impl Bounded { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.len() > N { - return Err(BoundedStringError { - max: N, - actual: value.len(), - }); - } - Ok(Self(value)) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } -} - -impl fmt::Debug for Bounded { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Bounded").field(&self.0).finish() - } -} - -impl TryFrom for Bounded { - type Error = BoundedStringError; - - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl TryFrom<&str> for Bounded { - type Error = BoundedStringError; - - fn try_from(value: &str) -> Result { - Self::new(value) - } -} - -impl Serialize for Bounded { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl<'de, const N: usize> Deserialize<'de> for Bounded { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BoundedStringError { - pub max: usize, - pub actual: usize, -} - -impl fmt::Display for BoundedStringError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "bounded string length {} exceeds maximum {}", - self.actual, self.max - ) - } -} - -impl std::error::Error for BoundedStringError {} - -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Hashed { - value: String, - _marker: PhantomData, -} - -impl Hashed { - #[must_use] - pub fn from_hash(value: impl Into) -> Self { - Self { - value: value.into(), - _marker: PhantomData, - } - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.value - } - - #[must_use] - pub fn into_inner(self) -> String { - self.value - } -} - -impl fmt::Debug for Hashed { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Hashed").field(&self.value).finish() - } -} - -impl Serialize for Hashed { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.value) - } -} - -impl<'de, T> Deserialize<'de> for Hashed { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct HashedVisitor(PhantomData); - - impl Visitor<'_> for HashedVisitor { - type Value = Hashed; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a hashed identifier string") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(Hashed::from_hash(value)) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(Hashed::from_hash(value)) - } - } - - deserializer.deserialize_string(HashedVisitor(PhantomData)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrincipalIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubjectBinding {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EvidenceEntityReference {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HolderIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PreAuthorizedCodeIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CredentialIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClaimSet {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolicyIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RequestIdentifier {} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BoundedVerifiedClaims { - pub issuer: VerifiedClaimValue, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub audiences: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_configuration_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_transaction_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_transaction_commitment: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scopes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_binding_claim: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_binding_value: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub acr: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_time: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exp: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub iat: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nbf: Option, -} - -impl fmt::Debug for BoundedVerifiedClaims { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BoundedVerifiedClaims") - .field("issuer", &self.issuer) - .field("audiences", &self.audiences) - .field("client_id", &self.client_id) - .field("token_type", &self.token_type) - .field( - "credential_configuration_id", - &self.credential_configuration_id, - ) - .field("issuance_transaction_id", &"") - .field( - "issuance_transaction_commitment", - &self.issuance_transaction_commitment, - ) - .field("scopes", &self.scopes) - .field("subject", &self.subject.as_ref().map(|_| "")) - .field("subject_binding_claim", &self.subject_binding_claim) - .field( - "subject_binding_value", - &self.subject_binding_value.as_ref().map(|_| ""), - ) - .field("acr", &self.acr) - .field("auth_time", &self.auth_time) - .field("exp", &self.exp) - .field("iat", &self.iat) - .field("nbf", &self.nbf) - .finish() - } -} - -impl BoundedVerifiedClaims { - #[must_use] - pub fn has_scope(&self, scope: &str) -> bool { - self.scopes - .iter() - .any(|candidate| candidate.as_str() == scope) - } - - #[must_use] - pub fn claim_value(&self, claim_name: &str) -> Option<&str> { - match claim_name { - "iss" => Some(self.issuer.as_str()), - "sub" => self.subject.as_ref().map(Bounded::as_str), - "typ" | "token_type" => self.token_type.as_ref().map(Bounded::as_str), - "credential_configuration_id" => self - .credential_configuration_id - .as_ref() - .map(Bounded::as_str), - "issuance_transaction_id" => self.issuance_transaction_id.as_ref().map(Bounded::as_str), - "issuance_transaction_commitment" => self - .issuance_transaction_commitment - .as_ref() - .map(Bounded::as_str), - "client_id" | "azp" => self.client_id.as_ref().map(Bounded::as_str), - "acr" => self.acr.as_ref().map(Bounded::as_str), - other => self - .subject_binding_claim - .as_ref() - .filter(|configured| configured.as_str() == other) - .and(self.subject_binding_value.as_ref()) - .map(Bounded::as_str), - } - } - - #[must_use] - pub fn subject_binding_value(&self, claim_name: &str) -> Option<&str> { - self.subject_binding_claim - .as_ref() - .filter(|configured| configured.as_str() == claim_name) - .and(self.subject_binding_value.as_ref()) - .map(Bounded::as_str) - .filter(|value| !value.trim().is_empty()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "mode", rename_all = "snake_case")] -pub enum EvaluationCapability { - Machine { - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - scopes: BTreeSet, - }, - SubjectBound { - #[serde(default, skip_serializing_if = "Option::is_none")] - claim_id: Option, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - allowed_claim_ids: BTreeSet, - subject_binding_hash: Hashed, - }, - DelegatedSubjectAccess { - proof_claim_id: BoundedClaimId, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - allowed_claim_ids: BTreeSet, - requester_subject_binding_hash: Hashed, - dependent_target_hash: Hashed, - relationship_type: ConfigMetadata, - }, -} - -impl EvaluationCapability { - #[must_use] - pub fn access_mode(&self) -> AccessMode { - match self { - Self::Machine { .. } => AccessMode::MachineClient, - Self::SubjectBound { .. } => AccessMode::SubjectBound, - Self::DelegatedSubjectAccess { .. } => AccessMode::DelegatedSubjectAccess, - } - } - - #[must_use] - pub fn allows_scope(&self, scope: &str) -> bool { - match self { - Self::Machine { scopes } => scopes.contains(scope), - Self::SubjectBound { .. } => false, - Self::DelegatedSubjectAccess { .. } => false, - } - } - - #[must_use] - pub fn allows_subject_access_claim(&self, claim_id: &str) -> bool { - match self { - Self::Machine { .. } => false, - Self::SubjectBound { - claim_id: allowed, - allowed_claim_ids, - .. - } => { - allowed - .as_ref() - .is_some_and(|allowed| allowed.as_str() == claim_id) - || allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) - } - Self::DelegatedSubjectAccess { .. } => false, - } - } - - #[must_use] - pub fn allows_delegated_claim(&self, claim_id: &str) -> bool { - match self { - Self::DelegatedSubjectAccess { - proof_claim_id, - allowed_claim_ids, - .. - } => { - proof_claim_id.as_str() == claim_id - || allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) - } - Self::Machine { .. } | Self::SubjectBound { .. } => false, - } - } - - #[must_use] - pub fn required_delegated_proof_for_claim(&self, claim_id: &str) -> Option<&str> { - match self { - Self::DelegatedSubjectAccess { - proof_claim_id, - allowed_claim_ids, - .. - } if proof_claim_id.as_str() != claim_id - && allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) => - { - Some(proof_claim_id.as_str()) - } - _ => None, - } - } - - #[must_use] - pub fn is_delegated_proof_claim(&self, claim_id: &str) -> bool { - match self { - Self::DelegatedSubjectAccess { proof_claim_id, .. } => { - proof_claim_id.as_str() == claim_id - } - Self::Machine { .. } | Self::SubjectBound { .. } => false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DisclosureProfile { - Value, - Predicate, - Redacted, -} - -impl DisclosureProfile { - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "value" => Some(Self::Value), - "predicate" => Some(Self::Predicate), - "redacted" => Some(Self::Redacted), - _ => None, - } - } - - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Value => "value", - Self::Predicate => "predicate", - Self::Redacted => "redacted", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DisclosureDowngrade { - Deny, - Default, - Redacted, -} - -impl DisclosureDowngrade { - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "deny" | "none" => Some(Self::Deny), - "default" => Some(Self::Default), - "redacted" => Some(Self::Redacted), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimRef { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -impl ClaimRef { - #[must_use] - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - version: None, - } - } - - #[must_use] - pub fn with_version(id: impl Into, version: impl Into) -> Self { - Self { - id: id.into(), - version: Some(version.into()), - } - } -} - -impl From for ClaimRef { - fn from(id: String) -> Self { - Self::new(id) - } -} - -impl From<&str> for ClaimRef { - fn from(id: &str) -> Self { - Self::new(id) - } -} - -impl std::ops::Deref for ClaimRef { - type Target = str; - - fn deref(&self) -> &Self::Target { - self.id.as_str() - } -} - -#[derive(Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct ClaimRefObject { - id: String, - #[serde(default)] - version: Option, -} - -#[derive(Deserialize, JsonSchema)] -#[serde(untagged)] -enum WireClaimRef { - Id(String), - Object(ClaimRefObject), -} - -impl<'de> Deserialize<'de> for ClaimRef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match WireClaimRef::deserialize(deserializer)? { - WireClaimRef::Id(id) => Ok(Self::new(id)), - WireClaimRef::Object(object) => Ok(Self { - id: object.id, - version: object.version, - }), - } - } -} - -impl JsonSchema for ClaimRef { - fn schema_name() -> Cow<'static, str> { - "ClaimRef".into() - } - - fn json_schema(generator: &mut SchemaGenerator) -> Schema { - generator.subschema_for::() - } -} - -/// Frozen minimal actor/delegation envelope for `on_behalf_of`. -/// -/// Replaces the previous free-form `Option`. This is the beta-frozen -/// shape per the 2026-06-11 evidence-contracts decision record (D4): a -/// structured actor plus an opaque `delegation_ref`. Simple deployments send no -/// envelope at all (the field stays optional). No OAuth token exchange, RAR, or -/// CIBA machinery is required here; those arrive post-1.0 as additive profiles -/// (notary#180) that map the actor onto OAuth `act`-claim semantics. The shape -/// does not bake in a single-actor assumption: an actor chain is expressed by -/// `delegation_ref` indirection, so the additive mapping stays open. -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceOnBehalfOf { - pub actor: EvidenceActor, - /// Opaque reference to an out-of-band delegation record. The envelope does - /// not interpret its contents; it is the indirection point through which a - /// later OAuth profile resolves an actor chain. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation_ref: Option, -} - -/// A structured actor in the delegation envelope. The same vocabulary is reused -/// for stored delegation-chain entries so wire requests and stored evaluations -/// do not diverge. -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceActor { - #[serde(rename = "type")] - pub actor_type: String, - /// Keyed-hash identifier of the actor in `hmac-sha256:` format per the - /// D7 vocabulary. Never a raw principal value. - pub id_hash: String, - /// Optional assurance level of the actor (for example an `acr` value). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvaluateRequest { - #[serde(default)] - pub requester: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default)] - pub relationship: Option, - #[serde(default)] - pub on_behalf_of: Option, - #[serde(default, skip_serializing_if = "RequestVariables::is_empty")] - pub variables: RequestVariables, - pub claims: Vec, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub purpose: Option, -} - -impl EvaluateRequest { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target - .as_ref() - .and_then(EvidenceEntity::to_subject_request) - } - - #[must_use] - pub fn request_context(&self) -> Option { - self.target.as_ref().map(|target| EvidenceRequestContext { - requester: self.requester.clone(), - target: target.clone(), - relationship: self.relationship.clone(), - on_behalf_of: self.on_behalf_of.clone(), - variables: self.variables.clone(), - }) - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceRequestContext { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester: Option, - pub target: EvidenceEntity, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_behalf_of: Option, - #[serde(default, skip_serializing_if = "RequestVariables::is_empty")] - pub variables: RequestVariables, -} - -impl EvidenceRequestContext { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target.to_subject_request() - } - - #[must_use] - pub fn lookup_value(&self, path: &str) -> Option { - match path { - "target.id" => self - .target - .id - .as_ref() - .map(|value| Value::String(value.clone())), - "requester.id" => self - .requester - .as_ref() - .and_then(|requester| requester.id.as_ref()) - .map(|value| Value::String(value.clone())), - _ if path.starts_with("target.attributes.") => { - let key = path.strip_prefix("target.attributes.")?; - self.target.attributes.get(key).cloned() - } - _ if path.starts_with("requester.attributes.") => { - let key = path.strip_prefix("requester.attributes.")?; - self.requester - .as_ref() - .and_then(|requester| requester.attributes.get(key)) - .cloned() - } - _ if path.starts_with("relationship.attributes.") => { - let key = path.strip_prefix("relationship.attributes.")?; - self.relationship - .as_ref() - .and_then(|relationship| relationship.attributes.get(key)) - .cloned() - } - _ if path.starts_with("target.identifiers.") => { - let scheme = path.strip_prefix("target.identifiers.")?; - self.target - .identifier_value(scheme) - .map(|value| Value::String(value.to_string())) - } - _ if path.starts_with("requester.identifiers.") => { - let scheme = path.strip_prefix("requester.identifiers.")?; - self.requester - .as_ref() - .and_then(|requester| requester.identifier_value(scheme)) - .map(|value| Value::String(value.to_string())) - } - _ if path.starts_with("variables.") => { - let name = path.strip_prefix("variables.")?; - self.variables - .get(name) - .map(|value| Value::String(value.to_string())) - } - _ => None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceEntity { - #[serde(rename = "type")] - pub entity_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifiers: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub attributes: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -impl EvidenceEntity { - #[must_use] - pub fn new(entity_type: impl Into) -> Self { - Self { - entity_type: entity_type.into(), - id: None, - identifiers: Vec::new(), - attributes: BTreeMap::new(), - assurance: None, - profile: None, - } - } - - #[must_use] - pub fn with_identifier( - entity_type: impl Into, - scheme: impl Into, - value: impl Into, - ) -> Self { - Self { - entity_type: entity_type.into(), - id: None, - identifiers: vec![EvidenceIdentifier { - scheme: scheme.into(), - value: value.into(), - issuer: None, - country: None, - }], - attributes: BTreeMap::new(), - assurance: None, - profile: None, - } - } - - #[must_use] - pub fn from_subject_request(entity_type: impl Into, subject: SubjectRequest) -> Self { - match subject.id_type { - Some(id_type) => Self::with_identifier(entity_type, id_type, subject.id), - None => { - let mut entity = Self::new(entity_type); - entity.id = Some(subject.id); - entity - } - } - } - - #[must_use] - pub fn to_subject_request(&self) -> Option { - if let Some(identifier) = self.identifiers.first() { - return Some(SubjectRequest { - id: identifier.value.clone(), - id_type: Some(identifier.scheme.clone()), - }); - } - self.id.as_ref().map(|id| SubjectRequest { - id: id.clone(), - id_type: None, - }) - } - - #[must_use] - pub fn identifier_value(&self, scheme: &str) -> Option<&str> { - self.identifiers - .iter() - .find(|identifier| identifier.scheme == scheme) - .map(|identifier| identifier.value.as_str()) - } - - #[must_use] - pub fn has_matching_input(&self) -> bool { - self.id.as_ref().is_some_and(|id| !id.trim().is_empty()) - || self - .identifiers - .iter() - .any(|identifier| !identifier.value.trim().is_empty()) - || !self.attributes.is_empty() - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceIdentifier { - pub scheme: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub country: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAssurance { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub level_scheme: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub level: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verified_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub evidence: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceRelationship { - #[serde(rename = "type")] - pub relationship_type: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub attributes: BTreeMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectRequest { - pub id: String, - #[serde(default)] - pub id_type: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchSubjectRequest { - pub id: String, - #[serde(default)] - pub id_type: Option, - #[serde(default)] - pub purpose: Option, -} - -impl From for SubjectRequest { - fn from(subject: BatchSubjectRequest) -> Self { - Self { - id: subject.id, - id_type: subject.id_type, - } - } -} - -impl From for BatchSubjectRequest { - fn from(subject: SubjectRequest) -> Self { - Self { - id: subject.id, - id_type: subject.id_type, - purpose: None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchEvaluateItemRequest { - pub target: EvidenceEntity, - #[serde(default)] - pub requester: Option, - #[serde(default)] - pub relationship: Option, - #[serde(default)] - pub on_behalf_of: Option, - #[serde(default)] - pub purpose: Option, -} - -impl BatchEvaluateItemRequest { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target.to_subject_request() - } - - #[must_use] - pub fn request_context(&self) -> EvidenceRequestContext { - EvidenceRequestContext { - requester: self.requester.clone(), - target: self.target.clone(), - relationship: self.relationship.clone(), - on_behalf_of: self.on_behalf_of.clone(), - variables: RequestVariables::default(), - } - } -} - -impl From for BatchEvaluateItemRequest { - fn from(subject: BatchSubjectRequest) -> Self { - let purpose = subject.purpose.clone(); - Self { - target: EvidenceEntity::from_subject_request("Person", SubjectRequest::from(subject)), - requester: None, - relationship: None, - on_behalf_of: None, - purpose, - } - } -} - -impl From for BatchEvaluateItemRequest { - fn from(subject: SubjectRequest) -> Self { - Self { - target: EvidenceEntity::from_subject_request("Person", subject), - requester: None, - relationship: None, - on_behalf_of: None, - purpose: None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchEvaluateRequest { - pub items: Vec, - pub claims: Vec, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub purpose: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchEvaluateResponse { - pub batch_id: String, - pub status: BatchStatus, - pub claims: Vec, - pub items: Vec, - pub summary: BatchSummary, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BatchStatus { - Completed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchSummary { - pub succeeded: usize, - pub failed: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchItemResponse { - pub input_index: usize, - pub target_ref: TargetRefView, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub evaluation_id: Option, - pub status: BatchItemStatus, - pub claim_results: Vec, - pub errors: Vec, - /// Request-local consultation evidence for restricted audit assembly. - /// This is never serialized into the public response or durable state. - #[doc(hidden)] - #[serde(skip)] - pub runtime_audit: BatchItemRuntimeAudit, -} - -/// Request-local, value-free consultation evidence for a batch member. -#[doc(hidden)] -#[derive(Clone, Default)] -pub struct BatchItemRuntimeAudit { - pub relay_forwarded_count: u64, - pub relay_consultation_ids: Vec, -} - -impl std::fmt::Debug for BatchItemRuntimeAudit { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("BatchItemRuntimeAudit") - .field("relay_forwarded_count", &self.relay_forwarded_count) - .field("relay_consultation_ids", &"[REDACTED]") - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BatchItemStatus { - Succeeded, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchClaimResultView { - pub result_id: String, - pub claim_id: String, - pub claim_version: String, - pub value_type: String, - pub value: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub satisfied: Option, - pub disclosure: String, - pub provenance: ClaimProvenance, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchItemError { - pub code: String, - pub title: String, - pub retryable: bool, - #[serde(default, skip)] - pub audit_code: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RenderRequest { - pub evaluation_id: String, - pub format: String, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub purpose: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RenderEvaluationRequest { - pub format: String, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub purpose: Option, -} - -impl RenderEvaluationRequest { - #[must_use] - pub fn with_evaluation_id(self, evaluation_id: String) -> RenderRequest { - RenderRequest { - evaluation_id, - format: self.format, - disclosure: self.disclosure, - claims: self.claims, - purpose: self.purpose, - } - } -} - -impl From for RenderEvaluationRequest { - fn from(request: RenderRequest) -> Self { - Self { - format: request.format, - disclosure: request.disclosure, - claims: request.claims, - purpose: request.purpose, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialIssueRequest { - pub evaluation_id: String, - #[serde(default)] - pub credential_profile: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub purpose: Option, - #[serde(default)] - pub holder: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct HolderRequest { - #[serde(default)] - pub binding: Option, - #[serde(default)] - pub id: Option, - #[serde(default)] - pub proof: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvidenceFormat { - pub id: String, - pub kind: String, - pub status: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ClaimResultView { - pub evaluation_id: String, - pub claim_id: String, - pub claim_version: String, - pub subject_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref: Option, - pub target_ref: TargetRefView, - pub value: Option, - pub satisfied: Option, - pub disclosure: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub redacted_fields: Vec, - pub format: String, - pub issued_at: String, - pub expires_at: Option, - pub provenance: ClaimProvenance, -} - -impl std::fmt::Debug for ClaimResultView { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ClaimResultView") - .field("evaluation_id", &"[REDACTED]") - .field("claim_id", &self.claim_id) - .field("claim_version", &self.claim_version) - .field("subject_type", &self.subject_type) - .field("requester_ref", &"[REDACTED]") - .field("target_ref", &"[REDACTED]") - .field("value", &"[REDACTED]") - .field("satisfied", &self.satisfied) - .field("disclosure", &self.disclosure) - .field("redacted_fields", &self.redacted_fields) - .field("format", &self.format) - .field("issued_at", &self.issued_at) - .field("expires_at", &self.expires_at) - .field("provenance", &self.provenance) - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TargetRefView { - #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] - pub entity_type: String, - pub handle: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifier_schemes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceEntityRef { - #[serde(rename = "type")] - pub entity_type: String, - pub handle: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifier_schemes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -/// `schema_version` value carried by every [`ClaimProvenance`]. V2 names the -/// consumed boundary as Relay consultations and removes unreleased source -/// runtime aliases. -pub const CLAIM_PROVENANCE_SCHEMA_VERSION: &str = "registry-notary-claim-provenance/v2"; - -/// The `type` value for a claim-evaluation provenance record. -pub const PROVENANCE_GENERATED_BY_CLAIM_EVALUATION: &str = "claim_evaluation"; - -/// Versioned claim provenance attached to every public claim result. -/// -/// This contract lets a verifier answer which evaluation produced the result, -/// under which policy, and across how many Relay consultations. The shape is -/// documented as PROV-mappable but is not PROV-O. -/// Requester-side identity (client, actor, subject) is deliberately absent; -/// those live in restricted audit, never on the public wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ClaimProvenance { - pub schema_version: String, - pub generated_by: ProvenanceGeneratedBy, - pub used: ProvenanceUsed, - /// Upstream provenance records this result was derived from. Reserved for - /// cross-evaluation linking; always empty in v2 but present in the shape so - /// adding entries later is additive. - pub derived_from: Vec, -} - -impl ClaimProvenance { - /// Construct a provenance record at the current schema version with the - /// canonical `generated_by.type`. - #[must_use] - pub fn new( - service_id: String, - evaluation_id: String, - claim_id: String, - claim_version: String, - used: ProvenanceUsed, - ) -> Self { - Self { - schema_version: CLAIM_PROVENANCE_SCHEMA_VERSION.to_string(), - generated_by: ProvenanceGeneratedBy { - entry_type: PROVENANCE_GENERATED_BY_CLAIM_EVALUATION.to_string(), - service_id, - evaluation_id, - claim_id, - claim_version, - policy_id: None, - policy_version: None, - policy_hash: None, - pack_id: None, - pack_version: None, - }, - used, - derived_from: Vec::new(), - } - } -} - -/// The producing side of a claim provenance record. -/// -/// `policy_id` here names the *evaluation* policy under which the result was -/// produced. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvenanceGeneratedBy { - #[serde(rename = "type")] - pub entry_type: String, - /// Identifier of the service that produced the result. Replaces the - /// dropped `computed_by` field; the CCCEV renderer maps its provider agent - /// from here. - pub service_id: String, - pub evaluation_id: String, - pub claim_id: String, - pub claim_version: String, - /// Evaluation policy identifier. Present for flows evaluated under a named - /// policy (e.g. subject-access); omitted for machine-client flows with no - /// evaluation policy. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - /// `sha256:` digest of the evaluation policy. Public in v1: a hash, - /// revealing no policy content, that lets a verifier correlate the result - /// with a policy evidence-pack later. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pack_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pack_version: Option, -} - -/// The consumed side of a claim provenance record: how many Relay consultations -/// contributed to the claim. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvenanceUsed { - pub relay_consultation_count: usize, -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredEvaluation { - pub client_id: String, - pub purpose: String, - pub claim_ids: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claim_refs: Vec, - pub disclosure: String, - pub format: String, - pub results: Vec, - pub created_at: String, - pub expires_at: String, - pub request_hash: String, - /// Private issuance-only Relay provenance for the selected dependency closure. - /// - /// This is deliberately separate from public [`ClaimProvenance`]. It is - /// persisted only for credential-capable selections so a later credential - /// request can prove that every fact being signed came from the exact - /// compiler-pinned Relay consultation. Evaluation-only selections retain - /// no private Relay execution identifiers. Evaluations written before this - /// field existed remain readable, but are not credential-issuable and must - /// be evaluated again. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_provenance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_access: Option, -} - -impl std::fmt::Debug for StoredEvaluation { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredEvaluation") - .field("client_id", &"[REDACTED]") - .field("purpose", &self.purpose) - .field("claim_ids", &self.claim_ids) - .field("claim_refs", &self.claim_refs) - .field("disclosure", &self.disclosure) - .field("format", &self.format) - .field("result_count", &self.results.len()) - .field("created_at", &self.created_at) - .field("expires_at", &self.expires_at) - .field("request_hash", &"[REDACTED]") - .field("issuance_provenance", &self.issuance_provenance) - .field("subject_access", &self.subject_access) - .finish() - } -} - -impl StoredEvaluation { - #[must_use] - pub fn access_mode(&self) -> AccessMode { - self.subject_access - .as_ref() - .map(|metadata| metadata.access_mode) - .unwrap_or(AccessMode::MachineClient) - } - - #[must_use] - pub fn selected_claim_refs(&self) -> Vec { - if self.claim_refs.is_empty() { - self.claim_ids - .iter() - .map(|claim_id| ClaimRef::from(claim_id.as_str())) - .collect() - } else { - self.claim_refs.clone() - } - } -} - -/// Bounded private provenance retained for credential issuance. -/// -/// The runtime admits at most the v1 claim-closure bound and the issuance -/// verifier rejects an over-sized or incomplete set before any signer or -/// credential-status side effect. Consultation identifiers remain restricted -/// state and never appear in evaluation, render, or credential responses. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceProvenance { - pub claims: Vec, - /// Unique Relay executions referenced by the claim closure. - /// - /// Keeping executions separate permits one coalesced Relay consultation to - /// support several claim pins without duplicating the restricted execution - /// record. A missing empty legacy field is readable but nonissuable. - #[serde(default)] - pub consultations: Vec, - /// Keyed commitment joining the complete opaque evaluated target reference - /// to the target's canonical primary authorization identity. The - /// commitment permits a later RAR comparison without retaining the raw - /// identity. Evaluations stored before this field existed remain readable, - /// but cannot initiate a registry-client credential offer. - #[serde(default)] - pub authorization_target_binding: String, -} - -impl std::fmt::Debug for StoredIssuanceProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceProvenance") - .field("claim_count", &self.claims.len()) - .field("consultation_count", &self.consultations.len()) - .finish() - } -} - -/// The exact compiler pin and successful Relay execution for one claim in a -/// selected root's dependency closure. This restricted persistence shape is -/// not a public API model. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceClaimProvenance { - pub claim_id: String, - pub claim_version: String, - pub relay_profile_id: String, - pub relay_contract_hash: String, - pub canonical_purpose: String, - pub consultation_id: String, - /// Deterministic SHA-256 commitment over this compiler pin, its Relay - /// execution record, and the claim result provenance produced by the - /// evaluation. A missing legacy value remains readable but is not - /// credential-issuable. - #[serde(default)] - pub execution_binding: String, - /// Canonical commitment to the exact evaluated result retained for - /// issuance. The value itself is deliberately not duplicated into this - /// restricted provenance record. - #[serde(default)] - pub result_content_binding: String, -} - -impl std::fmt::Debug for StoredIssuanceClaimProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceClaimProvenance") - .field("claim_id", &self.claim_id) - .field("claim_version", &self.claim_version) - .field("relay_profile_id", &self.relay_profile_id) - .field("relay_contract_hash", &self.relay_contract_hash) - .field("canonical_purpose", &self.canonical_purpose) - .field("consultation_id", &"[REDACTED]") - .field("execution_binding", &self.execution_binding) - .field("result_content_binding", &self.result_content_binding) - .finish() - } -} - -/// One unique successful Relay execution retained for issuance verification. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceConsultationProvenance { - pub consultation_id: String, - pub acquired_at: String, -} - -impl std::fmt::Debug for StoredIssuanceConsultationProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceConsultationProvenance") - .field("consultation_id", &"[REDACTED]") - .field("acquired_at", &"[REDACTED]") - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StoredSubjectAccessMetadata { - #[serde(default = "subject_access_access_mode")] - pub access_mode: AccessMode, - pub issuer: VerifiedClaimValue, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub audiences: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - pub principal_hash: Hashed, - pub subject_id_type: ConfigMetadata, - pub subject_binding_claim: ConfigMetadata, - pub subject_binding_hash: Hashed, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dependent_target_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub proof_claim_id: Option, - pub requested_claims_hash: Hashed, - pub disclosure: ConfigMetadata, - pub result_format: ConfigMetadata, - /// Delegation chain in the frozen envelope vocabulary (D4). Empty in v1; - /// populated post-1.0 by the additive OAuth profile (notary#180). The empty - /// case serializes identically to the previous placeholder. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegation_chain: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evaluation_expires_at: Option, -} - -const fn subject_access_access_mode() -> AccessMode { - AccessMode::SubjectBound -} - -/// Versioned authorization fields shared by static configuration and token/OIDC JSON. -/// -/// Unknown metadata is intentionally ignored for forward-compatible interoperability. -/// Authorization decisions consume only the modeled fields and must never infer authority -/// from an unrecognized extension. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationDetails { - #[serde(rename = "type")] - pub detail_type: String, - pub schema_version: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub locations: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claims: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disclosure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purpose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub legal_basis_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consent_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance_level: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub access_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assisted_access_context: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationSubject { - pub binding_claim: String, - pub id_type: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationTarget { - pub id_type: String, - pub id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationRelationship { - pub relationship_type: String, - pub proof_claim: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAssistedAccessContext { - pub channel: String, -} - -#[derive(Debug, Clone)] -pub struct EvidencePrincipal { - pub auth_profile_id: EvidenceAuthProfileId, - pub principal_id: String, - pub scopes: Vec, - pub access_mode: AccessMode, - pub verified_claims: Option, - pub authorization_details: Option, -} - -impl EvidencePrincipal { - #[must_use] - pub fn has_scope(&self, scope: &str) -> bool { - self.scopes.iter().any(|candidate| candidate == scope) - } - - #[must_use] - pub fn has_any_scope<'a>(&self, scopes: impl IntoIterator) -> bool { - scopes.into_iter().any(|scope| self.has_scope(scope)) - } - - #[must_use] - pub const fn access_mode(&self) -> AccessMode { - self.access_mode - } - - #[must_use] - pub const fn is_subject_access(&self) -> bool { - matches!( - self.access_mode, - AccessMode::SubjectBound | AccessMode::DelegatedSubjectAccess - ) - } - - #[must_use] - pub fn verified_claim(&self, claim_name: &str) -> Option<&str> { - self.verified_claims - .as_ref() - .and_then(|claims| claims.claim_value(claim_name)) - } - - #[must_use] - pub fn verified_subject_binding_value(&self, claim_name: &str) -> Option<&str> { - self.verified_claims - .as_ref() - .and_then(|claims| claims.subject_binding_value(claim_name)) - } -} - -#[derive(Clone, Deserialize, Serialize)] -pub struct EvidenceAuditEvent { - pub event_id: String, - pub occurred_at: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub principal_id_hash: Option>, - #[serde(default)] - pub scopes_used: Vec, - pub decision: String, - pub method: String, - pub path: String, - pub status: u16, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verification_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claim_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purposes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub row_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay_consultation_count: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relay_consultation_ids: Vec, - /// Conservative dispatch-attempt marker. `true` means Notary committed to - /// Relay work that may have reached Relay, not that Relay received it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub forwarded: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub access_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_peer_id_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_issuer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_purpose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_request_jti_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_subject_ref_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub denial_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_claim_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub correlation_id_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_configuration_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub holder_binding_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rate_limit_bucket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub redacted_fields: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub batch_items: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config: Option, -} - -impl std::fmt::Debug for EvidenceAuditEvent { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("EvidenceAuditEvent") - .field("event_id", &"[REDACTED]") - .field("decision", &self.decision) - .field("method", &self.method) - .field("path", &self.path) - .field("status", &self.status) - .field("verification_id", &"[REDACTED]") - .field("relay_consultation_ids", &"[REDACTED]") - .field("forwarded", &self.forwarded) - .field("error_code", &self.error_code) - .finish_non_exhaustive() - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ConfigAuditEvent { - pub action: String, - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub acceptance_identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_manifest_hash: Option, - #[serde( - default, - rename = "bundle_sequence", - skip_serializing_if = "Option::is_none" - )] - pub sequence: Option, - pub signer_kids: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub previous_config_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub previous_hash_matched: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub anchor_digest: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub anchor_version: Option, - pub product_validation_result: String, - pub apply_result: String, - pub posture_result: String, - pub applied: bool, - pub restart_required: bool, - pub change_classes: Vec, - pub break_glass: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_approval_reference: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_approved_by: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_reason_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_emergency_change_class: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_expires_at_unix_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_rate_limit_identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_reference: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_approved_by: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_reason_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_change_class: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_expires_at_unix_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_rate_limit_identity: Option, -} - -#[derive(Clone, Deserialize, Serialize)] -pub struct EvidenceBatchItemAuditEvent { - pub input_index: usize, - #[serde(default)] - pub outcome: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default)] - pub relay_consultation_count: u64, - #[serde(default)] - pub forwarded: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relay_consultation_ids: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref_hash: Option>, -} - -impl std::fmt::Debug for EvidenceBatchItemAuditEvent { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("EvidenceBatchItemAuditEvent") - .field("input_index", &self.input_index) - .field("outcome", &self.outcome) - .field("error_code", &self.error_code) - .field("relay_consultation_count", &self.relay_consultation_count) - .field("forwarded", &self.forwarded) - .field("relay_consultation_ids", &"[REDACTED]") - .finish_non_exhaustive() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn bounded(value: &str) -> Bounded { - Bounded::new(value).expect("test value is bounded") - } - - #[test] - fn access_mode_serializes_as_stable_snake_case() { - assert_eq!( - serde_json::to_value(AccessMode::SubjectBound).expect("access mode serializes"), - json!("subject_bound") - ); - assert_eq!( - AccessMode::parse("machine_client"), - Some(AccessMode::MachineClient) - ); - } - - #[test] - fn evidence_auth_profile_ids_are_closed_and_stable() { - for (profile, expected) in [ - (EvidenceAuthProfileId::StaticApiKey, "static_api_key"), - (EvidenceAuthProfileId::StaticBearer, "static_bearer"), - (EvidenceAuthProfileId::ExternalOidc, "external_oidc"), - ( - EvidenceAuthProfileId::NotaryAccessToken, - "notary_access_token", - ), - (EvidenceAuthProfileId::Federation, "federation"), - ] { - assert_eq!(profile.as_str(), expected); - assert_eq!( - serde_json::to_value(profile).expect("auth profile serializes"), - json!(expected) - ); - assert_eq!( - serde_json::from_value::(json!(expected)) - .expect("known auth profile deserializes"), - profile - ); - } - assert!( - serde_json::from_value::(json!("attacker_selected")).is_err() - ); - } - - #[test] - fn claim_ref_deserializes_string_and_versioned_object() { - let legacy: ClaimRef = - serde_json::from_value(json!("person-is-alive")).expect("legacy claim id deserializes"); - assert_eq!(legacy.id, "person-is-alive"); - assert_eq!(legacy.version, None); - - let versioned: ClaimRef = - serde_json::from_value(json!({ "id": "person-is-alive", "version": "2026-05" })) - .expect("versioned claim ref deserializes"); - assert_eq!(versioned.id, "person-is-alive"); - assert_eq!(versioned.version.as_deref(), Some("2026-05")); - } - - #[test] - fn private_issuance_target_binding_is_backward_readable_and_debug_redacted() { - let legacy: StoredIssuanceProvenance = serde_json::from_value(json!({ - "claims": [], - "consultations": [], - })) - .expect("legacy private provenance remains readable"); - assert!(legacy.authorization_target_binding.is_empty()); - - let stored = StoredIssuanceProvenance { - claims: Vec::new(), - consultations: Vec::new(), - authorization_target_binding: - "hmac-sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }; - let debug = format!("{stored:?}"); - assert!(!debug.contains("hmac-sha256")); - assert!(!debug.contains("aaaaaaaa")); - } - - #[test] - fn evaluate_request_deserializes_identity_bundle_target() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "requester": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-9001", "country": "RW" } - ] - }, - "target": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-1001" } - ], - "attributes": { - "given_name": "Amina", - "family_name": "Kamanzi", - "date_of_birth": "1990-01-15" - }, - "assurance": { - "method": "oidc", - "level_scheme": "example-loa", - "level": "substantial" - } - }, - "relationship": { - "type": "self" - }, - "on_behalf_of": { - "actor": { - "type": "operator", - "id_hash": "hmac-sha256:abc123" - } - }, - "claims": ["person-is-alive"], - "purpose": "https://purpose.example/social-protection" - })) - .expect("new request shape deserializes"); - - let target = request.target.as_ref().expect("target is present"); - assert_eq!(target.entity_type, "person"); - assert_eq!( - request - .target_subject() - .expect("identifier target maps to source subject") - .id_type - .as_deref(), - Some("national_id") - ); - assert_eq!(target.attributes["date_of_birth"], json!("1990-01-15")); - } - - #[test] - fn evaluate_request_variables_are_closed_bounded_full_dates() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": { "as_of_date": "2026-01-01" }, - "claims": ["age-band"] - })) - .expect("declared-shape request variable parses"); - assert_eq!(request.variables.get("as_of_date"), Some("2026-01-01")); - assert_eq!( - request - .request_context() - .and_then(|context| context.lookup_value("variables.as_of_date")), - Some(json!("2026-01-01")) - ); - let debug = format!("{:?}", request.variables); - assert!(debug.contains("[REDACTED]")); - assert!(!debug.contains("2026-01-01")); - - for variables in [ - json!({ "AsOf": "2026-01-01" }), - json!({ "as_of_date": "2026-02-30" }), - json!({ "as_of_date": 20260101 }), - ] { - assert!(serde_json::from_value::(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": variables, - "claims": ["age-band"] - })) - .is_err()); - } - let too_many = (0..=MAX_REQUEST_VARIABLES_V1) - .map(|index| (format!("date_{index}"), json!("2026-01-01"))) - .collect::>(); - assert!(serde_json::from_value::(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": too_many, - "claims": ["age-band"] - })) - .is_err()); - } - - #[test] - fn evaluate_request_allows_missing_target_for_server_derived_context() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "claims": ["person-is-alive"], - "purpose": "https://purpose.example/self" - })) - .expect("target may be omitted when the server derives subject-access context"); - - assert!(request.target.is_none()); - assert!(request.target_subject().is_none()); - assert!(request.request_context().is_none()); - } - - #[test] - fn evaluate_request_rejects_old_subject_shape() { - let error = serde_json::from_value::(json!({ - "subject": { "id": "NID-1001", "id_type": "national_id" }, - "claims": ["person-is-alive"] - })) - .expect_err("old subject shape is no longer accepted"); - - assert!( - error.to_string().contains("missing field `target`") - || error.to_string().contains("unknown field `subject`"), - "unexpected serde error: {error}" - ); - } - - #[test] - fn evidence_entity_reports_matching_input_only_when_non_empty() { - let mut entity = EvidenceEntity::new("Person"); - assert!(!entity.has_matching_input()); - - entity.id = Some(" ".to_string()); - entity.identifiers.push(EvidenceIdentifier { - scheme: "national_id".to_string(), - value: " ".to_string(), - issuer: None, - country: None, - }); - assert!(!entity.has_matching_input()); - - entity.identifiers[0].value = "NID-1001".to_string(); - assert!(entity.has_matching_input()); - - entity.identifiers[0].value = " ".to_string(); - entity - .attributes - .insert("district".to_string(), json!("north")); - assert!(entity.has_matching_input()); - } - - #[test] - fn batch_evaluate_request_deserializes_items_with_mixed_targets() { - let request: BatchEvaluateRequest = serde_json::from_value(json!({ - "items": [ - { - "target": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-1001" } - ] - } - }, - { - "target": { - "type": "land_parcel", - "identifiers": [ - { "scheme": "parcel_id", "value": "LP-42" } - ] - }, - "purpose": "https://purpose.example/land" - } - ], - "claims": ["eligibility"] - })) - .expect("batch request shape deserializes"); - - assert_eq!(request.items.len(), 2); - assert_eq!( - request.items[1] - .target_subject() - .expect("target maps to source subject") - .id_type - .as_deref(), - Some("parcel_id") - ); - } - - #[test] - fn result_views_serialize_target_ref_without_subject_ref_or_id_type() { - let result = ClaimResultView { - evaluation_id: "eval-1".to_string(), - claim_id: "person-is-alive".to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "person".to_string(), - requester_ref: None, - target_ref: TargetRefView { - entity_type: "Person".to_string(), - handle: "rnref:v1:test".to_string(), - identifier_schemes: Vec::new(), - profile: None, - }, - value: Some(json!(true)), - satisfied: Some(true), - disclosure: "predicate".to_string(), - redacted_fields: Vec::new(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - issued_at: "2026-05-31T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "test".to_string(), - "eval-1".to_string(), - "person-is-alive".to_string(), - "1.0.0".to_string(), - ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - }; - - let value = serde_json::to_value(result).expect("result serializes"); - assert!(value.get("target_ref").is_some()); - assert!(value.get("subject_ref").is_none()); - assert!(value["target_ref"].get("id_type").is_none()); - } - - #[test] - fn claim_provenance_v2_serializes_relay_consultation_shape() { - let mut provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "person_is_alive".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 1, - }, - ); - provenance.generated_by.policy_id = Some("subject-access".to_string()); - provenance.generated_by.policy_version = Some("v1".to_string()); - provenance.generated_by.policy_hash = Some("sha256:def456".to_string()); - - let value = serde_json::to_value(&provenance).expect("provenance serializes"); - - assert_eq!( - value["schema_version"], - json!("registry-notary-claim-provenance/v2") - ); - let generated_by = &value["generated_by"]; - assert_eq!(generated_by["type"], json!("claim_evaluation")); - assert_eq!(generated_by["service_id"], json!("registry-notary")); - assert_eq!(generated_by["evaluation_id"], json!("eval_01HX")); - assert_eq!(generated_by["claim_id"], json!("person_is_alive")); - assert_eq!(generated_by["claim_version"], json!("1")); - assert_eq!(generated_by["policy_id"], json!("subject-access")); - assert_eq!(generated_by["policy_version"], json!("v1")); - assert_eq!(generated_by["policy_hash"], json!("sha256:def456")); - - let used = &value["used"]; - assert_eq!(used["relay_consultation_count"], json!(1)); - assert!(used.get("source_count").is_none()); - assert!(used.get("source_versions").is_none()); - assert_eq!(value["derived_from"], json!([])); - } - - #[test] - fn claim_provenance_v2_round_trips() { - let provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "person_is_alive".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 2, - }, - ); - let value = serde_json::to_value(&provenance).expect("serializes"); - let parsed: ClaimProvenance = - serde_json::from_value(value).expect("provenance round-trips"); - assert_eq!(parsed.schema_version, CLAIM_PROVENANCE_SCHEMA_VERSION); - assert_eq!(parsed.used.relay_consultation_count, 2); - assert!(parsed.generated_by.policy_id.is_none()); - } - - #[test] - fn claim_provenance_omits_computed_by_and_requester_side_fields() { - let provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "claim".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 0, - }, - ); - let value = serde_json::to_value(&provenance).expect("serializes"); - let text = value.to_string(); - assert!( - !text.contains("computed_by"), - "computed_by must be gone from the provenance wire shape" - ); - for forbidden in ["client", "actor", "subject"] { - assert!( - value.get(forbidden).is_none() - && value["generated_by"].get(forbidden).is_none() - && value["used"].get(forbidden).is_none(), - "requester-side field {forbidden} must not appear in claim provenance" - ); - } - } - - #[test] - fn on_behalf_of_envelope_serializes_and_round_trips() { - let envelope = EvidenceOnBehalfOf { - actor: EvidenceActor { - actor_type: "service_account".to_string(), - id_hash: "hmac-sha256:abc123".to_string(), - assurance: Some("urn:example:loa:substantial".to_string()), - }, - delegation_ref: Some("urn:delegation:42".to_string()), - }; - let value = serde_json::to_value(&envelope).expect("envelope serializes"); - assert_eq!(value["actor"]["type"], json!("service_account")); - assert_eq!(value["actor"]["id_hash"], json!("hmac-sha256:abc123")); - assert_eq!( - value["actor"]["assurance"], - json!("urn:example:loa:substantial") - ); - assert_eq!(value["delegation_ref"], json!("urn:delegation:42")); - - let parsed: EvidenceOnBehalfOf = - serde_json::from_value(value).expect("envelope round-trips"); - assert_eq!(parsed.actor.actor_type, "service_account"); - assert_eq!(parsed.delegation_ref.as_deref(), Some("urn:delegation:42")); - } - - #[test] - fn on_behalf_of_minimal_envelope_omits_optional_fields() { - let envelope = EvidenceOnBehalfOf { - actor: EvidenceActor { - actor_type: "operator".to_string(), - id_hash: "hmac-sha256:def456".to_string(), - assurance: None, - }, - delegation_ref: None, - }; - let value = serde_json::to_value(&envelope).expect("envelope serializes"); - assert!(value.get("delegation_ref").is_none()); - assert!(value["actor"].get("assurance").is_none()); - } - - #[test] - fn on_behalf_of_rejects_free_form_payloads() { - let legacy = json!({ "delegator": "did:example:123", "scope": "read" }); - let err = serde_json::from_value::(legacy) - .expect_err("free-form on_behalf_of must be rejected"); - let message = err.to_string(); - assert!( - message.contains("unknown field") || message.contains("missing field"), - "rejection should be a schema mismatch, got: {message}" - ); - } - - #[test] - fn on_behalf_of_rejects_unknown_actor_field() { - let payload = json!({ - "actor": { - "type": "operator", - "id_hash": "hmac-sha256:def456", - "raw_id": "leaked" - } - }); - let err = serde_json::from_value::(payload) - .expect_err("unknown actor field must be rejected"); - assert!(err.to_string().contains("unknown field")); - } - - #[test] - fn evaluate_request_accepts_envelope_and_rejects_loose_json() { - let accepted = serde_json::from_value::(json!({ - "target": { "type": "Person", "identifiers": [{ "scheme": "id", "value": "x" }] }, - "claims": ["person_is_alive"], - "on_behalf_of": { - "actor": { "type": "operator", "id_hash": "hmac-sha256:abc" } - } - })); - assert!(accepted.is_ok(), "structured envelope must be accepted"); - - let rejected = serde_json::from_value::(json!({ - "target": { "type": "Person", "identifiers": [{ "scheme": "id", "value": "x" }] }, - "claims": ["person_is_alive"], - "on_behalf_of": { "anything": "goes" } - })); - assert!( - rejected.is_err(), - "free-form on_behalf_of must be rejected at request level" - ); - } - - #[test] - fn bounded_rejects_values_over_limit() { - let err = Bounded::<4>::new("12345").expect_err("value exceeds limit"); - assert_eq!(err.max, 4); - assert_eq!(err.actual, 5); - } - - #[test] - fn verified_claim_lookup_exposes_only_bounded_allow_listed_claims() { - let claims = BoundedVerifiedClaims { - issuer: bounded("https://id.example.gov"), - audiences: vec![bounded("registry-notary-citizen")], - client_id: Some(bounded("citizen-portal")), - token_type: Some(bounded("JWT")), - credential_configuration_id: None, - issuance_transaction_id: None, - issuance_transaction_commitment: None, - scopes: vec![bounded("subject_access")], - subject: Some(bounded("login-subject")), - subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), - subject_binding_value: Some(bounded("NAT-123")), - acr: Some(bounded("urn:example:loa:substantial")), - auth_time: Some(1_800_000_000), - exp: Some(1_800_000_900), - iat: Some(1_800_000_000), - nbf: None, - }; - - assert!(claims.has_scope("subject_access")); - assert_eq!(claims.claim_value("sub"), Some("login-subject")); - assert_eq!(claims.claim_value("email"), None); - assert_eq!( - claims.subject_binding_value("https://id.example.gov/claims/national_id"), - Some("NAT-123") - ); - } - - #[test] - fn verified_claim_lookup_treats_blank_subject_binding_value_as_missing() { - for blank in ["", " "] { - let claims = BoundedVerifiedClaims { - issuer: bounded("https://id.example.gov"), - audiences: vec![bounded("registry-notary-citizen")], - client_id: Some(bounded("citizen-portal")), - token_type: Some(bounded("JWT")), - credential_configuration_id: None, - issuance_transaction_id: None, - issuance_transaction_commitment: None, - scopes: vec![bounded("subject_access")], - subject: Some(bounded("login-subject")), - subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), - subject_binding_value: Some(bounded(blank)), - acr: None, - auth_time: None, - exp: None, - iat: None, - nbf: None, - }; - - assert_eq!( - claims.subject_binding_value("https://id.example.gov/claims/national_id"), - None - ); - } - } - - #[test] - fn evaluation_capability_separates_machine_scopes_from_subject_access_claims() { - let machine = EvaluationCapability::Machine { - scopes: BTreeSet::from(["civil_registry:evidence_verification".to_string()]), - }; - assert_eq!(machine.access_mode(), AccessMode::MachineClient); - assert!(machine.allows_scope("civil_registry:evidence_verification")); - assert!(!machine.allows_subject_access_claim("person-is-alive")); - - let citizen = EvaluationCapability::SubjectBound { - claim_id: Some(bounded("person-is-alive")), - allowed_claim_ids: BTreeSet::new(), - subject_binding_hash: Hashed::from_hash("sha256:test"), - }; - assert_eq!(citizen.access_mode(), AccessMode::SubjectBound); - assert!(!citizen.allows_scope("civil_registry:evidence_verification")); - assert!(citizen.allows_subject_access_claim("person-is-alive")); - } - - #[test] - fn audit_subject_access_fields_round_trip_without_raw_values() { - let event = EvidenceAuditEvent { - event_id: "01HX".to_string(), - occurred_at: "2026-05-25T00:00:00Z".to_string(), - principal_id_hash: Some(Hashed::from_hash("hmac-sha256:principal")), - scopes_used: vec!["subject_access".to_string()], - decision: "denied".to_string(), - method: "POST".to_string(), - path: "/v1/evaluations".to_string(), - status: 403, - verification_id: None, - claim_hash: Some("sha256:claims".to_string()), - purposes: None, - row_count: None, - relay_consultation_count: None, - relay_consultation_ids: vec!["01JRELAYCORRELATIONSENSITIVE".to_string()], - forwarded: None, - error_code: Some("subject_access.denied".to_string()), - access_mode: Some(AccessMode::SubjectBound), - federation_peer_id_hash: None, - federation_issuer: None, - federation_profile: None, - federation_purpose: None, - federation_request_jti_hash: None, - federation_subject_ref_hash: None, - denial_code: Some(SubjectAccessDenialCode::SubjectMismatch), - token_claim_name: Some(bounded("national_id")), - correlation_id_hash: Some(Hashed::from_hash("hmac-sha256:req-123")), - credential_profile: None, - protocol: Some(bounded("openid4vci")), - credential_configuration_id: Some(bounded("person_is_alive_sd_jwt")), - holder_binding_mode: None, - rate_limit_bucket: None, - policy_version: Some(bounded("citizen-v1")), - policy_hash: Some(Hashed::from_hash("sha256:policy")), - target_type: Some("person".to_string()), - target_ref_hash: Some(Hashed::from_hash("hmac-sha256:target")), - requester_type: Some("person".to_string()), - requester_ref_hash: Some(Hashed::from_hash("hmac-sha256:requester")), - redacted_fields: None, - batch_items: Some(vec![EvidenceBatchItemAuditEvent { - input_index: 0, - outcome: "failed".to_string(), - error_code: Some("evidence.not_available".to_string()), - relay_consultation_count: 1, - forwarded: true, - relay_consultation_ids: vec!["01JRELAYBATCHSENSITIVE".to_string()], - target_type: Some("person".to_string()), - target_ref_hash: Some(Hashed::from_hash("hmac-sha256:batch-target")), - requester_type: Some("person".to_string()), - requester_ref_hash: Some(Hashed::from_hash("hmac-sha256:batch-requester")), - }]), - config: None, - }; - - let value = serde_json::to_value(&event).expect("audit event serializes"); - assert_eq!( - value["relay_consultation_ids"], - json!(["01JRELAYCORRELATIONSENSITIVE"]) - ); - let debug = format!("{event:?}"); - assert!(!debug.contains("01JRELAYCORRELATIONSENSITIVE")); - assert!(!debug.contains("01JRELAYBATCHSENSITIVE")); - assert!(debug.contains("relay_consultation_ids: \"[REDACTED]\"")); - assert_eq!(value["access_mode"], json!("subject_bound")); - assert_eq!( - value["denial_code"], - json!("subject_access.subject_mismatch") - ); - assert_eq!(value["token_claim_name"], json!("national_id")); - assert_eq!(value["correlation_id_hash"], json!("hmac-sha256:req-123")); - assert!(value.get("correlation_id").is_none()); - assert_eq!(value["protocol"], json!("openid4vci")); - assert_eq!( - value["credential_configuration_id"], - json!("person_is_alive_sd_jwt") - ); - assert_eq!(value["principal_id_hash"], json!("hmac-sha256:principal")); - assert_eq!(value["scopes_used"], json!(["subject_access"])); - assert!(value.get("principal_id").is_none()); - assert!(value.get("subject_binding_value").is_none()); - assert_eq!(value["target_type"], json!("person")); - assert_eq!(value["target_ref_hash"], json!("hmac-sha256:target")); - assert_eq!(value["requester_type"], json!("person")); - assert_eq!(value["requester_ref_hash"], json!("hmac-sha256:requester")); - assert_eq!( - value["batch_items"][0]["target_ref_hash"], - json!("hmac-sha256:batch-target") - ); - assert!(value.get("target_id").is_none()); - assert!(value.get("target_attributes").is_none()); - assert!(value.get("requester_id").is_none()); - - let decoded: EvidenceAuditEvent = - serde_json::from_value(value).expect("audit event deserializes"); - assert_eq!(decoded.event_id, event.event_id); - assert_eq!(decoded.scopes_used, vec!["subject_access"]); - assert_eq!(decoded.access_mode, Some(AccessMode::SubjectBound)); - assert_eq!( - decoded.denial_code, - Some(SubjectAccessDenialCode::SubjectMismatch) - ); - assert_eq!( - decoded.token_claim_name.as_ref().map(Bounded::as_str), - Some("national_id") - ); - assert_eq!( - decoded.correlation_id_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:req-123") - ); - assert_eq!( - decoded.policy_hash.as_ref().map(Hashed::as_str), - Some("sha256:policy") - ); - assert_eq!(decoded.target_type.as_deref(), Some("person")); - assert_eq!( - decoded.target_ref_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:target") - ); - assert_eq!(decoded.requester_type.as_deref(), Some("person")); - assert_eq!( - decoded.requester_ref_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:requester") - ); - assert_eq!(decoded.batch_items.as_ref().map(Vec::len), Some(1)); - } - - #[test] - fn audit_event_missing_optional_fields_defaults_to_none() { - let decoded: EvidenceAuditEvent = serde_json::from_value(json!({ - "event_id": "01HX", - "occurred_at": "2026-05-25T00:00:00Z", - "decision": "allowed", - "method": "GET", - "path": "/v1/claims", - "status": 200 - })) - .expect("legacy audit event deserializes"); - - assert!(decoded.verification_id.is_none()); - assert!(decoded.claim_hash.is_none()); - assert!(decoded.purposes.is_none()); - assert!(decoded.scopes_used.is_empty()); - assert!(decoded.row_count.is_none()); - assert!(decoded.error_code.is_none()); - assert!(decoded.access_mode.is_none()); - assert!(decoded.target_type.is_none()); - assert!(decoded.target_ref_hash.is_none()); - assert!(decoded.requester_type.is_none()); - assert!(decoded.requester_ref_hash.is_none()); - assert!(decoded.batch_items.is_none()); - } - - #[test] - fn config_audit_governance_evidence_is_optional_for_legacy_records() { - let decoded: ConfigAuditEvent = serde_json::from_value(json!({ - "action": "boot", - "source": "signed_bundle_file", - "signer_kids": [], - "product_validation_result": "accepted", - "apply_result": "applied", - "posture_result": "accepted", - "applied": true, - "restart_required": false, - "change_classes": [], - "break_glass": false - })) - .expect("legacy config audit deserializes"); - - assert!(decoded.acceptance_identity.is_none()); - assert!(decoded.bundle_manifest_hash.is_none()); - assert!(decoded.anchor_digest.is_none()); - assert!(decoded.anchor_version.is_none()); - } - - #[test] - fn stored_evaluation_without_subject_access_defaults_to_machine_client() { - let raw = json!({ - "client_id": "client", - "purpose": "verification", - "claim_ids": ["person-is-alive"], - "disclosure": "predicate", - "format": FORMAT_CLAIM_RESULT_JSON, - "results": [], - "created_at": "2026-05-25T00:00:00Z", - "expires_at": "2026-05-25T00:15:00Z", - "request_hash": "sha256:request" - }); - let stored: StoredEvaluation = - serde_json::from_value(raw).expect("legacy stored evaluation deserializes"); - assert_eq!(stored.access_mode(), AccessMode::MachineClient); - assert_eq!( - stored.selected_claim_refs(), - vec![ClaimRef::from("person-is-alive")] - ); - assert!(stored.issuance_provenance.is_none()); - assert!(stored.subject_access.is_none()); - } -} diff --git a/crates/registry-notary-core/src/sd_jwt.rs b/crates/registry-notary-core/src/sd_jwt.rs deleted file mode 100644 index de794b316..000000000 --- a/crates/registry-notary-core/src/sd_jwt.rs +++ /dev/null @@ -1,1114 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Minimal SD-JWT VC issuer for Registry Notary claim views. - -use registry_platform_crypto::{parse_did_jwk, PrivateJwk, PublicJwk, SigningProvider}; -use registry_platform_sdjwt::{ - new_credential_id as platform_new_credential_id, Disclosure, HolderConfirmation, - SdJwtIssuanceInput, SdJwtIssuer, -}; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::fmt; -use std::sync::Arc; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -use crate::config::CredentialProfileConfig; -use crate::error::EvidenceError; -use crate::model::{ClaimResultView, SD_JWT_VC_SIGNING_ALG}; - -#[derive(Clone)] -pub struct SignedSdJwtVc { - pub credential_id: String, - pub issuer: String, - pub expires_at: String, - pub compact: String, - pub issuer_signed_jwt: String, - pub disclosures: Vec, -} - -impl fmt::Debug for SignedSdJwtVc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SignedSdJwtVc") - .field("credential_id", &self.credential_id) - .field("issuer", &self.issuer) - .field("expires_at", &self.expires_at) - .field("compact", &"[redacted]") - .field("issuer_signed_jwt", &"[redacted]") - .field("disclosures", &"[redacted]") - .finish() - } -} - -#[derive(Clone)] -pub struct EvidenceIssuer { - verification_method_id: String, - issuer: SdJwtIssuer, - public_jwk: Value, -} - -impl fmt::Debug for EvidenceIssuer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("EvidenceIssuer") - .field("verification_method_id", &self.verification_method_id) - .field("public_jwk", &"[omitted]") - .finish_non_exhaustive() - } -} - -impl EvidenceIssuer { - pub fn from_jwk_str(raw: &str, verification_method_id: String) -> Result { - let mut jwk = - PrivateJwk::parse(raw).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - jwk.kid = Some(verification_method_id.clone()); - jwk.alg - .get_or_insert_with(|| SD_JWT_VC_SIGNING_ALG.to_string()); - let public = jwk.public(); - let public_jwk = - serde_json::to_value(public).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let issuer = - SdJwtIssuer::from_jwk(jwk).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - Ok(Self { - verification_method_id, - issuer, - public_jwk, - }) - } - - pub fn from_signing_provider( - provider: Arc, - ) -> Result { - let verification_method_id = provider.key_id().to_string(); - if verification_method_id.trim().is_empty() { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let public = provider.public_jwk(); - if public.kid.as_deref() != Some(verification_method_id.as_str()) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let public_jwk = - serde_json::to_value(public).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let issuer = SdJwtIssuer::from_signing_provider(provider); - Ok(Self { - verification_method_id, - issuer, - public_jwk, - }) - } - - #[must_use] - pub fn public_jwk(&self) -> Value { - self.public_jwk.clone() - } - - pub async fn sign_compact_jwt( - &self, - typ: &str, - payload: Value, - ) -> Result { - self.issuer - .sign_compact_jwt(typ, payload) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed) - } -} - -#[derive(Clone, Debug, Default)] -pub struct IssueOptions { - pub credential_id: Option, - pub status: Option, - pub projection: Option>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SdJwtProjectionClaim { - pub claim_id: String, - pub output_name: String, -} - -pub async fn issue( - profile: &CredentialProfileConfig, - issuer: &EvidenceIssuer, - results: &[ClaimResultView], - subject_ref: &str, - holder_id: Option<&str>, - iat: OffsetDateTime, - options: IssueOptions, -) -> Result { - let holder_confirmation = holder_id.map(holder_confirmation).transpose()?; - if profile.holder_binding.mode != "none" && holder_confirmation.is_none() { - return Err(EvidenceError::HolderProofRequired); - } - if subject_ref.trim().is_empty() { - return Err(EvidenceError::InvalidRequest); - } - let expires_at = iat - .checked_add(time::Duration::seconds(profile.validity_seconds)) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - let public_claims = BTreeMap::from([ - ("issuanceDate".to_string(), json!(format_time(iat))), - ("expirationDate".to_string(), json!(format_time(expires_at))), - ]); - let disclosures = disclosures_for_results(results, options.projection.as_deref())?; - let signed = issuer - .issuer - .issue(SdJwtIssuanceInput { - iss: profile.issuer.clone(), - sub_ref: subject_ref.to_string(), - credential_id: options.credential_id, - iat: iat.unix_timestamp(), - exp: expires_at.unix_timestamp(), - vct: profile.vct.clone(), - status: options.status, - public_claims, - cnf: holder_confirmation, - disclosures, - }) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let (issuer_signed_jwt, disclosures) = split_sd_jwt_compact(&signed.jwt)?; - Ok(SignedSdJwtVc { - credential_id: signed.credential_id, - issuer: profile.issuer.clone(), - expires_at: format_time(expires_at), - compact: signed.jwt, - issuer_signed_jwt, - disclosures, - }) -} - -fn disclosures_for_results( - results: &[ClaimResultView], - projection: Option<&[SdJwtProjectionClaim]>, -) -> Result, EvidenceError> { - if let Some(projection) = projection { - return projection - .iter() - .map(|entry| { - let result = results - .iter() - .find(|result| result.claim_id == entry.claim_id) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - if result.satisfied == Some(false) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let Some(value) = result - .value - .as_ref() - .filter(|value| !value.is_null()) - .cloned() - else { - return Err(EvidenceError::CredentialIssuanceFailed); - }; - Ok(Disclosure { - name: entry.output_name.clone(), - value, - }) - }) - .collect(); - } - - Ok(results - .iter() - .map(|result| Disclosure { - name: result.claim_id.clone(), - value: json!({ - "claim_id": result.claim_id, - "version": result.claim_version, - "value": result.value, - "satisfied": result.satisfied, - "subject_type": result.subject_type, - "issued_at": result.issued_at, - }), - }) - .collect()) -} - -#[must_use] -pub fn new_credential_id() -> String { - platform_new_credential_id() -} - -fn split_sd_jwt_compact(compact: &str) -> Result<(String, Vec), EvidenceError> { - let mut parts = compact.split('~'); - let issuer_signed_jwt = parts - .next() - .filter(|jwt| !jwt.is_empty()) - .ok_or(EvidenceError::CredentialIssuanceFailed)? - .to_string(); - let disclosures = parts - .filter(|part| !part.is_empty()) - .map(ToString::to_string) - .collect(); - Ok((issuer_signed_jwt, disclosures)) -} - -fn holder_confirmation(holder_id: &str) -> Result { - Ok(HolderConfirmation { - jwk: holder_jwk(holder_id)?, - kid: Some(holder_id.to_string()), - }) -} - -pub fn holder_jwk(holder_id: &str) -> Result { - parse_did_jwk(holder_id).map_err(|_| EvidenceError::HolderProofRequired) -} - -fn format_time(value: OffsetDateTime) -> String { - value - .format(&Rfc3339) - .expect("OffsetDateTime within supported RFC3339 range") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::HolderBindingConfig; - use crate::model::{ClaimProvenance, TargetRefView, FORMAT_SD_JWT_VC, SD_JWT_VC_JWT_TYP}; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine; - use registry_platform_crypto::did_jwk_from_public_jwk; - use sha2::{Digest, Sha256}; - - const RAW_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; - - fn issue( - profile: &CredentialProfileConfig, - issuer: &EvidenceIssuer, - results: &[ClaimResultView], - subject_ref: &str, - holder_id: Option<&str>, - iat: OffsetDateTime, - options: IssueOptions, - ) -> Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime builds") - .block_on(super::issue( - profile, - issuer, - results, - subject_ref, - holder_id, - iat, - options, - )) - } - - #[test] - fn signing_algorithm_header_value_is_stable() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let compact = signed.compact.split('~').next().expect("compact jwt"); - let header = compact.split('.').next().expect("compact jwt has header"); - let header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON"); - assert_eq!(header["alg"], SD_JWT_VC_SIGNING_ALG); - assert_eq!(header["typ"], SD_JWT_VC_JWT_TYP); - } - - #[test] - fn evidence_issuer_can_be_backed_by_signing_provider() { - let mut jwk = PrivateJwk::parse(RAW_JWK).expect("test JWK parses"); - jwk.kid = Some("did:web:issuer.test#provider-key".to_string()); - jwk.alg = Some(SD_JWT_VC_SIGNING_ALG.to_string()); - let signer = - registry_platform_crypto::LocalJwkSigner::new(jwk).expect("local signer builds"); - let issuer = EvidenceIssuer::from_signing_provider(std::sync::Arc::new(signer)) - .expect("provider-backed issuer builds"); - - assert_eq!( - issuer.public_jwk()["kid"], - "did:web:issuer.test#provider-key" - ); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let compact = signed.compact.split('~').next().expect("compact jwt"); - let header = compact.split('.').next().expect("compact jwt has header"); - let header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON"); - assert_eq!(header["kid"], "did:web:issuer.test#provider-key"); - } - - #[test] - fn issued_credential_payload_includes_jti() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["jti"], signed.credential_id); - assert_eq!(payload["id"], signed.credential_id); - } - - #[test] - fn issued_credential_payload_can_include_status_claim() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let credential_id = new_credential_id(); - let status = json!({ - "status_list": { - "idx": 0, - "uri": format!("https://issuer.example/v1/credentials/{credential_id}/status") - } - }); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - credential_id: Some(credential_id.clone()), - status: Some(status.clone()), - projection: None, - }, - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(signed.credential_id, credential_id); - assert_eq!(payload["id"], credential_id); - assert_eq!(payload["jti"], credential_id); - assert_eq!(payload["status"], status); - } - - #[test] - fn issuing_with_overflowing_validity_returns_error() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut profile = test_profile(); - profile.validity_seconds = i64::MAX; - - let error = issue( - &profile, - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect_err("overflowing validity is rejected"); - - assert!(matches!(error, EvidenceError::CredentialIssuanceFailed)); - } - - #[test] - fn golden_sd_jwt_vc_fixture_matches_conformance_profile() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let iat = OffsetDateTime::from_unix_timestamp(1_700_000_000) - .expect("test fixture timestamp is valid"); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - let signed = issue( - &holder_required_profile(), - &issuer, - &[result], - &holder, - Some(&holder), - iat, - IssueOptions::default(), - ) - .expect("credential issues"); - - let header = header(&signed); - let payload = payload(&signed); - - assert_eq!(header["alg"], SD_JWT_VC_SIGNING_ALG); - assert_eq!(header["typ"], SD_JWT_VC_JWT_TYP); - assert_eq!(header["kid"], "did:web:issuer.test#key-1"); - assert_eq!(payload["iss"], "did:web:issuer.test"); - assert_eq!(payload["sub"], holder); - assert_eq!(payload["iat"], iat.unix_timestamp()); - assert_eq!(payload["exp"], iat.unix_timestamp() + 60); - assert_eq!(payload["vct"], "https://vct.example/test"); - assert_eq!(payload["jti"], signed.credential_id); - assert_eq!(payload["id"], signed.credential_id); - assert_eq!(payload["cnf"]["kid"], holder); - assert_eq!(payload["cnf"]["jwk"]["kty"], "OKP"); - assert_eq!(payload["cnf"]["jwk"]["crv"], "Ed25519"); - assert!(payload["cnf"]["jwk"].get("d").is_none()); - assert_eq!(payload_sd(&signed), disclosure_digests(&signed)); - assert_eq!(signed.disclosures.len(), 1); - assert!( - !payload.to_string().contains("registry-subject-ref"), - "holder-bound payload must not expose the raw registry subject_ref", - ); - } - - #[test] - fn issued_credential_exposes_verifiable_jwt_separately_from_disclosures() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - - assert_eq!( - signed.issuer_signed_jwt, - signed.compact.split('~').next().expect("sd-jwt has jwt") - ); - assert!(!signed.issuer_signed_jwt.contains('~')); - let segments = signed.issuer_signed_jwt.split('.').collect::>(); - assert_eq!(segments.len(), 3); - for segment in segments { - URL_SAFE_NO_PAD - .decode(segment) - .expect("JWT segment is base64url without SD-JWT disclosure tail"); - } - assert_eq!(signed.disclosures.len(), 1); - assert!(signed.compact.ends_with('~')); - } - - #[test] - fn issued_credential_disclosures_do_not_reintroduce_redacted_object_fields() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let source_value = json!({ - "name": "Ada", - "household_id": "hh-1", - "ssn": "123-45-6789" - }); - assert!( - source_value.to_string().contains("123-45-6789"), - "the source fixture must contain the PII this regression protects" - ); - let mut redacted_value = source_value - .as_object() - .expect("source fixture is an object") - .clone(); - assert!( - redacted_value.remove("ssn").is_some(), - "the test redaction fixture must remove an existing field" - ); - let mut result = claim_result("household-summary"); - result.value = Some(Value::Object(redacted_value)); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let disclosures = decoded_disclosures(&signed); - let household_disclosure = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("household-summary"))) - .expect("household-summary disclosure exists"); - let disclosure_json = serde_json::to_string(&disclosures).expect("disclosures serialize"); - - assert!(disclosure_json.contains("household-summary")); - assert!(disclosure_json.contains("Ada")); - assert_eq!(household_disclosure[2]["value"]["name"], json!("Ada")); - assert_eq!( - household_disclosure[2]["value"]["household_id"], - json!("hh-1") - ); - assert!(household_disclosure[2]["value"].get("ssn").is_none()); - assert!(!disclosure_json.contains("ssn"), "{disclosure_json}"); - assert!( - !disclosure_json.contains("123-45-6789"), - "{disclosure_json}" - ); - } - - #[test] - fn legacy_credential_disclosure_keeps_notary_result_wrapper() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut result = claim_result("date-of-birth"); - result.value = Some(json!("2016-01-15")); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let disclosures = decoded_disclosures(&signed); - let disclosure = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("date-of-birth"))) - .expect("date-of-birth disclosure exists"); - - assert_eq!(disclosure[2]["claim_id"], json!("date-of-birth")); - assert_eq!(disclosure[2]["version"], json!("1.0.0")); - assert_eq!(disclosure[2]["value"], json!("2016-01-15")); - assert_eq!(disclosure[2]["satisfied"], json!(true)); - } - - #[test] - fn projected_credential_disclosures_use_output_names_and_domain_values() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut date = claim_result("date-of-birth"); - date.value = Some(json!("2016-01-15")); - date.satisfied = None; - let mut place = claim_result("place-of-birth"); - place.value = Some(json!("North City")); - place.satisfied = None; - - let signed = issue( - &test_profile(), - &issuer, - &[date, place], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(vec![ - SdJwtProjectionClaim { - claim_id: "date-of-birth".to_string(), - output_name: "birth_date".to_string(), - }, - SdJwtProjectionClaim { - claim_id: "place-of-birth".to_string(), - output_name: "birth_place_name".to_string(), - }, - ]), - ..IssueOptions::default() - }, - ) - .expect("projected credential issues"); - let disclosures = decoded_disclosures(&signed); - let birth_date = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("birth_date"))) - .expect("birth_date disclosure exists"); - let birth_place = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("birth_place_name"))) - .expect("birth_place_name disclosure exists"); - - assert_eq!(birth_date[2], json!("2016-01-15")); - assert_eq!(birth_place[2], json!("North City")); - assert!(birth_date[2].get("claim_id").is_none()); - assert!(birth_place[2].get("version").is_none()); - } - - #[test] - fn projected_credential_rejects_missing_failed_or_null_results() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let projection = vec![SdJwtProjectionClaim { - claim_id: "date-of-birth".to_string(), - output_name: "birth_date".to_string(), - }]; - - let missing = issue( - &test_profile(), - &issuer, - &[claim_result("other")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection.clone()), - ..IssueOptions::default() - }, - ) - .expect_err("missing projected result rejects"); - assert!(matches!(missing, EvidenceError::CredentialIssuanceFailed)); - - let mut failed = claim_result("date-of-birth"); - failed.satisfied = Some(false); - let failed_error = issue( - &test_profile(), - &issuer, - &[failed], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection.clone()), - ..IssueOptions::default() - }, - ) - .expect_err("failed projected result rejects"); - assert!(matches!( - failed_error, - EvidenceError::CredentialIssuanceFailed - )); - - let mut null = claim_result("date-of-birth"); - null.value = Some(Value::Null); - let null_error = issue( - &test_profile(), - &issuer, - &[null], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection), - ..IssueOptions::default() - }, - ) - .expect_err("null projected result rejects"); - assert!(matches!( - null_error, - EvidenceError::CredentialIssuanceFailed - )); - } - - #[test] - fn issued_credential_uses_platform_holder_confirmation() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - &holder, - Some(&holder), - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["cnf"]["kid"], holder); - assert_eq!(payload["cnf"]["jwk"]["kty"], "OKP"); - assert_eq!(payload["cnf"]["jwk"]["crv"], "Ed25519"); - assert!(payload["cnf"]["jwk"].get("d").is_none()); - } - - #[test] - fn holder_bound_credential_uses_holder_did_as_subject() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - &holder, - Some(&holder), - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["sub"], holder); - assert!( - !payload.to_string().contains("registry-subject-ref"), - "holder-bound JWT payload must not expose the raw registry subject_ref", - ); - } - - #[test] - fn credential_without_holder_uses_registry_subject_ref() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "registry-subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["sub"], "registry-subject-ref"); - } - - #[test] - fn default_holder_binding_rejects_credential_without_holder() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let profile = default_bound_profile(); - - let err = issue( - &profile, - &issuer, - &[claim_result("first")], - "registry-subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect_err("default holder-bound profile requires holder material"); - - assert!(matches!(err, EvidenceError::HolderProofRequired)); - } - - #[test] - fn holder_required_profile_rejects_missing_or_unsupported_holder_binding() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let profile = holder_required_profile(); - let iat = OffsetDateTime::from_unix_timestamp(1_700_000_000) - .expect("test fixture timestamp is valid"); - - let missing_holder = issue( - &profile, - &issuer, - &[claim_result("first")], - "subject-ref", - None, - iat, - IssueOptions::default(), - ) - .expect_err("holder-bound profile requires holder proof material"); - assert!(matches!(missing_holder, EvidenceError::HolderProofRequired)); - - let unsupported_holder = issue( - &profile, - &issuer, - &[claim_result("first")], - "did:key:z6Mkunsupported", - Some("did:key:z6Mkunsupported"), - iat, - IssueOptions::default(), - ) - .expect_err("only did:jwk holder identifiers are supported"); - assert!(matches!( - unsupported_holder, - EvidenceError::HolderProofRequired - )); - } - - #[test] - fn issued_credential_iat_is_threaded_through_issue_not_recomputed() { - // Two re-issuances of the same evaluation must produce identical JWT - // `iat` because the caller threads `result.issued_at` through. The - // signed JWT payload `iat` is the load-bearing assertion: prior to - // the fix it was `OffsetDateTime::now_utc()` per call and drifted. - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let results = vec![claim_result("first"), claim_result("second")]; - // Pin iat to a fixed instant in the past so wall-clock drift between - // the two issue() calls cannot accidentally produce equal values. - let pinned_iat = - OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid unix timestamp"); - - let signed_1 = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - pinned_iat, - IssueOptions::default(), - ) - .expect("first issue"); - // Force a measurable wall-clock gap between calls. - std::thread::sleep(std::time::Duration::from_millis(20)); - let signed_2 = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - pinned_iat, - IssueOptions::default(), - ) - .expect("second issue"); - - let iat_1 = payload(&signed_1)["iat"] - .as_i64() - .expect("iat decodes as i64"); - let iat_2 = payload(&signed_2)["iat"] - .as_i64() - .expect("iat decodes as i64"); - assert_eq!( - iat_1, iat_2, - "JWT iat must be pinned to the threaded value, not OffsetDateTime::now_utc() per call", - ); - assert_eq!( - iat_1, - pinned_iat.unix_timestamp(), - "JWT iat must equal the threaded OffsetDateTime", - ); - // exp is derived from iat + validity, so it must also match. - let exp_1 = payload(&signed_1)["exp"].as_i64().expect("exp decodes"); - let exp_2 = payload(&signed_2)["exp"].as_i64().expect("exp decodes"); - assert_eq!(exp_1, exp_2, "exp must be derived from the threaded iat"); - assert_eq!( - payload(&signed_1)["issuanceDate"], - json!("2023-11-14T22:13:20Z") - ); - assert_eq!( - payload(&signed_1)["expirationDate"], - json!("2023-11-14T22:14:20Z") - ); - } - - #[test] - fn evidence_issuer_debug_omits_key_material() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let debug = format!("{issuer:?}"); - - assert!(debug.contains("EvidenceIssuer")); - assert!(debug.contains("did:web:issuer.test#key-1")); - assert!(!debug.contains("2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw")); - assert!(!debug.contains("1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc")); - assert!(!debug.contains("encoding_key")); - } - - #[test] - fn signed_sd_jwt_vc_debug_redacts_compact_material() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("person-is-alive")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let debug = format!("{signed:?}"); - - assert!(debug.contains("SignedSdJwtVc")); - assert!(debug.contains(&signed.credential_id)); - assert!(debug.contains(&signed.issuer)); - assert!(debug.contains(&signed.expires_at)); - assert!(!debug.contains(&signed.compact)); - assert!(!debug.contains(&signed.issuer_signed_jwt)); - for disclosure in &signed.disclosures { - assert!(!debug.contains(disclosure)); - } - } - - #[test] - fn issued_sd_digests_are_sorted_by_digest() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let results = vec![ - claim_result("third"), - claim_result("first"), - claim_result("second"), - claim_result("fourth"), - ]; - let signed = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - - let sd = payload_sd(&signed); - let mut sorted_disclosure_digests = disclosure_digests(&signed); - sorted_disclosure_digests.sort_unstable(); - - assert_eq!(sd, sorted_disclosure_digests); - } - - fn test_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - format: FORMAT_SD_JWT_VC.to_string(), - issuer: "did:web:issuer.test".to_string(), - signing_key: "issuer-key".to_string(), - vct: "https://vct.example/test".to_string(), - validity_seconds: 60, - holder_binding: HolderBindingConfig { - mode: "none".to_string(), - proof_of_possession: None, - allowed_did_methods: Vec::new(), - }, - allowed_claims: Vec::new(), - disclosure: Default::default(), - } - } - - fn default_bound_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - holder_binding: Default::default(), - ..test_profile() - } - } - - fn holder_required_profile() -> CredentialProfileConfig { - let mut profile = test_profile(); - profile.holder_binding.mode = "did".to_string(); - profile.holder_binding.proof_of_possession = Some("required".to_string()); - profile.holder_binding.allowed_did_methods = vec!["did:jwk".to_string()]; - profile - } - - fn claim_result(claim_id: &str) -> ClaimResultView { - ClaimResultView { - evaluation_id: "eval-1".to_string(), - claim_id: claim_id.to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "person".to_string(), - requester_ref: None, - target_ref: target_ref_view("subject-ref"), - value: Some(json!({ "claim": claim_id })), - satisfied: Some(true), - disclosure: "redacted".to_string(), - redacted_fields: Vec::new(), - format: "json".to_string(), - issued_at: "2026-05-23T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "test".to_string(), - "eval-test".to_string(), - "claim".to_string(), - "1".to_string(), - crate::model::ProvenanceUsed { - relay_consultation_count: 0, - }, - ), - } - } - - fn holder_did_jwk() -> String { - let holder = PrivateJwk::parse(RAW_JWK).expect("holder JWK parses"); - did_jwk_from_public_jwk(&holder.public()).expect("did:jwk encodes") - } - - fn payload_sd(signed: &SignedSdJwtVc) -> Vec { - let payload = payload(signed); - payload["_sd"] - .as_array() - .expect("_sd is an array") - .iter() - .map(|value| value.as_str().expect("_sd digest is a string").to_string()) - .collect() - } - - fn payload(signed: &SignedSdJwtVc) -> Value { - let compact_jwt = signed - .compact - .split('~') - .next() - .expect("sd-jwt has compact jwt"); - let payload = compact_jwt - .split('.') - .nth(1) - .expect("compact jwt has payload"); - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(payload) - .expect("payload decodes as base64url"), - ) - .expect("payload decodes as JSON") - } - - fn header(signed: &SignedSdJwtVc) -> Value { - let compact_jwt = signed - .compact - .split('~') - .next() - .expect("sd-jwt has compact jwt"); - let header = compact_jwt - .split('.') - .next() - .expect("compact jwt has header"); - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON") - } - - fn target_ref_view(handle: &str) -> TargetRefView { - TargetRefView { - entity_type: "Person".to_string(), - handle: handle.to_string(), - identifier_schemes: Vec::new(), - profile: None, - } - } - - fn disclosure_digests(signed: &SignedSdJwtVc) -> Vec { - signed - .compact - .split('~') - .skip(1) - .filter(|disclosure| !disclosure.is_empty()) - .map(|disclosure| URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes()))) - .collect() - } - - fn decoded_disclosures(signed: &SignedSdJwtVc) -> Vec { - signed - .disclosures - .iter() - .map(|disclosure| { - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(disclosure) - .expect("disclosure decodes as base64url"), - ) - .expect("disclosure decodes as JSON") - }) - .collect() - } -} diff --git a/crates/registry-notary-core/src/tokens.rs b/crates/registry-notary-core/src/tokens.rs deleted file mode 100644 index 149341d8a..000000000 --- a/crates/registry-notary-core/src/tokens.rs +++ /dev/null @@ -1,1080 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Notary-issued JWT primitives for the pre-authorized-code OID4VCI flow. -//! -//! Two token classes are minted with the dedicated access-token signing key -//! (never the SD-JWT VC credential-signing key): -//! -//! - The `pre-authorized_code`: a short-TTL JWT carrying a `jti` for single-use -//! tracking and the eSignet-verified subject claims, handed to the wallet -//! inside the credential offer. -//! - The Notary access token: redeemed at the token endpoint and accepted by -//! the existing credential-endpoint consumers unchanged. Its `iss`/`aud`/`typ` -//! and alg pin the second verifier's trust anchor; its claim set reproduces -//! what an eSignet token would carry so subject binding, audience, and -//! subject-access classification pass identically. -//! -//! The verify helpers here are sufficient for unit-testing the mint/verify -//! round-trip. PR3 wires the real middleware verification via the platform -//! `TokenVerifier`. - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use registry_platform_crypto::{verify as verify_signature, PublicJwk, SigningProvider}; -use serde_json::{Map, Value}; -use std::fmt; - -use crate::error::EvidenceError; - -/// JWT `alg` (and signing-key alg) for Notary-issued tokens. -pub const NOTARY_TOKEN_SIGNING_ALG: &str = "EdDSA"; - -/// Default header `typ` for the `pre-authorized_code` JWT. -pub const PRE_AUTHORIZED_CODE_JWT_TYP: &str = "registry-notary-preauth-code+jwt"; - -/// Default header `typ` for the Notary access token. -pub const NOTARY_ACCESS_TOKEN_JWT_TYP: &str = "registry-notary-access+jwt"; - -/// Header `typ` for Notary transaction tokens minted by the platform STS. -pub const NOTARY_TRANSACTION_TOKEN_JWT_TYP: &str = "at+jwt"; -pub const NOTARY_AUTHORIZATION_DETAILS_TYPE: &str = "registry_notary_evidence_transaction"; -pub const NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION: &str = - "registry-notary-authorization-details/v1"; - -/// The eSignet-verified subject the Notary binds a pre-authorized code and the -/// resulting access token to. Captured at the offer callback. -/// -/// `subject_binding_value` (the civil ID identified by `subject_binding_claim`) -/// is load-bearing: the credential endpoint attests the claim for this subject. -#[derive(Clone)] -pub struct BoundSubject { - /// Token `sub`. - pub subject: String, - /// Claim name holding the civil ID (e.g. - /// `subject_access.subject_binding.token_claim`). - pub subject_binding_claim: String, - /// The civil ID value, reproduced exactly from the eSignet id_token. - pub subject_binding_value: String, - /// Citizen `client_id` mapped to an allowed citizen client. - pub client_id: String, - /// OAuth scopes; must include the subject-access required scopes. - pub scopes: Vec, - /// Assurance level (`acr`), if present. - pub acr: Option, - /// Authentication time (`auth_time`), if present. - pub auth_time: Option, -} - -impl fmt::Debug for BoundSubject { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BoundSubject") - .field("subject", &"[redacted]") - .field("subject_binding_claim", &self.subject_binding_claim) - .field("subject_binding_value", &"[redacted]") - .field("client_id", &self.client_id) - .field("scopes", &self.scopes) - .field("acr", &self.acr) - .field("auth_time", &self.auth_time) - .finish() - } -} - -/// Inputs for minting a `pre-authorized_code` JWT. -#[derive(Clone, Debug)] -pub struct PreAuthorizedCodeClaims { - /// Notary token issuer (`iss`). - pub issuer: String, - /// Single-use identifier tracked in the replay store by PR3 (`jti`). - pub jti: String, - /// Selected credential configuration the code is bound to. - pub credential_configuration_id: String, - /// Opaque identifier of the immutable registry-backed issuance transaction. - pub issuance_transaction_id: String, - /// Versioned commitment over the authority-bearing transaction fields. - pub issuance_transaction_commitment: String, - /// Whether this individual code requires the holder-presented transaction - /// code advertised with its credential offer. - pub tx_code_required: bool, - /// eSignet-verified subject claims carried into the code. - pub subject: BoundSubject, - /// Issued-at (unix seconds). - pub iat: i64, - /// Expiry (unix seconds). - pub exp: i64, -} - -/// Inputs for minting a Notary access token. -#[derive(Clone, Debug)] -pub struct AccessTokenClaims { - /// Notary token issuer (`iss`); must equal the second verifier's pinned - /// issuer. - pub issuer: String, - /// Optional JWT id (`jti`) for transaction-token replay protection. - pub jti: Option, - /// Accepted audiences (`aud`); must satisfy - /// `oid4vci.accepted_token_audiences` and - /// `subject_access.citizen_clients.allowed_audiences`. - pub audiences: Vec, - /// Token-type claim surfaced to the credential endpoint. - pub token_type: String, - /// Credential configuration the token is scoped to. - pub credential_configuration_id: String, - /// Opaque identifier of the immutable registry-backed issuance transaction. - pub issuance_transaction_id: String, - /// Versioned commitment copied from the pre-authorized code transaction. - pub issuance_transaction_commitment: String, - /// eSignet-verified subject claims. - pub subject: BoundSubject, - /// OAuth 2.0 Rich Authorization Requests-shaped authorization details. - pub authorization_details: Vec, - /// Sender constraint confirmation (`cnf`) copied from the verified subject - /// token when the deployment profile requires sender-constrained tokens. - pub confirmation: Option, - /// Verified assisted-access actor envelope. Public responses must not copy - /// this wholesale; it is for restricted audit/evaluation records. - pub actor: Option, - /// Issued-at (unix seconds). - pub iat: i64, - /// Expiry (unix seconds). - pub exp: i64, -} - -/// A minted, signed Notary JWT. The compact form is a secret (it is a bearer -/// token), so its `Debug` redacts it. -#[derive(Clone)] -pub struct SignedNotaryToken { - /// Header `typ`. - pub typ: String, - /// `jti` for the pre-authorized code; `None` for access tokens. - pub jti: Option, - /// Compact `header.payload.signature` JWT. - pub compact: String, -} - -impl fmt::Debug for SignedNotaryToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SignedNotaryToken") - .field("typ", &self.typ) - .field("jti", &self.jti) - .field("compact", &"[redacted]") - .finish() - } -} - -/// Mint a signed `pre-authorized_code` JWT with the access-token signing key. -pub async fn mint_pre_authorized_code( - signer: &dyn SigningProvider, - typ: &str, - claims: &PreAuthorizedCodeClaims, -) -> Result { - let mut payload = Map::new(); - payload.insert("iss".to_string(), Value::String(claims.issuer.clone())); - payload.insert("jti".to_string(), Value::String(claims.jti.clone())); - payload.insert( - "credential_configuration_id".to_string(), - Value::String(claims.credential_configuration_id.clone()), - ); - payload.insert( - "issuance_transaction_id".to_string(), - Value::String(claims.issuance_transaction_id.clone()), - ); - payload.insert( - "issuance_transaction_commitment".to_string(), - Value::String(claims.issuance_transaction_commitment.clone()), - ); - payload.insert( - "tx_code_required".to_string(), - Value::Bool(claims.tx_code_required), - ); - payload.insert("iat".to_string(), Value::from(claims.iat)); - payload.insert("nbf".to_string(), Value::from(claims.iat)); - payload.insert("exp".to_string(), Value::from(claims.exp)); - insert_subject_claims(&mut payload, &claims.subject)?; - let compact = sign_compact_jwt(signer, typ, Value::Object(payload)).await?; - Ok(SignedNotaryToken { - typ: typ.to_string(), - jti: Some(claims.jti.clone()), - compact, - }) -} - -/// Mint a signed Notary access token with the access-token signing key. -/// -/// The claim set is the security-critical contract consumed unchanged by the -/// credential endpoint: `iss`, `aud`, `sub`, `client_id`, `token_type`, -/// `scope`, the subject-binding claim, `acr`/`auth_time`, and `iat`/`nbf`/`exp`. -pub async fn mint_access_token( - signer: &dyn SigningProvider, - typ: &str, - claims: &AccessTokenClaims, -) -> Result { - let mut payload = Map::new(); - payload.insert("iss".to_string(), Value::String(claims.issuer.clone())); - if let Some(jti) = &claims.jti { - payload.insert("jti".to_string(), Value::String(jti.clone())); - } - payload.insert("aud".to_string(), audience_value(&claims.audiences)); - payload.insert( - "token_type".to_string(), - Value::String(claims.token_type.clone()), - ); - payload.insert( - "credential_configuration_id".to_string(), - Value::String(claims.credential_configuration_id.clone()), - ); - payload.insert( - "issuance_transaction_id".to_string(), - Value::String(claims.issuance_transaction_id.clone()), - ); - payload.insert( - "issuance_transaction_commitment".to_string(), - Value::String(claims.issuance_transaction_commitment.clone()), - ); - payload.insert("iat".to_string(), Value::from(claims.iat)); - payload.insert("nbf".to_string(), Value::from(claims.iat)); - payload.insert("exp".to_string(), Value::from(claims.exp)); - if !claims.authorization_details.is_empty() { - payload.insert( - "authorization_details".to_string(), - Value::Array(claims.authorization_details.clone()), - ); - } - if let Some(cnf) = &claims.confirmation { - payload.insert("cnf".to_string(), cnf.clone()); - } - if let Some(actor) = &claims.actor { - payload.insert("act".to_string(), actor.clone()); - } - insert_subject_claims(&mut payload, &claims.subject)?; - let compact = sign_compact_jwt(signer, typ, Value::Object(payload)).await?; - Ok(SignedNotaryToken { - typ: typ.to_string(), - jti: None, - compact, - }) -} - -/// The decoded header and payload of a verified Notary token, exposed so unit -/// tests can assert on the exact claim set. -#[derive(Clone, Debug)] -pub struct VerifiedNotaryToken { - pub header: Value, - pub payload: Value, -} - -impl VerifiedNotaryToken { - #[must_use] - pub fn claim_str(&self, name: &str) -> Option<&str> { - self.payload.get(name).and_then(Value::as_str) - } - - #[must_use] - pub fn claim_i64(&self, name: &str) -> Option { - self.payload.get(name).and_then(Value::as_i64) - } - - /// Space-separated `scope` claim split into individual scopes. Empty - /// segments (from leading, trailing, or repeated spaces) are dropped. - #[must_use] - pub fn scopes(&self) -> Vec { - self.claim_str("scope") - .map(|scope| { - scope - .split(' ') - .filter(|segment| !segment.is_empty()) - .map(ToString::to_string) - .collect() - }) - .unwrap_or_default() - } -} - -/// Verify a Notary token against the access-token signing key's public JWK. -/// -/// Enforces: header alg in the allow-list, the expected `typ`, signature, -/// `iss` exactly equal, `aud` membership (when expected audiences are given), -/// and `exp`/`nbf` against `now`. This mirrors what PR3's middleware verifier -/// pins and is sufficient for the unit-test round-trip. Every failure collapses -/// to `EvidenceError::MissingCredential`, matching the middleware's no-info-leak -/// failure mapping. -pub fn verify_notary_token( - compact: &str, - public_jwk: &PublicJwk, - expected_typ: &str, - expected_issuer: &str, - expected_audiences: &[String], - now: i64, -) -> Result { - let (header_b64, payload_b64, signature_b64) = split_compact(compact)?; - // Verify the signature over the raw segments BEFORE decoding any JSON, so a - // malformed or hostile header/payload never reaches the JSON parser on an - // unauthenticated token. The expected key is supplied by the caller, so the - // header is not needed to locate it; the key fixes the algorithm. - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| EvidenceError::MissingCredential)?; - verify_signature(signing_input.as_bytes(), &signature, public_jwk) - .map_err(|_| EvidenceError::MissingCredential)?; - let header = decode_segment_json(header_b64)?; - if header.get("alg").and_then(Value::as_str) != Some(NOTARY_TOKEN_SIGNING_ALG) { - return Err(EvidenceError::MissingCredential); - } - if header.get("typ").and_then(Value::as_str) != Some(expected_typ) { - return Err(EvidenceError::MissingCredential); - } - if expected_typ == NOTARY_TRANSACTION_TOKEN_JWT_TYP - && header - .get("kid") - .and_then(Value::as_str) - .map(str::is_empty) - .unwrap_or(true) - { - return Err(EvidenceError::MissingCredential); - } - let payload = decode_segment_json(payload_b64)?; - if expected_typ == NOTARY_TRANSACTION_TOKEN_JWT_TYP { - require_nonempty_claim(&payload, "jti")?; - require_nonempty_claim(&payload, "sub")?; - require_nonempty_claim(&payload, "scope")?; - require_authorization_details(&payload)?; - if payload.get("cnf").is_some() { - return Err(EvidenceError::MissingCredential); - } - } - if payload.get("iss").and_then(Value::as_str) != Some(expected_issuer) { - return Err(EvidenceError::MissingCredential); - } - if !expected_audiences.is_empty() && !audience_matches(&payload, expected_audiences) { - return Err(EvidenceError::MissingCredential); - } - let exp = payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(EvidenceError::MissingCredential)?; - if now >= exp { - return Err(EvidenceError::MissingCredential); - } - if let Some(nbf) = payload.get("nbf").and_then(Value::as_i64) { - if now < nbf { - return Err(EvidenceError::MissingCredential); - } - } - Ok(VerifiedNotaryToken { header, payload }) -} - -/// Claim names the Notary tokens already populate. A configured -/// `subject_binding_claim` must not collide with any of these, or inserting it -/// would overwrite a standard or required claim (`iss`/`aud`/`exp`/...). -const RESERVED_TOKEN_CLAIMS: &[&str] = &[ - "iss", - "sub", - "aud", - "exp", - "nbf", - "iat", - "jti", - "authorization_details", - "cnf", - "act", - "scope", - "client_id", - "token_type", - "credential_configuration_id", - "issuance_transaction_id", - "issuance_transaction_commitment", - "tx_code_required", - "acr", - "auth_time", -]; - -fn insert_subject_claims( - payload: &mut Map, - subject: &BoundSubject, -) -> Result<(), EvidenceError> { - // A subject-binding claim configured to a reserved/emitted claim name would - // overwrite it (e.g. clobbering `aud` with the civil ID). Refuse to mint - // rather than emit a malformed token. - if RESERVED_TOKEN_CLAIMS.contains(&subject.subject_binding_claim.as_str()) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - payload.insert("sub".to_string(), Value::String(subject.subject.clone())); - payload.insert( - "client_id".to_string(), - Value::String(subject.client_id.clone()), - ); - payload.insert("scope".to_string(), Value::String(subject.scopes.join(" "))); - // The subject-binding claim (the civil ID) is load-bearing: the credential - // endpoint reads it to identify whose status is attested. - payload.insert( - subject.subject_binding_claim.clone(), - Value::String(subject.subject_binding_value.clone()), - ); - if let Some(acr) = &subject.acr { - payload.insert("acr".to_string(), Value::String(acr.clone())); - } - if let Some(auth_time) = subject.auth_time { - payload.insert("auth_time".to_string(), Value::from(auth_time)); - } - Ok(()) -} - -fn require_nonempty_claim(payload: &Value, name: &str) -> Result<(), EvidenceError> { - if payload - .get(name) - .and_then(Value::as_str) - .is_some_and(|value| !value.trim().is_empty()) - { - return Ok(()); - } - Err(EvidenceError::MissingCredential) -} - -fn require_authorization_details(payload: &Value) -> Result<(), EvidenceError> { - let Some(details) = payload - .get("authorization_details") - .and_then(Value::as_array) - else { - return Err(EvidenceError::MissingCredential); - }; - let has_matching_detail = details.iter().filter_map(Value::as_object).any(|detail| { - detail.get("type").and_then(Value::as_str) == Some(NOTARY_AUTHORIZATION_DETAILS_TYPE) - && detail.get("schema_version").and_then(Value::as_str) - == Some(NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION) - }); - if !has_matching_detail { - return Err(EvidenceError::MissingCredential); - } - Ok(()) -} - -fn audience_value(audiences: &[String]) -> Value { - if audiences.len() == 1 { - Value::String(audiences[0].clone()) - } else { - Value::Array(audiences.iter().cloned().map(Value::String).collect()) - } -} - -fn audience_matches(payload: &Value, expected: &[String]) -> bool { - match payload.get("aud") { - Some(Value::String(aud)) => expected.iter().any(|candidate| candidate == aud), - Some(Value::Array(values)) => values - .iter() - .filter_map(Value::as_str) - .any(|aud| expected.iter().any(|candidate| candidate.as_str() == aud)), - _ => false, - } -} - -async fn sign_compact_jwt( - signer: &dyn SigningProvider, - typ: &str, - payload: Value, -) -> Result { - let public_jwk = signer.public_jwk(); - let kid = public_jwk - .kid - .clone() - .filter(|kid| kid == signer.key_id()) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - let header = serde_json::json!({ - "alg": NOTARY_TOKEN_SIGNING_ALG, - "typ": typ, - "kid": kid, - }); - let header_b64 = URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&header).map_err(|_| EvidenceError::CredentialIssuanceFailed)?); - let payload_b64 = URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).map_err(|_| EvidenceError::CredentialIssuanceFailed)?); - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = signer - .sign(signing_input.as_bytes()) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - // Self-check so a misbehaving signer cannot emit an unverifiable token. - verify_signature(signing_input.as_bytes(), &signature, &public_jwk) - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - Ok(format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature) - )) -} - -fn split_compact(compact: &str) -> Result<(&str, &str, &str), EvidenceError> { - let mut parts = compact.split('.'); - let header = parts.next().ok_or(EvidenceError::MissingCredential)?; - let payload = parts.next().ok_or(EvidenceError::MissingCredential)?; - let signature = parts.next().ok_or(EvidenceError::MissingCredential)?; - if parts.next().is_some() || header.is_empty() || payload.is_empty() || signature.is_empty() { - return Err(EvidenceError::MissingCredential); - } - Ok((header, payload, signature)) -} - -fn decode_segment_json(segment: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| EvidenceError::MissingCredential)?; - serde_json::from_slice(&bytes).map_err(|_| EvidenceError::MissingCredential) -} - -#[cfg(test)] -mod tests { - use super::*; - use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; - - // Stands in for the dedicated access-token signing key. - const ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; - // Stands in for the SD-JWT VC credential-signing key (a different key). - const CREDENTIAL_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","alg":"EdDSA"}"#; - - const ISSUER: &str = "http://127.0.0.1:4325"; - const AUDIENCE: &str = "http://127.0.0.1:4325"; - const SUBJECT_BINDING_CLAIM: &str = "https://id.example.gov/claims/national_id"; - const CIVIL_ID: &str = "NAT-123456"; - const NOW: i64 = 1_700_000_000; - - fn signer(raw: &str, kid: &str) -> LocalJwkSigner { - let mut jwk = PrivateJwk::parse(raw).expect("test JWK parses"); - jwk.kid = Some(kid.to_string()); - jwk.alg = Some(NOTARY_TOKEN_SIGNING_ALG.to_string()); - LocalJwkSigner::new(jwk).expect("local signer builds") - } - - fn access_token_signer() -> LocalJwkSigner { - signer(ACCESS_TOKEN_JWK, "did:web:issuer.example#access-token-key") - } - - fn credential_signer() -> LocalJwkSigner { - signer(CREDENTIAL_JWK, "did:web:issuer.example#credential-key") - } - - fn bound_subject() -> BoundSubject { - BoundSubject { - subject: "citizen-subject-1".to_string(), - subject_binding_claim: SUBJECT_BINDING_CLAIM.to_string(), - subject_binding_value: CIVIL_ID.to_string(), - client_id: "registry-lab-live-client".to_string(), - scopes: vec!["openid".to_string(), "subject_access".to_string()], - acr: Some("urn:example:loa:substantial".to_string()), - auth_time: Some(NOW - 30), - } - } - - fn access_token_claims() -> AccessTokenClaims { - AccessTokenClaims { - issuer: ISSUER.to_string(), - jti: None, - audiences: vec![AUDIENCE.to_string()], - token_type: "Bearer".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "transaction-123".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - subject: bound_subject(), - authorization_details: Vec::new(), - confirmation: None, - actor: None, - iat: NOW, - exp: NOW + 300, - } - } - - fn transaction_token_claims() -> AccessTokenClaims { - AccessTokenClaims { - issuer: ISSUER.to_string(), - jti: Some("01J0000000000000000000TXN1".to_string()), - audiences: vec![AUDIENCE.to_string()], - token_type: "Bearer".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "transaction-123".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - subject: bound_subject(), - authorization_details: vec![serde_json::json!({ - "type": NOTARY_AUTHORIZATION_DETAILS_TYPE, - "schema_version": NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION, - "actions": ["evaluate"], - "locations": [AUDIENCE], - })], - confirmation: None, - actor: Some(serde_json::json!({ - "actor_id_hash": "hmac-sha256:actor", - "assurance": "workforce-login", - "delegation_ref": "delegation-123", - })), - iat: NOW, - exp: NOW + 300, - } - } - - fn pre_authorized_code_claims() -> PreAuthorizedCodeClaims { - PreAuthorizedCodeClaims { - issuer: ISSUER.to_string(), - jti: "01J0000000000000000000PREAU".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "01J0000000000000000000PREAU".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - tx_code_required: true, - subject: bound_subject(), - iat: NOW, - exp: NOW + 120, - } - } - - fn block_on(future: F) -> F::Output { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime builds") - .block_on(future) - } - - #[test] - fn access_token_round_trip_carries_full_required_claim_set() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - assert!(token.jti.is_none()); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("access token verifies"); - - // Header: alg + the distinct access-token typ + the access-token kid. - assert_eq!(verified.header["alg"], NOTARY_TOKEN_SIGNING_ALG); - assert_eq!(verified.header["typ"], NOTARY_ACCESS_TOKEN_JWT_TYP); - assert_eq!( - verified.header["kid"], - "did:web:issuer.example#access-token-key" - ); - - // iss: pinned by the second verifier (standalone.rs authenticate_oidc). - assert_eq!(verified.claim_str("iss"), Some(ISSUER)); - // aud: require_oid4vci_token_audience (api.rs:2296) + citizen audience. - assert_eq!(verified.payload["aud"], AUDIENCE); - // sub + subject_binding claim: oid4vci_bound_subject (api.rs:2317) and - // bounded_verified_claims_from_oidc (standalone.rs:2437). - assert_eq!(verified.claim_str("sub"), Some("citizen-subject-1")); - assert_eq!(verified.claim_str(SUBJECT_BINDING_CLAIM), Some(CIVIL_ID)); - // client_id + scope: classify_subject_access_principal (api.rs:2585). - assert_eq!( - verified.claim_str("client_id"), - Some("registry-lab-live-client") - ); - assert_eq!( - verified.scopes(), - vec!["openid".to_string(), "subject_access".to_string()] - ); - // token_type, acr, auth_time, exp, iat, nbf: BoundedVerifiedClaims. - assert_eq!(verified.claim_str("token_type"), Some("Bearer")); - assert_eq!( - verified.claim_str("acr"), - Some("urn:example:loa:substantial") - ); - assert_eq!(verified.claim_i64("auth_time"), Some(NOW - 30)); - assert_eq!(verified.claim_i64("iat"), Some(NOW)); - assert_eq!(verified.claim_i64("nbf"), Some(NOW)); - assert_eq!(verified.claim_i64("exp"), Some(NOW + 300)); - assert_eq!( - verified.claim_str("credential_configuration_id"), - Some("date_of_birth_sd_jwt") - ); - } - - #[test] - fn pre_authorized_code_round_trip_carries_jti_subject_and_tx_code_requirement() { - let signer = access_token_signer(); - let claims = pre_authorized_code_claims(); - let token = block_on(mint_pre_authorized_code( - &signer, - PRE_AUTHORIZED_CODE_JWT_TYP, - &claims, - )) - .expect("pre-authorized code mints"); - assert_eq!(token.jti.as_deref(), Some(claims.jti.as_str())); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - PRE_AUTHORIZED_CODE_JWT_TYP, - ISSUER, - &[], - NOW + 1, - ) - .expect("pre-authorized code verifies"); - - assert_eq!(verified.header["typ"], PRE_AUTHORIZED_CODE_JWT_TYP); - assert_eq!(verified.claim_str("jti"), Some(claims.jti.as_str())); - assert_eq!(verified.claim_str("sub"), Some("citizen-subject-1")); - assert_eq!(verified.claim_str(SUBJECT_BINDING_CLAIM), Some(CIVIL_ID)); - assert_eq!( - verified.claim_str("credential_configuration_id"), - Some("date_of_birth_sd_jwt") - ); - assert_eq!(verified.payload["tx_code_required"], true); - } - - #[test] - fn transaction_token_round_trip_requires_jti_authz_details_and_actor() { - let signer = access_token_signer(); - let claims = transaction_token_claims(); - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("transaction token verifies"); - - assert_eq!(verified.header["typ"], NOTARY_TRANSACTION_TOKEN_JWT_TYP); - assert_eq!( - verified.header["kid"], - "did:web:issuer.example#access-token-key" - ); - assert_eq!( - verified.claim_str("jti"), - Some("01J0000000000000000000TXN1") - ); - assert!(verified.payload.get("cnf").is_none()); - assert_eq!( - verified.payload["authorization_details"][0]["type"], - NOTARY_AUTHORIZATION_DETAILS_TYPE - ); - assert_eq!( - verified.payload["authorization_details"][0]["schema_version"], - NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION - ); - assert_eq!( - verified.payload["act"]["actor_id_hash"], - "hmac-sha256:actor" - ); - assert_eq!(verified.payload["act"]["delegation_ref"], "delegation-123"); - } - - #[test] - fn transaction_token_accepts_matching_authorization_details_after_other_entries() { - let signer = access_token_signer(); - let mut claims = transaction_token_claims(); - claims.authorization_details.insert( - 0, - serde_json::json!({ - "type": "unrelated_authorization_detail", - "schema_version": "example/v1", - }), - ); - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("matching authorization_details need not be first"); - } - - #[test] - fn transaction_token_verify_rejects_unvalidated_sender_constraint() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - confirmation: Some(serde_json::json!({"jkt": "sender-key-thumbprint"})), - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("cnf without proof validation must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn transaction_token_verify_rejects_missing_authorization_details() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - authorization_details: Vec::new(), - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("missing authorization_details must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn transaction_token_verify_rejects_wrong_authorization_details_type() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - authorization_details: vec![serde_json::json!({ - "type": "registry_notary_subject_access", - "schema_version": NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION, - })], - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("wrong authorization_details type must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_token_signed_by_a_different_key() { - let access = access_token_signer(); - let credential = credential_signer(); - // Sign with the access-token key, then verify against the credential - // key's public JWK. - let token = block_on(mint_access_token( - &access, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &credential.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token signed by a different key must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_token_signed_by_the_credential_key() { - // A token claiming the Notary issuer/typ but signed by the credential - // key must not verify against the access-token public key. - let credential = credential_signer(); - let access = access_token_signer(); - let token = block_on(mint_access_token( - &credential, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("token mints with the credential key"); - - let error = verify_notary_token( - &token.compact, - &access.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a credential-key-signed token must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_typ() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - PRE_AUTHORIZED_CODE_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token with the wrong typ must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_issuer() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - "https://attacker.example", - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token with the wrong issuer must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_audience() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &["https://other.example".to_string()], - NOW + 1, - ) - .expect_err("a token with no accepted audience must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_expired_token() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 301, - ) - .expect_err("an expired token must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn signed_token_debug_redacts_compact() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - let debug = format!("{token:?}"); - - assert!(debug.contains("SignedNotaryToken")); - assert!(debug.contains(NOTARY_ACCESS_TOKEN_JWT_TYP)); - assert!(!debug.contains(&token.compact)); - } - - #[test] - fn bound_subject_debug_redacts_subject_and_civil_id() { - let subject = bound_subject(); - let debug = format!("{subject:?}"); - - assert!(debug.contains("BoundSubject")); - assert!(debug.contains(SUBJECT_BINDING_CLAIM)); - assert!(!debug.contains("citizen-subject-1")); - assert!(!debug.contains(CIVIL_ID)); - } - - #[test] - fn pre_authorized_code_claims_debug_does_not_render_civil_id() { - let claims = pre_authorized_code_claims(); - let debug = format!("{claims:?}"); - - // The derived Debug recurses into BoundSubject's redacting Debug. - assert!(!debug.contains("citizen-subject-1")); - assert!(!debug.contains(CIVIL_ID)); - } - - #[test] - fn mint_rejects_subject_binding_claim_colliding_with_a_reserved_claim() { - // A subject_binding_claim configured to a reserved/emitted claim name - // would overwrite that claim; minting must fail loudly instead. - for &reserved in RESERVED_TOKEN_CLAIMS { - let mut subject = bound_subject(); - subject.subject_binding_claim = reserved.to_string(); - let claims = AccessTokenClaims { - subject, - ..access_token_claims() - }; - let error = block_on(mint_access_token( - &access_token_signer(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - &claims, - )) - .expect_err("a reserved subject-binding claim must be rejected"); - assert!(matches!(error, EvidenceError::CredentialIssuanceFailed)); - } - } -} diff --git a/crates/registry-notary-server/Cargo.toml b/crates/registry-notary-server/Cargo.toml deleted file mode 100644 index 816044f43..000000000 --- a/crates/registry-notary-server/Cargo.toml +++ /dev/null @@ -1,101 +0,0 @@ -[package] -name = "registry-notary-server" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Standalone Registry Notary routes, Relay-backed runtime, auth, and audit." -readme = "README.md" -repository.workspace = true -publish = false - -# not opted into [workspace.lints]: unsafe std::env::set_var/remove_var in -# #[cfg(test)] code (src/standalone.rs, tests/) - -[features] -default = [] -registry-notary-cel = ["dep:crosswalk-core", "dep:registry-notary-worker-harness"] -cel-worker-fixture = ["registry-notary-cel"] -pkcs11 = ["dep:cryptoki", "dep:hex"] -relay-contract-test-support = ["registry-platform-httputil/test-support"] - -[dependencies] -registry-notary-core.workspace = true -registry-platform-audit.workspace = true -registry-platform-authcommon.workspace = true -registry-platform-cache.workspace = true -registry-platform-config.workspace = true -registry-platform-crypto.workspace = true -registry-platform-httpsec.workspace = true -registry-platform-httputil.workspace = true -registry-platform-oid4vci.workspace = true -registry-platform-oidc.workspace = true -registry-platform-ops.workspace = true -registry-platform-pdp.workspace = true -registry-platform-replay.workspace = true -registry-platform-sdjwt.workspace = true -axum.workspace = true -async-trait.workspace = true -aws-lc-rs.workspace = true -base64.workspace = true -crosswalk-core = { workspace = true, optional = true } -deadpool.workspace = true -registry-notary-worker-harness = { workspace = true, optional = true } -cryptoki = { workspace = true, optional = true } -getrandom.workspace = true -hex = { workspace = true, optional = true } -hmac.workspace = true -jsonwebtoken.workspace = true -native-tls.workspace = true -postgres-native-tls.workspace = true -reqwest.workspace = true -rustix.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_norway.workspace = true -sha2.workspace = true -subtle.workspace = true -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -tokio-postgres.workspace = true -tower-http.workspace = true -tracing.workspace = true -ulid.workspace = true -utoipa.workspace = true -zeroize.workspace = true - -[dev-dependencies] -axum-test = { version = "20" } -chrono = { version = "0.4" } -registry-notary-client = { workspace = true, features = ["test-support", "verifier"] } -registry-platform-testing = { workspace = true, features = ["test-utils"] } -jsonschema.workspace = true -tempfile = { version = "3" } -wiremock = { version = "0.6" } -# Criterion drives the microbenchmarks under `benches/`. The benches are -# manually invoked via `cargo bench`; CI smoke-checks `cargo bench --no-run`. -criterion = { version = "0.8", features = ["html_reports"] } - -[[bench]] -name = "auth_bench" -harness = false - -[[bench]] -name = "sd_jwt_bench" -harness = false - -[[bench]] -name = "json_bench" -harness = false - -[[bin]] -name = "registry-notary-cel-worker" -path = "src/bin/registry_notary_cel_worker.rs" -required-features = ["registry-notary-cel"] -test = false - -[[bin]] -name = "registry-notary-cel-worker-fixture" -path = "src/bin/registry_notary_cel_worker_fixture.rs" -required-features = ["cel-worker-fixture"] -test = false diff --git a/crates/registry-notary-server/README.md b/crates/registry-notary-server/README.md deleted file mode 100644 index 69a74d3c3..000000000 --- a/crates/registry-notary-server/README.md +++ /dev/null @@ -1,263 +0,0 @@ -# registry-notary-server - -Standalone Registry Notary runtime, API routes, auth, audit, Relay -consultations, renderers, and credential issuance wiring. - -## What It Provides - -- Axum routers for the Registry Notary API. -- Runtime claim evaluation with dependency ordering and request-scoped Relay - consultation coalescing. -- Hash-pinned, semantically verified Relay consultations for registry-backed - evidence. -- Subject-bound and delegated subject access to Relay-backed evidence. -- API-key and bearer-token auth through `registry-platform` primitives. -- Redacted audit event emission. -- JSON, SD-JWT VC, and credential response renderers. -- Static-peer federated delegated evaluation at `/federation/v1/evaluations` - when federation is enabled in config. -- Prometheus metrics contract for `/metrics` with safe, low-cardinality labels. -- OpenAPI document generation. - -## Typical Use - -```rust -use registry_notary_core::StandaloneRegistryNotaryConfig; -use registry_notary_server::{ - compile_notary_runtime, notary_public_router_from_runtime, StandaloneServerError, -}; - -async fn app( - config: StandaloneRegistryNotaryConfig, -) -> Result { - let runtime = compile_notary_runtime(config)?; - let runtime = runtime.activate().await?; - notary_public_router_from_runtime(runtime) -} -``` - -The asynchronous `standalone_router` convenience helper is limited to explicit -local `state.storage: in_memory` configurations and must be awaited: - -```rust -let router = registry_notary_server::standalone_router(config).await?; -``` - -Both construction paths eagerly verify the retained audit chain before a -router can be returned. Confirmed integrity failures latch `/ready` until the -operator performs offline quarantine recovery and restarts the process. The -runtime-to-router functions reject snapshots that have not passed through this -verification boundary. PostgreSQL state and Registry-backed claims require the -full asynchronous activation sequence in the example before a listener is -built. - -The example builds only the public router. Embedders that deliberately select -`server.admin_listener.mode: shared_with_public` can instead use -`notary_shared_router_from_runtime`. Dedicated admin listeners require -`notary_routers_from_runtime` and separate public and admin binds. - -## Features - -- Default: no CEL runtime. -- `registry-notary-cel`: enables CEL-backed claim expression evaluation through - `crosswalk-core` in a hardened worker process with bounded IO, environment - scrubbing, resource limits where supported, timeout kill, and worker - replacement. -- `pkcs11`: enables HSM-backed SD-JWT VC issuer signing through PKCS#11. The - provider supports Ed25519 EdDSA keys and is configured through - `evidence.signing_keys`. See - [`../../docs/signing-key-provider.md`](../../docs/signing-key-provider.md). - -Run server tests without default features when checking the beta binary shape: - -```sh -cargo test -p registry-notary-server --no-default-features -``` - -Run the PKCS#11 feature path separately: - -```sh -cargo test -p registry-notary-server --no-default-features --features pkcs11 --lib -``` - -When SoftHSM and OpenSSL are installed, that feature test includes a live -PKCS#11 signing smoke test. - -## Correctness state configuration - -Registry Notary stores replay decisions, consumable nonces, evaluations, -idempotency records, credential status, quotas, and preauthorization state in -one Notary-owned PostgreSQL schema: - -```yaml -state: - storage: postgresql - postgresql: - url_env: REGISTRY_NOTARY_POSTGRES_URL - connect_timeout_ms: 5000 - operation_timeout_ms: 2000 - max_connections: 16 -``` - -Run `registry-notary state install` with a restricted migration login before -starting the service. Runtime connections require Transport Layer Security -(TLS), attest the exact schema and runtime role, and fail readiness when the -database is unavailable, read-only, incompatible, or configured with unsafe -durability settings. `max_connections` is a hard physical-connection cap per -Notary replica. Size the database budget as replica count multiplied by this -value, plus operator and migration connections. - -Local, single-process development can select the process-local backend -explicitly: - -```yaml -deployment: - profile: local - multi_instance: false -state: - storage: in_memory -``` - -`in_memory` is rejected outside the local, single-instance profile. It loses -correctness state on restart and does not provide cross-replica decisions. - -## Credential Lifecycle - -SD-JWT VC issuance is intentionally short-lived and status-free by default. -Each credential profile controls the credential lifetime with -`validity_seconds`, which defaults to 600 seconds when omitted. - -Set `credential_status.enabled = true` to add a storage-backed credential -status endpoint. Issued SD-JWT VC payloads then include a -`status.status_list.uri` pointing at `/v1/credentials/{credential_id}/status`. -The same URL serves `application/statuslist+jwt` for verifiers and the JSON -lifecycle representation for operational compatibility. The global -correctness-state backend stores status rows. The JSON endpoint returns -`valid`, `suspended`, `revoked`, or derived `expired`; admins update mutable -states through -`POST /admin/v1/credentials/{credential_id}/status` with the -`registry_notary:admin` scope. Status records contain only credential lifecycle -metadata, not subject ids, holder keys, claim values, disclosures, or source -rows. - -## Metrics - -`/metrics` is the Prometheus scrape surface for server metrics. Metric families -and labels must be safe for operational scraping: use bounded labels such as -endpoint kind, method, status code, status class, error code, outcome, profile, -and source id. Do not label or emit subject ids, principal ids, holder keys, -access tokens, source rows, request or correlation ids, SD-JWT disclosures, or -raw error details. The endpoint requires an authenticated principal with the -`registry_notary:metrics_read` scope, so Prometheus scrape jobs must send a -dedicated metrics credential. Static-auth deployments can use a metrics bearer -token or a metrics API key in `x-api-key`; OIDC deployments can use a token -whose mapped scopes include `registry_notary:metrics_read`. An internal-only -listener/proxy is defense in depth only and must still forward or inject a valid -metrics credential. It should still be exposed only through the deployment's -normal network and scrape controls. - -Example Prometheus scrape shape: - -```yaml -scrape_configs: - - job_name: registry-notary - metrics_path: /metrics - authorization: - type: Bearer - credentials_file: /run/secrets/registry-notary-metrics-token - static_configs: - - targets: ["registry-notary:4325"] -``` - -## Operations Posture - -`GET /admin/v1/posture` returns the redacted `registry.ops.posture.v1` -operations document for fleet polling. It requires an authenticated principal -with exactly the read-only `registry_notary:ops_read` scope; the write-capable -`registry_notary:admin` scope does not authorize posture unless the same -credential also carries `registry_notary:ops_read`. - -Registry Notary supports a dedicated admin listener with -`server.admin_listener.mode: dedicated`. In that mode `/admin/v1/*` and -`/metrics` are not mounted on the public listener. Simple local deployments may -use `server.admin_listener.mode: shared_with_public`, but governed -configuration with `config_trust` requires dedicated admin mode at startup. -Every topology still enforces the application scope checks. - -## Audit Configuration - -`standalone_router(...).await` builds the audit pipeline from -`StandaloneRegistryNotaryConfig.audit` and eagerly verifies retained records -before returning. The pipeline writes one redacted, tamper-evident JSON -envelope per security-relevant event and fails closed if the configured hash -secret is unavailable. - -```yaml -audit: - sink: file - path: /var/log/registry-notary/audit.jsonl - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - max_size_mb: 100 - max_files: 14 -``` - -Sink options: - -- `stdout` writes JSONL to process stdout and is appropriate when platform log - collection provides durability. -- `file` and `jsonl` require `path`. Use `max_size_mb` for active-file rotation - and `max_files` for retained file count. `max_files` includes the active - file; `max_size_mb: 0` disables rotation. -- `syslog` writes JSONL envelopes to a local Unix datagram syslog socket. Set - `syslog_socket_path` to override the platform default: - -```yaml -audit: - sink: syslog - syslog_socket_path: /run/systemd/journal/syslog - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -``` - -`hash_secret_env` names an environment variable containing the deployment HMAC -secret used for audit identifier hashing. Use a generated, high-entropy value, -keep it out of config files, and keep it stable for the retention period where -auditors must correlate records. - -Audit envelopes contain `prev_hash` and `record_hash`. File/jsonl sinks resume -from the retained tail hash on startup. `registry-platform-audit::verify_chain` -proves internal consistency of the retained record set: edits, insertions, -reordering, and deletions of interior records are detected. It cannot prove -completeness of the retained set on its own, since a suffix truncation or a -fully replaced log stays self-consistent. Completeness is an off-host -shipping guarantee: declare `deployment.evidence.audit_offhost_shipping: true` -once audit events are actually shipped to a log aggregator or SIEM outside -this host. Evidence-grade deployments refuse to start when the audit sink is -`file` or `jsonl` and that declaration is missing. - -## Security Notes - -- The server starts fail-closed when credentials are missing or invalid. -- SD-JWT VC credential profiles default to 600-second validity when - `validity_seconds` is omitted; subject-access keeps profiles within the - configured credential validity ceiling. -- Federated evaluation routes are not mounted unless `federation.enabled` is - true, and accepted requests must be signed compact JWS bodies from configured - peers. -- Production and active-active deployments use the typed Notary PostgreSQL - state plane. Process-local state is limited to explicit local development. -- Registry-backed evaluation is available only through an authenticated, - purpose-bound Relay consultation whose public contract is verified before - readiness succeeds. -- Runtime readiness attests the PostgreSQL schema, role, write authority, and - durability settings before serving correctness-dependent traffic. - -## Testing - -```sh -cargo test -p registry-notary-server --no-default-features -cargo test -p registry-notary-server --all-features -``` - -## License - -Apache-2.0. diff --git a/crates/registry-notary-server/benches/auth_bench.rs b/crates/registry-notary-server/benches/auth_bench.rs deleted file mode 100644 index da60c9a33..000000000 --- a/crates/registry-notary-server/benches/auth_bench.rs +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the standalone-notary auth hot path. -//! -//! Covers: -//! - `find_credential` linear scan with hashed API-key verification for a hit -//! in a small token table and a miss against the same table. -//! - platform Bearer header parser on a typical "Bearer " value -//! and on an invalid input (missing scheme). -//! -//! Notary compares request tokens against fixed-length stored fingerprints. -//! These benches lock in the per-call cost at a realistic small N (4 -//! credentials, matching the medium perf profile). - -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_server::standalone::{find_credential, ResolvedCredential}; -use registry_platform_authcommon::parse_bearer_token; - -const VALID_TOKEN: &str = "perf-bench-bearer-token-target0-0000000000-099"; -const WRONG_TOKEN: &str = "perf-bench-bearer-token-wrong00-0000000000-000"; - -fn build_credentials() -> Vec { - vec![ - cred("client_a", "perf-bench-bearer-token-clienta-0000000000-001"), - cred("client_b", "perf-bench-bearer-token-clientb-0000000000-002"), - cred("client_c", "perf-bench-bearer-token-clientc-0000000000-003"), - cred("bench_client", VALID_TOKEN), - ] -} - -fn cred(id: &str, token: &str) -> ResolvedCredential { - ResolvedCredential { - id: id.to_string(), - fingerprint: registry_platform_authcommon::fingerprint_api_key(token), - scopes: vec!["civil-registry.read".into(), "farmer-registry.read".into()], - authorization_details: None, - } -} - -fn benchmark_find_credential_hit(c: &mut Criterion) { - let credentials = build_credentials(); - c.bench_function("auth/find_credential_hit", |b| { - b.iter(|| find_credential(black_box(&credentials), black_box(VALID_TOKEN))); - }); -} - -fn benchmark_find_credential_miss(c: &mut Criterion) { - let credentials = build_credentials(); - c.bench_function("auth/find_credential_miss", |b| { - b.iter(|| find_credential(black_box(&credentials), black_box(WRONG_TOKEN))); - }); -} - -fn benchmark_parse_bearer_token_ok(c: &mut Criterion) { - let header = format!("Bearer {VALID_TOKEN}"); - c.bench_function("auth/parse_bearer_token_ok", |b| { - b.iter(|| parse_bearer_token(black_box(&header))); - }); -} - -fn benchmark_parse_bearer_token_bad(c: &mut Criterion) { - let header = format!("Basic {VALID_TOKEN}"); - c.bench_function("auth/parse_bearer_token_bad_scheme", |b| { - b.iter(|| parse_bearer_token(black_box(&header))); - }); -} - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_find_credential_hit, - benchmark_find_credential_miss, - benchmark_parse_bearer_token_ok, - benchmark_parse_bearer_token_bad -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/benches/json_bench.rs b/crates/registry-notary-server/benches/json_bench.rs deleted file mode 100644 index af919c8d6..000000000 --- a/crates/registry-notary-server/benches/json_bench.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the JSON serialization/deserialization hot paths. -//! -//! Covers: -//! - `EvidenceAuditEvent` serialization (written as a JSONL line on every request). -//! - `ClaimResultView` serialization (returned in `/v1/evaluations` and -//! `/v1/batch-evaluations` responses). -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_core::model::{ - ClaimProvenance, ClaimResultView, EvidenceAuditEvent, EvidenceEntityRef, - EvidenceEntityReference, Hashed, PrincipalIdentifier, TargetRefView, -}; -use serde_json::json; - -// --------------------------------------------------------------------------- -// Builder helpers -// --------------------------------------------------------------------------- - -fn build_audit_event() -> EvidenceAuditEvent { - EvidenceAuditEvent { - event_id: "01HWQZPJ3VXKM8N2BF5CSRTE4D".to_string(), - occurred_at: "2026-05-24T12:00:00Z".to_string(), - principal_id_hash: Some(Hashed::::from_hash( - "hmac-sha256:client-bench-001", - )), - scopes_used: vec!["farmer_registry:evidence_verification".to_string()], - decision: "allow".to_string(), - method: "POST".to_string(), - path: "/v1/evaluations".to_string(), - status: 200, - verification_id: Some("01HWQZPJ3VXKM8N2BF5CSRTE4E".to_string()), - claim_hash: Some( - "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), - ), - purposes: None, - row_count: None, - relay_consultation_count: None, - relay_consultation_ids: Vec::new(), - forwarded: None, - error_code: None, - access_mode: None, - federation_peer_id_hash: None, - federation_issuer: None, - federation_profile: None, - federation_purpose: None, - federation_request_jti_hash: None, - federation_subject_ref_hash: None, - denial_code: None, - token_claim_name: None, - correlation_id_hash: None, - credential_profile: None, - protocol: None, - credential_configuration_id: None, - holder_binding_mode: None, - rate_limit_bucket: None, - policy_version: None, - policy_hash: None, - target_type: Some("Person".to_string()), - target_ref_hash: Some(Hashed::::from_hash( - "hmac-sha256:target-bench-0000007", - )), - requester_type: Some("Agency".to_string()), - requester_ref_hash: Some(Hashed::::from_hash( - "hmac-sha256:requester-bench-001", - )), - redacted_fields: None, - batch_items: None, - config: None, - } -} - -fn build_claim_result_view() -> ClaimResultView { - ClaimResultView { - evaluation_id: "01HWQZPJ3VXKM8N2BF5CSRTE4F".to_string(), - claim_id: "date-of-birth".to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "national_id".to_string(), - requester_ref: Some(EvidenceEntityRef { - entity_type: "Agency".to_string(), - handle: "rnref:v1:requester-bench-001".to_string(), - identifier_schemes: vec!["agency_id".to_string()], - profile: Some("civil-registry".to_string()), - }), - target_ref: TargetRefView { - entity_type: "Person".to_string(), - handle: "rnref:v1:target-bench-0000007".to_string(), - identifier_schemes: vec!["national_id".to_string()], - profile: Some("resident".to_string()), - }, - value: Some(json!("1990-01-01")), - satisfied: Some(true), - disclosure: "full_disclosure".to_string(), - redacted_fields: Vec::new(), - format: "json".to_string(), - issued_at: "2026-05-24T12:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "registry-notary-server".to_string(), - "eval-bench".to_string(), - "date-of-birth".to_string(), - "1".to_string(), - registry_notary_core::ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - } -} - -// --------------------------------------------------------------------------- -// Benchmark functions -// --------------------------------------------------------------------------- - -fn benchmark_serialize_audit_event(c: &mut Criterion) { - let event = build_audit_event(); - c.bench_function("json/serialize_audit_event", |b| { - b.iter(|| serde_json::to_vec(black_box(&event)).expect("audit event must serialize")); - }); -} - -fn benchmark_serialize_claim_result_view(c: &mut Criterion) { - let view = build_claim_result_view(); - c.bench_function("json/serialize_claim_result_view", |b| { - b.iter(|| serde_json::to_vec(black_box(&view)).expect("claim result view must serialize")); - }); -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_serialize_audit_event, - benchmark_serialize_claim_result_view -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/benches/sd_jwt_bench.rs b/crates/registry-notary-server/benches/sd_jwt_bench.rs deleted file mode 100644 index fa6174c3d..000000000 --- a/crates/registry-notary-server/benches/sd_jwt_bench.rs +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the SD-JWT VC issuance hot path. -//! -//! Covers: -//! - `EvidenceIssuer::from_jwk_str`: JWK parsing and Ed25519 key loading. -//! - `issue` with one disclosure: the minimal single-claim credential. -//! - `issue` with three disclosures: a realistic multi-claim credential. -//! -//! All `issue` benches use holder_binding.mode = "none", holder_id = None, -//! and a fixed iat (OffsetDateTime::UNIX_EPOCH) to avoid wall-clock noise. - -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_core::config::{ - CredentialDisclosureConfig, CredentialProfileConfig, HolderBindingConfig, -}; -use registry_notary_core::model::{ - ClaimProvenance, ClaimResultView, EvidenceEntityRef, TargetRefView, -}; -use registry_notary_core::sd_jwt::{issue, EvidenceIssuer, IssueOptions}; -use time::OffsetDateTime; - -const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; -const VM_ID: &str = "did:web:perf.registry-notary.example#perf-key-1"; - -fn build_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - format: registry_notary_core::FORMAT_SD_JWT_VC.to_string(), - issuer: "did:web:perf.registry-notary.example".to_string(), - signing_key: "perf-key".to_string(), - vct: "https://data.example.gov/credentials/smallholder/v1".to_string(), - validity_seconds: 24 * 60 * 60, - holder_binding: HolderBindingConfig { - mode: "none".to_string(), - proof_of_possession: None, - allowed_did_methods: Vec::new(), - }, - allowed_claims: vec![ - "date-of-birth".into(), - "farmer-under-4ha".into(), - "farmed-land-size".into(), - ], - disclosure: CredentialDisclosureConfig::default(), - } -} - -fn build_issuer() -> EvidenceIssuer { - EvidenceIssuer::from_jwk_str(TEST_ISSUER_JWK, VM_ID.to_string()) - .expect("test JWK must load without error") -} - -fn claim_result(claim_id: &str, value: serde_json::Value) -> ClaimResultView { - ClaimResultView { - evaluation_id: "eval-perf-bench".to_string(), - claim_id: claim_id.to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "farmer".to_string(), - requester_ref: Some(EvidenceEntityRef { - entity_type: "Agency".to_string(), - handle: "rnref:v1:requester-perf-ref".to_string(), - identifier_schemes: vec!["agency_id".to_string()], - profile: Some("benefits".to_string()), - }), - target_ref: TargetRefView { - entity_type: "Farmer".to_string(), - handle: "rnref:v1:target-perf-ref".to_string(), - identifier_schemes: vec!["farmer_id".to_string()], - profile: Some("smallholder".to_string()), - }, - value: Some(value), - satisfied: Some(true), - disclosure: "value".to_string(), - redacted_fields: Vec::new(), - format: registry_notary_core::FORMAT_SD_JWT_VC.to_string(), - issued_at: "2026-01-01T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "bench".to_string(), - "eval-bench".to_string(), - "claim".to_string(), - "1".to_string(), - registry_notary_core::ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - } -} - -fn build_single_claim() -> Vec { - vec![claim_result( - "date-of-birth", - serde_json::json!("1990-01-01"), - )] -} - -fn build_three_claims() -> Vec { - vec![ - claim_result("date-of-birth", serde_json::json!("1990-01-01")), - claim_result("farmer-under-4ha", serde_json::json!(true)), - claim_result("farmed-land-size", serde_json::json!(3.2)), - ] -} - -fn benchmark_evidence_issuer_from_jwk_str(c: &mut Criterion) { - c.bench_function("sd_jwt/evidence_issuer_from_jwk_str", |b| { - b.iter(|| { - // `from_jwk_str` takes the verification-method id by value, so a - // fresh `String` allocation per call is part of the measured path. - let vm_id = black_box(VM_ID).to_string(); - EvidenceIssuer::from_jwk_str(black_box(TEST_ISSUER_JWK), vm_id).expect("JWK must load") - }); - }); -} - -// `issue()` invokes `getrandom::fill` (16 bytes of CSPRNG) once per claim -// disclosure. That entropy cost is unavoidable on the production path and is -// included in the measurement. With 3 claims it accounts for 3 syscalls per -// iteration on Linux, which dominates the noise floor of these benches. -fn benchmark_issue_single_claim(c: &mut Criterion) { - let profile = build_profile(); - let issuer = build_issuer(); - let results = build_single_claim(); - let iat = OffsetDateTime::UNIX_EPOCH; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("bench runtime builds"); - c.bench_function("sd_jwt/issue_single_claim", |b| { - b.iter(|| { - runtime - .block_on(issue( - black_box(&profile), - black_box(&issuer), - black_box(&results), - black_box("bench-subject"), - black_box(None), - black_box(iat), - black_box(IssueOptions::default()), - )) - .expect("issue must succeed") - }); - }); -} - -fn benchmark_issue_three_claims(c: &mut Criterion) { - let profile = build_profile(); - let issuer = build_issuer(); - let results = build_three_claims(); - let iat = OffsetDateTime::UNIX_EPOCH; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("bench runtime builds"); - c.bench_function("sd_jwt/issue_three_claims", |b| { - b.iter(|| { - runtime - .block_on(issue( - black_box(&profile), - black_box(&issuer), - black_box(&results), - black_box("bench-subject"), - black_box(None), - black_box(iat), - black_box(IssueOptions::default()), - )) - .expect("issue must succeed") - }); - }); -} - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_evidence_issuer_from_jwk_str, - benchmark_issue_single_claim, - benchmark_issue_three_claims -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/resources/scalar/api-reference.js b/crates/registry-notary-server/resources/scalar/api-reference.js deleted file mode 100644 index cf1d93a6f..000000000 --- a/crates/registry-notary-server/resources/scalar/api-reference.js +++ /dev/null @@ -1,2364 +0,0 @@ -/** - * _____ _________ __ ___ ____ - * / ___// ____/ | / / / | / __ \ - * \__ \/ / / /| | / / / /| | / /_/ / - * ___/ / /___/ ___ |/ /___/ ___ |/ _, _/ - * /____/\____/_/ |_/_____/_/ |_/_/ |_| - * - * @scalar/api-reference 1.57.1 - * - * Website: https://scalar.com - * GitHub: https://github.com/scalar/scalar - * License: https://github.com/scalar/scalar/blob/main/LICENSE -**/ - -(function(){try{if(typeof document<`u`){var e=document.createElement(`style`);e.appendChild(document.createTextNode(`/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-mask-linear:linear-gradient(#fff, #fff);--tw-mask-radial:linear-gradient(#fff, #fff);--tw-mask-conic:linear-gradient(#fff, #fff);--tw-mask-left:linear-gradient(#fff, #fff);--tw-mask-right:linear-gradient(#fff, #fff);--tw-mask-bottom:linear-gradient(#fff, #fff);--tw-mask-top:linear-gradient(#fff, #fff);--tw-mask-top-from-position:0%;--tw-mask-top-to-position:100%;--tw-mask-top-from-color:black;--tw-mask-top-to-color:transparent;--tw-mask-bottom-from-position:0%;--tw-mask-bottom-to-position:100%;--tw-mask-bottom-from-color:black;--tw-mask-bottom-to-color:transparent;--tw-leading:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}[data-v-b258a15f],[data-v-b258a15f]:before,[data-v-b258a15f]:after,[data-v-b258a15f]::backdrop{--tw-outline-style:solid}}}@layer scalar-base{body{line-height:inherit;margin:0}:root{--scalar-border-width:.5px;--scalar-radius:3px;--scalar-radius-lg:6px;--scalar-radius-xl:8px;--scalar-font:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;--scalar-font-code:"JetBrains Mono", ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;--scalar-heading-1:24px;--scalar-page-description:16px;--scalar-heading-2:20px;--scalar-heading-3:16px;--scalar-heading-4:16px;--scalar-heading-5:16px;--scalar-heading-6:16px;--scalar-paragraph:16px;--scalar-small:14px;--scalar-mini:13px;--scalar-micro:12px;--scalar-bold:600;--scalar-semibold:500;--scalar-regular:400;--scalar-font-size-1:21px;--scalar-font-size-2:16px;--scalar-font-size-3:14px;--scalar-font-size-4:13px;--scalar-font-size-5:12px;--scalar-font-size-6:12px;--scalar-font-size-7:10px;--scalar-line-height-1:32px;--scalar-line-height-2:24px;--scalar-line-height-3:20px;--scalar-line-height-4:18px;--scalar-line-height-5:16px;--scalar-font-normal:400;--scalar-font-medium:500;--scalar-font-bold:700;--scalar-text-decoration:none;--scalar-text-decoration-hover:underline;--scalar-link-font-weight:inherit;--scalar-sidebar-indent:20px;--scalar-sidebar-padding:12px}.dark-mode{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--scalar-scrollbar-color:#ffffff2e;--scalar-scrollbar-color-active:#ffffff5c;--scalar-button-1:#fff;--scalar-button-1-hover:#ffffffe6;--scalar-button-1-color:black;--scalar-shadow-1:0 1px 3px 0 #0000001a;--scalar-shadow-2:0 0 0 .5px var(--scalar-border-color), #0f0f0f33 0px 3px 6px, #0f0f0f66 0px 9px 24px;--scalar-lifted-brightness:1.45;--scalar-backdrop-brightness:.5;--scalar-text-decoration-color:currentColor;--scalar-text-decoration-color-hover:currentColor}.light-mode{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--scalar-scrollbar-color-active:#0000005c;--scalar-scrollbar-color:#0000002e;--scalar-button-1:#000;--scalar-button-1-hover:#000c;--scalar-button-1-color:#ffffffe6;--scalar-shadow-1:0 1px 3px 0 #0000001c;--scalar-shadow-2:#00000014 0px 13px 20px 0px, #00000014 0px 3px 8px 0px, #eeeeed 0px 0 0 .5px;--scalar-lifted-brightness:1;--scalar-backdrop-brightness:1;--scalar-text-decoration-color:currentColor;--scalar-text-decoration-color-hover:currentColor}.light-mode .dark-mode{--lightningcss-light: !important;--lightningcss-dark:initial!important;color-scheme:dark!important}@media (width<=460px){:root{--scalar-font-size-1:22px;--scalar-font-size-2:14px;--scalar-font-size-3:12px}}@media (width<=720px){:root{--scalar-heading-1:24px;--scalar-page-description:20px}}:root{--scalar-text-decoration:underline;--scalar-text-decoration-hover:underline}.light-mode{--scalar-background-1:#fff;--scalar-background-2:#f6f6f6;--scalar-background-3:#e7e7e7;--scalar-background-accent:#8ab4f81f;--scalar-color-1:#1b1b1b;--scalar-color-2:#757575;--scalar-color-3:#8e8e8e;--scalar-color-accent:#09f;--scalar-border-color:#dfdfdf}.dark-mode{--scalar-background-1:#0f0f0f;--scalar-background-2:#1a1a1a;--scalar-background-3:#272727;--scalar-color-1:#e7e7e7;--scalar-color-2:#a4a4a4;--scalar-color-3:#797979;--scalar-color-accent:#00aeff;--scalar-background-accent:#3ea6ff1f;--scalar-border-color:#2d2d2d}.light-mode,.dark-mode{--scalar-sidebar-background-1:var(--scalar-background-1);--scalar-sidebar-color-1:var(--scalar-color-1);--scalar-sidebar-color-2:var(--scalar-color-2);--scalar-sidebar-border-color:var(--scalar-border-color);--scalar-sidebar-item-hover-background:var(--scalar-background-2);--scalar-sidebar-item-hover-color:var(--scalar-sidebar-color-2);--scalar-sidebar-item-active-background:var(--scalar-background-2);--scalar-sidebar-color-active:var(--scalar-sidebar-color-1);--scalar-sidebar-indent-border:var(--scalar-sidebar-border-color);--scalar-sidebar-indent-border-hover:var(--scalar-sidebar-border-color);--scalar-sidebar-indent-border-active:var(--scalar-sidebar-border-color);--scalar-sidebar-search-background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.light-mode,.dark-mode{--scalar-sidebar-search-background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.light-mode,.dark-mode{--scalar-sidebar-search-color:var(--scalar-color-3);--scalar-sidebar-search-border-color:var(--scalar-border-color)}.light-mode{--scalar-color-green:#069061;--scalar-color-red:#ef0006;--scalar-color-yellow:#edbe20;--scalar-color-blue:#0082d0;--scalar-color-orange:#ff5800;--scalar-color-purple:#5203d1;--scalar-link-color:var(--scalar-color-1);--scalar-link-color-hover:var(--scalar-link-color);--scalar-button-1:#000;--scalar-button-1-hover:#000c;--scalar-button-1-color:#ffffffe6;--scalar-tooltip-background:#1a1a1ae6;--scalar-tooltip-color:#ffffffd9;--scalar-color-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-color-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-color-1) 20%)}}.light-mode{--scalar-color-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-color-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-color-1) 20%)}}.light-mode{--scalar-background-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-background-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-background-1) 95%)}}.light-mode{--scalar-background-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-background-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.dark-mode{--scalar-color-green:#00b648;--scalar-color-red:#dc1b19;--scalar-color-yellow:#ffc90d;--scalar-color-blue:#4eb3ec;--scalar-color-orange:#ff8d4d;--scalar-color-purple:#b191f9;--scalar-link-color:var(--scalar-color-1);--scalar-link-color-hover:var(--scalar-link-color);--scalar-button-1:#fff;--scalar-button-1-hover:#ffffffe6;--scalar-button-1-color:black;--scalar-tooltip-background:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-tooltip-background:color-mix(in srgb, var(--scalar-background-1), #fff 10%)}}.dark-mode{--scalar-tooltip-color:#fffffff2;--scalar-color-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-color-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 20%)}}.dark-mode{--scalar-background-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-background-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-background-1) 95%)}}.dark-mode{--scalar-background-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-background-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}@supports (color:color(display-p3 1 1 1)){.light-mode{--scalar-color-accent:color(display-p3 0 .6 1);--scalar-color-green:color(display-p3 .023529 .564706 .380392);--scalar-color-red:color(display-p3 .937255 0 .023529);--scalar-color-yellow:color(display-p3 .929412 .745098 .12549);--scalar-color-blue:color(display-p3 0 .509804 .815686);--scalar-color-orange:color(display-p3 1 .4 .02);--scalar-color-purple:color(display-p3 .321569 .011765 .819608)}.dark-mode{--scalar-color-accent:color(display-p3 .07 .67 1);--scalar-color-green:color(display-p3 0 .713725 .282353);--scalar-color-red:color(display-p3 .862745 .105882 .098039);--scalar-color-yellow:color(display-p3 1 .788235 .05098);--scalar-color-blue:color(display-p3 .305882 .701961 .92549);--scalar-color-orange:color(display-p3 1 .552941 .301961);--scalar-color-purple:color(display-p3 .694118 .568627 .976471)}}:root,:host{--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}body{background-color:var(--scalar-background-1);margin:0}}@layer scalar-theme;:where(.scalar-app){font-family:var(--scalar-font);color:var(--scalar-color-1);-webkit-text-size-adjust:100%;tab-size:4;line-height:1.15}:where(.scalar-app) *,:where(.scalar-app) :before,:where(.scalar-app) :after{box-sizing:border-box;border-style:solid;border-width:0;border-color:var(--scalar-border-color);outline-width:1px;outline-style:none;outline-color:var(--scalar-color-accent);font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;font-style:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit;text-align:inherit;line-height:inherit;color:inherit;margin:unset;padding:unset;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(.scalar-app) :before,:where(.scalar-app) :after{--tw-content:""}:where(.scalar-app) button,:where(.scalar-app) input,:where(.scalar-app) optgroup,:where(.scalar-app) select,:where(.scalar-app) textarea{background:0 0}:where(.scalar-app) ::file-selector-button{background:0 0}:where(.scalar-app) ol,:where(.scalar-app) ul,:where(.scalar-app) menu{list-style:none}:where(.scalar-app) input:where(:not([type=button],[type=reset],[type=submit])),:where(.scalar-app) select,:where(.scalar-app) textarea{border-radius:var(--scalar-radius);border-width:1px}:where(.scalar-app) input::placeholder{color:var(--scalar-color-3);font-family:var(--scalar-font)}:where(.scalar-app) input[type=search]::-webkit-search-cancel-button{appearance:none}:where(.scalar-app) input[type=search]::-webkit-search-decoration{appearance:none}:where(.scalar-app) summary::-webkit-details-marker{display:none}:where(.scalar-app) input:-webkit-autofill{-webkit-background-clip:text!important;background-clip:text!important}:where(.scalar-app) :focus-visible{border-radius:var(--scalar-radius);outline-style:solid}:where(.scalar-app) button:focus-visible,:where(.scalar-app) [role=button]:focus-visible{outline-offset:-1px}:where(.scalar-app) button,:where(.scalar-app) [role=button]{cursor:pointer}:where(.scalar-app) :disabled{cursor:default}:where(.scalar-app) img,:where(.scalar-app) svg,:where(.scalar-app) video,:where(.scalar-app) canvas,:where(.scalar-app) audio,:where(.scalar-app) iframe,:where(.scalar-app) embed,:where(.scalar-app) object{vertical-align:middle;display:block}:where(.scalar-app) [hidden]{display:none}.scalar-app .cm-scroller,.scalar-app .custom-scroll{scrollbar-color:transparent transparent;scrollbar-width:thin;-webkit-overflow-scrolling:touch;overflow-y:auto}.scalar-app .custom-scroll-self-contain-overflow{overscroll-behavior:contain}.scalar-app .cm-scroller:hover,.scalar-app .custom-scroll:hover,.scalar-app.scalar-scrollbars-obtrusive .cm-scroller,.scalar-app.scalar-scrollbars-obtrusive .custom-scroll{scrollbar-color:var(--scalar-scrollbar-color,transparent) transparent}.scalar-app .cm-scroller:hover::-webkit-scrollbar-thumb{background:var(--scalar-scrollbar-color);background-clip:content-box;border:3px solid #0000}.scalar-app .custom-scroll:hover::-webkit-scrollbar-thumb{background:var(--scalar-scrollbar-color);background-clip:content-box;border:3px solid #0000}.scalar-app .cm-scroller::-webkit-scrollbar-thumb:active{background:var(--scalar-scrollbar-color-active);background-clip:content-box;border:3px solid #0000}.scalar-app .custom-scroll::-webkit-scrollbar-thumb:active{background:var(--scalar-scrollbar-color-active);background-clip:content-box;border:3px solid #0000}.scalar-app .cm-scroller::-webkit-scrollbar-corner{background:0 0}.scalar-app .custom-scroll::-webkit-scrollbar-corner{background:0 0}.scalar-app .cm-scroller::-webkit-scrollbar{width:12px;height:12px}.scalar-app .custom-scroll::-webkit-scrollbar{width:12px;height:12px}.scalar-app .cm-scroller::-webkit-scrollbar-track{background:0 0}.scalar-app .custom-scroll::-webkit-scrollbar-track{background:0 0}.scalar-app .cm-scroller::-webkit-scrollbar-thumb{background:padding-box content-box;border:3px solid #0000;border-radius:20px}.scalar-app .custom-scroll::-webkit-scrollbar-thumb{background:padding-box content-box;border:3px solid #0000;border-radius:20px}@media (pointer:coarse){.scalar-app .cm-scroller,.scalar-app .custom-scroll{padding-right:12px}}:where(.scalar-app) [class*=rotate-],:where(.scalar-app) [class*=translate-],:where(.scalar-app) [class*=scale-]{transform:none}.loader-wrapper[data-v-27df5cd8]{--loader-size:50%;justify-content:center;align-items:center;display:flex;position:relative}.svg-loader[data-v-27df5cd8]{width:var(--loader-size);height:var(--loader-size);fill:none;stroke:currentColor;background-color:#0000;top:1rem;right:.9rem;overflow:visible}.svg-path[data-v-27df5cd8]{stroke-width:12px;fill:none;transition:all .3s}.svg-x-mark[data-v-27df5cd8]{stroke-dasharray:57;stroke-dashoffset:57px;transition-delay:0s}.svg-check-mark[data-v-27df5cd8]{stroke-dasharray:149;stroke-dashoffset:149px;transition-delay:0s}.icon-is-invalid .svg-x-mark[data-v-27df5cd8],.icon-is-valid .svg-check-mark[data-v-27df5cd8]{stroke-dashoffset:0;transition-delay:.3s}.circular-loader[data-v-27df5cd8]{transform-origin:50%;background:0 0;animation:.7s linear infinite rotate-27df5cd8,.4s fade-in-27df5cd8;transform:scale(3.5)}.loader-path[data-v-27df5cd8]{stroke-dasharray:50 200;stroke-dashoffset:-100px;stroke-linecap:round}.loader-path-off[data-v-27df5cd8]{stroke-dasharray:50 200;stroke-dashoffset:-100px;opacity:0;transition:opacity .3s}@keyframes fade-in-27df5cd8{0%{opacity:0}70%{opacity:0}to{opacity:1}}@keyframes rotate-27df5cd8{0%{transform:scale(3.5)rotate(0)}to{transform:scale(3.5)rotate(360deg)}}.scalar-code-block.bg-b-1 .scalar-code-copy-backdrop{background-color:var(--scalar-background-1)}.scalar-code-block.bg-b-2 .scalar-code-copy-backdrop{background-color:var(--scalar-background-2)}.scalar-code-block.bg-b-2 .scalar-code-copy{background-color:var(--scalar-background-3)}.toggle-icon-ellipse[data-v-60be8692]{background:var(--scalar-background-1);border-radius:50%;width:7px;height:7px;transition:width .3s ease-in-out,height .3s ease-in-out;display:inline-block;position:relative;overflow:hidden;box-shadow:inset 0 0 0 1px}.toggle-icon-moon-mask[data-v-60be8692]{background:var(--scalar-background-1);border:1px solid;border-radius:50%;width:100%;height:100%;transition:transform .3s ease-in-out;display:block;position:absolute;bottom:2.5px;left:2.5px;transform:translate(4px,-4px)}.toggle-icon-sun-ray[data-v-60be8692]{background:currentColor;border-radius:8px;width:12px;height:1px;transition:transform .3s ease-in-out;position:absolute}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(2){transform:rotate(90deg)}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(3){transform:rotate(45deg)}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(4){transform:rotate(-45deg)}.toggle-icon-dark .toggle-icon-ellipse[data-v-60be8692]{width:10px;height:10px;-webkit-mask-image:radial-gradient(circle at 0 100%,pink 10px,#0000 12px);mask-image:radial-gradient(circle at 0 100%,pink 10px,#0000 12px)}.toggle-icon-dark .toggle-icon-sun-ray[data-v-60be8692]{transform:scale(0)}.toggle-icon-dark .toggle-icon-moon-mask[data-v-60be8692]{transform:translate(0)}.dark-mode .scalar-dropdown-item[data-v-f5e0d3d8]:hover,.dark-mode .scalar-dropdown-item[data-highlighted][data-v-f5e0d3d8],.dark-mode .scalar-dropdown-item[data-v-3402682d]:hover{filter:brightness(1.1)}.scalar-icon[data-v-b651bb23],.scalar-icon[data-v-b651bb23] *{stroke-width:var(--c07589c2)}.scalar-app :where(code.hljs) *{font-size:inherit;font-family:var(--scalar-font-code);text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;tab-size:4;line-height:1.4}.scalar-app code.hljs{all:unset;font-size:inherit;color:var(--scalar-color-2);font-family:var(--scalar-font-code);counter-reset:linenumber}.scalar-app .hljs{color:var(--scalar-color-2);background:0 0}.scalar-app .hljs .line:before{color:var(--scalar-color-3);counter-increment:linenumber;content:counter(linenumber);min-width:calc(var(--line-digits) * 1ch);text-align:right;margin-right:.875rem;display:inline-block}.scalar-app .hljs-comment,.scalar-app .hljs-quote{color:var(--scalar-color-3);font-style:italic}.scalar-app .hljs-number{color:var(--scalar-color-orange)}.scalar-app .hljs-regexp,.scalar-app .hljs-string,.scalar-app .hljs-built_in{color:var(--scalar-color-blue)}.scalar-app .hljs-title.class_{color:var(--scalar-color-1)}.scalar-app .hljs-keyword{color:var(--scalar-color-purple)}.scalar-app .hljs-title.function_{color:var(--scalar-color-orange)}.scalar-app .hljs-subst,.scalar-app .hljs-name{color:var(--scalar-color-blue)}.scalar-app .hljs-attr,.scalar-app .hljs-attribute{color:var(--scalar-color-1)}.scalar-app .hljs-addition,.scalar-app .hljs-literal,.scalar-app .hljs-selector-tag,.scalar-app .hljs-type{color:var(--scalar-color-green)}.scalar-app .hljs-selector-attr,.scalar-app .hljs-selector-pseudo{color:var(--scalar-color-orange)}.scalar-app .hljs-doctag,.scalar-app .hljs-section,.scalar-app .hljs-title{color:var(--scalar-color-blue)}.scalar-app .hljs-selector-id,.scalar-app .hljs-template-variable,.scalar-app .hljs-variable{color:var(--scalar-color-1)}.scalar-app .hljs-name,.scalar-app .hljs-section,.scalar-app .hljs-strong{font-weight:var(--scalar-semibold)}.scalar-app .hljs-bullet,.scalar-app .hljs-link,.scalar-app .hljs-meta,.scalar-app .hljs-symbol{color:var(--scalar-color-blue)}.scalar-app .hljs-deletion{color:var(--scalar-color-red)}.scalar-app .hljs-formula{background:var(--scalar-color-1)}.scalar-app .hljs-emphasis{font-style:italic}.scalar-app .credential .credential-value{color:#0000;font-size:0}.scalar-app .credential:after{content:"·····";color:var(--scalar-color-3);-webkit-user-select:none;user-select:none}.hljs.language-html{color:var(--scalar-color-1)}.hljs.language-html .hljs-attr{color:var(--scalar-color-2)}.hljs.language-curl .hljs-string{color:var(--scalar-color-blue)}.hljs.language-curl .hljs-literal{color:var(--scalar-color-1)}.hljs.language-php .hljs-variable{color:var(--scalar-color-blue)}.hljs.language-objectivec .hljs-meta{color:var(--scalar-color-1)}.hljs.language-objectivec .hljs-built_in,.hljs-built_in{color:var(--scalar-color-orange)}.scalar-app .markdown{--scalar-refs-heading-spacing:24px;--markdown-border:var(--scalar-border-width) solid var(--scalar-border-color);--markdown-spacing-sm:12px;--markdown-spacing-md:16px;--markdown-line-height:1.625;--markdown-heading-line-height:1.15;font-family:var(--scalar-font);word-break:break-word;line-height:var(--markdown-line-height)}.scalar-app .markdown>*{margin-bottom:var(--markdown-spacing-md)}.scalar-app .markdown>:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):last-child{margin-bottom:0}.scalar-app .markdown h1,.scalar-app .markdown h2,.scalar-app .markdown h3,.scalar-app .markdown h4,.scalar-app .markdown h5,.scalar-app .markdown h6{font-weight:var(--scalar-bold);margin-top:var(--scalar-refs-heading-spacing);margin-bottom:var(--markdown-spacing-sm);line-height:var(--markdown-heading-line-height,1.15);scroll-margin-top:1rem;display:block}.scalar-app .markdown h1{font-size:1.5rem}.scalar-app .markdown h2,.scalar-app .markdown h3{font-size:1.25rem}.scalar-app .markdown h4,.scalar-app .markdown h5,.scalar-app .markdown h6{font-size:1rem}.scalar-app .markdown b,.scalar-app .markdown strong{font-weight:var(--scalar-bold)}.scalar-app .markdown p{color:inherit;line-height:var(--markdown-line-height);display:block}.scalar-app .markdown img{border-radius:var(--scalar-radius);max-width:100%;display:inline-block;overflow:hidden}.scalar-app .markdown ul,.scalar-app .markdown ol{line-height:var(--markdown-line-height);flex-direction:column;gap:2px;padding-left:1.6em;display:flex}.scalar-app .markdown li{margin-top:2px;padding-left:7px}.scalar-app ol>li::marker{font:var(--scalar-font);font-variant-numeric:tabular-nums;font-weight:var(--scalar-semibold);white-space:nowrap}.scalar-app ol>*>li::marker{font:var(--scalar-font);font-variant-numeric:tabular-nums;font-weight:var(--scalar-semibold);white-space:nowrap}.scalar-app .markdown ol{list-style-type:decimal}.scalar-app .markdown ol ol{list-style-type:lower-alpha}.scalar-app .markdown ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol{list-style-type:decimal}.scalar-app .markdown ol ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol ol{list-style-type:lower-alpha}.scalar-app .markdown ol ol ol,.scalar-app .markdown ol ol ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol ol ol{list-style-type:lower-roman}.scalar-app .markdown ul>li,.scalar-app .markdown ul>*>li{list-style-type:disc}.scalar-app .markdown table{table-layout:fixed;border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);border-spacing:0;width:max-content;max-width:100%;margin:1em 0;display:table;position:relative;overflow-x:auto}.scalar-app .markdown tbody,.scalar-app .markdown thead{vertical-align:middle}.scalar-app .markdown tbody{display:table-row-group}.scalar-app .markdown thead{display:table-header-group}.scalar-app .markdown tr{border-color:inherit;vertical-align:inherit;display:table-row}.scalar-app .markdown td,.scalar-app .markdown th{vertical-align:top;min-width:1em;line-height:var(--markdown-line-height);word-break:break-word;font-size:var(--scalar-small);color:var(--scalar-color-1);border-right:var(--markdown-border);border-bottom:var(--markdown-border);padding:8.5px 16px;display:table-cell;position:relative}.scalar-app .markdown td>*,.scalar-app .markdown th>*{margin-bottom:0}.scalar-app .markdown th:empty{display:none}.scalar-app .markdown td:first-of-type,.scalar-app .markdown th:first-of-type{border-left:none}.scalar-app .markdown td:last-of-type,.scalar-app .markdown th:last-of-type{border-right:none}.scalar-app .markdown tr:last-of-type td{border-bottom:none}.scalar-app .markdown th{font-weight:var(--scalar-bold);text-align:left;background:var(--scalar-background-2);border-left-color:#0000}.scalar-app .markdown th:first-of-type{border-top-left-radius:var(--scalar-radius)}.scalar-app .markdown th:last-of-type{border-top-right-radius:var(--scalar-radius)}.scalar-app .markdown tr>[align=left]{text-align:left}.scalar-app .markdown tr>[align=right]{text-align:right}.scalar-app .markdown tr>[align=center]{text-align:center}.scalar-app .markdown details{border:var(--markdown-border);border-radius:var(--scalar-radius-xl);color:var(--scalar-color-1)}.scalar-app .markdown details>:not(summary){margin:var(--markdown-spacing-md);margin-bottom:0}.scalar-app .markdown details>p:has(>strong):not(:has(:not(strong))){margin-bottom:8px}.scalar-app .markdown details>p:has(>strong):not(:has(:not(strong)))+*{margin-top:0}.scalar-app .markdown details>table{width:calc(100% - calc(var(--markdown-spacing-md) * 2))}.scalar-app .markdown summary{min-height:40px;font-weight:var(--scalar-semibold);line-height:var(--markdown-line-height);cursor:pointer;-webkit-user-select:none;user-select:none;border-radius:2.5px;align-items:flex-start;gap:8px;padding:7px 14px;display:flex;position:relative}.scalar-app .markdown summary:hover{background-color:var(--scalar-background-2)}.scalar-app .markdown details[open]{padding-bottom:var(--markdown-spacing-md)}.scalar-app .markdown details[open]>summary{border-bottom:var(--markdown-border);border-bottom-right-radius:0;border-bottom-left-radius:0}.scalar-app .markdown summary:before{content:"";width:var(--markdown-spacing-md);height:var(--markdown-spacing-md);background-color:var(--scalar-color-3);flex-shrink:0;margin-top:5px;display:block;-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown summary:hover:before{background-color:var(--scalar-color-1)}.scalar-app .markdown details[open]>summary:before{transition:transform .1s ease-in-out;transform:rotate(90deg)}.scalar-app .markdown details:has(+details){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0;margin-bottom:0}.scalar-app .markdown details:has(+details)+details{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .markdown details:has(+details)+details>summary{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .markdown a{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown a{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.scalar-app .markdown a:hover{-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);text-decoration-color:var(--scalar-color-1,currentColor);color:var(--scalar-link-color-hover,var(--scalar-color-accent));-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);text-decoration:var(--scalar-text-decoration-hover)}.scalar-app .markdown a:visited{color:var(--font-visited)}.scalar-app .markdown em{font-style:italic}.scalar-app .markdown sup,.scalar-app .markdown sub{font-size:var(--scalar-micro);font-weight:450}.scalar-app .markdown sup{vertical-align:super}.scalar-app .markdown sub{vertical-align:sub}.scalar-app .markdown del{text-decoration:line-through}.scalar-app .markdown code{font-family:var(--scalar-font-code);background-color:var(--scalar-background-2);box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color);font-size:var(--scalar-micro);border-radius:2px;padding:0 3px}.scalar-app .markdown .hljs{font-size:var(--scalar-small)}.scalar-app .markdown pre code{white-space:pre;padding:var(--markdown-spacing-sm);margin:var(--markdown-spacing-sm) 0;-webkit-overflow-scrolling:touch;min-width:100px;max-width:100%;line-height:1.5;display:block;overflow-x:auto}.scalar-app .markdown hr{border:none;border-bottom:var(--markdown-border)}.scalar-app .markdown blockquote{border-left:2px solid var(--scalar-border-color);padding-left:var(--markdown-spacing-sm)}.scalar-app .markdown blockquote>*{margin-bottom:var(--markdown-spacing-sm)}.scalar-app .markdown li.task-list-item{list-style:none;position:relative}.scalar-app .markdown li.task-list-item>input{appearance:none;width:var(--markdown-spacing-md);height:var(--markdown-spacing-md);border:1px solid var(--scalar-color-3);border-radius:var(--scalar-radius);display:inline;position:absolute;top:.225em;left:-1.4em}.scalar-app .markdown li.task-list-item>input[type=checkbox]:checked{background-color:var(--scalar-color-1);border-color:var(--scalar-color-1)}.scalar-app .markdown li.task-list-item>input[type=checkbox]:before{content:"";border:solid var(--scalar-background-1);opacity:0;border-width:0 1.5px 1.5px 0;width:5px;height:10px;position:absolute;top:1px;left:5px;transform:rotate(45deg)}.scalar-app .markdown li.task-list-item>input[type=checkbox]:checked:before{opacity:1}.scalar-app .markdown .markdown-alert{border-radius:var(--scalar-radius);background-color:var(--scalar-background-2);align-items:stretch}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert{background-color:color-mix(in srgb, var(--scalar-background-2), transparent)}}.scalar-app .markdown .markdown-alert{border:var(--markdown-border);gap:var(--markdown-spacing-sm);padding:10px 14px;display:flex;position:relative}.scalar-app .markdown .markdown-alert .markdown-alert-icon:before{content:"";background-color:currentColor;flex-shrink:0;width:18px;height:18px;margin-top:3px;display:block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.scalar-app .markdown .markdown-alert.markdown-alert-note{background-color:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-note{background-color:color-mix(in srgb, var(--scalar-color-blue), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-note{border:var(--scalar-border-width) solid var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-note{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-blue), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-tip{background-color:var(--scalar-color-2)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-tip{background-color:color-mix(in srgb, var(--scalar-color-2), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-tip{border:var(--scalar-border-width) solid var(--scalar-color-2)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-tip{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-2), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-note .markdown-alert-icon:before,.scalar-app .markdown .markdown-alert.markdown-alert-tip .markdown-alert-icon:before{color:var(--scalar-color-blue);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{background-color:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{background-color:color-mix(in srgb, var(--scalar-color-orange), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{border:var(--scalar-border-width) solid var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-orange), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-important .markdown-alert-icon:before,.scalar-app .markdown .markdown-alert.markdown-alert-warning .markdown-alert-icon:before{-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-caution{background-color:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-caution{background-color:color-mix(in srgb, var(--scalar-color-red), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-caution{border:var(--scalar-border-width) solid var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-caution{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-red), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-caution .markdown-alert-icon:before{color:var(--scalar-color-red);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-success{background-color:var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-success{background-color:color-mix(in srgb, var(--scalar-color-green), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-success{border:var(--scalar-border-width) solid var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-success{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-green), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-success .markdown-alert-icon:before{color:var(--scalar-color-green);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-note .markdown-alert-icon:before{color:var(--scalar-color-blue)}.scalar-app .markdown .markdown-alert.markdown-alert-tip .markdown-alert-icon:before{color:var(--scalar-color-2)}.scalar-app .markdown .markdown-alert.markdown-alert-important .markdown-alert-icon:before{color:var(--scalar-color-purple)}.scalar-app .markdown .markdown-alert.markdown-alert-warning .markdown-alert-icon:before{color:var(--scalar-color-orange)}.scalar-app .markdown .markdown-alert .markdown-alert-content{line-height:var(--markdown-line-height);margin:0}.scalar-app .markdown.markdown-summary.markdown-summary :before,.scalar-app .markdown.markdown-summary.markdown-summary :after{content:none}.scalar-app .markdown.markdown-summary.markdown-summary :not(strong,em,a){font-size:inherit;font-weight:inherit;line-height:var(--markdown-line-height);display:contents}.scalar-app .markdown.markdown-summary.markdown-summary img,.scalar-app .markdown.markdown-summary.markdown-summary svg,.scalar-app .markdown.markdown-summary.markdown-summary hr,.scalar-app .markdown.markdown-summary.markdown-summary pre{display:none}.scalar-modal-layout[data-v-5bb1dcc2]{animation:.3s ease-in-out forwards fadein-layout-5bb1dcc2}.scalar-modal[data-v-5bb1dcc2]{box-shadow:var(--scalar-shadow-2);animation:.3s ease-in-out .1s forwards fadein-modal-5bb1dcc2;transform:translateY(10px)}.scalar-modal-layout-full[data-v-5bb1dcc2]{opacity:1!important;background:0 0!important}.modal-content-search .modal-body[data-v-5bb1dcc2]{flex-direction:column;max-height:440px;padding:0;display:flex;overflow:hidden}@media (width<=720px) and (height<=480px){.scalar-modal-layout .scalar-modal[data-v-5bb1dcc2]{max-height:90svh;margin-top:5svh}}@keyframes fadein-layout-5bb1dcc2{0%{opacity:0}to{opacity:1}}@keyframes fadein-modal-5bb1dcc2{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translate(0)}}.full-size-styles[data-v-5bb1dcc2]{margin:initial;border-right:var(--scalar-border-width) solid var(--scalar-border-color);animation:.3s ease-in-out forwards fadein-layout-5bb1dcc2;left:0;transform:translate(0);background-color:var(--scalar-background-1)!important;max-height:100%!important;box-shadow:none!important;border-radius:0!important;position:absolute!important;top:0!important}@media (width>=800px){.full-size-styles[data-v-5bb1dcc2]{width:50dvw!important}}.full-size-styles[data-v-5bb1dcc2]:after{content:"";width:50dvw;height:100dvh;position:absolute;top:0;right:-50dvw}.group\\/item>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{inset-block:-1px}.group\\/item:first-child>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{top:0}.group\\/item:last-child>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{bottom:0}.group\\/item:last-of-type>.group\\/button>.group\\/button-label>.group\\/button-loading{width:66.6667%}.group\\/items.-translate-x-full .group\\/button{transition-behavior:allow-discrete;max-height:0;transition-property:display,max-height;transition-duration:0s;transition-delay:.3s;display:none}.group\\/item.group\\/nested-items-open>*>.group\\/items.translate-x-0 .group\\/button{max-height:3.40282e38px;display:flex}.animate-sidebar-border-bottom{animation:forwards border-bottom;animation-timeline:scroll();animation-range-end:1px}@keyframes border-bottom{0%{border-bottom-width:0}to{border-bottom-width:var(--scalar-border-width)}}.group\\/sidebar-section:first-of-type>.group\\/spacer-before,.group\\/sidebar-section:last-of-type>.group\\/spacer-after{height:0}.group\\/sidebar-section:has(+.group\\/sidebar-section)>.group\\/spacer-after{height:0;margin-bottom:-1px}:where(body)>.scalar-tooltip{--scalar-tooltip-padding:8px;padding:calc(var(--scalar-tooltip-padding) + var(--scalar-tooltip-offset));z-index:99999;max-width:320px;font-size:var(--scalar-font-size-5);--tw-font-weight:var(--scalar-semibold);line-height:16px;font-weight:var(--scalar-semibold);overflow-wrap:break-word;color:var(--scalar-tooltip-color)}:where(body)>.scalar-tooltip:before{content:"";inset:var(--scalar-tooltip-offset);z-index:calc(1 * -1);border-radius:var(--scalar-radius);background-color:var(--scalar-tooltip-background);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );position:absolute}:where(body.dark-mode)>.scalar-tooltip:before{--tw-shadow:inset 0 0 0 var(--tw-shadow-color,var(--scalar-border-width)) var(--scalar-border-color);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}.scalar-virtual-text-search-input[data-v-95edadbb]::-webkit-search-cancel-button{display:none}.scalar-virtual-text[data-v-b50a85e1]:focus{outline:none}.scalar-virtual-text-highlight[data-v-b50a85e1]{background:var(--scalar-background-accent);color:inherit;border-radius:2px;padding:1px 0}.scalar-virtual-text-highlight-active[data-v-b50a85e1]{background:var(--scalar-color-accent);color:var(--scalar-background-1);border-radius:2px}.sidebar-heading-type[data-v-1857170e]{text-transform:uppercase;color:var(--method-color,var(--scalar-color-1));font-size:10px;line-height:14px;font-weight:var(--scalar-bold);font-family:var(--scalar-font-code);white-space:nowrap;flex-shrink:0;align-items:center;gap:4px;display:inline-flex;overflow:hidden}.http-bg-gradient[data-v-7a19f363]{background:linear-gradient(#ffffffbf,#00000009)}.http-bg-gradient[data-v-7a19f363]:hover{background:linear-gradient(#00000009,#ffffffbf)}.dark-mode .http-bg-gradient[data-v-7a19f363]{background:linear-gradient(#ffffff09,#00000026)}.dark-mode .http-bg-gradient[data-v-7a19f363]:hover{background:linear-gradient(#00000026,#ffffff09)}.ascii-art-animate .ascii-art-line[data-v-9a695e58]{border-right:1ch solid #0000;animation:4s step-end 1s both typewriter-9a695e58,.5s step-end infinite blinkTextCursor-9a695e58}@keyframes typewriter-9a695e58{0%{width:0}to{width:100%}}@keyframes blinkTextCursor-9a695e58{0%{border-right-color:currentColor}50%{border-right-color:#0000}}.open-api-client-button[data-v-264dd42f]{cursor:pointer;text-align:center;white-space:nowrap;width:100%;height:31px;font-size:var(--scalar-small);border-radius:var(--scalar-radius);box-shadow:0 0 0 .5px var(--scalar-border-color);color:var(--scalar-sidebar-color-1);justify-content:center;align-items:center;gap:6px;padding:9px 12px;line-height:1.385;text-decoration:none;display:flex}.open-api-client-button[data-v-264dd42f]:hover{background:var(--scalar-sidebar-item-hover-background,var(--scalar-background-2))}.address-bar-history-button[data-v-2cc840c9]:hover{background:var(--scalar-background-3)}.address-bar-history-button[data-v-2cc840c9]:focus-within{background:var(--scalar-background-2)}[data-v-3118bf46] .cm-editor{background:0 0;outline:none;height:100%;padding:0}[data-v-3118bf46] .cm-placeholder{color:var(--scalar-color-3)}[data-v-3118bf46] .cm-content{font-family:var(--scalar-font-code);font-size:var(--scalar-small);max-height:20px;padding:8px 0}[data-v-3118bf46] .cm-tooltip{filter:brightness(var(--scalar-lifted-brightness));border-radius:var(--scalar-radius);box-shadow:var(--scalar-shadow-2);background:0 0!important;border:none!important;outline:none!important;overflow:hidden!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li{padding:3px 6px!important}[data-v-3118bf46] .cm-completionIcon-type:after{color:var(--scalar-color-3)!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li[aria-selected]{background:var(--scalar-background-2)!important;color:var(--scalar-color-1)!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul{position:relative;padding:6px!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li:hover{border-radius:3px;color:var(--scalar-color-1)!important;background:var(--scalar-background-3)!important}[data-v-3118bf46] .cm-activeLine,[data-v-3118bf46] .cm-activeLineGutter{background-color:#0000}[data-v-3118bf46] .cm-selectionMatch,[data-v-3118bf46] .cm-matchingBracket{border-radius:var(--scalar-radius);background:var(--scalar-background-4)!important}[data-v-3118bf46] .cm-css-color-picker-wrapper{outline:1px solid var(--scalar-background-3);border-radius:3px;display:inline-flex;overflow:hidden}[data-v-3118bf46] .cm-gutters{color:var(--scalar-color-3);font-size:var(--scalar-small);background-color:#0000;border-right:none;border-radius:0 0 0 3px;line-height:22px}[data-v-3118bf46] .cm-gutters:before{content:"";border-radius:var(--scalar-radius) 0 0 var(--scalar-radius);background-color:var(--scalar-background-1);width:calc(100% - 2px);height:calc(100% - 4px);position:absolute;top:2px;left:2px}[data-v-3118bf46] .cm-gutterElement{justify-content:flex-end;align-items:center;display:flex;position:relative;font-family:var(--scalar-font-code)!important;padding-left:0!important;padding-right:6px!important}[data-v-3118bf46] .cm-lineNumbers .cm-gutterElement{min-width:fit-content}[data-v-3118bf46] .cm-gutter+.cm-gutter :not(.cm-foldGutter) .cm-gutterElement{padding-left:0!important}[data-v-3118bf46] .cm-scroller{overflow:auto}.line-wrapping[data-v-3118bf46]:focus-within .cm-content{white-space:break-spaces;word-break:break-all;min-height:fit-content;padding:3px 6px;display:inline-table}.cm-pill{font-size:var(--scalar-small);border-radius:30px;padding:0 9px;display:inline-block;color:var(--scalar-color-1)!important}.light-mode .cm-pill{background:var(--scalar-background-3)!important}.dark-mode .cm-pill{background:var(--tw-bg-base)!important}@supports (color:color-mix(in lab, red, red)){.dark-mode .cm-pill{background:color-mix(in srgb, var(--tw-bg-base), transparent 90%)!important}}.cm-pill--context-fn{border:1px dashed var(--scalar-color-3)}@supports (color:color-mix(in lab, red, red)){.cm-pill--context-fn{border:1px dashed color-mix(in srgb, var(--scalar-color-3), transparent 35%)}}.cm-pill--context-fn{padding:0 8px}.light-mode .cm-pill--context-fn{background:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.light-mode .cm-pill--context-fn{background:color-mix(in srgb, var(--scalar-background-3), transparent 40%)!important}}.dark-mode .cm-pill--context-fn{background:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.dark-mode .cm-pill--context-fn{background:color-mix(in srgb, var(--scalar-background-3), transparent 55%)!important}}.cm-pill:first-of-type{margin-left:0}.cm-editor .cm-widgetBuffer{display:none}.cm-foldPlaceholder:hover{color:var(--scalar-color-1)}.cm-foldGutter .cm-gutterElement{font-size:var(--scalar-heading-4);padding:2px!important}.cm-foldGutter .cm-gutterElement:first-of-type{display:none}.cm-foldGutter .cm-gutterElement .cm-foldMarker{padding:2px}.cm-foldGutter .cm-gutterElement:hover .cm-foldMarker{background:var(--scalar-background-2);border-radius:var(--scalar-radius);color:var(--scalar-color-1)}.description[data-v-1b7a32a4] .markdown{font-weight:var(--scalar-semibold);color:var(--scalar-color--1);padding:0;display:block}.description[data-v-1b7a32a4] .markdown>:first-child{margin-top:0}[data-v-4d85ebb9] .cm-editor{outline:none;width:100%;height:100%}[data-v-4d85ebb9] .cm-line{padding:0}[data-v-4d85ebb9] .cm-content{font-size:var(--scalar-small);align-items:center;padding:0;display:flex}.scroll-timeline-x[data-v-4d85ebb9]{-ms-overflow-style:none;-webkit-mask-image:linear-gradient(90deg,#0000 0,#000 6px calc(100% - 24px),#0000 100%);mask-image:linear-gradient(90deg,#0000 0,#000 6px calc(100% - 24px),#0000 100%)}.scroll-timeline-x-hidden[data-v-4d85ebb9]{overflow-x:auto}.scroll-timeline-x-hidden[data-v-4d85ebb9] .cm-scroller{scrollbar-width:none;-ms-overflow-style:none;padding-right:20px;overflow:auto}.scroll-timeline-x-hidden[data-v-4d85ebb9]::-webkit-scrollbar{width:0;height:0;display:none}.scroll-timeline-x-hidden[data-v-4d85ebb9] .cm-scroller::-webkit-scrollbar{width:0;height:0;display:none}.scroll-timeline-x-address[data-v-4d85ebb9]{scrollbar-width:none;line-height:27px}.scroll-timeline-x-address[data-v-4d85ebb9]:after{content:"";cursor:text;width:24px;height:100%;position:absolute;right:0}.scroll-timeline-x-address[data-v-4d85ebb9]:empty:before{content:"Enter URL or cURL request";color:var(--scalar-color-3);pointer-events:none}.address-bar-bg-states[data-v-4d85ebb9]{--scalar-address-bar-bg:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.address-bar-bg-states[data-v-4d85ebb9]{--scalar-address-bar-bg:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.address-bar-bg-states[data-v-4d85ebb9]{background:var(--scalar-address-bar-bg)}.address-bar-bg-states[data-v-4d85ebb9]:has(.cm-focused){--scalar-address-bar-bg:var(--scalar-background-1);border-color:var(--scalar-border-color);outline-width:1px;outline-style:solid}.app-exit-button[data-v-b1eef1b6]{color:#fff;background:#0000001a}.app-exit-button[data-v-b1eef1b6]:hover{background:#ffffff1a}.fade-request-section-content[data-v-f97cc68c]{background:linear-gradient(to left, var(--scalar-background-1) 64%, transparent)}.filter-hover[data-v-f97cc68c]{height:100%;padding-left:24px;padding-right:39px;transition:width 0s ease-in-out .2s;position:absolute;right:0;overflow:hidden}.filter-hover[data-v-f97cc68c]:hover{z-index:10;width:100%}.filter-hover[data-v-f97cc68c]:has(:focus-visible){z-index:10;width:100%}.filter-hover[data-v-f97cc68c]:before{content:"";background-color:var(--scalar-background-1);opacity:0;pointer-events:none;width:100%;height:fit-content;transition:all .3s ease-in-out;position:absolute;top:0;left:0}.filter-hover-item[data-v-f97cc68c]{opacity:0}.filter-hover-item[data-v-f97cc68c]:not(:last-of-type){transform:translateY(3px)}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]{transition:opacity .2s ease-in-out,transform .2s ease-in-out}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:last-of-type{transition-delay:50ms}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(2){transition-delay:.1s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(3){transition-delay:.15s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(4){transition-delay:.2s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(5){transition-delay:.25s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(6){transition-delay:.3s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(7){transition-delay:.35s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]{opacity:1;transform:translate(0)}.filter-hover:has(:focus-visible) .filter-hover-item[data-v-f97cc68c]{opacity:1;transform:translate(0)}.filter-hover[data-v-f97cc68c]:hover:before{opacity:.9;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.filter-hover[data-v-f97cc68c]:has(:focus-visible):before{opacity:.9;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.filter-button[data-v-f97cc68c]{top:50%;transform:translateY(-50%)}.context-bar-group:hover .context-bar-group-hover\\:text-c-1[data-v-f97cc68c]{--tw-text-opacity:1;color:rgb(var(--scalar-color-1) / var(--tw-text-opacity))}.context-bar-group:has(:focus-visible) .context-bar-group-hover\\:text-c-1[data-v-f97cc68c]{--tw-text-opacity:1;color:rgb(var(--scalar-color-1) / var(--tw-text-opacity))}.context-bar-group:hover .context-bar-group-hover\\:hidden[data-v-f97cc68c]{display:none}.context-bar-group:has(:focus-visible) .context-bar-group-hover\\:hidden[data-v-f97cc68c]{display:none}[data-v-36811e28] .cm-editor{padding:0}[data-v-36811e28] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-36811e28] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-36811e28] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-36811e28] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-36811e28] .cm-line{text-overflow:ellipsis;padding:0;overflow:hidden}.filemask[data-v-36811e28]{-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px)}[data-v-0362e671] .cm-content{font-size:var(--scalar-small)}.oauth-scope-row-action-rail{--oauth-scope-row-rail-bg:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.oauth-scope-row-action-rail{--oauth-scope-row-rail-bg:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.oauth-scope-row-action-rail{background:linear-gradient(90deg, var(--oauth-scope-row-rail-bg) 0%, var(--oauth-scope-row-rail-bg) 30%, var(--oauth-scope-row-rail-bg) 100%)}@supports (color:color-mix(in lab, red, red)){.oauth-scope-row-action-rail{background:linear-gradient(90deg, color-mix(in srgb, var(--oauth-scope-row-rail-bg), transparent 100%) 0%, color-mix(in srgb, var(--oauth-scope-row-rail-bg), transparent 20%) 30%, var(--oauth-scope-row-rail-bg) 100%)}}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}[data-v-819cea32] .cm-editor{padding:0}[data-v-819cea32] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-819cea32] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-819cea32] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-819cea32] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-819cea32] .cm-line{text-overflow:ellipsis;word-break:break-word;padding:0;overflow:hidden}.required[data-v-819cea32]:after{content:"Required"}input[data-v-819cea32]::placeholder{color:var(--scalar-color-3)}.scalar-password-input[data-v-819cea32]{text-security:disc;-webkit-text-security:disc;-moz-text-security:disc}.request-section-content[data-v-dc6f670a]{--scalar-border-width:.5px}.request-section-content-filter[data-v-dc6f670a]{box-shadow:0 -10px 0 10px var(--scalar-background-1)}.request-item:focus-within .request-meta-buttons[data-v-dc6f670a]{opacity:1}.group-hover-input[data-v-dc6f670a]{border-width:var(--scalar-border-width);border-color:#0000}.group:hover .group-hover-input[data-v-dc6f670a]{background:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.group:hover .group-hover-input[data-v-dc6f670a]{background:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.group:hover .group-hover-input[data-v-dc6f670a]{border-color:var(--scalar-border-color)}.group-hover-input[data-v-dc6f670a]:focus{border-color:var(--scalar-border-color)!important;background:0 0!important}[data-v-20050fd6] .cm-editor{font-size:var(--scalar-small);background-color:#0000;outline:none}[data-v-20050fd6] .cm-gutters{background-color:var(--scalar-background-1);border-radius:var(--scalar-radius) 0 0 var(--scalar-radius)}[data-v-20050fd6] .cm-scroller{min-width:100%;overflow:auto}.light-mode .bg-preview[data-v-65a8f4ce]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23000' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.dark-mode .bg-preview[data-v-65a8f4ce]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23FFF' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.scalar-code-block[data-v-bb31ac6f] .hljs *{font-size:var(--scalar-small)}.response-body-virtual[data-headlessui-state=open],.response-body-virtual[data-headlessui-state=open] .diclosure-panel{flex-direction:column;flex-grow:1;display:flex}.scalar-version-number[data-v-ab8886a2]{width:76px;height:76px;font-size:8px;font-family:var(--scalar-font-code);box-shadow:inset 2px 0px 0 2px var(--scalar-background-2);text-align:center;text-transform:initial;-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);text-decoration-color:var(--scalar-color-3);border-radius:9px 9px 16px 12px;flex-direction:column;justify-content:center;align-items:center;margin-top:-113px;margin-left:-36px;line-height:11px;display:flex;position:absolute;transform:skewY(13deg)}.scalar-version-number a[data-v-ab8886a2]{background:var(--scalar-background-2);border:.5px solid var(--scalar-border-color);border-radius:3px;padding:2px 4px;font-weight:700;text-decoration:none}.gitbook-show[data-v-ab8886a2]{display:none}.v-enter-active[data-v-21dc7abd]{transition:opacity .5s}.v-enter-from[data-v-21dc7abd]{opacity:0}.animate-response-heading .response-heading[data-v-82f4df98]{opacity:1;animation:.2s ease-in-out forwards push-response-82f4df98}@keyframes push-response-82f4df98{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}.animate-response-heading .animate-response-children[data-v-82f4df98]{opacity:0;animation:.2s ease-in-out 50ms forwards response-spans-82f4df98}@keyframes response-spans-82f4df98{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.request-card[data-v-8d155354]{color:var(--scalar-color-1);font-size:var(--scalar-font-size-3)}.request-method[data-v-8d155354]{font-family:var(--scalar-font-code);text-transform:uppercase;margin-right:6px}.request-card-footer[data-v-8d155354]{flex-shrink:0;justify-content:flex-end;padding:6px;display:flex;position:relative}.request-card-footer-addon[data-v-8d155354]{flex:1;align-items:center;min-width:0;display:flex}.request-editor-section[data-v-8d155354]{flex:1;display:flex}.request-card-simple[data-v-8d155354]{font-size:var(--scalar-small);justify-content:space-between;align-items:center;padding:8px 8px 8px 12px;display:flex}.code-snippet[data-v-8d155354]{flex-direction:column;width:100%;display:flex}[data-radix-popper-content-wrapper]:has(.scalar-api-client-context-menu){z-index:1000!important}.resizer[data-v-e2c54c18]{cursor:col-resize;z-index:100;border-right:2px solid #0000;width:5px;transition:border-right-color .3s;position:absolute;top:0;bottom:0;right:0}.scalar-dragging{cursor:col-resize}.resizer:hover,.scalar-dragging .resizer{border-right-color:var(--scalar-background-3)}.scalar-dragging:after{content:"";display:block;position:absolute;inset:0}[data-v-28c8509c] .cm-editor{padding:0}[data-v-28c8509c] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-28c8509c] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-28c8509c] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-28c8509c] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-28c8509c] .cm-line{text-overflow:ellipsis;padding:0;overflow:hidden}.scalar .scalar-app-layout[data-v-ae5e8531]{background:var(--scalar-background-1);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:8px;width:100%;max-width:1390px;height:calc(100% - 120px);margin:auto;position:relative;overflow:hidden}@media (width<=720px) and (height<=480px){.scalar .scalar-app-layout[data-v-ae5e8531]{height:100%;max-height:90svh}}.scalar .scalar-app-exit[data-v-ae5e8531]{cursor:pointer;z-index:-1;background:#00000038;width:100vw;height:100vh;position:fixed;top:0;left:0}.dark-mode .scalar .scalar-app-exit[data-v-ae5e8531]{background:#00000073}.scalar .scalar-app-exit[data-v-ae5e8531]:before{text-align:center;color:#fff;opacity:.6;font-family:sans-serif;font-size:30px;font-weight:100;line-height:50px;position:absolute;top:0;right:12px}.scalar .scalar-app-exit[data-v-ae5e8531]:hover:before{opacity:1}.scalar-container[data-v-ae5e8531]{visibility:hidden;opacity:0;pointer-events:none;will-change:opacity;justify-content:center;align-items:center;width:100%;height:100%;transition:opacity .35s,visibility 0s linear .35s;display:flex;position:fixed;top:0;bottom:0;left:0;overflow:hidden}.scalar-container.scalar-client--open[data-v-ae5e8531]{opacity:1;visibility:visible;pointer-events:auto;transition:opacity .35s}.scalar .url-form-input[data-v-ae5e8531]{min-height:auto!important}.scalar .scalar-container[data-v-ae5e8531]{line-height:normal}.ref-search-meta[data-v-a00657cc]{background:var(--scalar-background-1);border-bottom-left-radius:var(--scalar-radius-lg);border-bottom-right-radius:var(--scalar-radius-lg);font-size:var(--scalar-font-size-4);color:var(--scalar-color-3);font-weight:var(--scalar-semibold);border-top:var(--scalar-border-width) solid var(--scalar-border-color);gap:12px;padding:6px 12px;display:flex}.authenticationProvided[data-v-e3416cd5]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.authenticationRequired[data-v-d15ef40b]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.askForAuthentication[data-v-3e7aa176]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;box-shadow:0 var(--scalar-border-width) 0 var(--scalar-background-1), 0 calc(-1 * var(--scalar-border-width)) 0 var(--scalar-background-1);flex-direction:column;margin-bottom:12px;padding:0;display:flex;position:relative}.authContent[data-v-3e7aa176]{grid-template-rows:0fr;width:100%;max-width:520px;min-height:0;margin:auto;transition:grid-template-rows .2s ease-out;display:grid;overflow:hidden}.authContentInner[data-v-3e7aa176]>div{margin:36px 0 48px}.authContent[data-v-3e7aa176] .markdown{margin-bottom:0!important}.askForAuthentication.open .authContent[data-v-3e7aa176]{grid-template-rows:1fr}.continueButton[data-v-3e7aa176]{align-self:flex-end}.toggleButton[data-v-3e7aa176]{cursor:pointer;text-align:left;color:var(--scalar-color-3);border-radius:var(--scalar-radius-lg);background:0 0;border:none;justify-content:space-between;align-items:center;display:flex;position:relative}.authContentInner[data-v-3e7aa176]{min-height:0;overflow:hidden}.authorizeButton[data-v-3e7aa176]{z-index:1;gap:5px;display:flex;background:var(--scalar-color-blue)!important;color:#fff!important;margin:0!important}.autosendPaused[data-v-d08225db]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.playIcon[data-v-5749c429]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-5749c429]:before{content:"";box-sizing:border-box;border:1.75px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-5749c429;display:inline-block;position:absolute}.buildingRequest[data-v-5749c429]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.buildingRequest svg[data-v-5749c429]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-5749c429{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.requestApproved[data-v-bb311586]{color:var(--scalar-color-green);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestFailed[data-v-bc27e533]{color:var(--scalar-color-red);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestFailedIcon[data-v-bc27e533]{border-radius:50%;width:16px;height:16px;padding:4px;box-shadow:inset 0 0 0 1.5px}.requestRejected[data-v-9803a54c]{color:var(--scalar-color-red);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestSuccess[data-v-acc2c0d8]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.light-mode .bg-preview[data-v-92f84612]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23000' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.dark-mode .bg-preview[data-v-92f84612]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23FFF' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.playIcon[data-v-65dc6dfb]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-65dc6dfb]:before{content:"";box-sizing:border-box;border:1.75px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-65dc6dfb;display:inline-block;position:absolute}.sendingRequest[data-v-65dc6dfb]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.sendingRequest svg[data-v-65dc6dfb]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-65dc6dfb{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.requestHeaderContainer[data-v-0eb5f95d]{justify-content:space-between;align-items:center;padding:0 5px;display:flex}.requestPreview[data-v-0eb5f95d]{border-radius:12px;flex-direction:column;width:100%;display:flex;position:relative}.requestContent[data-v-0eb5f95d]{grid-template-rows:0fr;min-height:0;transition:grid-template-rows .2s ease-out;display:grid;overflow:hidden}.requestPreview.open .requestContent[data-v-0eb5f95d]{grid-template-rows:1fr}.requestPreview.succeeded[data-v-0eb5f95d]{padding:0}.requestContentInner[data-v-0eb5f95d]{min-height:0;overflow:hidden}.code[data-v-0eb5f95d]{font-size:var(--scalar-font-size-4);background:var(--scalar-background-2);border-radius:12px;flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.code[data-v-0eb5f95d]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.code[data-v-0eb5f95d]{margin-bottom:12px;overflow:hidden}.dark-mode .code[data-v-0eb5f95d]{background:var(--scalar-background-2)}.code h1[data-v-0eb5f95d]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-3);padding:8px}.code[data-v-0eb5f95d] .codeBlock{max-height:calc(50vh - 100px);padding-top:0}.autosendContainer[data-v-0eb5f95d]{justify-content:space-between;display:flex}.sendButton[data-v-0eb5f95d]{background:var(--scalar-color-blue);color:#fff;font-weight:var(--scalar-semibold);padding:5px 10px}.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{background:color-mix(in srgb, var(--scalar-color-blue), black 10%)}}.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{color:#fff!important}.toggleButton[data-v-0eb5f95d]{cursor:pointer;text-align:left;color:var(--scalar-color-3);border-radius:var(--scalar-radius-lg);background:0 0;border:none;justify-content:space-between;align-items:center;display:flex;position:relative}.toggleButton[data-v-0eb5f95d]:hover{text-decoration:underline}.executeRequestTool[data-v-3e825a81]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);box-shadow:0 var(--scalar-border-width) 0 var(--scalar-background-1), 0 calc(-1 * var(--scalar-border-width)) 0 var(--scalar-background-1);flex-direction:column;gap:10px;margin-bottom:12px;display:flex}.tool[data-v-3e825a81]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:15px;margin-bottom:20px;padding:15px}.playIcon[data-v-9d9724d2]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-9d9724d2]:before{content:"";box-sizing:border-box;border:1.5px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-9d9724d2;display:inline-block;position:absolute}.loadingApiSpecs[data-v-9d9724d2]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);align-items:center;gap:6px;margin-bottom:10px;display:flex}.loadingApiSpecs svg[data-v-9d9724d2]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-9d9724d2{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.contextItem[data-v-e6786ce3]{white-space:nowrap;color:var(--scalar-color-2);cursor:pointer;vertical-align:middle;background:var(--scalar-background-2);border-radius:12px;align-items:center;padding:5px 10px;font-size:10px;display:flex}@supports (color:color-mix(in lab, red, red)){.contextItem[data-v-e6786ce3]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.contextItem[data-v-e6786ce3]{max-width:200px}.contextItemText[data-v-e6786ce3]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.shimmer[data-v-e6786ce3]{background:var(--scalar-background-2);background-image:linear-gradient(90deg, #202020 0%, var(--scalar-background-2) 40%, var(--scalar-background-3) 80%);background-size:200% 100%;animation:1.4s ease-in-out infinite shimmer-e6786ce3}.light-mode .shimmer[data-v-e6786ce3]{background:var(--scalar-background-2);background-image:linear-gradient(90deg, #fafafa 0%, var(--scalar-background-2) 40%, var(--scalar-background-3) 80%);background-size:200% 100%;animation:1.4s ease-in-out infinite shimmer-e6786ce3}@keyframes shimmer-e6786ce3{0%{background-position:200% 0}to{background-position:-200% 0}}.playIcon[data-v-6e0ac42c]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-6e0ac42c]:before{content:"";box-sizing:border-box;border:1.5px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-6e0ac42c;display:inline-block;position:absolute}.sendingRequest[data-v-6e0ac42c]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);align-items:center;gap:6px;margin-bottom:10px;display:flex}.sendingRequest svg[data-v-6e0ac42c]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-6e0ac42c{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.operations[data-v-43dd2b86]{flex-wrap:wrap;align-items:center;gap:5px;margin-bottom:12px;display:flex}.operations[data-v-43dd2b86]:empty{margin-bottom:-12px}.overflowPopover[data-v-43dd2b86]{flex-direction:column;gap:5px;padding:8px;display:flex}.catalogModal .scalar-modal-body{flex-direction:column;display:flex}.searchInput[data-v-bc24f891]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-bottom:10px}.catalog[data-v-bc24f891]{font-size:var(--scalar-font-size-3);grid-template-columns:1fr 1fr;gap:10px;display:grid;overflow-y:scroll}.item[data-v-bc24f891]{background-color:var(--scalar-background-2);border-radius:var(--scalar-radius-lg);align-items:center;gap:10px;padding:15px;transition:background-color .16s;display:flex}.item[data-v-bc24f891]:hover{background-color:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.item[data-v-bc24f891]:hover{background-color:color-mix(in srgb, var(--scalar-background-3), transparent 40%)!important}}.left[data-v-bc24f891]{align-items:center}.right[data-v-bc24f891]{flex-direction:column;display:flex}.logo[data-v-bc24f891]{width:25px}.item-top[data-v-bc24f891]{gap:10px;display:flex}.version[data-v-bc24f891]{background:var(--scalar-background-3);border-radius:var(--scalar-radius);font-size:var(--scalar-font-size-5);color:var(--scalar-color-3);padding:2px 5px}.description[data-v-bc24f891]{color:var(--scalar-color-2)}.dropdown-item[data-v-2d142bb5]{align-items:center;gap:10px;display:flex}.approvalSection[data-v-a7e6c699]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px}@supports (color:color-mix(in lab, red, red)){.approvalSection[data-v-a7e6c699]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.approvalSection[data-v-a7e6c699]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.approvalText[data-v-a7e6c699]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.approveContainer[data-v-a7e6c699]{gap:5px;display:flex}.actionButton[data-v-a7e6c699]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.rejectButton[data-v-a7e6c699]{color:#fff;background:var(--scalar-color-red)}.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 10%)}}.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{color:#fff!important}.approveButton[data-v-a7e6c699]{color:#fff;background:var(--scalar-color-blue)}.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{color:#fff!important}.error[data-v-63a481da]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:var(--scalar-color-red);border-radius:16px 16px 0 0;align-items:center;margin-bottom:-16px;padding:8px 8px 24px 12px;display:flex}@supports (color:color-mix(in lab, red, red)){.error[data-v-63a481da]{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.error[data-v-63a481da]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);position:absolute;top:0;transform:translateY(calc(16px - 100%))}.freeMessagesInfoSection[data-v-913a3815]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.freeMessagesInfoSection[data-v-913a3815]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.freeMessagesInfoSection[data-v-913a3815]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex}.infoText[data-v-913a3815]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.actionsContainer[data-v-913a3815]{align-items:center;gap:8px;display:flex}.actionButton[data-v-913a3815]{font-weight:var(--scalar-semibold);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.upgradeButton[data-v-913a3815]{color:#fff;font-size:var(--scalar-font-size-3);background:var(--scalar-color-blue)}.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{color:#fff!important}.closeButton[data-v-913a3815]{width:28px;height:28px;color:var(--scalar-color-2);cursor:pointer;background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;display:flex}.closeButton[data-v-913a3815]:hover{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.closeButton[data-v-913a3815]:hover{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 80%)}}.closeButton[data-v-913a3815]:hover{color:var(--scalar-color-1)}.paymentSection[data-v-8f005a5c]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.paymentSection[data-v-8f005a5c]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.paymentSection[data-v-8f005a5c]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.approvalText[data-v-8f005a5c]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.paymentContainer[data-v-8f005a5c]{gap:5px;display:flex}.actionButton[data-v-8f005a5c]{font-weight:var(--scalar-semibold);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.rejectButton[data-v-8f005a5c]{color:#fff;background:var(--scalar-color-red)}.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 10%)}}.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{color:#fff!important}.approveButton[data-v-8f005a5c]{color:#fff;font-size:var(--scalar-font-size-3);background:var(--scalar-color-blue)}.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{color:#fff!important}.paymentInfo[data-v-8f005a5c]{width:300px;box-shadow:var(--scalar-shadow-2);background:var(--scalar-background-1);pointer-events:none;opacity:0;border-radius:16px;padding:12px;transition:all .2s ease-in-out;position:absolute;bottom:70px;right:0;transform:translateY(-5px)}.paymentInfo h3[data-v-8f005a5c]{font-size:var(--scalar-font-size-1);font-weight:var(--scalar-bold);margin-bottom:18px}.paymentInfo h3 span[data-v-8f005a5c]{font-size:var(--scalar-font-size-2)}.dark-mode .paymentInfo[data-v-8f005a5c]{background:var(--scalar-background-2)}.paymentContainer:hover .paymentInfo[data-v-8f005a5c]{opacity:1;transform:translate(0)}.paymentInfoItem[data-v-8f005a5c]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);justify-content:space-between;margin-top:8px;display:flex}.paymentInfoSection[data-v-8f005a5c]:not(:last-child){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);padding-bottom:8px}.searchItem[data-v-7945f74c]{font-size:var(--scalar-font-size-3);align-items:center;gap:9px;padding:8px 10px;display:flex}.searchInput[data-v-7945f74c]{margin-bottom:5px}.searchItem[data-v-7945f74c]:hover{background:var(--scalar-background-2)}.searchItemLogo[data-v-7945f74c]{width:15px}.searchIcon[data-v-7945f74c]{margin-right:7px}.searchResultsEmpty[data-v-7945f74c]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);margin:10px}.uploadSection[data-v-9551cf83]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.uploadSection[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.uploadSection[data-v-9551cf83]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.uploadSection.error[data-v-9551cf83]{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.uploadSection.error[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.uploadSection.done[data-v-9551cf83]{background:var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.uploadSection.done[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-green), var(--scalar-background-1) 95%)}}.uploadText[data-v-9551cf83]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.icon[data-v-9551cf83]{width:20px;height:20px}.actionContainer[data-v-e7c7c266]{background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.actionContainer[data-v-e7c7c266]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.actionContainer[data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;box-shadow:0 24px 0 2px var(--scalar-background-1);border-radius:16px;position:relative}.promptForm[data-v-e7c7c266]{background:var(--scalar-background-1);width:100%;box-shadow:var(--scalar-shadow-1), 0 0 0 var(--scalar-border-width) var(--scalar-border-color);border-radius:16px;flex-direction:column;display:flex;position:relative}.inputActionsContainer[data-v-e7c7c266]{justify-content:space-between;padding:0 8px 8px;display:flex}.inputActionsLeft[data-v-e7c7c266]{flex-wrap:wrap;align-items:center;gap:5px;display:flex}.inputActionsRight[data-v-e7c7c266]{gap:5px;display:flex;position:relative}.apiPill[data-v-e7c7c266]{font-size:var(--scalar-font-size-3);border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);pointer-events:all;z-index:1;-webkit-user-select:none;user-select:none;border-radius:16px;align-items:center;gap:4px;height:28px;padding:0 8px;display:flex}.apiPillLogo[data-v-e7c7c266]{width:15px}.apiPillRemove[data-v-e7c7c266]{border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;margin-right:-6px;display:flex}.apiPill:hover .apiPillRemove[data-v-e7c7c266]{background:var(--scalar-background-2)}.dark-mode .apiPill:hover .apiPillRemove[data-v-e7c7c266]{background:var(--scalar-background-3)}.apiPillRemove[data-v-e7c7c266]:hover{color:var(--scalar-color-1)}.prompt[data-v-e7c7c266]{resize:none;field-sizing:content;z-index:1;scrollbar-width:thin;word-wrap:break-word;width:100%;max-width:100%;min-height:64px;max-height:250px;font-family:var(--scalar-font);border:none;outline:none;padding:12px 12px 14px;font-size:16px;overflow-y:auto}.dark-mode .promptForm[data-v-e7c7c266]{background:var(--scalar-background-2)}.prompt[data-v-e7c7c266]:disabled{color:var(--scalar-color-3)}.addAPIButton[data-v-e7c7c266]{color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);width:28px;height:28px;font-weight:var(--scalar-bold);pointer-events:all;z-index:1;box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color);border-radius:100%;justify-content:center;align-items:center;gap:4px;display:flex}.addAPIButton[data-v-e7c7c266]:hover{background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.addAPIButton[data-v-e7c7c266]:hover{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.addAPIButton[data-v-e7c7c266]:hover{box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color)}.dark-mode .addAPIButton[data-v-e7c7c266]:hover{background:var(--scalar-background-3)}.settingsButton[data-v-e7c7c266]{z-index:1;color:var(--scalar-color-3)!important;border-radius:50%!important;margin:0!important}.settingsButton[aria-disabled=true][data-v-e7c7c266]{background:var(--scalar-background-2)}.dark-mode .settingsButton[data-v-e7c7c266]:hover{background:var(--scalar-background-3)}.sendButton[data-v-e7c7c266]{z-index:1;border:var(--scalar-border-width) solid var(--scalar-color-blue);background:var(--scalar-color-blue)!important;border-radius:50%!important;margin:0!important}.sendButton[data-v-e7c7c266]:not([aria-disabled=true]){color:#fff!important}.sendButton[data-v-e7c7c266]:not([aria-disabled=true]):hover{background:var(--scalar-color-blue)!important}@supports (color:color-mix(in lab, red, red)){.sendButton[data-v-e7c7c266]:not([aria-disabled=true]):hover{background:color-mix(in srgb, var(--scalar-color-blue), transparent 10%)!important}}.sendButton[aria-disabled=true][data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:var(--scalar-background-2)!important;color:var(--scalar-color-3)!important}.dark-mode .sendButton[aria-disabled=true][data-v-e7c7c266]{background:var(--scalar-background-3)!important}.contextContainer[data-v-e7c7c266]{width:100%;color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);-webkit-user-select:none;user-select:none;justify-content:space-between;padding:10px 12px 12px;display:flex}.settingsButton[data-v-e7c7c266]{font-weight:var(--scalar-semibold);border-radius:var(--scalar-radius-lg);margin:-4px -6px;padding:4px 6px}.settingsButton[data-v-e7c7c266]:hover{background:var(--scalar-background-2);box-shadow:0 0 var(--scalar-border-width) 0 var(--scalar-border-color);cursor:pointer}.agentLabel[data-v-e7c7c266]{cursor:text;width:100%;height:100%;font-size:0;position:absolute}.sendCheckboxContinue[data-v-e7c7c266]:has(input){background:var(--scalar-background-2);box-shadow:0 0 0 1.5px var(--scalar-background-2);color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);font-weight:var(--scalar-semibold);-webkit-user-select:none;user-select:none;border-radius:14px;align-items:center;height:28px;display:flex}.dark-mode .sendCheckboxContinue[data-v-e7c7c266]:has(input){background:var(--scalar-background-3);box-shadow:0 0 0 1.5px var(--scalar-background-3)}.addMoreContext[data-v-e7c7c266]{height:40px;font-size:var(--scalar-font-size-3);color:var(--scalar-color-3);align-items:center;padding:0 8px 0 12px;display:flex;position:relative}.addMoreContext[data-v-e7c7c266]:before{content:"";background:var(--scalar-background-1);width:8px;height:8px}@supports (color:color-mix(in lab, red, red)){.addMoreContext[data-v-e7c7c266]:before{background:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.addMoreContext[data-v-e7c7c266]:before{box-shadow:-.5px -.5px 0 var(--scalar-border-color), inset .5px .5px 1px var(--scalar-border-color);position:absolute;top:-3px;left:18px;transform:rotate(45deg)}.dark-mode .addMoreContext[data-v-e7c7c266]:before{box-shadow:-.5px -.5px 0 var(--scalar-border-color)}.addAPIContext[data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:50%;justify-content:center;align-items:center;width:28px;height:28px;display:flex}.termsAgree[data-v-e7c7c266]{cursor:pointer;height:inherit;border-radius:14px;align-items:center;gap:5px;margin:0 5px;display:flex}.termsAgree[data-v-e7c7c266]:hover{color:var(--scalar-color-1)}.termsAgree:hover .termsAgreeIcon[data-v-e7c7c266]{background:var(--scalar-color-1);color:var(--scalar-background-1)}.termsAgreeIcon[data-v-e7c7c266]{width:inherit;height:inherit;background:var(--scalar-background-2);border-radius:50%;padding:2px}.chat[data-v-8e43ed7a]{flex-direction:column;flex:1;width:100%;max-width:744px;padding:24px 0;display:flex}.userMessage[data-v-8e43ed7a]{background:var(--scalar-background-2);width:fit-content;max-width:80%;color:var(--scalar-color-1);padding-top:6px;padding-bottom:6px;border-radius:18px;margin-bottom:12px;margin-left:auto;padding-inline:16px;font-size:16px;line-height:24px}div+.userMessage[data-v-8e43ed7a]{margin-top:64px}.chat[data-v-8e43ed7a]>div:has(.executeRequestTool)+div:has(.executeRequestTool){margin-top:-12px}.spacer[data-v-8e43ed7a]{width:100%;min-height:280px}.formContainer[data-v-8e43ed7a]{z-index:1;width:100%;max-width:744px;position:fixed;bottom:20px}.chat[data-v-8e43ed7a] .markdown{margin-bottom:12px}.agentLogo[data-v-56f7e8dd]{margin-bottom:15px}.startContainer[data-v-56f7e8dd]{flex-direction:column;justify-content:center;align-items:center;width:100%;max-width:720px;height:100%;display:flex;position:relative}.heading[data-v-56f7e8dd]{font-size:1.5rem;font-weight:var(--scalar-font-bold);margin-bottom:50px}.disclaimerText[data-v-56f7e8dd]{text-align:center;color:var(--scalar-color-3);font-size:var(--scalar-font-size-3);text-wrap:balance;margin-top:40px;line-height:1.44}.disclaimerLink[data-v-56f7e8dd]{text-decoration:underline}.wrapper[data-v-f1eee0af]{flex-direction:column;align-items:center;width:100%;height:100%;display:flex}.docSettings[data-v-01a25619]{font-size:var(--scalar-font-size-3);flex-direction:column;gap:12px;max-height:600px;margin-bottom:12px;display:flex}.documentName[data-v-01a25619]{font-weight:var(--scalar-semibold)}.settingsModal .scalar-modal-layout{z-index:10!important}.settingsModal .scalar-modal-body{overflow:hidden auto}.documentList[data-v-9843da37]{font-size:var(--scalar-font-size-3);flex-direction:column;margin-bottom:12px;display:flex}.document[data-v-9843da37]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;width:calc(100% + 24px);padding:0 12px;display:flex;position:relative;left:-12px}.document[data-v-9843da37]:first-of-type:not(:last-of-type){border-bottom:none}.documentName[data-v-9843da37]{font-weight:var(--scalar-semibold);color:var(--scalar-color-2);align-items:center;gap:4px;padding:12px 0;display:flex}.documentNameActive[data-v-9843da37]{color:var(--scalar-color-1)}.settingsHeading[data-v-9843da37]{font-size:19px;font-weight:var(--scalar-semibold);align-items:center;gap:5px;margin-bottom:12px;display:flex}.proxyUrlContainer[data-v-9843da37]{font-size:var(--scalar-font-size-3);flex-direction:column;gap:5px;display:flex}.proxyUrlContainer label[data-v-9843da37]{font-weight:var(--scalar-semibold)}.noDocuments[data-v-9843da37]{color:var(--scalar-color-2);margin-bottom:10px}.scalar-app .\\@container{container-type:inline-size}.scalar-app .pointer-events-auto{pointer-events:auto}.scalar-app .pointer-events-none{pointer-events:none}.scalar-app .collapse{visibility:collapse}.scalar-app .invisible{visibility:hidden}.scalar-app .visible{visibility:visible}.scalar-app .floating-bg:before{background-color:var(--scalar-background-2);border-radius:var(--scalar-radius);content:"";opacity:0;z-index:1;width:calc(100% + 8px);height:calc(100% - 4px);transition:opacity .2s ease-in-out;position:absolute;top:2.5px;left:-4px}.scalar-app .floating-bg:hover:before{opacity:1}.scalar-app .centered{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);position:absolute;top:50%;left:50%}.scalar-app .centered-y{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);position:absolute;top:50%}.scalar-app .sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.scalar-app .absolute{position:absolute}.scalar-app .fixed{position:fixed}.scalar-app .relative{position:relative}.scalar-app .static{position:static}.scalar-app .sticky{position:sticky}.scalar-app .inset-0{inset:0}.scalar-app .inset-x-0{inset-inline:0}.scalar-app .inset-x-1{inset-inline:4px}.scalar-app .inset-x-px{inset-inline:1px}.scalar-app .-inset-y-0\\.5{inset-block:-2px}.scalar-app .-inset-y-0\\.75{inset-block:-3px}.scalar-app .inset-y-0{inset-block:0}.scalar-app .inset-y-0\\.5{inset-block:2px}.scalar-app .start{inset-inline-start:4px}.scalar-app .end{inset-inline-end:4px}.scalar-app .-top-1{top:-4px}.scalar-app .-top-2{top:-8px}.scalar-app .top-\\(--nested-items-offset\\)\\!{top:var(--nested-items-offset)!important}.scalar-app .top-\\(--refs-header-height\\){top:var(--refs-header-height)}.scalar-app .top-\\(--scalar-custom-header-height\\,0\\){top:var(--scalar-custom-header-height,0)}.scalar-app .top-\\(--scalar-sidebar-sticky-offset\\,0\\){top:var(--scalar-sidebar-sticky-offset,0)}.scalar-app .top-0{top:0}.scalar-app .top-0\\.5{top:2px}.scalar-app .top-1\\/2{top:50%}.scalar-app .top-2{top:8px}.scalar-app .top-2\\.5{top:10px}.scalar-app .top-3\\.5{top:14px}.scalar-app .top-12{top:48px}.scalar-app .top-\\[1lh\\]{top:1lh}.scalar-app .top-\\[calc\\(10px\\+0\\.5lh\\)\\]{top:calc(10px + .5lh)}.scalar-app .top-\\[calc\\(100\\%\\+4px\\)\\]{top:calc(100% + 4px)}.scalar-app .top-px{top:1px}.scalar-app .-right-1{right:-4px}.scalar-app .-right-1\\.5{right:-6px}.scalar-app .-right-\\[30px\\]{right:-30px}.scalar-app .right-0{right:0}.scalar-app .right-0\\.75{right:3px}.scalar-app .right-1{right:4px}.scalar-app .right-1\\.5{right:6px}.scalar-app .right-1\\.25{right:5px}.scalar-app .right-2{right:8px}.scalar-app .right-2\\.5{right:10px}.scalar-app .right-4{right:16px}.scalar-app .right-7{right:28px}.scalar-app .right-12{right:48px}.scalar-app .bottom-1{bottom:4px}.scalar-app .bottom-4{bottom:16px}.scalar-app .bottom-\\[var\\(--scalar-border-width\\)\\]{bottom:var(--scalar-border-width)}.scalar-app .-left-4\\.5{left:-18px}.scalar-app .-left-5{left:-20px}.scalar-app .-left-6{left:-24px}.scalar-app .left-0{left:0}.scalar-app .left-1\\/2{left:50%}.scalar-app .left-2{left:8px}.scalar-app .left-2\\.5{left:10px}.scalar-app .left-3{left:12px}.scalar-app .left-4{left:16px}.scalar-app .left-border{left:var(--scalar-border-width)}.scalar-app .left-px{left:1px}.scalar-app .left-refs-w-sidebar{left:var(--refs-sidebar-width)}.scalar-app .-z-1{z-index:calc(1 * -1)}.scalar-app .-z-2{z-index:calc(2 * -1)}.scalar-app .z-0{z-index:0}.scalar-app .z-1{z-index:1}.scalar-app .z-10{z-index:10;z-index:10}.scalar-app .z-50{z-index:50;z-index:50}.scalar-app .z-\\[1\\]{z-index:1}.scalar-app .z-context{z-index:1000}.scalar-app .z-context-plus{z-index:1001}.scalar-app .z-overlay{z-index:10000}.scalar-app .z-tooltip{z-index:99999}.scalar-app .order-789{order:789}.scalar-app .order-last{order:9999}.scalar-app .col-span-full{grid-column:1/-1}.scalar-app .container{width:100%}@media (width>=400px){.scalar-app .container{max-width:400px}}@media (width>=600px){.scalar-app .container{max-width:600px}}@media (width>=800px){.scalar-app .container{max-width:800px}}@media (width>=1000px){.scalar-app .container{max-width:1000px}}@media (width>=1200px){.scalar-app .container{max-width:1200px}}@media (width>=96rem){.scalar-app .container{max-width:96rem}}.scalar-app .\\!m-0{margin:0!important}.scalar-app .-m-0\\.5{margin:-2px}.scalar-app .-m-1{margin:-4px}.scalar-app .-m-2{margin:-8px}.scalar-app .-m-px{margin:-1px}.scalar-app .m-0{margin:0}.scalar-app .m-1{margin:4px}.scalar-app .m-auto{margin:auto}.scalar-app .-mx-\\(--scalar-sidebar-padding\\){margin-inline:calc(var(--scalar-sidebar-padding) * -1)}.scalar-app .-mx-0\\.25{margin-inline:-1px}.scalar-app .-mx-0\\.75{margin-inline:-3px}.scalar-app .-mx-2{margin-inline:-8px}.scalar-app .-mx-px{margin-inline:-1px}.scalar-app .mx-0\\.5{margin-inline:2px}.scalar-app .mx-1{margin-inline:4px}.scalar-app .mx-1\\.5{margin-inline:6px}.scalar-app .mx-px{margin-inline:1px}.scalar-app .-my-0\\.5{margin-block:-2px}.scalar-app .-my-1{margin-block:-4px}.scalar-app .-my-1\\.5{margin-block:-6px}.scalar-app .-my-2{margin-block:-8px}.scalar-app .-my-px{margin-block:-1px}.scalar-app .my-0\\.75{margin-block:3px}.scalar-app .my-1\\.5{margin-block:6px}.scalar-app .my-2{margin-block:8px}.scalar-app .my-3{margin-block:12px}.scalar-app .my-12{margin-block:48px}.scalar-app .-mt-\\(--scalar-sidebar-padding\\){margin-top:calc(var(--scalar-sidebar-padding) * -1)}.scalar-app .-mt-1{margin-top:-4px}.scalar-app .-mt-1\\.5{margin-top:-6px}.scalar-app .-mt-\\[\\.5px\\]{margin-top:-.5px}.scalar-app .mt-0{margin-top:0}.scalar-app .mt-0\\.5{margin-top:2px}.scalar-app .mt-0\\.25{margin-top:1px}.scalar-app .mt-1{margin-top:4px}.scalar-app .mt-2{margin-top:8px}.scalar-app .mt-3{margin-top:12px}.scalar-app .mt-6{margin-top:24px}.scalar-app .mt-\\[15svh\\]{margin-top:15svh}.scalar-app .mt-\\[20svh\\]{margin-top:20svh}.scalar-app .mt-auto{margin-top:auto}.scalar-app .mt-px{margin-top:1px}.scalar-app .-mr-0\\.5{margin-right:-2px}.scalar-app .-mr-1{margin-right:-4px}.scalar-app .-mr-1\\.5{margin-right:-6px}.scalar-app .-mr-px{margin-right:-1px}.scalar-app .mr-0{margin-right:0}.scalar-app .mr-0\\.5{margin-right:2px}.scalar-app .mr-0\\.75{margin-right:3px}.scalar-app .mr-1{margin-right:4px}.scalar-app .mr-1\\.5{margin-right:6px}.scalar-app .mr-1\\.25{margin-right:5px}.scalar-app .mr-2{margin-right:8px}.scalar-app .mr-3{margin-right:12px}.scalar-app .mr-\\[calc\\(20px-var\\(--scalar-sidebar-indent\\)\\)\\]{margin-right:calc(20px - var(--scalar-sidebar-indent))}.scalar-app .-mb-1{margin-bottom:-4px}.scalar-app .-mb-\\[var\\(--scalar-border-width\\)\\]{margin-bottom:calc(var(--scalar-border-width) * -1)}.scalar-app .mb-0{margin-bottom:0}.scalar-app .mb-1{margin-bottom:4px}.scalar-app .mb-2{margin-bottom:8px}.scalar-app .mb-3{margin-bottom:12px}.scalar-app .mb-4{margin-bottom:16px}.scalar-app .-ml-0\\.5{margin-left:-2px}.scalar-app .-ml-0\\.25{margin-left:-1px}.scalar-app .-ml-0\\.75{margin-left:-3px}.scalar-app .ml-1{margin-left:4px}.scalar-app .ml-2{margin-left:8px}.scalar-app .ml-auto{margin-left:auto}.scalar-app .box-border{box-sizing:border-box}.scalar-app .box-content{box-sizing:content-box}.scalar-app .flex-center{justify-content:center;align-items:center;display:flex}.scalar-app .line-clamp-\\(--markdown-clamp\\){-webkit-line-clamp:var(--markdown-clamp);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.scalar-app .line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.scalar-app .\\!block{display:block!important}.scalar-app .\\!hidden{display:none!important}.scalar-app .block{display:block}.scalar-app .contents{display:contents}.scalar-app .flex{display:flex}.scalar-app .grid{display:grid}.scalar-app .hidden{display:none}.scalar-app .inline{display:inline}.scalar-app .inline-block{display:inline-block}.scalar-app .inline-flex{display:inline-flex}.scalar-app .table{display:table}.scalar-app .field-sizing-content{field-sizing:content}.scalar-app .aspect-\\[4\\/3\\]{aspect-ratio:4/3}.scalar-app .aspect-square{aspect-ratio:1}.scalar-app .size-1\\.5{width:6px;height:6px}.scalar-app .size-2\\.5{width:10px;height:10px}.scalar-app .size-2\\.75{width:11px;height:11px}.scalar-app .size-3{width:12px;height:12px}.scalar-app .size-3\\.5{width:14px;height:14px}.scalar-app .size-3\\/4{width:75%;height:75%}.scalar-app .size-4{width:16px;height:16px}.scalar-app .size-4\\.5{width:18px;height:18px}.scalar-app .size-5{width:20px;height:20px}.scalar-app .size-6{width:24px;height:24px}.scalar-app .size-8{width:32px;height:32px}.scalar-app .size-10{width:40px;height:40px}.scalar-app .size-\\[23px\\]{width:23px;height:23px}.scalar-app .size-full{width:100%;height:100%}.scalar-app .h-\\(--refs-sidebar-height\\){height:var(--refs-sidebar-height)}.scalar-app .h-\\(--scalar-address-bar-height\\){height:var(--scalar-address-bar-height)}.scalar-app .h-\\(--scalar-header-height\\){height:var(--scalar-header-height)}.scalar-app .h-0{height:0}.scalar-app .h-1{height:4px}.scalar-app .h-1\\.5{height:6px}.scalar-app .h-2{height:8px}.scalar-app .h-2\\.5{height:10px}.scalar-app .h-2\\.25{height:9px}.scalar-app .h-3{height:12px}.scalar-app .h-3\\.5{height:14px}.scalar-app .h-4{height:16px}.scalar-app .h-4\\.5{height:18px}.scalar-app .h-5{height:20px}.scalar-app .h-6{height:24px}.scalar-app .h-7{height:28px}.scalar-app .h-8{height:32px}.scalar-app .h-10{height:40px}.scalar-app .h-\\[calc\\(100\\%\\+16px\\)\\]{height:calc(100% + 16px)}.scalar-app .h-\\[calc\\(100\\%_-_50px\\)\\]{height:calc(100% - 50px)}.scalar-app .h-auto{height:auto}.scalar-app .h-border{height:var(--scalar-border-width)}.scalar-app .h-dvh{height:100dvh}.scalar-app .h-fit{height:fit-content}.scalar-app .h-full{height:100%}.scalar-app .h-lh{height:1lh}.scalar-app .h-min{height:min-content}.scalar-app .h-px{height:1px}.scalar-app .\\!max-h-\\[initial\\]{max-height:initial!important}.scalar-app .max-h-8{max-height:32px}.scalar-app .max-h-40{max-height:160px}.scalar-app .max-h-80{max-height:320px}.scalar-app .max-h-\\[60svh\\]{max-height:60svh}.scalar-app .max-h-\\[60vh\\]{max-height:60vh}.scalar-app .max-h-\\[80svh\\]{max-height:80svh}.scalar-app .max-h-\\[90svh\\]{max-height:90svh}.scalar-app .max-h-\\[auto\\]{max-height:auto}.scalar-app .max-h-\\[calc\\(100\\%-32px\\)\\]{max-height:calc(100% - 32px)}.scalar-app .max-h-\\[inherit\\]{max-height:inherit}.scalar-app .max-h-dvh{max-height:100dvh}.scalar-app .max-h-fit{max-height:fit-content}.scalar-app .max-h-radix-popper{max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px)}.scalar-app .max-h-screen{max-height:100vh}.scalar-app .min-h-0{min-height:0}.scalar-app .min-h-3{min-height:12px}.scalar-app .min-h-7{min-height:28px}.scalar-app .min-h-8{min-height:32px}.scalar-app .min-h-10{min-height:40px}.scalar-app .min-h-11{min-height:44px}.scalar-app .min-h-16{min-height:64px}.scalar-app .min-h-20{min-height:80px}.scalar-app .min-h-\\[64px\\]{min-height:64px}.scalar-app .min-h-\\[calc\\(4rem\\+0\\.5px\\)\\]{min-height:calc(4rem + .5px)}.scalar-app .min-h-fit{min-height:fit-content}.scalar-app .min-h-header{min-height:48px}.scalar-app .\\!w-fit{width:fit-content!important}.scalar-app .w-\\(--refs-sidebar-width\\){width:var(--refs-sidebar-width)}.scalar-app .w-0\\.5{width:2px}.scalar-app .w-1\\.5{width:6px}.scalar-app .w-2\\.5{width:10px}.scalar-app .w-2\\.25{width:9px}.scalar-app .w-3{width:12px}.scalar-app .w-4{width:16px}.scalar-app .w-4\\.5{width:18px}.scalar-app .w-5{width:20px}.scalar-app .w-6{width:24px}.scalar-app .w-7{width:28px}.scalar-app .w-8{width:32px}.scalar-app .w-36{width:144px}.scalar-app .w-40{width:160px}.scalar-app .w-56{width:224px}.scalar-app .w-64{width:256px}.scalar-app .w-72{width:288px}.scalar-app .w-120{width:480px}.scalar-app .w-\\[38px\\]{width:38px}.scalar-app .w-\\[calc\\(100vw-12px\\)\\]{width:calc(100vw - 12px)}.scalar-app .w-\\[var\\(--scalar-sidebar-indent\\)\\]{width:var(--scalar-sidebar-indent)}.scalar-app .w-auto{width:auto}.scalar-app .w-border{width:var(--scalar-border-width)}.scalar-app .w-content{width:720px}.scalar-app .w-dvw{width:100dvw}.scalar-app .w-fit{width:fit-content}.scalar-app .w-full{width:100%}.scalar-app .w-max{width:max-content}.scalar-app .w-px{width:1px}.scalar-app .max-w-\\(--refs-content-max-width\\){max-width:var(--refs-content-max-width)}.scalar-app .max-w-3xl{max-width:768px}.scalar-app .max-w-64{max-width:256px}.scalar-app .max-w-\\[9rem\\]{max-width:9rem}.scalar-app .max-w-\\[100\\%\\]{max-width:100%}.scalar-app .max-w-\\[160px\\]{max-width:160px}.scalar-app .max-w-\\[220px\\]{max-width:220px}.scalar-app .max-w-\\[360px\\]{max-width:360px}.scalar-app .max-w-\\[480px\\]{max-width:480px}.scalar-app .max-w-\\[540px\\]{max-width:540px}.scalar-app .max-w-\\[640px\\]{max-width:640px}.scalar-app .max-w-\\[800px\\]{max-width:800px}.scalar-app .max-w-\\[1000px\\]{max-width:1000px}.scalar-app .max-w-\\[inherit\\]{max-width:inherit}.scalar-app .max-w-full{max-width:100%}.scalar-app .max-w-screen-padded-4{max-width:calc(100vw - 32px)}.scalar-app .max-w-xs{max-width:320px}.scalar-app .min-w-0{min-width:0}.scalar-app .min-w-2\\.25{min-width:9px}.scalar-app .min-w-3{min-width:12px}.scalar-app .min-w-4{min-width:16px}.scalar-app .min-w-4\\.5{min-width:18px}.scalar-app .min-w-6{min-width:24px}.scalar-app .min-w-7{min-width:28px}.scalar-app .min-w-8{min-width:32px}.scalar-app .min-w-32{min-width:128px}.scalar-app .min-w-48{min-width:192px}.scalar-app .min-w-\\[4\\.5rem\\]{min-width:4.5rem}.scalar-app .min-w-fit{min-width:fit-content}.scalar-app .min-w-full{min-width:100%}.scalar-app .min-w-min{min-width:min-content}.scalar-app .flex-1{flex:1}.scalar-app .flex-shrink,.scalar-app .shrink{flex-shrink:1}.scalar-app .shrink-0{flex-shrink:0}.scalar-app .flex-grow,.scalar-app .grow{flex-grow:1}.scalar-app .grow-3{flex-grow:3}.scalar-app .-translate-x-1\\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-0{--tw-translate-x:calc(4px * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-2\\.5{--tw-translate-x:calc(4px * 2.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-\\[14px\\]{--tw-translate-x:14px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-y-1\\.5{--tw-translate-y:calc(4px * -1.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-0{--tw-translate-y:calc(4px * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-1\\.5{--tw-translate-y:calc(4px * 1.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-\\[200\\%\\]{--tw-translate-y:200%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scalar-app .scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scalar-app .rotate-45{rotate:45deg}.scalar-app .rotate-90{rotate:90deg}.scalar-app .rotate-180{rotate:180deg}.scalar-app .transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.scalar-app .animate-pulse{animation:var(--animate-pulse)}.scalar-app .cursor-auto{cursor:auto}.scalar-app .cursor-default{cursor:default}.scalar-app .cursor-help{cursor:help}.scalar-app .cursor-not-allowed{cursor:not-allowed}.scalar-app .cursor-pointer{cursor:pointer}.scalar-app .cursor-text{cursor:text}.scalar-app .resize{resize:both}.scalar-app .resize-none{resize:none}.scalar-app .scroll-mt-16{scroll-margin-top:64px}.scalar-app .scroll-mt-24{scroll-margin-top:96px}.scalar-app .list-none{list-style-type:none}.scalar-app .appearance-none{appearance:none}.scalar-app .grid-flow-col{grid-auto-flow:column}.scalar-app .auto-rows-auto{grid-auto-rows:auto}.scalar-app .grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.scalar-app .grid-cols-\\[44px_1fr_repeat\\(3\\,auto\\)\\]{grid-template-columns:44px 1fr repeat(3,auto)}.scalar-app .flex-col{flex-direction:column}.scalar-app .flex-row{flex-direction:row}.scalar-app .flex-wrap{flex-wrap:wrap}.scalar-app .content-end{align-content:flex-end}.scalar-app .content-start{align-content:flex-start}.scalar-app .items-baseline{align-items:baseline}.scalar-app .items-center{align-items:center}.scalar-app .items-end{align-items:flex-end}.scalar-app .items-start{align-items:flex-start}.scalar-app .items-stretch{align-items:stretch}.scalar-app .justify-between{justify-content:space-between}.scalar-app .justify-center{justify-content:center}.scalar-app .justify-end{justify-content:flex-end}.scalar-app .justify-start{justify-content:flex-start}.scalar-app .justify-stretch{justify-content:stretch}.scalar-app .gap-0\\.5{gap:2px}.scalar-app .gap-0\\.75{gap:3px}.scalar-app .gap-1{gap:4px}.scalar-app .gap-1\\.5{gap:6px}.scalar-app .gap-1\\.75{gap:7px}.scalar-app .gap-2{gap:8px}.scalar-app .gap-2\\.5{gap:10px}.scalar-app .gap-2\\.25{gap:9px}.scalar-app .gap-3{gap:12px}.scalar-app .gap-4{gap:16px}.scalar-app .gap-6{gap:24px}.scalar-app .gap-7{gap:28px}.scalar-app .gap-10{gap:40px}.scalar-app .gap-12{gap:48px}.scalar-app .gap-\\[1\\.5px\\]{gap:1.5px}.scalar-app .gap-px{gap:1px}.scalar-app .gap-x-2\\.5{column-gap:10px}:where(.scalar-app .space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(4px * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(4px * 1) * calc(1 - var(--tw-space-x-reverse)))}:where(.scalar-app .divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(var(--scalar-border-width) * var(--tw-divide-y-reverse));border-bottom-width:calc(var(--scalar-border-width) * calc(1 - var(--tw-divide-y-reverse)))}.scalar-app .self-center{align-self:center}.scalar-app .self-end{align-self:flex-end}.scalar-app .self-start{align-self:flex-start}.scalar-app .truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.scalar-app .overflow-auto{overflow:auto}.scalar-app .overflow-hidden{overflow:hidden}.scalar-app .overflow-visible{overflow:visible}.scalar-app .overflow-x-auto{overflow-x:auto}.scalar-app .overflow-x-hidden{overflow-x:hidden}.scalar-app .overflow-y-auto{overflow-y:auto}.scalar-app .overflow-y-hidden{overflow-y:hidden}.scalar-app .overflow-y-scroll{overflow-y:scroll}.scalar-app .overscroll-contain{overscroll-behavior:contain}.scalar-app .\\!rounded-none{border-radius:0!important}.scalar-app .rounded{border-radius:var(--scalar-radius)}.scalar-app .rounded-\\[inherit\\]{border-radius:inherit}.scalar-app .rounded-full{border-radius:9999px}.scalar-app .rounded-lg{border-radius:var(--scalar-radius-lg)}.scalar-app .rounded-md{border-radius:var(--scalar-radius)}.scalar-app .rounded-none{border-radius:0}.scalar-app .rounded-px{border-radius:1px}.scalar-app .rounded-xl{border-radius:var(--scalar-radius-xl)}.scalar-app .rounded-t{border-top-left-radius:var(--scalar-radius);border-top-right-radius:var(--scalar-radius)}.scalar-app .rounded-t-lg{border-top-left-radius:var(--scalar-radius-lg);border-top-right-radius:var(--scalar-radius-lg)}.scalar-app .rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .rounded-t-xl{border-top-left-radius:var(--scalar-radius-xl);border-top-right-radius:var(--scalar-radius-xl)}.scalar-app .\\!rounded-b-xl{border-bottom-right-radius:var(--scalar-radius-xl)!important;border-bottom-left-radius:var(--scalar-radius-xl)!important}.scalar-app .rounded-b{border-bottom-right-radius:var(--scalar-radius);border-bottom-left-radius:var(--scalar-radius)}.scalar-app .rounded-b-lg{border-bottom-right-radius:var(--scalar-radius-lg);border-bottom-left-radius:var(--scalar-radius-lg)}.scalar-app .rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.scalar-app .rounded-b-xl{border-bottom-right-radius:var(--scalar-radius-xl);border-bottom-left-radius:var(--scalar-radius-xl)}.scalar-app .border{border-style:var(--tw-border-style);border-width:var(--scalar-border-width)}.scalar-app .border-0{border-style:var(--tw-border-style);border-width:0}.scalar-app .border-1,.scalar-app .border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.scalar-app .border-x{border-inline-style:var(--tw-border-style);border-inline-width:var(--scalar-border-width)}.scalar-app .border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}.scalar-app .border-y{border-block-style:var(--tw-border-style);border-block-width:var(--scalar-border-width)}.scalar-app .border-t{border-top-style:var(--tw-border-style);border-top-width:var(--scalar-border-width)}.scalar-app .border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.scalar-app .\\!border-r{border-right-style:var(--tw-border-style)!important;border-right-width:var(--scalar-border-width)!important}.scalar-app .border-r{border-right-style:var(--tw-border-style);border-right-width:var(--scalar-border-width)}.scalar-app .border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.scalar-app .border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:var(--scalar-border-width)}.scalar-app .border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .border-l{border-left-style:var(--tw-border-style);border-left-width:var(--scalar-border-width)}.scalar-app .border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.scalar-app .border-dashed{--tw-border-style:dashed;border-style:dashed}.scalar-app .border-none{--tw-border-style:none;border-style:none}.scalar-app .\\!border-current{border-color:currentColor!important}.scalar-app .border-\\(--scalar-background-3\\){border-color:var(--scalar-background-3)}.scalar-app .border-border-header{border-color:var(--scalar-header-border-color,var(--scalar-border-color))}.scalar-app .border-c-accent,.scalar-app .border-c-accent\\/30{border-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .border-c-accent\\/30{border-color:color-mix(in oklab, var(--scalar-color-accent) 30%, transparent)}}.scalar-app .border-c-alert{border-color:var(--scalar-color-alert)}.scalar-app .border-c-danger{border-color:var(--scalar-color-danger)}.scalar-app .border-sidebar-border{border-color:var(--scalar-sidebar-border-color,var(--scalar-border-color))}.scalar-app .border-sidebar-border-search{border-color:var(--scalar-sidebar-search-border-color,var(--scalar-border-color))}.scalar-app .border-transparent{border-color:#0000}.scalar-app .border-r-transparent{border-right-color:#0000}.scalar-app .border-b-sidebar-border{border-bottom-color:var(--scalar-sidebar-border-color,var(--scalar-border-color))}.scalar-app .bg-\\(--bg-light\\){background-color:var(--bg-light)}.scalar-app .bg-b-1,.scalar-app .bg-b-1\\.5{background-color:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-b-1\\.5{background-color:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.scalar-app .bg-b-2{background-color:var(--scalar-background-2)}.scalar-app .bg-b-3{background-color:var(--scalar-background-3)}.scalar-app .bg-b-alert{background-color:var(--scalar-background-alert)}.scalar-app .bg-b-btn{background-color:var(--scalar-button-1)}.scalar-app .bg-b-danger{background-color:var(--scalar-background-danger)}.scalar-app .bg-b-header-1{background-color:var(--scalar-header-background-1,var(--scalar-background-1))}.scalar-app .bg-b-header-cta{background-color:var(--scalar-header-call-to-action-color,var(--scalar-button-1))}.scalar-app .bg-b-tooltip{background-color:var(--scalar-tooltip-background)}.scalar-app .bg-backdrop{background-color:#00000038}.scalar-app .bg-border{background-color:var(--scalar-border-color)}.scalar-app .bg-c-accent,.scalar-app .bg-c-accent\\/5{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-c-accent\\/5{background-color:color-mix(in oklab, var(--scalar-color-accent) 5%, transparent)}}.scalar-app .bg-c-accent\\/10{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-c-accent\\/10{background-color:color-mix(in oklab, var(--scalar-color-accent) 10%, transparent)}}.scalar-app .bg-c-danger{background-color:var(--scalar-color-danger)}.scalar-app .bg-current{background-color:currentColor}.scalar-app .bg-inherit{background-color:inherit}.scalar-app .bg-sidebar-b-1{background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .bg-sidebar-b-active{background-color:var(--scalar-sidebar-item-active-background,var(--scalar-background-2))}.scalar-app .bg-sidebar-b-search{background-color:var(--scalar-sidebar-search-background,var(--scalar-background-2))}.scalar-app .bg-sidebar-c-2\\/15{background-color:var(--scalar-sidebar-color-2,var(--scalar-color-2))}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-sidebar-c-2\\/15{background-color:color-mix(in oklab, var(--scalar-sidebar-color-2,var(--scalar-color-2)) 15%, transparent)}}.scalar-app .bg-sidebar-indent-border{background-color:var(--scalar-sidebar-indent-border,var(--scalar-border-color))}.scalar-app .bg-sidebar-indent-border-active{background-color:var(--scalar-sidebar-indent-border-active,var(--scalar-color-accent))}.scalar-app .bg-transparent{background-color:#0000}.scalar-app .bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.scalar-app .bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .bg-linear-to-l{--tw-gradient-position:to left}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .bg-linear-to-l{--tw-gradient-position:to left in oklab}}.scalar-app .bg-linear-to-l{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .from-b-1{--tw-gradient-from:var(--scalar-background-1);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .from-40\\%{--tw-gradient-from-position:40%}.scalar-app .to-b-1\\.5{--tw-gradient-to:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.scalar-app .to-b-1\\.5{--tw-gradient-to:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.scalar-app .to-b-1\\.5{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .to-b-2{--tw-gradient-to:var(--scalar-background-2);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .mask-y-from-\\[calc\\(100\\%-8px\\)\\]{-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top);--tw-mask-top:linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position));--tw-mask-top-from-position:calc(100% - 8px);--tw-mask-bottom:linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position));--tw-mask-bottom-from-position:calc(100% - 8px);-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.scalar-app .mask-y-to-100\\%{-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top);--tw-mask-top:linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position));--tw-mask-top-to-position:100%;--tw-mask-bottom:linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position));--tw-mask-bottom-to-position:100%;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.scalar-app .mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.scalar-app .fill-current{fill:currentColor}.scalar-app .\\!p-0{padding:0!important}.scalar-app .p-\\(--scalar-sidebar-padding\\){padding:var(--scalar-sidebar-padding)}.scalar-app .p-0{padding:0}.scalar-app .p-0\\.5{padding:2px}.scalar-app .p-0\\.75{padding:3px}.scalar-app .p-1{padding:4px}.scalar-app .p-1\\.5{padding:6px}.scalar-app .p-1\\.25{padding:5px}.scalar-app .p-1\\.75{padding:7px}.scalar-app .p-2{padding:8px}.scalar-app .p-2\\.5{padding:10px}.scalar-app .p-3{padding:12px}.scalar-app .p-4{padding:16px}.scalar-app .p-7{padding:28px}.scalar-app .p-\\[3px\\]{padding:3px}.scalar-app .p-px{padding:1px}.scalar-app .px-\\(--scalar-sidebar-padding\\){padding-inline:var(--scalar-sidebar-padding)}.scalar-app .px-0{padding-inline:0}.scalar-app .px-0\\.5{padding-inline:2px}.scalar-app .px-0\\.75{padding-inline:3px}.scalar-app .px-1{padding-inline:4px}.scalar-app .px-1\\.5{padding-inline:6px}.scalar-app .px-1\\.25{padding-inline:5px}.scalar-app .px-2{padding-inline:8px}.scalar-app .px-2\\.5{padding-inline:10px}.scalar-app .px-3{padding-inline:12px}.scalar-app .px-3\\.5{padding-inline:14px}.scalar-app .px-4{padding-inline:16px}.scalar-app .px-5{padding-inline:20px}.scalar-app .px-6{padding-inline:24px}.scalar-app .px-15{padding-inline:60px}.scalar-app .py-0{padding-block:0}.scalar-app .py-0\\.5{padding-block:2px}.scalar-app .py-0\\.25{padding-block:1px}.scalar-app .py-0\\.75{padding-block:3px}.scalar-app .py-1{padding-block:4px}.scalar-app .py-1\\.5{padding-block:6px}.scalar-app .py-1\\.25{padding-block:5px}.scalar-app .py-1\\.75{padding-block:7px}.scalar-app .py-2{padding-block:8px}.scalar-app .py-2\\.5{padding-block:10px}.scalar-app .py-2\\.25{padding-block:9px}.scalar-app .py-3{padding-block:12px}.scalar-app .py-4{padding-block:16px}.scalar-app .py-\\[6\\.75px\\]{padding-block:6.75px}.scalar-app .py-px{padding-block:1px}.scalar-app .pt-\\(--scalar-sidebar-padding\\){padding-top:var(--scalar-sidebar-padding)}.scalar-app .pt-1{padding-top:4px}.scalar-app .pt-2{padding-top:8px}.scalar-app .pt-3{padding-top:12px}.scalar-app .pt-px{padding-top:1px}.scalar-app .pr-0{padding-right:0}.scalar-app .pr-0\\.75{padding-right:3px}.scalar-app .pr-1{padding-right:4px}.scalar-app .pr-1\\.5{padding-right:6px}.scalar-app .pr-2{padding-right:8px}.scalar-app .pr-2\\.5{padding-right:10px}.scalar-app .pr-2\\.25{padding-right:9px}.scalar-app .pr-3{padding-right:12px}.scalar-app .pr-6{padding-right:24px}.scalar-app .pr-8{padding-right:32px}.scalar-app .pr-10{padding-right:40px}.scalar-app .pr-12{padding-right:48px}.scalar-app .pr-20{padding-right:80px}.scalar-app .pr-\\[100\\%\\]{padding-right:100%}.scalar-app .pb-1{padding-bottom:4px}.scalar-app .pb-1\\.5{padding-bottom:6px}.scalar-app .pb-3{padding-bottom:12px}.scalar-app .pb-6{padding-bottom:24px}.scalar-app .pb-12{padding-bottom:48px}.scalar-app .\\!pl-3{padding-left:12px!important}.scalar-app .pl-1{padding-left:4px}.scalar-app .pl-1\\.25{padding-left:5px}.scalar-app .pl-2{padding-left:8px}.scalar-app .pl-3{padding-left:12px}.scalar-app .pl-4{padding-left:16px}.scalar-app .pl-8{padding-left:32px}.scalar-app .pl-8\\.5{padding-left:34px}.scalar-app .pl-\\[100\\%\\]{padding-left:100%}.scalar-app .pl-px{padding-left:1px}.scalar-app .text-center{text-align:center}.scalar-app .text-left{text-align:left}.scalar-app .text-right{text-align:right}.scalar-app .font-code{font-family:var(--scalar-font-code)}.scalar-app .font-sans{font-family:var(--scalar-font)}.scalar-app .text-base{font-size:var(--scalar-font-size-3);line-height:var(--tw-leading,calc(1 / .875))}.scalar-app .text-base\\/4{font-size:var(--scalar-font-size-3);line-height:16px}.scalar-app .text-base\\/5{font-size:var(--scalar-font-size-3);line-height:20px}.scalar-app .text-base\\/5\\.25{font-size:var(--scalar-font-size-3);line-height:21px}.scalar-app .text-lg{font-size:var(--scalar-font-size-2);line-height:var(--tw-leading,calc(1.25 / 1))}.scalar-app .text-sm\\/4{font-size:var(--scalar-font-size-4);line-height:16px}.scalar-app .text-sm\\/none{font-size:var(--scalar-font-size-4);line-height:1}.scalar-app .text-xs\\/4{font-size:var(--scalar-font-size-5);line-height:16px}.scalar-app .text-3xs{font-size:var(--scalar-font-size-7)}.scalar-app .text-\\[6px\\]{font-size:6px}.scalar-app .text-\\[9px\\]{font-size:9px}.scalar-app .text-\\[10px\\]{font-size:10px}.scalar-app .text-\\[11px\\]{font-size:11px}.scalar-app .text-sm{font-size:var(--scalar-font-size-4)}.scalar-app .text-xs{font-size:var(--scalar-font-size-5)}.scalar-app .text-xxs{font-size:var(--scalar-font-size-6)}.scalar-app .leading-5{--tw-leading:calc(4px * 5);line-height:20px}.scalar-app .leading-5\\.5{--tw-leading:calc(4px * 5.5);line-height:22px}.scalar-app .leading-6{--tw-leading:calc(4px * 6);line-height:24px}.scalar-app .leading-\\[1\\.44\\]{--tw-leading:1.44;line-height:1.44}.scalar-app .leading-\\[1\\.45\\]{--tw-leading:1.45;line-height:1.45}.scalar-app .leading-\\[7px\\]{--tw-leading:7px;line-height:7px}.scalar-app .leading-\\[20px\\]{--tw-leading:20px;line-height:20px}.scalar-app .leading-\\[22px\\]{--tw-leading:22px;line-height:22px}.scalar-app .leading-\\[normal\\]{--tw-leading:normal;line-height:normal}.scalar-app .leading-none{--tw-leading:1;line-height:1}.scalar-app .leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.scalar-app .leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.scalar-app .leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.scalar-app .font-bold{--tw-font-weight:var(--scalar-bold);font-weight:var(--scalar-bold)}.scalar-app .font-medium{--tw-font-weight:var(--scalar-semibold);font-weight:var(--scalar-semibold)}.scalar-app .font-normal{--tw-font-weight:var(--scalar-regular);font-weight:var(--scalar-regular)}.scalar-app .font-sidebar{--tw-font-weight:var(--scalar-sidebar-font-weight,var(--scalar-regular));font-weight:var(--scalar-sidebar-font-weight,var(--scalar-regular))}.scalar-app .font-sidebar-active{--tw-font-weight:var(--scalar-sidebar-font-weight-active,var(--scalar-semibold));font-weight:var(--scalar-sidebar-font-weight-active,var(--scalar-semibold))}.scalar-app .text-balance{text-wrap:balance}.scalar-app .text-nowrap{text-wrap:nowrap}.scalar-app .text-pretty{text-wrap:pretty}.scalar-app .text-wrap{text-wrap:wrap}.scalar-app .break-words,.scalar-app .wrap-break-word{overflow-wrap:break-word}.scalar-app .text-ellipsis{text-overflow:ellipsis}.scalar-app .whitespace-nowrap{white-space:nowrap}.scalar-app .whitespace-pre{white-space:pre}.scalar-app .whitespace-pre-wrap{white-space:pre-wrap}.scalar-app .\\!text-c-1{color:var(--scalar-color-1)!important}.scalar-app .text-\\[color\\:var\\(--scalar-color-3\\)\\]{color:var(--scalar-color-3)}.scalar-app .text-b-1{color:var(--scalar-background-1)}.scalar-app .text-b-2{color:var(--scalar-background-2)}.scalar-app .text-blue{color:var(--scalar-color-blue)}.scalar-app .text-c-1{color:var(--scalar-color-1)}.scalar-app .text-c-2{color:var(--scalar-color-2)}.scalar-app .text-c-3{color:var(--scalar-color-3)}.scalar-app .text-c-accent{color:var(--scalar-color-accent)}.scalar-app .text-c-alert{color:var(--scalar-color-alert)}.scalar-app .text-c-btn{color:var(--scalar-button-1-color)}.scalar-app .text-c-danger{color:var(--scalar-color-danger)}.scalar-app .text-c-header-1{color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .text-c-header-2{color:var(--scalar-header-color-2,var(--scalar-color-2))}.scalar-app .text-c-header-cta{color:var(--scalar-button-1-color)}.scalar-app .text-c-tooltip{color:var(--scalar-tooltip-color)}.scalar-app .text-current{color:currentColor}.scalar-app .text-green{color:var(--scalar-color-green)}.scalar-app .text-grey{color:var(--scalar-color-3)}.scalar-app .text-orange{color:var(--scalar-color-orange)}.scalar-app .text-purple{color:var(--scalar-color-purple)}.scalar-app .text-red{color:var(--scalar-color-red)}.scalar-app .text-sidebar-c-1{color:var(--scalar-sidebar-color-1,var(--scalar-color-1))}.scalar-app .text-sidebar-c-2{color:var(--scalar-sidebar-color-2,var(--scalar-color-2))}.scalar-app .text-sidebar-c-active{color:var(--scalar-sidebar-color-active,var(--scalar-sidebar-color-1))}.scalar-app .text-sidebar-c-search{color:var(--scalar-sidebar-search-color,var(--scalar-color-3))}.scalar-app .text-transparent{color:#0000}.scalar-app .text-white{color:#fff}.scalar-app .text-yellow{color:var(--scalar-color-yellow)}.scalar-app .capitalize{text-transform:capitalize}.scalar-app .uppercase{text-transform:uppercase}.scalar-app .italic{font-style:italic}.scalar-app .tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.scalar-app .line-through{text-decoration-line:line-through}.scalar-app .no-underline{text-decoration-line:none}.scalar-app .underline{text-decoration-line:underline}.scalar-app .decoration-c-3{-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);text-decoration-color:var(--scalar-color-3)}.scalar-app .decoration-dotted{text-decoration-style:dotted}.scalar-app .underline-offset-2{text-underline-offset:2px}.scalar-app .opacity-0{opacity:0}.scalar-app .opacity-40{opacity:.4}.scalar-app .opacity-50{opacity:.5}.scalar-app .opacity-100{opacity:1}.scalar-app .bg-blend-normal{background-blend-mode:normal}.scalar-app .shadow{--tw-shadow:var(--scalar-shadow-1);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-\\[-8px_0_4px_var\\(--scalar-background-1\\)\\]{--tw-shadow:-8px 0 4px var(--tw-shadow-color,var(--scalar-background-1));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-border{--tw-shadow:inset 0 0 0 var(--tw-shadow-color,var(--scalar-border-width)) var(--scalar-border-color);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-lg{--tw-shadow:var(--scalar-shadow-2);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-md{--tw-shadow:var(--scalar-shadow-1);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .outline{outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .-outline-offset-1{outline-offset:calc(1px * -1)}.scalar-app .-outline-offset-2{outline-offset:calc(2px * -1)}.scalar-app .outline-offset-1{outline-offset:1px}.scalar-app .outline-offset-2{outline-offset:2px}.scalar-app .outline-offset-\\[-1px\\]{outline-offset:-1px}.scalar-app .outline-c-danger{outline-color:var(--scalar-color-danger)}.scalar-app .blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .brightness-90{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .brightness-lifted{--tw-brightness:brightness(var(--scalar-lifted-brightness));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.scalar-app .backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.scalar-app .transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-none{transition-property:none}.scalar-app .duration-100{--tw-duration:.1s;transition-duration:.1s}.scalar-app .duration-150{--tw-duration:.15s;transition-duration:.15s}.scalar-app .duration-200{--tw-duration:.2s;transition-duration:.2s}.scalar-app .duration-300{--tw-duration:.3s;transition-duration:.3s}.scalar-app .duration-400{--tw-duration:.4s;transition-duration:.4s}.scalar-app .duration-500{--tw-duration:.5s;transition-duration:.5s}.scalar-app .ease-\\[cubic-bezier\\(0\\.77\\,0\\,0\\.175\\,1\\)\\]{--tw-ease:cubic-bezier(.77,0,.175,1);transition-timing-function:cubic-bezier(.77,0,.175,1)}.scalar-app .ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.scalar-app .ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.scalar-app .ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.scalar-app .ease-spring{--tw-ease:linear(0, .008 1.1%, .034 2.3%, .134 4.9%, .264 7.3%, .683 14.3%, .797 16.5%, .89 18.6%, .967 20.7%, 1.027 22.8%, 1.073 25%, 1.104 27.3%, 1.123 30.6%, 1.119 34.3%, 1.018 49.5%, .988 58.6%, .985 65.2%, 1 84.5%, 1);transition-timing-function:linear(0, .008 1.1%, .034 2.3%, .134 4.9%, .264 7.3%, .683 14.3%, .797 16.5%, .89 18.6%, .967 20.7%, 1.027 22.8%, 1.073 25%, 1.104 27.3%, 1.123 30.6%, 1.119 34.3%, 1.018 49.5%, .988 58.6%, .985 65.2%, 1 84.5%, 1)}.scalar-app .outline-none{--tw-outline-style:none;outline-style:none}.scalar-app .select-none{-webkit-user-select:none;user-select:none}.scalar-app .\\[--scalar-address-bar-height\\:0px\\]{--scalar-address-bar-height:0px}.scalar-app .\\[--scalar-address-bar-height\\:32px\\]{--scalar-address-bar-height:32px}.scalar-app .\\[grid-area\\:header\\]{grid-area:header}.scalar-app .\\[grid-area\\:navigation\\]{grid-area:navigation}:is(.scalar-app .\\*\\:flex>*){display:flex}:is(.scalar-app .\\*\\:size-3>*){width:12px;height:12px}:is(.scalar-app .\\*\\:size-4>*){width:16px;height:16px}:is(.scalar-app .\\*\\:h-5>*){height:20px}:is(.scalar-app .\\*\\:h-8>*){height:32px}:is(.scalar-app .\\*\\:min-w-5>*){min-width:20px}:is(.scalar-app .\\*\\:flex-1>*){flex:1}:is(.scalar-app .\\*\\:cursor-pointer>*){cursor:pointer}:is(.scalar-app .\\*\\:items-center>*){align-items:center}:is(.scalar-app .\\*\\:justify-center>*){justify-content:center}:is(.scalar-app .\\*\\:gap-1>*){gap:4px}:is(.scalar-app .\\*\\:rounded>*){border-radius:var(--scalar-radius)}:is(.scalar-app .\\*\\:rounded-none>*){border-radius:0}:is(.scalar-app .\\*\\:border>*){border-style:var(--tw-border-style);border-width:var(--scalar-border-width)}:is(.scalar-app .\\*\\:border-t>*){border-top-style:var(--tw-border-style);border-top-width:var(--scalar-border-width)}:is(.scalar-app .\\*\\:border-border-tooltip>*){border-color:var(--scalar-tooltip-color)}@supports (color:color-mix(in lab, red, red)){:is(.scalar-app .\\*\\:border-border-tooltip>*){border-color:color-mix(in srgb, var(--scalar-tooltip-color), var(--scalar-tooltip-background))}}:is(.scalar-app .\\*\\:px-1>*){padding-inline:4px}:is(.scalar-app .\\*\\:px-1\\.5>*){padding-inline:6px}:is(.scalar-app .\\*\\:pl-4>*){padding-left:16px}.scalar-app .group-first\\/row\\:border-t-0:is(:where(.group\\/row):first-child *){border-top-style:var(--tw-border-style);border-top-width:0}.scalar-app .group-last\\:mr-0:is(:where(.group):last-child *){margin-right:0}.scalar-app .group-last\\:border-b-transparent:is(:where(.group):last-child *){border-bottom-color:#0000}.scalar-app .group-last\\/label\\:rounded-br-lg:is(:where(.group\\/label):last-child *){border-bottom-right-radius:var(--scalar-radius-lg)}.scalar-app .group-open\\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.scalar-app .group-open\\:flex-wrap:is(:where(.group):is([open],:popover-open,:open) *){flex-wrap:wrap}.scalar-app .group-open\\:whitespace-normal:is(:where(.group):is([open],:popover-open,:open) *){white-space:normal}.scalar-app .group-focus-within\\:flex:is(:where(.group):focus-within *){display:flex}.scalar-app .group-focus-within\\/parameter-item\\:opacity-100:is(:where(.group\\/parameter-item):focus-within *),.scalar-app .group-focus-within\\/scope-row\\:opacity-100:is(:where(.group\\/scope-row):focus-within *){opacity:1}@media (hover:hover){.scalar-app .group-hover\\:flex:is(:where(.group):hover *){display:flex}.scalar-app .group-hover\\:pr-10:is(:where(.group):hover *){padding-right:40px}.scalar-app .group-hover\\:text-c-1:is(:where(.group):hover *){color:var(--scalar-color-1)}.scalar-app .group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.scalar-app .group-hover\\/button\\:bg-sidebar-indent-border-hover:is(:where(.group\\/button):hover *){background-color:var(--scalar-sidebar-indent-border-hover,var(--scalar-border-color))}.scalar-app .group-hover\\/button\\:text-c-header-1:is(:where(.group\\/button):hover *){color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .group-hover\\/button\\:opacity-0:is(:where(.group\\/button):hover *){opacity:0}.scalar-app .group-hover\\/heading\\:opacity-100:is(:where(.group\\/heading):hover *),.scalar-app .group-hover\\/item\\:opacity-100:is(:where(.group\\/item):hover *),.scalar-app .group-hover\\/parameter-item\\:opacity-100:is(:where(.group\\/parameter-item):hover *),.scalar-app .group-hover\\/params\\:opacity-100:is(:where(.group\\/params):hover *){opacity:1}.scalar-app .group-hover\\/row\\:flex:is(:where(.group\\/row):hover *){display:flex}.scalar-app .group-hover\\/scope-row\\:text-c-1:is(:where(.group\\/scope-row):hover *){color:var(--scalar-color-1)}.scalar-app .group-hover\\/scope-row\\:opacity-100:is(:where(.group\\/scope-row):hover *){opacity:1}.scalar-app .group-hover\\/scopes-accordion\\:text-c-2:is(:where(.group\\/scopes-accordion):hover *){color:var(--scalar-color-2)}}.scalar-app .group-focus-visible\\:outline:is(:where(.group):focus-visible *){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .group-focus-visible\\/button\\:opacity-0:is(:where(.group\\/button):focus-visible *){opacity:0}.scalar-app .group-focus-visible\\/toggle\\:outline:is(:where(.group\\/toggle):focus-visible *){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .group-has-focus-visible\\/heading\\:opacity-100:is(:where(.group\\/heading):has(:focus-visible) *){opacity:1}.scalar-app .group-has-\\[\\.cm-focused\\]\\:z-1:is(:where(.group):has(.cm-focused) *){z-index:1}.scalar-app .group-has-\\[\\.cm-focused\\]\\:flex:is(:where(.group):has(.cm-focused) *){display:flex}.scalar-app .group-has-\\[\\.cm-focused\\]\\:pr-10:is(:where(.group):has(.cm-focused) *){padding-right:40px}.scalar-app .group-has-\\[\\:focus-visible\\]\\/cell\\:border-c-accent:is(:where(.group\\/cell):has(:focus-visible) *){border-color:var(--scalar-color-accent)}.scalar-app .group-has-\\[\\:focus-visible\\]\\/cell\\:opacity-100:is(:where(.group\\/cell):has(:focus-visible) *){opacity:1}.scalar-app .group-has-\\[\\:focus-visible\\]\\/input\\:block:is(:where(.group\\/input):has(:focus-visible) *){display:block}.scalar-app .group-has-\\[input\\]\\/label\\:mr-0:is(:where(.group\\/label):has(:is(input)) *){margin-right:0}.scalar-app .group-has-\\[\\~\\*_\\[aria-expanded\\=true\\]\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~* [aria-expanded=true]) *),.scalar-app .group-has-\\[\\~\\*\\:focus-within\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~:focus-within) *),.scalar-app .group-has-\\[\\~\\*\\:hover\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~:hover) *){opacity:0}.scalar-app .group-aria-expanded\\/button\\:rotate-180:is(:where(.group\\/button)[aria-expanded=true] *),.scalar-app .group-aria-expanded\\/combobox-button\\:rotate-180:is(:where(.group\\/combobox-button)[aria-expanded=true] *){rotate:180deg}.scalar-app .group-hocus\\/copy-button\\:sr-only:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.scalar-app .group-hocus\\/copy-button\\:not-sr-only:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.scalar-app .group-hocus\\/copy-button\\:block:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){display:block}.scalar-app .group-hocus-within\\/code-block\\:-left-0\\.5:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){left:-2px}.scalar-app .group-hocus-within\\/code-block\\:inline:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){display:inline}.scalar-app .group-hocus-within\\/code-block\\:opacity-100:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){opacity:1}.scalar-app .group-\\[\\.alert\\]\\:bg-b-alert:is(:where(.group).alert *){background-color:var(--scalar-background-alert)}.scalar-app .group-\\[\\.alert\\]\\:bg-transparent:is(:where(.group).alert *){background-color:#0000}.scalar-app .group-\\[\\.alert\\]\\:shadow-none:is(:where(.group).alert *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .group-\\[\\.alert\\]\\:outline-orange:is(:where(.group).alert *){outline-color:var(--scalar-color-orange)}.scalar-app .group-\\[\\.error\\]\\:bg-b-danger:is(:where(.group).error *){background-color:var(--scalar-background-danger)}.scalar-app .group-\\[\\.error\\]\\:bg-transparent:is(:where(.group).error *){background-color:#0000}.scalar-app .group-\\[\\.error\\]\\:text-red:is(:where(.group).error *){color:var(--scalar-color-red)}.scalar-app .group-\\[\\.error\\]\\:shadow-none:is(:where(.group).error *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .group-\\[\\.error\\]\\:outline-red:is(:where(.group).error *){outline-color:var(--scalar-color-red)}.scalar-app .peer-checked\\:text-c-1:is(:where(.peer):checked~*){color:var(--scalar-color-1)}@media (hover:hover){.scalar-app .peer-hover\\/button\\:opacity-100:is(:where(.peer\\/button):hover~*){opacity:1}}.scalar-app .peer-focus-visible\\/button\\:opacity-100:is(:where(.peer\\/button):focus-visible~*){opacity:1}.scalar-app .peer-has-\\[\\.cm-focused\\]\\:opacity-0:is(:where(.peer):has(.cm-focused)~*){opacity:0}.scalar-app .peer-has-\\[\\.color-selector\\]\\:hidden:is(:where(.peer):has(.color-selector)~*){display:none}.scalar-app .placeholder\\:font-\\[inherit\\]::placeholder{font-family:inherit}:is(.scalar-app .\\*\\:not-first\\:before\\:content-\\[\\'_·_\\'\\]>*):not(:first-child):before{--tw-content:" · ";content:var(--tw-content)}.scalar-app .after\\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.scalar-app .after\\:absolute:after{content:var(--tw-content);position:absolute}.scalar-app .after\\:inset-0:after{content:var(--tw-content);inset:0}.scalar-app .after\\:inset-x-0:after{content:var(--tw-content);inset-inline:0}.scalar-app .after\\:-top-0\\.5:after{content:var(--tw-content);top:-2px}.scalar-app .after\\:-bottom-0\\.5:after{content:var(--tw-content);bottom:-2px}.scalar-app .after\\:block:after{content:var(--tw-content);display:block}.scalar-app .after\\:h-0\\.75:after{content:var(--tw-content);height:3px}.scalar-app .after\\:rounded:after{content:var(--tw-content);border-radius:var(--scalar-radius)}.scalar-app .after\\:bg-blue:after{content:var(--tw-content);background-color:var(--scalar-color-blue)}.scalar-app .after\\:opacity-15:after{content:var(--tw-content);opacity:.15}.scalar-app .after\\:content-\\[\\'\\:\\'\\]:after{--tw-content:":";content:var(--tw-content)}.scalar-app .first\\:rounded-t-\\[inherit\\]:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}:is(.scalar-app .\\*\\:first\\:rounded-l>*):first-child{border-top-left-radius:var(--scalar-radius);border-bottom-left-radius:var(--scalar-radius)}:is(.scalar-app .\\*\\:first\\:border-t-0>*):first-child,:is(.scalar-app .first\\:\\*\\:border-t-0:first-child>*){border-top-style:var(--tw-border-style);border-top-width:0}:is(.scalar-app .\\*\\:first\\:p-3>*):first-child{padding:12px}.scalar-app .last\\:rounded-b-\\[inherit\\]:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.scalar-app .last\\:rounded-b-lg:last-child{border-bottom-right-radius:var(--scalar-radius-lg);border-bottom-left-radius:var(--scalar-radius-lg)}.scalar-app .last\\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}:is(.scalar-app .\\*\\:last\\:rounded-r>*):last-child{border-top-right-radius:var(--scalar-radius);border-bottom-right-radius:var(--scalar-radius)}.scalar-app .last-of-type\\:first-of-type\\:border-b-0:last-of-type:first-of-type{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .empty\\:hidden:empty{display:none}.scalar-app .focus-within\\:bg-b-1:focus-within{background-color:var(--scalar-background-1)}.scalar-app .focus-within\\:text-c-1:focus-within{color:var(--scalar-color-1)}.scalar-app .focus-within\\:opacity-100:focus-within{opacity:1}.scalar-app .focus-within\\:outline-none:focus-within{--tw-outline-style:none;outline-style:none}@media (hover:hover){.scalar-app .hover\\:bg-b-2:hover{background-color:var(--scalar-background-2)}.scalar-app .hover\\:bg-b-3:hover{background-color:var(--scalar-background-3)}.scalar-app .hover\\:bg-b-header-2:hover{background-color:var(--scalar-header-background-2,var(--scalar-background-2))}.scalar-app .hover\\:bg-c-accent\\/20:hover{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .hover\\:bg-c-accent\\/20:hover{background-color:color-mix(in oklab, var(--scalar-color-accent) 20%, transparent)}}.scalar-app .hover\\:bg-h-btn:hover{background-color:var(--scalar-button-1-hover)}.scalar-app .hover\\:bg-h-header-cta:hover{background-color:var(--scalar-header-call-to-action-color,var(--scalar-button-1))}@supports (color:color-mix(in lab, red, red)){.scalar-app .hover\\:bg-h-header-cta:hover{background-color:color-mix(in srgb, var(--scalar-header-call-to-action-color,var(--scalar-button-1)), var(--scalar-header-background-1,var(--scalar-background-1)) 15%)}}.scalar-app .hover\\:bg-sidebar-b-1:hover{background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .hover\\:bg-sidebar-b-hover:hover{background-color:var(--scalar-sidebar-item-hover-background,var(--scalar-background-2))}.scalar-app .hover\\:bg-linear-to-b:hover{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .hover\\:bg-linear-to-b:hover{--tw-gradient-position:to bottom in oklab}}.scalar-app .hover\\:bg-linear-to-b:hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .hover\\:bg-linear-to-t:hover{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .hover\\:bg-linear-to-t:hover{--tw-gradient-position:to top in oklab}}.scalar-app .hover\\:bg-linear-to-t:hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .hover\\:text-c-1:hover{color:var(--scalar-color-1)}.scalar-app .hover\\:text-c-2:hover{color:var(--scalar-color-2)}.scalar-app .hover\\:text-c-header-1:hover{color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .hover\\:text-sidebar-c-1:hover{color:var(--scalar-sidebar-color-1,var(--scalar-color-1))}.scalar-app .hover\\:text-sidebar-c-hover:hover{color:var(--scalar-sidebar-item-hover-color,var(--scalar-sidebar-color-2))}.scalar-app .hover\\:underline:hover{text-decoration-line:underline}.scalar-app .hover\\:opacity-100:hover{opacity:1}.scalar-app .hover\\:brightness-75:hover{--tw-brightness:brightness(75%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .hover\\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.scalar-app .focus\\:border-b-1:focus{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--scalar-background-1)}.scalar-app .focus\\:text-c-1:focus{color:var(--scalar-color-1)}.scalar-app .focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.scalar-app .focus-visible\\:border-c-btn:focus-visible{border-color:var(--scalar-button-1-color)}.scalar-app .focus-visible\\:opacity-100:focus-visible{opacity:1}.scalar-app .focus-visible\\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .focus-visible\\:outline-offset-2:focus-visible{outline-offset:2px}.scalar-app .active\\:bg-b-btn:active{background-color:var(--scalar-button-1)}.scalar-app .active\\:text-c-1:active{color:var(--scalar-color-1)}.scalar-app .active\\:brightness-90:active{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .disabled\\:pointer-events-none:disabled{pointer-events:none}.scalar-app .disabled\\:cursor-default:disabled{cursor:default}.scalar-app .disabled\\:text-c-2:disabled{color:var(--scalar-color-2)}.scalar-app .disabled\\:opacity-30:disabled{opacity:.3}.scalar-app .has-focus-visible\\:outline:has(:focus-visible){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .has-\\[\\:focus-visible\\]\\:absolute:has(:focus-visible){position:absolute}.scalar-app .has-\\[\\:focus-visible\\]\\:z-1:has(:focus-visible){z-index:1}.scalar-app .has-\\[\\:focus-visible\\]\\:rounded-\\[4px\\]:has(:focus-visible){border-radius:4px}.scalar-app .has-\\[\\:focus-visible\\]\\:bg-b-1:has(:focus-visible){background-color:var(--scalar-background-1)}.scalar-app .has-\\[\\:focus-visible\\]\\:bg-sidebar-b-1:has(:focus-visible){background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .has-\\[\\:focus-visible\\]\\:opacity-100:has(:focus-visible){opacity:1}:is(.scalar-app .has-\\[\\:focus-visible\\]\\:outline:has(:focus-visible),.scalar-app .has-\\[input\\:focus-visible\\]\\:outline:has(:is(input:focus-visible))){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .has-\\[\\&\\[aria-expanded\\=true\\]\\]\\:opacity-100:has([aria-expanded=true]){opacity:1}@media not all and (width>=800px){.scalar-app .max-md\\:absolute\\!{position:absolute!important}.scalar-app .max-md\\:top-4{top:16px}.scalar-app .max-md\\:z-5{z-index:5}.scalar-app .max-md\\:w-full\\!{width:100%!important}.scalar-app .max-md\\:pt-2{padding-top:8px}.scalar-app .max-md\\:pt-12{padding-top:48px}.scalar-app .max-md\\:pl-4\\!{padding-left:16px!important}.scalar-app .max-md\\:pl-10{padding-left:40px}.scalar-app .max-md\\:pl-14{padding-left:56px}}@media (width>=800px){.scalar-app .md\\:pointer-events-none{pointer-events:none}.scalar-app .md\\:absolute{position:absolute}.scalar-app .md\\:inset-x-1{inset-inline:4px}.scalar-app .md\\:top-1\\/2{top:50%}.scalar-app .md\\:mt-0{margin-top:0}.scalar-app .md\\:-ml-1\\.25{margin-left:-5px}.scalar-app .md\\:w-\\[calc\\(100vw-16px\\)\\]{width:calc(100vw - 16px)}.scalar-app .md\\:-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .md\\:flex-row{flex-direction:row}.scalar-app .md\\:px-1\\.5{padding-inline:6px}.scalar-app .md\\:opacity-0{opacity:0}:is(.scalar-app .md\\:\\*\\:border-t-0>*){border-top-style:var(--tw-border-style);border-top-width:0}@media (hover:hover){.scalar-app .md\\:group-hover\\/upload\\:pointer-events-auto:is(:where(.group\\/upload):hover *){pointer-events:auto}.scalar-app .md\\:group-hover\\/upload\\:opacity-100:is(:where(.group\\/upload):hover *){opacity:1}}}@media (width>=1000px){.scalar-app .lg\\:-mr-1{margin-right:-4px}.scalar-app .lg\\:flex{display:flex}.scalar-app .lg\\:hidden{display:none}.scalar-app .lg\\:min-h-header{min-height:48px}.scalar-app .lg\\:w-\\[calc\\(100vw-32px\\)\\]{width:calc(100vw - 32px)}.scalar-app .lg\\:w-full{width:100%}.scalar-app .lg\\:p-0{padding:0}.scalar-app .lg\\:pr-24{padding-right:96px}}@media (width>=1200px){.scalar-app .xl\\:mb-1\\.5{margin-bottom:6px}.scalar-app .xl\\:flex{display:flex}.scalar-app .xl\\:h-full{height:100%}.scalar-app .xl\\:min-w-0{min-width:0}.scalar-app .xl\\:flex-row{flex-direction:row}.scalar-app .xl\\:gap-12{gap:48px}.scalar-app .xl\\:overflow-hidden{overflow:hidden}.scalar-app .xl\\:rounded-none{border-radius:0}.scalar-app .xl\\:border-r{border-right-style:var(--tw-border-style);border-right-width:var(--scalar-border-width)}.scalar-app .xl\\:border-none{--tw-border-style:none;border-style:none}.scalar-app .xl\\:pr-0\\.5{padding-right:2px}.scalar-app .xl\\:pl-2{padding-left:8px}:is(.scalar-app .\\*\\:xl\\:border-t-0>*){border-top-style:var(--tw-border-style);border-top-width:0}:is(.scalar-app .\\*\\:xl\\:border-l>*){border-left-style:var(--tw-border-style);border-left-width:var(--scalar-border-width)}.scalar-app .xl\\:first\\:ml-auto:first-child{margin-left:auto}:is(.scalar-app .\\*\\:first\\:xl\\:border-l-0>*):first-child{border-left-style:var(--tw-border-style);border-left-width:0}}@container (width>=768px){.scalar-app .\\@3xl\\:order-0{order:0}.scalar-app .\\@3xl\\:mb-0{margin-bottom:0}.scalar-app .\\@3xl\\:ml-0\\.75{margin-left:3px}.scalar-app .\\@3xl\\:flex{display:flex}.scalar-app .\\@3xl\\:hidden{display:none}.scalar-app .\\@3xl\\:flex-nowrap{flex-wrap:nowrap}}.scalar-app .dark\\:bg-\\(--bg-dark\\):where(.dark-mode,.dark-mode *){background-color:var(--bg-dark)}.scalar-app .dark\\:bg-b-3:where(.dark-mode,.dark-mode *){background-color:var(--scalar-background-3)}.scalar-app .dark\\:bg-backdrop-dark:where(.dark-mode,.dark-mode *){background-color:#00000073}.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){--tw-gradient-position:to top in oklab}}.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){background-image:linear-gradient(var(--tw-gradient-stops))}@media (hover:hover){.scalar-app .dark\\:hover\\:bg-b-3:where(.dark-mode,.dark-mode *):hover{background-color:var(--scalar-background-3)}.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to bottom in oklab}}.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to top in oklab}}.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{background-image:linear-gradient(var(--tw-gradient-stops))}}.scalar-app .ui-open\\:rotate-90[data-headlessui-state~=open],:where([data-headlessui-state~=open]) :is(.scalar-app .ui-open\\:rotate-90){rotate:90deg}.scalar-app .ui-open\\:rotate-180[data-headlessui-state~=open],:where([data-headlessui-state~=open]) :is(.scalar-app .ui-open\\:rotate-180){rotate:180deg}.scalar-app .last\\:ui-open\\:border-b-0:last-child[data-headlessui-state~=open],:where([data-headlessui-state~=open]) .scalar-app .last\\:ui-open\\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .ui-not-open\\:hidden[data-headlessui-state]:not([data-headlessui-state~=open]),:where([data-headlessui-state]:not([data-headlessui-state~=open])) :is(.scalar-app .ui-not-open\\:hidden):not([data-headlessui-state]){display:none}.scalar-app .ui-not-open\\:rotate-0[data-headlessui-state]:not([data-headlessui-state~=open]),:where([data-headlessui-state]:not([data-headlessui-state~=open])) :is(.scalar-app .ui-not-open\\:rotate-0):not([data-headlessui-state]){rotate:none}:is(.scalar-app .ui-active\\:\\*\\:bg-b-2[data-headlessui-state~=active]>*),:is(:where([data-headlessui-state~=active]) :is(.scalar-app .ui-active\\:\\*\\:bg-b-2)>*){background-color:var(--scalar-background-2)}@media (width<=720px) and (height<=480px){.scalar-app .zoomed\\:static{position:static}.scalar-app .zoomed\\:p-1{padding:4px}.scalar-app .zoomed\\:whitespace-normal\\!{white-space:normal!important}}.scalar-app .highlighted\\:bg-b-2[data-highlighted]{background-color:var(--scalar-background-2)}.app-platform-mac :is(.scalar-app .mac\\:h-12){height:48px}.app-platform-mac :is(.scalar-app .mac\\:app-drag-region){-webkit-app-region:drag;-webkit-app-region:drag}.scalar-app .\\[\\&_a\\]\\:underline a{text-decoration-line:underline}.scalar-app .\\[\\&_a\\:hover\\]\\:text-c-1 a:hover{color:var(--scalar-color-1)}.scalar-app .\\[\\&_code\\]\\:font-code code{font-family:var(--scalar-font-code)}.scalar-app .\\[\\&_em\\]\\:text-c-1 em{color:var(--scalar-color-1)}.scalar-app .\\[\\&_em\\]\\:not-italic em{font-style:normal}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-mask-linear{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-radial{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-conic{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-left{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-right{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-bottom{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-top{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-top-from-position{syntax:"*";inherits:false;initial-value:0%}@property --tw-mask-top-to-position{syntax:"*";inherits:false;initial-value:100%}@property --tw-mask-top-from-color{syntax:"*";inherits:false;initial-value:black}@property --tw-mask-top-to-color{syntax:"*";inherits:false;initial-value:transparent}@property --tw-mask-bottom-from-position{syntax:"*";inherits:false;initial-value:0%}@property --tw-mask-bottom-to-position{syntax:"*";inherits:false;initial-value:100%}@property --tw-mask-bottom-from-color{syntax:"*";inherits:false;initial-value:black}@property --tw-mask-bottom-to-color{syntax:"*";inherits:false;initial-value:transparent}@property --tw-leading{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes pulse{50%{opacity:.5}}@media (width<=1000px){.agent-scalar.agent-scalar[data-v-a9f22fa8]{border-top-left-radius:var(--scalar-radius-lg);border-top-right-radius:var(--scalar-radius-lg);inset-inline:0;top:48px}.agent-scalar.agent-scalar[data-v-a9f22fa8],.agent-scalar-overlay.agent-scalar-overlay[data-v-a9f22fa8]{z-index:15}}.scalar-mcp-layer[data-v-496918d9]{background:0 0;flex-direction:column;justify-content:flex-end;gap:2px;height:32px;transition:all .4s ease-in-out;display:flex;position:relative}.scalar-mcp-layer[data-v-496918d9]:hover{height:172px}.scalar-mcp-layer-link[data-v-496918d9]:hover{cursor:pointer!important}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]{cursor:pointer;text-align:center;white-space:nowrap;width:100%;height:31px;font-size:var(--scalar-small);border-radius:var(--scalar-radius);border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-sidebar-color-1);background:var(--scalar-background-1);align-items:center;gap:6px;padding:9px 6px;line-height:1.385;text-decoration:none;transition:transform .2s ease-in-out;display:flex;position:absolute;bottom:0}.scalar-mcp-layer-link[data-v-496918d9]:after{content:"";width:100%;height:2px;position:absolute;bottom:-2px;left:0}.scalar-mcp-layer div.scalar-mcp-layer-link[data-v-496918d9]{cursor:default}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:last-child{position:relative;transform:translate(0,0)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(2){transform:translateY(-2px)scale(.99)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(2){transform:translateY(calc(-100% - 2px))scale(.99)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(3){transform:translateY(-4px)scale(.98)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(3){transform:translateY(calc(-200% - 4px))scale(1)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(4){transform:translateY(-6px)scale(.97)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(4){transform:translateY(calc(-300% - 6px))scale(1)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(5){transform:translateY(-8px)scale(.96)}.scalar-mcp-layer:hover .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(5){transform:translateY(calc(-400% - 8px))scale(1)}.scalar-mcp-layer:hover .scalar-mcp-layer-link[data-v-496918d9]{transition:transform .2s ease-in-out .1s}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:hover{background:var(--scalar-background-2)}.scalar-mcp-layer .mcp-logo[data-v-496918d9]{width:16px;height:16px;color:var(--scalar-sidebar-color-1)}.mcp-nav[data-v-496918d9]{color:var(--scalar-sidebar-color-2)}.references-classic-header[data-v-8a3822ca]{max-width:var(--refs-content-max-width);align-items:center;gap:12px;margin:auto;padding:12px 0;display:flex}.references-classic-header-content[data-v-8a3822ca]{flex-grow:1;gap:12px;display:flex}.references-classic-header-container[data-v-8a3822ca]{padding:0 60px;position:relative}@container narrow-references-container (width<=900px){.references-classic-header[data-v-8a3822ca]{padding:12px 24px}.references-classic-header-container[data-v-8a3822ca]{padding:0}}.references-classic-header-icon[data-v-8a3822ca]{height:24px;color:var(--scalar-color-1)}.client-libraries-content[data-v-75d206b8]{background-color:var(--scalar-background-1);border-left:var(--scalar-border-width) solid var(--scalar-border-color);border-right:var(--scalar-border-width) solid var(--scalar-border-color);justify-content:center;padding:0 12px;display:flex;overflow:hidden;container:client-libraries-content/inline-size}.client-libraries[data-v-75d206b8]{cursor:pointer;white-space:nowrap;width:100%;color:var(--scalar-color-3);-webkit-user-select:none;user-select:none;border-bottom:1px solid #0000;justify-content:center;align-items:center;gap:6px;padding:8px 2px;display:flex;position:relative}.client-libraries[data-v-75d206b8]:not(.client-libraries__active):hover:before{content:"";background:var(--scalar-background-2);z-index:0;border-radius:var(--scalar-radius);width:calc(100% - 4px);height:calc(100% - 4px);position:absolute;top:2px;left:2px}.client-libraries[data-v-75d206b8]:active{color:var(--scalar-color-1)}.client-libraries[data-v-75d206b8]:focus-visible{box-shadow:inset 0 0 0 1px var(--scalar-color-accent);outline:none}@media screen and (width<=450px){.client-libraries[data-v-75d206b8]:nth-of-type(4),.client-libraries[data-v-75d206b8]:nth-of-type(5){display:none}}.client-libraries-icon[data-v-75d206b8]{aspect-ratio:1;box-sizing:border-box;color:currentColor;justify-content:center;align-items:center;width:100%;min-width:14px;max-width:14px;max-height:14px;display:flex;position:relative}.client-libraries-icon__more svg[data-v-75d206b8]{height:initial}@container client-libraries-content (width<400px){.client-libraries__select[data-v-75d206b8]{width:fit-content}.client-libraries__select .client-libraries-icon__more+span[data-v-75d206b8]{display:none}}@container client-libraries-content (width<380px){.client-libraries[data-v-75d206b8]{width:100%}.client-libraries span[data-v-75d206b8]{display:none}}.client-libraries__active[data-v-75d206b8]{color:var(--scalar-color-1);border-bottom:1px solid var(--scalar-color-1)}@keyframes codeloader-75d206b8{0%{transform:rotate(0)}to{transform:rotate(1turn)}}.client-libraries .client-libraries-text[data-v-75d206b8]{font-size:var(--scalar-small);align-items:center;display:flex;position:relative}.client-libraries__active .client-libraries-text[data-v-75d206b8]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}@media screen and (width<=600px){.references-classic .client-libraries[data-v-75d206b8]{flex-direction:column}}.selected-client[data-v-c6251633]{color:var(--scalar-color-1);font-size:var(--scalar-small);font-family:var(--scalar-font-code);white-space:nowrap;text-overflow:ellipsis;background:var(--scalar-background-1);border-top:none;border:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-left-radius:var(--scalar-radius-xl);border-bottom-right-radius:var(--scalar-radius-xl);min-height:fit-content;padding:9px 12px;overflow:hidden}.client-libraries-heading[data-v-c6251633]{font-size:var(--scalar-small);font-weight:var(--scalar-font-medium);color:var(--scalar-color-1);background-color:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-top-left-radius:var(--scalar-radius-xl);border-top-right-radius:var(--scalar-radius-xl);align-items:center;max-height:32px;padding:9px 12px;display:flex}[data-v-c6251633] .scalar-codeblock-pre .hljs{margin-top:8px}:where(.badge[data-v-fb946bb8]){color:var(--badge-text-color,var(--scalar-color-2));font-size:var(--scalar-mini);background:var(--badge-background-color,var(--scalar-background-2));border:var(--scalar-border-width) solid var(--badge-border-color,var(--scalar-border-color));border-radius:12px;padding:2px 6px;display:inline-block}:where(.badge).text-orange[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-orange), transparent 90%);border:#0000}:where(.badge).text-yellow[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-yellow), transparent 90%);border:#0000}:where(.badge).text-red[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-red), transparent 90%);border:#0000}:where(.badge).text-purple[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-purple), transparent 90%);border:#0000}:where(.badge).text-green[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-green), transparent 90%);border:#0000}.download-container[data-v-b258a15f]{z-index:1;flex-direction:column;gap:16px;width:fit-content;margin:0 .5px 8px;display:flex;position:relative}.download-container.download-both[data-v-b258a15f]:hover:before{content:"";border-radius:var(--scalar-radius-lg);width:calc(100% + 24px);height:90px;box-shadow:var(--scalar-shadow-2);pointer-events:none;background:var(--scalar-background-1);position:absolute;top:-11px;left:-12px}.download-container[data-v-b258a15f]:has(:focus-visible):before{content:"";border-radius:var(--scalar-radius-lg);width:calc(100% + 24px);height:90px;box-shadow:var(--scalar-shadow-2);pointer-events:none;background:var(--scalar-background-1);position:absolute;top:-11px;left:-12px}.download-button[data-v-b258a15f]{color:var(--scalar-link-color);cursor:pointer;outline:none;justify-content:center;align-items:center;gap:4px;height:fit-content;padding:0;display:flex;position:relative;white-space:nowrap!important}.download-button[data-v-b258a15f]:before{border-radius:var(--scalar-radius);content:"";width:calc(100% + 18px);height:calc(100% + 16px);position:absolute;top:-8px;left:-9px}.download-button[data-v-b258a15f]:last-of-type:before{width:calc(100% + 15px)}.download-button[data-v-b258a15f]:hover:before{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color)}.download-button[data-v-b258a15f]:focus-visible:before{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);outline-style:var(--tw-outline-style);outline-width:1px}.download-button span[data-v-b258a15f]{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.download-button span[data-v-b258a15f]{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.download-button span[data-v-b258a15f]{z-index:1;align-items:center;gap:6px;line-height:1.625;display:flex}.download-button:hover span[data-v-b258a15f]{-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);text-decoration-color:var(--scalar-color-1,currentColor);color:var(--scalar-link-color-hover,var(--scalar-color-accent));-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);text-decoration:var(--scalar-text-decoration-hover)}.download-button[data-v-b258a15f]:nth-of-type(2){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.download-container:hover .download-button[data-v-b258a15f]:nth-of-type(2){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:absolute;top:42px;overflow:visible}.download-container:has(:focus-visible) .download-button[data-v-b258a15f]:nth-of-type(2){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:absolute;top:42px;overflow:visible}.extension[data-v-b258a15f]{z-index:1;background:var(--scalar-link-color,var(--scalar-color-accent));color:var(--scalar-background-1)}.download-container:hover .extension[data-v-b258a15f]{opacity:1}.download-container:has(:focus-visible) .extension[data-v-b258a15f]{opacity:1}.download-link[data-v-b258a15f]{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.download-link[data-v-b258a15f]{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.download-link[data-v-b258a15f]:hover{--font-color:var(--scalar-link-color,var(--scalar-color-accent));-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}.introduction-card[data-v-5764c94a]{flex-direction:column;gap:12px;display:flex}.introduction-card-row[data-v-5764c94a]{gap:24px}@media (width>=600px){.introduction-card-row[data-v-5764c94a]{flex-flow:wrap}}.introduction-card-row[data-v-5764c94a]>*{flex:1}@media (width>=600px){.introduction-card-row[data-v-5764c94a]>*{min-width:min-content}}@media (width<=600px){.introduction-card-row[data-v-5764c94a]>*{max-width:100%}}@container (width<=900px){.introduction-card-row[data-v-5764c94a]{flex-direction:column;align-items:stretch;gap:0}}.introduction-card[data-v-5764c94a] .security-scheme-label{text-transform:uppercase;font-weight:var(--scalar-semibold)}.introduction-card-row[data-v-5764c94a] .scalar-card:nth-of-type(2) .scalar-card-header{display:none}.introduction-card-row[data-v-5764c94a] .scalar-card:nth-of-type(2) .scalar-card-header.scalar-card--borderless+.scalar-card-content{margin-top:0}.section[data-v-be4443e9]{max-width:var(--refs-content-max-width);scroll-margin-top:var(--refs-viewport-offset);flex-direction:column;margin:auto;padding:90px 0;display:flex;position:relative}.section[data-v-be4443e9]:has(~div.contents){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.references-classic .section[data-v-be4443e9]{gap:24px;padding:48px 0}@container narrow-references-container (width<=900px){.references-classic .section[data-v-be4443e9],.section[data-v-be4443e9]{padding:48px 24px}}.section[data-v-be4443e9]:not(:last-of-type){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.section-wrapper[data-v-ff689b94]{color:var(--scalar-color-1);margin-top:-12px;padding-top:12px}.section-accordion[data-v-ff689b94]{border-radius:var(--scalar-radius-lg);background:var(--scalar-background-2);scroll-margin-top:var(--refs-viewport-offset);flex-direction:column;display:flex}.section-accordion-transparent[data-v-ff689b94]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:0 0}.section-accordion-button[data-v-ff689b94]{cursor:pointer;align-items:center;gap:6px;padding:6px;display:flex}.section-accordion-button-content[data-v-ff689b94]{flex:1;min-width:0}.section-accordion-button-actions[data-v-ff689b94]{color:var(--scalar-color-3);align-items:center;gap:6px;display:flex}.section-accordion-chevron[data-v-ff689b94]{cursor:pointer;opacity:1;color:var(--scalar-color-3);margin-right:4px}.section-accordion-button:hover .section-accordion-chevron[data-v-ff689b94]{color:var(--scalar-color-1)}.section-accordion-content[data-v-ff689b94]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;display:flex}.section-accordion-description[data-v-ff689b94]{font-weight:var(--scalar-semibold);font-size:var(--scalar-mini);color:var(--scalar-color--1);padding:10px 12px 0}.section-accordion-content-card[data-v-ff689b94] .property:last-of-type{padding-bottom:9px}.section-column[data-v-699c28e3]{flex:1;min-width:0}@container narrow-references-container (width<=900px){.section-column[data-v-699c28e3]:nth-of-type(2){padding-top:0}}.section-columns[data-v-8b9602bf]{gap:48px;display:flex}@container narrow-references-container (width<=900px){.section-columns[data-v-8b9602bf]{flex-direction:column;gap:24px}}.section-container[data-v-20a1472a]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;padding:0 60px;position:relative}.section-container[data-v-20a1472a]:has(.introduction-section){border-top:none}@container narrow-references-container (width<=900px){.section-container[data-v-20a1472a]{padding:0}}.section-accordion-wrapper[data-v-9419dd23]{padding:0 60px}.section-accordion[data-v-9419dd23]{width:100%;max-width:var(--refs-content-max-width);margin:auto;position:relative}.section-accordion-content[data-v-9419dd23]{flex-direction:column;gap:12px;padding-top:12px;display:flex}.section-accordion-button[data-v-9419dd23]{cursor:pointer;border-radius:var(--scalar-radius);width:100%;margin:-6px 0;padding:6px 0;display:flex}.section-accordion-chevron[data-v-9419dd23]{color:var(--scalar-color-3);position:absolute;top:12px;left:-22px}.section-accordion-button:hover .section-accordion-chevron[data-v-9419dd23]{color:var(--scalar-color-1)}.section-accordion-title[data-v-9419dd23]{flex-direction:column;flex:1;align-items:flex-start;padding:0 6px;display:flex}.section-accordion-title[data-v-9419dd23] .section-header-wrapper{grid-template-columns:1fr}.section-accordion-title[data-v-9419dd23] .section-header{margin-bottom:0}@container narrow-references-container (width<=900px){.section-accordion-chevron[data-v-9419dd23]{width:16px;top:14px;left:-16px}.section-accordion-wrapper[data-v-9419dd23]{padding:calc(var(--refs-viewport-offset)) 24px 0 24px}}.loading[data-v-8e0226d7]{background:var(--scalar-background-3);border-radius:var(--scalar-radius-lg);max-width:100%;min-height:1.6em;margin:.6em 0;animation:1.5s infinite alternate loading-skeleton-8e0226d7}.loading[data-v-8e0226d7]:first-of-type{min-height:3em;margin-top:0;margin-bottom:24px}.loading[data-v-8e0226d7]:last-of-type{width:60%}.loading.single-line[data-v-8e0226d7]{max-width:80%;min-height:3em;margin:.6em 0}@keyframes loading-skeleton-8e0226d7{0%{opacity:1}to{opacity:.33}}@container narrow-references-container (width<=900px){.section-content--with-columns[data-v-9735459e]{flex-direction:column;gap:24px}}.section-header-wrapper[data-v-8a5913a9]{grid-template-columns:1fr;display:grid}@media (width>=1200px){.section-header-wrapper[data-v-8a5913a9]{grid-template-columns:repeat(2,1fr)}}.section-header[data-v-8a5913a9]{font-size:var(--font-size,var(--scalar-heading-1));font-weight:var(--font-weight,var(--scalar-bold));color:var(--scalar-color-1);word-wrap:break-word;margin-top:0;line-height:1.45}.section-header.tight[data-v-8a5913a9]{margin-bottom:6px}.section-header.loading[data-v-8a5913a9]{width:80%}.section-header-label[data-v-f1ac6c38]{display:inline}.screenreader-only[data-v-df2e1026]{clip:rect(0, 0, 0, 0);border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.collapsible-section[data-v-999a158a]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);position:relative}.collapsible-section-header[data-v-999a158a]{color:var(--scalar-color-1)}.collapsible-section .collapsible-section-trigger[data-v-999a158a]{cursor:pointer;font-size:var(--scalar-font-size-3);z-index:1;align-items:center;padding:10px 0;display:flex;position:relative}.collapsible-section-trigger svg[data-v-999a158a]{color:var(--scalar-color-3);position:absolute;left:-19px}.collapsible-section:hover .collapsible-section-trigger svg[data-v-999a158a]{color:var(--scalar-color-1)}.collapsible-section .collapsible-section-trigger[data-v-999a158a] .anchor-copy{line-height:18.5px}.collapsible-section-content[data-v-999a158a]{margin:0 0 10px;padding:0;scroll-margin-top:140px}.references-classic .introduction-description[data-v-0370764f] img{max-width:720px}.icons-only[data-v-b59b0acf] span{display:none}.sticky-cards[data-v-6c24d687]{top:calc(var(--refs-viewport-offset) + 24px);flex-direction:column;display:flex;position:sticky}.introduction-card-item[data-v-dfab866f]{flex-direction:column;justify-content:flex-start;display:flex}.introduction-card-item[data-v-dfab866f]:empty{display:none}.introduction-card-item[data-v-dfab866f]:has(.description) .server-form-container{border-bottom-right-radius:0;border-bottom-left-radius:0}.introduction-card-item[data-v-dfab866f] .request-item{border-bottom:0}.schema-type-icon[data-v-70cb5c13]{color:var(--scalar-color-1);display:none}.schema-type[data-v-70cb5c13]{font-family:var(--scalar-font-code);color:var(--scalar-color-1)}.property-enum-value[data-v-f4b54bdd]{color:var(--scalar-color-3);overflow-wrap:break-word;--decorator-width:1px;--decorator-color:color-mix(in srgb, var(--scalar-background-1), var(--scalar-color-1) 25%);align-items:stretch;line-height:1.5;display:flex;position:relative}.property-enum-value-content[data-v-f4b54bdd]{flex-direction:column;padding:3px 0;display:flex}.property-enum-value-label[data-v-f4b54bdd]{font-family:var(--scalar-font-code);color:var(--scalar-color-1);font-size:var(--scalar-font-size-4);position:relative}.property-enum-value:last-of-type .property-enum-value-label[data-v-f4b54bdd]{padding-bottom:0}.property-enum-value[data-v-f4b54bdd]:before{content:"";width:var(--decorator-width);background-color:var(--decorator-color);margin-right:12px;display:block}.property-enum-value[data-v-f4b54bdd]:last-of-type:before{height:calc(.5lh + 4px)}.property-enum-values:has(.enum-toggle-button) .property-enum-value[data-v-f4b54bdd]:nth-last-child(2):before{height:calc(.5lh + 4px)}.property-enum-value-label[data-v-f4b54bdd]:after{content:"";width:8px;height:var(--decorator-width);background-color:var(--decorator-color);position:absolute;top:.5lh;left:-12px}.property-enum-value[data-v-f4b54bdd]:last-of-type:after{background:var(--scalar-background-1);border-top:var(--scalar-border-width) solid var(--decorator-color);height:50%;bottom:0}.property-enum-value-description[data-v-f4b54bdd]{color:var(--scalar-color-3)}.property-heading:empty+.property-description[data-v-55c01b89]:last-of-type,.property-description[data-v-55c01b89]:first-of-type:last-of-type{margin-top:0}.property-list[data-v-55c01b89]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);margin-top:10px}.property-list .property[data-v-55c01b89]:last-of-type{padding-bottom:10px}.property-enum-values[data-v-55c01b89]{font-size:var(--scalar-font-size-3);margin-top:8px;padding-left:2px;list-style:none}.enum-toggle-button[data-v-55c01b89]:hover{color:var(--scalar-color-1)}.property-enum-property-names[data-v-55c01b89]{font-size:var(--scalar-font-size-4);color:var(--scalar-color-2);margin-top:8px;padding:0 2px;display:inline-block}.property-default[data-v-4da5c70a]{font-size:var(--scalar-mini);flex-direction:column;display:flex;position:relative}.property-default[data-v-4da5c70a]:hover:before{content:"";border-radius:var(--scalar-radius);width:100%;height:20px;position:absolute;top:0;left:0}.property-default:hover .property-default-label span[data-v-4da5c70a]{color:var(--scalar-color-1)}.property-default-label span[data-v-4da5c70a]{color:var(--scalar-color-3);border-bottom:var(--scalar-border-width) dotted currentColor;position:relative}.property-default-value[data-v-4da5c70a]{font-family:var(--scalar-font-code);align-items:center;gap:8px;width:100%;padding:6px;display:flex}.property-default-value span[data-v-4da5c70a]{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.property-default-value[data-v-4da5c70a] svg{color:var(--scalar-color-3)}.property-default-value[data-v-4da5c70a]:hover svg{color:var(--scalar-color-1)}.property-default-value[data-v-4da5c70a]{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius)}.property-default-value-list[data-v-4da5c70a]{background-color:var(--scalar-background-1);box-shadow:var(--scalar-shadow-1);border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);z-index:2;flex-direction:column;gap:3px;min-width:200px;max-width:300px;padding:9px;display:none;position:absolute;top:18px;left:50%;overflow:auto;transform:translate(-50%)}.property-default:hover .property-default-value-list[data-v-4da5c70a],.property-default:focus-within .property-default-value-list[data-v-4da5c70a]{display:flex}.property-detail[data-v-1295f965]{display:inline-flex}.property-detail+.property-detail[data-v-1295f965]:before{content:"·";margin:0 .5ch;display:block}.property-detail-truncate[data-v-1295f965]{overflow:hidden}.property-detail-truncate>.property-detail-value[data-v-1295f965]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.property-detail-prefix[data-v-1295f965]{color:var(--scalar-color-2)}code.property-detail-value[data-v-1295f965]{font-family:var(--scalar-font-code);font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1));border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);padding:0 4px}.property-example[data-v-77f8279d]{font-size:var(--scalar-mini);flex-direction:column;display:flex;position:relative}.property-example[data-v-77f8279d]:hover:before{content:"";border-radius:var(--scalar-radius);width:100%;height:20px;position:absolute;top:0;left:0}.property-example-value[data-v-77f8279d]{font-family:var(--scalar-font-code);align-items:center;gap:8px;width:100%;padding:6px;display:flex}.property-example-value span[data-v-77f8279d]{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.property-example-value[data-v-77f8279d] svg{color:var(--scalar-color-3)}.property-example-value[data-v-77f8279d]:hover svg{color:var(--scalar-color-1)}.property-example-value[data-v-77f8279d]{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius)}.property-example-value-list[data-v-77f8279d]{background-color:var(--scalar-background-1);box-shadow:var(--scalar-shadow-1);border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);z-index:1000;flex-direction:column;gap:3px;min-width:200px;max-width:300px;padding:9px;display:none;position:absolute;top:18px;left:50%;overflow:auto;transform:translate(-50%)}.property-example:hover .property-example-value-list[data-v-77f8279d],.property-example:focus-within .property-example-value-list[data-v-77f8279d]{display:flex}.property-heading[data-v-3264097c]{white-space:nowrap;flex-wrap:wrap;align-items:baseline;row-gap:9px;display:flex}:is(.property-heading[data-v-3264097c]:has(+.children),.property-heading[data-v-3264097c]:has(+.property-rule)){margin-bottom:9px}.property-heading[data-v-3264097c]>*{margin-right:9px}.property-heading[data-v-3264097c]:last-child,.property-heading>.property-detail[data-v-3264097c]:not(:last-of-type){margin-right:0}.property-name[data-v-3264097c]{max-width:100%;font-family:var(--scalar-font-code);font-weight:var(--scalar-bold);font-size:var(--scalar-font-size-4);white-space:normal;overflow-wrap:break-word}.property-additional[data-v-3264097c]{font-family:var(--scalar-font-code)}.property-required[data-v-3264097c],.property-optional[data-v-3264097c]{color:var(--scalar-color-2)}.property-required[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-orange)}.property-read-only[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-blue)}.property-write-only[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-green)}.property-discriminator[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-purple)}.property-detail[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-2);align-items:center;min-width:0;display:flex}.property-const[data-v-3264097c]{color:var(--scalar-color-1)}.deprecated[data-v-3264097c]{text-decoration:line-through}.property[data-v-c8e3c666]{color:var(--scalar-color-1);font-size:var(--scalar-small);flex-direction:column;padding:10px;display:flex;position:relative}.property.property--level-0[data-v-c8e3c666]:has(>.property-rule>.schema-card>.schema-properties.schema-properties-open>ul>li.property){padding-top:0}.property--compact.property--level-0[data-v-c8e3c666],.property--compact.property--level-1[data-v-c8e3c666]{padding:10px 0}.composition-panel .property.property.property.property--level-0[data-v-c8e3c666]{padding:0}.property--compact.property--level-0 .composition-panel .property--compact.property--level-1[data-v-c8e3c666]{padding:8px}.property[data-v-c8e3c666]:has(>.property-rule:first-of-type):not(.property--compact){padding-top:8px;padding-bottom:8px}.property--deprecated[data-v-c8e3c666]{background:repeating-linear-gradient(-45deg, var(--scalar-background-2) 0, var(--scalar-background-2) 2px, transparent 2px, transparent 5px);background-size:100%}.property--deprecated[data-v-c8e3c666]>*{opacity:.75}.property-description[data-v-c8e3c666]{line-height:1.4;font-size:var(--scalar-small);margin-top:6px}.property-description[data-v-c8e3c666]:has(+.property-rule){margin-bottom:9px}[data-v-c8e3c666] .property-description *{color:var(--scalar-color-2)!important}.property[data-v-c8e3c666]:not(:last-of-type){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.property-description+.children[data-v-c8e3c666],.children+.property-rule[data-v-c8e3c666]{margin-top:9px}.children[data-v-c8e3c666]{flex-direction:column;display:flex}.children .property--compact.property--level-1[data-v-c8e3c666]{padding:12px}.property-example-value[data-v-c8e3c666]{all:unset;font-family:var(--scalar-font-code);border-top:var(--scalar-border-width) solid var(--scalar-border-color);padding:6px}.property-rule[data-v-c8e3c666]{border-radius:var(--scalar-radius-lg);flex-direction:column;display:flex}.property--level-2[data-v-c8e3c666] .relative>button{left:-2rem}.property-rule[data-v-c8e3c666] .composition-panel .schema-card--level-1>.schema-properties.schema-properties-open{border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg)}.property-rule[data-v-c8e3c666] .composition-panel>.schema-card>.schema-card-description{border-left:var(--scalar-border-width) solid var(--scalar-border-color);border-right:var(--scalar-border-width) solid var(--scalar-border-color);padding:10px}.property-rule[data-v-c8e3c666] .composition-panel>.schema-card>.schema-card-description+.schema-properties{margin-top:0}.property-example[data-v-c8e3c666]{background:0 0;border:none;flex-direction:row;gap:8px;display:flex}.property-example-label[data-v-c8e3c666],.property-example-value[data-v-c8e3c666]{padding:3px 0 0}.property-example-value[data-v-c8e3c666]{background:var(--scalar-background-2);border-radius:var(--scalar-radius);border-top:0;padding:3px 4px}.property-name[data-v-c8e3c666]{font-family:var(--scalar-font-code);font-weight:var(--scalar-semibold)}.property-name-additional-properties[data-v-c8e3c666]:before,.property-name-pattern-properties[data-v-c8e3c666]:before{text-transform:uppercase;font-size:var(--scalar-micro);border-radius:var(--scalar-radius);color:var(--scalar-color-1);border:var(--scalar-border-width) solid var(--scalar-border-color);background-color:var(--scalar-background-2);margin-right:4px;padding:2px 4px;display:inline-block}.property-name-pattern-properties[data-v-c8e3c666]:before{content:"regex"}.property-name-additional-properties[data-v-c8e3c666],.property-name-pattern-properties[data-v-c8e3c666]{border:1px dashed var(--scalar-border-color);color:var(--scalar-color-accent);border-radius:var(--scalar-radius);padding:2px;display:inline-block}.error[data-v-c988b726]{background-color:var(--scalar-color-red)}.schema-card[data-v-c988b726]{font-size:var(--scalar-font-size-4);color:var(--scalar-color-1)}.schema-card-title[data-v-c988b726]{height:var(--schema-title-height);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-mini);border-bottom:var(--scalar-border-width) solid transparent;align-items:center;gap:4px;padding:6px 8px;display:flex}button.schema-card-title[data-v-c988b726]{cursor:pointer}button.schema-card-title[data-v-c988b726]:hover{color:var(--scalar-color-1)}.schema-card-title-icon--open[data-v-c988b726]{transform:rotate(45deg)}.schema-properties-open>.schema-card-title[data-v-c988b726]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-right-radius:0;border-bottom-left-radius:0}.schema-properties-open>.schema-properties[data-v-c988b726]{width:fit-content}.schema-card-description[data-v-c988b726]{color:var(--scalar-color-2)}.schema-card-description+.schema-properties[data-v-c988b726]{width:fit-content;margin-top:8px}.schema-card--level-0:first-of-type>.schema-card-description[data-v-c988b726]:has(+.schema-properties){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);margin-bottom:-8px;padding-bottom:8px}.schema-card--level-0~.schema-card--level-0>.schema-card-description[data-v-c988b726]:has(+.schema-properties){padding-top:8px}.schema-properties-open.schema-properties[data-v-c988b726],.schema-properties-open>.schema-card--open[data-v-c988b726]{width:100%}.schema-properties[data-v-c988b726]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);flex-direction:column;width:fit-content;display:flex}.schema-properties-name[data-v-c988b726]{width:100%}.schema-properties .schema-properties[data-v-c988b726]{border-radius:13.5px}.schema-properties .schema-properties.schema-properties-open[data-v-c988b726]{border-radius:var(--scalar-radius-lg)}.schema-properties-open[data-v-c988b726]{width:100%}.schema-card--compact[data-v-c988b726]{align-self:flex-start}.schema-card--compact.schema-card--open[data-v-c988b726]{align-self:initial}.schema-card-title--compact[data-v-c988b726]{color:var(--scalar-color-2);border-bottom:none;height:auto;padding:6px 10px 6px 8px}.schema-card-title--compact>.schema-card-title-icon[data-v-c988b726]{margin:0}.schema-card-title--compact>.schema-card-title-icon--open[data-v-c988b726]{transform:rotate(45deg)}.schema-properties-open>.schema-card-title--compact[data-v-c988b726]{position:static}.property--level-0>.schema-properties>.schema-card--level-0>.schema-properties[data-v-c988b726]{border:none}.property--level-0 .schema-card--level-0:not(.schema-card--compact) .property--level-1[data-v-c988b726]{padding:0 0 8px}:not(.composition-panel)>.schema-card--compact.schema-card--level-0>.schema-properties[data-v-c988b726]{border:none}[data-v-c988b726] .schema-card-description p{font-size:var(--scalar-small,var(--scalar-paragraph));color:var(--scalar-color-2);margin-bottom:6px;display:block}.children .schema-card-description[data-v-c988b726]:first-of-type{padding-top:0}.reference-models-anchor[data-v-5aa6b456]{color:var(--scalar-color-1);align-items:center;padding-left:6px;font-size:20px;display:flex}.reference-models-label[data-v-5aa6b456]{font-size:var(--scalar-mini);display:block}.reference-models-label[data-v-5aa6b456] em{font-weight:var(--scalar-bold)}.show-more[data-v-d1c2b649]{appearance:none;border:none;border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-color-1);font-weight:var(--scalar-semibold);font-size:var(--scalar-small);border-radius:30px;justify-content:center;align-items:center;gap:6px;margin:auto;padding:8px 12px 8px 16px;display:flex;position:relative;top:-48px}.show-more[data-v-d1c2b649]:hover{background:var(--scalar-background-2);cursor:pointer}.show-more[data-v-d1c2b649]:active{box-shadow:0 0 0 1px var(--scalar-border-color)}@container narrow-references-container (width<=900px){.show-more[data-v-d1c2b649]{top:-24px}}.tag-section[data-v-d4f47ce7]{margin-bottom:48px}.tag-name[data-v-d4f47ce7]{text-transform:capitalize}.tag-description[data-v-d4f47ce7]{text-align:left;padding-bottom:4px}.tag-section-group .tag-group-name[data-v-d4f47ce7]{grid-template-columns:auto 1fr;align-self:stretch;gap:12px}.tag-group-name[data-v-d4f47ce7]:after{content:"";background:var(--scalar-border-color);align-self:center;height:1px;display:block}:is(.tag-group-name[data-v-d4f47ce7]:has(*>:hover),.tag-group-name[data-v-d4f47ce7]:has(:focus-visible)){gap:32px}.tag-section-group .tag-section[data-v-d4f47ce7]{margin-bottom:24px;padding-inline:0}.tag-section-group .tag-section[data-v-d4f47ce7]:last-of-type{margin-bottom:0}.endpoint[data-v-ad8530a6]{white-space:nowrap;cursor:pointer;text-decoration:none;display:flex}.endpoint:hover .endpoint-path[data-v-ad8530a6],.endpoint:focus-visible .endpoint-path[data-v-ad8530a6]{text-decoration:underline}.endpoint .post[data-v-ad8530a6],.endpoint .get[data-v-ad8530a6],.endpoint .delete[data-v-ad8530a6],.endpoint .put[data-v-ad8530a6]{white-space:nowrap}.endpoint-method[data-v-ad8530a6],.endpoint-path[data-v-ad8530a6]{color:var(--scalar-color-1);min-width:62px;line-height:1.55;font-family:var(--scalar-font-code);font-size:var(--scalar-small);cursor:pointer;display:inline-flex}.endpoint-method[data-v-ad8530a6]{text-align:right}.endpoint-path[data-v-ad8530a6]{text-transform:initial;margin-left:12px}.deprecated[data-v-ad8530a6]{text-decoration:line-through}.endpoints-card[data-v-f726f753]{top:calc(var(--refs-viewport-offset) + 24px);font-size:var(--scalar-font-size-3);position:sticky}.endpoints[data-v-f726f753]{background:var(--scalar-background-2);width:100%;padding:10px 12px;overflow:auto}.section-container[data-v-8f1a275c]{border-top:var(--scalar-border-width) solid var(--scalar-border-color)}.section-container[data-v-8f1a275c]:has(.show-more){background-color:color-mix(in srgb, var(--scalar-background-2), transparent)}.operation-path[data-v-ec6c8861]{word-wrap:break-word;font-weight:var(--scalar-semibold);line-break:anywhere;overflow:hidden}.deprecated[data-v-ec6c8861]{text-decoration:line-through}.empty-state[data-v-6aae906d]{text-align:center;font-size:var(--scalar-mini);border-radius:var(--scalar-radius-lg);min-height:56px;color:var(--scalar-color-2);justify-content:center;align-items:center;margin:10px 0 10px 12px;display:flex}.rule-title[data-v-6aae906d]{font-family:var(--scalar-font-code);color:var(--scalar-color-1);border-radius:var(--scalar-radius);margin:12px 0 6px;display:inline-block}.rule[data-v-6aae906d]{border-radius:var(--scalar-radius-lg);margin:0 12px}.rule-items[data-v-6aae906d]{counter-reset:list-number;border-left:1px solid var(--scalar-border-color);flex-direction:column;gap:12px;padding:12px 0;display:flex}.rule-item[data-v-6aae906d]{counter-increment:list-number;border:1px solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-left:24px;overflow:hidden}.rule-item[data-v-6aae906d]:before{border:1px solid var(--scalar-border-color);content:" ";border-radius:0 0 0 var(--scalar-radius-lg);width:24px;height:6px;color:var(--scalar-color-1);border-top:0;border-right:0;margin-top:6px;display:block;position:absolute;transform:translate(-25px)}.tab[data-v-804dba49]{font-size:var(--scalar-small);font-family:var(--scalar-font);font-weight:var(--scalar-font-normal);color:var(--scalar-color-2);line-height:calc(var(--scalar-small) + 2px);white-space:nowrap;cursor:pointer;text-transform:uppercase;background:0 0;border:none;margin-right:3px;padding:0;line-height:22px;position:relative}.tab[data-v-804dba49]:before{content:"";z-index:0;border-radius:var(--scalar-radius);background:var(--scalar-background-3);opacity:0;width:calc(100% + 12px);height:calc(100% + 4px);position:absolute;top:-2px;left:-6px}.tab[data-v-804dba49]:hover:before,.tab[data-v-804dba49]:focus-visible:before{opacity:1}.tab[data-v-804dba49]:focus-visible:before{outline:1px solid var(--scalar-color-accent)}.tab span[data-v-804dba49]{z-index:1;position:relative}.tab-selected[data-v-804dba49]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}.tab-selected[data-v-804dba49]:after{content:"";width:100%;height:1px;left:0;bottom:calc(var(--tab-list-padding-y) * -1);background:currentColor;position:absolute}.tab-list[data-v-fec8fbbb]{--tab-list-padding-y:7px;--tab-list-padding-x:12px;padding:var(--tab-list-padding-y) var(--tab-list-padding-x);flex:1;gap:6px;display:flex;position:relative;overflow:auto}.scalar-card-header.scalar-card-header-tabs[data-v-fec8fbbb]{padding:0}.response-card[data-v-4e56e3cf]{font-size:var(--scalar-font-size-3)}.code-copy[data-v-4e56e3cf]{appearance:none;cursor:pointer;color:var(--scalar-color-3);background:0 0;border:none;outline:none;justify-content:center;align-items:center;margin-right:12px;padding:0;display:flex}.code-copy[data-v-4e56e3cf]:hover{color:var(--scalar-color-1)}.code-copy svg[data-v-4e56e3cf]{width:13px;height:13px}.response-card-footer[data-v-4e56e3cf]{flex-flow:row-reverse wrap;flex-shrink:0;justify-content:start;column-gap:8px;padding:7px 12px;display:flex}.response-example-selector[data-v-4e56e3cf]{flex-shrink:0;margin:-4px}.response-description[data-v-4e56e3cf]{font-weight:var(--scalar-semibold);font-size:var(--scalar-small);color:var(--scalar-color--1);box-sizing:border-box;flex-grow:1}.response-description-markdown[data-v-4e56e3cf]{max-height:3lh}.response-description-markdown[data-v-4e56e3cf] *{margin:0}.schema-type[data-v-4e56e3cf]{font-size:var(--scalar-micro);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);background:var(--scalar-background-3);border-radius:4px;margin-right:4px;padding:2px 4px}.schema-example[data-v-4e56e3cf]{font-size:var(--scalar-micro);color:var(--scalar-color-2);font-weight:var(--scalar-semibold)}.example-response-tab[data-v-4e56e3cf]{margin:6px;display:block}.scalar-card-checkbox[data-v-4e56e3cf]{cursor:pointer;-webkit-user-select:none;user-select:none;min-height:17px;font-size:var(--scalar-small);font-weight:var(--scalar-font-normal);color:var(--scalar-color-2);white-space:nowrap;justify-content:center;align-items:center;gap:6px;width:fit-content;padding:7px 6px;display:flex;position:relative}.scalar-card-checkbox:has(.scalar-card-checkbox-input:focus-visible) .scalar-card-checkbox-checkmark[data-v-4e56e3cf]{outline:1px solid var(--scalar-color-accent)}.scalar-card-checkbox[data-v-4e56e3cf]:hover{color:var(--scalar-color--1)}.scalar-card-checkbox .scalar-card-checkbox-input[data-v-4e56e3cf]{opacity:0;cursor:pointer;width:0;height:0;position:absolute}.scalar-card-checkbox-checkmark[data-v-4e56e3cf]{border-radius:var(--scalar-radius);background-color:#0000;background-color:var(--scalar-background-3);width:16px;height:16px;box-shadow:inset 0 0 0 var(--scalar-border-width) var(--scalar-border-color)}.scalar-card-checkbox[data-v-4e56e3cf]:has(.scalar-card-checkbox-input:checked){color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}.scalar-card-checkbox .scalar-card-checkbox-input:checked~.scalar-card-checkbox-checkmark[data-v-4e56e3cf]{background-color:var(--scalar-button-1);box-shadow:none}.scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{content:"";display:none;position:absolute}.scalar-card-checkbox .scalar-card-checkbox-input:checked~.scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{display:block}.scalar-card-checkbox .scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{border:solid 1px var(--scalar-button-1-color);border-width:0 1.5px 1.5px 0;width:5px;height:9px;top:12.5px;right:11.5px;transform:rotate(45deg)}.headers-card[data-v-ab19704d]{z-index:0;font-size:var(--scalar-font-size-4);color:var(--scalar-color-1);align-self:flex-start;margin-top:12px;margin-bottom:6px;position:relative}.headers-card.headers-card--open[data-v-ab19704d]{align-self:initial}.headers-card-title[data-v-ab19704d]{color:var(--scalar-color-3);font-weight:var(--scalar-semibold);font-size:var(--scalar-micro);border-radius:13.5px;align-items:center;gap:4px;padding:6px 10px;display:flex}button.headers-card-title[data-v-ab19704d]{cursor:pointer}button.headers-card-title[data-v-ab19704d]:hover{color:var(--scalar-color-1)}.headers-card-title-icon--open[data-v-ab19704d]{transform:rotate(45deg)}.headers-properties[data-v-ab19704d]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:13.5px;flex-direction:column;width:fit-content;display:flex}.headers-properties-open>.headers-card-title[data-v-ab19704d]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-right-radius:0;border-bottom-left-radius:0}.headers-properties-open[data-v-ab19704d]{border-radius:var(--scalar-radius-lg);width:100%}.headers-card .property[data-v-ab19704d]:last-of-type{padding-bottom:10px}.headers-card-title>.headers-card-title-icon[data-v-ab19704d]{width:14px;height:14px;margin:0}.headers-card-title>.headers-card-title-icon--open[data-v-ab19704d]{transform:rotate(45deg)}.parameter-item[data-v-bd50387f]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;display:flex;position:relative}.parameter-item:last-of-type .parameter-schema[data-v-bd50387f]{padding-bottom:0}.parameter-item-container[data-v-bd50387f]{padding:0}.parameter-item-headers[data-v-bd50387f]{border:var(--scalar-border-width) solid var(--scalar-border-color)}.parameter-item-name[data-v-bd50387f]{font-weight:var(--scalar-bold);font-size:var(--scalar-font-size-4);font-family:var(--scalar-font-code);color:var(--scalar-color-1);overflow-wrap:break-word;position:relative}.parameter-item-description[data-v-bd50387f],.parameter-item-description-summary[data-v-bd50387f]{font-size:var(--scalar-mini);color:var(--scalar-color-2)}.parameter-item-description-summary.parameter-item-description-summary[data-v-bd50387f]>*{--markdown-line-height:var(--scalar-line-height-5)}.parameter-item-trigger~.parameter-item-container[data-v-bd50387f] .property--level-0>.property-heading .property-detail-value{font-size:var(--scalar-micro)}.parameter-item-required-optional[data-v-bd50387f]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);margin-right:6px;position:relative}.parameter-item--required[data-v-bd50387f]{text-transform:uppercase;font-size:var(--scalar-micro);font-weight:var(--scalar-semibold);color:var(--scalar-color-orange)}.parameter-item-description[data-v-bd50387f]{font-size:var(--scalar-small);color:var(--scalar-color-2);margin-top:6px;line-height:1.4}.parameter-item-description[data-v-bd50387f] p{font-size:var(--scalar-small);color:var(--scalar-color-2);margin-top:4px}.parameter-schema[data-v-bd50387f]{margin-top:3px;padding-bottom:9px}.parameter-item-trigger[data-v-bd50387f]{line-height:var(--scalar-line-height-5);outline:none;flex-wrap:wrap;align-items:baseline;gap:6px;padding:10px 0;display:flex}.parameter-item-trigger-open[data-v-bd50387f]{padding-bottom:0}.parameter-item-icon[data-v-bd50387f]{color:var(--scalar-color-3);position:absolute;top:.5lh;left:-19px;translate:0 -50%}.parameter-item-trigger:hover .parameter-item-icon[data-v-bd50387f],.parameter-item-trigger:focus-visible .parameter-item-icon[data-v-bd50387f]{color:var(--scalar-color-1)}.parameter-item-trigger:focus-visible .parameter-item-icon[data-v-bd50387f]{outline:1px solid var(--scalar-color-accent);outline-offset:2px;border-radius:var(--scalar-radius)}.request-body[data-v-e475e108]{margin-top:24px}.request-body-header[data-v-e475e108]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);flex-flow:wrap;justify-content:space-between;align-items:center;padding-bottom:12px;display:flex}.request-body-title[data-v-e475e108]{font-size:var(--scalar-font-size-2);font-weight:var(--scalar-semibold);color:var(--scalar-color-1);align-items:center;gap:8px;display:flex}.request-body-required[data-v-e475e108]{font-size:var(--scalar-micro);color:var(--scalar-color-orange);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px;height:20px;padding:2px 8px;font-weight:400}.request-body-description[data-v-e475e108]{font-size:var(--scalar-small);width:100%;margin-top:6px}:is(.request-body-header+.request-body-schema[data-v-e475e108]:has(>.schema-card>.schema-card-description),.request-body-header+.request-body-schema[data-v-e475e108]:has(>.schema-card>.schema-properties>*>.property--level-0)){padding-top:8px}.request-body-description[data-v-e475e108] .markdown *{color:var(--scalar-color-2)!important}.callback-sticky-offset[data-v-12fe1373]{top:var(--refs-viewport-offset,0px);z-index:1}.callback-operation-container[data-v-12fe1373] .request-body,.callback-operation-container[data-v-12fe1373] .request-body-description,.callback-operation-container[data-v-12fe1373] .request-body-header{margin-top:0}.callback-operation-container[data-v-12fe1373] .request-body-header{--scalar-font-size-2:var(--scalar-font-size-4);border-bottom:none;border:.5px solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg) var(--scalar-radius-lg) 0 0;background:color-mix(in srgb, var(--scalar-background-2) 50%, transparent);padding:10px}.callback-operation-container[data-v-12fe1373] .request-body-schema>.schema-card>.schema-card-description{padding-inline:8px}.callback-operation-container[data-v-12fe1373] ul li.property.property--level-1{padding:10px}.callback-operation-container[data-v-12fe1373] .request-body-schema{background-color:var(--scalar-background-1);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg);border-top:none;overflow:hidden}.callback-operation-container[data-v-12fe1373] .parameter-list{margin-top:0}.callback-operation-container[data-v-12fe1373] .parameter-list-title{background:color-mix(in srgb, var(--scalar-background-2) 50%, transparent);border-radius:var(--scalar-radius-lg) var(--scalar-radius-lg) 0 0;border:var(--scalar-border-width) solid var(--scalar-border-color);--scalar-font-size-2:var(--scalar-font-size-4);border-bottom:none;margin-bottom:0;padding:10px}.callback-operation-container[data-v-12fe1373] .parameter-list-items{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg)}.callback-operation-container[data-v-12fe1373] .parameter-list-items>li:first-of-type{border-top:none}.callback-operation-container[data-v-12fe1373] .parameter-list-items>li{padding:0 8px}.show-api-client-button[data-v-15e312d9]{appearance:none;white-space:nowrap;border-radius:var(--scalar-radius);font-weight:var(--scalar-semibold);font-size:var(--scalar-small);color:var(--scalar-background-2);line-height:22px;font-family:var(--scalar-font);background:var(--scalar-button-1);cursor:pointer;box-sizing:border-box;outline-offset:2px;border:none;justify-content:center;align-items:center;padding:1px 6px;display:flex;position:relative;box-shadow:inset 0 0 0 1px #0000001a}.show-api-client-button span[data-v-15e312d9],.show-api-client-button svg[data-v-15e312d9]{fill:currentColor;color:var(--scalar-button-1-color);z-index:1}.show-api-client-button[data-v-15e312d9]:hover{background:var(--scalar-button-1-hover)}.show-api-client-button svg[data-v-15e312d9]{margin-right:4px}.operation-title[data-v-8f643b1c]{justify-content:space-between;display:flex}.operation-details[data-v-8f643b1c]{flex-shrink:1;align-items:center;gap:9px;min-width:0;margin-top:0;display:flex}.operation-details[data-v-8f643b1c] .endpoint-anchor .scalar-button svg{width:16px;height:16px}.endpoint-type[data-v-8f643b1c]{z-index:0;width:60px;font-size:var(--scalar-small);text-transform:uppercase;font-weight:var(--scalar-bold);font-family:var(--scalar-font);flex-shrink:0;justify-content:center;align-items:center;gap:6px;padding:6px;display:flex;position:relative}.endpoint-type[data-v-8f643b1c]:after{content:"";z-index:-1;opacity:.15;border-radius:var(--scalar-radius);background:currentColor;position:absolute;inset:0}.endpoint-anchor[data-v-8f643b1c]{flex-shrink:1;align-items:center;min-width:0;display:flex}.endpoint-anchor.label[data-v-8f643b1c]{display:flex}.endpoint-label[data-v-8f643b1c]{min-width:0;color:var(--scalar-color-1);flex-shrink:1;align-items:baseline;gap:9px;display:flex}.endpoint-label-path[data-v-8f643b1c]{font-family:var(--scalar-font-code);font-size:var(--scalar-mini);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.endpoint-label-path[data-v-8f643b1c] em{color:var(--scalar-color-2)}.endpoint-label-name[data-v-8f643b1c]{color:var(--scalar-color-2);font-size:var(--scalar-small);text-overflow:ellipsis;white-space:nowrap;flex-shrink:1000000000;overflow:hidden}.endpoint-try-hint[data-v-8f643b1c]{flex-shrink:0;padding:2px}.endpoint-copy[data-v-8f643b1c]{color:currentColor}.endpoint-copy[data-v-8f643b1c] svg{stroke-width:2px}.endpoint-content[data-v-8f643b1c]{grid-auto-columns:1fr;grid-auto-flow:row;gap:9px;padding:9px;display:grid}@media (width>=1000px){.endpoint-content[data-v-8f643b1c]{grid-auto-flow:column}}@container (width<=900px){.endpoint-content[data-v-8f643b1c]{grid-template-columns:1fr}}.endpoint-content[data-v-8f643b1c]>*{min-width:0}.operation-details-card[data-v-8f643b1c]{flex-direction:column;gap:12px;min-width:0;display:flex}:is(.operation-details-card-item[data-v-8f643b1c] .parameter-list,.operation-details-card-item[data-v-8f643b1c] .callbacks-list){border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-top:0}.operation-details-card-item[data-v-8f643b1c]{flex-direction:column;gap:12px;display:flex}.operation-details-card-item[data-v-8f643b1c] .parameter-list-items{margin-bottom:0}.operation-details-card[data-v-8f643b1c] .parameter-item:last-of-type .parameter-schema{padding-bottom:12px}.operation-details-card[data-v-8f643b1c] .parameter-list .parameter-list{margin-bottom:12px}.operation-details-card[data-v-8f643b1c] .parameter-item{margin:0;padding:0}.operation-details-card[data-v-8f643b1c] .property{margin:0;padding:9px}:is(.operation-details-card[data-v-8f643b1c] .parameter-list-title,.operation-details-card[data-v-8f643b1c] .request-body-title,.operation-details-card[data-v-8f643b1c] .callbacks-title){text-transform:uppercase;font-weight:var(--scalar-bold);font-size:var(--scalar-mini);color:var(--scalar-color-2);margin:0;padding:9px;line-height:1.33}.operation-details-card[data-v-8f643b1c] .callback-list-item-title{padding-left:28px;padding-right:12px}.operation-details-card[data-v-8f643b1c] .callback-list-item-icon{left:6px}.operation-details-card[data-v-8f643b1c] .callback-operation-container{padding-inline:9px;padding-bottom:9px}:is(.operation-details-card[data-v-8f643b1c] .callback-operation-container>.request-body,.operation-details-card[data-v-8f643b1c] .callback-operation-container>.parameter-list){border:none}.operation-details-card[data-v-8f643b1c] .callback-operation-container>.request-body>.request-body-header{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);padding:0 0 9px}.operation-details-card[data-v-8f643b1c] .request-body-description{border-top:var(--scalar-border-width) solid var(--scalar-border-color);margin-top:0;padding:9px 9px 0}.operation-details-card[data-v-8f643b1c] .request-body{border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);margin-top:0}.operation-details-card[data-v-8f643b1c] .request-body .schema-card--level-0>.schema-card-description{padding-inline:9px}.operation-details-card[data-v-8f643b1c] .request-body-header{border-bottom:0;padding-bottom:0}.operation-details-card[data-v-8f643b1c] .contents button{margin-right:9px}.operation-details-card[data-v-8f643b1c] .schema-card--open+.schema-card:not(.schema-card--open){margin-inline:9px;margin-bottom:9px}.operation-details-card[data-v-8f643b1c] .request-body-schema .property--level-0{padding:0}.operation-details-card[data-v-8f643b1c] .selected-content-type{margin-right:9px}.operation-example-card[data-v-8f643b1c]{top:calc(var(--refs-viewport-offset) + 24px);max-height:calc(var(--refs-viewport-height) - 48px);position:sticky}@media (width<=600px){.operation-example-card[data-v-8f643b1c]{max-height:unset;position:static}}.agent-button-container[data-v-78f5377c]{color:var(--scalar-color-1);background:color-mix(in srgb, var(--scalar-background-3), white 15%);cursor:pointer;border-radius:var(--scalar-radius);z-index:2;align-items:center;height:100%;margin-right:4px;padding:1px 6px;display:flex;position:relative}.agent-button-container[data-v-78f5377c]:hover:not(:focus-within){background:color-mix(in srgb, var(--scalar-background-3), white 20%)}.agent-button-container[data-v-78f5377c]:focus-within{border-radius:var(--scalar-radius-lg);height:auto;margin-right:0;position:absolute;inset:2px}.agent-button-container[data-v-78f5377c]:has(.ask-agent-scalar-input-not-empty){border-radius:var(--scalar-radius-lg);height:auto;margin-right:0;position:absolute;inset:2px}.agent-button-container[data-v-78f5377c]:has(.ask-agent-scalar-input:focus-visible){outline-style:solid}.ask-agent-scalar-input[data-v-78f5377c]{opacity:0;border:none;width:0;font-size:0}.agent-button-container:focus-within .ask-agent-scalar-input[data-v-78f5377c]{width:100%;font-size:inherit;opacity:1;outline:none;padding-inline:4px;line-height:20px}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-input[data-v-78f5377c]{width:100%;font-size:inherit;opacity:1;outline:none;padding-inline:4px;line-height:20px}.ask-agent-scalar-input[data-v-78f5377c]::placeholder{color:var(--scalar-color-2);font-family:inherit}.ask-agent-scalar-input-label[data-v-78f5377c]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);margin-left:4px}.agent-button-container:focus-within .ask-agent-scalar-input-label[data-v-78f5377c]{display:none}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-input-label[data-v-78f5377c]{display:none}.ask-agent-scalar-send[data-v-78f5377c]{background:var(--scalar-color-blue);color:#fff;border-radius:var(--scalar-radius);outline-offset:1px;flex-shrink:0;justify-content:center;align-items:center;width:24px;height:24px;display:none}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-send[data-v-78f5377c]{display:flex}.ask-agent-scalar-send[data-v-78f5377c]:hover{background:color-mix(in srgb, var(--scalar-color-blue), transparent 10%)}.examples[data-v-1f2d95d2]{top:calc(var(--refs-viewport-offset) + 24px);position:sticky}.examples[data-v-1f2d95d2]>*{max-height:calc((var(--refs-viewport-height) - 60px) / 2);position:relative}.examples[data-v-1f2d95d2]>:first-of-type:last-of-type{max-height:calc((var(--refs-viewport-height) - 60px))}@media (width<=600px){.examples[data-v-1f2d95d2]>*{max-height:unset}}.deprecated[data-v-1f2d95d2] *{text-decoration:line-through}.operation-header[data-v-1f2d95d2]{flex-direction:column;justify-content:space-between;align-items:flex-start;gap:4px;margin-bottom:12px;display:flex}@media (width>=600px){.operation-header[data-v-1f2d95d2]{flex-direction:row;align-items:center}}.section-flare[data-v-5cebff7a]{pointer-events:none;position:fixed;top:0;right:0}.narrow-references-container{container:narrow-references-container/inline-size}.ref-search-meta[data-v-9b0e55ef]{background:var(--scalar-background-1);border-bottom-left-radius:var(--scalar-radius-lg);border-bottom-right-radius:var(--scalar-radius-lg);font-size:var(--scalar-font-size-4);color:var(--scalar-color-3);font-weight:var(--scalar-semibold);border-top:var(--scalar-border-width) solid var(--scalar-border-color);gap:12px;padding:6px 12px;display:flex}:root{--scalar-loaded-api-reference:true}@layer scalar-config{.scalar-api-reference[data-v-e26e2526]{--refs-header-height:calc(var(--scalar-custom-header-height,0px) + var(--scalar-header-height,0px));--refs-viewport-offset:calc(var(--refs-header-height,0px) + var(--refs-content-offset,0px));--refs-viewport-height:calc(var(--full-height,100dvh) - var(--refs-viewport-offset,0px));--refs-sidebar-width:var(--scalar-sidebar-width,0px);--refs-sidebar-height:calc(var(--full-height,100dvh) - var(--refs-header-height,0px));--refs-content-max-width:var(--scalar-content-max-width,1540px)}.scalar-api-reference.references-classic[data-v-e26e2526]{--refs-content-max-width:var(--scalar-content-max-width,1420px);--refs-sidebar-width:0;min-height:100dvh}.references-sidebar[data-v-e26e2526]{--refs-sidebar-width:var(--scalar-sidebar-width,288px)}}.t-doc__sidebar[data-v-e26e2526]{z-index:10}.references-layout[data-v-e26e2526]{--full-height:100dvh;grid-template-rows:var(--scalar-header-height,0px) repeat(2, auto);background:var(--scalar-background-1);flex:1;grid-template-columns:auto 1fr;grid-template-areas:"header header""navigation rendered""footer footer";min-width:100%;max-width:100%;min-height:100dvh;display:grid}.references-editor[data-v-e26e2526]{background:var(--scalar-background-1);grid-area:editor;min-width:0;display:flex}.references-rendered[data-v-e26e2526]{background:var(--scalar-background-1);grid-area:rendered;min-width:0;position:relative}.scalar-api-reference.references-classic[data-v-e26e2526],.references-classic .references-rendered[data-v-e26e2526]{height:initial!important;max-height:initial!important}.references-footer[data-v-e26e2526]{grid-area:footer}@media (width<=1000px){.references-developer-tools[data-v-e26e2526]{display:none}.references-layout[data-v-e26e2526]{--refs-sidebar-height:calc(var(--full-height,100dvh) - var(--scalar-custom-header-height,0px));grid-template-columns:100%;grid-template-rows:var(--scalar-header-height,0px) 0px auto auto;grid-template-areas:"header""navigation""rendered""footer"}.references-editable[data-v-e26e2526]{grid-template-areas:"header""navigation""editor"}.references-rendered[data-v-e26e2526]{position:static}.scalar-api-references-standalone-mobile[data-v-e26e2526]:not(.references-classic){--scalar-header-height:50px}}.darklight-reference[data-v-e26e2526]{width:100%;margin-top:auto}/*$vite$:1*/`)),document.head.appendChild(e)}}catch(e){console.error(`vite-plugin-css-injected-by-js`,e)}})(); -(function(e,t){typeof exports==`object`&&typeof module<`u`?t(require(`radix-vue/namespaced`)):typeof define==`function`&&define.amd?define([`radix-vue/namespaced`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e[`{}`]))})(this,function(e){var t=Object.create,n=Object.defineProperty,r=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getPrototypeOf,o=Object.prototype.hasOwnProperty,s=(e,t)=>()=>(e&&(t=e(e=0)),t),c=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),l=(e,t)=>{let r={};for(var i in e)n(r,i,{get:e[i],enumerable:!0});return t||n(r,Symbol.toStringTag,{value:`Module`}),r},u=(e,t,a,s)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var c=i(t),l=0,u=c.length,d;lt[e]).bind(null,d),enumerable:!(s=r(t,d))||s.enumerable});return e},d=(e,r,i)=>(i=e==null?{}:t(a(e)),u(r||!e||!e.__esModule?n(i,`default`,{value:e,enumerable:!0}):i,e)),f,p=s((()=>{f=e=>{if(typeof e!=`object`||!e)return!1;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}})),m,h=s((()=>{p(),m=(e,t)=>{if(!e)return!1;if(e.type===`any`||e.type===`unknown`)return!0;if(e.type===`function`)return typeof t==`function`;if(e.type===`number`)return typeof t==`number`&&!Number.isNaN(t)&&Number.isFinite(t);if(e.type===`string`)return typeof t==`string`;if(e.type===`boolean`)return typeof t==`boolean`;if(e.type===`nullable`)return t===null;if(e.type===`notDefined`)return t===void 0;if(e.type===`array`)return Array.isArray(t)&&t.every(t=>m(e.items,t));if(e.type===`record`)return f(t)?Object.keys(t).every(n=>m(e.key,n)&&m(e.value,t[n])):!1;if(e.type===`object`)return f(t)?Object.keys(e.properties).every(n=>m(e.properties[n],t[n])):!1;if(e.type===`optional`)return t===void 0||m(e.schema,t);if(e.type===`union`)return e.schemas.some(e=>m(e,t));if(e.type===`intersection`)return e.schemas.length===0?!0:f(t)?e.schemas.every(e=>m(e,t)):!1;if(e.type===`literal`)return t===e.value;if(e.type===`lazy`)return m(e.schema(),t);if(e.type===`evaluate`)return m(e.schema,e.expression(t));let n=e;return console.warn(`Unknown schema type:`,n),!1}})),g,_,v,y=s((()=>{p(),h(),g=e=>e.type===`optional`?g(e.schema):e.type===`literal`?!0:e.type===`union`?e.schemas.length>0&&e.schemas.every(g):!1,_=(e,t)=>e.type===`object`?f(t)?Object.keys(e.properties).reduce((n,r)=>{if(!(r in t))return n;let i=e.properties[r],a=t[r],o=_(i,a);return g(i)?n+(o>0?o*10:0):n+(o>0?o:1)},0):0:e.type===`array`?Array.isArray(t)?1:0:e.type===`record`?f(t)?1:0:e.type===`optional`?t===void 0?1:_(e.schema,t):e.type===`union`?Math.max(...e.schemas.map(e=>_(e,t))):e.type===`intersection`?e.schemas.length===0?1:e.schemas.reduce((e,n)=>e+_(n,t),0):e.type===`lazy`?_(e.schema(),t):e.type===`evaluate`?_(e.schema,e.expression(t)):m(e,t)?1:0,v=(e,t,n=new WeakMap)=>{if(f(t)&&n.get(t)?.has(e))return t;if(f(t)){let r=n.get(t)||new Set;r.add(e),n.set(t,r)}if(!e||e.type===`any`||e.type===`unknown`)return t;if(e.type===`function`)return typeof t==`function`?t:(()=>void 0);if(e.type===`number`)return m(e,t)?t:e.default??0;if(e.type===`string`)return m(e,t)?t:e.default??``;if(e.type===`boolean`)return m(e,t)?t:e.default??!1;if(e.type===`nullable`)return null;if(e.type===`notDefined`)return;if(e.type===`optional`)return t===void 0?void 0:v(e.schema,t,n);if(e.type===`array`)return Array.isArray(t)?t.map(t=>v(e.items,t,n)):[];if(e.type===`record`)return f(t)?Object.fromEntries(Object.entries(t).map(([t,r])=>[t,v(e.value,r,n)])):{};if(e.type===`object`){let r=Object.keys(e.properties),i=f(t)?t:null,a=[];for(let t of r){let r=e.properties[t],o=i?.[t];r.type===`optional`&&o===void 0||a.push([t,v(r,o,n)])}return Object.fromEntries(a)}if(e.type===`union`)return v(e.schemas.reduce((e,n)=>{let r=_(n,t);return r>e.score?{schema:n,score:r}:e},{schema:e.schemas[0],score:0}).schema,t,n);if(e.type===`intersection`)return e.schemas.reduce((e,r)=>Object.assign(e,v(r,t,n)),{});if(e.type===`literal`)return e.value;if(e.type===`lazy`)return v(e.schema(),t,n);if(e.type===`evaluate`)return v(e.schema,e.expression(t),n);let r=e;return console.warn(`Unknown schema type:`,r),t}})),b,x,S,C,w,ee,te,ne,re,T,ie,ae,E,D,oe,se,ce=s((()=>{b=e=>({type:`number`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),x=e=>({type:`string`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),S=e=>({type:`boolean`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),C=e=>({type:`nullable`,typeName:e?.typeName,typeComment:e?.typeComment}),w=e=>({type:`any`,typeName:e?.typeName,typeComment:e?.typeComment}),ee=e=>({type:`unknown`,typeName:e?.typeName,typeComment:e?.typeComment}),te=e=>({type:`function`,typeName:e?.typeName,typeComment:e?.typeComment}),ne=(e,t)=>({type:`array`,items:e,typeName:t?.typeName,typeComment:t?.typeComment}),re=(e,t,n)=>({type:`record`,key:e,value:t,typeName:n?.typeName,typeComment:n?.typeComment}),T=(e,t)=>({type:`object`,properties:e,typeName:t?.typeName,typeComment:t?.typeComment}),ie=(e,t)=>({type:`union`,schemas:e,typeName:t?.typeName,typeComment:t?.typeComment}),ae=(e,t)=>({type:`intersection`,schemas:e,typeName:t?.typeName,typeComment:t?.typeComment}),E=(e,t)=>({type:`optional`,schema:e,typeName:t?.typeName,typeComment:t?.typeComment}),D=e=>({type:`literal`,value:e}),oe=e=>({type:`lazy`,schema:e}),se=(e,t)=>({type:`evaluate`,expression:e,schema:t})})),le=s((()=>{y(),ce(),h()})),ue,de,fe=s((()=>{le(),T({name:x({typeComment:"Name of specification extension property. Has to start with `x-`."}),component:ee({typeComment:`Vue component to render the specification extension`}),renderer:E(ee({typeComment:`Custom renderer to render the specification extension`}))}),ue=T({component:ee({typeComment:`Vue component to render in the view`}),renderer:E(ee({typeComment:`Custom renderer to render the view component (e.g., ReactRenderer)`})),props:E(re(x(),w()),{typeComment:`Additional props to pass to the component`})}),T({"content.end":E(ne(ue),{typeComment:`Components to render at specific views in the API Reference`})}),T({onInit:E(te()),onConfigChange:E(te()),onDestroy:E(te())}),de=te()})),pe,me,he=s((()=>{le(),pe=T({dashboardUrl:x({default:`https://dashboard.scalar.com`}),registryUrl:x({default:`https://registry.scalar.com`}),proxyUrl:x({default:`https://proxy.scalar.com`}),apiBaseUrl:x({default:`https://api.scalar.com`})},{typeComment:`External service URLs used by Scalar packages`}),me=T({title:E(x(),{typeComment:`The title of the OpenAPI document.`}),slug:E(x(),{typeComment:`The slug of the OpenAPI document used in the URL. If none is passed, the title will be used. If no title is used, it will just use the index.`}),authentication:E(w(),{typeComment:`Prefill authentication`}),baseServerURL:E(x(),{typeComment:`Base URL for the API server`}),hideClientButton:S({default:!1,typeComment:`Whether to hide the client button`}),proxyUrl:E(x(),{typeComment:`URL to a request proxy for the API client`}),oauth2RedirectUri:E(x(),{typeComment:`Default OAuth 2.0 redirect URI used to prefill auth flows in the API client.`}),searchHotKey:E(ie([D(`a`),D(`b`),D(`c`),D(`d`),D(`e`),D(`f`),D(`g`),D(`h`),D(`i`),D(`j`),D(`k`),D(`l`),D(`m`),D(`n`),D(`o`),D(`p`),D(`q`),D(`r`),D(`s`),D(`t`),D(`u`),D(`v`),D(`w`),D(`x`),D(`y`),D(`z`)]),{typeComment:`Key used with CTRL/CMD to open the search modal (defaults to 'k' e.g. CMD+k)`}),servers:E(ne(w()),{typeComment:`List of OpenAPI server objects`}),showSidebar:S({default:!0,typeComment:`Whether to show the sidebar`}),showDeveloperTools:ie([D(`localhost`),D(`always`),D(`never`)],{typeComment:`Whether and when to show the developer tools.`}),showToolbar:ie([D(`localhost`),D(`always`),D(`never`)],{typeComment:`@deprecated Use showDeveloperTools instead`}),operationTitleSource:ie([D(`summary`),D(`path`)],{typeComment:`Whether to use the operation summary or the operation path for the sidebar and search`}),theme:ie([D(`default`),D(`alternate`),D(`moon`),D(`purple`),D(`solarized`),D(`bluePlanet`),D(`deepSpace`),D(`saturn`),D(`kepler`),D(`elysiajs`),D(`fastify`),D(`mars`),D(`laserwave`),D(`none`)],{typeComment:`A string to use one of the color presets`}),_integration:E(ie([D(`adonisjs`),D(`astro`),D(`docusaurus`),D(`dotnet`),D(`elysiajs`),D(`express`),D(`fastapi`),D(`fastify`),D(`go`),D(`hono`),D(`html`),D(`laravel`),D(`litestar`),D(`nestjs`),D(`nextjs`),D(`nitro`),D(`nuxt`),D(`platformatic`),D(`react`),D(`rust`),D(`svelte`),D(`vue`),C()]),{typeComment:`Integration type identifier`}),onRequestSent:E(te(),{typeComment:`onRequestSent is fired when a request is sent`}),persistAuth:S({default:!1,typeComment:`Whether to persist auth to local storage`}),telemetry:S({default:!0,typeComment:`Enables / disables telemetry`}),externalUrls:pe})})),ge,_e,ve=s((()=>{le(),ge=ie([x(),C(),re(x(),w()),te()]),_e=T({default:S({default:!1}),url:E(x(),{typeComment:`URL to an OpenAPI/Swagger document`}),content:E(ge,{typeComment:`Directly embed the OpenAPI document. Can be a string, object, function returning an object, or null. It is recommended to pass a URL instead of content.`}),title:E(x(),{typeComment:"The title of the OpenAPI document. @deprecated Please move `title` to the top level and remove the `spec` prefix."}),slug:E(x(),{typeComment:"The slug of the OpenAPI document used in the URL. @deprecated Please move `slug` to the top level and remove the `spec` prefix."}),spec:E(T({url:E(x()),content:E(ge)}),{typeComment:"@deprecated Use `url` and `content` on the top level instead."}),agent:E(T({key:E(x()),disabled:E(S()),hideAddApi:E(S(),{typeComment:`When true, hide the control to add more APIs in the agent chat. Only preloaded/registry documents are shown; the public API list is not offered.`})}),{typeComment:`Agent Scalar configuration`})})})),ye,be,xe,Se,Ce=s((()=>{le(),fe(),he(),ve(),ye=ae([me,_e,T({layout:ie([D(`modern`),D(`classic`)],{typeComment:`The layout to use for the references`}),proxy:E(x(),{typeComment:`@deprecated Use proxyUrl instead`}),fetch:E(te(),{typeComment:`Custom fetch function for custom logic. Can be used to add custom headers, handle auth, etc.`}),plugins:E(ne(de),{typeComment:`Plugins for the API reference`}),isEditable:S({default:!1,typeComment:`Allows the user to inject an editor for the spec`}),isLoading:S({default:!1,typeComment:`Controls whether the references show a loading state in the intro`}),hideModels:S({default:!1,typeComment:`Whether to show models in the sidebar, search, and content.`}),documentDownloadType:ie([D(`both`),D(`yaml`),D(`json`),D(`direct`),D(`none`)],{typeComment:"Sets the file type of the document to download, set to `none` to hide the download button"}),hideDownloadButton:E(S(),{typeComment:"@deprecated Use `documentDownloadType: 'none'` instead"}),hideTestRequestButton:S({default:!1,typeComment:`Whether to show the "Test Request" button`}),hideSearch:S({default:!1,typeComment:`Whether to show the sidebar search bar`}),showOperationId:S({default:!1,typeComment:`Whether to show the operationId`}),darkMode:E(S(),{typeComment:`Whether dark mode is on or off initially (light mode)`}),forceDarkModeState:E(ie([D(`dark`),D(`light`)]),{typeComment:`forceDarkModeState makes it always this state no matter what`}),hideDarkModeToggle:S({default:!1,typeComment:`Whether to show the dark mode toggle`}),metaData:E(w(),{typeComment:`If used, passed data will be added to the HTML header. @see https://unhead.unjs.io/usage/composables/use-seo-meta`}),favicon:E(x(),{typeComment:`Path to a favicon image`}),hiddenClients:E(ie([re(x(),ie([S(),ne(x())])),ne(x()),D(!0)]),{typeComment:"List of httpsnippet clients to hide from the clients menu. By default hides Unirest, pass `[]` to show all clients"}),defaultHttpClient:E(T({targetKey:x(),clientKey:x()}),{typeComment:`Determine the HTTP client that is selected by default`}),customCss:E(x(),{typeComment:`Custom CSS to be added to the page`}),onSpecUpdate:E(te(),{typeComment:`onSpecUpdate is fired on spec/swagger content change`}),onServerChange:E(te(),{typeComment:`onServerChange is fired on selected server change`}),onDocumentSelect:E(te(),{typeComment:`onDocumentSelect is fired when the config is selected`}),onLoaded:E(te(),{typeComment:`Callback fired when the reference is fully loaded`}),onBeforeRequest:E(te(),{typeComment:`Fired before the outbound request is built; callback receives a mutable request builder. Experimental API.`}),onShowMore:E(te(),{typeComment:`onShowMore is fired when the user clicks the "Show more" button on the references`}),onSidebarClick:E(te(),{typeComment:`onSidebarClick is fired when the user clicks on a sidebar item`}),pathRouting:E(T({basePath:x()}),{typeComment:`Route using paths instead of hashes, your server MUST support this. @experimental`}),mcp:E(T({name:E(x(),{typeComment:`Display name for the MCP server`}),url:E(x(),{typeComment:`URL of the MCP server`}),disabled:E(S(),{typeComment:`When true, disables the MCP integration`})}),{typeComment:`MCP (Model Context Protocol) configuration. When provided, enables MCP integration with the given name and url.`}),generateHeadingSlug:E(te(),{typeComment:`Customize the heading portion of the hash`}),generateModelSlug:E(te(),{typeComment:`Customize the model portion of the hash`}),generateTagSlug:E(te(),{typeComment:`Customize the tag portion of the hash`}),generateOperationSlug:E(te(),{typeComment:`Customize the operation portion of the hash`}),generateWebhookSlug:E(te(),{typeComment:`Customize the webhook portion of the hash`}),redirect:E(te(),{typeComment:`To handle redirects, pass a function that receives the current path/hash and passes that to history.replaceState`}),withDefaultFonts:S({default:!0,typeComment:`Whether to include default fonts`}),defaultOpenFirstTag:S({default:!0,typeComment:`Whether to expand the first tag in the sidebar when no specific URL target is present`}),defaultOpenAllTags:S({default:!1,typeComment:`Whether to expand all tags by default. Warning: this can cause performance issues on big documents`}),expandAllModelSections:S({default:!1,typeComment:`Whether to expand all models by default. Warning: this can cause performance issues on big documents`}),expandAllResponses:S({default:!1,typeComment:`Whether to expand all responses by default. Warning: this can cause performance issues on big documents`}),tagsSorter:E(ie([D(`alpha`),te()]),{typeComment:`Function to sort tags`}),operationsSorter:E(ie([D(`alpha`),D(`method`),te()]),{typeComment:`Function to sort operations`}),orderSchemaPropertiesBy:ie([D(`alpha`),D(`preserve`)],{typeComment:`Order the schema properties by`}),orderRequiredPropertiesFirst:S({default:!0,typeComment:`Sort the schema properties by required ones first`})})]),be=`https://api.scalar.com/request-proxy`,xe=`https://proxy.scalar.com`,Se=e=>{let t=v(ye,e);return t.hideDownloadButton&&(console.warn(`[DEPRECATED] You're using the deprecated 'hideDownloadButton' attribute. Use 'documentDownloadType: 'none'' instead.`),t.documentDownloadType=`none`),t.spec?.url&&(console.warn(`[DEPRECATED] You're using the deprecated 'spec.url' attribute. Remove the spec prefix and move the 'url' attribute to the top level.`),t.url=t.spec.url,delete t.spec),t.spec?.content&&(console.warn(`[DEPRECATED] You're using the deprecated 'spec.content' attribute. Remove the spec prefix and move the 'content' attribute to the top level.`),t.content=t.spec.content,delete t.spec),t.proxy&&(console.warn(`[DEPRECATED] You're using the deprecated 'proxy' attribute. Use 'proxyUrl' instead.`),t.proxyUrl||=t.proxy,delete t.proxy),t.proxyUrl===be&&(console.warn(`[DEPRECATED] Warning: configuration.proxyUrl points to our old proxy (${be}).`),console.warn(`[DEPRECATED] We are overwriting the value and use the new proxy URL (${xe}) instead.`),console.warn(`[DEPRECATED] Action Required: You should manually update your configuration to use the new URL (${xe}). Read more: https://github.com/scalar/scalar`),t.proxyUrl=xe),t.showToolbar&&t.showToolbar!==`localhost`&&(console.warn(`[DEPRECATED] You're using the deprecated 'showToolbar' attribute. Use 'showDeveloperTools' instead.`),t.showDeveloperTools=t.showToolbar,delete t.showToolbar),t}})),we=s((()=>{le(),T({cdn:x({default:`https://cdn.jsdelivr.net/npm/@scalar/api-reference`}),pageTitle:x({default:`Scalar API Reference`})})})),Te=s((()=>{Ce(),we()}));Te();function Ee(e,t={},n){for(let r in e){let i=e[r],a=n?`${n}:${r}`:r;typeof i==`object`&&i?Ee(i,t,a):typeof i==`function`&&(t[a]=i)}return t}var De=(()=>{if(console.createTask)return console.createTask;let e={run:e=>e()};return()=>e})();function Oe(e,t,n,r){for(let i=n;ie[i](...t)):e[i](...t);if(n instanceof Promise)return n.then(()=>Oe(e,t,i+1,r))}catch(e){return Promise.reject(e)}}function ke(e,t,n){if(e.length>0)return Oe(e,t,0,De(n))}function Ae(e,t,n){if(e.length>0){let r=De(n);return Promise.all(e.map(e=>r.run(()=>e(...t))))}}function je(e,t){for(let n of[...e])n(t)}var Me=class{_hooks;_before;_after;_deprecatedHooks;_deprecatedMessages;constructor(){this._hooks={},this._before=void 0,this._after=void 0,this._deprecatedMessages=void 0,this._deprecatedHooks={},this.hook=this.hook.bind(this),this.callHook=this.callHook.bind(this),this.callHookWith=this.callHookWith.bind(this)}hook(e,t,n={}){if(!e||typeof t!=`function`)return()=>{};let r=e,i;for(;this._deprecatedHooks[e];)i=this._deprecatedHooks[e],e=i.to;if(i&&!n.allowDeprecated){let e=i.message;e||=`${r} hook has been deprecated`+(i.to?`, please use ${i.to}`:``),this._deprecatedMessages||=new Set,this._deprecatedMessages.has(e)||(console.warn(e),this._deprecatedMessages.add(e))}if(!t.name)try{Object.defineProperty(t,`name`,{get:()=>`_`+e.replace(/\W+/g,`_`)+`_hook_cb`,configurable:!0})}catch{}return this._hooks[e]=this._hooks[e]||[],this._hooks[e].push(t),()=>{t&&=(this.removeHook(e,t),void 0)}}hookOnce(e,t){let n,r=(...e)=>(typeof n==`function`&&n(),n=void 0,r=void 0,t(...e));return n=this.hook(e,r),n}removeHook(e,t){let n=this._hooks[e];if(n){let r=n.indexOf(t);r!==-1&&n.splice(r,1),n.length===0&&(this._hooks[e]=void 0)}}deprecateHook(e,t){this._deprecatedHooks[e]=typeof t==`string`?{to:t}:t;let n=this._hooks[e]||[];this._hooks[e]=void 0;for(let t of n)this.hook(e,t)}deprecateHooks(e){for(let t in e)this.deprecateHook(t,e[t])}addHooks(e){let t=Ee(e),n=Object.keys(t).map(e=>this.hook(e,t[e]));return()=>{for(let e of n)e();n.length=0}}removeHooks(e){let t=Ee(e);for(let e in t)this.removeHook(e,t[e])}removeAllHooks(){this._hooks={}}callHook(e,...t){return this.callHookWith(ke,e,t)}callHookParallel(e,...t){return this.callHookWith(Ae,e,t)}callHookWith(e,t,n){let r=this._before||this._after?{name:t,args:n,context:{}}:void 0;this._before&&je(this._before,r);let i=e(this._hooks[t]?[...this._hooks[t]]:[],n,t);return i instanceof Promise?i.finally(()=>{this._after&&r&&je(this._after,r)}):(this._after&&r&&je(this._after,r),i)}beforeEach(e){return this._before=this._before||[],this._before.push(e),()=>{if(this._before!==void 0){let t=this._before.indexOf(e);t!==-1&&this._before.splice(t,1)}}}afterEach(e){return this._after=this._after||[],this._after.push(e),()=>{if(this._after!==void 0){let t=this._after.indexOf(e);t!==-1&&this._after.splice(t,1)}}}};function Ne(){return new Me}var Pe=new Set([`link`,`style`,`script`,`noscript`]),Fe=new Set([`title`,`titleTemplate`,`script`,`style`,`noscript`]),Ie=new Set([`base`,`meta`,`link`,`style`,`script`,`noscript`]),Le=new Set([`title`,`base`,`htmlAttrs`,`bodyAttrs`,`meta`,`link`,`style`,`script`,`noscript`]),Re=new Set([`base`,`title`,`titleTemplate`,`bodyAttrs`,`htmlAttrs`,`templateParams`]),ze=new Set([`key`,`tagPosition`,`tagPriority`,`tagDuplicateStrategy`,`innerHTML`,`textContent`,`processTemplateParams`]),Be=new Set([`templateParams`,`htmlAttrs`,`bodyAttrs`]),Ve=new Set([`theme-color`,`google-site-verification`,`og`,`article`,`book`,`profile`,`twitter`,`author`]),He=[`name`,`property`,`http-equiv`],Ue=new Set([`viewport`,`description`,`keywords`,`robots`]);function We(e){let t=e.split(`:`);return t.length?Ve.has(t[1]):!1}function Ge(e){let{props:t,tag:n}=e;if(Re.has(n))return n;if(n===`link`&&t.rel===`canonical`)return`canonical`;if(n===`link`&&t.rel===`alternate`){let e=t.hreflang||t.type;if(e)return`alternate:${e}`}if(t.charset)return`charset`;if(e.tag===`meta`){for(let r of He)if(t[r]!==void 0){let i=t[r],a=i&&typeof i==`string`&&i.includes(`:`),o=i&&Ue.has(i);return`${n}:${i}${!(a||o)&&e.key?`:key:${e.key}`:``}`}}if(e.key)return`${n}:key:${e.key}`;if(t.id)return`${n}:id:${t.id}`;if(n===`link`&&t.rel===`alternate`)return`alternate:${t.href||``}`;if(Fe.has(n)){let t=e.textContent||e.innerHTML;if(t)return`${n}:content:${t}`}}function Ke(e){return e._h||e._d||e.textContent||e.innerHTML||`${e.tag}:${Object.entries(e.props).map(([e,t])=>`${e}:${String(t)}`).join(`,`)}`}function qe(e,t,n){typeof e==`function`&&(!n||n!==`titleTemplate`&&!(n[0]===`o`&&n[1]===`n`))&&(e=e());let r=t?t(n,e):e;if(Array.isArray(r))return r.map(e=>qe(e,t));if(r?.constructor===Object){let e={};for(let n of Object.keys(r))e[n]=qe(r[n],t,n);return e}return r}function Je(e,t){let n=e===`style`?new Map:new Set;function r(t){if(t==null||t===void 0)return;let r=String(t).trim();if(r)if(e===`style`){let[e,...t]=r.split(`:`).map(e=>e?e.trim():``);e&&t.length&&n.set(e,t.join(`:`))}else r.split(` `).filter(Boolean).forEach(e=>n.add(e))}return typeof t==`string`?e===`style`?t.split(`;`).forEach(r):r(t):Array.isArray(t)?t.forEach(e=>r(e)):t&&typeof t==`object`&&Object.entries(t).forEach(([t,i])=>{i&&i!==`false`&&(e===`style`?n.set(String(t).trim(),String(i)):r(t))}),n}function Ye(e,t){if(e.props=e.props||{},!t)return e;if(e.tag===`templateParams`)return e.props=t,e;let n=Ie.has(e.tag)||e.tag===`htmlAttrs`||e.tag===`bodyAttrs`;return Object.entries(t).forEach(([r,i])=>{if(r===`__proto__`||r===`constructor`||r===`prototype`)return;if(i===null){e.props[r]=null;return}if(r===`class`||r===`style`){e.props[r]=Je(r,i);return}if(ze.has(r)){if((r===`textContent`||r===`innerHTML`)&&typeof i==`object`){let n=t.type;if(t.type||(n=`application/json`),!n?.endsWith(`json`)&&n!==`speculationrules`)return;t.type=n,e.props.type=n,e[r]=JSON.stringify(i)}else e[r]=i;return}let a=r.startsWith(`data-`),o=n&&!a?r.toLowerCase():r,s=String(i),c=e.tag===`meta`&&o===`content`;s===`true`||s===``?e.props[o]=a||c?s:!0:!i&&a&&s===`false`?e.props[o]=`false`:i!==void 0&&(e.props[o]=i)}),e}function eee(e,t){let n=Ye({tag:e,props:{}},typeof t==`object`&&typeof t!=`function`?t:{[e===`script`||e===`noscript`||e===`style`?`innerHTML`:`textContent`]:t});return n.key&&Pe.has(n.tag)&&(n.props[`data-hid`]=n._h=n.key),n.tag===`script`&&typeof n.innerHTML==`object`&&(n.innerHTML=JSON.stringify(n.innerHTML),n.props.type=n.props.type||`application/json`),Array.isArray(n.props.content)?n.props.content.map(e=>({...n,props:{...n.props,content:e}})):n}function Xe(e,t){if(!e)return[];typeof e==`function`&&(e=e());let n=(e,n)=>{for(let r=0;r{if(t!==void 0)for(let n of Array.isArray(t)?t:[t])r.push(eee(e,n))}),r.flat()}var Ze=(e,t)=>e._w===t._w?e._p-t._p:e._w-t._w,tee={base:-10,title:10},nee={critical:-8,high:-1,low:2},ree={meta:{"content-security-policy":-30,charset:-20,viewport:-15},link:{preconnect:20,stylesheet:60,preload:70,modulepreload:70,prefetch:90,"dns-prefetch":90,prerender:90},script:{async:30,defer:80,sync:50},style:{imported:40,sync:60}},iee=/@import/,Qe=e=>e===``||e===!0;function aee(e,t){if(typeof t.tagPriority==`number`)return t.tagPriority;let n=100,r=nee[t.tagPriority]||0,i=e.resolvedOptions.disableCapoSorting?{link:{},script:{},style:{}}:ree;if(t.tag in tee)n=tee[t.tag];else if(t.tag===`meta`){let e=t.props[`http-equiv`]===`content-security-policy`?`content-security-policy`:t.props.charset?`charset`:t.props.name===`viewport`?`viewport`:null;e&&(n=ree.meta[e])}else if(t.tag===`link`&&t.props.rel)n=i.link[t.props.rel];else if(t.tag===`script`){let e=String(t.props.type);Qe(t.props.async)?n=i.script.async:t.props.src&&!Qe(t.props.defer)&&!Qe(t.props.async)&&e!==`module`&&!e.endsWith(`json`)||t.innerHTML&&!e.endsWith(`json`)?n=i.script.sync:(Qe(t.props.defer)&&t.props.src&&!Qe(t.props.async)||e===`module`)&&(n=i.script.defer)}else t.tag===`style`&&(n=t.innerHTML&&iee.test(t.innerHTML)?i.style.imported:i.style.sync);return(n||100)+r}function oee(e,t){let n=typeof t==`function`?t(e):t,r=n.key||String(e.plugins.size+1);e.plugins.get(r)||(e.plugins.set(r,n),e.hooks.addHooks(n.hooks||{}))}function see(e={}){let t=Ne();t.addHooks(e.hooks||{});let n=!e.document,r=new Map,i=new Map,a=new Set,o={_entryCount:1,plugins:i,dirty:!1,resolvedOptions:e,hooks:t,ssr:n,entries:r,headEntries(){return[...r.values()]},use:e=>oee(o,e),push(e,i){let s={...i||{}};delete s.head;let c=s._index??o._entryCount++,l={_i:c,input:e,options:s},u={_poll(e=!1){o.dirty=!0,!e&&a.add(c),t.callHook(`entries:updated`,o)},dispose(){r.delete(c)&&o.invalidate()},patch(e){(!s.mode||s.mode===`server`&&n||s.mode===`client`&&!n)&&(l.input=e,r.set(c,l),u._poll())}};return u.patch(e),u},async resolveTags(){let n={tagMap:new Map,tags:[],entries:[...o.entries.values()]};for(await t.callHook(`entries:resolve`,n);a.size;){let n=a.values().next().value;a.delete(n);let i=r.get(n);if(i){let n={tags:Xe(i.input,e.propResolvers||[]).map(e=>Object.assign(e,i.options)),entry:i};await t.callHook(`entries:normalize`,n),i._tags=n.tags.map((e,t)=>(e._w=aee(o,e),e._p=(i._i<<10)+t,e._d=Ge(e),e._d||(e._h=Ke(e)),e))}}let i=!1;n.entries.flatMap(e=>(e._tags||[]).map(e=>({...e,props:{...e.props}}))).sort(Ze).reduce((e,t)=>{let n=t._d||t._h;if(!e.has(n))return e.set(n,t);let r=e.get(n);if((t?.tagDuplicateStrategy||(Be.has(t.tag)?`merge`:null)||(t.key&&t.key===r.key?`merge`:null))===`merge`){let i={...r.props};Object.entries(t.props).forEach(([e,t])=>i[e]=e===`style`?new Map([...r.props.style||new Map,...t]):e===`class`?new Set([...r.props.class||new Set,...t]):t),e.set(n,{...t,props:i})}else t._p>>10==r._p>>10&&t.tag===`meta`&&We(n)?(e.set(n,Object.assign([...Array.isArray(r)?r:[r],t],t)),i=!0):(t._w===r._w?t._p>r._p:t?._woee(o,e)),o.hooks.callHook(`init`,o),e.init?.forEach(e=>e&&o.push(e)),o}async function cee(e,t={}){let n=t.document||e.resolvedOptions.document;if(!n||!e.dirty)return;let r={shouldRender:!0,tags:[]};if(await e.hooks.callHook(`dom:beforeRender`,r),r.shouldRender)return e._domUpdatePromise||=new Promise(async t=>{let r=new Map,i=new Promise(t=>{e.resolveTags().then(e=>{t(e.map(e=>{let t=r.get(e._d)||0,n={tag:e,id:(t?`${e._d}:${t}`:e._d)||e._h,shouldRender:!0};return e._d&&We(e._d)&&r.set(e._d,t+1),n}))})}),a=e._dom;if(!a){a={title:n.title,elMap:new Map().set(`htmlAttrs`,n.documentElement).set(`bodyAttrs`,n.body)};for(let e of[`body`,`head`]){let t=n[e]?.children;for(let e of t){let t=e.tagName.toLowerCase();if(!Ie.has(t))continue;let n=Ye({tag:t,props:{}},{innerHTML:e.innerHTML,...e.getAttributeNames().reduce((t,n)=>(t[n]=e.getAttribute(n),t),{})||{}});if(n.key=e.getAttribute(`data-hid`)||void 0,n._d=Ge(n)||Ke(n),a.elMap.has(n._d)){let t=1,r=n._d;for(;a.elMap.has(r);)r=`${n._d}:${t++}`;a.elMap.set(r,e)}else a.elMap.set(n._d,e)}}}a.pendingSideEffects={...a.sideEffects},a.sideEffects={};function o(e,t,n){let r=`${e}:${t}`;a.sideEffects[r]=n,delete a.pendingSideEffects[r]}function s({id:e,$el:t,tag:r}){let i=r.tag.endsWith(`Attrs`);a.elMap.set(e,t),i||(r.textContent&&r.textContent!==t.textContent&&(t.textContent=r.textContent),r.innerHTML&&r.innerHTML!==t.innerHTML&&(t.innerHTML=r.innerHTML),o(e,`el`,()=>{t?.remove(),a.elMap.delete(e)}));for(let a in r.props){if(!Object.prototype.hasOwnProperty.call(r.props,a))continue;let s=r.props[a];if(a.startsWith(`on`)&&typeof s==`function`){let e=t?.dataset;if(e&&e[`${a}fired`]){let e=a.slice(0,-5);s.call(t,new Event(e.substring(2)))}t.getAttribute(`data-${a}`)!==``&&((r.tag===`bodyAttrs`?n.defaultView:t).addEventListener(a.substring(2),s.bind(t)),t.setAttribute(`data-${a}`,``));continue}let c=`attr:${a}`;if(a===`class`){if(!s)continue;for(let n of s)i&&o(e,`${c}:${n}`,()=>t.classList.remove(n)),!t.classList.contains(n)&&t.classList.add(n)}else if(a===`style`){if(!s)continue;for(let[n,r]of s)o(e,`${c}:${n}`,()=>{t.style.removeProperty(n)}),t.style.setProperty(n,r)}else s!==!1&&s!==null&&(t.getAttribute(a)!==s&&t.setAttribute(a,s===!0?``:String(s)),i&&o(e,c,()=>t.removeAttribute(a)))}}let c=[],l={bodyClose:void 0,bodyOpen:void 0,head:void 0},u=await i;for(let e of u){let{tag:t,shouldRender:r,id:i}=e;if(r){if(t.tag===`title`){n.title=t.textContent,o(`title`,``,()=>n.title=a.title);continue}e.$el=e.$el||a.elMap.get(i),e.$el?s(e):Ie.has(t.tag)&&c.push(e)}}for(let e of c){let t=e.tag.tagPosition||`head`;e.$el=n.createElement(e.tag.tag),s(e),l[t]=l[t]||n.createDocumentFragment(),l[t].appendChild(e.$el)}for(let t of u)await e.hooks.callHook(`dom:renderTag`,t,n,o);l.head&&n.head.appendChild(l.head),l.bodyOpen&&n.body.insertBefore(l.bodyOpen,n.body.firstChild),l.bodyClose&&n.body.appendChild(l.bodyClose);for(let e in a.pendingSideEffects)a.pendingSideEffects[e]();e._dom=a,await e.hooks.callHook(`dom:rendered`,{renders:u}),t()}).finally(()=>{e._domUpdatePromise=void 0,e.dirty=!1}),e._domUpdatePromise}function lee(e={}){let t=e.domOptions?.render||cee;e.document=e.document||(typeof window<`u`?document:void 0);let n=e.document?.head.querySelector(`script[id="unhead:payload"]`)?.innerHTML||!1;return see({...e,plugins:[...e.plugins||[],{key:`client`,hooks:{"entries:updated":t}}],init:[n?JSON.parse(n):!1,...e.init||[]]})}function uee(e,t){let n=0;return()=>{let r=++n;t(()=>{n===r&&e()})}}var $e={META:new Set([`twitter`]),OG:new Set([`og`,`book`,`article`,`profile`,`fb`]),MEDIA:new Set([`ogImage`,`ogVideo`,`ogAudio`,`twitterImage`]),HTTP_EQUIV:new Set([`contentType`,`defaultStyle`,`xUaCompatible`])},dee={articleExpirationTime:`article:expiration_time`,articleModifiedTime:`article:modified_time`,articlePublishedTime:`article:published_time`,bookReleaseDate:`book:release_date`,fbAppId:`fb:app_id`,ogAudioSecureUrl:`og:audio:secure_url`,ogAudioUrl:`og:audio`,ogImageSecureUrl:`og:image:secure_url`,ogImageUrl:`og:image`,ogSiteName:`og:site_name`,ogVideoSecureUrl:`og:video:secure_url`,ogVideoUrl:`og:video`,profileFirstName:`profile:first_name`,profileLastName:`profile:last_name`,profileUsername:`profile:username`,msapplicationConfig:`msapplication-Config`,msapplicationTileColor:`msapplication-TileColor`,msapplicationTileImage:`msapplication-TileImage`},fee={appleItunesApp:{unpack:{entrySeparator:`, `,resolve:({key:e,value:t})=>`${et(e)}=${t}`}},refresh:{metaKey:`http-equiv`,unpack:{entrySeparator:`;`,resolve:({key:e,value:t})=>e===`seconds`?`${t}`:void 0}},robots:{unpack:{entrySeparator:`, `,resolve:({key:e,value:t})=>typeof t==`boolean`?et(e):`${et(e)}:${t}`}},contentSecurityPolicy:{metaKey:`http-equiv`,unpack:{entrySeparator:`; `,resolve:({key:e,value:t})=>`${et(e)} ${t}`}},charset:{}};function et(e){let t=e.replace(/([A-Z])/g,`-$1`).toLowerCase(),n=t.indexOf(`-`);return n===-1?t:$e.META.has(t.slice(0,n))||$e.OG.has(t.slice(0,n))?e.replace(/([A-Z])/g,`:$1`).toLowerCase():t}function pee(e){return Object.fromEntries(Object.entries(e).filter(([e,t])=>String(t)!==`false`&&e))}function tt(e){return Array.isArray(e)?e.map(tt):!e||typeof e!=`object`?e:Object.fromEntries(Object.entries(e).map(([e,t])=>[et(e),tt(t)]))}function mee(e,t={}){let{entrySeparator:n=``,keyValueSeparator:r=``,wrapValue:i,resolve:a}=t;return Object.entries(e).map(([e,n])=>{if(a){let t=a({key:e,value:n});if(t!==void 0)return t}return`${e}${r}${typeof n==`object`?mee(n,t):typeof n==`number`?n.toString():typeof n==`string`&&i?`${i}${n.replace(new RegExp(i,`g`),`\\${i}`)}${i}`:n}`}).join(n)}function hee(e,t){let n=pee(t),r=et(e),i=gee(r);return Ve.has(r)?nt(Object.fromEntries(Object.entries(n).map(([t,n])=>[`${e}${t===`url`?``:`${t[0].toUpperCase()}${t.slice(1)}`}`,n]))||{}).sort((e,t)=>(e[i]?.length||0)-(t[i]?.length||0)):[{[i]:r,...n}]}function gee(e){if(fee[e]?.metaKey===`http-equiv`||$e.HTTP_EQUIV.has(e))return`http-equiv`;let t=et(e),n=t.indexOf(`:`);return n===-1?`name`:$e.OG.has(t.slice(0,n))?`property`:`name`}function _ee(e){return dee[e]||et(e)}function vee(e,t){return t===`refresh`?`${e.seconds};url=${e.url}`:mee(tt(e),{keyValueSeparator:`=`,entrySeparator:`, `,resolve:({value:e,key:t})=>e===null?``:typeof e==`boolean`?t:void 0,...fee[t]?.unpack})}function nt(e){let t=[],n={};for(let[r,i]of Object.entries(e)){if(Array.isArray(i)){if(r===`themeColor`){i.forEach(e=>{typeof e==`object`&&e&&t.push({name:`theme-color`,...e})});continue}for(let e of i)if(typeof e==`object`&&e){let n=[],i=[];for(let[t,a]of Object.entries(e)){let e=nt({[`${r}${t===`url`?``:`:${t}`}`]:a});(t===`url`?n:i).push(...e)}t.push(...n,...i)}else t.push(...typeof e==`string`?nt({[r]:e}):hee(r,e));continue}if(typeof i==`object`&&i)if($e.MEDIA.has(r)){let e=r.startsWith(`twitter`)?`twitter`:`og`,n=r.replace(/^(og|twitter)/,``).toLowerCase(),a=e===`twitter`?`name`:`property`;i.url&&t.push({[a]:`${e}:${n}`,content:i.url}),i.secureUrl&&t.push({[a]:`${e}:${n}:secure_url`,content:i.secureUrl});for(let[r,o]of Object.entries(i))r!==`url`&&r!==`secureUrl`&&t.push({[a]:`${e}:${n}:${r}`,content:o})}else Ve.has(et(r))?t.push(...hee(r,i)):n[r]=pee(i);else n[r]=i}let r=Object.entries(n).map(([e,t])=>{if(e===`charset`)return{charset:t===null?`_null`:t};let n=gee(e),r=_ee(e),i=t===null?`_null`:typeof t==`object`?vee(t,e):typeof t==`number`?t.toString():t;return n===`http-equiv`?{"http-equiv":r,content:i}:{[n]:r,content:i}});return[...t,...r].map(e=>`content`in e&&e.content===`_null`?{...e,content:null}:e)}function yee(e){return e}var bee=yee({key:`flatMeta`,hooks:{"entries:normalize":e=>{let t=[];e.tags=e.tags.map(e=>e.tag===`_flatMeta`?(t.push(nt(e.props).map(t=>({...e,tag:`meta`,props:t}))),!1):e).filter(Boolean).concat(...t)}}});function xee(e){if(Array.isArray(e))return e.map(xee);if(e&&typeof e==`object`){let t={};for(let n of Object.keys(e))n===`__proto__`||n===`constructor`||n===`prototype`||(t[n]=xee(e[n]));return t}return e}function rt(e){let t=Object.create(null);for(let n of e.split(`,`))t[n]=1;return e=>e in t}function it(e){if(_t(e)){let t={};for(let n=0;n{if(e){let n=e.split(Nee);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function O(e){let t=``;if(St(e))t=e;else if(_t(e))for(let n=0;not(e,t))}function Tee(e){return e==null?`initial`:typeof e==`string`?e===``?` `:e:String(e)}var ct,lt,ut,dt,ft,pt,mt,ht,Eee,gt,_t,vt,yt,bt,xt,St,Ct,wt,Tt,Et,Dt,Dee,Ot,kt,At,jt,Oee,Mt,kee,Nt,Pt,Ft,It,Lt,Rt,zt,Aee,jee,Bt,Mee,Nee,Pee,Vt,Fee,Ht,k,Ut,Wt,Gt=s((()=>{ct={},lt=[],ut=()=>{},dt=()=>!1,ft=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),pt=e=>e.startsWith(`onUpdate:`),mt=Object.assign,ht=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},Eee=Object.prototype.hasOwnProperty,gt=(e,t)=>Eee.call(e,t),_t=Array.isArray,vt=e=>Dt(e)===`[object Map]`,yt=e=>Dt(e)===`[object Set]`,bt=e=>Dt(e)===`[object Date]`,xt=e=>typeof e==`function`,St=e=>typeof e==`string`,Ct=e=>typeof e==`symbol`,wt=e=>typeof e==`object`&&!!e,Tt=e=>(wt(e)||xt(e))&&xt(e.then)&&xt(e.catch),Et=Object.prototype.toString,Dt=e=>Et.call(e),Dee=e=>Dt(e).slice(8,-1),Ot=e=>Dt(e)===`[object Object]`,kt=e=>St(e)&&e!==`NaN`&&e[0]!==`-`&&``+parseInt(e,10)===e,At=rt(`,key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted`),jt=e=>{let t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Oee=/-\w/g,Mt=jt(e=>e.replace(Oee,e=>e.slice(1).toUpperCase())),kee=/\B([A-Z])/g,Nt=jt(e=>e.replace(kee,`-$1`).toLowerCase()),Pt=jt(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ft=jt(e=>e?`on${Pt(e)}`:``),It=(e,t)=>!Object.is(e,t),Lt=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},zt=e=>{let t=parseFloat(e);return isNaN(t)?e:t},Aee=e=>{let t=St(e)?Number(e):NaN;return isNaN(t)?e:t},Bt=()=>jee||=typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:typeof global<`u`?global:{},Mee=/;(?![^(]*\))/g,Nee=/:([^]+)/,Pee=/\/\*[^]*?\*\//g,Vt=`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`,Fee=rt(Vt),rt(Vt+`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected`),Ht=e=>!!(e&&e.__v_isRef===!0),k=e=>St(e)?e:e==null?``:_t(e)||wt(e)&&(e.toString===Et||!xt(e.toString))?Ht(e)?k(e.value):JSON.stringify(e,Ut,2):String(e),Ut=(e,t)=>Ht(t)?Ut(e,t.value):vt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[Wt(t,r)+` =>`]=n,e),{})}:yt(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>Wt(e))}:Ct(t)?Wt(t):wt(t)&&!_t(t)&&!Ot(t)?String(t):t,Wt=(e,t=``)=>Ct(e)?`Symbol(${e.description??t})`:e}));function Kt(){return En}function qt(e,t=!1){En&&En.cleanups.push(e)}function Iee(e,t=!1){if(e.flags|=8,t){e.next=Mn,Mn=e;return}e.next=jn,jn=e}function Jt(){An++}function Yt(){if(--An>0)return;if(Mn){let e=Mn;for(Mn=void 0;e;){let t=e.next;e.next=void 0,e.flags&=-9,e=t}}let e;for(;jn;){let t=jn;for(jn=void 0;t;){let n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(t){e||=t}t=n}}if(e)throw e}function Lee(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ree(e){let t,n=e.depsTail,r=n;for(;r;){let e=r.prevDep;r.version===-1?(r===n&&(n=e),Zt(r),Bee(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function Xt(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(zee(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function zee(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Fn)||(e.globalVersion=Fn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Xt(e))))return;e.flags|=2;let t=e.dep,n=Dn,r=Nn;Dn=e,Nn=!0;try{Lee(e);let n=e.fn(e._value);(t.version===0||It(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(e){throw t.version++,e}finally{Dn=n,Nn=r,Ree(e),e.flags&=-3}}function Zt(e,t=!1){let{dep:n,prevSub:r,nextSub:i}=e;if(r&&(r.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)Zt(e,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Bee(e){let{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}function Qt(){Pn.push(Nn),Nn=!1}function $t(){let e=Pn.pop();Nn=e===void 0?!0:e}function Vee(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let e=Dn;Dn=void 0;try{t()}finally{Dn=e}}}function Hee(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)Hee(e)}let n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}function en(e,t,n){if(Nn&&Dn){let t=Ln.get(e);t||Ln.set(e,t=new Map);let r=t.get(n);r||(t.set(n,r=new In),r.map=t,r.key=n),r.track()}}function tn(e,t,n,r,i,a){let o=Ln.get(e);if(!o){Fn++;return}let s=e=>{e&&e.trigger()};if(Jt(),t===`clear`)o.forEach(s);else{let i=_t(e),a=i&&kt(n);if(i&&n===`length`){let e=Number(r);o.forEach((t,n)=>{(n===`length`||n===Bn||!Ct(n)&&n>=e)&&s(t)})}else switch((n!==void 0||o.has(void 0))&&s(o.get(n)),a&&s(o.get(Bn)),t){case`add`:i?a&&s(o.get(`length`)):(s(o.get(Rn)),vt(e)&&s(o.get(zn)));break;case`delete`:i||(s(o.get(Rn)),vt(e)&&s(o.get(zn)));break;case`set`:vt(e)&&s(o.get(Rn));break}}Yt()}function Uee(e,t){let n=Ln.get(e);return n&&n.get(t)}function nn(e){let t=bn(e);return t===e?t:(en(t,`iterate`,Bn),vn(e)?t:t.map(Zn))}function rn(e){return en(e=bn(e),`iterate`,Bn),e}function an(e,t){return _n(e)?gn(e)?Qn(Zn(t)):Qn(t):Zn(t)}function on(e,t,n){let r=rn(e),i=r[t]();return r!==e&&!vn(e)&&(i._next=i.next,i.next=()=>{let e=i._next();return e.done||(e.value=n(e.value)),e}),i}function sn(e,t,n,r,i,a){let o=rn(e),s=o!==e&&!vn(e),c=o[t];if(c!==ute[t]){let t=c.apply(e,a);return s?Zn(t):t}let l=n;o!==e&&(s?l=function(t,r){return n.call(this,an(e,t),r,e)}:n.length>2&&(l=function(t,r){return n.call(this,t,r,e)}));let u=c.call(o,l,r);return s&&i?i(u):u}function Wee(e,t,n,r){let i=rn(e),a=i!==e&&!vn(e),o=n,s=!1;i!==e&&(a?(s=r.length===0,o=function(t,r,i){return s&&(s=!1,t=an(e,t)),n.call(this,t,an(e,r),i,e)}):n.length>3&&(o=function(t,r,i){return n.call(this,t,r,i,e)}));let c=i[t](o,...r);return s?an(e,c):c}function cn(e,t,n){let r=bn(e);en(r,`iterate`,Bn);let i=r[t](...n);return(i===-1||i===!1)&&yn(n[0])?(n[0]=bn(n[0]),r[t](...n)):i}function ln(e,t,n=[]){Qt(),Jt();let r=bn(e)[t].apply(e,n);return Yt(),$t(),r}function Gee(e){Ct(e)||(e=String(e));let t=bn(this);return en(t,`has`,e),t.hasOwnProperty(e)}function Kee(e,t,n){return function(...r){let i=this.__v_raw,a=bn(i),o=vt(a),s=e===`entries`||e===Symbol.iterator&&o,c=e===`keys`&&o,l=i[e](...r),u=n?Gn:t?Qn:Zn;return!t&&en(a,`iterate`,c?zn:Rn),mt(Object.create(l),{next(){let{value:e,done:t}=l.next();return t?{value:e,done:t}:{value:s?[u(e[0]),u(e[1])]:u(e),done:t}}})}}function un(e){return function(...t){return e===`delete`?!1:e===`clear`?void 0:this}}function qee(e,t){let n={get(n){let r=this.__v_raw,i=bn(r),a=bn(n);e||(It(n,a)&&en(i,`get`,n),en(i,`get`,a));let{has:o}=Kn(i),s=t?Gn:e?Qn:Zn;if(o.call(i,n))return s(r.get(n));if(o.call(i,a))return s(r.get(a));r!==i&&r.get(n)},get size(){let t=this.__v_raw;return!e&&en(bn(t),`iterate`,Rn),t.size},has(t){let n=this.__v_raw,r=bn(n),i=bn(t);return e||(It(t,i)&&en(r,`has`,t),en(r,`has`,i)),t===i?n.has(t):n.has(t)||n.has(i)},forEach(n,r){let i=this,a=i.__v_raw,o=bn(a),s=t?Gn:e?Qn:Zn;return!e&&en(o,`iterate`,Rn),a.forEach((e,t)=>n.call(r,s(e),s(t),i))}};return mt(n,e?{add:un(`add`),set:un(`set`),delete:un(`delete`),clear:un(`clear`)}:{add(e){let n=bn(this),r=Kn(n),i=bn(e),a=!t&&!vn(e)&&!_n(e)?i:e;return r.has.call(n,a)||It(e,a)&&r.has.call(n,e)||It(i,a)&&r.has.call(n,i)||(n.add(a),tn(n,`add`,a,a)),this},set(e,n){!t&&!vn(n)&&!_n(n)&&(n=bn(n));let r=bn(this),{has:i,get:a}=Kn(r),o=i.call(r,e);o||=(e=bn(e),i.call(r,e));let s=a.call(r,e);return r.set(e,n),o?It(n,s)&&tn(r,`set`,e,n,s):tn(r,`add`,e,n),this},delete(e){let t=bn(this),{has:n,get:r}=Kn(t),i=n.call(t,e);i||=(e=bn(e),n.call(t,e));let a=r?r.call(t,e):void 0,o=t.delete(e);return i&&tn(t,`delete`,e,void 0,a),o},clear(){let e=bn(this),t=e.size!==0,n=e.clear();return t&&tn(e,`clear`,void 0,void 0,void 0),n}}),[`keys`,`values`,`entries`,Symbol.iterator].forEach(r=>{n[r]=Kee(r,e,t)}),n}function dn(e,t){let n=qee(e,t);return(t,r,i)=>r===`__v_isReactive`?!e:r===`__v_isReadonly`?e:r===`__v_raw`?t:Reflect.get(gt(n,r)&&r in t?n:t,r,i)}function Jee(e){switch(e){case`Object`:case`Array`:return 1;case`Map`:case`Set`:case`WeakMap`:case`WeakSet`:return 2;default:return 0}}function Yee(e){return e.__v_skip||!Object.isExtensible(e)?0:Jee(Dee(e))}function fn(e){return _n(e)?e:hn(e,!1,fte,gte,qn)}function Xee(e){return hn(e,!1,mte,_te,Jn)}function pn(e){return hn(e,!0,pte,vte,Yn)}function mn(e){return hn(e,!0,hte,yte,Xn)}function hn(e,t,n,r,i){if(!wt(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let a=Yee(e);if(a===0)return e;let o=i.get(e);if(o)return o;let s=new Proxy(e,a===2?r:n);return i.set(e,s),s}function gn(e){return _n(e)?gn(e.__v_raw):!!(e&&e.__v_isReactive)}function _n(e){return!!(e&&e.__v_isReadonly)}function vn(e){return!!(e&&e.__v_isShallow)}function yn(e){return e?!!e.__v_raw:!1}function bn(e){let t=e&&e.__v_raw;return t?bn(t):e}function Zee(e){return!gt(e,`__v_skip`)&&Object.isExtensible(e)&&Rt(e,`__v_skip`,!0),e}function xn(e){return e?e.__v_isRef===!0:!1}function A(e){return Qee(e,!1)}function Sn(e){return Qee(e,!0)}function Qee(e,t){return xn(e)?e:new bte(e,t)}function $ee(e){e.dep&&e.dep.trigger()}function j(e){return xn(e)?e.value:e}function Cn(e){return xt(e)?e():j(e)}function ete(e){return gn(e)?e:new Proxy(e,xte)}function tte(e){return new Ste(e)}function nte(e){let t=_t(e)?Array(e.length):{};for(let n in e)t[n]=rte(e,n);return t}function wn(e,t,n){return xn(e)?e:xt(e)?new wte(e):wt(e)&&arguments.length>1?rte(e,t,n):A(e)}function rte(e,t,n){return new Cte(e,t,n)}function ite(e,t,n=!1){let r,i;return xt(e)?r=e:(r=e.get,i=e.set),new Tte(r,i,n)}function ate(e,t=!1,n=tr){if(n){let t=er.get(n);t||er.set(n,t=[]),t.push(e)}}function ote(e,t,n=ct){let{immediate:r,deep:i,once:a,scheduler:o,augmentJob:s,call:c}=n,l=e=>i?e:vn(e)||i===!1||i===0?Tn(e,1):Tn(e),u,d,f,p,m=!1,h=!1;if(xn(e)?(d=()=>e.value,m=vn(e)):gn(e)?(d=()=>l(e),m=!0):_t(e)?(h=!0,m=e.some(e=>gn(e)||vn(e)),d=()=>e.map(e=>{if(xn(e))return e.value;if(gn(e))return l(e);if(xt(e))return c?c(e,2):e()})):d=xt(e)?t?c?()=>c(e,2):e:()=>{if(f){Qt();try{f()}finally{$t()}}let t=tr;tr=u;try{return c?c(e,3,[p]):e(p)}finally{tr=t}}:ut,t&&i){let e=d,t=i===!0?1/0:i;d=()=>Tn(e(),t)}let g=Kt(),_=()=>{u.stop(),g&&g.active&&ht(g.effects,u)};if(a&&t){let e=t;t=(...t)=>{e(...t),_()}}let v=h?Array(e.length).fill($n):$n,y=e=>{if(!(!(u.flags&1)||!u.dirty&&!e))if(t){let e=u.run();if(i||m||(h?e.some((e,t)=>It(e,v[t])):It(e,v))){f&&f();let n=tr;tr=u;try{let n=[e,v===$n?void 0:h&&v[0]===$n?[]:v,p];v=e,c?c(t,3,n):t(...n)}finally{tr=n}}}else u.run()};return s&&s(y),u=new kn(d),u.scheduler=o?()=>o(y,!1):y,p=e=>ate(e,!1,u),f=u.onStop=()=>{let e=er.get(u);if(e){if(c)c(e,4);else for(let t of e)t();er.delete(u)}},t?r?y(!0):v=u.run():o?o(y.bind(null,!0),!0):u.run(),_.pause=u.pause.bind(u),_.resume=u.resume.bind(u),_.stop=_,_}function Tn(e,t=1/0,n){if(t<=0||!wt(e)||e.__v_skip||(n||=new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,xn(e))Tn(e.value,t,n);else if(_t(e))for(let r=0;r{Tn(e,t,n)});else if(Ot(e)){for(let r in e)Tn(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&Tn(e[r],t,n)}return e}var En,ste,Dn,On,kn,An,jn,Mn,Nn,Pn,Fn,cte,In,Ln,Rn,zn,Bn,lte,ute,dte,Vn,Hn,Un,Wn,fte,pte,mte,hte,Gn,Kn,gte,_te,vte,yte,qn,Jn,Yn,Xn,Zn,Qn,bte,xte,Ste,Cte,wte,Tte,$n,er,tr,Ete=s((()=>{Gt(),ste=class{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=En,!e&&En&&(this.index=(En.scopes||=[]).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let e,t;if(this.scopes)for(e=0,t=this.scopes.length;e0&&--this._on===0&&(En=this.prevScope,this.prevScope=void 0)}stop(e){if(this._active){this._active=!1;let t,n;for(t=0,n=this.effects.length;tan(this,e))},concat(...e){return nn(this).concat(...e.map(e=>_t(e)?nn(e):e))},entries(){return on(this,`entries`,e=>(e[1]=an(this,e[1]),e))},every(e,t){return sn(this,`every`,e,t,void 0,arguments)},filter(e,t){return sn(this,`filter`,e,t,e=>e.map(e=>an(this,e)),arguments)},find(e,t){return sn(this,`find`,e,t,e=>an(this,e),arguments)},findIndex(e,t){return sn(this,`findIndex`,e,t,void 0,arguments)},findLast(e,t){return sn(this,`findLast`,e,t,e=>an(this,e),arguments)},findLastIndex(e,t){return sn(this,`findLastIndex`,e,t,void 0,arguments)},forEach(e,t){return sn(this,`forEach`,e,t,void 0,arguments)},includes(...e){return cn(this,`includes`,e)},indexOf(...e){return cn(this,`indexOf`,e)},join(e){return nn(this).join(e)},lastIndexOf(...e){return cn(this,`lastIndexOf`,e)},map(e,t){return sn(this,`map`,e,t,void 0,arguments)},pop(){return ln(this,`pop`)},push(...e){return ln(this,`push`,e)},reduce(e,...t){return Wee(this,`reduce`,e,t)},reduceRight(e,...t){return Wee(this,`reduceRight`,e,t)},shift(){return ln(this,`shift`)},some(e,t){return sn(this,`some`,e,t,void 0,arguments)},splice(...e){return ln(this,`splice`,e)},toReversed(){return nn(this).toReversed()},toSorted(e){return nn(this).toSorted(e)},toSpliced(...e){return nn(this).toSpliced(...e)},unshift(...e){return ln(this,`unshift`,e)},values(){return on(this,`values`,e=>an(this,e))}},ute=Array.prototype,dte=rt(`__proto__,__v_isRef,__isVue`),Vn=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!==`arguments`&&e!==`caller`).map(e=>Symbol[e]).filter(Ct)),Hn=class{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){if(t===`__v_skip`)return e.__v_skip;let r=this._isReadonly,i=this._isShallow;if(t===`__v_isReactive`)return!r;if(t===`__v_isReadonly`)return r;if(t===`__v_isShallow`)return i;if(t===`__v_raw`)return n===(r?i?Xn:Yn:i?Jn:qn).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let a=_t(e);if(!r){let e;if(a&&(e=lte[t]))return e;if(t===`hasOwnProperty`)return Gee}let o=Reflect.get(e,t,xn(e)?e:n);if((Ct(t)?Vn.has(t):dte(t))||(r||en(e,`get`,t),i))return o;if(xn(o)){let e=a&&kt(t)?o:o.value;return r&&wt(e)?pn(e):e}return wt(o)?r?pn(o):fn(o):o}},Un=class extends Hn{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t],a=_t(e)&&kt(t);if(!this._isShallow){let e=_n(i);if(!vn(n)&&!_n(n)&&(i=bn(i),n=bn(n)),!a&&xn(i)&&!xn(n))return e||(i.value=n),!0}let o=a?Number(t)e,Kn=e=>Reflect.getPrototypeOf(e),gte={get:dn(!1,!1)},_te={get:dn(!1,!0)},vte={get:dn(!0,!1)},yte={get:dn(!0,!0)},qn=new WeakMap,Jn=new WeakMap,Yn=new WeakMap,Xn=new WeakMap,Zn=e=>wt(e)?fn(e):e,Qn=e=>wt(e)?pn(e):e,bte=class{constructor(e,t){this.dep=new In,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:bn(e),this._value=t?e:Zn(e),this.__v_isShallow=t}get value(){return this.dep.track(),this._value}set value(e){let t=this._rawValue,n=this.__v_isShallow||vn(e)||_n(e);e=n?e:bn(e),It(e,t)&&(this._rawValue=e,this._value=n?e:Zn(e),this.dep.trigger())}},xte={get:(e,t,n)=>t===`__v_raw`?e:j(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return xn(i)&&!xn(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}},Ste=class{constructor(e){this.__v_isRef=!0,this._value=void 0;let t=this.dep=new In,{get:n,set:r}=e(t.track.bind(t),t.trigger.bind(t));this._get=n,this._set=r}get value(){return this._value=this._get()}set value(e){this._set(e)}},Cte=class{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0,this._value=void 0,this._raw=bn(e);let r=!0,i=e;if(!_t(e)||!kt(String(t)))do r=!yn(i)||vn(i);while(r&&(i=i.__v_raw));this._shallow=r}get value(){let e=this._object[this._key];return this._shallow&&(e=j(e)),this._value=e===void 0?this._defaultValue:e}set value(e){if(this._shallow&&xn(this._raw[this._key])){let t=this._object[this._key];if(xn(t)){t.value=e;return}}this._object[this._key]=e}get dep(){return Uee(this._raw,this._key)}},wte=class{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}},Tte=class{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new In(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Fn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&Dn!==this)return Iee(this,!0),!0}get value(){let e=this.dep.track();return zee(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}},$n={},er=new WeakMap,tr=void 0}));function nr(e,t,n,r){try{return r?e(...r):e()}catch(e){ir(e,t,n)}}function rr(e,t,n,r){if(xt(e)){let i=nr(e,t,n,r);return i&&Tt(i)&&i.catch(e=>{ir(e,t,n)}),i}if(_t(e)){let i=[];for(let a=0;a>>1,i=li[r],a=gi(i);a=gi(n)?li.push(e):li.splice(Ote(t),0,e),e.flags|=1,kte()}}function kte(){hi||=mi.then(Mte)}function Ate(e){_t(e)?di.push(...e):fi&&e.id===-1?fi.splice(pi+1,0,e):e.flags&1||(di.push(e),e.flags|=1),kte()}function jte(e,t,n=ui+1){for(;ngi(e)-gi(t));if(di.length=0,fi){fi.push(...e);return}for(fi=e,pi=0;pi{r._d&&Xr(-1);let i=cr(t),a;try{a=e(...n)}finally{cr(i),r._d&&Xr(1)}return a};return r._n=!0,r._c=!0,r._d=!0,r}function lr(e,t){if(_i===null)return e;let n=si(_i),r=e.dirs||=[];for(let e=0;e1)return n&&xt(t)?t.call(r&&r.proxy):t}}function Nte(){return!!(ba()||$i)}function pr(e,t){return hr(e,null,t)}function Pte(e,t){return hr(e,null,{flush:`sync`})}function mr(e,t,n){return hr(e,t,n)}function hr(e,t,n=ct){let{immediate:r,deep:i,flush:a,once:o}=n,s=mt({},n),c=t&&r||!t&&a!==`post`,l;if(Ta){if(a===`sync`){let e=qne();l=e.__watcherHandles||=[]}else if(!c){let e=()=>{};return e.stop=ut,e.resume=ut,e.pause=ut,e}}let u=ya;s.call=(e,t,n)=>rr(e,u,t,n);let d=!1;a===`post`?s.scheduler=e=>{la(e,u&&u.suspense)}:a!==`sync`&&(d=!0,s.scheduler=(e,t)=>{t?e():or(e)}),s.augmentJob=e=>{t&&(e.flags|=4),d&&(e.flags|=2,u&&(e.id=u.uid,e.i=u))};let f=ote(e,t,s);return Ta&&(l?l.push(f):c&&f()),f}function Fte(e,t,n){let r=this.proxy,i=St(e)?e.includes(`.`)?Ite(r,e):()=>r[e]:e.bind(r,r),a;xt(t)?a=t:(a=t.handler,n=t);let o=Ca(this),s=hr(i,a.bind(r),n);return o(),s}function Ite(e,t){let n=t.split(`.`);return()=>{let t=e;for(let e=0;e{e.isMounted=!0}),Wi(()=>{e.isUnmounting=!0}),e}function zte(e){let t=e[0];if(e.length>1){for(let n of e)if(n.type!==fa){t=n;break}}return t}function Bte(e,t){let{leavingVNodes:n}=e,r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function yr(e,t,n,r,i){let{appear:a,mode:o,persisted:s=!1,onBeforeEnter:c,onEnter:l,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:f,onLeave:p,onAfterLeave:m,onLeaveCancelled:h,onBeforeAppear:g,onAppear:_,onAfterAppear:v,onAppearCancelled:y}=t,b=String(e.key),x=Bte(n,e),S=(e,t)=>{e&&rr(e,r,9,t)},C=(e,t)=>{let n=t[1];S(e,t),_t(e)?e.every(e=>e.length<=1)&&n():e.length<=1&&n()},w={mode:o,persisted:s,beforeEnter(t){let r=c;if(!n.isMounted)if(a)r=g||c;else return;t[Oi]&&t[Oi](!0);let i=x[b];i&&Qr(e,i)&&i.el[Oi]&&i.el[Oi](),S(r,[t])},enter(t){if(x[b]===e)return;let r=l,i=u,o=d;if(!n.isMounted)if(a)r=_||l,i=v||u,o=y||d;else return;let s=!1;t[ki]=e=>{s||(s=!0,S(e?o:i,[t]),w.delayedLeave&&w.delayedLeave(),t[ki]=void 0)};let c=t[ki].bind(null,!1);r?C(r,[t,c]):c()},leave(t,r){let i=String(e.key);if(t[ki]&&t[ki](!0),n.isUnmounting)return r();S(f,[t]);let a=!1;t[Oi]=n=>{a||(a=!0,r(),S(n?h:m,[t]),t[Oi]=void 0,x[i]===e&&delete x[i])};let o=t[Oi].bind(null,!1);x[i]=e,p?C(p,[t,o]):o()},clone(e){let a=yr(e,t,n,r,i);return i&&i(a),a}};return w}function br(e){if(zi(e))return e=ei(e),e.children=null,e}function Vte(e){if(!zi(e))return bi(e.type)&&e.children?zte(e.children):e;if(e.component)return e.component.subTree;let{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&xt(n.default))return n.default()}}function xr(e,t){e.shapeFlag&6&&e.component?(e.transition=t,xr(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Hte(e,t=!1,n){let r=[],i=0;for(let a=0;a1)for(let e=0;en.value,set:e=>n.value=e})}return n}function Ute(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}function Tr(e,t,n,r,i=!1){if(_t(e)){e.forEach((e,a)=>Tr(e,t&&(_t(t)?t[a]:t),n,r,i));return}if(Ri(r)&&!i){r.shapeFlag&512&&r.type.__asyncResolved&&r.component.subTree.component&&Tr(e,t,n,r.component.subTree);return}let a=r.shapeFlag&4?si(r.component):r.el,o=i?null:a,{i:s,r:c}=e,l=t&&t.r,u=s.refs===ct?s.refs={}:s.refs,d=s.setupState,f=bn(d),p=d===ct?dt:e=>Ute(u,e)?!1:gt(f,e),m=(e,t)=>!(t&&Ute(u,t));if(l!=null&&l!==c){if(Wte(t),St(l))u[l]=null,p(l)&&(d[l]=null);else if(xn(l)){let e=t;m(l,e.k)&&(l.value=null),e.k&&(u[e.k]=null)}}if(xt(c))nr(c,s,12,[o,u]);else{let t=St(c),r=xn(c);if(t||r){let s=()=>{if(e.f){let n=t?p(c)?d[c]:u[c]:m(c)||!e.k?c.value:u[e.k];if(i)_t(n)&&ht(n,a);else if(_t(n))n.includes(a)||n.push(a);else if(t)u[c]=[a],p(c)&&(d[c]=u[c]);else{let t=[a];m(c,e.k)&&(c.value=t),e.k&&(u[e.k]=t)}}else t?(u[c]=o,p(c)&&(d[c]=o)):r&&(m(c,e.k)&&(c.value=o),e.k&&(u[e.k]=o))};if(o){let t=()=>{s(),Ni.delete(e)};t.id=-1,Ni.set(e,t),la(t,n)}else Wte(e),s()}}}function Wte(e){let t=Ni.get(e);t&&(t.flags|=8,Ni.delete(e))}function Gte(e){let{mt:t,p:n,o:{patchProp:r,createText:i,nextSibling:a,parentNode:o,remove:s,insert:c,createComment:l}}=e,u=(e,t)=>{if(!t.hasChildNodes()){n(null,e,t),sr(),t._vnode=e;return}d(t.firstChild,e,null,null,null),sr(),t._vnode=e},d=(n,r,s,l,u,y=!1)=>{y||=!!r.dynamicChildren;let b=Ii(n)&&n.data===`[`,x=()=>h(n,r,s,l,u,b),{type:S,ref:C,shapeFlag:w,patchFlag:ee}=r,te=n.nodeType;r.el=n,ee===-2&&(y=!1,r.dynamicChildren=null);let ne=null;switch(S){case da:te===3?(n.data!==r.children&&(Pi(),n.data=r.children),ne=a(n)):r.children===``?(c(r.el=i(``),o(n),n),ne=n):ne=x();break;case fa:v(n)?(ne=a(n),_(r.el=n.content.firstChild,n,s)):ne=te!==8||b?x():a(n);break;case pa:if(b&&(n=a(n),te=n.nodeType),te===1||te===3){ne=n;let e=!r.children.length;for(let t=0;t{o||=!!t.dynamicChildren;let{type:c,props:l,patchFlag:u,shapeFlag:d,dirs:f,transition:m}=t,h=c===`input`||c===`option`;if(h||u!==-1){f&&ur(t,null,n,`created`);let c=!1;if(v(e)){c=One(null,m)&&n&&n.vnode.props&&n.vnode.props.appear;let r=e.content.firstChild;if(c){let e=r.getAttribute(`class`);e&&(r.$cls=e),m.beforeEnter(r)}_(r,e,n),t.el=e=r}if(d&16&&!(l&&(l.innerHTML||l.textContent))){let r=p(e.firstChild,t,e,n,i,a,o);for(;r;){Er(e,1)||Pi();let t=r;r=r.nextSibling,s(t)}}else if(d&8){let n=t.children;n[0]===` -`&&(e.tagName===`PRE`||e.tagName===`TEXTAREA`)&&(n=n.slice(1));let{textContent:r}=e;r!==n&&r!==n.replace(/\r\n|\r/g,` -`)&&(Er(e,0)||Pi(),e.textContent=t.children)}if(l){if(h||!o||u&48){let t=e.tagName.includes(`-`);for(let i in l)(h&&(i.endsWith(`value`)||i===`indeterminate`)||ft(i)&&!At(i)||i[0]===`.`||t&&!At(i))&&r(e,i,null,l[i],void 0,n)}else if(l.onClick)r(e,`onClick`,null,l.onClick,void 0,n);else if(u&4&&gn(l.style))for(let e in l.style)l.style[e]}let g;(g=l&&l.onVnodeBeforeMount)&&oi(g,n,t),f&&ur(t,null,n,`beforeMount`),((g=l&&l.onVnodeMounted)||f||c)&&Nne(()=>{g&&oi(g,n,t),c&&m.enter(e),f&&ur(t,null,n,`mounted`)},i)}return e.nextSibling},p=(e,t,r,o,s,l,u)=>{u||=!!t.dynamicChildren;let f=t.children,p=f.length;for(let t=0;t{let{slotScopeIds:u}=t;u&&(i=i?i.concat(u):u);let d=o(e),f=p(a(e),t,d,n,r,i,s);return f&&Ii(f)&&f.data===`]`?a(t.anchor=f):(Pi(),c(t.anchor=l(`]`),d,f),f)},h=(e,t,r,i,c,l)=>{if(Er(e.parentElement,1)||Pi(),t.el=null,l){let t=g(e);for(;;){let n=a(e);if(n&&n!==t)s(n);else break}}let u=a(e),d=o(e);return s(e),n(null,t,d,u,r,i,Fi(d),c),r&&(r.vnode.el=t.el,yne(r,t.el)),u},g=(e,t=`[`,n=`]`)=>{let r=0;for(;e;)if(e=a(e),e&&Ii(e)&&(e.data===t&&r++,e.data===n)){if(r===0)return a(e);r--}return e},_=(e,t,n)=>{let r=t.parentNode;r&&r.replaceChild(e,t);let i=n;for(;i;)i.vnode.el===t&&(i.vnode.el=i.subTree.el=e),i=i.parent},v=e=>e.nodeType===1&&e.tagName===`TEMPLATE`;return[u,d]}function Er(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(Li);)e=e.parentElement;let n=e&&e.getAttribute(Li);if(n==null)return!1;if(n===``)return!0;{let e=n.split(`,`);return t===0&&e.includes(`children`)?!0:e.includes($ne[t])}}function Kte(e,t){if(Ii(e)&&e.data===`[`){let n=1,r=e.nextSibling;for(;r;){if(r.nodeType===1){if(t(r)===!1)break}else if(Ii(r))if(r.data===`]`){if(--n===0)break}else r.data===`[`&&n++;r=r.nextSibling}}else t(e)}function qte(e){xt(e)&&(e={loader:e});let{loader:t,loadingComponent:n,errorComponent:r,delay:i=200,hydrate:a,timeout:o,suspensible:s=!0,onError:c}=e,l=null,u,d=0,f=()=>(d++,l=null,p()),p=()=>{let e;return l||(e=l=t().catch(e=>{if(e=e instanceof Error?e:Error(String(e)),c)return new Promise((t,n)=>{c(e,()=>t(f()),()=>n(e),d+1)});throw e}).then(t=>e!==l&&l?l:(t&&(t.__esModule||t[Symbol.toStringTag]===`Module`)&&(t=t.default),u=t,t)))};return N({name:`AsyncComponentWrapper`,__asyncLoader:p,__asyncHydrate(e,t,n){let r=!1;(t.bu||=[]).push(()=>r=!0);let i=()=>{r||n()},o=a?()=>{let n=a(i,t=>Kte(e,t));n&&(t.bum||=[]).push(n)}:i;u?o():p().then(()=>!t.isUnmounted&&o())},get __asyncResolved(){return u},setup(){let e=ya;if(Cr(e),u)return()=>Dr(u,e);let t=t=>{l=null,ir(t,e,13,!r)};if(s&&e.suspense||Ta)return p().then(t=>()=>Dr(t,e)).catch(e=>(t(e),()=>r?H(r,{error:e}):null));let a=A(!1),c=A(),d=A(!!i);return i&&setTimeout(()=>{d.value=!1},i),o!=null&&setTimeout(()=>{if(!a.value&&!c.value){let e=Error(`Async component timed out after ${o}ms.`);t(e),c.value=e}},o),p().then(()=>{a.value=!0,e.parent&&zi(e.parent.vnode)&&e.parent.update()}).catch(e=>{t(e),c.value=e}),()=>{if(a.value&&u)return Dr(u,e);if(c.value&&r)return H(r,{error:c.value});if(n&&!d.value)return Dr(n,e)}}})}function Dr(e,t){let{ref:n,props:r,children:i,ce:a}=t.vnode,o=H(e,r,i);return o.ref=n,o.ce=a,delete t.vnode.ce,o}function Jte(e,t){Xte(e,`a`,t)}function Yte(e,t){Xte(e,`da`,t)}function Xte(e,t,n=ya){let r=e.__wdc||=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()};if(Or(t,r,n),n){let e=n.parent;for(;e&&e.parent;)zi(e.parent.vnode)&&Zte(r,t,n,e),e=e.parent}}function Zte(e,t,n,r){let i=Or(t,e,r,!0);Gi(()=>{ht(r[t],i)},n)}function Or(e,t,n=ya,r=!1){if(n){let i=n[e]||(n[e]=[]),a=t.__weh||=(...r)=>{Qt();let i=Ca(n),a=rr(t,n,e,r);return i(),$t(),a};return r?i.unshift(a):i.push(a),a}}function Qte(e,t=ya){Or(`ec`,e,t)}function $te(e,t){return ene(qi,e,!0,t)||e}function kr(e){return St(e)?ene(qi,e,!1)||e:e||Ji}function ene(e,t,n=!0,r=!1){let i=_i||ya;if(i){let n=i.type;if(e===qi){let e=Wne(n,!1);if(e&&(e===t||e===Mt(t)||e===Pt(Mt(t))))return n}let a=tne(i[e]||n[e],t)||tne(i.appContext[e],t);return!a&&r?n:a}}function tne(e,t){return e&&(e[t]||e[Mt(t)]||e[Pt(Mt(t))])}function Ar(e,t,n,r){let i,a=n&&n[r],o=_t(e);if(o||St(e)){let n=o&&gn(e),r=!1,s=!1;n&&(r=!vn(e),s=_n(e),e=rn(e)),i=Array(e.length);for(let n=0,o=e.length;nt(e,n,void 0,a&&a[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,o=n.length;r{let t=r.fn(...e);return t&&(t.key=r.key),t}:r.fn)}return e}function P(e,t,n={},r,i){if(_i.ce||_i.parent&&Ri(_i.parent)&&_i.parent.ce){let e=Object.keys(n).length>0;return t!==`default`&&(n.name=t),F(),L(V,null,[H(`slot`,n,r&&r())],e?-2:64)}let a=e[t];a&&a._c&&(a._d=!1),F();let o=a&&Mr(a(n)),s=n.key||o&&o.key,c=L(V,{key:(s&&!Ct(s)?s:`_${t}`)+(!o&&r?`_fb`:``)},o||(r?r():[]),o&&e._===1?64:-2);return!i&&c.scopeId&&(c.slotScopeIds=[c.scopeId+`-s`]),a&&a._c&&(a._d=!0),c}function Mr(e){return e.some(e=>Zr(e)?!(e.type===fa||e.type===V&&!Mr(e.children)):!0)?e:null}function Nr(e,t){let n={};for(let r in e)n[t&&/[A-Z]/.test(r)?`on:${r}`:Ft(r)]=e[r];return n}function Pr(){return nne(`useSlots`).slots}function Fr(){return nne(`useAttrs`).attrs}function nne(e){let t=ba();return t.setupContext||=Une(t)}function Ir(e){return _t(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}function Lr(e,t){return!e||!t?e||t:_t(e)&&_t(t)?e.concat(t):mt({},Ir(e),Ir(t))}function rne(e,t){let n={};for(let r in e)t.includes(r)||Object.defineProperty(n,r,{enumerable:!0,get:()=>e[r]});return n}function ine(e){let t=Rr(e),n=e.proxy,r=e.ctx;Qi=!1,t.beforeCreate&&one(t.beforeCreate,e,`bc`);let{data:i,computed:a,methods:o,watch:s,provide:c,inject:l,created:u,beforeMount:d,mounted:f,beforeUpdate:p,updated:m,activated:h,deactivated:g,beforeDestroy:_,beforeUnmount:v,destroyed:y,unmounted:b,render:x,renderTracked:S,renderTriggered:C,errorCaptured:w,serverPrefetch:ee,expose:te,inheritAttrs:ne,components:re,directives:T,filters:ie}=t;if(l&&ane(l,r,null),o)for(let e in o){let t=o[e];xt(t)&&(r[e]=t.bind(n))}if(i){let t=i.call(n,n);wt(t)&&(e.data=fn(t))}if(Qi=!0,a)for(let e in a){let t=a[e],i=U({get:xt(t)?t.bind(n,n):xt(t.get)?t.get.bind(n,n):ut,set:!xt(t)&&xt(t.set)?t.set.bind(n):ut});Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e})}if(s)for(let e in s)sne(s[e],r,n,e);if(c){let e=xt(c)?c.call(n):c;Reflect.ownKeys(e).forEach(t=>{dr(t,e[t])})}u&&one(u,e,`c`);function ae(e,t){_t(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(ae(Vi,d),ae(Hi,f),ae(Ui,p),ae(ere,m),ae(Jte,h),ae(Yte,g),ae(Qte,w),ae(nre,S),ae(tre,C),ae(Wi,v),ae(Gi,b),ae(Ki,ee),_t(te))if(te.length){let t=e.exposed||={};te.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t,enumerable:!0})})}else e.exposed||={};x&&e.render===ut&&(e.render=x),ne!=null&&(e.inheritAttrs=ne),re&&(e.components=re),T&&(e.directives=T),ee&&Cr(e)}function ane(e,t,n=ut){_t(e)&&(e=Br(e));for(let n in e){let r=e[n],i;i=wt(r)?`default`in r?fr(r.from||n,r.default,!0):fr(r.from||n):fr(r),xn(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e}):t[n]=i}}function one(e,t,n){rr(_t(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function sne(e,t,n,r){let i=r.includes(`.`)?Ite(n,r):()=>n[r];if(St(e)){let n=t[e];xt(n)&&mr(i,n)}else if(xt(e))mr(i,e.bind(n));else if(wt(e))if(_t(e))e.forEach(e=>sne(e,t,n,r));else{let r=xt(e.handler)?e.handler.bind(n):t[e.handler];xt(r)&&mr(i,r,e)}}function Rr(e){let t=e.type,{mixins:n,extends:r}=t,{mixins:i,optionsCache:a,config:{optionMergeStrategies:o}}=e.appContext,s=a.get(t),c;return s?c=s:!i.length&&!n&&!r?c=t:(c={},i.length&&i.forEach(e=>zr(c,e,o,!0)),zr(c,t,o)),wt(t)&&a.set(t,c),c}function zr(e,t,n,r=!1){let{mixins:i,extends:a}=t;a&&zr(e,a,n,!0),i&&i.forEach(t=>zr(e,t,n,!0));for(let i in t)if(!(r&&i===`expose`)){let r=ire[i]||n&&n[i];e[i]=r?r(e[i],t[i]):t[i]}return e}function cne(e,t){return t?e?function(){return mt(xt(e)?e.call(this,this):e,xt(t)?t.call(this,this):t)}:t:e}function lne(e,t){return Hr(Br(e),Br(t))}function Br(e){if(_t(e)){let t={};for(let n=0;n{let c,l=ct,u;return Pte(()=>{let t=e[i];It(c,t)&&(c=t,s())}),{get(){return o(),n.get?n.get(c):c},set(e){let o=n.set?n.set(e):e;if(!It(o,c)&&!(l!==ct&&It(e,l)))return;let d=r.vnode.props;d&&(t in d||i in d||a in d)&&(`onUpdate:${t}`in d||`onUpdate:${i}`in d||`onUpdate:${a}`in d)||(c=e,s()),r.emit(`update:${t}`,o),It(e,o)&&It(e,l)&&!It(o,u)&&s(),l=e,u=o}}});return s[Symbol.iterator]=()=>{let e=0;return{next(){return e<2?{value:e++?o||ct:s,done:!1}:{done:!0}}}},s}function mne(e,t,...n){if(e.isUnmounted)return;let r=e.vnode.props||ct,i=n,a=t.startsWith(`update:`),o=a&&ea(r,t.slice(7));o&&(o.trim&&(i=n.map(e=>St(e)?e.trim():e)),o.number&&(i=n.map(zt)));let s,c=r[s=Ft(t)]||r[s=Ft(Mt(t))];!c&&a&&(c=r[s=Ft(Nt(t))]),c&&rr(c,e,6,i);let l=r[s+`Once`];if(l){if(!e.emitted)e.emitted={};else if(e.emitted[s])return;e.emitted[s]=!0,rr(l,e,6,i)}}function hne(e,t,n=!1){let r=n?ore:t.emitsCache,i=r.get(e);if(i!==void 0)return i;let a=e.emits,o={},s=!1;if(!xt(e)){let r=e=>{let n=hne(e,t,!0);n&&(s=!0,mt(o,n))};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!a&&!s?(wt(e)&&r.set(e,null),null):(_t(a)?a.forEach(e=>o[e]=null):mt(o,a),wt(e)&&r.set(e,o),o)}function Wr(e,t){return!e||!ft(t)?!1:(t=t.slice(2).replace(/Once$/,``),gt(e,t[0].toLowerCase()+t.slice(1))||gt(e,Nt(t))||gt(e,t))}function Gr(e){let{type:t,vnode:n,proxy:r,withProxy:i,propsOptions:[a],slots:o,attrs:s,emit:c,render:l,renderCache:u,props:d,data:f,setupState:p,ctx:m,inheritAttrs:h}=e,g=cr(e),_,v;try{if(n.shapeFlag&4){let e=i||r,t=e;_=ni(l.call(t,e,u,d,p,f,m)),v=s}else{let e=t;_=ni(e.length>1?e(d,{attrs:s,slots:o,emit:c}):e(d,null)),v=t.props?s:sre(s)}}catch(t){ma.length=0,ir(t,e,1),_=H(fa)}let y=_;if(v&&h!==!1){let e=Object.keys(v),{shapeFlag:t}=y;e.length&&t&7&&(a&&e.some(pt)&&(v=cre(v,a)),y=ei(y,v,!1,!0))}return n.dirs&&(y=ei(y,null,!1,!0),y.dirs=y.dirs?y.dirs.concat(n.dirs):n.dirs),n.transition&&xr(y,n.transition),_=y,cr(g),_}function gne(e,t,n){let{props:r,children:i,component:a}=e,{props:o,children:s,patchFlag:c}=t,l=a.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return r?_ne(r,o,l):!!o;if(c&8){let e=t.dynamicProps;for(let t=0;t0)&&!(o&16)){if(o&8){let n=e.vnode.dynamicProps;for(let r=0;r{c=!0;let[n,r]=Cne(e,t,!0);mt(o,n),r&&s.push(...r)};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}if(!a&&!c)return wt(e)&&r.set(e,lt),lt;if(_t(a))for(let e=0;e{if(e===t)return;e&&!Qr(e,t)&&(r=he(e),ue(e,i,a,!0),e=null),t.patchFlag===-2&&(c=!1,t.dynamicChildren=null);let{type:l,ref:u,shapeFlag:d}=t;switch(l){case da:g(e,t,n,r);break;case fa:_(e,t,n,r);break;case pa:e??v(t,n,r,o);break;case V:re(e,t,n,r,i,a,o,s,c);break;default:d&1?x(e,t,n,r,i,a,o,s,c):d&6?T(e,t,n,r,i,a,o,s,c):(d&64||d&128)&&l.process(e,t,n,r,i,a,o,s,c,ve)}u!=null&&i?Tr(u,e&&e.ref,a,t||e,!t):u==null&&e&&e.ref!=null&&Tr(e.ref,null,a,e,!0)},g=(e,t,n,i)=>{if(e==null)r(t.el=s(t.children),n,i);else{let n=t.el=e.el;t.children!==e.children&&l(n,t.children)}},_=(e,t,n,i)=>{e==null?r(t.el=c(t.children||``),n,i):t.el=e.el},v=(e,t,n,r)=>{[e.el,e.anchor]=m(e.children,t,n,r,e.el,e.anchor)},y=({el:e,anchor:t},n,i)=>{let a;for(;e&&e!==t;)a=f(e),r(e,n,i),e=a;r(t,n,i)},b=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=f(e),i(e),e=n;i(t)},x=(e,t,n,r,i,a,o,s,c)=>{if(t.type===`svg`?o=`svg`:t.type===`math`&&(o=`mathml`),e==null)S(t,n,r,i,a,o,s,c);else{let n=e.el&&e.el._isVueCE?e.el:null;try{n&&n._beginPatch(),ee(e,t,i,a,o,s,c)}finally{n&&n._endPatch()}}},S=(e,t,n,i,s,c,l,d)=>{let f,p,{props:m,shapeFlag:h,transition:g,dirs:_}=e;if(f=e.el=o(e.type,c,m&&m.is,m),h&8?u(f,e.children):h&16&&w(e.children,f,null,i,s,qr(e,c),l,d),_&&ur(e,null,i,`created`),C(f,e,e.scopeId,l,i),m){for(let e in m)e!==`value`&&!At(e)&&a(f,e,null,m[e],c,i);`value`in m&&a(f,`value`,null,m.value,c),(p=m.onVnodeBeforeMount)&&oi(p,i,e)}_&&ur(e,null,i,`beforeMount`);let v=One(s,g);v&&g.beforeEnter(f),r(f,t,n),((p=m&&m.onVnodeMounted)||v||_)&&la(()=>{p&&oi(p,i,e),v&&g.enter(f),_&&ur(e,null,i,`mounted`)},s)},C=(e,t,n,r,i)=>{if(n&&p(e,n),r)for(let t=0;t{for(let l=c;l{let c=t.el=e.el,{patchFlag:l,dynamicChildren:d,dirs:f}=t;l|=e.patchFlag&16;let p=e.props||ct,m=t.props||ct,h;if(n&&Jr(n,!1),(h=m.onVnodeBeforeUpdate)&&oi(h,n,t,e),f&&ur(t,e,n,`beforeUpdate`),n&&Jr(n,!0),(p.innerHTML&&m.innerHTML==null||p.textContent&&m.textContent==null)&&u(c,``),d?te(e.dynamicChildren,d,c,n,r,qr(t,i),o):s||oe(e,t,c,null,n,r,qr(t,i),o,!1),l>0){if(l&16)ne(c,p,m,n,i);else if(l&2&&p.class!==m.class&&a(c,`class`,null,m.class,i),l&4&&a(c,`style`,p.style,m.style,i),l&8){let e=t.dynamicProps;for(let t=0;t{h&&oi(h,n,t,e),f&&ur(t,e,n,`updated`)},r)},te=(e,t,n,r,i,a,o)=>{for(let s=0;s{if(t!==n){if(t!==ct)for(let o in t)!At(o)&&!(o in n)&&a(e,o,t[o],null,i,r);for(let o in n){if(At(o))continue;let s=n[o],c=t[o];s!==c&&o!==`value`&&a(e,o,c,s,i,r)}`value`in n&&a(e,`value`,t.value,n.value,i)}},re=(e,t,n,i,a,o,c,l,u)=>{let d=t.el=e?e.el:s(``),f=t.anchor=e?e.anchor:s(``),{patchFlag:p,dynamicChildren:m,slotScopeIds:h}=t;h&&(l=l?l.concat(h):h),e==null?(r(d,n,i),r(f,n,i),w(t.children||[],n,f,a,o,c,l,u)):p>0&&p&64&&m&&e.dynamicChildren&&e.dynamicChildren.length===m.length?(te(e.dynamicChildren,m,n,a,o,c,l),(t.key!=null||a&&t===a.subTree)&&Yr(e,t,!0)):oe(e,t,n,f,a,o,c,l,u)},T=(e,t,n,r,i,a,o,s,c)=>{t.slotScopeIds=s,e==null?t.shapeFlag&512?i.ctx.activate(t,n,r,o,c):ie(t,n,r,i,a,o,c):ae(e,t,c)},ie=(e,t,n,r,i,a,o)=>{let s=e.component=Lne(e,r,i);if(zi(e)&&(s.ctx.renderer=ve),zne(s,!1,o),s.asyncDep){if(i&&i.registerDep(s,E,o),!e.el){let r=s.subTree=H(fa);_(null,r,t,n),e.placeholder=r.el}}else E(s,e,t,n,i,a,o)},ae=(e,t,n)=>{let r=t.component=e.component;if(gne(e,t,n))if(r.asyncDep&&!r.asyncResolved){D(r,t,n);return}else r.next=t,r.update();else t.el=e.el,r.vnode=t},E=(e,t,n,r,i,a,o)=>{let s=()=>{if(e.isMounted){let{next:t,bu:n,u:r,parent:s,vnode:c}=e;{let n=Ane(e);if(n){t&&(t.el=c.el,D(e,t,o)),n.asyncDep.then(()=>{la(()=>{e.isUnmounted||l()},i)});return}}let u=t,f;Jr(e,!1),t?(t.el=c.el,D(e,t,o)):t=c,n&&Lt(n),(f=t.props&&t.props.onVnodeBeforeUpdate)&&oi(f,s,t,c),Jr(e,!0);let p=Gr(e),m=e.subTree;e.subTree=p,h(m,p,d(m.el),he(m),e,i,a),t.el=p.el,u===null&&yne(e,p.el),r&&la(r,i),(f=t.props&&t.props.onVnodeUpdated)&&la(()=>oi(f,s,t,c),i)}else{let o,{el:s,props:c}=t,{bm:l,m:u,parent:d,root:f,type:p}=e,m=Ri(t);if(Jr(e,!1),l&&Lt(l),!m&&(o=c&&c.onVnodeBeforeMount)&&oi(o,d,t),Jr(e,!0),s&&be){let t=()=>{e.subTree=Gr(e),be(s,e.subTree,e,i,null)};m&&p.__asyncHydrate?p.__asyncHydrate(s,e,t):t()}else{f.ce&&f.ce._hasShadowRoot()&&f.ce._injectChildStyle(p,e.parent?e.parent.type:void 0);let o=e.subTree=Gr(e);h(null,o,n,r,e,i,a),t.el=o.el}if(u&&la(u,i),!m&&(o=c&&c.onVnodeMounted)){let e=t;la(()=>oi(o,d,e),i)}(t.shapeFlag&256||d&&Ri(d.vnode)&&d.vnode.shapeFlag&256)&&e.a&&la(e.a,i),e.isMounted=!0,t=n=r=null}};e.scope.on();let c=e.effect=new kn(s);e.scope.off();let l=e.update=c.run.bind(c),u=e.job=c.runIfDirty.bind(c);u.i=e,u.id=e.uid,c.scheduler=()=>or(u),Jr(e,!0),l()},D=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,xne(e,t.props,r,n),fre(e,t.children,n),Qt(),jte(e),$t()},oe=(e,t,n,r,i,a,o,s,c=!1)=>{let l=e&&e.children,d=e?e.shapeFlag:0,f=t.children,{patchFlag:p,shapeFlag:m}=t;if(p>0){if(p&128){ce(l,f,n,r,i,a,o,s,c);return}else if(p&256){se(l,f,n,r,i,a,o,s,c);return}}m&8?(d&16&&me(l,i,a),f!==l&&u(n,f)):d&16?m&16?ce(l,f,n,r,i,a,o,s,c):me(l,i,a,!0):(d&8&&u(n,``),m&16&&w(f,n,r,i,a,o,s,c))},se=(e,t,n,r,i,a,o,s,c)=>{e||=lt,t||=lt;let l=e.length,u=t.length,d=Math.min(l,u),f;for(f=0;fu?me(e,i,a,!0,!1,d):w(t,n,r,i,a,o,s,c,d)},ce=(e,t,n,r,i,a,o,s,c)=>{let l=0,u=t.length,d=e.length-1,f=u-1;for(;l<=d&&l<=f;){let r=e[l],u=t[l]=c?ri(t[l]):ni(t[l]);if(Qr(r,u))h(r,u,n,null,i,a,o,s,c);else break;l++}for(;l<=d&&l<=f;){let r=e[d],l=t[f]=c?ri(t[f]):ni(t[f]);if(Qr(r,l))h(r,l,n,null,i,a,o,s,c);else break;d--,f--}if(l>d){if(l<=f){let e=f+1,d=ef)for(;l<=d;)ue(e[l],i,a,!0),l++;else{let p=l,m=l,g=new Map;for(l=m;l<=f;l++){let e=t[l]=c?ri(t[l]):ni(t[l]);e.key!=null&&g.set(e.key,l)}let _,v=0,y=f-m+1,b=!1,x=0,S=Array(y);for(l=0;l=y){ue(r,i,a,!0);continue}let u;if(r.key!=null)u=g.get(r.key);else for(_=m;_<=f;_++)if(S[_-m]===0&&Qr(r,t[_])){u=_;break}u===void 0?ue(r,i,a,!0):(S[u-m]=l+1,u>=x?x=u:b=!0,h(r,t[u],n,null,i,a,o,s,c),v++)}let C=b?kne(S):lt;for(_=C.length-1,l=y-1;l>=0;l--){let e=m+l,d=t[e],f=t[e+1],p=e+1{let{el:s,type:c,transition:l,children:u,shapeFlag:d}=e;if(d&6){le(e.component.subTree,t,n,a);return}if(d&128){e.suspense.move(t,n,a);return}if(d&64){c.move(e,t,n,ve);return}if(c===V){r(s,t,n);for(let e=0;el.enter(s),o);else{let{leave:a,delayLeave:o,afterLeave:c}=l,u=()=>{e.ctx.isUnmounted?i(s):r(s,t,n)},d=()=>{s._isLeaving&&s[Oi](!0),a(s,()=>{u(),c&&c()})};o?o(s,u,d):d()}else r(s,t,n)},ue=(e,t,n,r=!1,i=!1)=>{let{type:a,props:o,ref:s,children:c,dynamicChildren:l,shapeFlag:u,patchFlag:d,dirs:f,cacheIndex:p}=e;if(d===-2&&(i=!1),s!=null&&(Qt(),Tr(s,null,n,e,!0),$t()),p!=null&&(t.renderCache[p]=void 0),u&256){t.ctx.deactivate(e);return}let m=u&1&&f,h=!Ri(e),g;if(h&&(g=o&&o.onVnodeBeforeUnmount)&&oi(g,t,e),u&6)pe(e.component,n,r);else{if(u&128){e.suspense.unmount(n,r);return}m&&ur(e,null,t,`beforeUnmount`),u&64?e.type.remove(e,t,n,ve,r):l&&!l.hasOnce&&(a!==V||d>0&&d&64)?me(l,t,n,!1,!0):(a===V&&d&384||!i&&u&16)&&me(c,t,n),r&&de(e)}(h&&(g=o&&o.onVnodeUnmounted)||m)&&la(()=>{g&&oi(g,t,e),m&&ur(e,null,t,`unmounted`)},n)},de=e=>{let{type:t,el:n,anchor:r,transition:a}=e;if(t===V){fe(n,r);return}if(t===pa){b(e);return}let o=()=>{i(n),a&&!a.persisted&&a.afterLeave&&a.afterLeave()};if(e.shapeFlag&1&&a&&!a.persisted){let{leave:t,delayLeave:r}=a,i=()=>t(n,o);r?r(e.el,o,i):i()}else o()},fe=(e,t)=>{let n;for(;e!==t;)n=f(e),i(e),e=n;i(t)},pe=(e,t,n)=>{let{bum:r,scope:i,job:a,subTree:o,um:s,m:c,a:l}=e;jne(c),jne(l),r&&Lt(r),i.stop(),a&&(a.flags|=8,ue(o,e,t,n)),s&&la(s,t),la(()=>{e.isUnmounted=!0},t)},me=(e,t,n,r=!1,i=!1,a=0)=>{for(let o=a;o{if(e.shapeFlag&6)return he(e.component.subTree);if(e.shapeFlag&128)return e.suspense.next();let t=f(e.anchor||e.el),n=t&&t[yi];return n?f(n):t},ge=!1,_e=(e,t,n)=>{let r;e==null?t._vnode&&(ue(t._vnode,null,null,!0),r=t._vnode.component):h(t._vnode||null,e,t,null,null,null,n),t._vnode=e,ge||=(ge=!0,jte(r),sr(),!1)},ve={p:h,um:ue,m:le,r:de,mt:ie,mc:w,pc:oe,pbc:te,n:he,o:e},ye,be;return t&&([ye,be]=t(ve)),{render:_e,hydrate:ye,createApp:pne(_e,ye)}}function qr({type:e,props:t},n){return n===`svg`&&e===`foreignObject`||n===`mathml`&&e===`annotation-xml`&&t&&t.encoding&&t.encoding.includes(`html`)?void 0:n}function Jr({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function One(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Yr(e,t,n=!1){let r=e.children,i=t.children;if(_t(r)&&_t(i))for(let e=0;e>1,e[n[s]]0&&(t[r]=n[a-1]),n[a]=r)}}for(a=n.length,o=n[a-1];a-- >0;)n[a]=o,o=t[o];return n}function Ane(e){let t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Ane(t)}function jne(e){if(e)for(let t=0;t0?ha||lt:null,Pne(),ga>0&&ha&&ha.push(e),e}function I(e,t,n,r,i,a){return Fne(R(e,t,n,r,i,a,!0))}function L(e,t,n,r,i){return Fne(H(e,t,n,r,i,!0))}function Zr(e){return e?e.__v_isVNode===!0:!1}function Qr(e,t){return e.type===t.type&&e.key===t.key}function R(e,t=null,n=null,r=0,i=null,a=e===V?0:1,o=!1,s=!1){let c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&_a(t),ref:t&&va(t),scopeId:vi,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:a,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:_i};return s?(ii(c,n),a&128&&e.normalize(c)):n&&(c.shapeFlag|=St(n)?8:16),ga>0&&!o&&ha&&(c.patchFlag>0||a&6)&&c.patchFlag!==32&&ha.push(c),c}function Ine(e,t=null,n=null,r=0,i=null,a=!1){if((!e||e===Ji)&&(e=fa),Zr(e)){let r=ei(e,t,!0);return n&&ii(r,n),ga>0&&!a&&ha&&(r.shapeFlag&6?ha[ha.indexOf(e)]=r:ha.push(r)),r.patchFlag=-2,r}if(Gne(e)&&(e=e.__vccOpts),t){t=$r(t);let{class:e,style:n}=t;e&&!St(e)&&(t.class=O(e)),wt(n)&&(yn(n)&&!_t(n)&&(n=mt({},n)),t.style=it(n))}let o=St(e)?1:ua(e)?128:bi(e)?64:wt(e)?4:xt(e)?2:0;return R(e,t,n,r,i,o,a,!0)}function $r(e){return e?yn(e)||ra(e)?mt({},e):e:null}function ei(e,t,n=!1,r=!1){let{props:i,ref:a,patchFlag:o,children:s,transition:c}=e,l=t?ai(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&_a(l),ref:t&&t.ref?n&&a?_t(a)?a.concat(va(t)):[a,va(t)]:va(t):a,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:s,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==V?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&ei(e.ssContent),ssFallback:e.ssFallback&&ei(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&r&&xr(u,c.clone(u)),u}function z(e=` `,t=0){return H(da,null,e,t)}function ti(e,t){let n=H(pa,null,e);return n.staticCount=t,n}function B(e=``,t=!1){return t?(F(),L(fa,null,e)):H(fa,null,e)}function ni(e){return e==null||typeof e==`boolean`?H(fa):_t(e)?H(V,null,e.slice()):Zr(e)?ri(e):H(da,null,String(e))}function ri(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:ei(e)}function ii(e,t){let n=0,{shapeFlag:r}=e;if(t==null)t=null;else if(_t(t))n=16;else if(typeof t==`object`)if(r&65){let n=t.default;n&&(n._c&&(n._d=!1),ii(e,n()),n._c&&(n._d=!0));return}else{n=32;let r=t._;!r&&!ra(t)?t._ctx=_i:r===3&&_i&&(_i.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else xt(t)?(t={default:t,_ctx:_i},n=32):(t=String(t),r&64?(n=16,t=[z(t)]):n=8);e.children=t,e.shapeFlag|=n}function ai(...e){let t={};for(let n=0;n1?Une(e):null,i=Ca(e),a=nr(r,e,0,[e.props,n]),o=Tt(a);if($t(),i(),(o||e.sp)&&!Ri(e)&&Cr(e),o){if(a.then(wa,wa),t)return a.then(n=>{Vne(e,n,t)}).catch(t=>{ir(t,e,0)});e.asyncDep=a}else Vne(e,a,t)}else Hne(e,t)}function Vne(e,t,n){xt(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:wt(t)&&(e.setupState=ete(t)),Hne(e,n)}function Hne(e,t,n){let r=e.type;if(!e.render){if(!t&&hre&&!r.render){let t=r.template||Rr(e).template;if(t){let{isCustomElement:n,compilerOptions:i}=e.appContext.config,{delimiters:a,compilerOptions:o}=r;r.render=hre(t,mt(mt({isCustomElement:n,delimiters:a},i),o))}}e.render=r.render||ut,gre&&gre(e)}{let t=Ca(e);Qt();try{ine(e)}finally{$t(),t()}}}function Une(e){return{attrs:new Proxy(e.attrs,_re),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function si(e){return e.exposed?e.exposeProxy||=new Proxy(ete(Zee(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Xi)return Xi[n](e)},has(e,t){return t in e||t in Xi}}):e.proxy}function Wne(e,t=!0){return xt(e)?e.displayName||e.name:e.name||t&&e.__name}function Gne(e){return xt(e)&&`__vccOpts`in e}function ci(e,t,n){try{Xr(-1);let r=arguments.length;return r===2?wt(t)&&!_t(t)?Zr(t)?H(e,null,[t]):H(e,t):H(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&Zr(n)&&(n=[n]),H(e,t,n))}finally{Xr(1)}}var li,ui,di,fi,pi,mi,hi,gi,_i,vi,Kne,qne,yi,bi,xi,Si,Ci,wi,Ti,Ei,Di,Oi,ki,Ai,ji,Mi,Jne,Yne,Ni,Xne,Pi,Zne,Qne,Fi,Ii,Li,$ne,Ri,zi,Bi,Vi,Hi,Ui,ere,Wi,Gi,Ki,tre,nre,qi,Ji,Yi,Xi,Zi,rre,Qi,ire,are,$i,ea,ore,sre,cre,ta,na,ra,lre,ia,aa,ure,oa,sa,ca,dre,fre,la,ua,V,da,fa,pa,ma,ha,ga,_a,va,H,pre,mre,ya,ba,xa,Sa,Ca,wa,Ta,hre,gre,_re,U,vre,Ea=s((()=>{Ete(),Gt(),li=[],ui=-1,di=[],fi=null,pi=0,mi=Promise.resolve(),hi=null,gi=e=>e.id==null?e.flags&2?-1:1/0:e.id,_i=null,vi=null,Kne=Symbol.for(`v-scx`),qne=()=>fr(Kne),yi=Symbol(`_vte`),bi=e=>e.__isTeleport,xi=e=>e&&(e.disabled||e.disabled===``),Si=e=>e&&(e.defer||e.defer===``),Ci=e=>typeof SVGElement<`u`&&e instanceof SVGElement,wi=e=>typeof MathMLElement==`function`&&e instanceof MathMLElement,Ti=(e,t)=>{let n=e&&e.to;return St(n)?t?t(n):null:n},Ei={name:`Teleport`,__isTeleport:!0,process(e,t,n,r,i,a,o,s,c,l){let{mc:u,pc:d,pbc:f,o:{insert:p,querySelector:m,createText:h,createComment:g}}=l,_=xi(t.props),{shapeFlag:v,children:y,dynamicChildren:b}=t;if(e==null){let e=t.el=h(``),l=t.anchor=h(``);p(e,n,r),p(l,n,r);let d=(e,t)=>{v&16&&u(y,e,t,i,a,o,s,c)},f=()=>{let e=t.target=Ti(t.props,m),n=vr(e,t,h,p);e&&(o!==`svg`&&Ci(e)?o=`svg`:o!==`mathml`&&wi(e)&&(o=`mathml`),i&&i.isCE&&(i.ce._teleportTargets||(i.ce._teleportTargets=new Set)).add(e),_||(d(e,n),_r(t,!1)))};_&&(d(n,l),_r(t,!0)),Si(t.props)?(t.el.__isMounted=!1,la(()=>{f(),delete t.el.__isMounted},a)):f()}else{if(Si(t.props)&&e.el.__isMounted===!1){la(()=>{Ei.process(e,t,n,r,i,a,o,s,c,l)},a);return}t.el=e.el,t.targetStart=e.targetStart;let u=t.anchor=e.anchor,p=t.target=e.target,h=t.targetAnchor=e.targetAnchor,g=xi(e.props),v=g?n:p,y=g?u:h;if(o===`svg`||Ci(p)?o=`svg`:(o===`mathml`||wi(p))&&(o=`mathml`),b?(f(e.dynamicChildren,b,v,i,a,o,s),Yr(e,t,!0)):c||d(e,t,v,y,i,a,o,s,!1),_)g?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):gr(t,n,u,l,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){let e=t.target=Ti(t.props,m);e&&gr(t,e,null,l,0)}else g&&gr(t,p,h,l,1);_r(t,_)}},remove(e,t,n,{um:r,o:{remove:i}},a){let{shapeFlag:o,children:s,anchor:c,targetStart:l,targetAnchor:u,target:d,props:f}=e;if(d&&(i(l),i(u)),a&&i(c),o&16){let e=a||!xi(f);for(let i=0;i{let t=e.subTree;return t.component?Mi(t.component):t},Jne={name:`BaseTransition`,props:ji,setup(e,{slots:t}){let n=ba(),r=Rte();return()=>{let i=t.default&&Hte(t.default(),!0);if(!i||!i.length)return;let a=zte(i),o=bn(e),{mode:s}=o;if(r.isLeaving)return br(a);let c=Vte(a);if(!c)return br(a);let l=yr(c,o,r,n,e=>l=e);c.type!==fa&&xr(c,l);let u=n.subTree&&Vte(n.subTree);if(u&&u.type!==fa&&!Qr(u,c)&&Mi(n).type!==fa){let e=yr(u,o,r,n);if(xr(u,e),s===`out-in`&&c.type!==fa)return r.isLeaving=!0,e.afterLeave=()=>{r.isLeaving=!1,n.job.flags&8||n.update(),delete e.afterLeave,u=void 0},br(a);s===`in-out`&&c.type!==fa?e.delayLeave=(e,t,n)=>{let i=Bte(r,u);i[String(u.key)]=u,e[Oi]=()=>{t(),e[Oi]=void 0,delete l.delayedLeave,u=void 0},l.delayedLeave=()=>{n(),delete l.delayedLeave,u=void 0}}:u=void 0}else u&&=void 0;return a}}},Yne=Jne,Ni=new WeakMap,Xne=!1,Pi=()=>{Xne||=(console.error(`Hydration completed but contains mismatches.`),!0)},Zne=e=>e.namespaceURI.includes(`svg`)&&e.tagName!==`foreignObject`,Qne=e=>e.namespaceURI.includes(`MathML`),Fi=e=>{if(e.nodeType===1){if(Zne(e))return`svg`;if(Qne(e))return`mathml`}},Ii=e=>e.nodeType===8,Li=`data-allow-mismatch`,$ne={0:`text`,1:`children`,2:`class`,3:`style`,4:`attribute`},Bt().requestIdleCallback,Bt().cancelIdleCallback,Ri=e=>!!e.type.__asyncLoader,zi=e=>e.type.__isKeepAlive,Bi=e=>(t,n=ya)=>{(!Ta||e===`sp`)&&Or(e,(...e)=>t(...e),n)},Vi=Bi(`bm`),Hi=Bi(`m`),Ui=Bi(`bu`),ere=Bi(`u`),Wi=Bi(`bum`),Gi=Bi(`um`),Ki=Bi(`sp`),tre=Bi(`rtg`),nre=Bi(`rtc`),qi=`components`,Ji=Symbol.for(`v-ndc`),Yi=e=>e?Rne(e)?si(e):Yi(e.parent):null,Xi=mt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Yi(e.parent),$root:e=>Yi(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Rr(e),$forceUpdate:e=>e.f||=()=>{or(e.update)},$nextTick:e=>e.n||=ar.bind(e.proxy),$watch:e=>Fte.bind(e)}),Zi=(e,t)=>e!==ct&&!e.__isScriptSetup&>(e,t),rre={get({_:e},t){if(t===`__v_skip`)return!0;let{ctx:n,setupState:r,data:i,props:a,accessCache:o,type:s,appContext:c}=e;if(t[0]!==`$`){let e=o[t];if(e!==void 0)switch(e){case 1:return r[t];case 2:return i[t];case 4:return n[t];case 3:return a[t]}else if(Zi(r,t))return o[t]=1,r[t];else if(i!==ct&>(i,t))return o[t]=2,i[t];else if(gt(a,t))return o[t]=3,a[t];else if(n!==ct&>(n,t))return o[t]=4,n[t];else Qi&&(o[t]=0)}let l=Xi[t],u,d;if(l)return t===`$attrs`&&en(e.attrs,`get`,``),l(e);if((u=s.__cssModules)&&(u=u[t]))return u;if(n!==ct&>(n,t))return o[t]=4,n[t];if(d=c.config.globalProperties,gt(d,t))return d[t]},set({_:e},t,n){let{data:r,setupState:i,ctx:a}=e;return Zi(i,t)?(i[t]=n,!0):r!==ct&>(r,t)?(r[t]=n,!0):gt(e.props,t)||t[0]===`$`&&t.slice(1)in e?!1:(a[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,props:a,type:o}},s){let c;return!!(n[s]||e!==ct&&s[0]!==`$`&>(e,s)||Zi(t,s)||gt(a,s)||gt(r,s)||gt(Xi,s)||gt(i.config.globalProperties,s)||(c=o.__cssModules)&&c[s])},defineProperty(e,t,n){return n.get==null?gt(n,`value`)&&this.set(e,t,n.value,null):e._.accessCache[t]=0,Reflect.defineProperty(e,t,n)}},Qi=!0,ire={data:cne,props:une,emits:une,methods:Hr,computed:Hr,beforeCreate:Vr,created:Vr,beforeMount:Vr,mounted:Vr,beforeUpdate:Vr,updated:Vr,beforeDestroy:Vr,beforeUnmount:Vr,destroyed:Vr,unmounted:Vr,activated:Vr,deactivated:Vr,errorCaptured:Vr,serverPrefetch:Vr,components:Hr,directives:Hr,watch:dne,provide:cne,inject:lne},are=0,$i=null,ea=(e,t)=>t===`modelValue`||t===`model-value`?e.modelModifiers:e[`${t}Modifiers`]||e[`${Mt(t)}Modifiers`]||e[`${Nt(t)}Modifiers`],ore=new WeakMap,sre=e=>{let t;for(let n in e)(n===`class`||n===`style`||ft(n))&&((t||={})[n]=e[n]);return t},cre=(e,t)=>{let n={};for(let r in e)(!pt(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n},ta={},na=()=>Object.create(ta),ra=e=>Object.getPrototypeOf(e)===ta,lre=new WeakMap,ia=e=>e===`_`||e===`_ctx`||e===`$stable`,aa=e=>_t(e)?e.map(ni):[ni(e)],ure=(e,t,n)=>{if(t._n)return t;let r=M((...e)=>aa(t(...e)),n);return r._c=!1,r},oa=(e,t,n)=>{let r=e._ctx;for(let n in e){if(ia(n))continue;let i=e[n];if(xt(i))t[n]=ure(n,i,r);else if(i!=null){let e=aa(i);t[n]=()=>e}}},sa=(e,t)=>{let n=aa(t);e.slots.default=()=>n},ca=(e,t,n)=>{for(let r in t)(n||!ia(r))&&(e[r]=t[r])},dre=(e,t,n)=>{let r=e.slots=na();if(e.vnode.shapeFlag&32){let e=t._;e?(ca(r,t,n),n&&Rt(r,`_`,e,!0)):oa(t,r)}else t&&sa(e,t)},fre=(e,t,n)=>{let{vnode:r,slots:i}=e,a=!0,o=ct;if(r.shapeFlag&32){let e=t._;e?n&&e===1?a=!1:ca(i,t,n):(a=!t.$stable,oa(t,i)),o=t}else t&&(sa(e,t),o={default:1});if(a)for(let e in i)!ia(e)&&o[e]==null&&delete i[e]},la=Nne,ua=e=>e.__isSuspense,V=Symbol.for(`v-fgt`),da=Symbol.for(`v-txt`),fa=Symbol.for(`v-cmt`),pa=Symbol.for(`v-stc`),ma=[],ha=null,ga=1,_a=({key:e})=>e??null,va=({ref:e,ref_key:t,ref_for:n})=>(typeof e==`number`&&(e=``+e),e==null?null:St(e)||xn(e)||xt(e)?{i:_i,r:e,k:t,f:!!n}:e),H=Ine,pre=fne(),mre=0,ya=null,ba=()=>ya||_i;{let e=Bt(),t=(t,n)=>{let r;return(r=e[t])||(r=e[t]=[]),r.push(n),e=>{r.length>1?r.forEach(t=>t(e)):r[0](e)}};xa=t(`__VUE_INSTANCE_SETTERS__`,e=>ya=e),Sa=t(`__VUE_SSR_SETTERS__`,e=>Ta=e)}Ca=e=>{let t=ya;return xa(e),e.scope.on(),()=>{e.scope.off(),xa(t)}},wa=()=>{ya&&ya.scope.off(),xa(null)},Ta=!1,_re={get(e,t){return en(e,`get`,``),e[t]}},U=(e,t)=>ite(e,t,Ta),vre=`3.5.30`}));function yre(e){let t={};for(let n in e)n in Ga||(t[n]=e[n]);if(e.css===!1)return t;let{name:n=`v`,type:r,duration:i,enterFromClass:a=`${n}-enter-from`,enterActiveClass:o=`${n}-enter-active`,enterToClass:s=`${n}-enter-to`,appearFromClass:c=a,appearActiveClass:l=o,appearToClass:u=s,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:f=`${n}-leave-active`,leaveToClass:p=`${n}-leave-to`}=e,m=bre(i),h=m&&m[0],g=m&&m[1],{onBeforeEnter:_,onEnter:v,onEnterCancelled:y,onLeave:b,onLeaveCancelled:x,onBeforeAppear:S=_,onAppear:C=v,onAppearCancelled:w=y}=t,ee=(e,t,n,r)=>{e._enterCancelled=r,ka(e,t?u:s),ka(e,t?l:o),n&&n()},te=(e,t)=>{e._isLeaving=!1,ka(e,d),ka(e,p),ka(e,f),t&&t()},ne=e=>(t,n)=>{let i=e?C:v,o=()=>ee(t,e,n);qa(i,[t,o]),xre(()=>{ka(t,e?c:a),Oa(t,e?u:s),Ja(i)||Sre(t,r,h,o)})};return mt(t,{onBeforeEnter(e){qa(_,[e]),Oa(e,a),Oa(e,o)},onBeforeAppear(e){qa(S,[e]),Oa(e,c),Oa(e,l)},onEnter:ne(!1),onAppear:ne(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>te(e,t);Oa(e,d),e._enterCancelled?(Oa(e,f),Ere(e)):(Ere(e),Oa(e,f)),xre(()=>{e._isLeaving&&(ka(e,d),Oa(e,p),Ja(b)||Sre(e,r,g,n))}),qa(b,[e,n])},onEnterCancelled(e){ee(e,!1,void 0,!0),qa(y,[e])},onAppearCancelled(e){ee(e,!0,void 0,!0),qa(w,[e])},onLeaveCancelled(e){te(e),qa(x,[e])}})}function bre(e){if(e==null)return null;if(wt(e))return[Da(e.enter),Da(e.leave)];{let t=Da(e);return[t,t]}}function Da(e){return Aee(e)}function Oa(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[Wa]||(e[Wa]=new Set)).add(t)}function ka(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[Wa];n&&(n.delete(t),n.size||(e[Wa]=void 0))}function xre(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}function Sre(e,t,n,r){let i=e._endId=++nie,a=()=>{i===e._endId&&r()};if(n!=null)return setTimeout(a,n);let{type:o,timeout:s,propCount:c}=Cre(e,t);if(!o)return r();let l=o+`end`,u=0,d=()=>{e.removeEventListener(l,f),a()},f=t=>{t.target===e&&++u>=c&&d()};setTimeout(()=>{u(n[e]||``).split(`, `),i=r(`${Ha}Delay`),a=r(`${Ha}Duration`),o=wre(i,a),s=r(`${Ua}Delay`),c=r(`${Ua}Duration`),l=wre(s,c),u=null,d=0,f=0;t===Ha?o>0&&(u=Ha,d=o,f=a.length):t===Ua?l>0&&(u=Ua,d=l,f=c.length):(d=Math.max(o,l),u=d>0?o>l?Ha:Ua:null,f=u?u===Ha?a.length:c.length:0);let p=u===Ha&&/\b(?:transform|all)(?:,|$)/.test(r(`${Ha}Property`).toString());return{type:u,timeout:d,propCount:f,hasTransform:p}}function wre(e,t){for(;e.lengthTre(t)+Tre(e[n])))}function Tre(e){return e===`auto`?0:Number(e.slice(0,-1).replace(`,`,`.`))*1e3}function Ere(e){return(e?e.ownerDocument:document).body.offsetHeight}function Dre(e,t,n){let r=e[Wa];r&&(t=(t?[t,...r]:[...r]).join(` `)),t==null?e.removeAttribute(`class`):n?e.setAttribute(`class`,t):e.className=t}function Aa(e,t){e.style.display=t?e[Ya]:`none`,e[Xa]=!t}function Ore(e){let t=ba();if(!t)return;let n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(e=>Ma(e,n))},r=()=>{let r=e(t.proxy);t.ce?Ma(t.ce,r):ja(t.subTree,r),n(r)};Ui(()=>{Ate(r)}),Hi(()=>{mr(r,ut,{flush:`post`});let e=new MutationObserver(r);e.observe(t.subTree.el.parentNode,{childList:!0}),Gi(()=>e.disconnect())})}function ja(e,t){if(e.shapeFlag&128){let n=e.suspense;e=n.activeBranch,n.pendingBranch&&!n.isHydrating&&n.effects.push(()=>{ja(n.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)Ma(e.el,t);else if(e.type===V)e.children.forEach(e=>ja(e,t));else if(e.type===pa){let{el:n,anchor:r}=e;for(;n&&(Ma(n,t),n!==r);)n=n.nextSibling}}function Ma(e,t){if(e.nodeType===1){let n=e.style,r=``;for(let e in t){let i=Tee(t[e]);n.setProperty(`--${e}`,i),r+=`--${e}: ${i};`}n[Qa]=r}}function kre(e,t,n){let r=e.style,i=St(n),a=!1;if(n&&!i){if(t)if(St(t))for(let e of t.split(`;`)){let t=e.slice(0,e.indexOf(`:`)).trim();n[t]??Na(r,t,``)}else for(let e in t)n[e]??Na(r,e,``);for(let e in n)e===`display`&&(a=!0),Na(r,e,n[e])}else if(i){if(t!==n){let e=r[Qa];e&&(n+=`;`+e),r.cssText=n,a=rie.test(n)}}else t&&e.removeAttribute(`style`);Ya in e&&(e[Ya]=a?r.display:``,e[Xa]&&(r.display=`none`))}function Na(e,t,n){if(_t(n))n.forEach(n=>Na(e,t,n));else if(n??=``,t.startsWith(`--`))e.setProperty(t,n);else{let r=Are(e,t);$a.test(n)?e.setProperty(Nt(r),n.replace($a,``),`important`):e[r]=n}}function Are(e,t){let n=to[t];if(n)return n;let r=Mt(t);if(r!==`filter`&&r in e)return to[t]=r;r=Pt(r);for(let n=0;n{if(!e._vts)e._vts=Date.now();else if(e._vts<=n.attached)return;rr(Lre(e,n.value),t,5,[e])};return n.value=e,n.attached=aie(),n}function Lre(e,t){if(_t(t)){let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}else return t}function Rre(e,t,n,r){if(r)return!!(t===`innerHTML`||t===`textContent`||t in e&&oo(t)&&xt(n));if(t===`spellcheck`||t===`draggable`||t===`translate`||t===`autocorrect`||t===`sandbox`&&e.tagName===`IFRAME`||t===`form`||t===`list`&&e.tagName===`INPUT`||t===`type`&&e.tagName===`TEXTAREA`)return!1;if(t===`width`||t===`height`){let t=e.tagName;if(t===`IMG`||t===`VIDEO`||t===`CANVAS`||t===`SOURCE`)return!1}return oo(t)&&St(n)?!1:t in e}function zre(e,t){let n=e._def.props;if(!n)return!1;let r=Mt(t);return Array.isArray(n)?n.some(e=>Mt(e)===r):Object.keys(n).some(e=>Mt(e)===r)}function Bre(e){e.target.composing=!0}function Vre(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event(`input`)))}function Hre(e,t,n){return t&&(e=e.trim()),n&&(e=zt(e)),e}function Ure(e,{value:t,oldValue:n},r){e._modelValue=t;let i;if(_t(t))i=st(t,r.props.value)>-1;else if(yt(t))i=t.has(r.props.value);else{if(t===n)return;i=ot(t,Gre(e,!0))}e.checked!==i&&(e.checked=i)}function Wre(e,t){let n=e.multiple,r=_t(t);if(!(n&&!r&&!yt(t))){for(let i=0,a=e.options.length;iString(e)===String(o)):a.selected=st(t,o)>-1}else a.selected=t.has(o);else if(ot(Fa(a),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Fa(e){return`_value`in e?e._value:e.value}function Gre(e,t){let n=t?`_trueValue`:`_falseValue`;return n in e?e[n]:t}function Kre(e,t){switch(e){case`SELECT`:return cie;case`TEXTAREA`:return lo;default:switch(t){case`checkbox`:return uo;case`radio`:return sie;default:return lo}}}function Ia(e,t,n,r,i){let a=Kre(e.tagName,n.props&&n.props.type)[i];a&&a(e,t,n,r)}function qre(){return go||=Tne(ho)}function Jre(){return go=_o?go:Ene(ho),_o=!0,go}function Yre(e){if(e instanceof SVGElement)return`svg`;if(typeof MathMLElement==`function`&&e instanceof MathMLElement)return`mathml`}function Xre(e){return St(e)?document.querySelector(e):e}var La,Ra,za,Zre,Qre,Ba,Va,$re,Ha,Ua,Wa,Ga,eie,tie,Ka,qa,Ja,nie,Ya,Xa,Za,Qa,rie,$a,eo,to,no,ro,io,ao,iie,aie,oo,oie,so,co,lo,uo,sie,cie,fo,lie,uie,po,die,mo,ho,go,_o,fie,vo,pie,mie=s((()=>{if(Ea(),Ea(),Gt(),La=void 0,Ra=typeof window<`u`&&window.trustedTypes,Ra)try{La=Ra.createPolicy(`vue`,{createHTML:e=>e})}catch{}za=La?e=>La.createHTML(e):e=>e,Zre=`http://www.w3.org/2000/svg`,Qre=`http://www.w3.org/1998/Math/MathML`,Ba=typeof document<`u`?document:null,Va=Ba&&Ba.createElement(`template`),$re={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i=t===`svg`?Ba.createElementNS(Zre,e):t===`mathml`?Ba.createElementNS(Qre,e):n?Ba.createElement(e,{is:n}):Ba.createElement(e);return e===`select`&&r&&r.multiple!=null&&i.setAttribute(`multiple`,r.multiple),i},createText:e=>Ba.createTextNode(e),createComment:e=>Ba.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ba.querySelector(e),setScopeId(e,t){e.setAttribute(t,``)},insertStaticContent(e,t,n,r,i,a){let o=n?n.previousSibling:t.lastChild;if(i&&(i===a||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===a||!(i=i.nextSibling)););else{Va.innerHTML=za(r===`svg`?`${e}`:r===`mathml`?`${e}`:e);let i=Va.content;if(r===`svg`||r===`mathml`){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Ha=`transition`,Ua=`animation`,Wa=Symbol(`_vtc`),Ga={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},eie=mt({},ji,Ga),tie=e=>(e.displayName=`Transition`,e.props=eie,e),Ka=tie((e,{slots:t})=>ci(Yne,yre(e),t)),qa=(e,t=[])=>{_t(e)?e.forEach(e=>e(...t)):e&&e(...t)},Ja=e=>e?_t(e)?e.some(e=>e.length>1):e.length>1:!1,nie=0,Ya=Symbol(`_vod`),Xa=Symbol(`_vsh`),Za={name:`show`,beforeMount(e,{value:t},{transition:n}){e[Ya]=e.style.display===`none`?``:e.style.display,n&&t?n.beforeEnter(e):Aa(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),Aa(e,!0),r.enter(e)):r.leave(e,()=>{Aa(e,!1)}):Aa(e,t))},beforeUnmount(e,{value:t}){Aa(e,t)}},Qa=Symbol(``),rie=/(?:^|;)\s*display\s*:/,$a=/\s*!important$/,eo=[`Webkit`,`Moz`,`ms`],to={},no=`http://www.w3.org/1999/xlink`,ro=Symbol(`_vei`),io=/(?:Once|Passive|Capture)$/,ao=0,iie=Promise.resolve(),aie=()=>ao||=(iie.then(()=>ao=0),Date.now()),oo=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,oie=(e,t,n,r,i,a)=>{let o=i===`svg`;t===`class`?Dre(e,r,o):t===`style`?kre(e,n,r):ft(t)?pt(t)||Pre(e,t,n,r,a):(t[0]===`.`?(t=t.slice(1),!0):t[0]===`^`?(t=t.slice(1),!1):Rre(e,t,r,o))?(Mre(e,t,r),!e.tagName.includes(`-`)&&(t===`value`||t===`checked`||t===`selected`)&&jre(e,t,r,o,a,t!==`value`)):e._isVueCE&&(zre(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!St(r)))?Mre(e,Mt(t),r,a,t):(t===`true-value`?e._trueValue=r:t===`false-value`&&(e._falseValue=r),jre(e,t,r,o))},so=e=>{let t=e.props[`onUpdate:modelValue`]||!1;return _t(t)?e=>Lt(t,e):t},co=Symbol(`_assign`),lo={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[co]=so(i);let a=r||i.props&&i.props.type===`number`;Pa(e,t?`change`:`input`,t=>{t.target.composing||e[co](Hre(e.value,n,a))}),(n||a)&&Pa(e,`change`,()=>{e.value=Hre(e.value,n,a)}),t||(Pa(e,`compositionstart`,Bre),Pa(e,`compositionend`,Vre),Pa(e,`change`,Vre))},mounted(e,{value:t}){e.value=t??``},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:a}},o){if(e[co]=so(o),e.composing)return;let s=(a||e.type===`number`)&&!/^0\d/.test(e.value)?zt(e.value):e.value,c=t??``;s!==c&&(document.activeElement===e&&e.type!==`range`&&(r&&t===n||i&&e.value.trim()===c)||(e.value=c))}},uo={deep:!0,created(e,t,n){e[co]=so(n),Pa(e,`change`,()=>{let t=e._modelValue,n=Fa(e),r=e.checked,i=e[co];if(_t(t)){let e=st(t,n),a=e!==-1;if(r&&!a)i(t.concat(n));else if(!r&&a){let n=[...t];n.splice(e,1),i(n)}}else if(yt(t)){let e=new Set(t);r?e.add(n):e.delete(n),i(e)}else i(Gre(e,r))})},mounted:Ure,beforeUpdate(e,t,n){e[co]=so(n),Ure(e,t,n)}},sie={created(e,{value:t},n){e.checked=ot(t,n.props.value),e[co]=so(n),Pa(e,`change`,()=>{e[co](Fa(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[co]=so(r),t!==n&&(e.checked=ot(t,r.props.value))}},cie={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=yt(t);Pa(e,`change`,()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?zt(Fa(e)):Fa(e));e[co](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,ar(()=>{e._assigning=!1})}),e[co]=so(r)},mounted(e,{value:t}){Wre(e,t)},beforeUpdate(e,t,n){e[co]=so(n)},updated(e,{value:t}){e._assigning||Wre(e,t)}},fo={created(e,t,n){Ia(e,t,n,null,`created`)},mounted(e,t,n){Ia(e,t,n,null,`mounted`)},beforeUpdate(e,t,n,r){Ia(e,t,n,r,`beforeUpdate`)},updated(e,t,n,r){Ia(e,t,n,r,`updated`)}},lie=[`ctrl`,`shift`,`alt`,`meta`],uie={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>`button`in e&&e.button!==0,middle:e=>`button`in e&&e.button!==1,right:e=>`button`in e&&e.button!==2,exact:(e,t)=>lie.some(n=>e[`${n}Key`]&&!t.includes(n))},po=(e,t)=>{if(!e)return e;let n=e._withMods||={},r=t.join(`.`);return n[r]||(n[r]=((n,...r)=>{for(let e=0;e{let n=e._withKeys||={},r=t.join(`.`);return n[r]||(n[r]=(n=>{if(!(`key`in n))return;let r=Nt(n.key);if(t.some(e=>e===r||die[e]===r))return e(n)}))},ho=mt({patchProp:oie},$re),_o=!1,fie=((...e)=>{qre().render(...e)}),vo=((...e)=>{let t=qre().createApp(...e),{mount:n}=t;return t.mount=e=>{let r=Xre(e);if(!r)return;let i=t._component;!xt(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent=``);let a=n(r,!1,Yre(r));return r instanceof Element&&(r.removeAttribute(`v-cloak`),r.setAttribute(`data-v-app`,``)),a},t}),pie=((...e)=>{let t=Jre().createApp(...e),{mount:n}=t;return t.mount=e=>{let t=Xre(e);if(t)return n(t,!0,Yre(t))},t})})),W=s((()=>{mie()}));W();var hie=(e,t)=>xn(t)?Cn(t):t;W();var gie=`usehead`;function _ie(e){return{install(t){t.config.globalProperties.$unhead=e,t.config.globalProperties.$head=e,t.provide(gie,e)}}.install}function vie(){if(Nte()){let e=fr(gie);if(e)return e}throw Error(`useHead() was called without provide context, ensure you call it through the setup() function.`)}function yie(e,t={}){let n=t.head||vie();return n.ssr?n.push(e||{},t):bie(n,e,t)}function bie(e,t,n={}){let r=A(!1),i;return pr(()=>{let a=r.value?{}:qe(t,hie);i?i.patch(a):i=e.push(a,n)}),ba()&&(Wi(()=>{i.dispose()}),Yte(()=>{r.value=!0}),Jte(()=>{r.value=!1})),i}function xie(e={},t={}){(t.head||vie()).use(bee);let{title:n,titleTemplate:r,...i}=e;return yie({title:n,titleTemplate:r,_flatMeta:i},t)}W();function Sie(e={}){let t=lee({domOptions:{render:uee(()=>cee(t),e=>setTimeout(e,0))},...e});return t.install=_ie(t),t}function yo(e,t,n){let r=n.initialDeps??[],i;return()=>{var a;let o;n.key&&n.debug?.call(n)&&(o=Date.now());let s=e();if(!(s.length!==r.length||s.some((e,t)=>r[t]!==e)))return i;r=s;let c;if(n.key&&n.debug?.call(n)&&(c=Date.now()),i=t(...s),n.key&&n.debug?.call(n)){let e=Math.round((Date.now()-o)*100)/100,t=Math.round((Date.now()-c)*100)/100,r=t/16,i=(e,t)=>{for(e=String(e);e.length{Cie=(e,t)=>Math.abs(e-t)<1,wie=(e,t,n)=>{let r;return function(...i){e.clearTimeout(r),r=e.setTimeout(()=>t.apply(this,i),n)}}}));function Eie({measurements:e,outerSize:t,scrollOffset:n}){let r=e.length-1,i=So(0,r,t=>e[t].start,n),a=i;for(;a{Tie(),Die=e=>e,Oie=e=>{let t=Math.max(e.startIndex-e.overscan,0),n=Math.min(e.endIndex+e.overscan,e.count-1),r=[];for(let e=t;e<=n;e++)r.push(e);return r},kie=(e,t)=>{let n=e.scrollElement;if(!n)return;let r=e.targetWindow;if(!r)return;let i=e=>{let{width:n,height:r}=e;t({width:Math.round(n),height:Math.round(r)})};if(i(n.getBoundingClientRect()),!r.ResizeObserver)return()=>{};let a=new r.ResizeObserver(e=>{let t=e[0];if(t?.borderBoxSize){let e=t.borderBoxSize[0];if(e){i({width:e.inlineSize,height:e.blockSize});return}}i(n.getBoundingClientRect())});return a.observe(n,{box:`border-box`}),()=>{a.unobserve(n)}},xo={passive:!0},Aie=typeof window>`u`?!0:`onscrollend`in window,jie=(e,t)=>{let n=e.scrollElement;if(!n)return;let r=e.targetWindow;if(!r)return;let i=0,a=Aie?()=>void 0:wie(r,()=>{t(i,!1)},e.options.isScrollingResetDelay),o=r=>()=>{i=n[e.options.horizontal?`scrollLeft`:`scrollTop`],a(),t(i,r)},s=o(!0),c=o(!1);return c(),n.addEventListener(`scroll`,s,xo),n.addEventListener(`scrollend`,c,xo),()=>{n.removeEventListener(`scroll`,s),n.removeEventListener(`scrollend`,c)}},Mie=(e,t,n)=>{if(t?.borderBoxSize){let e=t.borderBoxSize[0];if(e)return Math.round(e[n.options.horizontal?`inlineSize`:`blockSize`])}return Math.round(e.getBoundingClientRect()[n.options.horizontal?`width`:`height`])},Nie=(e,{adjustments:t=0,behavior:n},r)=>{var i,a;let o=e+t;(a=(i=r.scrollElement)?.scrollTo)==null||a.call(i,{[r.options.horizontal?`left`:`top`]:o,behavior:n})},Pie=class{constructor(e){this.unsubs=[],this.scrollElement=null,this.targetWindow=null,this.isScrolling=!1,this.scrollToIndexTimeoutId=null,this.measurementsCache=[],this.itemSizeCache=new Map,this.pendingMeasuredCacheIndexes=[],this.scrollRect=null,this.scrollOffset=null,this.scrollDirection=null,this.scrollAdjustments=0,this.elementsCache=new Map,this.observer=(()=>{let e=null,t=()=>e||(!this.targetWindow||!this.targetWindow.ResizeObserver?null:e=new this.targetWindow.ResizeObserver(e=>{e.forEach(e=>{this._measureElement(e.target,e)})}));return{disconnect:()=>t()?.disconnect(),observe:e=>t()?.observe(e,{box:`border-box`}),unobserve:e=>t()?.unobserve(e)}})(),this.range=null,this.setOptions=e=>{Object.entries(e).forEach(([t,n])=>{n===void 0&&delete e[t]}),this.options={debug:!1,initialOffset:0,overscan:1,paddingStart:0,paddingEnd:0,scrollPaddingStart:0,scrollPaddingEnd:0,horizontal:!1,getItemKey:Die,rangeExtractor:Oie,onChange:()=>{},measureElement:Mie,initialRect:{width:0,height:0},scrollMargin:0,gap:0,indexAttribute:`data-index`,initialMeasurementsCache:[],lanes:1,isScrollingResetDelay:150,enabled:!0,...e}},this.notify=(e,t)=>{var n,r;let{startIndex:i,endIndex:a}=this.range??{startIndex:void 0,endIndex:void 0},o=this.calculateRange();(e||i!==o?.startIndex||a!==o?.endIndex)&&((r=(n=this.options).onChange)==null||r.call(n,this,t))},this.cleanup=()=>{this.unsubs.filter(Boolean).forEach(e=>e()),this.unsubs=[],this.scrollElement=null,this.targetWindow=null,this.observer.disconnect(),this.elementsCache.clear()},this._didMount=()=>()=>{this.cleanup()},this._willUpdate=()=>{let e=this.options.enabled?this.options.getScrollElement():null;if(this.scrollElement!==e){if(this.cleanup(),!e){this.notify(!1,!1);return}this.scrollElement=e,this.scrollElement&&`ownerDocument`in this.scrollElement?this.targetWindow=this.scrollElement.ownerDocument.defaultView:this.targetWindow=this.scrollElement?.window??null,this._scrollToOffset(this.getScrollOffset(),{adjustments:void 0,behavior:void 0}),this.unsubs.push(this.options.observeElementRect(this,e=>{this.scrollRect=e,this.notify(!1,!1)})),this.unsubs.push(this.options.observeElementOffset(this,(e,t)=>{this.scrollAdjustments=0,this.scrollDirection=t?this.getScrollOffset()this.options.enabled?(this.scrollRect=this.scrollRect??this.options.initialRect,this.scrollRect[this.options.horizontal?`width`:`height`]):(this.scrollRect=null,0),this.getScrollOffset=()=>this.options.enabled?(this.scrollOffset=this.scrollOffset??(typeof this.options.initialOffset==`function`?this.options.initialOffset():this.options.initialOffset),this.scrollOffset):(this.scrollOffset=null,0),this.getFurthestMeasurement=(e,t)=>{let n=new Map,r=new Map;for(let i=t-1;i>=0;i--){let t=e[i];if(n.has(t.lane))continue;let a=r.get(t.lane);if(a==null||t.end>a.end?r.set(t.lane,t):t.ende.end===t.end?e.index-t.index:e.end-t.end)[0]:void 0},this.getMeasurementOptions=yo(()=>[this.options.count,this.options.paddingStart,this.options.scrollMargin,this.options.getItemKey,this.options.enabled],(e,t,n,r,i)=>(this.pendingMeasuredCacheIndexes=[],{count:e,paddingStart:t,scrollMargin:n,getItemKey:r,enabled:i}),{key:!1}),this.getMeasurements=yo(()=>[this.getMeasurementOptions(),this.itemSizeCache],({count:e,paddingStart:t,scrollMargin:n,getItemKey:r,enabled:i},a)=>{if(!i)return this.measurementsCache=[],this.itemSizeCache.clear(),[];this.measurementsCache.length===0&&(this.measurementsCache=this.options.initialMeasurementsCache,this.measurementsCache.forEach(e=>{this.itemSizeCache.set(e.key,e.size)}));let o=this.pendingMeasuredCacheIndexes.length>0?Math.min(...this.pendingMeasuredCacheIndexes):0;this.pendingMeasuredCacheIndexes=[];let s=this.measurementsCache.slice(0,o);for(let i=o;i{let t=r(i),n=this.elementsCache.get(t);if(!e){n&&(this.observer.unobserve(n),this.elementsCache.delete(t));return}n!==e&&(n&&this.observer.unobserve(n),this.observer.observe(e),this.elementsCache.set(t,e)),e.isConnected&&this.resizeItem(i,this.options.measureElement(e,void 0,this))};let o=r(i),c=this.options.lanes===1?s[i-1]:this.getFurthestMeasurement(s,i),l=c?c.end+this.options.gap:t+n,u=a.get(o),d=typeof u==`number`?u:this.options.estimateSize(i),f=l+d,p=c?c.lane:i%this.options.lanes;s[i]={index:i,start:l,size:d,end:f,key:o,lane:p,measureElement:e}}return this.measurementsCache=s,s},{key:!1,debug:()=>this.options.debug}),this.calculateRange=yo(()=>[this.getMeasurements(),this.getSize(),this.getScrollOffset()],(e,t,n)=>this.range=e.length>0&&t>0?Eie({measurements:e,outerSize:t,scrollOffset:n}):null,{key:!1,debug:()=>this.options.debug}),this.getIndexes=yo(()=>[this.options.rangeExtractor,this.calculateRange(),this.options.overscan,this.options.count],(e,t,n,r)=>t===null?[]:e({startIndex:t.startIndex,endIndex:t.endIndex,overscan:n,count:r}),{key:!1,debug:()=>this.options.debug}),this.indexFromElement=e=>{let t=this.options.indexAttribute,n=e.getAttribute(t);return n?parseInt(n,10):(console.warn(`Missing attribute name '${t}={index}' on measured element.`),-1)},this._measureElement=(e,t)=>{let n=this.indexFromElement(e),r=this.getMeasurements()[n];if(!r||!e.isConnected){this.elementsCache.forEach((t,n)=>{t===e&&(this.observer.unobserve(e),this.elementsCache.delete(n))});return}let i=this.elementsCache.get(r.key);i!==e&&(i&&this.observer.unobserve(i),this.observer.observe(e),this.elementsCache.set(r.key,e)),this.resizeItem(n,this.options.measureElement(e,t,this))},this.resizeItem=(e,t)=>{let n=this.getMeasurements()[e];if(!n)return;let r=t-(this.itemSizeCache.get(n.key)??n.size);r!==0&&((this.shouldAdjustScrollPositionOnItemSizeChange===void 0?n.start{e&&this._measureElement(e,void 0)},this.getVirtualItems=yo(()=>[this.getIndexes(),this.getMeasurements()],(e,t)=>{let n=[];for(let r=0,i=e.length;rthis.options.debug}),this.getVirtualItemForOffset=e=>{let t=this.getMeasurements();if(t.length!==0)return bo(t[So(0,t.length-1,e=>bo(t[e]).start,e)])},this.getOffsetForAlignment=(e,t)=>{let n=this.getSize(),r=this.getScrollOffset();t===`auto`&&(t=e<=r?`start`:e>=r+n?`end`:`start`),t===`start`?e=e:t===`end`?e-=n:t===`center`&&(e-=n/2);let i=this.options.horizontal?`scrollWidth`:`scrollHeight`,a=(this.scrollElement?`document`in this.scrollElement?this.scrollElement.document.documentElement[i]:this.scrollElement[i]:0)-n;return Math.max(Math.min(a,e),0)},this.getOffsetForIndex=(e,t=`auto`)=>{e=Math.max(0,Math.min(e,this.options.count-1));let n=this.getMeasurements()[e];if(!n)return;let r=this.getSize(),i=this.getScrollOffset();if(t===`auto`)if(n.end>=i+r-this.options.scrollPaddingEnd)t=`end`;else if(n.start<=i+this.options.scrollPaddingStart)t=`start`;else return[i,t];let a=t===`end`?n.end+this.options.scrollPaddingEnd:n.start-this.options.scrollPaddingStart;return[this.getOffsetForAlignment(a,t),t]},this.isDynamicMode=()=>this.elementsCache.size>0,this.cancelScrollToIndex=()=>{this.scrollToIndexTimeoutId!==null&&this.targetWindow&&(this.targetWindow.clearTimeout(this.scrollToIndexTimeoutId),this.scrollToIndexTimeoutId=null)},this.scrollToOffset=(e,{align:t=`start`,behavior:n}={})=>{this.cancelScrollToIndex(),n===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size."),this._scrollToOffset(this.getOffsetForAlignment(e,t),{adjustments:void 0,behavior:n})},this.scrollToIndex=(e,{align:t=`auto`,behavior:n}={})=>{e=Math.max(0,Math.min(e,this.options.count-1)),this.cancelScrollToIndex(),n===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size.");let r=this.getOffsetForIndex(e,t);if(!r)return;let[i,a]=r;this._scrollToOffset(i,{adjustments:void 0,behavior:n}),n!==`smooth`&&this.isDynamicMode()&&this.targetWindow&&(this.scrollToIndexTimeoutId=this.targetWindow.setTimeout(()=>{if(this.scrollToIndexTimeoutId=null,this.elementsCache.has(this.options.getItemKey(e))){let[t]=bo(this.getOffsetForIndex(e,a));Cie(t,this.getScrollOffset())||this.scrollToIndex(e,{align:a,behavior:n})}else this.scrollToIndex(e,{align:a,behavior:n})}))},this.scrollBy=(e,{behavior:t}={})=>{this.cancelScrollToIndex(),t===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size."),this._scrollToOffset(this.getScrollOffset()+e,{adjustments:void 0,behavior:t})},this.getTotalSize=()=>{let e=this.getMeasurements(),t;return t=e.length===0?this.options.paddingStart:this.options.lanes===1?e[e.length-1]?.end??0:Math.max(...e.slice(-this.options.lanes).map(e=>e.end)),t-this.options.scrollMargin+this.options.paddingEnd},this._scrollToOffset=(e,{adjustments:t,behavior:n})=>{this.options.scrollToFn(e,{behavior:n,adjustments:t},this)},this.measure=()=>{var e,t;this.itemSizeCache=new Map,(t=(e=this.options).onChange)==null||t.call(e,this,!1)},this.setOptions(e)}},So=(e,t,n,r)=>{for(;e<=t;){let i=(e+t)/2|0,a=n(i);if(ar)t=i-1;else return i}return e>0?e-1:0}}));function Iie(e){let t=new Pie(j(e)),n=Sn(t),r=t._didMount();return mr(()=>j(e).getScrollElement(),e=>{e&&t._willUpdate()},{immediate:!0}),mr(()=>j(e),e=>{t.setOptions({...e,onChange:(t,r)=>{var i;$ee(n),(i=e.onChange)==null||i.call(e,t,r)}}),t._willUpdate(),$ee(n)},{immediate:!0}),qt(r),n}function Lie(e){return Iie(U(()=>({observeElementRect:kie,observeElementOffset:jie,scrollToFn:Nie,...j(e)})))}var Rie=s((()=>{Fie(),Fie(),W()}));function Co(e,t,n){let r=A(n?.value),i=U(()=>e.value!==void 0);return[U(()=>i.value?e.value:r.value),function(e){return i.value||(r.value=e),t?.(e)}]}var wo=s((()=>{W()}));function To(e){typeof queueMicrotask==`function`?queueMicrotask(e):Promise.resolve().then(e).catch(e=>setTimeout(()=>{throw e}))}var Eo=s((()=>{}));function Do(){let e=[],t={addEventListener(e,n,r,i){return e.addEventListener(n,r,i),t.add(()=>e.removeEventListener(n,r,i))},requestAnimationFrame(...e){let n=requestAnimationFrame(...e);t.add(()=>cancelAnimationFrame(n))},nextFrame(...e){t.requestAnimationFrame(()=>{t.requestAnimationFrame(...e)})},setTimeout(...e){let n=setTimeout(...e);t.add(()=>clearTimeout(n))},microTask(...e){let n={current:!0};return To(()=>{n.current&&e[0]()}),t.add(()=>{n.current=!1})},style(e,t,n){let r=e.style.getPropertyValue(t);return Object.assign(e.style,{[t]:n}),this.add(()=>{Object.assign(e.style,{[t]:r})})},group(e){let t=Do();return e(t),this.add(()=>t.dispose())},add(t){return e.push(t),()=>{let n=e.indexOf(t);if(n>=0)for(let t of e.splice(n,1))t()}},dispose(){for(let t of e.splice(0))t()}};return t}var Oo=s((()=>{Eo()}));function zie(){let e=Do();return Gi(()=>e.dispose()),e}var Bie=s((()=>{W(),Oo()}));function Vie(){let e=zie();return t=>{e.dispose(),e.nextFrame(t)}}var Hie=s((()=>{Bie()}));function Uie(e){dr(ko,e)}var ko,Wie,Ao,jo=s((()=>{W(),ko=Symbol(`headlessui.useid`),Wie=0,Ao=Sr??function(){return fr(ko,()=>`${++Wie}`)()}}));function G(e){if(e==null||e.value==null)return null;let t=e.value.$el??e.value;return t instanceof Node?t:null}var Mo=s((()=>{}));function No(e,t,...n){if(e in t){let r=t[e];return typeof r==`function`?r(...n):r}let r=Error(`Tried to handle "${e}" but there is no handler defined. Only defined handlers are: ${Object.keys(t).map(e=>`"${e}"`).join(`, `)}.`);throw Error.captureStackTrace&&Error.captureStackTrace(r,No),r}var Po=s((()=>{})),Gie,Kie,Fo,qie,Io,Lo=s((()=>{Gie=Object.defineProperty,Kie=(e,t,n)=>t in e?Gie(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,Fo=(e,t,n)=>(Kie(e,typeof t==`symbol`?t:t+``,n),n),qie=class{constructor(){Fo(this,`current`,this.detect()),Fo(this,`currentId`,0)}set(e){this.current!==e&&(this.currentId=0,this.current=e)}reset(){this.set(this.detect())}nextId(){return++this.currentId}get isServer(){return this.current===`server`}get isClient(){return this.current===`client`}detect(){return typeof window>`u`||typeof document>`u`?`server`:`client`}},Io=new qie}));function Ro(e){if(Io.isServer)return null;if(e instanceof Node)return e.ownerDocument;if(e!=null&&e.hasOwnProperty(`value`)){let t=G(e);if(t)return t.ownerDocument}return document}var zo=s((()=>{Mo(),Lo()}));function Bo(e=document.body){return e==null?[]:Array.from(e.querySelectorAll(Go)).sort((e,t)=>Math.sign((e.tabIndex||2**53-1)-(t.tabIndex||2**53-1)))}function Vo(e,t=0){return e===Ro(e)?.body?!1:No(t,{0(){return e.matches(Go)},1(){let t=e;for(;t!==null;){if(t.matches(Go))return!0;t=t.parentElement}return!1}})}function Jie(e){let t=Ro(e);ar(()=>{t&&!Vo(t.activeElement,0)&&Ho(e)})}function Ho(e){e?.focus({preventScroll:!0})}function Yie(e){return(e?.matches)?.call(e,$ie)??!1}function Uo(e,t=e=>e){return e.slice().sort((e,n)=>{let r=t(e),i=t(n);if(r===null||i===null)return 0;let a=r.compareDocumentPosition(i);return a&Node.DOCUMENT_POSITION_FOLLOWING?-1:a&Node.DOCUMENT_POSITION_PRECEDING?1:0})}function Xie(e,t){return Wo(Bo(),t,{relativeTo:e})}function Wo(e,t,{sorted:n=!0,relativeTo:r=null,skipElements:i=[]}={}){let a=(Array.isArray(e)?e.length>0?e[0].ownerDocument:document:e?.ownerDocument)??document,o=Array.isArray(e)?n?Uo(e):e:Bo(e);i.length>0&&o.length>1&&(o=o.filter(e=>!i.includes(e))),r??=a.activeElement;let s=(()=>{if(t&5)return 1;if(t&10)return-1;throw Error(`Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last`)})(),c=(()=>{if(t&1)return 0;if(t&2)return Math.max(0,o.indexOf(r))-1;if(t&4)return Math.max(0,o.indexOf(r))+1;if(t&8)return o.length-1;throw Error(`Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last`)})(),l=t&32?{preventScroll:!0}:{},u=0,d=o.length,f;do{if(u>=d||u+d<=0)return 0;let e=c+u;if(t&16)e=(e+d)%d;else{if(e<0)return 3;if(e>=d)return 1}f=o[e],f?.focus(l),u+=s}while(f!==a.activeElement);return t&6&&Yie(f)&&f.select(),2}var Go,Ko,qo,Zie,Jo,Qie,$ie,Yo=s((()=>{W(),Po(),zo(),Go=[`[contentEditable=true]`,`[tabindex]`,`a[href]`,`area[href]`,`button:not([disabled])`,`iframe`,`input:not([disabled])`,`select:not([disabled])`,`textarea:not([disabled])`].map(e=>`${e}:not([tabindex='-1'])`).join(`,`),Ko=(e=>(e[e.First=1]=`First`,e[e.Previous=2]=`Previous`,e[e.Next=4]=`Next`,e[e.Last=8]=`Last`,e[e.WrapAround=16]=`WrapAround`,e[e.NoScroll=32]=`NoScroll`,e))(Ko||{}),qo=(e=>(e[e.Error=0]=`Error`,e[e.Overflow=1]=`Overflow`,e[e.Success=2]=`Success`,e[e.Underflow=3]=`Underflow`,e))(qo||{}),Zie=(e=>(e[e.Previous=-1]=`Previous`,e[e.Next=1]=`Next`,e))(Zie||{}),Jo=(e=>(e[e.Strict=0]=`Strict`,e[e.Loose=1]=`Loose`,e))(Jo||{}),Qie=(e=>(e[e.Keyboard=0]=`Keyboard`,e[e.Mouse=1]=`Mouse`,e))(Qie||{}),typeof window<`u`&&typeof document<`u`&&(document.addEventListener(`keydown`,e=>{e.metaKey||e.altKey||e.ctrlKey||(document.documentElement.dataset.headlessuiFocusVisible=``)},!0),document.addEventListener(`click`,e=>{e.detail===1?delete document.documentElement.dataset.headlessuiFocusVisible:e.detail===0&&(document.documentElement.dataset.headlessuiFocusVisible=``)},!0)),$ie=[`textarea`,`input`].join(`,`)}));function eae(){return/iPhone/gi.test(window.navigator.platform)||/Mac/gi.test(window.navigator.platform)&&window.navigator.maxTouchPoints>0}function tae(){return/Android/gi.test(window.navigator.userAgent)}function nae(){return eae()||tae()}var Xo=s((()=>{}));function Zo(e,t,n){Io.isServer||pr(r=>{document.addEventListener(e,t,n),r(()=>document.removeEventListener(e,t,n))})}var rae=s((()=>{W(),Lo()}));function iae(e,t,n){Io.isServer||pr(r=>{window.addEventListener(e,t,n),r(()=>window.removeEventListener(e,t,n))})}var aae=s((()=>{W(),Lo()}));function Qo(e,t,n=U(()=>!0)){function r(r,i){if(!n.value||r.defaultPrevented)return;let a=i(r);if(a===null||!a.getRootNode().contains(a))return;let o=function e(t){return typeof t==`function`?e(t()):Array.isArray(t)||t instanceof Set?t:[t]}(e);for(let e of o){if(e===null)continue;let t=e instanceof HTMLElement?e:G(e);if(t!=null&&t.contains(a)||r.composed&&r.composedPath().includes(t))return}return!Vo(a,Jo.Loose)&&a.tabIndex!==-1&&r.preventDefault(),t(r,a)}let i=A(null);Zo(`pointerdown`,e=>{n.value&&(i.value=e.composedPath?.call(e)?.[0]||e.target)},!0),Zo(`mousedown`,e=>{n.value&&(i.value=e.composedPath?.call(e)?.[0]||e.target)},!0),Zo(`click`,e=>{nae()||(i.value&&=(r(e,()=>i.value),null))},!0),Zo(`touchend`,e=>r(e,()=>e.target instanceof HTMLElement?e.target:null),!0),iae(`blur`,e=>r(e,()=>window.document.activeElement instanceof HTMLIFrameElement?window.document.activeElement:null),!0)}var $o=s((()=>{W(),Mo(),Yo(),Xo(),rae(),aae()}));function oae(e,t){if(e)return e;let n=t??`button`;if(typeof n==`string`&&n.toLowerCase()===`button`)return`button`}function es(e,t){let n=A(oae(e.value.type,e.value.as));return Hi(()=>{n.value=oae(e.value.type,e.value.as)}),pr(()=>{var e;n.value||G(t)&&G(t)instanceof HTMLButtonElement&&!((e=G(t))!=null&&e.hasAttribute(`type`))&&(n.value=`button`)}),n}var ts=s((()=>{W(),Mo()}));function sae(e){return[e.screenX,e.screenY]}function ns(){let e=A([-1,-1]);return{wasMoved(t){let n=sae(t);return e.value[0]===n[0]&&e.value[1]===n[1]?!1:(e.value=n,!0)},update(t){e.value=sae(t)}}}var rs=s((()=>{W()}));function is({container:e,accept:t,walk:n,enabled:r}){pr(()=>{let i=e.value;if(!i||r!==void 0&&!r.value)return;let a=Ro(e);if(!a)return;let o=Object.assign(e=>t(e),{acceptNode:t}),s=a.createTreeWalker(i,NodeFilter.SHOW_ELEMENT,o,!1);for(;s.nextNode();)n(s.currentNode)})}var as=s((()=>{W(),zo()}));function os({visible:e=!0,features:t=0,ourProps:n,theirProps:r,...i}){var a;let o=lae(r,n),s=Object.assign(i,{props:o});return e||t&2&&o.static?ss(s):t&1?No((a=o.unmount)==null||a?0:1,{0(){return null},1(){return ss({...i,props:{...o,hidden:!0,style:{display:`none`}}})}}):ss(s)}function ss({props:e,attrs:t,slots:n,slot:r,name:i}){let{as:a,...o}=ls(e,[`unmount`,`static`]),s=n.default?.call(n,r),c={};if(r){let e=!1,t=[];for(let[n,i]of Object.entries(r))typeof i==`boolean`&&(e=!0),i===!0&&t.push(n);e&&(c[`data-headlessui-state`]=t.join(` `))}if(a===`template`){if(s=cae(s??[]),Object.keys(o).length>0||Object.keys(t).length>0){let[e,...n]=s??[];if(!uae(e)||n.length>0)throw Error([`Passing props on "template"!`,``,`The current component <${i} /> is rendering a "template".`,`However we need to passthrough the following props:`,Object.keys(o).concat(Object.keys(t)).map(e=>e.trim()).filter((e,t,n)=>n.indexOf(e)===t).sort((e,t)=>e.localeCompare(t)).map(e=>` - ${e}`).join(` -`),``,`You can apply a few solutions:`,['Add an `as="..."` prop, to ensure that we render an actual element instead of a "template".',`Render a single element as the child so that we can forward the props onto that element.`].map(e=>` - ${e}`).join(` -`)].join(` -`));let r=lae(e.props??{},o,c),a=ei(e,r,!0);for(let e in r)e.startsWith(`on`)&&(a.props||={},a.props[e]=r[e]);return a}return Array.isArray(s)&&s.length===1?s[0]:s}return ci(a,Object.assign({},o,c),{default:()=>s})}function cae(e){return e.flatMap(e=>e.type===V?cae(e.children):[e])}function lae(...e){if(e.length===0)return{};if(e.length===1)return e[0];let t={},n={};for(let r of e)for(let e in r)e.startsWith(`on`)&&typeof r[e]==`function`?(n[e]??(n[e]=[]),n[e].push(r[e])):t[e]=r[e];if(t.disabled||t[`aria-disabled`])return Object.assign(t,Object.fromEntries(Object.keys(n).map(e=>[e,void 0])));for(let e in n)Object.assign(t,{[e](t,...r){let i=n[e];for(let e of i){if(t instanceof Event&&t.defaultPrevented)return;e(t,...r)}}});return t}function cs(e){let t=Object.assign({},e);for(let e in t)t[e]===void 0&&delete t[e];return t}function ls(e,t=[]){let n=Object.assign({},e);for(let e of t)e in n&&delete n[e];return n}function uae(e){return e==null?!1:typeof e.type==`string`||typeof e.type==`object`||typeof e.type==`function`}var us,ds,fs=s((()=>{W(),Po(),us=(e=>(e[e.None=0]=`None`,e[e.RenderStrategy=1]=`RenderStrategy`,e[e.Static=2]=`Static`,e))(us||{}),ds=(e=>(e[e.Unmount=0]=`Unmount`,e[e.Hidden=1]=`Hidden`,e))(ds||{})})),ps,ms,hs=s((()=>{W(),fs(),ps=(e=>(e[e.None=1]=`None`,e[e.Focusable=2]=`Focusable`,e[e.Hidden=4]=`Hidden`,e))(ps||{}),ms=N({name:`Hidden`,props:{as:{type:[Object,String],default:`div`},features:{type:Number,default:1}},setup(e,{slots:t,attrs:n}){return()=>{let{features:r,...i}=e;return os({ourProps:{"aria-hidden":(r&2)==2?!0:i[`aria-hidden`]??void 0,hidden:(r&4)==4?!0:void 0,style:{position:`fixed`,top:1,left:1,width:1,height:0,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,borderWidth:`0`,...(r&4)==4&&(r&2)!=2&&{display:`none`}}},theirProps:i,slot:{},attrs:n,slots:t,name:`Hidden`})}}})}));function dae(){return gs()!==null}function gs(){return fr(vs,null)}function _s(e){dr(vs,e)}var vs,ys,bs=s((()=>{W(),vs=Symbol(`Context`),ys=(e=>(e[e.Open=1]=`Open`,e[e.Closed=2]=`Closed`,e[e.Closing=4]=`Closing`,e[e.Opening=8]=`Opening`,e))(ys||{})})),xs,Ss=s((()=>{xs=(e=>(e.Space=` `,e.Enter=`Enter`,e.Escape=`Escape`,e.Backspace=`Backspace`,e.Delete=`Delete`,e.ArrowLeft=`ArrowLeft`,e.ArrowUp=`ArrowUp`,e.ArrowRight=`ArrowRight`,e.ArrowDown=`ArrowDown`,e.Home=`Home`,e.End=`End`,e.PageUp=`PageUp`,e.PageDown=`PageDown`,e.Tab=`Tab`,e))(xs||{})})),Cs,fae=s((()=>{Cs=(e=>(e[e.Left=0]=`Left`,e[e.Right=2]=`Right`,e))(Cs||{})}));function pae(e){function t(){document.readyState!==`loading`&&(e(),document.removeEventListener(`DOMContentLoaded`,t))}typeof window<`u`&&typeof document<`u`&&(document.addEventListener(`DOMContentLoaded`,t),t())}var mae=s((()=>{})),ws,hae=s((()=>{mae(),ws=[],pae(()=>{function e(e){e.target instanceof HTMLElement&&e.target!==document.body&&ws[0]!==e.target&&(ws.unshift(e.target),ws=ws.filter(e=>e!=null&&e.isConnected),ws.splice(10))}window.addEventListener(`click`,e,{capture:!0}),window.addEventListener(`mousedown`,e,{capture:!0}),window.addEventListener(`focus`,e,{capture:!0}),document.body.addEventListener(`click`,e,{capture:!0}),document.body.addEventListener(`mousedown`,e,{capture:!0}),document.body.addEventListener(`focus`,e,{capture:!0})})}));function gae(e){throw Error(`Unexpected object: `+e)}function Ts(e,t){let n=t.resolveItems();if(n.length<=0)return null;let r=t.resolveActiveIndex(),i=r??-1;switch(e.focus){case 0:for(let e=0;e=0;--e)if(!t.resolveDisabled(n[e],e,n))return e;return r;case 2:for(let e=i+1;e=0;--e)if(!t.resolveDisabled(n[e],e,n))return e;return r;case 4:for(let r=0;r{Es=(e=>(e[e.First=0]=`First`,e[e.Previous=1]=`Previous`,e[e.Next=2]=`Next`,e[e.Last=3]=`Last`,e[e.Specific=4]=`Specific`,e[e.Nothing=5]=`Nothing`,e))(Es||{})}));function Os(e={},t=null,n=[]){for(let[r,i]of Object.entries(e))vae(n,_ae(t,r),i);return n}function _ae(e,t){return e?e+`[`+t+`]`:t}function vae(e,t,n){if(Array.isArray(n))for(let[r,i]of n.entries())vae(e,_ae(t,r.toString()),i);else n instanceof Date?e.push([t,n.toISOString()]):typeof n==`boolean`?e.push([t,n?`1`:`0`]):typeof n==`string`?e.push([t,n]):typeof n==`number`?e.push([t,`${n}`]):n==null?e.push([t,``]):Os(n,t,e)}function yae(e){var t;let n=e?.form??e.closest(`form`);if(n){for(let t of n.elements)if(t!==e&&(t.tagName===`INPUT`&&t.type===`submit`||t.tagName===`BUTTON`&&t.type===`submit`||t.nodeName===`INPUT`&&t.type===`image`)){t.click();return}(t=n.requestSubmit)==null||t.call(n)}}var ks=s((()=>{}));function bae(e,t){return e===t}function As(e){let t=fr(js,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,As),t}return t}var xae,Sae,Cae,js,Ms,wae,Tae=s((()=>{Rie(),W(),wo(),Hie(),jo(),$o(),ts(),rs(),as(),hs(),bs(),Ss(),fae(),hae(),Ds(),Oo(),Mo(),Yo(),ks(),Po(),zo(),Xo(),fs(),xae=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(xae||{}),Sae=(e=>(e[e.Single=0]=`Single`,e[e.Multi=1]=`Multi`,e))(Sae||{}),Cae=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Focus=1]=`Focus`,e[e.Other=2]=`Other`,e))(Cae||{}),js=Symbol(`ComboboxContext`),Ms=Symbol(`VirtualContext`),wae=N({name:`VirtualProvider`,setup(e,{slots:t}){let n=As(`VirtualProvider`),r=U(()=>{let e=G(n.optionsRef);if(!e)return{start:0,end:0};let t=window.getComputedStyle(e);return{start:parseFloat(t.paddingBlockStart||t.paddingTop),end:parseFloat(t.paddingBlockEnd||t.paddingBottom)}}),i=Lie(U(()=>({scrollPaddingStart:r.value.start,scrollPaddingEnd:r.value.end,count:n.virtual.value.options.length,estimateSize(){return 40},getScrollElement(){return G(n.optionsRef)},overscan:12}))),a=U(()=>n.virtual.value?.options),o=A(0);return mr([a],()=>{o.value+=1}),dr(Ms,n.virtual.value?i:null),()=>[ci(`div`,{style:{position:`relative`,width:`100%`,height:`${i.value.getTotalSize()}px`},ref:e=>{if(e){if(typeof process<`u`&&process.env.JEST_WORKER_ID!==void 0||n.activationTrigger.value===0)return;n.activeOptionIndex.value!==null&&n.virtual.value.options.length>n.activeOptionIndex.value&&i.value.scrollToIndex(n.activeOptionIndex.value)}}},i.value.getVirtualItems().map(e=>ei(t.default({option:n.virtual.value.options[e.index],open:n.comboboxState.value===0})[0],{key:`${o.value}-${e.index}`,"data-index":e.index,"aria-setsize":n.virtual.value.options.length,"aria-posinset":e.index+1,style:{position:`absolute`,top:0,left:0,transform:`translateY(${e.start}px)`,overflowAnchor:`none`}})))]}}),N({name:`Combobox`,emits:{"update:modelValue":e=>!0},props:{as:{type:[Object,String],default:`template`},disabled:{type:[Boolean],default:!1},by:{type:[String,Function],nullable:!0,default:null},modelValue:{type:[Object,String,Number,Boolean],default:void 0},defaultValue:{type:[Object,String,Number,Boolean],default:void 0},form:{type:String,optional:!0},name:{type:String,optional:!0},nullable:{type:Boolean,default:!1},multiple:{type:[Boolean],default:!1},immediate:{type:[Boolean],default:!1},virtual:{type:Object,default:null}},inheritAttrs:!1,setup(e,{slots:t,attrs:n,emit:r}){let i=A(1),a=A(null),o=A(null),s=A(null),c=A(null),l=A({static:!1,hold:!1}),u=A([]),d=A(null),f=A(2),p=A(!1);function m(e=e=>e){let t=d.value===null?null:u.value[d.value],n=e(u.value.slice()),r=n.length>0&&n[0].dataRef.order.value!==null?n.sort((e,t)=>e.dataRef.order.value-t.dataRef.order.value):Uo(n,e=>G(e.dataRef.domRef)),i=t?r.indexOf(t):null;return i===-1&&(i=null),{options:r,activeOptionIndex:i}}let h=U(()=>e.multiple?1:0),g=U(()=>e.nullable),[_,v]=Co(U(()=>e.modelValue),e=>r(`update:modelValue`,e),U(()=>e.defaultValue)),y=U(()=>_.value===void 0?No(h.value,{1:[],0:void 0}):_.value),b=null,x=null;function S(e){return No(h.value,{0(){return v?.(e)},1:()=>{let t=bn(C.value.value).slice(),n=bn(e),r=t.findIndex(e=>C.compare(n,bn(e)));return r===-1?t.push(n):t.splice(r,1),v?.(t)}})}mr([U(()=>{})],([e],[t])=>{if(C.virtual.value&&e&&t&&d.value!==null){let n=e.indexOf(t[d.value]);n===-1?d.value=null:d.value=n}});let C={comboboxState:i,value:y,mode:h,compare(t,n){if(typeof e.by==`string`){let r=e.by;return t?.[r]===n?.[r]}return e.by===null?bae(t,n):e.by(t,n)},calculateIndex(t){return C.virtual.value?e.by===null?C.virtual.value.options.indexOf(t):C.virtual.value.options.findIndex(e=>C.compare(e,t)):u.value.findIndex(e=>C.compare(e.dataRef.value,t))},defaultValue:U(()=>e.defaultValue),nullable:g,immediate:U(()=>!1),virtual:U(()=>null),inputRef:o,labelRef:a,buttonRef:s,optionsRef:c,disabled:U(()=>e.disabled),options:u,change(e){v(e)},activeOptionIndex:U(()=>{if(p.value&&d.value===null&&(C.virtual.value?C.virtual.value.options.length>0:u.value.length>0)){if(C.virtual.value){let e=C.virtual.value.options.findIndex(e=>{var t;return!((t=C.virtual.value)!=null&&t.disabled(e))});if(e!==-1)return e}let e=u.value.findIndex(e=>!e.dataRef.disabled);if(e!==-1)return e}return d.value}),activationTrigger:f,optionsPropsRef:l,closeCombobox(){p.value=!1,!e.disabled&&i.value!==1&&(i.value=1,d.value=null)},openCombobox(){if(p.value=!0,!e.disabled&&i.value!==0){if(C.value.value){let e=C.calculateIndex(C.value.value);e!==-1&&(d.value=e)}i.value=0}},setActivationTrigger(e){f.value=e},goToOption(t,n,r){p.value=!1,b!==null&&cancelAnimationFrame(b),b=requestAnimationFrame(()=>{if(e.disabled||c.value&&!l.value.static&&i.value===1)return;if(C.virtual.value){d.value=t===Es.Specific?n:Ts({focus:t},{resolveItems:()=>C.virtual.value.options,resolveActiveIndex:()=>C.activeOptionIndex.value??C.virtual.value.options.findIndex(e=>{var t;return!((t=C.virtual.value)!=null&&t.disabled(e))})??null,resolveDisabled:e=>C.virtual.value.disabled(e),resolveId(){throw Error(`Function not implemented.`)}}),f.value=r??2;return}let a=m();if(a.activeOptionIndex===null){let e=a.options.findIndex(e=>!e.dataRef.disabled);e!==-1&&(a.activeOptionIndex=e)}d.value=t===Es.Specific?n:Ts({focus:t},{resolveItems:()=>a.options,resolveActiveIndex:()=>a.activeOptionIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled}),f.value=r??2,u.value=a.options})},selectOption(e){let t=u.value.find(t=>t.id===e);if(!t)return;let{dataRef:n}=t;S(n.value)},selectActiveOption(){if(C.activeOptionIndex.value!==null){if(C.virtual.value)S(C.virtual.value.options[C.activeOptionIndex.value]);else{let{dataRef:e}=u.value[C.activeOptionIndex.value];S(e.value)}C.goToOption(Es.Specific,C.activeOptionIndex.value)}},registerOption(e,t){let n=fn({id:e,dataRef:t});if(C.virtual.value){u.value.push(n);return}x&&cancelAnimationFrame(x);let r=m(e=>(e.push(n),e));d.value===null&&C.isSelected(t.value.value)&&(r.activeOptionIndex=r.options.indexOf(n)),u.value=r.options,d.value=r.activeOptionIndex,f.value=2,r.options.some(e=>!G(e.dataRef.domRef))&&(x=requestAnimationFrame(()=>{let e=m();u.value=e.options,d.value=e.activeOptionIndex}))},unregisterOption(e,t){if(b!==null&&cancelAnimationFrame(b),t&&(p.value=!0),C.virtual.value){u.value=u.value.filter(t=>t.id!==e);return}let n=m(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});u.value=n.options,d.value=n.activeOptionIndex,f.value=2},isSelected(e){return No(h.value,{0:()=>C.compare(bn(C.value.value),bn(e)),1:()=>bn(C.value.value).some(t=>C.compare(bn(t),bn(e)))})},isActive(e){return d.value===C.calculateIndex(e)}};Qo([o,s,c],()=>C.closeCombobox(),U(()=>i.value===0)),dr(js,C),_s(U(()=>No(i.value,{0:ys.Open,1:ys.Closed})));let w=U(()=>G(o)?.closest(`form`));return Hi(()=>{mr([w],()=>{if(!w.value||e.defaultValue===void 0)return;function t(){C.change(e.defaultValue)}return w.value.addEventListener(`reset`,t),()=>{var e;(e=w.value)==null||e.removeEventListener(`reset`,t)}},{immediate:!0})}),()=>{let{name:r,disabled:a,form:o,...s}=e,c={open:i.value===0,disabled:a,activeIndex:C.activeOptionIndex.value,activeOption:C.activeOptionIndex.value===null?null:C.virtual.value?C.virtual.value.options[C.activeOptionIndex.value??0]:C.options.value[C.activeOptionIndex.value]?.dataRef.value??null,value:y.value};return ci(V,[...r!=null&&y.value!=null?Os({[r]:y.value}).map(([e,t])=>ci(ms,cs({features:ps.Hidden,key:e,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,form:o,disabled:a,name:e,value:t}))):[],os({theirProps:{...n,...ls(s,[`by`,`defaultValue`,`immediate`,`modelValue`,`multiple`,`nullable`,`onUpdate:modelValue`,`virtual`])},ourProps:{},slot:c,slots:t,attrs:n,name:`Combobox`})])}}}),N({name:`ComboboxLabel`,props:{as:{type:[Object,String],default:`label`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-combobox-label-${Ao()}`,i=As(`ComboboxLabel`);function a(){var e;(e=G(i.inputRef))==null||e.focus({preventScroll:!0})}return()=>{let o={open:i.comboboxState.value===0,disabled:i.disabled.value},{...s}=e;return os({ourProps:{id:r,ref:i.labelRef,onClick:a},theirProps:s,slot:o,attrs:t,slots:n,name:`ComboboxLabel`})}}}),N({name:`ComboboxButton`,props:{as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-combobox-button-${Ao()}`,a=As(`ComboboxButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){a.disabled.value||(a.comboboxState.value===0?a.closeCombobox():(e.preventDefault(),a.openCombobox()),ar(()=>G(a.inputRef)?.focus({preventScroll:!0})))}function s(e){switch(e.key){case xs.ArrowDown:e.preventDefault(),e.stopPropagation(),a.comboboxState.value===1&&a.openCombobox(),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return;case xs.ArrowUp:e.preventDefault(),e.stopPropagation(),a.comboboxState.value===1&&(a.openCombobox(),ar(()=>{a.value.value||a.goToOption(Es.Last)})),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return;case xs.Escape:if(a.comboboxState.value!==0)return;e.preventDefault(),a.optionsRef.value&&!a.optionsPropsRef.value.static&&e.stopPropagation(),a.closeCombobox(),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return}}let c=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.comboboxState.value===0,disabled:a.disabled.value,value:a.value.value},{...l}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:c.value,tabindex:`-1`,"aria-haspopup":`listbox`,"aria-controls":G(a.optionsRef)?.id,"aria-expanded":a.comboboxState.value===0,"aria-labelledby":a.labelRef.value?[G(a.labelRef)?.id,i].join(` `):void 0,disabled:a.disabled.value===!0?!0:void 0,onKeydown:s,onClick:o},theirProps:l,slot:r,attrs:t,slots:n,name:`ComboboxButton`})}}}),N({name:`ComboboxInput`,props:{as:{type:[Object,String],default:`input`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},displayValue:{type:Function},defaultValue:{type:String,default:void 0},id:{type:String,default:null}},emits:{change:e=>!0},setup(e,{emit:t,attrs:n,slots:r,expose:i}){let a=e.id??`headlessui-combobox-input-${Ao()}`,o=As(`ComboboxInput`),s=U(()=>Ro(G(o.inputRef))),c={value:!1};i({el:o.inputRef,$el:o.inputRef});function l(){o.change(null);let e=G(o.optionsRef);e&&(e.scrollTop=0),o.goToOption(Es.Nothing)}let u=U(()=>{let t=o.value.value;return G(o.inputRef)?e.displayValue!==void 0&&t!==void 0?e.displayValue(t)??``:typeof t==`string`?t:``:``});Hi(()=>{mr([u,o.comboboxState,s],([e,t],[n,r])=>{if(c.value)return;let i=G(o.inputRef);i&&((r===0&&t===1||e!==n)&&(i.value=e),requestAnimationFrame(()=>{if(c.value||!i||s.value?.activeElement!==i)return;let{selectionStart:e,selectionEnd:t}=i;Math.abs((t??0)-(e??0))===0&&e===0&&i.setSelectionRange(i.value.length,i.value.length)}))},{immediate:!0}),mr([o.comboboxState],([e],[t])=>{if(e===0&&t===1){if(c.value)return;let e=G(o.inputRef);if(!e)return;let t=e.value,{selectionStart:n,selectionEnd:r,selectionDirection:i}=e;e.value=``,e.value=t,i===null?e.setSelectionRange(n,r):e.setSelectionRange(n,r,i)}})});let d=A(!1);function f(){d.value=!0}function p(){Do().nextFrame(()=>{d.value=!1})}let m=Vie();function h(e){switch(c.value=!0,m(()=>{c.value=!1}),e.key){case xs.Enter:if(c.value=!1,o.comboboxState.value!==0||d.value)return;if(e.preventDefault(),e.stopPropagation(),o.activeOptionIndex.value===null){o.closeCombobox();return}o.selectActiveOption(),o.mode.value===0&&o.closeCombobox();break;case xs.ArrowDown:return c.value=!1,e.preventDefault(),e.stopPropagation(),No(o.comboboxState.value,{0:()=>o.goToOption(Es.Next),1:()=>o.openCombobox()});case xs.ArrowUp:return c.value=!1,e.preventDefault(),e.stopPropagation(),No(o.comboboxState.value,{0:()=>o.goToOption(Es.Previous),1:()=>{o.openCombobox(),ar(()=>{o.value.value||o.goToOption(Es.Last)})}});case xs.Home:if(e.shiftKey)break;return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.First);case xs.PageUp:return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.First);case xs.End:if(e.shiftKey)break;return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.Last);case xs.PageDown:return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.Last);case xs.Escape:if(c.value=!1,o.comboboxState.value!==0)return;e.preventDefault(),o.optionsRef.value&&!o.optionsPropsRef.value.static&&e.stopPropagation(),o.nullable.value&&o.mode.value===0&&o.value.value===null&&l(),o.closeCombobox();break;case xs.Tab:if(c.value=!1,o.comboboxState.value!==0)return;o.mode.value===0&&o.activationTrigger.value!==1&&o.selectActiveOption(),o.closeCombobox();break}}function g(e){t(`change`,e),o.nullable.value&&o.mode.value===0&&e.target.value===``&&l(),o.openCombobox()}function _(e){var t,n;let r=e.relatedTarget??ws.find(t=>t!==e.currentTarget);if(c.value=!1,!((t=G(o.optionsRef))!=null&&t.contains(r))&&!((n=G(o.buttonRef))!=null&&n.contains(r))&&o.comboboxState.value===0)return e.preventDefault(),o.mode.value===0&&(o.nullable.value&&o.value.value===null?l():o.activationTrigger.value!==1&&o.selectActiveOption()),o.closeCombobox()}function v(e){var t,n;let r=e.relatedTarget??ws.find(t=>t!==e.currentTarget);(t=G(o.buttonRef))!=null&&t.contains(r)||(n=G(o.optionsRef))!=null&&n.contains(r)||o.disabled.value||o.immediate.value&&o.comboboxState.value!==0&&(o.openCombobox(),Do().nextFrame(()=>{o.setActivationTrigger(1)}))}let y=U(()=>e.defaultValue??(o.defaultValue.value===void 0?null:e.displayValue?.call(e,o.defaultValue.value))??o.defaultValue.value??``);return()=>{let t={open:o.comboboxState.value===0},{displayValue:i,onChange:s,...c}=e;return os({ourProps:{"aria-controls":o.optionsRef.value?.id,"aria-expanded":o.comboboxState.value===0,"aria-activedescendant":o.activeOptionIndex.value===null?void 0:o.virtual.value?o.options.value.find(e=>!o.virtual.value.disabled(e.dataRef.value)&&o.compare(e.dataRef.value,o.virtual.value.options[o.activeOptionIndex.value]))?.id:o.options.value[o.activeOptionIndex.value]?.id,"aria-labelledby":G(o.labelRef)?.id??G(o.buttonRef)?.id,"aria-autocomplete":`list`,id:a,onCompositionstart:f,onCompositionend:p,onKeydown:h,onInput:g,onFocus:v,onBlur:_,role:`combobox`,type:n.type??`text`,tabIndex:0,ref:o.inputRef,defaultValue:y.value,disabled:o.disabled.value===!0?!0:void 0},theirProps:c,slot:t,attrs:n,slots:r,features:us.RenderStrategy|us.Static,name:`ComboboxInput`})}}}),N({name:`ComboboxOptions`,props:{as:{type:[Object,String],default:`ul`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},hold:{type:[Boolean],default:!1}},setup(e,{attrs:t,slots:n,expose:r}){let i=As(`ComboboxOptions`),a=`headlessui-combobox-options-${Ao()}`;r({el:i.optionsRef,$el:i.optionsRef}),pr(()=>{i.optionsPropsRef.value.static=e.static}),pr(()=>{i.optionsPropsRef.value.hold=e.hold});let o=gs(),s=U(()=>o===null?i.comboboxState.value===0:(o.value&ys.Open)===ys.Open);is({container:U(()=>G(i.optionsRef)),enabled:U(()=>i.comboboxState.value===0),accept(e){return e.getAttribute(`role`)===`option`?NodeFilter.FILTER_REJECT:e.hasAttribute(`role`)?NodeFilter.FILTER_SKIP:NodeFilter.FILTER_ACCEPT},walk(e){e.setAttribute(`role`,`none`)}});function c(e){e.preventDefault()}return()=>{let r={open:i.comboboxState.value===0};return os({ourProps:{"aria-labelledby":G(i.labelRef)?.id??G(i.buttonRef)?.id,id:a,ref:i.optionsRef,role:`listbox`,"aria-multiselectable":i.mode.value===1?!0:void 0,onMousedown:c},theirProps:ls(e,[`hold`]),slot:r,attrs:t,slots:i.virtual.value&&i.comboboxState.value===0?{...n,default:()=>[ci(wae,{},n.default)]}:n,features:us.RenderStrategy|us.Static,visible:s.value,name:`ComboboxOptions`})}}}),N({name:`ComboboxOption`,props:{as:{type:[Object,String],default:`li`},value:{type:[Object,String,Number,Boolean]},disabled:{type:Boolean,default:!1},order:{type:[Number],default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=As(`ComboboxOption`),a=`headlessui-combobox-option-${Ao()}`,o=A(null),s=U(()=>e.disabled);r({el:o,$el:o});let c=U(()=>i.virtual.value?i.activeOptionIndex.value===i.calculateIndex(e.value):i.activeOptionIndex.value===null?!1:i.options.value[i.activeOptionIndex.value]?.id===a),l=U(()=>i.isSelected(e.value)),u=fr(Ms,null),d=U(()=>({disabled:e.disabled,value:e.value,domRef:o,order:U(()=>e.order)}));Hi(()=>i.registerOption(a,d)),Gi(()=>i.unregisterOption(a,c.value)),pr(()=>{let e=G(o);e&&u?.value.measureElement(e)}),pr(()=>{i.comboboxState.value===0&&c.value&&(i.virtual.value||i.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})}))});function f(e){e.preventDefault(),e.button===Cs.Left&&(s.value||(i.selectOption(a),nae()||requestAnimationFrame(()=>G(i.inputRef)?.focus({preventScroll:!0})),i.mode.value===0&&i.closeCombobox()))}function p(){var t;if(e.disabled||(t=i.virtual.value)!=null&&t.disabled(e.value))return i.goToOption(Es.Nothing);let n=i.calculateIndex(e.value);i.goToOption(Es.Specific,n)}let m=ns();function h(e){m.update(e)}function g(t){var n;if(!m.wasMoved(t)||e.disabled||(n=i.virtual.value)!=null&&n.disabled(e.value)||c.value)return;let r=i.calculateIndex(e.value);i.goToOption(Es.Specific,r,0)}function _(t){var n;m.wasMoved(t)&&(e.disabled||(n=i.virtual.value)!=null&&n.disabled(e.value)||c.value&&(i.optionsPropsRef.value.hold||i.goToOption(Es.Nothing)))}return()=>{let{disabled:r}=e,i={active:c.value,selected:l.value,disabled:r};return os({ourProps:{id:a,ref:o,role:`option`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,"aria-selected":l.value,disabled:void 0,onMousedown:f,onFocus:p,onPointerenter:h,onMouseenter:h,onPointermove:g,onMousemove:g,onPointerleave:_,onMouseleave:_},theirProps:ls(e,[`order`,`value`]),slot:i,attrs:n,slots:t,name:`ComboboxOption`})}}})}));function Ns(e,t,n,r){Io.isServer||pr(i=>{e??=window,e.addEventListener(t,n,r),i(()=>e.removeEventListener(t,n,r))})}var Ps=s((()=>{W(),Lo()}));function Fs(){let e=A(0);return iae(`keydown`,t=>{t.key===`Tab`&&(e.value=t.shiftKey?1:0)}),e}var Is,Eae=s((()=>{W(),aae(),Is=(e=>(e[e.Forwards=0]=`Forwards`,e[e.Backwards=1]=`Backwards`,e))(Is||{})}));function Dae(e){if(!e)return new Set;if(typeof e==`function`)return new Set(e());let t=new Set;for(let n of e.value){let e=G(n);e instanceof HTMLElement&&t.add(e)}return t}function Oae(e){let t=A(ws.slice());return mr([e],([e],[n])=>{n===!0&&e===!1?To(()=>{t.value.splice(0)}):n===!1&&e===!0&&(t.value=ws.slice())},{flush:`post`}),()=>t.value.find(e=>e!=null&&e.isConnected)??null}function kae({ownerDocument:e},t){let n=Oae(t);Hi(()=>{pr(()=>{t.value||e.value?.activeElement===e.value?.body&&Ho(n())},{flush:`post`})}),Gi(()=>{t.value&&Ho(n())})}function Aae({ownerDocument:e,container:t,initialFocus:n},r){let i=A(null),a=A(!1);return Hi(()=>a.value=!0),Gi(()=>a.value=!1),Hi(()=>{mr([t,n,r],(o,s)=>{if(o.every((e,t)=>s?.[t]===e)||!r.value)return;let c=G(t);c&&To(()=>{if(!a.value)return;let t=G(n),r=e.value?.activeElement;if(t){if(t===r){i.value=r;return}}else if(c.contains(r)){i.value=r;return}t?Ho(t):Wo(c,Ko.First|Ko.NoScroll)===qo.Error&&console.warn(`There are no focusable elements inside the `),i.value=e.value?.activeElement})},{immediate:!0,flush:`post`})}),i}function jae({ownerDocument:e,container:t,containers:n,previousActiveElement:r},i){Ns(e.value?.defaultView,`focus`,e=>{if(!i.value)return;let a=Dae(n);G(t)instanceof HTMLElement&&a.add(G(t));let o=r.value;if(!o)return;let s=e.target;s&&s instanceof HTMLElement?Mae(a,s)?(r.value=s,Ho(s)):(e.preventDefault(),e.stopPropagation(),Ho(o)):Ho(r.value)},!0)}function Mae(e,t){for(let n of e)if(n.contains(t))return!0;return!1}var Ls,Rs,Nae=s((()=>{W(),Ps(),Eae(),hs(),hae(),Mo(),Yo(),Po(),Eo(),zo(),fs(),Ls=(e=>(e[e.None=1]=`None`,e[e.InitialFocus=2]=`InitialFocus`,e[e.TabLock=4]=`TabLock`,e[e.FocusLock=8]=`FocusLock`,e[e.RestoreFocus=16]=`RestoreFocus`,e[e.All=30]=`All`,e))(Ls||{}),Rs=Object.assign(N({name:`FocusTrap`,props:{as:{type:[Object,String],default:`div`},initialFocus:{type:Object,default:null},features:{type:Number,default:30},containers:{type:[Object,Function],default:A(new Set)}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=A(null);r({el:i,$el:i});let a=U(()=>Ro(i)),o=A(!1);Hi(()=>o.value=!0),Gi(()=>o.value=!1),kae({ownerDocument:a},U(()=>o.value&&!!(e.features&16)));let s=Aae({ownerDocument:a,container:i,initialFocus:U(()=>e.initialFocus)},U(()=>o.value&&!!(e.features&2)));jae({ownerDocument:a,container:i,containers:e.containers,previousActiveElement:s},U(()=>o.value&&!!(e.features&8)));let c=Fs();function l(e){let t=G(i);t&&(e=>e())(()=>{No(c.value,{[Is.Forwards]:()=>{Wo(t,Ko.First,{skipElements:[e.relatedTarget]})},[Is.Backwards]:()=>{Wo(t,Ko.Last,{skipElements:[e.relatedTarget]})}})})}let u=A(!1);function d(e){e.key===`Tab`&&(u.value=!0,requestAnimationFrame(()=>{u.value=!1}))}function f(t){if(!o.value)return;let n=Dae(e.containers);G(i)instanceof HTMLElement&&n.add(G(i));let r=t.relatedTarget;r instanceof HTMLElement&&r.dataset.headlessuiFocusGuard!==`true`&&(Mae(n,r)||(u.value?Wo(G(i),No(c.value,{[Is.Forwards]:()=>Ko.Next,[Is.Backwards]:()=>Ko.Previous})|Ko.WrapAround,{relativeTo:t.target}):t.target instanceof HTMLElement&&Ho(t.target)))}return()=>{let r={},a={ref:i,onKeydown:d,onFocusout:f},{features:o,initialFocus:s,containers:c,...u}=e;return ci(V,[!!(o&4)&&ci(ms,{as:`button`,type:`button`,"data-headlessui-focus-guard":!0,onFocus:l,features:ps.Focusable}),os({ourProps:a,theirProps:{...t,...u},slot:r,attrs:t,slots:n,name:`FocusTrap`}),!!(o&4)&&ci(ms,{as:`button`,type:`button`,"data-headlessui-focus-guard":!0,onFocus:l,features:ps.Focusable})])}}}),{features:Ls})}));function Pae(e){let t=Sn(e.getSnapshot());return Gi(e.subscribe(()=>{t.value=e.getSnapshot()})),t}var Fae=s((()=>{W()}));function Iae(e,t){let n=e(),r=new Set;return{getSnapshot(){return n},subscribe(e){return r.add(e),()=>r.delete(e)},dispatch(e,...i){let a=t[e].call(n,...i);a&&(n=a,r.forEach(e=>e()))}}}var Lae=s((()=>{}));function Rae(){let e;return{before({doc:t}){let n=t.documentElement;e=(t.defaultView??window).innerWidth-n.clientWidth},after({doc:t,d:n}){let r=t.documentElement,i=r.clientWidth-r.offsetWidth,a=e-i;n.style(r,`paddingRight`,`${a}px`)}}}var zae=s((()=>{}));function Bae(){return eae()?{before({doc:e,d:t,meta:n}){function r(e){return n.containers.flatMap(e=>e()).some(t=>t.contains(e))}t.microTask(()=>{if(window.getComputedStyle(e.documentElement).scrollBehavior!==`auto`){let n=Do();n.style(e.documentElement,`scrollBehavior`,`auto`),t.add(()=>t.microTask(()=>n.dispose()))}let n=window.scrollY??window.pageYOffset,i=null;t.addEventListener(e,`click`,t=>{if(t.target instanceof HTMLElement)try{let n=t.target.closest(`a`);if(!n)return;let{hash:a}=new URL(n.href),o=e.querySelector(a);o&&!r(o)&&(i=o)}catch{}},!0),t.addEventListener(e,`touchstart`,e=>{if(e.target instanceof HTMLElement)if(r(e.target)){let n=e.target;for(;n.parentElement&&r(n.parentElement);)n=n.parentElement;t.style(n,`overscrollBehavior`,`contain`)}else t.style(e.target,`touchAction`,`none`)}),t.addEventListener(e,`touchmove`,e=>{if(e.target instanceof HTMLElement){if(e.target.tagName===`INPUT`)return;if(r(e.target)){let t=e.target;for(;t.parentElement&&t.dataset.headlessuiPortal!==``&&!(t.scrollHeight>t.clientHeight||t.scrollWidth>t.clientWidth);)t=t.parentElement;t.dataset.headlessuiPortal===``&&e.preventDefault()}else e.preventDefault()}},{passive:!1}),t.add(()=>{n!==(window.scrollY??window.pageYOffset)&&window.scrollTo(0,n),i&&i.isConnected&&(i.scrollIntoView({block:`nearest`}),i=null)})})}}:{}}var Vae=s((()=>{Oo(),Xo()}));function Hae(){return{before({doc:e,d:t}){t.style(e.documentElement,`overflow`,`hidden`)}}}var Uae=s((()=>{}));function Wae(e){let t={};for(let n of e)Object.assign(t,n(t));return t}var zs,Gae=s((()=>{Oo(),Lae(),zae(),Vae(),Uae(),zs=Iae(()=>new Map,{PUSH(e,t){let n=this.get(e)??{doc:e,count:0,d:Do(),meta:new Set};return n.count++,n.meta.add(t),this.set(e,n),this},POP(e,t){let n=this.get(e);return n&&(n.count--,n.meta.delete(t)),this},SCROLL_PREVENT({doc:e,d:t,meta:n}){let r={doc:e,d:t,meta:Wae(n)},i=[Bae(),Rae(),Hae()];i.forEach(({before:e})=>e?.(r)),i.forEach(({after:e})=>e?.(r))},SCROLL_ALLOW({d:e}){e.dispose()},TEARDOWN({doc:e}){this.delete(e)}}),zs.subscribe(()=>{let e=zs.getSnapshot(),t=new Map;for(let[n]of e)t.set(n,n.documentElement.style.overflow);for(let n of e.values()){let e=t.get(n.doc)===`hidden`,r=n.count!==0;(r&&!e||!r&&e)&&zs.dispatch(n.count>0?`SCROLL_PREVENT`:`SCROLL_ALLOW`,n),n.count===0&&zs.dispatch(`TEARDOWN`,n)}})}));function Kae(e,t,n){let r=Pae(zs),i=U(()=>{let t=e.value?r.value.get(e.value):void 0;return t?t.count>0:!1});return mr([e,t],([e,t],[r],i)=>{if(!e||!t)return;zs.dispatch(`PUSH`,e,n);let a=!1;i(()=>{a||=(zs.dispatch(`POP`,r??e,n),!0)})},{immediate:!0}),i}var qae=s((()=>{W(),Fae(),Gae()}));function Jae(e,t=A(!0)){pr(n=>{if(!t.value)return;let r=G(e);if(!r)return;n(function(){if(!r)return;let e=Vs.get(r)??1;if(e===1?Vs.delete(r):Vs.set(r,e-1),e!==1)return;let t=Bs.get(r);t&&(t[`aria-hidden`]===null?r.removeAttribute(`aria-hidden`):r.setAttribute(`aria-hidden`,t[`aria-hidden`]),r.inert=t.inert,Bs.delete(r))});let i=Vs.get(r)??0;Vs.set(r,i+1),i===0&&(Bs.set(r,{"aria-hidden":r.getAttribute(`aria-hidden`),inert:r.inert}),r.setAttribute(`aria-hidden`,`true`),r.inert=!0)})}var Bs,Vs,Yae=s((()=>{W(),Mo(),Bs=new Map,Vs=new Map}));function Xae({defaultContainers:e=[],portals:t,mainTreeNodeRef:n}={}){let r=A(null),i=Ro(r);function a(){let n=[];for(let t of e)t!==null&&(t instanceof HTMLElement?n.push(t):`value`in t&&t.value instanceof HTMLElement&&n.push(t.value));if(t!=null&&t.value)for(let e of t.value)n.push(e);for(let e of i?.querySelectorAll(`html > *, body > *`)??[])e!==document.body&&e!==document.head&&e instanceof HTMLElement&&e.id!==`headlessui-portal-root`&&(e.contains(G(r))||e.contains(G(r)?.getRootNode()?.host)||n.some(t=>e.contains(t))||n.push(e));return n}return{resolveContainers:a,contains(e){return a().some(t=>t.contains(e))},mainTreeNodeRef:r,MainTreeNode(){return n==null?ci(ms,{features:ps.Hidden,ref:r}):null}}}function Zae(){let e=A(null);return{mainTreeNodeRef:e,MainTreeNode(){return ci(ms,{features:ps.Hidden,ref:e})}}}var Qae=s((()=>{W(),hs(),Mo(),zo()}));function $ae(){return fr(Hs,!1)}var Hs,Us,eoe=s((()=>{W(),fs(),Hs=Symbol(`ForcePortalRootContext`),Us=N({name:`ForcePortalRoot`,props:{as:{type:[Object,String],default:`template`},force:{type:Boolean,default:!1}},setup(e,{slots:t,attrs:n}){return dr(Hs,e.force),()=>{let{force:r,...i}=e;return os({theirProps:i,ourProps:{},slot:{},slots:t,attrs:n,name:`ForcePortalRoot`})}}})}));function toe(){return fr(Ws,()=>{})}function noe({type:e,enabled:t,element:n,onUpdate:r}){let i=toe();function a(...e){r?.(...e),i(...e)}Hi(()=>{mr(t,(t,r)=>{t?a(0,e,n):r===!0&&a(1,e,n)},{immediate:!0,flush:`sync`})}),Gi(()=>{t.value&&a(1,e,n)}),dr(Ws,a)}var Ws,Gs,roe=s((()=>{W(),Ws=Symbol(`StackContext`),Gs=(e=>(e[e.Add=0]=`Add`,e[e.Remove=1]=`Remove`,e))(Gs||{})}));function ioe(){let e=fr(qs,null);if(e===null)throw Error(`Missing parent`);return e}function Ks({slot:e=A({}),name:t=`Description`,props:n={}}={}){let r=A([]);function i(e){return r.value.push(e),()=>{let t=r.value.indexOf(e);t!==-1&&r.value.splice(t,1)}}return dr(qs,{register:i,slot:e,name:t,props:n}),U(()=>r.value.length>0?r.value.join(` `):void 0)}var qs,Js=s((()=>{W(),jo(),fs(),qs=Symbol(`DescriptionContext`),N({name:`Description`,props:{as:{type:[Object,String],default:`p`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-description-${Ao()}`,i=ioe();return Hi(()=>Gi(i.register(r))),()=>{let{name:a=`Description`,slot:o=A({}),props:s={}}=i,{...c}=e;return os({ourProps:{...Object.entries(s).reduce((e,[t,n])=>Object.assign(e,{[t]:j(n)}),{}),id:r},theirProps:c,slot:o.value,attrs:t,slots:n,name:a})}}})}));function aoe(e){let t=Ro(e);if(!t){if(e===null)return null;throw Error(`[Headless UI]: Cannot find ownerDocument for contextElement: ${e}`)}let n=t.getElementById(`headlessui-portal-root`);if(n)return n;let r=t.createElement(`div`);return r.setAttribute(`id`,`headlessui-portal-root`),t.body.appendChild(r)}function ooe(e){return Ys.get(e)??0}function soe(e,t){let n=t(ooe(e));return n<=0?Ys.delete(e):Ys.set(e,n),n}function coe(){let e=fr(Zs,null),t=A([]);function n(n){return t.value.push(n),e&&e.register(n),()=>r(n)}function r(n){let r=t.value.indexOf(n);r!==-1&&t.value.splice(r,1),e&&e.unregister(n)}let i={register:n,unregister:r,portals:t};return[t,N({name:`PortalWrapper`,setup(e,{slots:t}){return dr(Zs,i),()=>t.default?.call(t)}})]}var Ys,Xs,Zs,Qs,loe,uoe=s((()=>{W(),eoe(),Mo(),zo(),fs(),Ys=new WeakMap,Xs=N({name:`Portal`,props:{as:{type:[Object,String],default:`div`}},setup(e,{slots:t,attrs:n}){let r=A(null),i=U(()=>Ro(r)),a=$ae(),o=fr(Qs,null),s=A(a===!0||o==null?aoe(r.value):o.resolveTarget());s.value&&soe(s.value,e=>e+1);let c=A(!1);Hi(()=>{c.value=!0}),pr(()=>{a||o!=null&&(s.value=o.resolveTarget())});let l=fr(Zs,null),u=!1,d=ba();return mr(r,()=>{if(u||!l)return;let e=G(r);e&&(Gi(l.register(e),d),u=!0)}),Gi(()=>{var e;let t=i.value?.getElementById(`headlessui-portal-root`);!t||s.value!==t||soe(s.value,e=>e-1)||s.value.children.length>0||(e=s.value.parentElement)==null||e.removeChild(s.value)}),()=>{if(!c.value||s.value===null)return null;let i={ref:r,"data-headlessui-portal":``};return ci(Di,{to:s.value},os({ourProps:i,theirProps:e,slot:{},attrs:n,slots:t,name:`Portal`}))}}}),Zs=Symbol(`PortalParentContext`),Qs=Symbol(`PortalGroupContext`),loe=N({name:`PortalGroup`,props:{as:{type:[Object,String],default:`template`},target:{type:Object,default:null}},setup(e,{attrs:t,slots:n}){return dr(Qs,fn({resolveTarget(){return e.target}})),()=>{let{target:r,...i}=e;return os({theirProps:i,ourProps:{},slot:{},attrs:t,slots:n,name:`PortalGroup`})}}})}));function $s(e){let t=fr(ec,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,$s),t}return t}var doe,ec,tc,foe,poe,moe,hoe=s((()=>{W(),Nae(),qae(),Ps(),jo(),Yae(),$o(),Qae(),bs(),eoe(),roe(),Ss(),Mo(),Po(),zo(),fs(),Js(),uoe(),doe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(doe||{}),ec=Symbol(`DialogContext`),tc=`DC8F892D-2EBD-447C-A4C8-A03058436FF4`,foe=N({name:`Dialog`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},open:{type:[Boolean,String],default:tc},initialFocus:{type:Object,default:null},id:{type:String,default:null},role:{type:String,default:`dialog`}},emits:{close:e=>!0},setup(e,{emit:t,attrs:n,slots:r,expose:i}){let a=e.id??`headlessui-dialog-${Ao()}`,o=A(!1);Hi(()=>{o.value=!0});let s=!1,c=U(()=>e.role===`dialog`||e.role===`alertdialog`?e.role:(s||(s=!0,console.warn(`Invalid role [${c}] passed to . Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`)),`dialog`)),l=A(0),u=gs(),d=U(()=>e.open===tc&&u!==null?(u.value&ys.Open)===ys.Open:e.open),f=A(null),p=U(()=>Ro(f));if(i({el:f,$el:f}),!(e.open!==tc||u!==null))throw Error("You forgot to provide an `open` prop to the `Dialog`.");if(typeof d.value!=`boolean`)throw Error(`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${d.value===tc?void 0:e.open}`);let m=U(()=>o.value&&d.value?0:1),h=U(()=>m.value===0),g=U(()=>l.value>1),_=fr(ec,null)!==null,[v,y]=coe(),{resolveContainers:b,mainTreeNodeRef:x,MainTreeNode:S}=Xae({portals:v,defaultContainers:[U(()=>T.panelRef.value??f.value)]}),C=U(()=>g.value?`parent`:`leaf`),w=U(()=>u===null?!1:(u.value&ys.Closing)===ys.Closing),ee=U(()=>_||w.value?!1:h.value);Jae(U(()=>Array.from(p.value?.querySelectorAll(`body > *`)??[]).find(e=>e.id===`headlessui-portal-root`?!1:e.contains(G(x))&&e instanceof HTMLElement)??null),ee);let te=U(()=>g.value?!0:h.value);Jae(U(()=>Array.from(p.value?.querySelectorAll(`[data-headlessui-portal]`)??[]).find(e=>e.contains(G(x))&&e instanceof HTMLElement)??null),te),noe({type:`Dialog`,enabled:U(()=>m.value===0),element:f,onUpdate:(e,t)=>{if(t===`Dialog`)return No(e,{[Gs.Add]:()=>l.value+=1,[Gs.Remove]:()=>--l.value})}});let ne=Ks({name:`DialogDescription`,slot:U(()=>({open:d.value}))}),re=A(null),T={titleId:re,panelRef:A(null),dialogState:m,setTitleId(e){re.value!==e&&(re.value=e)},close(){t(`close`,!1)}};dr(ec,T),Qo(b,(e,t)=>{e.preventDefault(),T.close(),ar(()=>t?.focus())},U(()=>!(!h.value||g.value)));let ie=U(()=>!(g.value||m.value!==0));return Ns(p.value?.defaultView,`keydown`,e=>{ie.value&&(e.defaultPrevented||e.key===xs.Escape&&(e.preventDefault(),e.stopPropagation(),T.close()))}),Kae(p,U(()=>!(w.value||m.value!==0||_)),e=>({containers:[...e.containers??[],b]})),pr(e=>{if(m.value!==0)return;let t=G(f);if(!t)return;let n=new ResizeObserver(e=>{for(let t of e){let e=t.target.getBoundingClientRect();e.x===0&&e.y===0&&e.width===0&&e.height===0&&T.close()}});n.observe(t),e(()=>n.disconnect())}),()=>{let{open:t,initialFocus:i,...o}=e,s={...n,ref:f,id:a,role:c.value,"aria-modal":m.value===0?!0:void 0,"aria-labelledby":re.value,"aria-describedby":ne.value},l={open:m.value===0};return ci(Us,{force:!0},()=>[ci(Xs,()=>ci(loe,{target:f.value},()=>ci(Us,{force:!1},()=>ci(Rs,{initialFocus:i,containers:b,features:h.value?No(C.value,{parent:Rs.features.RestoreFocus,leaf:Rs.features.All&~Rs.features.FocusLock}):Rs.features.None},()=>ci(y,{},()=>os({ourProps:s,theirProps:{...o,...n},slot:l,attrs:n,slots:r,visible:m.value===0,features:us.RenderStrategy|us.Static,name:`Dialog`})))))),ci(S)])}}}),N({name:`DialogOverlay`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-dialog-overlay-${Ao()}`,i=$s(`DialogOverlay`);function a(e){e.target===e.currentTarget&&(e.preventDefault(),e.stopPropagation(),i.close())}return()=>{let{...o}=e;return os({ourProps:{id:r,"aria-hidden":!0,onClick:a},theirProps:o,slot:{open:i.dialogState.value===0},attrs:t,slots:n,name:`DialogOverlay`})}}}),N({name:`DialogBackdrop`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-dialog-backdrop-${Ao()}`,a=$s(`DialogBackdrop`),o=A(null);return r({el:o,$el:o}),Hi(()=>{if(a.panelRef.value===null)throw Error(`A component is being used, but a component is missing.`)}),()=>{let{...r}=e,s={id:i,ref:o,"aria-hidden":!0};return ci(Us,{force:!0},()=>ci(Xs,()=>os({ourProps:s,theirProps:{...t,...r},slot:{open:a.dialogState.value===0},attrs:t,slots:n,name:`DialogBackdrop`})))}}}),poe=N({name:`DialogPanel`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-dialog-panel-${Ao()}`,a=$s(`DialogPanel`);r({el:a.panelRef,$el:a.panelRef});function o(e){e.stopPropagation()}return()=>{let{...r}=e;return os({ourProps:{id:i,ref:a.panelRef,onClick:o},theirProps:r,slot:{open:a.dialogState.value===0},attrs:t,slots:n,name:`DialogPanel`})}}}),moe=N({name:`DialogTitle`,props:{as:{type:[Object,String],default:`h2`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-dialog-title-${Ao()}`,i=$s(`DialogTitle`);return Hi(()=>{i.setTitleId(r),Gi(()=>i.setTitleId(null))}),()=>{let{...a}=e;return os({ourProps:{id:r},theirProps:a,slot:{open:i.dialogState.value===0},attrs:t,slots:n,name:`DialogTitle`})}}})}));function nc(e){let t=fr(rc,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,nc),t}return t}function goe(){return fr(ic,null)}var _oe,rc,ic,ac,oc,sc,voe=s((()=>{W(),jo(),ts(),bs(),Ss(),Mo(),Po(),fs(),_oe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(_oe||{}),rc=Symbol(`DisclosureContext`),ic=Symbol(`DisclosurePanelContext`),ac=N({name:`Disclosure`,props:{as:{type:[Object,String],default:`template`},defaultOpen:{type:[Boolean],default:!1}},setup(e,{slots:t,attrs:n}){let r=A(e.defaultOpen?0:1),i=A(null),a=A(null),o={buttonId:A(`headlessui-disclosure-button-${Ao()}`),panelId:A(`headlessui-disclosure-panel-${Ao()}`),disclosureState:r,panel:i,button:a,toggleDisclosure(){r.value=No(r.value,{0:1,1:0})},closeDisclosure(){r.value!==1&&(r.value=1)},close(e){o.closeDisclosure(),(e?e instanceof HTMLElement?e:e.value instanceof HTMLElement?G(e):G(o.button):G(o.button))?.focus()}};return dr(rc,o),_s(U(()=>No(r.value,{0:ys.Open,1:ys.Closed}))),()=>{let{defaultOpen:i,...a}=e;return os({theirProps:a,ourProps:{},slot:{open:r.value===0,close:o.close},slots:t,attrs:n,name:`Disclosure`})}}}),oc=N({name:`DisclosureButton`,props:{as:{type:[Object,String],default:`button`},disabled:{type:[Boolean],default:!1},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=nc(`DisclosureButton`),a=goe(),o=U(()=>a===null?!1:a.value===i.panelId.value);Hi(()=>{o.value||e.id!==null&&(i.buttonId.value=e.id)}),Gi(()=>{o.value||(i.buttonId.value=null)});let s=A(null);r({el:s,$el:s}),o.value||pr(()=>{i.button.value=s.value});let c=es(U(()=>({as:e.as,type:t.type})),s);function l(){var t;e.disabled||(o.value?(i.toggleDisclosure(),(t=G(i.button))==null||t.focus()):i.toggleDisclosure())}function u(t){var n;if(!e.disabled)if(o.value)switch(t.key){case xs.Space:case xs.Enter:t.preventDefault(),t.stopPropagation(),i.toggleDisclosure(),(n=G(i.button))==null||n.focus();break}else switch(t.key){case xs.Space:case xs.Enter:t.preventDefault(),t.stopPropagation(),i.toggleDisclosure();break}}function d(e){switch(e.key){case xs.Space:e.preventDefault();break}}return()=>{let r={open:i.disclosureState.value===0},{id:a,...f}=e;return os({ourProps:o.value?{ref:s,type:c.value,onClick:l,onKeydown:u}:{id:i.buttonId.value??a,ref:s,type:c.value,"aria-expanded":i.disclosureState.value===0,"aria-controls":i.disclosureState.value===0||G(i.panel)?i.panelId.value:void 0,disabled:e.disabled?!0:void 0,onClick:l,onKeydown:u,onKeyup:d},theirProps:f,slot:r,attrs:t,slots:n,name:`DisclosureButton`})}}}),sc=N({name:`DisclosurePanel`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=nc(`DisclosurePanel`);Hi(()=>{e.id!==null&&(i.panelId.value=e.id)}),Gi(()=>{i.panelId.value=null}),r({el:i.panel,$el:i.panel}),dr(ic,i.panelId);let a=gs(),o=U(()=>a===null?i.disclosureState.value===0:(a.value&ys.Open)===ys.Open);return()=>{let r={open:i.disclosureState.value===0,close:i.close},{id:a,...s}=e;return os({ourProps:{id:i.panelId.value??a,ref:i.panel},theirProps:s,slot:r,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:o.value,name:`DisclosurePanel`})}}})}));function yoe(e){let t=e.innerText??``,n=e.cloneNode(!0);if(!(n instanceof HTMLElement))return t;let r=!1;for(let e of n.querySelectorAll(`[hidden],[aria-hidden],[role="img"]`))e.remove(),r=!0;let i=r?n.innerText??``:t;return cc.test(i)&&(i=i.replace(cc,``)),i}function boe(e){let t=e.getAttribute(`aria-label`);if(typeof t==`string`)return t.trim();let n=e.getAttribute(`aria-labelledby`);if(n){let e=n.split(` `).map(e=>{let t=document.getElementById(e);if(t){let e=t.getAttribute(`aria-label`);return typeof e==`string`?e.trim():yoe(t).trim()}return null}).filter(Boolean);if(e.length>0)return e.join(`, `)}return yoe(e).trim()}var cc,xoe=s((()=>{cc=/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g}));function Soe(e){let t=A(``),n=A(``);return()=>{let r=G(e);if(!r)return``;let i=r.innerText;if(t.value===i)return n.value;let a=boe(r).trim().toLowerCase();return t.value=i,n.value=a,a}}var Coe=s((()=>{W(),Mo(),xoe()}));function woe(e,t){return e===t}function Toe(e){requestAnimationFrame(()=>requestAnimationFrame(e))}function lc(e){let t=fr(uc,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,lc),t}return t}var Eoe,Doe,Ooe,uc,koe,Aoe,joe,Moe,Noe,Poe=s((()=>{W(),wo(),jo(),$o(),ts(),Coe(),rs(),hs(),bs(),Ss(),Ds(),Mo(),Yo(),ks(),Po(),fs(),Eoe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Eoe||{}),Doe=(e=>(e[e.Single=0]=`Single`,e[e.Multi=1]=`Multi`,e))(Doe||{}),Ooe=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Other=1]=`Other`,e))(Ooe||{}),uc=Symbol(`ListboxContext`),koe=N({name:`Listbox`,emits:{"update:modelValue":e=>!0},props:{as:{type:[Object,String],default:`template`},disabled:{type:[Boolean],default:!1},by:{type:[String,Function],default:()=>woe},horizontal:{type:[Boolean],default:!1},modelValue:{type:[Object,String,Number,Boolean],default:void 0},defaultValue:{type:[Object,String,Number,Boolean],default:void 0},form:{type:String,optional:!0},name:{type:String,optional:!0},multiple:{type:[Boolean],default:!1}},inheritAttrs:!1,setup(e,{slots:t,attrs:n,emit:r}){let i=A(1),a=A(null),o=A(null),s=A(null),c=A([]),l=A(``),u=A(null),d=A(1);function f(e=e=>e){let t=u.value===null?null:c.value[u.value],n=Uo(e(c.value.slice()),e=>G(e.dataRef.domRef)),r=t?n.indexOf(t):null;return r===-1&&(r=null),{options:n,activeOptionIndex:r}}let p=U(()=>e.multiple?1:0),[m,h]=Co(U(()=>e.modelValue),e=>r(`update:modelValue`,e),U(()=>e.defaultValue)),g=U(()=>m.value===void 0?No(p.value,{1:[],0:void 0}):m.value),_={listboxState:i,value:g,mode:p,compare(t,n){if(typeof e.by==`string`){let r=e.by;return t?.[r]===n?.[r]}return e.by(t,n)},orientation:U(()=>e.horizontal?`horizontal`:`vertical`),labelRef:a,buttonRef:o,optionsRef:s,disabled:U(()=>e.disabled),options:c,searchQuery:l,activeOptionIndex:u,activationTrigger:d,closeListbox(){e.disabled||i.value!==1&&(i.value=1,u.value=null)},openListbox(){e.disabled||i.value!==0&&(i.value=0)},goToOption(t,n,r){if(e.disabled||i.value===1)return;let a=f(),o=Ts(t===Es.Specific?{focus:Es.Specific,id:n}:{focus:t},{resolveItems:()=>a.options,resolveActiveIndex:()=>a.activeOptionIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled});l.value=``,u.value=o,d.value=r??1,c.value=a.options},search(t){if(e.disabled||i.value===1)return;let n=l.value===``?1:0;l.value+=t.toLowerCase();let r=(u.value===null?c.value:c.value.slice(u.value+n).concat(c.value.slice(0,u.value+n))).find(e=>e.dataRef.textValue.startsWith(l.value)&&!e.dataRef.disabled),a=r?c.value.indexOf(r):-1;a===-1||a===u.value||(u.value=a,d.value=1)},clearSearch(){e.disabled||i.value!==1&&l.value!==``&&(l.value=``)},registerOption(e,t){let n=f(n=>[...n,{id:e,dataRef:t}]);c.value=n.options,u.value=n.activeOptionIndex},unregisterOption(e){let t=f(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});c.value=t.options,u.value=t.activeOptionIndex,d.value=1},theirOnChange(t){e.disabled||h(t)},select(t){e.disabled||h(No(p.value,{0:()=>t,1:()=>{let e=bn(_.value.value).slice(),n=bn(t),r=e.findIndex(e=>_.compare(n,bn(e)));return r===-1?e.push(n):e.splice(r,1),e}}))}};Qo([o,s],(e,t)=>{var n;_.closeListbox(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(o))==null||n.focus())},U(()=>i.value===0)),dr(uc,_),_s(U(()=>No(i.value,{0:ys.Open,1:ys.Closed})));let v=U(()=>G(o)?.closest(`form`));return Hi(()=>{mr([v],()=>{if(!v.value||e.defaultValue===void 0)return;function t(){_.theirOnChange(e.defaultValue)}return v.value.addEventListener(`reset`,t),()=>{var e;(e=v.value)==null||e.removeEventListener(`reset`,t)}},{immediate:!0})}),()=>{let{name:r,modelValue:a,disabled:o,form:s,...c}=e,l={open:i.value===0,disabled:o,value:g.value};return ci(V,[...r!=null&&g.value!=null?Os({[r]:g.value}).map(([e,t])=>ci(ms,cs({features:ps.Hidden,key:e,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,form:s,disabled:o,name:e,value:t}))):[],os({ourProps:{},theirProps:{...n,...ls(c,[`defaultValue`,`onUpdate:modelValue`,`horizontal`,`multiple`,`by`])},slot:l,slots:t,attrs:n,name:`Listbox`})])}}}),Aoe=N({name:`ListboxLabel`,props:{as:{type:[Object,String],default:`label`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-listbox-label-${Ao()}`,i=lc(`ListboxLabel`);function a(){var e;(e=G(i.buttonRef))==null||e.focus({preventScroll:!0})}return()=>{let o={open:i.listboxState.value===0,disabled:i.disabled.value},{...s}=e;return os({ourProps:{id:r,ref:i.labelRef,onClick:a},theirProps:s,slot:o,attrs:t,slots:n,name:`ListboxLabel`})}}}),joe=N({name:`ListboxButton`,props:{as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-listbox-button-${Ao()}`,a=lc(`ListboxButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){switch(e.key){case xs.Space:case xs.Enter:case xs.ArrowDown:e.preventDefault(),a.openListbox(),ar(()=>{var e;(e=G(a.optionsRef))==null||e.focus({preventScroll:!0}),a.value.value||a.goToOption(Es.First)});break;case xs.ArrowUp:e.preventDefault(),a.openListbox(),ar(()=>{var e;(e=G(a.optionsRef))==null||e.focus({preventScroll:!0}),a.value.value||a.goToOption(Es.Last)});break}}function s(e){switch(e.key){case xs.Space:e.preventDefault();break}}function c(e){a.disabled.value||(a.listboxState.value===0?(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}))):(e.preventDefault(),a.openListbox(),Toe(()=>G(a.optionsRef)?.focus({preventScroll:!0}))))}let l=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.listboxState.value===0,disabled:a.disabled.value,value:a.value.value},{...u}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:l.value,"aria-haspopup":`listbox`,"aria-controls":G(a.optionsRef)?.id,"aria-expanded":a.listboxState.value===0,"aria-labelledby":a.labelRef.value?[G(a.labelRef)?.id,i].join(` `):void 0,disabled:a.disabled.value===!0?!0:void 0,onKeydown:o,onKeyup:s,onClick:c},theirProps:u,slot:r,attrs:t,slots:n,name:`ListboxButton`})}}}),Moe=N({name:`ListboxOptions`,props:{as:{type:[Object,String],default:`ul`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-listbox-options-${Ao()}`,a=lc(`ListboxOptions`),o=A(null);r({el:a.optionsRef,$el:a.optionsRef});function s(e){switch(o.value&&clearTimeout(o.value),e.key){case xs.Space:if(a.searchQuery.value!==``)return e.preventDefault(),e.stopPropagation(),a.search(e.key);case xs.Enter:if(e.preventDefault(),e.stopPropagation(),a.activeOptionIndex.value!==null){let e=a.options.value[a.activeOptionIndex.value];a.select(e.dataRef.value)}a.mode.value===0&&(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0})));break;case No(a.orientation.value,{vertical:xs.ArrowDown,horizontal:xs.ArrowRight}):return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Next);case No(a.orientation.value,{vertical:xs.ArrowUp,horizontal:xs.ArrowLeft}):return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Previous);case xs.Home:case xs.PageUp:return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.First);case xs.End:case xs.PageDown:return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Last);case xs.Escape:e.preventDefault(),e.stopPropagation(),a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}));break;case xs.Tab:e.preventDefault(),e.stopPropagation();break;default:e.key.length===1&&(a.search(e.key),o.value=setTimeout(()=>a.clearSearch(),350));break}}let c=gs(),l=U(()=>c===null?a.listboxState.value===0:(c.value&ys.Open)===ys.Open);return()=>{var r;let o={open:a.listboxState.value===0},{...c}=e;return os({ourProps:{"aria-activedescendant":a.activeOptionIndex.value===null||(r=a.options.value[a.activeOptionIndex.value])==null?void 0:r.id,"aria-multiselectable":a.mode.value===1?!0:void 0,"aria-labelledby":G(a.buttonRef)?.id,"aria-orientation":a.orientation.value,id:i,onKeydown:s,role:`listbox`,tabIndex:0,ref:a.optionsRef},theirProps:c,slot:o,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:l.value,name:`ListboxOptions`})}}}),Noe=N({name:`ListboxOption`,props:{as:{type:[Object,String],default:`li`},value:{type:[Object,String,Number,Boolean]},disabled:{type:Boolean,default:!1},id:{type:String,default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=e.id??`headlessui-listbox-option-${Ao()}`,a=lc(`ListboxOption`),o=A(null);r({el:o,$el:o});let s=U(()=>a.activeOptionIndex.value===null?!1:a.options.value[a.activeOptionIndex.value].id===i),c=U(()=>No(a.mode.value,{0:()=>a.compare(bn(a.value.value),bn(e.value)),1:()=>bn(a.value.value).some(t=>a.compare(bn(t),bn(e.value)))})),l=U(()=>No(a.mode.value,{1:()=>{let e=bn(a.value.value);return a.options.value.find(t=>e.some(e=>a.compare(bn(e),bn(t.dataRef.value))))?.id===i},0:()=>c.value})),u=Soe(o),d=U(()=>({disabled:e.disabled,value:e.value,get textValue(){return u()},domRef:o}));Hi(()=>a.registerOption(i,d)),Gi(()=>a.unregisterOption(i)),Hi(()=>{mr([a.listboxState,c],()=>{a.listboxState.value===0&&c.value&&No(a.mode.value,{1:()=>{l.value&&a.goToOption(Es.Specific,i)},0:()=>{a.goToOption(Es.Specific,i)}})},{immediate:!0})}),pr(()=>{a.listboxState.value===0&&s.value&&a.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})})});function f(t){if(e.disabled)return t.preventDefault();a.select(e.value),a.mode.value===0&&(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0})))}function p(){if(e.disabled)return a.goToOption(Es.Nothing);a.goToOption(Es.Specific,i)}let m=ns();function h(e){m.update(e)}function g(t){m.wasMoved(t)&&(e.disabled||s.value||a.goToOption(Es.Specific,i,0))}function _(t){m.wasMoved(t)&&(e.disabled||s.value&&a.goToOption(Es.Nothing))}return()=>{let{disabled:r}=e,a={active:s.value,selected:c.value,disabled:r},{value:l,disabled:u,...d}=e;return os({ourProps:{id:i,ref:o,role:`option`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,"aria-selected":c.value,disabled:void 0,onClick:f,onFocus:p,onPointerenter:h,onMouseenter:h,onPointermove:g,onMousemove:g,onPointerleave:_,onMouseleave:_},theirProps:d,slot:a,attrs:n,slots:t,name:`ListboxOption`})}}})}));function Foe(e){requestAnimationFrame(()=>requestAnimationFrame(e))}function dc(e){let t=fr(fc,null);if(t===null){let t=Error(`<${e} /> is missing a parent
component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,dc),t}return t}var Ioe,Loe,fc,Roe,zoe,Boe,Voe,Hoe=s((()=>{W(),jo(),$o(),ts(),Coe(),rs(),as(),bs(),Ss(),Ds(),Mo(),Yo(),Po(),fs(),Ioe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Ioe||{}),Loe=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Other=1]=`Other`,e))(Loe||{}),fc=Symbol(`MenuContext`),Roe=N({name:`Menu`,props:{as:{type:[Object,String],default:`template`}},setup(e,{slots:t,attrs:n}){let r=A(1),i=A(null),a=A(null),o=A([]),s=A(``),c=A(null),l=A(1);function u(e=e=>e){let t=c.value===null?null:o.value[c.value],n=Uo(e(o.value.slice()),e=>G(e.dataRef.domRef)),r=t?n.indexOf(t):null;return r===-1&&(r=null),{items:n,activeItemIndex:r}}let d={menuState:r,buttonRef:i,itemsRef:a,items:o,searchQuery:s,activeItemIndex:c,activationTrigger:l,closeMenu:()=>{r.value=1,c.value=null},openMenu:()=>r.value=0,goToItem(e,t,n){let r=u(),i=Ts(e===Es.Specific?{focus:Es.Specific,id:t}:{focus:e},{resolveItems:()=>r.items,resolveActiveIndex:()=>r.activeItemIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled});s.value=``,c.value=i,l.value=n??1,o.value=r.items},search(e){let t=s.value===``?1:0;s.value+=e.toLowerCase();let n=(c.value===null?o.value:o.value.slice(c.value+t).concat(o.value.slice(0,c.value+t))).find(e=>e.dataRef.textValue.startsWith(s.value)&&!e.dataRef.disabled),r=n?o.value.indexOf(n):-1;r===-1||r===c.value||(c.value=r,l.value=1)},clearSearch(){s.value=``},registerItem(e,t){let n=u(n=>[...n,{id:e,dataRef:t}]);o.value=n.items,c.value=n.activeItemIndex,l.value=1},unregisterItem(e){let t=u(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});o.value=t.items,c.value=t.activeItemIndex,l.value=1}};return Qo([i,a],(e,t)=>{var n;d.closeMenu(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(i))==null||n.focus())},U(()=>r.value===0)),dr(fc,d),_s(U(()=>No(r.value,{0:ys.Open,1:ys.Closed}))),()=>os({ourProps:{},theirProps:e,slot:{open:r.value===0,close:d.closeMenu},slots:t,attrs:n,name:`Menu`})}}),zoe=N({name:`MenuButton`,props:{disabled:{type:Boolean,default:!1},as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-menu-button-${Ao()}`,a=dc(`MenuButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){switch(e.key){case xs.Space:case xs.Enter:case xs.ArrowDown:e.preventDefault(),e.stopPropagation(),a.openMenu(),ar(()=>{var e;(e=G(a.itemsRef))==null||e.focus({preventScroll:!0}),a.goToItem(Es.First)});break;case xs.ArrowUp:e.preventDefault(),e.stopPropagation(),a.openMenu(),ar(()=>{var e;(e=G(a.itemsRef))==null||e.focus({preventScroll:!0}),a.goToItem(Es.Last)});break}}function s(e){switch(e.key){case xs.Space:e.preventDefault();break}}function c(t){e.disabled||(a.menuState.value===0?(a.closeMenu(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}))):(t.preventDefault(),a.openMenu(),Foe(()=>G(a.itemsRef)?.focus({preventScroll:!0}))))}let l=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.menuState.value===0},{...u}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:l.value,"aria-haspopup":`menu`,"aria-controls":G(a.itemsRef)?.id,"aria-expanded":a.menuState.value===0,onKeydown:o,onKeyup:s,onClick:c},theirProps:u,slot:r,attrs:t,slots:n,name:`MenuButton`})}}}),Boe=N({name:`MenuItems`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-menu-items-${Ao()}`,a=dc(`MenuItems`),o=A(null);r({el:a.itemsRef,$el:a.itemsRef}),is({container:U(()=>G(a.itemsRef)),enabled:U(()=>a.menuState.value===0),accept(e){return e.getAttribute(`role`)===`menuitem`?NodeFilter.FILTER_REJECT:e.hasAttribute(`role`)?NodeFilter.FILTER_SKIP:NodeFilter.FILTER_ACCEPT},walk(e){e.setAttribute(`role`,`none`)}});function s(e){var t;switch(o.value&&clearTimeout(o.value),e.key){case xs.Space:if(a.searchQuery.value!==``)return e.preventDefault(),e.stopPropagation(),a.search(e.key);case xs.Enter:if(e.preventDefault(),e.stopPropagation(),a.activeItemIndex.value!==null){let e=a.items.value[a.activeItemIndex.value];(t=G(e.dataRef.domRef))==null||t.click()}a.closeMenu(),Jie(G(a.buttonRef));break;case xs.ArrowDown:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Next);case xs.ArrowUp:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Previous);case xs.Home:case xs.PageUp:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.First);case xs.End:case xs.PageDown:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Last);case xs.Escape:e.preventDefault(),e.stopPropagation(),a.closeMenu(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}));break;case xs.Tab:e.preventDefault(),e.stopPropagation(),a.closeMenu(),ar(()=>Xie(G(a.buttonRef),e.shiftKey?Ko.Previous:Ko.Next));break;default:e.key.length===1&&(a.search(e.key),o.value=setTimeout(()=>a.clearSearch(),350));break}}function c(e){switch(e.key){case xs.Space:e.preventDefault();break}}let l=gs(),u=U(()=>l===null?a.menuState.value===0:(l.value&ys.Open)===ys.Open);return()=>{var r;let o={open:a.menuState.value===0},{...l}=e;return os({ourProps:{"aria-activedescendant":a.activeItemIndex.value===null||(r=a.items.value[a.activeItemIndex.value])==null?void 0:r.id,"aria-labelledby":G(a.buttonRef)?.id,id:i,onKeydown:s,onKeyup:c,role:`menu`,tabIndex:0,ref:a.itemsRef},theirProps:l,slot:o,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:u.value,name:`MenuItems`})}}}),Voe=N({name:`MenuItem`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`template`},disabled:{type:Boolean,default:!1},id:{type:String,default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=e.id??`headlessui-menu-item-${Ao()}`,a=dc(`MenuItem`),o=A(null);r({el:o,$el:o});let s=U(()=>a.activeItemIndex.value===null?!1:a.items.value[a.activeItemIndex.value].id===i),c=Soe(o),l=U(()=>({disabled:e.disabled,get textValue(){return c()},domRef:o}));Hi(()=>a.registerItem(i,l)),Gi(()=>a.unregisterItem(i)),pr(()=>{a.menuState.value===0&&s.value&&a.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})})});function u(t){if(e.disabled)return t.preventDefault();a.closeMenu(),Jie(G(a.buttonRef))}function d(){if(e.disabled)return a.goToItem(Es.Nothing);a.goToItem(Es.Specific,i)}let f=ns();function p(e){f.update(e)}function m(t){f.wasMoved(t)&&(e.disabled||s.value||a.goToItem(Es.Specific,i,0))}function h(t){f.wasMoved(t)&&(e.disabled||s.value&&a.goToItem(Es.Nothing))}return()=>{let{disabled:r,...c}=e,l={active:s.value,disabled:r,close:a.closeMenu};return os({ourProps:{id:i,ref:o,role:`menuitem`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,onClick:u,onFocus:d,onPointerenter:p,onMouseenter:p,onPointermove:m,onMousemove:m,onPointerleave:h,onMouseleave:h},theirProps:{...n,...c},slot:l,attrs:n,slots:t,name:`MenuItem`})}}})}));function pc(e){let t=fr(mc,null);if(t===null){let t=Error(`<${e} /> is missing a parent <${_c.name} /> component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,pc),t}return t}function Uoe(){return fr(hc,null)}function Woe(){return fr(gc,null)}var Goe,mc,hc,gc,_c,vc,yc,Koe=s((()=>{W(),uoe(),Ps(),jo(),$o(),ts(),Qae(),Eae(),hs(),bs(),Ss(),Mo(),Yo(),Po(),zo(),fs(),Goe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Goe||{}),mc=Symbol(`PopoverContext`),hc=Symbol(`PopoverGroupContext`),gc=Symbol(`PopoverPanelContext`),_c=N({name:`Popover`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`}},setup(e,{slots:t,attrs:n,expose:r}){let i=A(null);r({el:i,$el:i});let a=A(1),o=A(null),s=A(null),c=A(null),l=A(null),u=U(()=>Ro(i)),d=U(()=>{var e,t;if(!G(o)||!G(l))return!1;for(let e of document.querySelectorAll(`body > *`))if(Number(e?.contains(G(o)))^Number(e?.contains(G(l))))return!0;let n=Bo(),r=n.indexOf(G(o)),i=(r+n.length-1)%n.length,a=(r+1)%n.length,s=n[i],c=n[a];return!((e=G(l))!=null&&e.contains(s))&&!((t=G(l))!=null&&t.contains(c))}),f={popoverState:a,buttonId:A(null),panelId:A(null),panel:l,button:o,isPortalled:d,beforePanelSentinel:s,afterPanelSentinel:c,togglePopover(){a.value=No(a.value,{0:1,1:0})},closePopover(){a.value!==1&&(a.value=1)},close(e){f.closePopover(),(e?e instanceof HTMLElement?e:e.value instanceof HTMLElement?G(e):G(f.button):G(f.button))?.focus()}};dr(mc,f),_s(U(()=>No(a.value,{0:ys.Open,1:ys.Closed})));let p={buttonId:f.buttonId,panelId:f.panelId,close(){f.closePopover()}},m=Uoe(),h=m?.registerPopover,[g,_]=coe(),v=Xae({mainTreeNodeRef:m?.mainTreeNodeRef,portals:g,defaultContainers:[o,l]});function y(){return m?.isFocusWithinPopoverGroup()??(u.value?.activeElement&&(G(o)?.contains(u.value.activeElement)||G(l)?.contains(u.value.activeElement)))}return pr(()=>h?.(p)),Ns(u.value?.defaultView,`focus`,e=>{var t,n;e.target!==window&&e.target instanceof HTMLElement&&a.value===0&&(y()||o&&l&&(v.contains(e.target)||(t=G(f.beforePanelSentinel))!=null&&t.contains(e.target)||(n=G(f.afterPanelSentinel))!=null&&n.contains(e.target)||f.closePopover()))},!0),Qo(v.resolveContainers,(e,t)=>{var n;f.closePopover(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(o))==null||n.focus())},U(()=>a.value===0)),()=>{let r={open:a.value===0,close:f.close};return ci(V,[ci(_,{},()=>os({theirProps:{...e,...n},ourProps:{ref:i},slot:r,slots:t,attrs:n,name:`Popover`})),ci(v.MainTreeNode)])}}}),vc=N({name:`PopoverButton`,props:{as:{type:[Object,String],default:`button`},disabled:{type:[Boolean],default:!1},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-popover-button-${Ao()}`,a=pc(`PopoverButton`),o=U(()=>Ro(a.button));r({el:a.button,$el:a.button}),Hi(()=>{a.buttonId.value=i}),Gi(()=>{a.buttonId.value=null});let s=Uoe()?.closeOthers,c=Woe(),l=U(()=>c===null?!1:c.value===a.panelId.value),u=A(null),d=`headlessui-focus-sentinel-${Ao()}`;l.value||pr(()=>{a.button.value=G(u)});let f=es(U(()=>({as:e.as,type:t.type})),u);function p(e){var t,n,r,i,c;if(l.value){if(a.popoverState.value===1)return;switch(e.key){case xs.Space:case xs.Enter:e.preventDefault(),(n=(t=e.target).click)==null||n.call(t),a.closePopover(),(r=G(a.button))==null||r.focus();break}}else switch(e.key){case xs.Space:case xs.Enter:e.preventDefault(),e.stopPropagation(),a.popoverState.value===1&&s?.(a.buttonId.value),a.togglePopover();break;case xs.Escape:if(a.popoverState.value!==0)return s?.(a.buttonId.value);if(!G(a.button)||(i=o.value)!=null&&i.activeElement&&!((c=G(a.button))!=null&&c.contains(o.value.activeElement)))return;e.preventDefault(),e.stopPropagation(),a.closePopover();break}}function m(e){l.value||e.key===xs.Space&&e.preventDefault()}function h(t){var n,r;e.disabled||(l.value?(a.closePopover(),(n=G(a.button))==null||n.focus()):(t.preventDefault(),t.stopPropagation(),a.popoverState.value===1&&s?.(a.buttonId.value),a.togglePopover(),(r=G(a.button))==null||r.focus()))}function g(e){e.preventDefault(),e.stopPropagation()}let _=Fs();function v(){let e=G(a.panel);if(!e)return;function t(){No(_.value,{[Is.Forwards]:()=>Wo(e,Ko.First),[Is.Backwards]:()=>Wo(e,Ko.Last)})===qo.Error&&Wo(Bo().filter(e=>e.dataset.headlessuiFocusGuard!==`true`),No(_.value,{[Is.Forwards]:Ko.Next,[Is.Backwards]:Ko.Previous}),{relativeTo:G(a.button)})}t()}return()=>{let r=a.popoverState.value===0,o={open:r},{...s}=e;return ci(V,[os({ourProps:l.value?{ref:u,type:f.value,onKeydown:p,onClick:h}:{ref:u,id:i,type:f.value,"aria-expanded":a.popoverState.value===0,"aria-controls":G(a.panel)?a.panelId.value:void 0,disabled:e.disabled?!0:void 0,onKeydown:p,onKeyup:m,onClick:h,onMousedown:g},theirProps:{...t,...s},slot:o,attrs:t,slots:n,name:`PopoverButton`}),r&&!l.value&&a.isPortalled.value&&ci(ms,{id:d,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:v})])}}}),N({name:`PopoverOverlay`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0}},setup(e,{attrs:t,slots:n}){let r=pc(`PopoverOverlay`),i=`headlessui-popover-overlay-${Ao()}`,a=gs(),o=U(()=>a===null?r.popoverState.value===0:(a.value&ys.Open)===ys.Open);function s(){r.closePopover()}return()=>{let a={open:r.popoverState.value===0};return os({ourProps:{id:i,"aria-hidden":!0,onClick:s},theirProps:e,slot:a,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:o.value,name:`PopoverOverlay`})}}}),yc=N({name:`PopoverPanel`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},focus:{type:Boolean,default:!1},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-popover-panel-${Ao()}`,{focus:a}=e,o=pc(`PopoverPanel`),s=U(()=>Ro(o.panel)),c=`headlessui-focus-sentinel-before-${Ao()}`,l=`headlessui-focus-sentinel-after-${Ao()}`;r({el:o.panel,$el:o.panel}),Hi(()=>{o.panelId.value=i}),Gi(()=>{o.panelId.value=null}),dr(gc,o.panelId),pr(()=>{var e;if(!a||o.popoverState.value!==0||!o.panel)return;let t=s.value?.activeElement;(e=G(o.panel))!=null&&e.contains(t)||Wo(G(o.panel),Ko.First)});let u=gs(),d=U(()=>u===null?o.popoverState.value===0:(u.value&ys.Open)===ys.Open);function f(e){var t,n;switch(e.key){case xs.Escape:if(o.popoverState.value!==0||!G(o.panel)||s.value&&!((t=G(o.panel))!=null&&t.contains(s.value.activeElement)))return;e.preventDefault(),e.stopPropagation(),o.closePopover(),(n=G(o.button))==null||n.focus();break}}function p(e){var t,n,r,i,a;let s=e.relatedTarget;s&&G(o.panel)&&((t=G(o.panel))!=null&&t.contains(s)||(o.closePopover(),((r=(n=G(o.beforePanelSentinel))?.contains)!=null&&r.call(n,s)||(a=(i=G(o.afterPanelSentinel))?.contains)!=null&&a.call(i,s))&&s.focus({preventScroll:!0})))}let m=Fs();function h(){let e=G(o.panel);if(!e)return;function t(){No(m.value,{[Is.Forwards]:()=>{var t;Wo(e,Ko.First)===qo.Error&&((t=G(o.afterPanelSentinel))==null||t.focus())},[Is.Backwards]:()=>{var e;(e=G(o.button))==null||e.focus({preventScroll:!0})}})}t()}function g(){let e=G(o.panel);if(!e)return;function t(){No(m.value,{[Is.Forwards]:()=>{let e=G(o.button),t=G(o.panel);if(!e)return;let n=Bo(),r=n.indexOf(e),i=n.slice(0,r+1),a=[...n.slice(r+1),...i];for(let e of a.slice())if(e.dataset.headlessuiFocusGuard===`true`||t!=null&&t.contains(e)){let t=a.indexOf(e);t!==-1&&a.splice(t,1)}Wo(a,Ko.First,{sorted:!1})},[Is.Backwards]:()=>{var t;Wo(e,Ko.Previous)===qo.Error&&((t=G(o.button))==null||t.focus())}})}t()}return()=>{let r={open:o.popoverState.value===0,close:o.close},{focus:s,...u}=e;return os({ourProps:{ref:o.panel,id:i,onKeydown:f,onFocusout:a&&o.popoverState.value===0?p:void 0,tabIndex:-1},theirProps:{...t,...u},attrs:t,slot:r,slots:{...n,default:(...e)=>[ci(V,[d.value&&o.isPortalled.value&&ci(ms,{id:c,ref:o.beforePanelSentinel,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:h}),n.default?.call(n,...e),d.value&&o.isPortalled.value&&ci(ms,{id:l,ref:o.afterPanelSentinel,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:g})])]},features:us.RenderStrategy|us.Static,visible:d.value,name:`PopoverPanel`})}}}),N({name:`PopoverGroup`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`}},setup(e,{attrs:t,slots:n,expose:r}){let i=A(null),a=Sn([]),o=U(()=>Ro(i)),s=Zae();r({el:i,$el:i});function c(e){let t=a.value.indexOf(e);t!==-1&&a.value.splice(t,1)}function l(e){return a.value.push(e),()=>{c(e)}}function u(){var e;let t=o.value;if(!t)return!1;let n=t.activeElement;return(e=G(i))!=null&&e.contains(n)?!0:a.value.some(e=>t.getElementById(e.buttonId.value)?.contains(n)||t.getElementById(e.panelId.value)?.contains(n))}function d(e){for(let t of a.value)t.buttonId.value!==e&&t.close()}return dr(hc,{registerPopover:l,unregisterPopover:c,isFocusWithinPopoverGroup:u,closeOthers:d,mainTreeNodeRef:s.mainTreeNodeRef}),()=>ci(V,[os({ourProps:{ref:i},theirProps:{...e,...t},slot:{},attrs:t,slots:n,name:`PopoverGroup`}),ci(s.MainTreeNode)])}})}));function qoe(){let e=fr(xc,null);if(e===null){let e=Error(`You used a