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..c6bb105 --- /dev/null +++ b/demos/opencrvs-v2/README.md @@ -0,0 +1,331 @@ +# 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, the exact five disclosed + predicate names and `true` values, and the 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. The proof +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 +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. + +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 +rerun after credentials are missing, incomplete, or rotated. + +## 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, 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 +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. + +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: + +- 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..0ee47b8 --- /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: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 + +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..0e2d504 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/ambiguous.yaml @@ -0,0 +1,45 @@ +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 + headers: + Content-Type: application/json + 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..6164bb6 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/malformed-response.yaml @@ -0,0 +1,36 @@ +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 + headers: + Content-Type: application/json + 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..a5aefbf --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/match.yaml @@ -0,0 +1,82 @@ +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 + headers: + Content-Type: application/json + 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..f27ecaf --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/no-match.yaml @@ -0,0 +1,44 @@ +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 + headers: + Content-Type: application/json + 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..cce541e --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/source-rejected.yaml @@ -0,0 +1,36 @@ +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 + headers: + Content-Type: application/json + 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..f8d08be --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/subject-mismatch.yaml @@ -0,0 +1,44 @@ +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 + headers: + Content-Type: application/json + 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..67ee721 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/tracking-mismatch.yaml @@ -0,0 +1,44 @@ +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 + headers: + Content-Type: application/json + 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..efd5b88 --- /dev/null +++ b/demos/opencrvs-v2/project/integrations/birth-record/fixtures/unknown-oversized-fields.yaml @@ -0,0 +1,69 @@ +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 + headers: + Content-Type: application/json + 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..26cd102 --- /dev/null +++ b/demos/opencrvs-v2/runner.py @@ -0,0 +1,1713 @@ +#!/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" +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" +) +CREDENTIAL_VALIDITY_SECONDS = 600 +CREDENTIAL_CLOCK_SKEW_SECONDS = 30 +CLAIMS = [ + "birth-record-exists", + "registration-number-matches", + "child-national-id-matches", + "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", + "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 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 + 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 + + +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(ISSUER_KID), + } + + +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], + *, + expected_status: int, + expected_code: 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 ( + summary["status"] != expected_status + or summary["code"] != expected_code + 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 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): + raise DemoFailure("Notary evaluation returned duplicate or missing claims") + return { + "status": result.status, + "claim_ids": CLAIMS, + "results": values, + } + + +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"]: + 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_KID + or issuer_jwk.get("kid") != ISSUER_KID + 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") + 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") + 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") + 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, + "vct": payload.get("vct"), + "issuer": payload.get("iss"), + "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, + "disclosed_claims_verified": 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) + 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() + 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, + 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" + invalid_selector = live_negative( + url, + api_headers(caller_token, PURPOSE), + invalid_body, + environment, + expected_status=409, + expected_code="evidence.not_available", + ) + + 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) + require_no_match_contract(missing_summary) + missing_dispatch = exact_consultation_dispatch( + before_missing, after_missing, "no-match" + ) + + logs = run(compose_command("logs", "--no-color"), env=environment).stdout.encode( + "utf-8" + ) + 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: + run( + compose_command("down", "-v", "--remove-orphans"), + env=compose_environment(), + ) + 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..f0e2645 --- /dev/null +++ b/demos/opencrvs-v2/tests/test_runner.py @@ -0,0 +1,928 @@ +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 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: + 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_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 = [ + 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="", + ), + 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( + runner.running_relay_runtime_identity(expected, {}), + { + **expected, + "running_container_verified": True, + "notary_image_id": notary_image_id, + "running_notary_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_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" + 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", + {}, + {}, + {}, + 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}) + + 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( + 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, + 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( + runner.b64url_decode(issuer_jwk["d"]) + ) + holder_id, _, holder_public = runner.holder_material() + 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( + [ + 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()) + for disclosure in disclosures + ], + "_sd_alg": "sha-256", + "cnf": {"kid": holder_id, "jwk": holder_public}, + "exp": expires_at, + "iat": issued_at, + "iss": issuer, + "vct": vct, + } + header = {"alg": "EdDSA", "kid": kid} + 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}~{'~'.join(disclosures)}~", + json.dumps(issuer_jwk), + holder_id, + holder_public, + disclosures[0] if disclosures else "", + ) + + 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["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)) + + 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, + ) + + 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, + ) + + 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: + 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: + 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..c7d96c8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,47 @@ 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. 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. 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 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 +`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 \