From 84942c09eac29a3d2c498a6f4147828339aaed81 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 03:42:40 +0700 Subject: [PATCH 1/6] feat(demo): add OpenCRVS v2 interoperability proof Signed-off-by: Jeremi Joslin --- .gitignore | 2 + README.md | 23 + demos/opencrvs-v2/README.md | 314 ++++ demos/opencrvs-v2/build-candidate.sh | 108 ++ demos/opencrvs-v2/compose.yaml | 179 ++ .../invalid-registration-number.yaml | 11 + demos/opencrvs-v2/postgres-init.sh | 115 ++ .../project/environments/local.yaml | 58 + .../integrations/birth-record/adapter.rhai | 65 + .../birth-record/fixtures/ambiguous.yaml | 43 + .../fixtures/malformed-response.yaml | 34 + .../birth-record/fixtures/match.yaml | 80 + .../birth-record/fixtures/no-match.yaml | 42 + .../fixtures/source-rejected.yaml | 34 + .../fixtures/subject-mismatch.yaml | 42 + .../fixtures/tracking-mismatch.yaml | 42 + .../fixtures/unknown-oversized-fields.yaml | 67 + .../birth-record/integration.yaml | 52 + demos/opencrvs-v2/project/registry-stack.yaml | 52 + demos/opencrvs-v2/runner.py | 1575 +++++++++++++++++ .../opencrvs-v2/tests/test_candidate_build.py | 34 + demos/opencrvs-v2/tests/test_runner.py | 571 ++++++ docs/changelog.md | 33 + justfile | 27 + scripts/check-config-secrets.py | 10 +- scripts/check-fiction.sh | 2 + 26 files changed, 3614 insertions(+), 1 deletion(-) create mode 100644 demos/opencrvs-v2/README.md create mode 100755 demos/opencrvs-v2/build-candidate.sh create mode 100644 demos/opencrvs-v2/compose.yaml create mode 100644 demos/opencrvs-v2/negative-fixtures/invalid-registration-number.yaml create mode 100755 demos/opencrvs-v2/postgres-init.sh create mode 100644 demos/opencrvs-v2/project/environments/local.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/adapter.rhai create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml create mode 100644 demos/opencrvs-v2/project/integrations/birth-record/integration.yaml create mode 100644 demos/opencrvs-v2/project/registry-stack.yaml create mode 100755 demos/opencrvs-v2/runner.py create mode 100644 demos/opencrvs-v2/tests/test_candidate_build.py create mode 100644 demos/opencrvs-v2/tests/test_runner.py diff --git a/.gitignore b/.gitignore index 264ab1e..0798621 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ __pycache__/ dist/ .cache/ projects/*/.registry-stack/ +demos/opencrvs-v2/project/.registry-stack/ +demos/opencrvs-v2/.runtime/ *.log config/postgres/ssl/* !config/postgres/ssl/.gitkeep diff --git a/README.md b/README.md index 5ad6d1d..c9730cd 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ The first wave covers three journeys: - `portal/` contains the citizen portal and BFF. - `scenarios/`, `requests/`, and `perf/` carry guided scenarios, API examples, and k6 smoke coverage. +- `demos/opencrvs-v2/` contains an optional, isolated OpenCRVS interoperability + demo. Its released live path is blocked; paired candidate use is + development-only. It is not part of the six-authority topology. - `scripts/` contains root quality gates and orchestration helpers. ## Development Commands @@ -95,11 +98,31 @@ just registry-projects-review # complete redacted acquisition and disclosure rep just registry-projects-capabilities # value-free installed/used/missing capability inventory just registry-projects-editor # version-matched VS Code and Zed schemas for all projects just contract-generation-proof # release-only live SRO blue/mixed/successor proof +just opencrvs-demo-test # optional OpenCRVS fixture and compiler proof +just opencrvs-demo-candidate-build # build matched pre-release compiler and Relay +just opencrvs-demo-compose # validate the isolated demo topology +just opencrvs-demo-up # start with compatible release or paired dev artifacts +just opencrvs-demo-proof # live proof with compatible release or paired dev artifacts +just opencrvs-demo-down # remove its containers, volumes, and runtime closure just release-pins # compare committed versions.env pins against a candidate or release tag just review # normal security and release-readiness checks just review-release # candidate review with published pin validation ``` +The OpenCRVS demo's offline compiler, fixture, Compose, and paired pre-release +live checks pass. Its released live path remains blocked. Relay v0.15.2 has the +strict no-expiry OAuth decoder, yet its durable state plane rejects this +no-cache script plan before source dispatch. Registryctl v0.15.2 also cannot +author the profile. One exact pending Registry Stack commit enables offline +compiler development only. Pre-release live development requires Registryctl +and a labeled Relay image built from one later exact candidate commit +containing the authoring, state-plane, and active script-budget fixes. Do not +deploy the demo until a Registry Stack release contains those fixes and all +coordinated pins in `versions.env` have passed release review. The safe paired +override, exact native endpoints, cleanup sequence, privacy boundary, and +direct machine issuance limits are documented in +[`demos/opencrvs-v2/README.md`](demos/opencrvs-v2/README.md). + Normal startup pulls the immutable canonical Relay image pinned in `versions.env`; it does not clone or compile Registry Stack. Governed attribute release is part of the canonical Registry Stack v0.15.2 Relay build. The diff --git a/demos/opencrvs-v2/README.md b/demos/opencrvs-v2/README.md new file mode 100644 index 0000000..3390186 --- /dev/null +++ b/demos/opencrvs-v2/README.md @@ -0,0 +1,314 @@ +# OpenCRVS v2 interoperability demo + +This optional, isolated demo is designed to prove a narrow interoperability +path from a live OpenCRVS deployment through Registry Relay and Registry +Notary. It does not add an authority to the normal Solmara topology and it is +not started by `just up`. + +The offline fixtures and compiler checks work today with the exact development +Registryctl override below, and Compose validation works without live inputs. +The live proof does not complete on the released Registry Stack v0.15.2 +runtime, even with that compiler. The paired pre-release candidate path below +has completed the full live proof. Deployment still requires a later Registry +Stack release containing the same fixes. + +The intended live path is: + +1. Relay sends form-encoded client credentials to + `POST https://auth./token` with + `grant_type=client_credentials`, `client_id`, and `client_secret`. +2. A bounded Rhai adapter makes one exact + `POST https://gateway./events/events/search` request. + It reads the flattened OpenCRVS v2 declaration keys such as `child.nid`, + `mother.name`, and `informant.relation`. +3. Relay emits six booleans and no identifying source values. +4. Notary evaluates five predicate claims. +5. Notary issues a holder-bound `dc+sd-jwt` through `POST /v1/credentials`. +6. The runner verifies the issuer signature, disclosures, and ephemeral + `did:jwk` holder binding in memory. + +## Release boundary + +OpenCRVS returns exactly `access_token` and `token_type` from its token endpoint, +without the otherwise common `expires_in` member. The two Registry Stack +surfaces are at different release stages: + +- The released v0.15.2 Relay has the strict no-expiry OAuth decoder, but its + durable completion-seed path treats the absent token lifetime as a different + credential mode. It rejects this script plan before OpenCRVS dispatch, so + v0.15.2 cannot complete the live proof. +- The released Registryctl v0.15.2 cannot author that contract. +- Registry Stack commit + [`d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e`](https://github.com/registrystack/registry-stack/commit/d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e) + adds the pending Registryctl authoring support only. It is not a release and + does not include the Relay state-plane fix. + +The pending Registryctl change adds this explicit project setting: + +```yaml +response_profile: oauth2_bearer_no_expiry +``` + +That profile accepts only HTTP 200 with a JSON object containing exactly +`access_token` and case-correct `token_type: Bearer`. It rejects `expires_in` +and every other extra response member. Token caching is disabled: Relay +acquires a token for the current bounded consultation and does not retain it +for another consultation. Registryctl does not infer freshness from unsigned +JWT claims, and the existing expiry-based profile is unchanged. + +Until the next Registry Stack release is pinned in `versions.env`, offline +authoring development must use a Registryctl binary built from that exact +commit in a dedicated clean Registry Stack worktree: + +```sh +registry_stack_checkout=/absolute/path/to/registry-stack-oauth-no-expiry +git clone https://github.com/registrystack/registry-stack.git \ + "$registry_stack_checkout" +git -C "$registry_stack_checkout" switch --detach \ + d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e +cargo build --locked --manifest-path "$registry_stack_checkout/Cargo.toml" \ + -p registryctl --bin registryctl + +export OPENCRVS_DEMO_REGISTRYCTL="$registry_stack_checkout/target/debug/registryctl" +export OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT=d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e +``` + +The runner rejects an override outside its Registry Stack worktree, a dirty +tracked source tree, or a checkout at any other commit. Sanitized evidence +records both the declared source commit and the compiler executable SHA-256. +Do not change `versions.env` to represent this development build as a release. +This compiler-only override enables fixture and authoring work. Commit +`d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e` does not make the v0.15.2 Relay +capable of completing the live consultation. + +### Optional pre-release live proof + +A pre-release live proof requires a later exact Registry Stack candidate commit +that contains the Registryctl change above, the Relay no-cache state-plane +handling, and active script-budget accounting that excludes bounded +Relay-owned source waits. Build Registryctl and the Relay image from the same +clean source checkout. Do not pair the compiler-only commit above with a Relay +image from another commit. + +From the Solmara repository root, after setting `candidate_commit` to that +reviewed 40-character commit: + +```sh +registry_stack_checkout=/absolute/path/to/registry-stack-candidate +candidate_commit=REPLACE_WITH_REVIEWED_40_CHARACTER_COMMIT +git -C "$registry_stack_checkout" fetch origin +git -C "$registry_stack_checkout" switch --detach "$candidate_commit" +just opencrvs-demo-candidate-build "$registry_stack_checkout" +``` + +The candidate builder verifies the clean checkout, builds Registryctl and a +labeled Relay image for the Docker server's native `amd64` or `arm64` +architecture from its exact `HEAD`, then prints the binary path, image tag, +commit, platform, and five required `export` commands. Copy those final five +export lines into the shell that will run the demo. Do not evaluate the entire +build output as shell code. The host-native Relay image is deliberate. It lets +the one-shot Rhai worker retain its 128 MiB sandbox on Apple Silicon instead of +placing that sandbox around Rosetta. The published Notary image remains on the +release platform configured by `REGISTRY_STACK_PLATFORM`. + +Before starting live services, the runner requires the compiler checkout to be +clean at that commit and requires both declared source commits to match. It +also inspects the Relay image for the matching +`org.opencontainers.image.revision` label and the exact +`attribute-release,crosswalk-runtime` feature label. Sanitized evidence records +the compiler executable SHA-256 and the inspected Relay image ID. These paired +overrides are for local development proof only. Do not deploy the candidate or +replace release pins with its tag. + +Once a release contains the Registryctl profile, Relay's explicit no-cache +state-plane handling, and the active script-budget correction, update the +coordinated Registryctl, source, Relay, and Notary pins through the normal +release-adoption process. Then unset all five development override variables +and run: + +```sh +just release-pins vX.Y.Z +just opencrvs-demo-test +just review-release vX.Y.Z +``` + +Run the live proof with those released pins before deployment. There is +currently no hosted Compose file or deployment recipe for this demo, so the +local proof must not be presented as hosted deployment evidence. + +## Operator input + +The runner reads the operator-owned file +`registry-internal/.env.opencrvs` without modifying it. Override its location +with `OPENCRVS_DEMO_ENV_FILE` when the repositories are not sibling checkouts. +The file must define: + +- `OPENCRVS_CLIENT_ID` +- `OPENCRVS_SECRET` +- `OPENCRVS_URL` + +Provide these selectors either in the process environment or in that external +file: + +- `OPENCRVS_DEMO_REGISTRATION_NUMBER` +- `OPENCRVS_DEMO_CHILD_NATIONAL_ID` +- `OPENCRVS_DEMO_TRACKING_ID` + +Explicit selector variables take precedence over the runner's legacy fallback. +Their values are never copied into committed files or sanitized evidence. + +`OPENCRVS_URL` must be a lowercase, path-free HTTPS DNS host. The runner writes +only its derived `auth.` and `gateway.` origins into +`demos/opencrvs-v2/.runtime/`, which is ignored and removed by the down command. +Client credentials remain environment references throughout. Do not copy the +operator file, its values, live origins, or generated runtime closure into +tracked files or a support report. + +## Commands + +The checks available without a live runtime are: + +```sh +just opencrvs-demo-test +just opencrvs-demo-compose +``` + +With all five matching development overrides above set, or after a compatible +Registry Stack release is pinned and those overrides are unset, run the live +path from the repository root with cleanup guaranteed: + +```sh +( + trap 'just opencrvs-demo-down' EXIT + just opencrvs-demo-up + just opencrvs-demo-proof +) +``` + +`opencrvs-demo-test` runs the runner unit tests plus Registryctl fixture, +compiler, and build checks without reading live OpenCRVS inputs. +`opencrvs-demo-compose` validates Compose without starting services or reading +operator secrets. On the released v0.15.2 runtime alone, do not treat a +successful offline or Compose check as a live interoperability result. +`opencrvs-demo-up` requires either the five matching candidate overrides or +compatible release pins, plus Docker, OpenSSL, the external operator inputs, +and network access to the derived OpenCRVS hosts. It creates disposable local +keys, TLS material, PostgreSQL state, and the ignored compiled closure, then +starts only the demo services. `opencrvs-demo-proof` requires that topology to +be running. It performs three pre-dispatch negative controls, one exact +known-record search, one exact syntactically valid no-match search, and direct +credential issuance. + +The subshell trap runs `opencrvs-demo-down` after success or failure. That +command removes the demo containers, volumes, and ignored runtime closure, and +is safe to rerun. + +## Evidence and privacy + +Successful proof writes only +`output/opencrvs-v2-demo/evidence.json`. The output directory is ignored. +Evidence includes artifact hashes, image digests, bounded dispatch counts, +predicate results, credential metadata, cryptographic verification booleans, +and the authoring compiler identity. It reports public authored bounds +separately from effective private runtime limits, and requires exactly one +credential dispatch plus one source dispatch for each live consultation. A +development proof records the exact shared source commit, compiler executable +SHA-256, and inspected Relay image ID. + +The runner fails before writing evidence if its scan finds any of the following +in compiled configuration, existing evidence, container logs, or the pending +evidence object: + +- OpenCRVS client ID or secret +- OAuth access token +- raw credential +- registration, national ID, or tracking selectors +- the known child name when present in the operator file +- another bearer-shaped token + +The raw OpenCRVS response, holder private key, OAuth token, and issued credential +remain memory-only. The evidence reports issuer, audience, scope, and lifetime +from unsigned token claims and labels that parsing explicitly. It does not +claim those metadata fields were cryptographically verified. + +## Capability boundary + +Implemented and verified offline: + +- exact native request construction in the bounded Rhai adapter +- synthetic match, no-match, ambiguity, and malformed-response fixtures +- the compiled strict, non-caching OAuth response contract +- minimized scalar predicate outputs +- the compiler boundary rejecting structured parent outputs +- runner unit verification of `dc+sd-jwt` signature, disclosures, and holder key +- top-level scalar parent-related predicates + +Verified live with a same-commit compiler and Relay development candidate. +Reproduction requires that paired candidate or the compatible release: + +- OAuth-authenticated native OpenCRVS Events API search through Relay +- Notary evaluation from Relay provenance +- holder-bound `dc+sd-jwt` issuance through `/v1/credentials` +- live pre-dispatch negative controls and sanitized evidence generation + +Not demonstrated by this demo: + +- structured `parents[]` or representative objects in a credential +- proof that the credential holder is the child’s parent or informant +- registrar-initiated OID4VCI pre-authorized offers +- delivery into a parent’s wallet +- OpenCRVS-triggered issuance +- official OpenCRVS compatibility certification + +Issuance is a direct authenticated machine API call to a demo-controlled +ephemeral holder key. Holder binding proves possession of that key only. It is +not an OID4VCI registrar offer, does not deliver a credential to a wallet, and +does not establish that the machine caller or key holder is the child's parent +or informant. The two parent-related scalar predicates report fields in the +source record; they are not relationship proof. + +## Troubleshooting + +- A Registryctl error naming `oauth2_bearer_no_expiry` means the v0.15.2 + compiler is still selected. Build the exact pending commit above and set both + development override variables for offline authoring work, or use the future + release that contains the profile. +- An error about the development Registryctl worktree means the executable is + outside the declared checkout, the checkout has tracked changes, or its + `HEAD` differs from `OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT`. Rebuild from a + dedicated clean checkout. Do not bypass the provenance check. +- An error requiring a development Relay image means the compiler override was + selected without all five paired development variables. If the image is + rejected, confirm its source revision and exact feature labels match the + compiler candidate. Do not pair artifacts from different commits. +- If the candidate builder cannot find Crosswalk, place the `crosswalk` + checkout beside the primary Registry Stack checkout as required by Registry + Stack's source build, then rerun the builder. +- If a candidate image was copied from another machine, rebuild it locally. + The runner rejects a declared Relay platform that differs from the image + architecture. This also avoids Rosetta failing inside the worker's fixed + 128 MiB data limit on Apple Silicon. +- A strict no-expiry OAuth probe failure means the token endpoint did not + return HTTP 200 with exactly the accepted two-member response and + case-correct `Bearer` value. Verify `OPENCRVS_URL`, credentials, and the + native `auth.` endpoint. Do not loosen the profile or infer expiry from the + token. +- A Notary readiness timeout usually means one of the disposable PostgreSQL + bootstrap, workload identity, Relay, or CEL worker services is unhealthy. + Inspect `docker compose` status without printing its environment. +- On v0.15.2, a valid evaluation that is rejected with zero OpenCRVS dispatches + is the known Relay completion-seed blocker. The development compiler override + alone cannot fix it. Use the exact paired development candidate for local + proof, or pin the compatible release. +- A rate-limited second live control means the environment no longer has the + documented effective burst of two. The proof deliberately performs one + known-record consultation and one no-match consultation back to back. +- An evidence-unavailable response with zero data dispatches means the request + was denied before OpenCRVS access. On a later compatible runtime, check + purpose, caller, selector shape, and OAuth response compatibility in that + order. +- If the sanitized-output scan refuses to write evidence, treat that as a + privacy failure. Run the down command, inspect locally without sharing raw + logs, and do not weaken the scan. +- On Apple Silicon, the published amd64 CEL worker needs the bounded 1 GiB local + ceiling already configured for this demo. diff --git a/demos/opencrvs-v2/build-candidate.sh b/demos/opencrvs-v2/build-candidate.sh new file mode 100755 index 0000000..d6dc004 --- /dev/null +++ b/demos/opencrvs-v2/build-candidate.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 1 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +stack_dir="$(cd -- "$1" && pwd -P)" +commit="$(git -C "${stack_dir}" rev-parse HEAD)" +if [[ ! "${commit}" =~ ^[0-9a-f]{40}$ ]]; then + printf 'Registry Stack HEAD must resolve to a lowercase 40-character commit SHA\n' >&2 + exit 1 +fi +if [[ -n "$(git -C "${stack_dir}" status --porcelain)" ]]; then + printf 'Registry Stack candidate worktree must be clean\n' >&2 + exit 1 +fi + +builder_image='rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3' +relay_features="$(<"${stack_dir}/crates/registry-relay/canonical-release-features.txt")" +engine_arch="$(docker version --format '{{.Server.Arch}}')" +case "${engine_arch}" in + amd64 | x86_64) + relay_arch='amd64' + ;; + arm64 | aarch64) + relay_arch='arm64' + ;; + *) + printf 'unsupported Docker server architecture: %s\n' "${engine_arch}" >&2 + exit 1 + ;; +esac +relay_platform="linux/${relay_arch}" +image="registry-relay-opencrvs:${commit}-${relay_arch}" +image_version="0.15.2-opencrvs.${commit:0:12}" +linux_target="/workspace/target/opencrvs-linux-${relay_arch}" + +cargo build --locked --manifest-path "${stack_dir}/Cargo.toml" \ + -p registryctl --bin registryctl + +docker run --rm \ + --platform "${relay_platform}" \ + --user "$(id -u):$(id -g)" \ + --volume "${stack_dir}:/workspace" \ + --workdir /workspace \ + --env CARGO_HOME=/workspace/.cargo-home \ + --env CARGO_INCREMENTAL=0 \ + --env CARGO_TARGET_DIR="${linux_target}" \ + --env HOME=/workspace \ + --env REGISTRY_RELAY_FEATURES="${relay_features}" \ + "${builder_image}" \ + bash -c 'set -euo pipefail + cargo build --release --locked \ + -p registry-relay \ + --no-default-features \ + --features "${REGISTRY_RELAY_FEATURES}" + python3 release/scripts/check-release-relay-features.py \ + "${CARGO_TARGET_DIR}/release/registry-relay" + ' + +mkdir -p "${stack_dir}/dist/image-bin" +install -m 0755 \ + "${stack_dir}/target/opencrvs-linux-${relay_arch}/release/registry-relay" \ + "${stack_dir}/dist/image-bin/registry-relay" +install -m 0755 \ + "${stack_dir}/target/opencrvs-linux-${relay_arch}/release/registry-relay-rhai-worker" \ + "${stack_dir}/dist/image-bin/registry-relay-rhai-worker" + +docker buildx build \ + --load \ + --platform "${relay_platform}" \ + --file "${stack_dir}/release/docker/Dockerfile.registry-relay" \ + --tag "${image}" \ + --label 'org.opencontainers.image.source=https://github.com/registrystack/registry-stack' \ + --label "org.opencontainers.image.revision=${commit}" \ + --label "org.opencontainers.image.version=${image_version}" \ + --label "org.registrystack.registry-relay.features=${relay_features}" \ + --build-arg SOURCE_DATE_EPOCH=0 \ + "${stack_dir}" + +image_architecture="$( + docker image inspect --format '{{.Architecture}}' "${image}" +)" +image_revision="$( + docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + "${image}" +)" +image_features="$( + docker image inspect \ + --format '{{index .Config.Labels "org.registrystack.registry-relay.features"}}' \ + "${image}" +)" +if [[ "${image_architecture}" != "${relay_arch}" || + "${image_revision}" != "${commit}" || + "${image_features}" != "${relay_features}" ]]; then + printf 'candidate Relay image identity check failed\n' >&2 + exit 1 +fi + +printf "export OPENCRVS_DEMO_REGISTRYCTL='%s'\n" \ + "${stack_dir}/target/debug/registryctl" +printf "export OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT='%s'\n" "${commit}" +printf "export OPENCRVS_DEMO_RELAY_IMAGE='%s'\n" "${image}" +printf "export OPENCRVS_DEMO_RELAY_SOURCE_COMMIT='%s'\n" "${commit}" +printf "export OPENCRVS_DEMO_RELAY_PLATFORM='%s'\n" "${relay_platform}" diff --git a/demos/opencrvs-v2/compose.yaml b/demos/opencrvs-v2/compose.yaml new file mode 100644 index 0000000..17fdfd1 --- /dev/null +++ b/demos/opencrvs-v2/compose.yaml @@ -0,0 +1,179 @@ +services: + opencrvs-db: + image: ${POSTGRES_IMAGE:-postgres:16-alpine} + restart: unless-stopped + entrypoint: ["/bin/sh", "/usr/local/bin/opencrvs-postgres-entrypoint.sh"] + command: + - postgres + - -c + - ssl=on + - -c + - ssl_cert_file=/var/lib/postgresql/server-ssl/server.crt + - -c + - ssl_key_file=/var/lib/postgresql/server-ssl/server.key + environment: + POSTGRES_USER: opencrvs_admin + POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-compose-validation-only} + POSTGRES_DB: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U opencrvs_admin -d postgres"] + interval: 2s + timeout: 3s + retries: 30 + volumes: + - opencrvs-db-data:/var/lib/postgresql/data + - ../../scripts/postgres-entrypoint.sh:/usr/local/bin/opencrvs-postgres-entrypoint.sh:ro + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/../postgres:/run/solmara-postgres-ssl:ro + + opencrvs-db-init: + image: ${POSTGRES_IMAGE:-postgres:16-alpine} + restart: "no" + environment: + POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-compose-validation-only} + OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD: ${OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD:-compose-validation-only} + OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD: ${OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD:-compose-validation-only} + OPENCRVS_RELAY_POSTGRES_READER_PASSWORD: ${OPENCRVS_RELAY_POSTGRES_READER_PASSWORD:-compose-validation-only} + command: ["/opt/opencrvs-demo/postgres-init.sh"] + volumes: + - ./postgres-init.sh:/opt/opencrvs-demo/postgres-init.sh:ro + depends_on: + opencrvs-db: + condition: service_healthy + + opencrvs-relay-bootstrap: + image: ${REGISTRY_RELAY_IMAGE} + platform: ${OPENCRVS_DEMO_RELAY_PLATFORM:-linux/amd64} + restart: "no" + command: + - consultation + - bootstrap-state + - --config + - /etc/registry-relay/relay-consultation.yaml + - --migration-database-url-env + - REGISTRY_RELAY_STATE_MIGRATION_URL + - --owner-role + - opencrvs_demo_owner + - --keyring-maintenance-database-url-env + - REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL + - --keyring-reader-database-url-env + - REGISTRY_RELAY_STATE_KEYRING_READER_URL + - --active-key-id + - epoch-1 + - --active-write-deadline-unix-ms + - "4102444800000" + - --audit-event-retention-ms + - "2592000000" + environment: + REGISTRY_RELAY_AUDIT_HASH_SECRET: ${OPENCRVS_RELAY_AUDIT_HASH_SECRET:-compose-validation-only} + REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${OPENCRVS_RELAY_AUDIT_PSEUDONYM_EPOCH_1:-compose-validation-only} + REGISTRY_RELAY_CONSULTATION_DATABASE_URL: postgresql://opencrvs_demo_runtime:${OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD:-compose-validation-only}@opencrvs-db:5432/opencrvs_demo?sslmode=require + REGISTRY_RELAY_STATE_MIGRATION_URL: postgresql://opencrvs_admin:${POSTGRES_ADMIN_PASSWORD:-compose-validation-only}@opencrvs-db:5432/opencrvs_demo?sslmode=require + REGISTRY_RELAY_STATE_KEYRING_MAINTENANCE_URL: postgresql://opencrvs_demo_keyring_maintenance:${OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD:-compose-validation-only}@opencrvs-db:5432/opencrvs_demo?sslmode=require + REGISTRY_RELAY_STATE_KEYRING_READER_URL: postgresql://opencrvs_demo_keyring_reader:${OPENCRVS_RELAY_POSTGRES_READER_PASSWORD:-compose-validation-only}@opencrvs-db:5432/opencrvs_demo?sslmode=require + OPENCRVS_CLIENT_ID: ${OPENCRVS_CLIENT_ID:-compose-validation-only} + OPENCRVS_SECRET: ${OPENCRVS_SECRET:-compose-validation-only} + volumes: + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/../postgres/server.crt:/etc/opencrvs-demo/postgres/root.crt:ro + depends_on: + opencrvs-db-init: + condition: service_completed_successfully + + opencrvs-relay: + image: ${REGISTRY_RELAY_IMAGE} + platform: ${OPENCRVS_DEMO_RELAY_PLATFORM:-linux/amd64} + restart: unless-stopped + command: + - --config + - /etc/registry-relay/relay-consultation.yaml + - --bind + - 127.0.0.1:8080 + environment: + RUST_LOG: ${RUST_LOG:-info} + REGISTRY_RELAY_BIND: 127.0.0.1:8080 + REGISTRY_RELAY_AUDIT_HASH_SECRET: ${OPENCRVS_RELAY_AUDIT_HASH_SECRET:-compose-validation-only} + REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1: ${OPENCRVS_RELAY_AUDIT_PSEUDONYM_EPOCH_1:-compose-validation-only} + REGISTRY_RELAY_CONSULTATION_DATABASE_URL: postgresql://opencrvs_demo_runtime:${OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD:-compose-validation-only}@opencrvs-db:5432/opencrvs_demo?sslmode=require + OPENCRVS_CLIENT_ID: ${OPENCRVS_CLIENT_ID:-compose-validation-only} + OPENCRVS_SECRET: ${OPENCRVS_SECRET:-compose-validation-only} + healthcheck: + test: ["CMD", "/usr/local/bin/registry-relay", "healthcheck"] + interval: 3s + timeout: 5s + retries: 30 + ports: + - "${OPENCRVS_DEMO_NOTARY_PORT:-4391}:8081" + volumes: + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/../postgres/server.crt:/etc/opencrvs-demo/postgres/root.crt:ro + - opencrvs-relay-cache:/var/lib/registry-relay/cache + depends_on: + opencrvs-relay-bootstrap: + condition: service_completed_successfully + + opencrvs-workload-agent: + build: + context: ../.. + dockerfile: scenario-runner/Dockerfile + image: ${SOLMARA_SCENARIO_RUNNER_IMAGE:-solmara-lab-scenario-runner:local} + command: ["python", "scripts/relay_workload_identity_agent.py"] + restart: unless-stopped + network_mode: service:opencrvs-relay + environment: + WORKLOAD_ISSUER: http://127.0.0.1:8090 + WORKLOAD_IDENTITIES_JSON: >- + [{"audience":"registry-relay","azp":"opencrvs-v2-demo-notary","subject":"opencrvs-v2-demo-notary","scopes":["registry:consult:opencrvs-birth-evidence"],"token_file":"/run/secrets/opencrvs-demo-notary-relay-token","private_jwk_env":"OPENCRVS_RELAY_WORKLOAD_JWK"}] + OPENCRVS_RELAY_WORKLOAD_JWK: ${OPENCRVS_RELAY_WORKLOAD_JWK:-compose-validation-only} + healthcheck: + test: + - CMD-SHELL + - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3)" + interval: 3s + timeout: 5s + retries: 30 + volumes: + - opencrvs-workload-token:/run/secrets + depends_on: + opencrvs-relay: + condition: service_healthy + + opencrvs-notary: + image: ${REGISTRY_NOTARY_IMAGE} + platform: ${REGISTRY_STACK_PLATFORM:-linux/amd64} + user: "65534:65534" + restart: unless-stopped + command: + - --config + - /etc/registry-notary/notary.yaml + - --bind + - 0.0.0.0:8081 + network_mode: service:opencrvs-relay + environment: + RUST_LOG: ${RUST_LOG:-info} + REGISTRY_NOTARY_BIND: 0.0.0.0:8081 + REGISTRY_NOTARY_AUDIT_HASH_SECRET: ${OPENCRVS_NOTARY_AUDIT_HASH_SECRET:-compose-validation-only} + OPENCRVS_DEMO_CALLER_TOKEN_HASH: ${OPENCRVS_DEMO_CALLER_TOKEN_HASH:-sha256:compose-validation-only} + OPENCRVS_DEMO_ISSUER_JWK: ${OPENCRVS_DEMO_ISSUER_JWK:-compose-validation-only} + healthcheck: + test: + - CMD + - /usr/local/bin/registry-notary + - healthcheck + - --url + - http://127.0.0.1:8081/ready + interval: 3s + timeout: 5s + retries: 30 + volumes: + - ${OPENCRVS_RUNTIME_PROJECT_DIR:-./.runtime/project}/.registry-stack/build/local/private/notary/config/notary.yaml:/etc/registry-notary/notary.yaml:ro + - opencrvs-workload-token:/run/secrets:ro + depends_on: + opencrvs-relay: + condition: service_healthy + opencrvs-workload-agent: + condition: service_healthy + +volumes: + opencrvs-db-data: + opencrvs-relay-cache: + opencrvs-workload-token: diff --git a/demos/opencrvs-v2/negative-fixtures/invalid-registration-number.yaml b/demos/opencrvs-v2/negative-fixtures/invalid-registration-number.yaml new file mode 100644 index 0000000..5584c23 --- /dev/null +++ b/demos/opencrvs-v2/negative-fixtures/invalid-registration-number.yaml @@ -0,0 +1,11 @@ +name: opencrvs-v2-invalid-registration-number +classification: synthetic +input: + registration_number: INVALID + child_national_id: "9999000011" + tracking_id: SYN011 +interactions: [] +expect: + error: input.pattern_mismatch + outputs: {} + claims: {} diff --git a/demos/opencrvs-v2/postgres-init.sh b/demos/opencrvs-v2/postgres-init.sh new file mode 100755 index 0000000..2a57010 --- /dev/null +++ b/demos/opencrvs-v2/postgres-init.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env sh +set -eu + +# Provision only the four database identities required by Relay consultation +# state. Values arrive through the disposable demo environment and are never +# printed or copied into generated Registry Stack configuration. + +for name in \ + POSTGRES_ADMIN_PASSWORD \ + OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD \ + OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD \ + OPENCRVS_RELAY_POSTGRES_READER_PASSWORD +do + case "$name" in + POSTGRES_ADMIN_PASSWORD) + value=${POSTGRES_ADMIN_PASSWORD:-} + ;; + OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD) + value=${OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD:-} + ;; + OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD) + value=${OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD:-} + ;; + OPENCRVS_RELAY_POSTGRES_READER_PASSWORD) + value=${OPENCRVS_RELAY_POSTGRES_READER_PASSWORD:-} + ;; + esac + case "$value" in + "" | *[!A-Za-z0-9_-]*) + echo "$name must contain only bounded URL-safe characters" >&2 + exit 1 + ;; + esac + if [ "${#value}" -lt 32 ] || [ "${#value}" -gt 128 ]; then + echo "$name is outside its length bound" >&2 + exit 1 + fi +done + +export PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" + +psql \ + --quiet \ + --set=ON_ERROR_STOP=1 \ + --host=opencrvs-db \ + --username=opencrvs_admin \ + --dbname=postgres \ + --set=runtime_password="$OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD" \ + --set=maintenance_password="$OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD" \ + --set=reader_password="$OPENCRVS_RELAY_POSTGRES_READER_PASSWORD" <<'SQL' +SELECT format( + 'CREATE ROLE %I NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS', + 'opencrvs_demo_owner' +) +WHERE NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'opencrvs_demo_owner' +) +\gexec +SELECT format( + 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', + role_name, + role_password +) +FROM (VALUES + ('opencrvs_demo_runtime', :'runtime_password'), + ('opencrvs_demo_keyring_maintenance', :'maintenance_password'), + ('opencrvs_demo_keyring_reader', :'reader_password') +) AS requested(role_name, role_password) +WHERE NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = requested.role_name +) +\gexec +SELECT format( + 'ALTER ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', + role_name, + role_password +) +FROM (VALUES + ('opencrvs_demo_runtime', :'runtime_password'), + ('opencrvs_demo_keyring_maintenance', :'maintenance_password'), + ('opencrvs_demo_keyring_reader', :'reader_password') +) AS requested(role_name, role_password) +\gexec +SELECT format('CREATE DATABASE %I OWNER %I', 'opencrvs_demo', 'opencrvs_demo_owner') +WHERE NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_database WHERE datname = 'opencrvs_demo' +) +\gexec +SELECT format('REVOKE %I FROM %I', granted.rolname, member.rolname) +FROM pg_catalog.pg_auth_members AS membership +JOIN pg_catalog.pg_roles AS granted ON granted.oid = membership.roleid +JOIN pg_catalog.pg_roles AS member ON member.oid = membership.member +WHERE granted.rolname = 'opencrvs_demo_owner' + AND member.rolname IN ( + 'opencrvs_demo_runtime', + 'opencrvs_demo_keyring_maintenance', + 'opencrvs_demo_keyring_reader' + ) +\gexec +REVOKE ALL ON DATABASE opencrvs_demo FROM PUBLIC; +GRANT CONNECT, CREATE ON DATABASE opencrvs_demo TO opencrvs_demo_owner; +GRANT CONNECT ON DATABASE opencrvs_demo + TO opencrvs_demo_runtime, + opencrvs_demo_keyring_maintenance, + opencrvs_demo_keyring_reader; +SQL + +psql \ + --quiet \ + --set=ON_ERROR_STOP=1 \ + --host=opencrvs-db \ + --username=opencrvs_admin \ + --dbname=opencrvs_demo <<'SQL' +REVOKE ALL ON SCHEMA public FROM PUBLIC; +SQL diff --git a/demos/opencrvs-v2/project/environments/local.yaml b/demos/opencrvs-v2/project/environments/local.yaml new file mode 100644 index 0000000..ac4b3e4 --- /dev/null +++ b/demos/opencrvs-v2/project/environments/local.yaml @@ -0,0 +1,58 @@ +version: 1 + +integrations: + birth-record: + source: + # The live runner replaces only these two .invalid origins in its ignored + # runtime copy. Credentials remain environment references throughout. + origin: https://gateway.opencrvs.invalid + credential: + client_id: { secret: OPENCRVS_CLIENT_ID } + client_secret: { secret: OPENCRVS_SECRET } + generation: 1 + oauth: + origin: https://auth.opencrvs.invalid + path: /token + generation: 1 + # The proof makes one known-record consultation and one no-match + # consultation back to back. Keep the effective burst at exactly two. + rate: { per_minute: 4, burst: 2 } + concurrency: 1 + timeout: 15s + +issuance: + issuer: did:web:opencrvs-demo.invalid + signing_kid: opencrvs-demo-issuer-key-1 + signing_key: { secret: OPENCRVS_DEMO_ISSUER_JWK } + generation: 1 + +callers: + opencrvs-demo-runner: + api_key_fingerprint: { secret: OPENCRVS_DEMO_CALLER_TOKEN_HASH } + scopes: ["evidence:opencrvs-v2:read"] + +relay: + origin: http://127.0.0.1:8080 + issuer: http://127.0.0.1:8090 + jwks_url: http://127.0.0.1:8090/.well-known/jwks.json + audience: registry-relay + allowed_clients: [opencrvs-v2-demo-notary] + +notary_relay: + base_url: http://127.0.0.1:8080 + workload_client_id: opencrvs-v2-demo-notary + token_file: /run/secrets/opencrvs-demo-notary-relay-token + +relay_state: + postgresql: + root_certificate_path: /etc/opencrvs-demo/postgres/root.crt + +# The published amd64 CEL worker needs the same bounded ceiling under local +# Apple Silicon emulation as the existing Solmara authority projects. +notary_cel: + worker_memory_bytes: 1073741824 + +deployment: + profile: local + relay: { service: opencrvs-v2-demo-relay } + notary: { service: opencrvs-v2-demo-notary } diff --git a/demos/opencrvs-v2/project/integrations/birth-record/adapter.rhai b/demos/opencrvs-v2/project/integrations/birth-record/adapter.rhai new file mode 100644 index 0000000..bc9675c --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/adapter.rhai @@ -0,0 +1,65 @@ +fn non_empty(value) { + value != () && type_of(value) == "string" && value.len > 0 +} + +fn consult(ctx) { + let exact_registration = #{ + type: "exact", + term: ctx.input.registration_number + }; + let clause = #{ eventType: "birth" }; + clause["legalStatuses.REGISTERED.registrationNumber"] = exact_registration; + let request_body = #{ + query: #{ + type: "and", + clauses: [clause] + }, + limit: 2, + offset: 0 + }; + let response = source.post_json("/events/events/search", request_body); + + if response.status != 200 { + return result.fail(failure.source_rejected); + } + if response.body.results == () || type_of(response.body.results) != "array" { + return result.fail(failure.source_rejected); + } + + let records = response.body.results; + if records.len == 0 { + return result.no_match(); + } + if records.len > 1 { + return result.ambiguous(); + } + + let record = records[0]; + if record.type != "birth" { + return result.fail(failure.subject_mismatch); + } + if record.trackingId != ctx.input.tracking_id { + return result.fail(failure.subject_mismatch); + } + + let declaration = record.declaration; + let mother_name = declaration["mother.name"]; + let informant_relation = declaration["informant.relation"]; + let mother_recorded = type_of(mother_name) == "map" + && non_empty(mother_name.firstname) + && non_empty(mother_name.surname); + let informant_is_mother = non_empty(informant_relation) + && xw.text.lower_ascii(informant_relation) == "mother"; + + result.match(#{ + event_type_birth: true, + tracking_id_matches: true, + registration_number_matches: + record.legalStatuses.REGISTERED.registrationNumber + == ctx.input.registration_number, + child_national_id_matches: + declaration["child.nid"] == ctx.input.child_national_id, + mother_recorded_on_birth: mother_recorded, + informant_is_mother: informant_is_mother + }) +} diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml new file mode 100644 index 0000000..f1f5152 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml @@ -0,0 +1,43 @@ +name: opencrvs-v2-ambiguous +classification: synthetic +input: + registration_number: SYNTHREG0003 + child_national_id: "9999000004" + tracking_id: SYN003 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0003 + limit: 2 + offset: 0 + respond: + status: 200 + body: + total: 2 + results: + - { type: birth, trackingId: SYN003 } + - { type: birth, trackingId: SYN004 } +expect: + outcome: ambiguous + outputs: {} + claims: {} diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml new file mode 100644 index 0000000..46e2e4b --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml @@ -0,0 +1,34 @@ +name: opencrvs-v2-malformed-response +classification: synthetic +input: + registration_number: SYNTHREG0004 + child_national_id: "9999000005" + tracking_id: SYN005 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0004 + limit: 2 + offset: 0 + respond: { status: 200, body: { total: 1, results: malformed } } +expect: { error: source.status_rejected, outputs: {}, claims: {} } diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml new file mode 100644 index 0000000..346dc96 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml @@ -0,0 +1,80 @@ +name: opencrvs-v2-single-match +classification: synthetic +request: + target: + type: Person + identifiers: + - { scheme: opencrvs_registration_number, value: SYNTHREG0001 } + - { scheme: opencrvs_child_national_id, value: "9999000001" } + attributes: { opencrvs_tracking_id: SYN001 } + claims: + - birth-record-exists + - registration-number-matches + - child-national-id-matches + - mother-recorded-on-birth + - informant-is-mother + disclosure: predicate + format: application/vnd.registry-notary.claim-result+json + purpose: https://id.registrystack.org/solmara/purpose/opencrvs-v2-birth-proof +input: + registration_number: SYNTHREG0001 + child_national_id: "9999000001" + tracking_id: SYN001 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0001 + limit: 2 + offset: 0 + respond: + status: 200 + body: + total: 1 + results: + - type: birth + trackingId: SYN001 + legalStatuses: + REGISTERED: { registrationNumber: SYNTHREG0001 } + declaration: + child.nid: "9999000001" + child.name: { firstname: Synthetic, surname: Child } + mother.nid: "9999000002" + mother.name: { firstname: Synthetic, surname: Parent } + informant.relation: MOTHER + upstreamOnly: + nested: [one, two, three] +expect: + outcome: match + outputs: + event_type_birth: true + tracking_id_matches: true + registration_number_matches: true + child_national_id_matches: true + mother_recorded_on_birth: true + informant_is_mother: true + claims: + birth-record-exists: true + registration-number-matches: true + child-national-id-matches: true + mother-recorded-on-birth: true + informant-is-mother: true diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml new file mode 100644 index 0000000..f52660a --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml @@ -0,0 +1,42 @@ +name: opencrvs-v2-no-match +classification: synthetic +input: + registration_number: SYNTHREG0002 + child_national_id: "9999000003" + tracking_id: SYN002 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0002 + limit: 2 + offset: 0 + respond: { status: 200, body: { total: 0, results: [] } } +expect: + outcome: no_match + outputs: {} + claims: + birth-record-exists: false + registration-number-matches: null + child-national-id-matches: null + mother-recorded-on-birth: null + informant-is-mother: null diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml new file mode 100644 index 0000000..075df62 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml @@ -0,0 +1,34 @@ +name: opencrvs-v2-source-rejected +classification: synthetic +input: + registration_number: SYNTHREG0005 + child_national_id: "9999000006" + tracking_id: SYN006 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0005 + limit: 2 + offset: 0 + respond: { status: 500, body: {} } +expect: { error: source.status_rejected, outputs: {}, claims: {} } diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml new file mode 100644 index 0000000..c07dac9 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml @@ -0,0 +1,42 @@ +name: opencrvs-v2-event-type-mismatch +classification: synthetic +input: + registration_number: SYNTHREG0007 + child_national_id: "9999000009" + tracking_id: SYN009 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0007 + limit: 2 + offset: 0 + respond: + status: 200 + body: + total: 1 + results: + - { type: death, trackingId: SYN009 } +expect: + error: failure.subject_mismatch + outputs: {} + claims: {} diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml new file mode 100644 index 0000000..6f1aa75 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml @@ -0,0 +1,42 @@ +name: opencrvs-v2-tracking-mismatch +classification: synthetic +input: + registration_number: SYNTHREG0008 + child_national_id: "9999000010" + tracking_id: SYN010 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0008 + limit: 2 + offset: 0 + respond: + status: 200 + body: + total: 1 + results: + - { type: birth, trackingId: WRONG1 } +expect: + error: failure.subject_mismatch + outputs: {} + claims: {} diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml new file mode 100644 index 0000000..d5bed1b --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml @@ -0,0 +1,67 @@ +name: opencrvs-v2-unknown-oversized-fields +classification: synthetic +input: + registration_number: SYNTHREG0006 + child_national_id: "9999000007" + tracking_id: SYN007 +interactions: + - expect: + method: POST + path: /token + headers: + Accept: application/json + Content-Type: application/x-www-form-urlencoded + body: { grant_type: client_credentials } + respond: + status: 200 + body: + access_token: SYNTHETIC_FIXTURE_TOKEN + token_type: Bearer + - expect: + method: POST + path: /events/events/search + body: + query: + type: and + clauses: + - eventType: birth + legalStatuses.REGISTERED.registrationNumber: + type: exact + term: SYNTHREG0006 + limit: 2 + offset: 0 + respond: + status: 200 + body: + total: 1 + results: + - type: birth + trackingId: SYN007 + legalStatuses: + REGISTERED: { registrationNumber: SYNTHREG0006 } + declaration: + child.nid: "9999000007" + mother.name: { firstname: Synthetic, surname: Parent } + informant.relation: OTHER + ignoredLongField: >- + This synthetic source-only value is intentionally much longer + than any declared string output. The adapter must ignore it and + emit only the bounded booleans in its contract. + ignoredObjects: + - { arbitrary: true } + - { arbitrary: false } +expect: + outcome: match + outputs: + event_type_birth: true + tracking_id_matches: true + registration_number_matches: true + child_national_id_matches: true + mother_recorded_on_birth: true + informant_is_mother: false + claims: + birth-record-exists: true + registration-number-matches: true + child-national-id-matches: true + mother-recorded-on-birth: true + informant-is-mother: false diff --git a/demos/opencrvs-v2/project/integrations/birth-record/integration.yaml b/demos/opencrvs-v2/project/integrations/birth-record/integration.yaml new file mode 100644 index 0000000..4d45a9a --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/integration.yaml @@ -0,0 +1,52 @@ +version: 1 +id: opencrvs-v2-birth-record +revision: 1 + +source: + product: opencrvs + versions: { unverified: [deployed-build-unattested] } + auth: + type: oauth2_client_credentials + request: form + response_profile: oauth2_bearer_no_expiry + allow: + - method: POST + path: /events/events/search + semantics: read_only + response: { format: json, max_bytes: 256KiB } + +input: + registration_number: + role: selector + type: string + minLength: 12 + maxLength: 12 + pattern: "^[A-Z0-9]{12}$" + child_national_id: + role: selector + type: string + minLength: 10 + maxLength: 10 + pattern: "^[0-9]{10}$" + tracking_id: + role: parameter + type: string + minLength: 6 + maxLength: 6 + pattern: "^[A-Z0-9]{6}$" + +capability: + script: + file: adapter.rhai + +outputs: + event_type_birth: { type: boolean } + tracking_id_matches: { type: boolean } + registration_number_matches: { type: boolean } + child_national_id_matches: { type: boolean } + mother_recorded_on_birth: { type: boolean } + informant_is_mother: { type: boolean } + +limits: + calls: 1 + source_bytes: 320KiB diff --git a/demos/opencrvs-v2/project/registry-stack.yaml b/demos/opencrvs-v2/project/registry-stack.yaml new file mode 100644 index 0000000..c5b7814 --- /dev/null +++ b/demos/opencrvs-v2/project/registry-stack.yaml @@ -0,0 +1,52 @@ +version: 1 + +registry: + id: opencrvs-v2-demo + +integrations: + birth-record: + file: integrations/birth-record/integration.yaml + +services: + opencrvs-birth-evidence: + kind: evidence + version: 1 + purpose: https://id.registrystack.org/solmara/purpose/opencrvs-v2-birth-proof + legal_basis: interoperability-demonstration + consent: not_required + access: + scopes: ["evidence:opencrvs-v2:read"] + consultations: + birth: + integration: birth-record + input: + registration_number: request.target.identifiers.opencrvs_registration_number + child_national_id: request.target.identifiers.opencrvs_child_national_id + tracking_id: request.target.attributes.opencrvs_tracking_id + claims: + birth-record-exists: + cel: "birth.matched" + disclosure: predicate + registration-number-matches: + output: birth.registration_number_matches + disclosure: predicate + child-national-id-matches: + output: birth.child_national_id_matches + disclosure: predicate + mother-recorded-on-birth: + output: birth.mother_recorded_on_birth + disclosure: predicate + informant-is-mother: + output: birth.informant_is_mother + disclosure: predicate + credential_profiles: + birth-predicates: + format: dc+sd-jwt + type: https://id.registrystack.org/solmara/credential/opencrvs-v2-birth-proof/v1 + validity: 10m + claims: + - birth-record-exists + - registration-number-matches + - child-national-id-matches + - mother-recorded-on-birth + - informant-is-mother diff --git a/demos/opencrvs-v2/runner.py b/demos/opencrvs-v2/runner.py new file mode 100755 index 0000000..de7c51d --- /dev/null +++ b/demos/opencrvs-v2/runner.py @@ -0,0 +1,1575 @@ +#!/usr/bin/env python3 +"""Run the optional OpenCRVS v2 interoperability proof without leaking source data.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import ipaddress +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Mapping, Sequence + +import yaml +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + +ROOT = Path(__file__).resolve().parents[2] +DEMO = ROOT / "demos" / "opencrvs-v2" +AUTHORED_PROJECT = DEMO / "project" +RUNTIME = DEMO / ".runtime" +RUNTIME_PROJECT = RUNTIME / "project" +RUNTIME_ENV = RUNTIME / "local.env" +EVIDENCE_DIR = ROOT / "output" / "opencrvs-v2-demo" +EVIDENCE_PATH = EVIDENCE_DIR / "evidence.json" + + +def external_env_path() -> Path: + override = os.environ.get("OPENCRVS_DEMO_ENV_FILE") + if override: + return Path(override).expanduser().resolve() + for parent in (ROOT, *ROOT.parents): + candidate = parent / "registry-internal" / ".env.opencrvs" + if candidate.is_file(): + return candidate + return ROOT.parent / "registry-internal" / ".env.opencrvs" + + +EXTERNAL_ENV = external_env_path() +PURPOSE = "https://id.registrystack.org/solmara/purpose/opencrvs-v2-birth-proof" +WRONG_PURPOSE = ( + "https://id.registrystack.org/solmara/purpose/opencrvs-v2-not-authorized" +) +RESULT_FORMAT = "application/vnd.registry-notary.claim-result+json" +CREDENTIAL_FORMAT = "application/dc+sd-jwt" +PROFILE = "opencrvs-birth-evidence.birth-predicates" +NOTARY_SERVICE_ID = "opencrvs-v2-demo-notary" +CLAIMS = [ + "birth-record-exists", + "registration-number-matches", + "child-national-id-matches", + "mother-recorded-on-birth", + "informant-is-mother", +] +EXPECTED_OUTPUTS = { + "child_national_id_matches": "boolean", + "event_type_birth": "boolean", + "informant_is_mother": "boolean", + "mother_recorded_on_birth": "boolean", + "registration_number_matches": "boolean", + "tracking_id_matches": "boolean", +} +RUNTIME_ORIGIN_ACK = "OPENCRVS_DEMO_ALLOW_IGNORED_RUNTIME_ORIGINS" +SELECTOR_KEYS = { + "registration_number": "OPENCRVS_DEMO_REGISTRATION_NUMBER", + "child_national_id": "OPENCRVS_DEMO_CHILD_NATIONAL_ID", + "tracking_id": "OPENCRVS_DEMO_TRACKING_ID", +} +SUPPORTED = [ + "OAuth-authenticated native OpenCRVS search", + "bounded Relay/Rhai source adaptation", + "exact record matching and ambiguity handling", + "minimized scalar and predicate outputs", + "Notary evaluation from Relay provenance", + "holder-bound dc+sd-jwt issuance through /v1/credentials", + "top-level scalar parent-related predicates", +] +NOT_DEMONSTRATED = [ + "structured parents[] or representative objects in a credential", + "proof that the credential holder is the child's parent or informant", + "registrar-initiated OID4VCI pre-authorized offers", + "delivery into a parent's wallet", + "OpenCRVS-triggered issuance", + "unrelated unreleased structured-claim behavior", + "official OpenCRVS compatibility certification", +] + + +class DemoFailure(RuntimeError): + """A deliberately value-free error safe to print to an operator.""" + + +@dataclass(frozen=True) +class HttpResult: + status: int | None + body: Any + headers: Mapping[str, str] + + +@dataclass(frozen=True) +class ExampleSelectors: + registration_number: str + child_national_id: str + tracking_id: str + child_name: str | None = None + + +@dataclass(frozen=True) +class RelayActivity: + completion_intents: int + credential_dispatches: int + data_dispatches: int + + +def exact_consultation_dispatch( + before: RelayActivity, after: RelayActivity, label: str +) -> dict[str, int]: + dispatch = { + "credential_dispatch_delta": ( + after.credential_dispatches - before.credential_dispatches + ), + "source_data_dispatch_delta": (after.data_dispatches - before.data_dispatches), + } + if any(delta != 1 for delta in dispatch.values()): + raise DemoFailure( + f"the {label} consultation did not make exactly one credential " + "and one source request" + ) + return dispatch + + +def relay_rate_bound_evidence( + public_bounds: Any, effective_limits: Any +) -> dict[str, dict[str, Any]]: + if ( + not isinstance(public_bounds, dict) + or not isinstance(effective_limits, dict) + or effective_limits.get("quota_per_minute") != 4 + or effective_limits.get("quota_burst") != 2 + or effective_limits["quota_per_minute"] + > public_bounds.get("quota_per_minute", 0) + or effective_limits["quota_burst"] > public_bounds.get("quota_burst", 0) + ): + raise DemoFailure( + "the compiled Relay rate limits do not preserve the demo's effective " + "four-per-minute, burst-two contract" + ) + return { + "public_bounds": public_bounds, + "effective_runtime_limits": effective_limits, + } + + +def b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def read_dotenv(path: Path) -> dict[str, str]: + """Parse the small dotenv subset used by the two runtime env files.""" + if not path.is_file(): + raise DemoFailure(f"required environment file is missing: {path}") + values: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line.removeprefix("export ").lstrip() + if "=" not in line: + raise DemoFailure(f"invalid environment assignment in {path.name}") + name, value = line.split("=", 1) + name = name.strip() + value = value.strip() + if re.fullmatch(r"[A-Z][A-Z0-9_]*", name) is None: + raise DemoFailure(f"invalid environment name in {path.name}") + if len(value) >= 2 and value[:1] == value[-1:] and value[0] in {"'", '"'}: + value = value[1:-1] + values[name] = value + return values + + +def required_external_env() -> tuple[dict[str, str], ExampleSelectors]: + values = read_dotenv(EXTERNAL_ENV) + for name in ("OPENCRVS_CLIENT_ID", "OPENCRVS_SECRET", "OPENCRVS_URL"): + if not values.get(name): + raise DemoFailure(f"{EXTERNAL_ENV} must define {name}") + + selectors = { + field: os.environ.get(name) or values.get(name, "") + for field, name in SELECTOR_KEYS.items() + } + child_name = ( + os.environ.get("OPENCRVS_DEMO_CHILD_NAME") + or values.get("OPENCRVS_DEMO_CHILD_NAME") + or None + ) + # The current read-only operator file carries the example as a comment. + # Use it only to fill missing selector values and strengthen the output scan. + text = EXTERNAL_ENV.read_text(encoding="utf-8") + match = re.search( + r"child called (?P[^:]+):\s*Tracking ID:\s*(?P[A-Z0-9]{6})," + r"\s*Registration Number:\s*(?P[A-Z0-9]{12})," + r"\s*National ID:\s*(?P[0-9]{10})", + text, + ) + if match is not None: + selectors = { + "registration_number": selectors["registration_number"] + or match.group("registration"), + "child_national_id": selectors["child_national_id"] or match.group("nid"), + "tracking_id": selectors["tracking_id"] or match.group("tracking"), + } + child_name = child_name or match.group("name").strip() + if not all(selectors.values()): + missing = [ + env_name + for field, env_name in SELECTOR_KEYS.items() + if not selectors[field] + ] + raise DemoFailure( + f"{EXTERNAL_ENV} must define the demo selectors: {', '.join(missing)}" + ) + if re.fullmatch(r"[A-Z0-9]{12}", selectors["registration_number"]) is None: + raise DemoFailure("the demo registration selector has the wrong format") + if re.fullmatch(r"[0-9]{10}", selectors["child_national_id"]) is None: + raise DemoFailure("the demo national ID selector has the wrong format") + if re.fullmatch(r"[A-Z0-9]{6}", selectors["tracking_id"]) is None: + raise DemoFailure("the demo tracking selector has the wrong format") + return values, ExampleSelectors(**selectors, child_name=child_name) + + +def opencrvs_host(raw: str) -> str: + candidate = raw.strip() + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urllib.parse.urlsplit(candidate) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.port not in (None, 443) + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise DemoFailure("OPENCRVS_URL must be a path-free HTTPS DNS host") + host = parsed.hostname + raw_hostname = parsed.netloc.removesuffix(":443") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise DemoFailure("OPENCRVS_URL must be a DNS host, not an IP address") + if raw_hostname != host or ( + re.fullmatch( + r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" + r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", + host, + ) + is None + ): + raise DemoFailure("OPENCRVS_URL must be a valid lowercase DNS host") + return host + + +def run( + command: Sequence[str], + *, + env: Mapping[str, str] | None = None, + expected_failure: bool = False, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + list(command), + cwd=ROOT, + env=dict(env) if env is not None else None, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if expected_failure: + return result + if result.returncode != 0: + label = " ".join( + Path(part).name if index == 0 else part + for index, part in enumerate(command[:2]) + ) + raise DemoFailure( + f"{label} failed; inspect the command locally with secrets redacted" + ) + return result + + +def registryctl() -> str: + override = os.environ.get("OPENCRVS_DEMO_REGISTRYCTL") + if override: + path = Path(override).expanduser().resolve() + if not path.is_file() or not os.access(path, os.X_OK): + raise DemoFailure("OPENCRVS_DEMO_REGISTRYCTL must name an executable file") + return str(path) + result = run([str(ROOT / "scripts" / "registryctl-pinned.sh"), "path"]) + path = result.stdout.strip() + if not path: + raise DemoFailure("the pinned registryctl helper returned no executable") + return path + + +def registryctl_identity(versions: Mapping[str, str]) -> dict[str, Any]: + executable = Path(registryctl()).resolve() + version = run([str(executable), "--version"]).stdout.strip() + executable_sha256 = hashlib.sha256(executable.read_bytes()).hexdigest() + overridden = bool(os.environ.get("OPENCRVS_DEMO_REGISTRYCTL")) + if not overridden: + return { + "version": version, + "source_ref": versions["REGISTRY_STACK_SOURCE_REF"], + "source_commit": versions["REGISTRY_STACK_SOURCE_COMMIT"], + "executable_sha256": executable_sha256, + "development_override": False, + } + source_commit = os.environ.get( + "OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT", + "", + ) + if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None: + raise DemoFailure( + "OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT must identify the exact " + "development compiler commit" + ) + repository_result = run( + ["git", "-C", str(executable.parent), "rev-parse", "--show-toplevel"], + expected_failure=True, + ) + if repository_result.returncode != 0: + raise DemoFailure( + "the development registryctl must be inside its Registry Stack worktree" + ) + repository = Path(repository_result.stdout.strip()).resolve() + try: + executable.relative_to(repository) + except ValueError as error: + raise DemoFailure( + "the development registryctl is outside its reported Registry Stack worktree" + ) from error + actual_commit = run( + ["git", "-C", str(repository), "rev-parse", "HEAD"] + ).stdout.strip() + tracked_status = run( + [ + "git", + "-C", + str(repository), + "status", + "--porcelain", + "--untracked-files=no", + ] + ).stdout.strip() + if actual_commit != source_commit or tracked_status: + raise DemoFailure( + "the development registryctl source worktree must be clean at the exact " + "declared commit" + ) + return { + "version": version, + "source_commit": source_commit, + "executable_sha256": executable_sha256, + "development_override": True, + } + + +def relay_runtime_identity( + versions: Mapping[str, str], + compiler: Mapping[str, Any], +) -> dict[str, Any]: + image = os.environ.get("OPENCRVS_DEMO_RELAY_IMAGE", "") + if not image: + if compiler.get("development_override") is True: + raise DemoFailure( + "the development compiler requires OPENCRVS_DEMO_RELAY_IMAGE " + "from the same Registry Stack candidate" + ) + return { + "version": versions["REGISTRYCTL_VERSION"], + "source_ref": versions["REGISTRY_STACK_SOURCE_REF"], + "source_commit": versions["REGISTRY_STACK_SOURCE_COMMIT"], + "relay_image": versions["REGISTRY_RELAY_IMAGE"], + "notary_image": versions["REGISTRY_NOTARY_IMAGE"], + "development_override": False, + } + source_commit = os.environ.get("OPENCRVS_DEMO_RELAY_SOURCE_COMMIT", "") + relay_platform = os.environ.get("OPENCRVS_DEMO_RELAY_PLATFORM", "") + if ( + re.fullmatch(r"[0-9a-f]{40}", source_commit) is None + or relay_platform not in {"linux/amd64", "linux/arm64"} + or compiler.get("development_override") is not True + or compiler.get("source_commit") != source_commit + ): + raise DemoFailure( + "the development Relay image, platform, and registryctl must declare " + "one exact Registry Stack candidate" + ) + relay_architecture = relay_platform.removeprefix("linux/") + inspected = run( + [ + "docker", + "image", + "inspect", + "--format", + ( + "{{.Id}}|{{.Architecture}}|" + '{{index .Config.Labels "org.opencontainers.image.revision"}}|' + '{{index .Config.Labels "org.registrystack.registry-relay.features"}}' + ), + image, + ], + expected_failure=True, + ) + if inspected.returncode != 0: + raise DemoFailure( + "the declared development Relay image is not available locally" + ) + parts = inspected.stdout.strip().split("|") + if ( + len(parts) != 4 + or re.fullmatch(r"sha256:[0-9a-f]{64}", parts[0]) is None + or parts[1] != relay_architecture + or parts[2] != source_commit + or parts[3] != "attribute-release,crosswalk-runtime" + ): + raise DemoFailure( + "the development Relay image lacks the exact platform, source, or " + "feature labels" + ) + version = run( + [ + "docker", + "run", + "--rm", + "--platform", + relay_platform, + "--entrypoint", + "/usr/local/bin/registry-relay", + image, + "--version", + ] + ).stdout.strip() + return { + "version": version, + "source_commit": source_commit, + "relay_image": image, + "relay_image_id": parts[0], + "relay_platform": relay_platform, + "notary_image": versions["REGISTRY_NOTARY_IMAGE"], + "development_override": True, + } + + +def registry_command( + action: str, + project: Path, + *, + environment: str | None = None, + expected_failure: bool = False, +) -> subprocess.CompletedProcess[str]: + command = [registryctl(), action, "--project-dir", str(project)] + if environment is not None: + command.extend(["--environment", environment]) + if action in {"test", "build"}: + command.extend(["--format", "json"]) + return run(command, expected_failure=expected_failure) + + +def parse_registry_report(result: subprocess.CompletedProcess[str]) -> dict[str, Any]: + try: + report = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise DemoFailure("registryctl returned an invalid JSON report") from error + if ( + not isinstance(report, dict) + or report.get("schema_version") != "registryctl.project_command.v1" + ): + raise DemoFailure("registryctl returned an unsupported report") + return report + + +def compiler_boundary() -> dict[str, Any]: + results: dict[str, Any] = {} + for structured_type in ("object", "array"): + with tempfile.TemporaryDirectory(prefix="opencrvs-v2-boundary-") as temporary: + project = Path(temporary) / "project" + shutil.copytree( + AUTHORED_PROJECT, + project, + ignore=shutil.ignore_patterns(".registry-stack"), + ) + path = project / "integrations" / "birth-record" / "integration.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["outputs"]["parents"] = {"type": structured_type} + path.write_text( + yaml.safe_dump(document, sort_keys=False), + encoding="utf-8", + ) + check = registry_command( + "check", project, environment="local", expected_failure=True + ) + results[f"structured_parents_{structured_type}"] = { + "accepted": check.returncode == 0, + "rejected_or_unsupported": check.returncode != 0, + "diagnostic_persisted": False, + } + if not all(item["rejected_or_unsupported"] for item in results.values()): + raise DemoFailure( + "registryctl unexpectedly accepted a structured parents output" + ) + return results + + +def invalid_selector_boundary() -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="opencrvs-v2-selector-") as temporary: + project = Path(temporary) / "project" + shutil.copytree( + AUTHORED_PROJECT, + project, + ignore=shutil.ignore_patterns(".registry-stack"), + ) + fixture = ( + project + / "integrations" + / "birth-record" + / "fixtures" + / "invalid-registration-number.yaml" + ) + shutil.copy2( + DEMO / "negative-fixtures" / "invalid-registration-number.yaml", + fixture, + ) + result = registry_command("test", project, expected_failure=True) + if result.returncode == 0: + raise DemoFailure( + "registryctl unexpectedly accepted an invalid selector fixture" + ) + return { + "coverage": "authored synthetic fixture rejected by registryctl input validation", + "rejected_before_execution": True, + "source_access": False, + "diagnostic_persisted": False, + } + + +def compiled_integration_contract(build: Path) -> tuple[dict[str, Any], dict[str, Any]]: + pack = json.loads( + (build / "reviewable" / "integration-packs" / "birth-record.json").read_text( + encoding="utf-8" + ) + ) + outputs = { + name: definition.get("type") + for name, definition in pack["spec"]["output"].items() + } + if outputs != EXPECTED_OUTPUTS: + raise DemoFailure( + "the compiled Relay output contract is not minimized as expected" + ) + oauth_response = pack["spec"]["plan"]["credential_operation"]["response"] + expected_oauth_response = { + "accepted_statuses": [200], + "access_token_max_bytes": 4096, + "cache_mode": "disabled", + "max_bytes": 8192, + "schema": "strict_access_token_bearer_no_expiry", + "token_type": "Bearer", + } + if oauth_response != expected_oauth_response: + raise DemoFailure( + "the compiled Relay OAuth response contract is not strict and non-caching" + ) + private_binding = json.loads( + ( + build + / "private" + / "relay" + / "config" + / "artifacts" + / "private-bindings" + / "opencrvs-birth-evidence-birth.json" + ).read_text(encoding="utf-8") + ) + if "max_token_lifetime_ms" in private_binding["limits"]: + raise DemoFailure( + "the no-expiry OAuth profile unexpectedly gained a cache lifetime" + ) + return pack, oauth_response + + +def offline_checks() -> dict[str, Any]: + test_report = parse_registry_report(registry_command("test", AUTHORED_PROJECT)) + fixtures = test_report.get("fixtures") + if not isinstance(fixtures, list) or not fixtures: + raise DemoFailure("registryctl reported no demo fixtures") + if any( + not isinstance(item, dict) or item.get("passed") is not True + for item in fixtures + ): + raise DemoFailure("one or more Registry project fixture cases failed") + registry_command("check", AUTHORED_PROJECT, environment="local") + build_report = parse_registry_report( + registry_command("build", AUTHORED_PROJECT, environment="local") + ) + if build_report.get("status") != "built": + raise DemoFailure("registryctl did not complete the demo build") + _, oauth_response = compiled_integration_contract( + AUTHORED_PROJECT / ".registry-stack" / "build" / "local" + ) + return { + "fixture_cases": len(fixtures), + "all_passed": True, + "oauth_response_profile": oauth_response, + "invalid_selector": invalid_selector_boundary(), + "structured_parent_boundary": compiler_boundary(), + } + + +def generate_private_jwk(kid: str) -> str: + key = Ed25519PrivateKey.generate() + private = key.private_bytes_raw() + public = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return json.dumps( + { + "alg": "EdDSA", + "crv": "Ed25519", + "d": b64url(private), + "kid": kid, + "kty": "OKP", + "x": b64url(public), + }, + separators=(",", ":"), + sort_keys=True, + ) + + +def fresh_runtime_values() -> dict[str, str]: + caller = secrets.token_urlsafe(32) + return { + "POSTGRES_ADMIN_PASSWORD": secrets.token_urlsafe(32), + "OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD": secrets.token_urlsafe(32), + "OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD": secrets.token_urlsafe(32), + "OPENCRVS_RELAY_POSTGRES_READER_PASSWORD": secrets.token_urlsafe(32), + "OPENCRVS_RELAY_AUDIT_HASH_SECRET": secrets.token_urlsafe(32), + "OPENCRVS_RELAY_AUDIT_PSEUDONYM_EPOCH_1": secrets.token_urlsafe(32), + "OPENCRVS_NOTARY_AUDIT_HASH_SECRET": secrets.token_urlsafe(32), + "OPENCRVS_DEMO_CALLER_TOKEN": caller, + "OPENCRVS_DEMO_CALLER_TOKEN_HASH": ( + "sha256:" + hashlib.sha256(caller.encode("ascii")).hexdigest() + ), + "OPENCRVS_RELAY_WORKLOAD_JWK": generate_private_jwk( + "opencrvs-v2-demo-relay-workload-key-1" + ), + "OPENCRVS_DEMO_ISSUER_JWK": generate_private_jwk("opencrvs-demo-issuer-key-1"), + } + + +def write_runtime_env(values: Mapping[str, str]) -> None: + RUNTIME.mkdir(parents=True, exist_ok=True) + temporary = RUNTIME / "local.env.tmp" + temporary.write_text( + "".join(f"{name}={value}\n" for name, value in sorted(values.items())), + encoding="utf-8", + ) + temporary.chmod(0o600) + temporary.replace(RUNTIME_ENV) + + +def ensure_postgres_tls() -> None: + directory = RUNTIME / "postgres" + certificate = directory / "server.crt" + private_key = directory / "server.key" + if certificate.is_file() and private_key.is_file(): + return + directory.mkdir(parents=True, exist_ok=True) + result = run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "30", + "-subj", + "/CN=opencrvs-db", + "-addext", + "subjectAltName=DNS:opencrvs-db,IP:127.0.0.1", + "-keyout", + str(private_key), + "-out", + str(certificate), + ] + ) + if result.returncode != 0 or not certificate.is_file() or not private_key.is_file(): + raise DemoFailure("the disposable PostgreSQL TLS material was not generated") + private_key.chmod(0o600) + certificate.chmod(0o644) + + +def prepare_runtime_project(host: str) -> None: + if os.environ.get(RUNTIME_ORIGIN_ACK) != "yes": + raise DemoFailure( + f"set {RUNTIME_ORIGIN_ACK}=yes to acknowledge that Registry Stack " + "0.15.2 writes the live origins into its ignored runtime closure" + ) + if RUNTIME_PROJECT.exists(): + shutil.rmtree(RUNTIME_PROJECT) + shutil.copytree( + AUTHORED_PROJECT, + RUNTIME_PROJECT, + ignore=shutil.ignore_patterns(".registry-stack"), + ) + environment_path = RUNTIME_PROJECT / "environments" / "local.yaml" + environment = yaml.safe_load(environment_path.read_text(encoding="utf-8")) + source = environment["integrations"]["birth-record"]["source"] + source["origin"] = f"https://gateway.{host}" + source["oauth"]["origin"] = f"https://auth.{host}" + environment_path.write_text( + yaml.safe_dump(environment, sort_keys=False), + encoding="utf-8", + ) + registry_command("check", RUNTIME_PROJECT, environment="local") + report = parse_registry_report( + registry_command("build", RUNTIME_PROJECT, environment="local") + ) + if report.get("status") != "built": + raise DemoFailure("registryctl did not build the runtime project") + + +def compose_project_name() -> str: + suffix = hashlib.sha256(str(ROOT).encode("utf-8")).hexdigest()[:10] + return f"solmara-opencrvs-v2-{suffix}" + + +def compose_environment( + external: Mapping[str, str] | None = None, + runtime: Mapping[str, str] | None = None, +) -> dict[str, str]: + environment = dict(os.environ) + environment.update(read_dotenv(ROOT / "versions.env")) + if runtime is not None: + environment.update(runtime) + if external is not None: + for name in ("OPENCRVS_CLIENT_ID", "OPENCRVS_SECRET"): + environment[name] = external[name] + environment["OPENCRVS_RUNTIME_PROJECT_DIR"] = str(RUNTIME_PROJECT) + environment["COMPOSE_PROJECT_NAME"] = compose_project_name() + development_relay = os.environ.get("OPENCRVS_DEMO_RELAY_IMAGE") + if development_relay: + environment["REGISTRY_RELAY_IMAGE"] = development_relay + return environment + + +def compose_command(*arguments: str) -> list[str]: + return [ + "docker", + "compose", + "--env-file", + str(ROOT / "versions.env"), + "-f", + str(DEMO / "compose.yaml"), + *arguments, + ] + + +def wait_ready(url: str, timeout: int = 150) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{url}/ready", timeout=2) as response: + if response.status in (200, 204): + return + except (OSError, urllib.error.URLError): + pass + time.sleep(1) + raise DemoFailure("the OpenCRVS demo Notary did not become ready") + + +def notary_url() -> str: + port = os.environ.get("OPENCRVS_DEMO_NOTARY_PORT", "4391") + if re.fullmatch(r"[0-9]{1,5}", port) is None or not 1 <= int(port) <= 65535: + raise DemoFailure("OPENCRVS_DEMO_NOTARY_PORT is invalid") + return f"http://127.0.0.1:{port}" + + +def start_demo() -> None: + external, _ = required_external_env() + host = opencrvs_host(external["OPENCRVS_URL"]) + versions = read_dotenv(ROOT / "versions.env") + compiler = registryctl_identity(versions) + relay_runtime_identity(versions, compiler) + runtime = ( + read_dotenv(RUNTIME_ENV) if RUNTIME_ENV.is_file() else fresh_runtime_values() + ) + prepare_runtime_project(host) + write_runtime_env(runtime) + ensure_postgres_tls() + environment = compose_environment(external, runtime) + run(compose_command("up", "-d", "--build"), env=environment) + wait_ready(notary_url()) + + +def http_json( + method: str, + url: str, + headers: Mapping[str, str], + body: Any | None = None, + *, + form: Mapping[str, str] | None = None, + timeout: float = 30, +) -> HttpResult: + data: bytes | None = None + request_headers = dict(headers) + if body is not None: + data = json.dumps(body, separators=(",", ":")).encode("utf-8") + request_headers.setdefault("Content-Type", "application/json") + if form is not None: + data = urllib.parse.urlencode(form).encode("ascii") + request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + request = urllib.request.Request( + url, + method=method, + headers=request_headers, + data=data, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read() + status = response.status + response_headers = { + key.lower(): value for key, value in response.headers.items() + } + except urllib.error.HTTPError as error: + raw = error.read() + status = error.code + response_headers = {key.lower(): value for key, value in error.headers.items()} + except (OSError, urllib.error.URLError) as error: + raise DemoFailure( + "an HTTP request failed before receiving a response" + ) from error + try: + parsed: Any = json.loads(raw) if raw else {} + except (json.JSONDecodeError, UnicodeDecodeError): + parsed = None + return HttpResult(status, parsed, response_headers) + + +def jwt_parts(token: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]: + parts = token.split(".") + if len(parts) != 3 or not all(parts): + raise DemoFailure("a compact JWT has the wrong shape") + try: + header = json.loads(b64url_decode(parts[0])) + payload = json.loads(b64url_decode(parts[1])) + signature = b64url_decode(parts[2]) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise DemoFailure("a compact JWT could not be decoded") from error + if not isinstance(header, dict) or not isinstance(payload, dict): + raise DemoFailure("a compact JWT contains non-object JSON") + return header, payload, signature, f"{parts[0]}.{parts[1]}".encode("ascii") + + +def sanitized_oauth_claim(value: Any, client_id: str) -> Any: + if isinstance(value, str): + return value.replace(client_id, "[client-id-redacted]") + if isinstance(value, list): + return [sanitized_oauth_claim(item, client_id) for item in value] + if value is None or isinstance(value, (bool, int, float)): + return value + return "[unsupported-claim-shape]" + + +def oauth_probe(external: Mapping[str, str]) -> tuple[dict[str, Any], str]: + host = opencrvs_host(external["OPENCRVS_URL"]) + response = http_json( + "POST", + f"https://auth.{host}/token", + {"Accept": "application/json"}, + form={ + "client_id": external["OPENCRVS_CLIENT_ID"], + "client_secret": external["OPENCRVS_SECRET"], + "grant_type": "client_credentials", + }, + ) + body = response.body if isinstance(response.body, dict) else {} + token = body.get("access_token") + if ( + response.status != 200 + or set(body) != {"access_token", "token_type"} + or body.get("token_type") != "Bearer" + or not isinstance(token, str) + or not token + ): + raise DemoFailure( + "the OpenCRVS OAuth probe did not return the strict no-expiry " + "bearer response" + ) + _, payload, _, _ = jwt_parts(token) + issued = payload.get("iat") + expires = payload.get("exp") + lifetime = ( + expires - issued + if isinstance(issued, int) and isinstance(expires, int) and expires > issued + else None + ) + client_id = external["OPENCRVS_CLIENT_ID"] + return ( + { + "status": response.status, + "token_present": True, + "lifetime_seconds": lifetime, + "issuer": sanitized_oauth_claim(payload.get("iss"), client_id), + "audience": sanitized_oauth_claim(payload.get("aud"), client_id), + "scope": sanitized_oauth_claim(payload.get("scope"), client_id), + "jwt_claims_parsed_without_signature_verification": True, + }, + token, + ) + + +def evaluation_body( + selectors: ExampleSelectors, registration: str | None = None +) -> dict[str, Any]: + return { + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "opencrvs_registration_number", + "value": registration or selectors.registration_number, + }, + { + "scheme": "opencrvs_child_national_id", + "value": selectors.child_national_id, + }, + ], + "attributes": { + "opencrvs_tracking_id": selectors.tracking_id, + }, + }, + "claims": CLAIMS, + "disclosure": "predicate", + "format": RESULT_FORMAT, + } + + +def api_headers(token: str, purpose: str) -> dict[str, str]: + return { + "Accept": RESULT_FORMAT, + "Data-Purpose": purpose, + "x-api-key": token, + } + + +def safe_error(result: HttpResult) -> dict[str, Any]: + body = result.body if isinstance(result.body, dict) else {} + code = body.get("code") + return { + "status": result.status, + "rejected": result.status is not None and result.status >= 400, + "code": code if isinstance(code, str) else None, + } + + +def relay_activity(environment: Mapping[str, str]) -> RelayActivity: + result = run( + compose_command( + "exec", + "--no-TTY", + "opencrvs-db", + "psql", + "--username", + "opencrvs_admin", + "--dbname", + "opencrvs_demo", + "--tuples-only", + "--no-align", + "--command", + "SELECT (SELECT count(*) FROM relay_state_private.consultation_completion_intent)," + "(SELECT count(*) FROM relay_state_private.dispatch_permit " + "WHERE kind = 'credential' AND dispatched_at IS NOT NULL)," + "(SELECT count(*) FROM relay_state_private.dispatch_permit " + "WHERE kind = 'data' AND dispatched_at IS NOT NULL);", + ), + env=environment, + ) + match = re.fullmatch( + r"\s*([0-9]+)\|([0-9]+)\|([0-9]+)\s*", + result.stdout, + ) + if match is None: + raise DemoFailure("the Relay activity counters could not be read") + return RelayActivity( + completion_intents=int(match.group(1)), + credential_dispatches=int(match.group(2)), + data_dispatches=int(match.group(3)), + ) + + +def live_negative( + url: str, + headers: Mapping[str, str], + body: Mapping[str, Any], + environment: Mapping[str, str], +) -> dict[str, Any]: + before = relay_activity(environment) + result = http_json("POST", f"{url}/v1/evaluations", headers, body) + after = relay_activity(environment) + summary = safe_error(result) + summary["credential_dispatch_delta"] = ( + after.credential_dispatches - before.credential_dispatches + ) + summary["source_data_dispatch_delta"] = ( + after.data_dispatches - before.data_dispatches + ) + if ( + not summary["rejected"] + or summary["credential_dispatch_delta"] != 0 + or summary["source_data_dispatch_delta"] != 0 + ): + raise DemoFailure("a live negative control crossed its expected boundary") + return summary + + +def evaluation_summary(result: HttpResult) -> dict[str, Any]: + if result.status != 200 or not isinstance(result.body, dict): + summary = safe_error(result) + code = summary["code"] or "no stable problem code" + raise DemoFailure(f"Notary evaluation returned HTTP {result.status}: {code}") + results = result.body.get("results") + if not isinstance(results, list) or len(results) != len(CLAIMS): + raise DemoFailure("Notary evaluation returned the wrong claim set") + values: dict[str, bool | None] = {} + for item in results: + if not isinstance(item, dict) or item.get("claim_id") not in CLAIMS: + raise DemoFailure("Notary evaluation returned an unknown claim") + raw_value = item.get("satisfied", item.get("value")) + if raw_value not in (True, False, None): + raise DemoFailure("Notary evaluation returned a non-predicate result") + values[item["claim_id"]] = raw_value + if set(values) != set(CLAIMS): + raise DemoFailure("Notary evaluation returned duplicate or missing claims") + return { + "status": result.status, + "claim_ids": CLAIMS, + "results": values, + } + + +def first_evaluation_id(body: Any) -> str: + if isinstance(body, dict) and isinstance(body.get("results"), list): + for result in body["results"]: + if isinstance(result, dict) and isinstance( + result.get("evaluation_id"), str + ): + return result["evaluation_id"] + raise DemoFailure("Notary evaluation returned no evaluation identifier") + + +def holder_material() -> tuple[str, Ed25519PrivateKey, dict[str, str]]: + key = Ed25519PrivateKey.generate() + public_bytes = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + public_jwk = { + "crv": "Ed25519", + "kty": "OKP", + "x": b64url(public_bytes), + } + holder_id = "did:jwk:" + b64url( + json.dumps(public_jwk, separators=(",", ":")).encode("utf-8") + ) + return holder_id, key, public_jwk + + +def holder_proof( + holder_id: str, + key: Ed25519PrivateKey, + evaluation_id: str, +) -> str: + now = int(time.time()) + header = {"alg": "EdDSA", "kid": holder_id, "typ": "kb+jwt"} + payload = { + "aud": NOTARY_SERVICE_ID, + "claims": CLAIMS, + "credential_profile": PROFILE, + "disclosure": b64url(hashlib.sha256(b"predicate").digest()), + "evaluation_id": evaluation_id, + "exp": now + 60, + "iat": now, + "jti": secrets.token_urlsafe(24), + "sub": holder_id, + } + header_segment = b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) + payload_segment = b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + signing_input = f"{header_segment}.{payload_segment}".encode("ascii") + return f"{signing_input.decode('ascii')}.{b64url(key.sign(signing_input))}" + + +def verify_sd_jwt( + credential: str, + issuer_private_jwk: str, + holder_id: str, + holder_public_jwk: Mapping[str, str], +) -> dict[str, Any]: + compact, *disclosure_parts = credential.split("~") + disclosures = [part for part in disclosure_parts if part] + header, payload, signature, signing_input = jwt_parts(compact) + try: + issuer_jwk = json.loads(issuer_private_jwk) + if ( + header.get("alg") != "EdDSA" + or header.get("kid") != issuer_jwk.get("kid") + or payload.get("_sd_alg") != "sha-256" + ): + raise DemoFailure("the SD-JWT protected metadata is invalid") + public_key = Ed25519PrivateKey.from_private_bytes( + b64url_decode(issuer_jwk["d"]) + ).public_key() + public_key.verify(signature, signing_input) + except (InvalidSignature, KeyError, ValueError, TypeError) as error: + raise DemoFailure( + "the issuer key or credential signature is invalid" + ) from error + digests = payload.get("_sd") + if not isinstance(digests, list) or not all( + isinstance(item, str) for item in digests + ): + raise DemoFailure("the SD-JWT has no valid disclosure digest set") + computed = [ + b64url(hashlib.sha256(item.encode("ascii")).digest()) for item in disclosures + ] + if sorted(computed) != sorted(digests): + raise DemoFailure("the returned disclosures do not match the SD-JWT digests") + confirmation = payload.get("cnf") + if not isinstance(confirmation, dict): + raise DemoFailure("the SD-JWT has no holder confirmation") + if ( + confirmation.get("kid") != holder_id + or confirmation.get("jwk") != holder_public_jwk + ): + raise DemoFailure( + "the SD-JWT holder confirmation does not match the ephemeral holder" + ) + issued = payload.get("iat") + expires = payload.get("exp") + if not isinstance(issued, int) or not isinstance(expires, int) or expires <= issued: + raise DemoFailure("the SD-JWT lifetime is invalid") + return { + "format": CREDENTIAL_FORMAT, + "vct": payload.get("vct"), + "issuer": payload.get("iss"), + "kid": header.get("kid"), + "algorithm": header.get("alg"), + "lifetime_seconds": expires - issued, + "disclosure_count": len(disclosures), + "issuer_signature_valid": True, + "disclosures_match_digests": True, + "holder_binding": "did:jwk", + "cnf_matches_ephemeral_holder": True, + "sha256": hashlib.sha256(credential.encode("utf-8")).hexdigest(), + } + + +def issue_credential( + url: str, + caller_token: str, + evaluation: HttpResult, + issuer_private_jwk: str, +) -> tuple[dict[str, Any], str]: + evaluation_id = first_evaluation_id(evaluation.body) + holder_id, holder_key, holder_public_jwk = holder_material() + proof = holder_proof(holder_id, holder_key, evaluation_id) + body = { + "claims": CLAIMS, + "credential_profile": PROFILE, + "disclosure": "predicate", + "evaluation_id": evaluation_id, + "format": CREDENTIAL_FORMAT, + "holder": { + "binding": "did", + "id": holder_id, + "proof": proof, + }, + "purpose": PURPOSE, + } + response = http_json( + "POST", + f"{url}/v1/credentials", + { + "Accept": "application/json", + "Data-Purpose": PURPOSE, + "x-api-key": caller_token, + }, + body, + ) + response_body = response.body if isinstance(response.body, dict) else {} + credential = response_body.get("credential") + if response.status not in (200, 201) or not isinstance(credential, str): + raise DemoFailure("Notary credential issuance did not succeed") + returned_disclosures = response_body.get("disclosures") + compact_disclosures = [part for part in credential.split("~")[1:] if part] + if ( + not isinstance(returned_disclosures, list) + or not all(isinstance(item, str) for item in returned_disclosures) + or returned_disclosures != compact_disclosures + ): + raise DemoFailure( + "Notary returned disclosures that differ from the compact credential" + ) + verification = verify_sd_jwt( + credential, + issuer_private_jwk, + holder_id, + holder_public_jwk, + ) + return { + "status": response.status, + **verification, + }, credential + + +def compiled_artifacts() -> dict[str, Any]: + build = RUNTIME_PROJECT / ".registry-stack" / "build" / "local" + relay_config = yaml.safe_load( + (build / "private" / "relay" / "config" / "relay-consultation.yaml").read_text( + encoding="utf-8" + ) + ) + pack_entry = relay_config["consultation"]["artifacts"]["integration_packs"][0] + contract_entry = relay_config["consultation"]["artifacts"]["public_contracts"][0] + private_entry = relay_config["consultation"]["artifacts"]["private_bindings"][0] + private_binding = json.loads( + (build / "private" / "relay" / "config" / private_entry["path"]).read_text( + encoding="utf-8" + ) + ) + pack, oauth_response = compiled_integration_contract(build) + public_bounds = pack["spec"]["bounds"] + effective_limits = private_binding.get("limits") + rate_bounds = relay_rate_bound_evidence(public_bounds, effective_limits) + outputs = { + name: definition.get("type") + for name, definition in pack["spec"]["output"].items() + } + return { + "integration_pack": { + "typed_hash": pack_entry["hash"], + "artifact_sha256": pack_entry["sha256"], + }, + "consultation_contract": { + "typed_hash": contract_entry["hash"], + "artifact_sha256": contract_entry["sha256"], + }, + "relay": { + "outcome": "match", + "emitted_output_names_types": outputs, + **rate_bounds, + "oauth_response_profile": oauth_response, + "cross_consultation_token_cache": False, + }, + } + + +def scan_bytes( + paths: Sequence[Path], + additional: Sequence[bytes], + sensitive: Mapping[str, bytes], +) -> dict[str, Any]: + blobs: list[bytes] = list(additional) + file_count = 0 + for path in paths: + if not path.exists(): + continue + for candidate in [path] if path.is_file() else path.rglob("*"): + if candidate.is_file(): + blobs.append(candidate.read_bytes()) + file_count += 1 + for label, needle in sensitive.items(): + if needle and any(needle in blob for blob in blobs): + raise DemoFailure( + f"sensitive value detected during sanitized-output scan: {label}" + ) + token_pattern = re.compile( + rb"(?:Bearer\s+[A-Za-z0-9._~-]{20,}|" + rb"eyJ[A-Za-z0-9_-]{12,}\.eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,})" + ) + if any(token_pattern.search(blob) for blob in blobs): + raise DemoFailure( + "a bearer-shaped token was detected during sanitized-output scan" + ) + return { + "passed": True, + "files_scanned": file_count, + "memory_blobs_scanned": len(additional), + } + + +def proof() -> None: + external, selectors = required_external_env() + runtime = read_dotenv(RUNTIME_ENV) + environment = compose_environment(external, runtime) + wait_ready(notary_url(), timeout=10) + offline = offline_checks() + oauth, oauth_token = oauth_probe(external) + url = notary_url() + caller_token = runtime["OPENCRVS_DEMO_CALLER_TOKEN"] + request_body = evaluation_body(selectors) + + wrong_caller = live_negative( + url, + api_headers(secrets.token_urlsafe(32), PURPOSE), + request_body, + environment, + ) + wrong_purpose = live_negative( + url, + api_headers(caller_token, WRONG_PURPOSE), + request_body, + environment, + ) + invalid_body = evaluation_body(selectors) + invalid_body["target"]["identifiers"][0]["value"] = "INVALID" + invalid_selector = live_negative( + url, + api_headers(caller_token, PURPOSE), + invalid_body, + environment, + ) + + before_positive = relay_activity(environment) + positive = http_json( + "POST", + f"{url}/v1/evaluations", + api_headers(caller_token, PURPOSE), + request_body, + ) + after_positive = relay_activity(environment) + positive_summary = evaluation_summary(positive) + if not all(value is True for value in positive_summary["results"].values()): + raise DemoFailure("the known OpenCRVS record did not satisfy every predicate") + positive_dispatch = exact_consultation_dispatch( + before_positive, after_positive, "known-record" + ) + + credential_summary, raw_credential = issue_credential( + url, + caller_token, + positive, + runtime["OPENCRVS_DEMO_ISSUER_JWK"], + ) + + nonexistent_body = evaluation_body(selectors, registration="ZZZZZZZZZZZZ") + before_missing = relay_activity(environment) + missing = http_json( + "POST", + f"{url}/v1/evaluations", + api_headers(caller_token, PURPOSE), + nonexistent_body, + ) + after_missing = relay_activity(environment) + missing_summary = evaluation_summary(missing) + if missing_summary["results"]["birth-record-exists"] is not False: + raise DemoFailure("the nonexistent registration did not return no match") + missing_dispatch = exact_consultation_dispatch( + before_missing, after_missing, "no-match" + ) + + logs = run(compose_command("logs", "--no-color"), env=environment).stdout.encode( + "utf-8" + ) + versions = read_dotenv(ROOT / "versions.env") + compiler = registryctl_identity(versions) + registry_runtime = relay_runtime_identity(versions, compiler) + artifacts = compiled_artifacts() + evidence: dict[str, Any] = { + "schema_version": "solmara.opencrvs-v2-demo.evidence.v1", + "generated_at": datetime.now(UTC).isoformat(), + "registry_stack": { + "runtime": registry_runtime, + "authoring_compiler": compiler, + }, + "oauth": oauth, + "opencrvs_search": { + "endpoint_path": "/events/events/search", + "http_status": 200, + "result_count": 1, + "event_type": "birth", + "known_registration_number_matched": True, + "known_tracking_id_matched": True, + "observation": ( + "Inferred from the live Relay match. The reviewed adapter returns match " + "only after HTTP 200, one result, birth type, and exact tracking binding." + ), + }, + "compiled_artifacts": { + "integration_pack": artifacts["integration_pack"], + "consultation_contract": artifacts["consultation_contract"], + }, + "relay": { + **artifacts["relay"], + "known_record_dispatch": positive_dispatch, + }, + "notary_evaluation": positive_summary, + "credential_issuance": credential_summary, + "negative_controls": { + "wrong_caller": wrong_caller, + "wrong_purpose": wrong_purpose, + "invalid_selector": invalid_selector, + "syntactically_valid_nonexistent_registration": { + **missing_summary, + **missing_dispatch, + }, + }, + "offline_evidence": offline, + "persistence": { + "raw_opencrvs_response_written": False, + "bearer_token_written": False, + "holder_private_key_written": False, + "credential_written": False, + }, + "capability_boundary": { + "supported_and_demonstrated": SUPPORTED, + "not_demonstrated_or_unavailable": NOT_DEMONSTRATED, + "release_boundary": ( + "The released v0.15.2 decoder recognizes strict no-expiry OAuth, " + "but its durable completion-seed contract cannot admit this script " + "plan, and its worker budget charges Relay-owned source waits. " + "Development proof uses exact-commit Registry Stack compiler and " + "Relay candidates. Pin the next Registry Stack release before " + "deployment." + ), + "issuance_boundary": ( + "Direct authenticated machine API issuance to a demo-controlled " + "ephemeral holder key. This is not an OID4VCI registrar offer and " + "does not prove that the holder is a parent or informant." + ), + }, + } + encoded = json.dumps(evidence, indent=2, sort_keys=True).encode("utf-8") + b"\n" + sensitive: dict[str, bytes] = { + "OpenCRVS client id": external["OPENCRVS_CLIENT_ID"].encode("utf-8"), + "OpenCRVS client secret": external["OPENCRVS_SECRET"].encode("utf-8"), + "OAuth access token": oauth_token.encode("utf-8"), + "credential": raw_credential.encode("utf-8"), + "registration number": selectors.registration_number.encode("utf-8"), + "child national id": selectors.child_national_id.encode("utf-8"), + "tracking id": selectors.tracking_id.encode("utf-8"), + } + if selectors.child_name: + sensitive["child name"] = selectors.child_name.encode("utf-8") + scan = scan_bytes( + [ + RUNTIME_PROJECT / ".registry-stack" / "build" / "local", + EVIDENCE_DIR, + ], + [logs, encoded], + sensitive, + ) + evidence["sanitized_output_scan"] = scan + EVIDENCE_DIR.mkdir(parents=True, exist_ok=True) + temporary = EVIDENCE_DIR / "evidence.json.tmp" + temporary.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(EVIDENCE_PATH) + print(f"sanitized evidence written to {EVIDENCE_PATH.relative_to(ROOT)}") + + +def compose_config() -> None: + validation_runtime = { + "POSTGRES_ADMIN_PASSWORD": "v" * 40, + "OPENCRVS_RELAY_POSTGRES_RUNTIME_PASSWORD": "r" * 40, + "OPENCRVS_RELAY_POSTGRES_MAINTENANCE_PASSWORD": "m" * 40, + "OPENCRVS_RELAY_POSTGRES_READER_PASSWORD": "d" * 40, + "OPENCRVS_RELAY_AUDIT_HASH_SECRET": "a" * 40, + "OPENCRVS_RELAY_AUDIT_PSEUDONYM_EPOCH_1": "p" * 40, + "OPENCRVS_NOTARY_AUDIT_HASH_SECRET": "n" * 40, + "OPENCRVS_DEMO_CALLER_TOKEN_HASH": "sha256:" + "0" * 64, + "OPENCRVS_RELAY_WORKLOAD_JWK": "{}", + "OPENCRVS_DEMO_ISSUER_JWK": "{}", + } + validation_external = { + "OPENCRVS_CLIENT_ID": "compose-validation-only", + "OPENCRVS_SECRET": "compose-validation-only", + } + run( + compose_command("config", "--quiet"), + env=compose_environment(validation_external, validation_runtime), + ) + + +def down() -> None: + external: dict[str, str] | None = None + runtime: dict[str, str] | None = None + if EXTERNAL_ENV.is_file(): + external = read_dotenv(EXTERNAL_ENV) + if RUNTIME_ENV.is_file(): + runtime = read_dotenv(RUNTIME_ENV) + run( + compose_command("down", "-v", "--remove-orphans"), + env=compose_environment(external, runtime), + ) + if RUNTIME.exists(): + resolved = RUNTIME.resolve() + if resolved.parent != DEMO.resolve() or resolved.name != ".runtime": + raise DemoFailure("refusing to remove an unexpected runtime path") + shutil.rmtree(resolved) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "action", + choices=("offline", "compose-config", "up", "proof", "down"), + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + try: + if args.action == "offline": + report = offline_checks() + print( + f"OpenCRVS demo offline checks passed: " + f"{report['fixture_cases']} fixture cases" + ) + elif args.action == "compose-config": + compose_config() + print("OpenCRVS demo Compose configuration is valid") + elif args.action == "up": + start_demo() + print("OpenCRVS demo is ready") + elif args.action == "proof": + proof() + elif args.action == "down": + down() + print( + "OpenCRVS demo containers, volumes, and ignored runtime closure removed" + ) + except DemoFailure as error: + print(f"opencrvs-v2 demo: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/demos/opencrvs-v2/tests/test_candidate_build.py b/demos/opencrvs-v2/tests/test_candidate_build.py new file mode 100644 index 0000000..e173e1e --- /dev/null +++ b/demos/opencrvs-v2/tests/test_candidate_build.py @@ -0,0 +1,34 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "demos" / "opencrvs-v2" / "build-candidate.sh" + + +class CandidateBuildContractTests(unittest.TestCase): + def test_builder_is_fail_fast_and_uses_the_locked_monorepo(self) -> None: + text = SCRIPT.read_text() + + self.assertIn("set -euo pipefail", text) + self.assertIn("cargo build --release --locked", text) + self.assertIn("release/docker/Dockerfile.registry-relay", text) + self.assertNotIn("crates/registry-relay/scripts/build-image.sh", text) + + def test_builder_closes_candidate_provenance(self) -> None: + text = SCRIPT.read_text() + + self.assertIn('git -C "${stack_dir}" status --porcelain', text) + self.assertIn("org.opencontainers.image.revision=${commit}", text) + self.assertIn( + "org.registrystack.registry-relay.features=${relay_features}", text + ) + self.assertIn("image_architecture", text) + self.assertIn("image_revision", text) + self.assertIn("image_features", text) + self.assertIn("docker version --format '{{.Server.Arch}}'", text) + self.assertIn("OPENCRVS_DEMO_RELAY_PLATFORM", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/demos/opencrvs-v2/tests/test_runner.py b/demos/opencrvs-v2/tests/test_runner.py new file mode 100644 index 0000000..ff12dba --- /dev/null +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +RUNNER_PATH = Path(__file__).resolve().parents[1] / "runner.py" +SPEC = importlib.util.spec_from_file_location("opencrvs_v2_runner", RUNNER_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("could not load the OpenCRVS demo runner") +runner = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = runner +SPEC.loader.exec_module(runner) + + +class DotenvTests(unittest.TestCase): + def test_reads_only_supported_assignments(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "operator.env" + path.write_text( + "# ignored\nexport FIRST='one'\nSECOND=\"two\"\n", + encoding="utf-8", + ) + self.assertEqual( + runner.read_dotenv(path), + {"FIRST": "one", "SECOND": "two"}, + ) + + def test_rejects_invalid_environment_names(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "operator.env" + path.write_text("lowercase=value\n", encoding="utf-8") + with self.assertRaises(runner.DemoFailure): + runner.read_dotenv(path) + + def test_explicit_process_selectors_override_comment_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "operator.env" + path.write_text( + "OPENCRVS_CLIENT_ID=client\n" + "OPENCRVS_SECRET=secret\n" + "OPENCRVS_URL=country.example\n" + "# child called Example Person: Tracking ID: ABC123, " + "Registration Number: AAAAAAAAAAAA, National ID: 1111111111\n", + encoding="utf-8", + ) + environment = { + "OPENCRVS_DEMO_REGISTRATION_NUMBER": "BBBBBBBBBBBB", + "OPENCRVS_DEMO_CHILD_NATIONAL_ID": "2222222222", + "OPENCRVS_DEMO_TRACKING_ID": "XYZ789", + } + with ( + mock.patch.object(runner, "EXTERNAL_ENV", path), + mock.patch.dict(os.environ, environment, clear=False), + ): + _, selectors = runner.required_external_env() + self.assertEqual(selectors.registration_number, "BBBBBBBBBBBB") + self.assertEqual(selectors.child_national_id, "2222222222") + self.assertEqual(selectors.tracking_id, "XYZ789") + self.assertEqual(selectors.child_name, "Example Person") + + +class OriginTests(unittest.TestCase): + def test_accepts_only_path_free_https_dns_hosts(self) -> None: + self.assertEqual( + runner.opencrvs_host("https://country.example"), + "country.example", + ) + for invalid in ( + "http://country.example", + "https://country.example/path", + "https://user@country.example", + "https://country.example:444", + "https://UPPER.example", + "127.0.0.1", + ): + with self.subTest(invalid=invalid): + with self.assertRaises(runner.DemoFailure): + runner.opencrvs_host(invalid) + + +class RegistryctlIdentityTests(unittest.TestCase): + def test_requires_exact_commit_for_development_override(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "registry-stack" + executable = repository / "target" / "debug" / "registryctl" + executable.parent.mkdir(parents=True) + executable.write_bytes(b"synthetic registryctl") + versions = { + "REGISTRY_STACK_SOURCE_REF": "v0.15.2", + "REGISTRY_STACK_SOURCE_COMMIT": "a" * 40, + } + environment = { + "OPENCRVS_DEMO_REGISTRYCTL": str(executable), + "OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT": "b" * 40, + } + results = [ + subprocess.CompletedProcess( + [str(executable), "--version"], + 0, + stdout="registryctl 0.16.0-dev\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "rev-parse"], + 0, + stdout=f"{repository}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "rev-parse", "HEAD"], + 0, + stdout=f"{'b' * 40}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "status"], + 0, + stdout="", + stderr="", + ), + ] + with ( + mock.patch.object( + runner, + "registryctl", + return_value=str(executable), + ), + mock.patch.object(runner, "run", side_effect=results), + mock.patch.dict(os.environ, environment, clear=False), + ): + self.assertEqual( + runner.registryctl_identity(versions), + { + "version": "registryctl 0.16.0-dev", + "source_commit": "b" * 40, + "executable_sha256": hashlib.sha256( + b"synthetic registryctl" + ).hexdigest(), + "development_override": True, + }, + ) + + environment["OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT"] = "main" + with ( + mock.patch.object( + runner, + "registryctl", + return_value=str(executable), + ), + mock.patch.object(runner, "run", return_value=results[0]), + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaises(runner.DemoFailure), + ): + runner.registryctl_identity(versions) + + +class RelayRuntimeIdentityTests(unittest.TestCase): + def test_requires_the_same_labeled_candidate_commit(self) -> None: + commit = "b" * 40 + versions = { + "REGISTRYCTL_VERSION": "0.15.2", + "REGISTRY_STACK_SOURCE_REF": "v0.15.2", + "REGISTRY_STACK_SOURCE_COMMIT": "a" * 40, + "REGISTRY_RELAY_IMAGE": "relay@sha256:released", + "REGISTRY_NOTARY_IMAGE": "notary@sha256:released", + } + compiler = { + "source_commit": commit, + "development_override": True, + } + environment = { + "OPENCRVS_DEMO_RELAY_IMAGE": "registry-relay:candidate", + "OPENCRVS_DEMO_RELAY_SOURCE_COMMIT": commit, + "OPENCRVS_DEMO_RELAY_PLATFORM": "linux/arm64", + } + results = [ + subprocess.CompletedProcess( + ["docker", "image", "inspect"], + 0, + stdout=( + f"sha256:{'1' * 64}|arm64|{commit}|" + "attribute-release,crosswalk-runtime\n" + ), + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "run"], + 0, + stdout="registry-relay 0.15.2\n", + stderr="", + ), + ] + with ( + mock.patch.object(runner, "run", side_effect=results) as run_mock, + mock.patch.dict(os.environ, environment, clear=False), + ): + self.assertEqual( + runner.relay_runtime_identity(versions, compiler), + { + "version": "registry-relay 0.15.2", + "source_commit": commit, + "relay_image": "registry-relay:candidate", + "relay_image_id": f"sha256:{'1' * 64}", + "relay_platform": "linux/arm64", + "notary_image": "notary@sha256:released", + "development_override": True, + }, + ) + run_command = run_mock.call_args_list[1].args[0] + self.assertEqual( + run_command[run_command.index("--platform") + 1], + "linux/arm64", + ) + + def test_rejects_an_unlabeled_or_cross_commit_candidate(self) -> None: + commit = "b" * 40 + versions = { + "REGISTRYCTL_VERSION": "0.15.2", + "REGISTRY_STACK_SOURCE_REF": "v0.15.2", + "REGISTRY_STACK_SOURCE_COMMIT": "a" * 40, + "REGISTRY_RELAY_IMAGE": "relay@sha256:released", + "REGISTRY_NOTARY_IMAGE": "notary@sha256:released", + } + compiler = { + "source_commit": commit, + "development_override": True, + } + environment = { + "OPENCRVS_DEMO_RELAY_IMAGE": "registry-relay:candidate", + "OPENCRVS_DEMO_RELAY_SOURCE_COMMIT": "c" * 40, + "OPENCRVS_DEMO_RELAY_PLATFORM": "linux/amd64", + } + with ( + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaises(runner.DemoFailure), + ): + runner.relay_runtime_identity(versions, compiler) + + def test_rejects_a_candidate_from_another_platform(self) -> None: + commit = "b" * 40 + versions = { + "REGISTRYCTL_VERSION": "0.15.2", + "REGISTRY_STACK_SOURCE_REF": "v0.15.2", + "REGISTRY_STACK_SOURCE_COMMIT": "a" * 40, + "REGISTRY_RELAY_IMAGE": "relay@sha256:released", + "REGISTRY_NOTARY_IMAGE": "notary@sha256:released", + } + compiler = { + "source_commit": commit, + "development_override": True, + } + environment = { + "OPENCRVS_DEMO_RELAY_IMAGE": "registry-relay:candidate", + "OPENCRVS_DEMO_RELAY_SOURCE_COMMIT": commit, + "OPENCRVS_DEMO_RELAY_PLATFORM": "linux/arm64", + } + inspected = subprocess.CompletedProcess( + ["docker", "image", "inspect"], + 0, + stdout=( + f"sha256:{'1' * 64}|amd64|{commit}|" + "attribute-release,crosswalk-runtime\n" + ), + stderr="", + ) + with ( + mock.patch.object(runner, "run", return_value=inspected), + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaises(runner.DemoFailure), + ): + runner.relay_runtime_identity(versions, compiler) + + def test_rejects_dirty_or_mismatched_development_source(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "registry-stack" + executable = repository / "target" / "debug" / "registryctl" + executable.parent.mkdir(parents=True) + executable.write_bytes(b"synthetic registryctl") + versions = { + "REGISTRY_STACK_SOURCE_REF": "v0.15.2", + "REGISTRY_STACK_SOURCE_COMMIT": "a" * 40, + } + environment = { + "OPENCRVS_DEMO_REGISTRYCTL": str(executable), + "OPENCRVS_DEMO_REGISTRYCTL_SOURCE_COMMIT": "b" * 40, + } + results = [ + subprocess.CompletedProcess( + [str(executable), "--version"], + 0, + stdout="registryctl 0.16.0-dev\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "rev-parse"], + 0, + stdout=f"{repository}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "rev-parse", "HEAD"], + 0, + stdout=f"{'c' * 40}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["git", "status"], + 0, + stdout=" M crates/registryctl/src/main.rs\n", + stderr="", + ), + ] + with ( + mock.patch.object( + runner, + "registryctl", + return_value=str(executable), + ), + mock.patch.object(runner, "run", side_effect=results), + mock.patch.dict(os.environ, environment, clear=False), + self.assertRaises(runner.DemoFailure), + ): + runner.registryctl_identity(versions) + + +class OAuthProbeTests(unittest.TestCase): + def token(self, audience: str) -> str: + header = runner.b64url(b'{"alg":"EdDSA"}') + payload = runner.b64url( + json.dumps( + { + "aud": audience, + "exp": 1_600, + "iat": 1_000, + "iss": "https://issuer.example", + "scope": "record.search", + }, + separators=(",", ":"), + ).encode("utf-8") + ) + return f"{header}.{payload}.{runner.b64url(b'signature')}" + + def test_accepts_exact_shape_and_redacts_client_id_from_claims(self) -> None: + client_id = "operator-client-id" + response = runner.HttpResult( + 200, + { + "access_token": self.token(client_id), + "token_type": "Bearer", + }, + {}, + ) + external = { + "OPENCRVS_CLIENT_ID": client_id, + "OPENCRVS_SECRET": "secret", + "OPENCRVS_URL": "country.example", + } + with mock.patch.object(runner, "http_json", return_value=response): + metadata, token = runner.oauth_probe(external) + self.assertEqual(metadata["audience"], "[client-id-redacted]") + self.assertEqual(metadata["lifetime_seconds"], 600) + self.assertNotIn(client_id, json.dumps(metadata)) + self.assertEqual(token, response.body["access_token"]) + + def test_rejects_an_expiry_member_in_the_no_expiry_profile(self) -> None: + response = runner.HttpResult( + 200, + { + "access_token": self.token("audience"), + "token_type": "Bearer", + "expires_in": 600, + }, + {}, + ) + external = { + "OPENCRVS_CLIENT_ID": "client", + "OPENCRVS_SECRET": "secret", + "OPENCRVS_URL": "country.example", + } + with ( + mock.patch.object(runner, "http_json", return_value=response), + self.assertRaises(runner.DemoFailure), + ): + runner.oauth_probe(external) + + +class RelayActivityTests(unittest.TestCase): + def test_counts_credential_and_data_dispatches_separately(self) -> None: + completed = subprocess.CompletedProcess( + ["docker"], + 0, + stdout=" 5|3|2\n", + stderr="", + ) + with mock.patch.object(runner, "run", return_value=completed) as invoked: + activity = runner.relay_activity({}) + self.assertEqual( + activity, + runner.RelayActivity( + completion_intents=5, + credential_dispatches=3, + data_dispatches=2, + ), + ) + command = invoked.call_args.args[0] + query = command[command.index("--command") + 1] + self.assertIn("kind = 'credential'", query) + self.assertIn("kind = 'data'", query) + + def test_exact_consultation_dispatch_requires_fresh_oauth_and_source_calls( + self, + ) -> None: + before = runner.RelayActivity(1, 1, 1) + after = runner.RelayActivity(2, 2, 2) + self.assertEqual( + runner.exact_consultation_dispatch(before, after, "known-record"), + { + "credential_dispatch_delta": 1, + "source_data_dispatch_delta": 1, + }, + ) + + with self.assertRaises(runner.DemoFailure): + runner.exact_consultation_dispatch( + before, + runner.RelayActivity(2, 1, 2), + "cached-token", + ) + + def test_rate_bound_evidence_separates_public_and_effective_limits(self) -> None: + public = {"quota_per_minute": 60, "quota_burst": 8} + effective = {"quota_per_minute": 4, "quota_burst": 2} + self.assertEqual( + runner.relay_rate_bound_evidence(public, effective), + { + "public_bounds": public, + "effective_runtime_limits": effective, + }, + ) + + for invalid in ( + {"quota_per_minute": 4, "quota_burst": 1}, + {"quota_per_minute": 61, "quota_burst": 2}, + {"quota_per_minute": 4, "quota_burst": 9}, + ): + with self.subTest(invalid=invalid): + with self.assertRaises(runner.DemoFailure): + runner.relay_rate_bound_evidence(public, invalid) + + def test_negative_rejects_any_source_boundary_dispatch(self) -> None: + activities = [ + runner.RelayActivity(0, 0, 0), + runner.RelayActivity(0, 1, 0), + ] + rejected = runner.HttpResult(403, {"code": "purpose.denied"}, {}) + with ( + mock.patch.object(runner, "relay_activity", side_effect=activities), + mock.patch.object(runner, "http_json", return_value=rejected), + self.assertRaises(runner.DemoFailure), + ): + runner.live_negative( + "http://127.0.0.1:4391", + {}, + {}, + {}, + ) + + +class CredentialVerificationTests(unittest.TestCase): + def make_credential( + self, + ) -> tuple[str, str, str, dict[str, str], str]: + issuer_jwk = json.loads(runner.generate_private_jwk("issuer-key-1")) + issuer_private = Ed25519PrivateKey.from_private_bytes( + runner.b64url_decode(issuer_jwk["d"]) + ) + holder_id, _, holder_public = runner.holder_material() + disclosure = runner.b64url( + json.dumps( + ["salt", "birth-record-exists", True], + separators=(",", ":"), + ).encode("utf-8") + ) + payload = { + "_sd": [runner.b64url(hashlib.sha256(disclosure.encode("ascii")).digest())], + "_sd_alg": "sha-256", + "cnf": {"kid": holder_id, "jwk": holder_public}, + "exp": 1_030, + "iat": 1_000, + "iss": "did:web:issuer.example", + "vct": "https://id.example/credential/v1", + } + header = {"alg": "EdDSA", "kid": "issuer-key-1"} + header_segment = runner.b64url( + json.dumps(header, separators=(",", ":")).encode("utf-8") + ) + payload_segment = runner.b64url( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signing_input = f"{header_segment}.{payload_segment}".encode("ascii") + compact = ( + f"{signing_input.decode('ascii')}." + f"{runner.b64url(issuer_private.sign(signing_input))}" + ) + return ( + f"{compact}~{disclosure}~", + json.dumps(issuer_jwk), + holder_id, + holder_public, + disclosure, + ) + + def test_verifies_signature_disclosures_and_holder_binding(self) -> None: + credential, issuer_jwk, holder_id, holder_public, _ = self.make_credential() + summary = runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + self.assertTrue(summary["issuer_signature_valid"]) + self.assertTrue(summary["disclosures_match_digests"]) + self.assertTrue(summary["cnf_matches_ephemeral_holder"]) + self.assertEqual(summary["disclosure_count"], 1) + self.assertNotIn(credential, json.dumps(summary)) + + def test_rejects_a_disclosure_not_bound_by_the_sd_jwt(self) -> None: + credential, issuer_jwk, holder_id, holder_public, disclosure = ( + self.make_credential() + ) + credential = credential.replace(disclosure, runner.b64url(b"tampered")) + with self.assertRaises(runner.DemoFailure): + runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + + +class SanitizationTests(unittest.TestCase): + def test_scan_rejects_exact_sensitive_values(self) -> None: + with self.assertRaises(runner.DemoFailure): + runner.scan_bytes( + [], + [b"prefix super-sensitive-value suffix"], + {"test secret": b"super-sensitive-value"}, + ) + + def test_scan_rejects_bearer_shaped_tokens(self) -> None: + with self.assertRaises(runner.DemoFailure): + runner.scan_bytes( + [], + [b"Authorization: Bearer abcdefghijklmnopqrstuvwxyz"], + {}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/changelog.md b/docs/changelog.md index 877d4b5..feaefb9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,39 @@ A small dated log of what changed in the visitor center and the lab topology. Newest entry first. +## 2026-07-30 Optional OpenCRVS v2 interoperability proof + +An isolated, opt-in local demo now authors the native OpenCRVS token and +`POST /events/events/search` path through Relay, minimized predicate evaluation +through Notary, and holder-bound `dc+sd-jwt` issuance through the direct machine +API. Offline fixture, compiler, and Compose checks pass. A paired pre-release +compiler and host-native Relay candidate completed the live OpenCRVS search, +Notary evaluation, direct issuance, cryptographic verification, negative +controls, and sanitized evidence scan. The released v0.15.2 path remains +blocked and must not be reported as a released result. Holder binding proves +possession of the demo's ephemeral key, not a parent or informant relationship, +and this is not an OID4VCI wallet flow. Operator credentials and source values +remain outside the repository, and live origins are limited to an ignored +runtime closure. + +OpenCRVS omits `expires_in` from its otherwise strict bearer-token response. +The demo therefore selects Registry Stack's explicit +`oauth2_bearer_no_expiry` authoring profile, which disables cross-consultation +token caching, rejects extra response members, and does not infer freshness +from unverified JWT claims. Development uses exact Registry Stack commit +[`d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e`](https://github.com/registrystack/registry-stack/commit/d6f3ed71680e45af4eeac37b0ee1c7bab69bb23e) +in a clean worktree for offline authoring. That commit adds Registryctl support +only. Relay v0.15.2 has the strict decoder, but its durable completion-seed path +rejects the no-cache script plan before OpenCRVS dispatch. The next Registry +Stack release must include both the authoring profile and explicit Relay +state-plane cache-mode handling, plus active script-budget accounting that does +not charge bounded Relay-owned OAuth and source waits. Only then can the live +proof write sanitized evidence using released artifacts. The successful +pre-release local proof records the compiler commit and executable hash plus +the same-commit Relay image ID. +Deployment remains blocked until `versions.env` pins the release and the +coordinated release review passes. + ## 2026-07-29 Registry Stack v0.15.2 adoption The lab now consumes the canonical Registry Stack Relay image directly. The diff --git a/justfile b/justfile index 7167187..207e1d6 100644 --- a/justfile +++ b/justfile @@ -99,6 +99,32 @@ test: @if [ -f portal/package.json ]; then cd portal && pnpm test; fi @if [ -f home/package.json ]; then cd home && pnpm test; fi uv run python3 -m unittest discover -s scripts -p 'test_*.py' + uv run --locked python -m unittest discover -s demos/opencrvs-v2/tests -p 'test_*.py' + +# Run the optional OpenCRVS demo unit and offline Registry project checks. +opencrvs-demo-test: + uv run --locked python -m unittest discover -s demos/opencrvs-v2/tests -p 'test_*.py' + uv run --locked demos/opencrvs-v2/runner.py offline + +# Build exact-commit Registryctl and host-native Relay candidates for pre-release proof. +opencrvs-demo-candidate-build registry_stack_dir: + demos/opencrvs-v2/build-candidate.sh "{{registry_stack_dir}}" + +# Validate the optional OpenCRVS demo Compose topology without starting it. +opencrvs-demo-compose: + uv run --locked demos/opencrvs-v2/runner.py compose-config + +# Build the ignored OpenCRVS runtime closure and start the isolated demo. +opencrvs-demo-up: + OPENCRVS_DEMO_ALLOW_IGNORED_RUNTIME_ORIGINS=yes uv run --locked demos/opencrvs-v2/runner.py up + +# Run the live OpenCRVS, Relay, Notary, and holder-bound credential proof. +opencrvs-demo-proof: + uv run --locked demos/opencrvs-v2/runner.py proof + +# Remove the OpenCRVS demo containers, volumes, and ignored runtime closure. +opencrvs-demo-down: + uv run --locked demos/opencrvs-v2/runner.py down # Validate Compose files without starting services. compose: @@ -107,6 +133,7 @@ compose: @if [ -f compose.hosted.yaml ]; then COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.hosted.yaml config >/dev/null; fi @if [ -f compose.esignet.yaml ]; then COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{compose_project_name}}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml config >/dev/null; fi scripts/check-coolify-compose.sh + just opencrvs-demo-compose # Start the local topology. up: diff --git a/scripts/check-config-secrets.py b/scripts/check-config-secrets.py index 1d9d819..9742ceb 100755 --- a/scripts/check-config-secrets.py +++ b/scripts/check-config-secrets.py @@ -12,6 +12,8 @@ "ministries", "metadata", "projects", + "demos/opencrvs-v2/project", + "demos/opencrvs-v2/compose.yaml", "runtime/registry-projects", "config/hosted-relay-bundles", "compose.yaml", @@ -51,7 +53,13 @@ def iter_files() -> list[Path]: if path.is_file(): files.append(path) elif path.exists(): - files.extend(p for p in path.rglob("*") if p.is_file()) + files.extend( + p + for p in path.rglob("*") + if p.is_file() + and ".registry-stack" not in p.parts + and ".runtime" not in p.parts + ) files.extend(sorted(ROOT.glob("compose.coolify*.yaml"))) return files diff --git a/scripts/check-fiction.sh b/scripts/check-fiction.sh index da077eb..59b21e2 100755 --- a/scripts/check-fiction.sh +++ b/scripts/check-fiction.sh @@ -22,6 +22,8 @@ for term in "${forbidden[@]}"; do --exclude-dir=node_modules \ --exclude-dir=.venv \ --exclude-dir=.cache \ + --exclude-dir=.registry-stack \ + --exclude-dir=.runtime \ --exclude-dir=.svelte-kit \ --exclude-dir=build \ --exclude-dir=output \ From 776bff7f6b1ee9beab80abb3ce02caaf81ff689e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 04:19:52 +0700 Subject: [PATCH 2/6] fix(demo): verify issued evidence and cleanup safely Signed-off-by: Jeremi Joslin --- demos/opencrvs-v2/README.md | 16 +- .../project/environments/local.yaml | 4 +- demos/opencrvs-v2/runner.py | 44 ++++- demos/opencrvs-v2/tests/test_runner.py | 160 ++++++++++++++++-- docs/changelog.md | 5 +- 5 files changed, 200 insertions(+), 29 deletions(-) diff --git a/demos/opencrvs-v2/README.md b/demos/opencrvs-v2/README.md index 3390186..634ebce 100644 --- a/demos/opencrvs-v2/README.md +++ b/demos/opencrvs-v2/README.md @@ -24,8 +24,9 @@ The intended live path is: 3. Relay emits six booleans and no identifying source values. 4. Notary evaluates five predicate claims. 5. Notary issues a holder-bound `dc+sd-jwt` through `POST /v1/credentials`. -6. The runner verifies the issuer signature, disclosures, and ephemeral - `did:jwk` holder binding in memory. +6. The runner verifies the issuer signature, the exact five disclosed + predicate names and `true` values, and the ephemeral `did:jwk` holder + binding in memory. ## Release boundary @@ -200,8 +201,9 @@ known-record search, one exact syntactically valid no-match search, and direct credential issuance. The subshell trap runs `opencrvs-demo-down` after success or failure. That -command removes the demo containers, volumes, and ignored runtime closure, and -is safe to rerun. +command removes the demo containers, volumes, and ignored runtime closure +without reading operator or generated runtime credentials, and is safe to +rerun after credentials are missing, incomplete, or rotated. ## Evidence and privacy @@ -231,6 +233,12 @@ remain memory-only. The evidence reports issuer, audience, scope, and lifetime from unsigned token claims and labels that parsing explicitly. It does not claim those metadata fields were cryptographically verified. +Credential issuance uses the canonical Solmara Civil Registration Authority +identifier, `did:web:id.registrystack.org:solmara:authority:cra`, with a +disposable local demo signing key. The runner verifies that local signature and +the credential identity, but this demo does not perform public DID resolution +or prove possession of a production CRA signing key. + ## Capability boundary Implemented and verified offline: diff --git a/demos/opencrvs-v2/project/environments/local.yaml b/demos/opencrvs-v2/project/environments/local.yaml index ac4b3e4..0ee47b8 100644 --- a/demos/opencrvs-v2/project/environments/local.yaml +++ b/demos/opencrvs-v2/project/environments/local.yaml @@ -21,8 +21,8 @@ integrations: timeout: 15s issuance: - issuer: did:web:opencrvs-demo.invalid - signing_kid: opencrvs-demo-issuer-key-1 + issuer: did:web:id.registrystack.org:solmara:authority:cra + signing_kid: did:web:id.registrystack.org:solmara:authority:cra#opencrvs-demo-issuer-key-1 signing_key: { secret: OPENCRVS_DEMO_ISSUER_JWK } generation: 1 diff --git a/demos/opencrvs-v2/runner.py b/demos/opencrvs-v2/runner.py index de7c51d..9c8167e 100755 --- a/demos/opencrvs-v2/runner.py +++ b/demos/opencrvs-v2/runner.py @@ -60,6 +60,11 @@ def external_env_path() -> Path: CREDENTIAL_FORMAT = "application/dc+sd-jwt" PROFILE = "opencrvs-birth-evidence.birth-predicates" NOTARY_SERVICE_ID = "opencrvs-v2-demo-notary" +CREDENTIAL_ISSUER = "did:web:id.registrystack.org:solmara:authority:cra" +ISSUER_KID = f"{CREDENTIAL_ISSUER}#opencrvs-demo-issuer-key-1" +CREDENTIAL_VCT = ( + "https://id.registrystack.org/solmara/credential/opencrvs-v2-birth-proof/v1" +) CLAIMS = [ "birth-record-exists", "registration-number-matches", @@ -676,7 +681,7 @@ def fresh_runtime_values() -> dict[str, str]: "OPENCRVS_RELAY_WORKLOAD_JWK": generate_private_jwk( "opencrvs-v2-demo-relay-workload-key-1" ), - "OPENCRVS_DEMO_ISSUER_JWK": generate_private_jwk("opencrvs-demo-issuer-key-1"), + "OPENCRVS_DEMO_ISSUER_JWK": generate_private_jwk(ISSUER_KID), } @@ -1135,6 +1140,8 @@ def verify_sd_jwt( issuer_jwk = json.loads(issuer_private_jwk) if ( header.get("alg") != "EdDSA" + or header.get("kid") != ISSUER_KID + or issuer_jwk.get("kid") != ISSUER_KID or header.get("kid") != issuer_jwk.get("kid") or payload.get("_sd_alg") != "sha-256" ): @@ -1157,6 +1164,32 @@ def verify_sd_jwt( ] if sorted(computed) != sorted(digests): raise DemoFailure("the returned disclosures do not match the SD-JWT digests") + try: + disclosed_claims: dict[str, Any] = {} + for encoded in disclosures: + disclosure = json.loads(b64url_decode(encoded)) + if ( + not isinstance(disclosure, list) + or len(disclosure) != 3 + or not isinstance(disclosure[0], str) + or not disclosure[0] + or not isinstance(disclosure[1], str) + or not isinstance(disclosure[2], dict) + or disclosure[2].get("claim_id") != disclosure[1] + or disclosure[2].get("value") is not True + or disclosure[2].get("satisfied") is not True + or disclosure[1] in disclosed_claims + ): + raise DemoFailure("the SD-JWT disclosure set is invalid") + disclosed_claims[disclosure[1]] = True + except (json.JSONDecodeError, UnicodeDecodeError, ValueError, TypeError) as error: + raise DemoFailure("the SD-JWT disclosure set is invalid") from error + if disclosed_claims != {claim: True for claim in CLAIMS}: + raise DemoFailure( + "the SD-JWT does not disclose the expected evaluated predicates" + ) + if payload.get("iss") != CREDENTIAL_ISSUER or payload.get("vct") != CREDENTIAL_VCT: + raise DemoFailure("the SD-JWT credential identity is invalid") confirmation = payload.get("cnf") if not isinstance(confirmation, dict): raise DemoFailure("the SD-JWT has no holder confirmation") @@ -1181,6 +1214,7 @@ def verify_sd_jwt( "disclosure_count": len(disclosures), "issuer_signature_valid": True, "disclosures_match_digests": True, + "disclosed_claims_verified": True, "holder_binding": "did:jwk", "cnf_matches_ephemeral_holder": True, "sha256": hashlib.sha256(credential.encode("utf-8")).hexdigest(), @@ -1517,15 +1551,9 @@ def compose_config() -> None: def down() -> None: - external: dict[str, str] | None = None - runtime: dict[str, str] | None = None - if EXTERNAL_ENV.is_file(): - external = read_dotenv(EXTERNAL_ENV) - if RUNTIME_ENV.is_file(): - runtime = read_dotenv(RUNTIME_ENV) run( compose_command("down", "-v", "--remove-orphans"), - env=compose_environment(external, runtime), + env=compose_environment(), ) if RUNTIME.exists(): resolved = RUNTIME.resolve() diff --git a/demos/opencrvs-v2/tests/test_runner.py b/demos/opencrvs-v2/tests/test_runner.py index ff12dba..b5ef3dc 100644 --- a/demos/opencrvs-v2/tests/test_runner.py +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -89,6 +89,24 @@ def test_accepts_only_path_free_https_dns_hosts(self) -> None: runner.opencrvs_host(invalid) +class AuthoredProjectTests(unittest.TestCase): + def test_credential_issuer_is_the_canonical_solmara_cra(self) -> None: + environment = runner.yaml.safe_load( + (runner.AUTHORED_PROJECT / "environments" / "local.yaml").read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + environment["issuance"], + { + "issuer": runner.CREDENTIAL_ISSUER, + "signing_kid": runner.ISSUER_KID, + "signing_key": {"secret": "OPENCRVS_DEMO_ISSUER_JWK"}, + "generation": 1, + }, + ) + + class RegistryctlIdentityTests(unittest.TestCase): def test_requires_exact_commit_for_development_override(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -480,28 +498,54 @@ def test_negative_rejects_any_source_boundary_dispatch(self) -> None: class CredentialVerificationTests(unittest.TestCase): def make_credential( self, + disclosed_claims: list[tuple[str, object]] | None = None, + *, + issuer: str = runner.CREDENTIAL_ISSUER, + vct: str = runner.CREDENTIAL_VCT, + kid: str = runner.ISSUER_KID, + embedded_claim_ids: dict[str, str] | None = None, ) -> tuple[str, str, str, dict[str, str], str]: - issuer_jwk = json.loads(runner.generate_private_jwk("issuer-key-1")) + issuer_jwk = json.loads(runner.generate_private_jwk(kid)) issuer_private = Ed25519PrivateKey.from_private_bytes( runner.b64url_decode(issuer_jwk["d"]) ) holder_id, _, holder_public = runner.holder_material() - disclosure = runner.b64url( - json.dumps( - ["salt", "birth-record-exists", True], - separators=(",", ":"), - ).encode("utf-8") - ) + requested = disclosed_claims + if requested is None: + requested = [(claim, True) for claim in runner.CLAIMS] + disclosures = [ + runner.b64url( + json.dumps( + [ + f"salt-{index}", + claim, + { + "claim_id": (embedded_claim_ids or {}).get(claim, claim), + "version": "1", + "value": value, + "satisfied": value, + "subject_type": "Person", + "issued_at": "2026-01-01T00:00:00Z", + }, + ], + separators=(",", ":"), + ).encode("utf-8") + ) + for index, (claim, value) in enumerate(requested) + ] payload = { - "_sd": [runner.b64url(hashlib.sha256(disclosure.encode("ascii")).digest())], + "_sd": [ + runner.b64url(hashlib.sha256(disclosure.encode("ascii")).digest()) + for disclosure in disclosures + ], "_sd_alg": "sha-256", "cnf": {"kid": holder_id, "jwk": holder_public}, "exp": 1_030, "iat": 1_000, - "iss": "did:web:issuer.example", - "vct": "https://id.example/credential/v1", + "iss": issuer, + "vct": vct, } - header = {"alg": "EdDSA", "kid": "issuer-key-1"} + header = {"alg": "EdDSA", "kid": kid} header_segment = runner.b64url( json.dumps(header, separators=(",", ":")).encode("utf-8") ) @@ -514,11 +558,11 @@ def make_credential( f"{runner.b64url(issuer_private.sign(signing_input))}" ) return ( - f"{compact}~{disclosure}~", + f"{compact}~{'~'.join(disclosures)}~", json.dumps(issuer_jwk), holder_id, holder_public, - disclosure, + disclosures[0] if disclosures else "", ) def test_verifies_signature_disclosures_and_holder_binding(self) -> None: @@ -531,8 +575,10 @@ def test_verifies_signature_disclosures_and_holder_binding(self) -> None: ) self.assertTrue(summary["issuer_signature_valid"]) self.assertTrue(summary["disclosures_match_digests"]) + self.assertTrue(summary["disclosed_claims_verified"]) self.assertTrue(summary["cnf_matches_ephemeral_holder"]) - self.assertEqual(summary["disclosure_count"], 1) + self.assertEqual(summary["disclosure_count"], len(runner.CLAIMS)) + self.assertEqual(summary["issuer"], runner.CREDENTIAL_ISSUER) self.assertNotIn(credential, json.dumps(summary)) def test_rejects_a_disclosure_not_bound_by_the_sd_jwt(self) -> None: @@ -548,6 +594,92 @@ def test_rejects_a_disclosure_not_bound_by_the_sd_jwt(self) -> None: holder_public, ) + def test_rejects_missing_unrelated_false_or_duplicate_predicates(self) -> None: + invalid_sets = [ + [], + [("unrelated-claim", True)], + [ + (runner.CLAIMS[0], False), + *[(claim, True) for claim in runner.CLAIMS[1:]], + ], + [ + (runner.CLAIMS[0], True), + (runner.CLAIMS[0], True), + *[(claim, True) for claim in runner.CLAIMS[1:]], + ], + ] + for disclosed_claims in invalid_sets: + with self.subTest(disclosed_claims=disclosed_claims): + credential, issuer_jwk, holder_id, holder_public, _ = ( + self.make_credential(disclosed_claims) + ) + with self.assertRaises(runner.DemoFailure): + runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + + def test_rejects_noncanonical_credential_identity(self) -> None: + for identity in ( + {"issuer": "did:web:opencrvs-demo.invalid"}, + {"vct": "https://id.registrystack.org/solmara/credential/unrelated/v1"}, + {"kid": "did:web:id.registrystack.org:solmara:authority:cra#unrelated"}, + ): + with self.subTest(identity=identity): + credential, issuer_jwk, holder_id, holder_public, _ = ( + self.make_credential(**identity) + ) + with self.assertRaises(runner.DemoFailure): + runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + + def test_rejects_mismatched_embedded_claim_id(self) -> None: + credential, issuer_jwk, holder_id, holder_public, _ = self.make_credential( + embedded_claim_ids={runner.CLAIMS[0]: "unrelated-claim"} + ) + with self.assertRaises(runner.DemoFailure): + runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + + +class CleanupTests(unittest.TestCase): + def test_down_ignores_incomplete_operator_and_runtime_credentials(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + demo = Path(temporary) / "opencrvs-v2" + runtime = demo / ".runtime" + runtime.mkdir(parents=True) + external = Path(temporary) / "operator.env" + external.write_text( + "OPENCRVS_CLIENT_ID=rotated-client-only\n", + encoding="utf-8", + ) + runtime_env = runtime / "local.env" + runtime_env.write_text("incomplete runtime file\n", encoding="utf-8") + with ( + mock.patch.object(runner, "DEMO", demo), + mock.patch.object(runner, "RUNTIME", runtime), + mock.patch.object(runner, "RUNTIME_PROJECT", runtime / "project"), + mock.patch.object(runner, "RUNTIME_ENV", runtime_env), + mock.patch.object(runner, "EXTERNAL_ENV", external), + mock.patch.object(runner, "run") as run_mock, + ): + runner.down() + + self.assertFalse(runtime.exists()) + command = run_mock.call_args.args[0] + self.assertIn("down", command) + self.assertIn("--remove-orphans", command) + class SanitizationTests(unittest.TestCase): def test_scan_rejects_exact_sensitive_values(self) -> None: diff --git a/docs/changelog.md b/docs/changelog.md index feaefb9..54d377e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,7 +16,10 @@ blocked and must not be reported as a released result. Holder binding proves possession of the demo's ephemeral key, not a parent or informant relationship, and this is not an OID4VCI wallet flow. Operator credentials and source values remain outside the repository, and live origins are limited to an ignored -runtime closure. +runtime closure. Credential verification requires all five requested predicate +disclosures with their expected `true` values, uses the canonical Solmara CRA +identifier with a disposable local signing key, and cleanup no longer depends +on complete operator or generated runtime credential files. OpenCRVS omits `expires_in` from its otherwise strict bearer-token response. The demo therefore selects Registry Stack's explicit From ba9db373e5899114fc0793a24fbff8f061d06c62 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 04:39:02 +0700 Subject: [PATCH 3/6] fix(demo): require exact negative proof contracts Signed-off-by: Jeremi Joslin --- demos/opencrvs-v2/README.md | 4 +++ demos/opencrvs-v2/runner.py | 23 +++++++++++++-- demos/opencrvs-v2/tests/test_runner.py | 41 ++++++++++++++++++++++++++ docs/changelog.md | 4 ++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/demos/opencrvs-v2/README.md b/demos/opencrvs-v2/README.md index 634ebce..42703ad 100644 --- a/demos/opencrvs-v2/README.md +++ b/demos/opencrvs-v2/README.md @@ -200,6 +200,10 @@ be running. It performs three pre-dispatch negative controls, one exact known-record search, one exact syntactically valid no-match search, and direct credential issuance. +Each negative control must return its expected HTTP status and stable problem +code with zero dispatches. The no-match control requires `birth-record-exists` +to be `false` and all four dependent predicates to be `null`. + The subshell trap runs `opencrvs-demo-down` after success or failure. That command removes the demo containers, volumes, and ignored runtime closure without reading operator or generated runtime credentials, and is safe to diff --git a/demos/opencrvs-v2/runner.py b/demos/opencrvs-v2/runner.py index 9c8167e..2c44d54 100755 --- a/demos/opencrvs-v2/runner.py +++ b/demos/opencrvs-v2/runner.py @@ -72,6 +72,9 @@ def external_env_path() -> Path: "mother-recorded-on-birth", "informant-is-mother", ] +NO_MATCH_RESULTS = { + claim: False if claim == "birth-record-exists" else None for claim in CLAIMS +} EXPECTED_OUTPUTS = { "child_national_id_matches": "boolean", "event_type_birth": "boolean", @@ -1034,6 +1037,9 @@ def live_negative( headers: Mapping[str, str], body: Mapping[str, Any], environment: Mapping[str, str], + *, + expected_status: int, + expected_code: str, ) -> dict[str, Any]: before = relay_activity(environment) result = http_json("POST", f"{url}/v1/evaluations", headers, body) @@ -1046,7 +1052,8 @@ def live_negative( after.data_dispatches - before.data_dispatches ) if ( - not summary["rejected"] + summary["status"] != expected_status + or summary["code"] != expected_code or summary["credential_dispatch_delta"] != 0 or summary["source_data_dispatch_delta"] != 0 ): @@ -1079,6 +1086,11 @@ def evaluation_summary(result: HttpResult) -> dict[str, Any]: } +def require_no_match_contract(summary: Mapping[str, Any]) -> None: + if summary.get("results") != NO_MATCH_RESULTS: + raise DemoFailure("the nonexistent registration did not return exact no match") + + def first_evaluation_id(body: Any) -> str: if isinstance(body, dict) and isinstance(body.get("results"), list): for result in body["results"]: @@ -1371,12 +1383,16 @@ def proof() -> None: api_headers(secrets.token_urlsafe(32), PURPOSE), request_body, environment, + expected_status=401, + expected_code="auth.missing_credential", ) wrong_purpose = live_negative( url, api_headers(caller_token, WRONG_PURPOSE), request_body, environment, + expected_status=403, + expected_code="purpose.not_allowed", ) invalid_body = evaluation_body(selectors) invalid_body["target"]["identifiers"][0]["value"] = "INVALID" @@ -1385,6 +1401,8 @@ def proof() -> None: api_headers(caller_token, PURPOSE), invalid_body, environment, + expected_status=409, + expected_code="evidence.not_available", ) before_positive = relay_activity(environment) @@ -1419,8 +1437,7 @@ def proof() -> None: ) after_missing = relay_activity(environment) missing_summary = evaluation_summary(missing) - if missing_summary["results"]["birth-record-exists"] is not False: - raise DemoFailure("the nonexistent registration did not return no match") + require_no_match_contract(missing_summary) missing_dispatch = exact_consultation_dispatch( before_missing, after_missing, "no-match" ) diff --git a/demos/opencrvs-v2/tests/test_runner.py b/demos/opencrvs-v2/tests/test_runner.py index b5ef3dc..0efb618 100644 --- a/demos/opencrvs-v2/tests/test_runner.py +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -492,8 +492,49 @@ def test_negative_rejects_any_source_boundary_dispatch(self) -> None: {}, {}, {}, + expected_status=403, + expected_code="purpose.not_allowed", ) + def test_negative_requires_expected_status_and_code(self) -> None: + for unexpected in ( + runner.HttpResult(500, {"code": "internal.error"}, {}), + runner.HttpResult(429, {"code": "rate_limited"}, {}), + runner.HttpResult(403, {"code": "purpose.denied"}, {}), + ): + with ( + self.subTest(unexpected=unexpected), + mock.patch.object( + runner, + "relay_activity", + side_effect=[ + runner.RelayActivity(0, 0, 0), + runner.RelayActivity(0, 0, 0), + ], + ), + mock.patch.object(runner, "http_json", return_value=unexpected), + self.assertRaises(runner.DemoFailure), + ): + runner.live_negative( + "http://127.0.0.1:4391", + {}, + {}, + {}, + expected_status=403, + expected_code="purpose.not_allowed", + ) + + def test_no_match_requires_every_dependent_predicate_to_be_null(self) -> None: + runner.require_no_match_contract({"results": dict(runner.NO_MATCH_RESULTS)}) + for unexpected in (True, False): + results = dict(runner.NO_MATCH_RESULTS) + results[runner.CLAIMS[1]] = unexpected + with ( + self.subTest(unexpected=unexpected), + self.assertRaises(runner.DemoFailure), + ): + runner.require_no_match_contract({"results": results}) + class CredentialVerificationTests(unittest.TestCase): def make_credential( diff --git a/docs/changelog.md b/docs/changelog.md index 54d377e..1958c3c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,7 +19,9 @@ remain outside the repository, and live origins are limited to an ignored runtime closure. Credential verification requires all five requested predicate disclosures with their expected `true` values, uses the canonical Solmara CRA identifier with a disposable local signing key, and cleanup no longer depends -on complete operator or generated runtime credential files. +on complete operator or generated runtime credential files. Live negative +controls require their exact status and stable problem code, and the no-match +control requires every dependent predicate to remain `null`. OpenCRVS omits `expires_in` from its otherwise strict bearer-token response. The demo therefore selects Registry Stack's explicit From cf78b64993399778764224b2919e29ebae4fddc0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 04:54:05 +0700 Subject: [PATCH 4/6] fix(demo): bind proof to live runtime identity Signed-off-by: Jeremi Joslin --- demos/opencrvs-v2/README.md | 11 ++- demos/opencrvs-v2/runner.py | 70 +++++++++++++++- demos/opencrvs-v2/tests/test_runner.py | 111 ++++++++++++++++++++++++- docs/changelog.md | 4 +- 4 files changed, 185 insertions(+), 11 deletions(-) diff --git a/demos/opencrvs-v2/README.md b/demos/opencrvs-v2/README.md index 42703ad..e14bb25 100644 --- a/demos/opencrvs-v2/README.md +++ b/demos/opencrvs-v2/README.md @@ -117,9 +117,11 @@ clean at that commit and requires both declared source commits to match. It also inspects the Relay image for the matching `org.opencontainers.image.revision` label and the exact `attribute-release,crosswalk-runtime` feature label. Sanitized evidence records -the compiler executable SHA-256 and the inspected Relay image ID. These paired -overrides are for local development proof only. Do not deploy the candidate or -replace release pins with its tag. +the compiler executable SHA-256 and the inspected Relay image ID. The proof +re-inspects the running Relay container and fails if its image ID, image +reference, source revision, or feature label differs from that declared +identity. These paired overrides are for local development proof only. Do not +deploy the candidate or replace release pins with its tag. Once a release contains the Registryctl profile, Relay's explicit no-cache state-plane handling, and the active script-budget correction, update the @@ -219,7 +221,8 @@ and the authoring compiler identity. It reports public authored bounds separately from effective private runtime limits, and requires exactly one credential dispatch plus one source dispatch for each live consultation. A development proof records the exact shared source commit, compiler executable -SHA-256, and inspected Relay image ID. +SHA-256, and running Relay image ID. Issued credentials must be currently valid +within 30 seconds of clock skew and match the authored 10-minute lifetime. The runner fails before writing evidence if its scan finds any of the following in compiled configuration, existing evidence, container logs, or the pending diff --git a/demos/opencrvs-v2/runner.py b/demos/opencrvs-v2/runner.py index 2c44d54..7e733ae 100755 --- a/demos/opencrvs-v2/runner.py +++ b/demos/opencrvs-v2/runner.py @@ -65,6 +65,8 @@ def external_env_path() -> Path: CREDENTIAL_VCT = ( "https://id.registrystack.org/solmara/credential/opencrvs-v2-birth-proof/v1" ) +CREDENTIAL_VALIDITY_SECONDS = 600 +CREDENTIAL_CLOCK_SKEW_SECONDS = 30 CLAIMS = [ "birth-record-exists", "registration-number-matches", @@ -484,6 +486,52 @@ def relay_runtime_identity( } +def running_relay_runtime_identity( + expected: Mapping[str, Any], + environment: Mapping[str, str], +) -> dict[str, Any]: + container = run( + compose_command("ps", "-q", "opencrvs-relay"), + env=environment, + ).stdout.strip() + if re.fullmatch(r"[0-9a-f]{64}", container) is None: + raise DemoFailure("the running OpenCRVS Relay container is unavailable") + inspected = run( + [ + "docker", + "container", + "inspect", + "--format", + ( + "{{.Image}}|{{.Config.Image}}|" + '{{index .Config.Labels "org.opencontainers.image.revision"}}|' + '{{index .Config.Labels "org.registrystack.registry-relay.features"}}' + ), + container, + ], + env=environment, + ) + parts = inspected.stdout.strip().split("|") + if ( + len(parts) != 4 + or re.fullmatch(r"sha256:[0-9a-f]{64}", parts[0]) is None + or parts[1] != expected.get("relay_image") + or parts[2] != expected.get("source_commit") + or parts[3] != "attribute-release,crosswalk-runtime" + or ( + expected.get("relay_image_id") is not None + and parts[0] != expected["relay_image_id"] + ) + ): + raise DemoFailure( + "the running Relay does not match the declared image and source identity" + ) + identity = dict(expected) + identity["relay_image_id"] = parts[0] + identity["running_container_verified"] = True + return identity + + def registry_command( action: str, project: Path, @@ -1214,7 +1262,16 @@ def verify_sd_jwt( ) issued = payload.get("iat") expires = payload.get("exp") - if not isinstance(issued, int) or not isinstance(expires, int) or expires <= issued: + now = int(time.time()) + if ( + not isinstance(issued, int) + or isinstance(issued, bool) + or not isinstance(expires, int) + or isinstance(expires, bool) + or expires - issued != CREDENTIAL_VALIDITY_SECONDS + or issued > now + CREDENTIAL_CLOCK_SKEW_SECONDS + or expires <= now - CREDENTIAL_CLOCK_SKEW_SECONDS + ): raise DemoFailure("the SD-JWT lifetime is invalid") return { "format": CREDENTIAL_FORMAT, @@ -1223,6 +1280,8 @@ def verify_sd_jwt( "kid": header.get("kid"), "algorithm": header.get("alg"), "lifetime_seconds": expires - issued, + "currently_valid": True, + "authored_lifetime_verified": True, "disclosure_count": len(disclosures), "issuer_signature_valid": True, "disclosures_match_digests": True, @@ -1372,6 +1431,12 @@ def proof() -> None: runtime = read_dotenv(RUNTIME_ENV) environment = compose_environment(external, runtime) wait_ready(notary_url(), timeout=10) + versions = read_dotenv(ROOT / "versions.env") + compiler = registryctl_identity(versions) + registry_runtime = running_relay_runtime_identity( + relay_runtime_identity(versions, compiler), + environment, + ) offline = offline_checks() oauth, oauth_token = oauth_probe(external) url = notary_url() @@ -1445,9 +1510,6 @@ def proof() -> None: logs = run(compose_command("logs", "--no-color"), env=environment).stdout.encode( "utf-8" ) - versions = read_dotenv(ROOT / "versions.env") - compiler = registryctl_identity(versions) - registry_runtime = relay_runtime_identity(versions, compiler) artifacts = compiled_artifacts() evidence: dict[str, Any] = { "schema_version": "solmara.opencrvs-v2-demo.evidence.v1", diff --git a/demos/opencrvs-v2/tests/test_runner.py b/demos/opencrvs-v2/tests/test_runner.py index 0efb618..f8a65f5 100644 --- a/demos/opencrvs-v2/tests/test_runner.py +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -299,6 +299,71 @@ def test_rejects_a_candidate_from_another_platform(self) -> None: ): runner.relay_runtime_identity(versions, compiler) + def test_binds_evidence_to_the_running_relay_container(self) -> None: + commit = "b" * 40 + image_id = f"sha256:{'1' * 64}" + expected = { + "version": "registry-relay 0.15.2", + "source_commit": commit, + "relay_image": "registry-relay:candidate", + "relay_image_id": image_id, + "development_override": True, + } + results = [ + subprocess.CompletedProcess( + ["docker", "compose", "ps"], + 0, + stdout=f"{'c' * 64}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "container", "inspect"], + 0, + stdout=( + f"{image_id}|registry-relay:candidate|{commit}|" + "attribute-release,crosswalk-runtime\n" + ), + stderr="", + ), + ] + with mock.patch.object(runner, "run", side_effect=results): + self.assertEqual( + runner.running_relay_runtime_identity(expected, {}), + { + **expected, + "running_container_verified": True, + }, + ) + + def test_rejects_running_relay_that_differs_from_declared_identity(self) -> None: + expected = { + "source_commit": "a" * 40, + "relay_image": "relay@sha256:released", + "development_override": False, + } + results = [ + subprocess.CompletedProcess( + ["docker", "compose", "ps"], + 0, + stdout=f"{'c' * 64}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "container", "inspect"], + 0, + stdout=( + f"sha256:{'1' * 64}|registry-relay:candidate|{'b' * 40}|" + "attribute-release,crosswalk-runtime\n" + ), + stderr="", + ), + ] + with ( + mock.patch.object(runner, "run", side_effect=results), + self.assertRaises(runner.DemoFailure), + ): + runner.running_relay_runtime_identity(expected, {}) + def test_rejects_dirty_or_mismatched_development_source(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "registry-stack" @@ -545,6 +610,8 @@ def make_credential( vct: str = runner.CREDENTIAL_VCT, kid: str = runner.ISSUER_KID, embedded_claim_ids: dict[str, str] | None = None, + issued_at: int | None = None, + expires_at: int | None = None, ) -> tuple[str, str, str, dict[str, str], str]: issuer_jwk = json.loads(runner.generate_private_jwk(kid)) issuer_private = Ed25519PrivateKey.from_private_bytes( @@ -554,6 +621,10 @@ def make_credential( requested = disclosed_claims if requested is None: requested = [(claim, True) for claim in runner.CLAIMS] + if issued_at is None: + issued_at = int(runner.time.time()) + if expires_at is None: + expires_at = issued_at + runner.CREDENTIAL_VALIDITY_SECONDS disclosures = [ runner.b64url( json.dumps( @@ -581,8 +652,8 @@ def make_credential( ], "_sd_alg": "sha-256", "cnf": {"kid": holder_id, "jwk": holder_public}, - "exp": 1_030, - "iat": 1_000, + "exp": expires_at, + "iat": issued_at, "iss": issuer, "vct": vct, } @@ -618,6 +689,8 @@ def test_verifies_signature_disclosures_and_holder_binding(self) -> None: self.assertTrue(summary["disclosures_match_digests"]) self.assertTrue(summary["disclosed_claims_verified"]) self.assertTrue(summary["cnf_matches_ephemeral_holder"]) + self.assertTrue(summary["currently_valid"]) + self.assertTrue(summary["authored_lifetime_verified"]) self.assertEqual(summary["disclosure_count"], len(runner.CLAIMS)) self.assertEqual(summary["issuer"], runner.CREDENTIAL_ISSUER) self.assertNotIn(credential, json.dumps(summary)) @@ -692,6 +765,40 @@ def test_rejects_mismatched_embedded_claim_id(self) -> None: holder_public, ) + def test_rejects_expired_future_or_wrong_lifetime(self) -> None: + now = int(runner.time.time()) + invalid_lifetimes = ( + { + "issued_at": now - 700, + "expires_at": now - 100, + }, + { + "issued_at": now + runner.CREDENTIAL_CLOCK_SKEW_SECONDS + 1, + "expires_at": ( + now + + runner.CREDENTIAL_CLOCK_SKEW_SECONDS + + 1 + + runner.CREDENTIAL_VALIDITY_SECONDS + ), + }, + { + "issued_at": now, + "expires_at": now + runner.CREDENTIAL_VALIDITY_SECONDS + 1, + }, + ) + for lifetime in invalid_lifetimes: + with self.subTest(lifetime=lifetime): + credential, issuer_jwk, holder_id, holder_public, _ = ( + self.make_credential(**lifetime) + ) + with self.assertRaises(runner.DemoFailure): + runner.verify_sd_jwt( + credential, + issuer_jwk, + holder_id, + holder_public, + ) + class CleanupTests(unittest.TestCase): def test_down_ignores_incomplete_operator_and_runtime_credentials(self) -> None: diff --git a/docs/changelog.md b/docs/changelog.md index 1958c3c..691b92a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,7 +21,9 @@ disclosures with their expected `true` values, uses the canonical Solmara CRA identifier with a disposable local signing key, and cleanup no longer depends on complete operator or generated runtime credential files. Live negative controls require their exact status and stable problem code, and the no-match -control requires every dependent predicate to remain `null`. +control requires every dependent predicate to remain `null`. Evidence identity +is bound to the running Relay container, and credential verification enforces +current validity plus the authored 10-minute lifetime. OpenCRVS omits `expires_in` from its otherwise strict bearer-token response. The demo therefore selects Registry Stack's explicit From d8c766f7b77cd65be6bc968304ebde93edb9e37d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 05:07:46 +0700 Subject: [PATCH 5/6] fix(demo): verify Notary and predicate types Signed-off-by: Jeremi Joslin --- demos/opencrvs-v2/README.md | 12 ++-- demos/opencrvs-v2/runner.py | 33 ++++++++++- demos/opencrvs-v2/tests/test_runner.py | 77 ++++++++++++++++++++++++++ docs/changelog.md | 5 +- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/demos/opencrvs-v2/README.md b/demos/opencrvs-v2/README.md index e14bb25..c6bb105 100644 --- a/demos/opencrvs-v2/README.md +++ b/demos/opencrvs-v2/README.md @@ -118,9 +118,10 @@ also inspects the Relay image for the matching `org.opencontainers.image.revision` label and the exact `attribute-release,crosswalk-runtime` feature label. Sanitized evidence records the compiler executable SHA-256 and the inspected Relay image ID. The proof -re-inspects the running Relay container and fails if its image ID, image -reference, source revision, or feature label differs from that declared -identity. These paired overrides are for local development proof only. Do not +re-inspects the running Relay and Notary containers. It fails if the Relay +image ID, image reference, source revision, or feature label differs from the +declared identity, or if the Notary image reference differs from the pinned +digest. These paired overrides are for local development proof only. Do not deploy the candidate or replace release pins with its tag. Once a release contains the Registryctl profile, Relay's explicit no-cache @@ -221,8 +222,9 @@ and the authoring compiler identity. It reports public authored bounds separately from effective private runtime limits, and requires exactly one credential dispatch plus one source dispatch for each live consultation. A development proof records the exact shared source commit, compiler executable -SHA-256, and running Relay image ID. Issued credentials must be currently valid -within 30 seconds of clock skew and match the authored 10-minute lifetime. +SHA-256, running Relay image ID, and running Notary image ID. Issued credentials +must be currently valid within 30 seconds of clock skew and match the authored +10-minute lifetime. The runner fails before writing evidence if its scan finds any of the following in compiled configuration, existing evidence, container logs, or the pending diff --git a/demos/opencrvs-v2/runner.py b/demos/opencrvs-v2/runner.py index 7e733ae..26cd102 100755 --- a/demos/opencrvs-v2/runner.py +++ b/demos/opencrvs-v2/runner.py @@ -529,6 +529,37 @@ def running_relay_runtime_identity( identity = dict(expected) identity["relay_image_id"] = parts[0] identity["running_container_verified"] = True + notary_container = run( + compose_command("ps", "-q", "opencrvs-notary"), + env=environment, + ).stdout.strip() + if re.fullmatch(r"[0-9a-f]{64}", notary_container) is None: + raise DemoFailure("the running OpenCRVS Notary container is unavailable") + notary_inspected = ( + run( + [ + "docker", + "container", + "inspect", + "--format", + "{{.Image}}|{{.Config.Image}}", + notary_container, + ], + env=environment, + ) + .stdout.strip() + .split("|") + ) + if ( + len(notary_inspected) != 2 + or re.fullmatch(r"sha256:[0-9a-f]{64}", notary_inspected[0]) is None + or notary_inspected[1] != expected.get("notary_image") + ): + raise DemoFailure( + "the running Notary does not match the declared image identity" + ) + identity["notary_image_id"] = notary_inspected[0] + identity["running_notary_container_verified"] = True return identity @@ -1122,7 +1153,7 @@ def evaluation_summary(result: HttpResult) -> dict[str, Any]: if not isinstance(item, dict) or item.get("claim_id") not in CLAIMS: raise DemoFailure("Notary evaluation returned an unknown claim") raw_value = item.get("satisfied", item.get("value")) - if raw_value not in (True, False, None): + if raw_value is not None and not isinstance(raw_value, bool): raise DemoFailure("Notary evaluation returned a non-predicate result") values[item["claim_id"]] = raw_value if set(values) != set(CLAIMS): diff --git a/demos/opencrvs-v2/tests/test_runner.py b/demos/opencrvs-v2/tests/test_runner.py index f8a65f5..f0e2645 100644 --- a/demos/opencrvs-v2/tests/test_runner.py +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -302,11 +302,13 @@ def test_rejects_a_candidate_from_another_platform(self) -> None: def test_binds_evidence_to_the_running_relay_container(self) -> None: commit = "b" * 40 image_id = f"sha256:{'1' * 64}" + notary_image_id = f"sha256:{'2' * 64}" expected = { "version": "registry-relay 0.15.2", "source_commit": commit, "relay_image": "registry-relay:candidate", "relay_image_id": image_id, + "notary_image": "notary@sha256:released", "development_override": True, } results = [ @@ -325,6 +327,18 @@ def test_binds_evidence_to_the_running_relay_container(self) -> None: ), stderr="", ), + subprocess.CompletedProcess( + ["docker", "compose", "ps"], + 0, + stdout=f"{'d' * 64}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "container", "inspect"], + 0, + stdout=f"{notary_image_id}|notary@sha256:released\n", + stderr="", + ), ] with mock.patch.object(runner, "run", side_effect=results): self.assertEqual( @@ -332,6 +346,8 @@ def test_binds_evidence_to_the_running_relay_container(self) -> None: { **expected, "running_container_verified": True, + "notary_image_id": notary_image_id, + "running_notary_container_verified": True, }, ) @@ -364,6 +380,50 @@ def test_rejects_running_relay_that_differs_from_declared_identity(self) -> None ): runner.running_relay_runtime_identity(expected, {}) + def test_rejects_running_notary_that_differs_from_declared_identity(self) -> None: + commit = "a" * 40 + expected = { + "source_commit": commit, + "relay_image": "registry-relay:candidate", + "relay_image_id": f"sha256:{'1' * 64}", + "notary_image": "notary@sha256:declared", + "development_override": True, + } + results = [ + subprocess.CompletedProcess( + ["docker", "compose", "ps"], + 0, + stdout=f"{'c' * 64}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "container", "inspect"], + 0, + stdout=( + f"sha256:{'1' * 64}|registry-relay:candidate|{commit}|" + "attribute-release,crosswalk-runtime\n" + ), + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "compose", "ps"], + 0, + stdout=f"{'d' * 64}\n", + stderr="", + ), + subprocess.CompletedProcess( + ["docker", "container", "inspect"], + 0, + stdout=f"sha256:{'2' * 64}|notary@sha256:running\n", + stderr="", + ), + ] + with ( + mock.patch.object(runner, "run", side_effect=results), + self.assertRaises(runner.DemoFailure), + ): + runner.running_relay_runtime_identity(expected, {}) + def test_rejects_dirty_or_mismatched_development_source(self) -> None: with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) / "registry-stack" @@ -600,6 +660,23 @@ def test_no_match_requires_every_dependent_predicate_to_be_null(self) -> None: ): runner.require_no_match_contract({"results": results}) + def test_evaluation_rejects_numeric_predicate_values(self) -> None: + for numeric in (0, 1): + results = [ + { + "claim_id": claim, + "satisfied": numeric if index == 0 else None, + } + for index, claim in enumerate(runner.CLAIMS) + ] + with ( + self.subTest(numeric=numeric), + self.assertRaises(runner.DemoFailure), + ): + runner.evaluation_summary( + runner.HttpResult(200, {"results": results}, {}) + ) + class CredentialVerificationTests(unittest.TestCase): def make_credential( diff --git a/docs/changelog.md b/docs/changelog.md index 691b92a..c7d96c8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,8 +22,9 @@ identifier with a disposable local signing key, and cleanup no longer depends on complete operator or generated runtime credential files. Live negative controls require their exact status and stable problem code, and the no-match control requires every dependent predicate to remain `null`. Evidence identity -is bound to the running Relay container, and credential verification enforces -current validity plus the authored 10-minute lifetime. +is bound to the running Relay and Notary containers, predicate results require +actual booleans or `null`, and credential verification enforces current +validity plus the authored 10-minute lifetime. OpenCRVS omits `expires_in` from its otherwise strict bearer-token response. The demo therefore selects Registry Stack's explicit From b7a885e76df809e73f31bef92199122cc8c2dc78 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 30 Jul 2026 11:10:14 +0700 Subject: [PATCH 6/6] test(demo): declare OAuth fixture response media type Signed-off-by: Jeremi Joslin --- .../project/integrations/birth-record/fixtures/ambiguous.yaml | 2 ++ .../integrations/birth-record/fixtures/malformed-response.yaml | 2 ++ .../project/integrations/birth-record/fixtures/match.yaml | 2 ++ .../project/integrations/birth-record/fixtures/no-match.yaml | 2 ++ .../integrations/birth-record/fixtures/source-rejected.yaml | 2 ++ .../integrations/birth-record/fixtures/subject-mismatch.yaml | 2 ++ .../integrations/birth-record/fixtures/tracking-mismatch.yaml | 2 ++ .../birth-record/fixtures/unknown-oversized-fields.yaml | 2 ++ 8 files changed, 16 insertions(+) diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml index f1f5152..0e2d504 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml index 46e2e4b..6164bb6 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml index 346dc96..a5aefbf 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml @@ -30,6 +30,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml index f52660a..f27ecaf 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml index 075df62..cce541e 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml index c07dac9..f8d08be 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml index 6f1aa75..67ee721 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer diff --git a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml index d5bed1b..efd5b88 100644 --- a/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml @@ -14,6 +14,8 @@ interactions: body: { grant_type: client_credentials } respond: status: 200 + headers: + Content-Type: application/json body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer