diff --git a/.gitignore b/.gitignore index 27739554..fdadf4eb 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ fixtures/wrpac-clients/*.issued/ # bootmgr editable install metadata (make install) *.egg-info/ + +# nym_auth circuit artifact (decompressed, ~1 MB): a build input for dockerfiles/verifier-nymauth.Dockerfile +fixtures/nym-auth-circuits/*.bin diff --git a/README.md b/README.md index 4ebbae00..5421ef0a 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ The verifier has matching presentation-request templates in `eu.europa.ec.eudi.nym_auth.1`, carrying `birth_date` (full-date, tag 1004), `ssc` - SHA-256 of a 16-byte `secret_seed` the issuer and the prover share - and optionally `psc` (SHA-256 of a prover-only seed). It exists for -`circuit_zk` presentations: pseudonyms and age predicates proven in zero +`zkp_req` presentations (called `circuit_zk` before rulebook v0.4): pseudonyms and age predicates proven in zero knowledge, never disclosed. `fixtures/vc-metadata/nym_auth.mdoc.json` is a byte-for-byte copy of the rulebook's schema until registry.siros.org publishes it (mdoc types stay vendored even with `REGISTRY=external`). @@ -517,6 +517,20 @@ in:** holder-supplied commitments (the `known-offender-matching-psc` configurati lands, REMOVE the fixed `psc` from `nym_auth*.json`: the issuer refuses any document that already carries a value for a configured commitment claim. +**Verifying a pseudonym (gdc config; image comes with vc's verifier release).** `environments/gdc.yaml` +`values.verifier.extraConfig` carries `verifier.nym_auth` (SUNET/vc#832): the allowlist entry +`known-offender-matching-ssc` (circuit ids, Ligero block parameters, artifact size and SHA-256 from +zk-cred-longfellow `src/nymauth/profile.rs`; do not copy the digest from vc's `docs/CONFIGURATION.md`, whose +example has 52 zeros in the middle) and `preferred_vp_formats.zkp_req` (`system_id_values`, `proof_system_values`; rulebook v0.4 names). The ~1 MB decompressed artifact cannot +travel as a Fly secret and is not in the vc repo, so `dockerfiles/verifier-nymauth.Dockerfile` bakes it into the +verifier image at `/etc/vc/circuits/nym_auth-ssc.bin`, checking size and digest at build time (build input: +`fixtures/nym-auth-circuits/nym_auth-ssc.bin`, gitignored; the recipe is in the file). The verifier needs the +`zknative` build and refuses to start without `trust.pdp_url`: it takes the issuer key from the HOLDER's own +chain, so the PDP is the only check that key belongs to an issuer (here: action `mdoc-issuer`, the mDOC IACA +registry; a self-signed chain with the same SAN gets decision false). **`verifier_id` is the verifier's client_id exactly as published, +`x509_san_dns:` with the prefix (rulebook v0.4; the bare host was the rule before), and scopes every +pseudonym: changing it changes every pseudonym this verifier reports.** + ### Service Architecture ``` diff --git a/chart/templates/04-verifier.yaml b/chart/templates/04-verifier.yaml index 148bd66f..48f8a0d4 100644 --- a/chart/templates/04-verifier.yaml +++ b/chart/templates/04-verifier.yaml @@ -30,7 +30,7 @@ common: branding: favicon_path: /branding-assets/favicon.png logo_path: /branding-assets/logo.png - credential_metadata: {{- include "siros-id.vc.credentialMetadata" . | nindent 4 }} + credential_metadata: {{- include "siros-id.vc.credentialMetadataVerifier" . | nindent 4 }} verifier: public_url: https://{{ $hostnameVerifier }} api_server: diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 185c59b7..85539611 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -144,18 +144,31 @@ the file extension is what distinguishes them on disk. concern of the issuer-apigw alone, so it is rendered only by the ...Apigw variant below: the other services have no seed source and must not be handed a block that names one. */ -}} {{- define "siros-id.vc.credentialMetadata" -}} -{{- include "siros-id.vc.credentialMetadata.render" (list . false) -}} +{{- include "siros-id.vc.credentialMetadata.render" (list . false false) -}} {{- end -}} {{- define "siros-id.vc.credentialMetadataApigw" -}} -{{- include "siros-id.vc.credentialMetadata.render" (list . true) -}} +{{- include "siros-id.vc.credentialMetadata.render" (list . true false) -}} +{{- end -}} + +{{/* +The verifier's credential_metadata: the same block WITHOUT the pre-authorized offer twins (`_offer`, +authProvider: preauth). A twin is an issuance-only scope, so listing it in the verifier doubled every entry of +its /ui/metadata and picker. Nothing verifier-side resolves a scope by its `_offer` name; the base scope (nym_auth +included, whose zkp_req presets take source_format and doctype_value from it) stays. The issuer keeps its twins: an +offer's credential_configuration_id is resolved against credential_configurations_supported. +*/}} +{{- define "siros-id.vc.credentialMetadataVerifier" -}} +{{- include "siros-id.vc.credentialMetadata.render" (list . false true) -}} {{- end -}} {{- define "siros-id.vc.credentialMetadata.render" -}} {{- $root := index . 0 -}} {{- $withCommitments := index . 1 -}} +{{- $skipPreauth := index . 2 -}} {{- $_ := required "You must define credential types in the value features.credentialTypes" $root.Values.features.credentialTypes -}} {{- range $id, $data := $root.Values.features.credentialTypes }} +{{- if not (and $skipPreauth (eq (default "" $data.issuance.authProvider) "preauth")) }} {{ $id | quote }}: {{- /* A pre-authorized twin reads its source's metadata file (see features.credentialTypes._offer). */}} {{- $file := $data.metadataFrom | default $id }} @@ -181,6 +194,7 @@ the file extension is what distinguishes them on disk. {{- end }} {{- end }} {{- end }} +{{- end }} {{- end -}} {{/* diff --git a/dockerfiles/verifier-nymauth.Dockerfile b/dockerfiles/verifier-nymauth.Dockerfile new file mode 100644 index 00000000..f47f77d4 --- /dev/null +++ b/dockerfiles/verifier-nymauth.Dockerfile @@ -0,0 +1,34 @@ +# The vc verifier image plus the nym_auth circuit artifact, for environments that verify nym_auth pseudonym proofs +# (environments/gdc.yaml, verifier.nym_auth). +# +# The artifact is the DECOMPRESSED Longfellow circuit (1,062,842 bytes). It cannot travel as a Fly file secret +# (base64 of it exceeds the ~1 MiB machine config body) and the vc repo does not carry it, so it is baked in here. +# The size and digest are checked at build time against the values the allowlist entry in environments/gdc.yaml +# states, which are in turn the ones zk-cred-longfellow's src/nymauth/profile.rs ships; vc re-checks both at startup +# and refuses anything else, so a wrong file fails the build rather than the first presentation. +# +# zstd -dc /longfellow/mdoc/circuits.zst > fixtures/nym-auth-circuits/nym_auth-ssc.bin # gitignored +# docker build -f dockerfiles/verifier-nymauth.Dockerfile \ +# --build-arg BASE=registry.fly.io/sirosid-gdc-vc-verifier: \ +# -t registry.fly.io/sirosid-gdc-vc-verifier:local- . +# +# The source archive is ours (no repository yet): ~/.cache/nymzip/nym-auth-circuits/longfellow/mdoc/circuits.zst +# (395,608 bytes, sha256 6c4b96ed431ee547a3a2e5b62621943a9a0a44646267653bf2db0fcc269b5c3b). Fetch it from its +# repository once there is one. +ARG BASE +FROM alpine:3.20 AS check +ARG ARTIFACT_BYTES=1062842 +ARG ARTIFACT_SHA256=92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f +COPY fixtures/nym-auth-circuits/nym_auth-ssc.bin /nym_auth-ssc.bin +RUN set -e; \ + [ "$(wc -c < /nym_auth-ssc.bin)" = "${ARTIFACT_BYTES}" ] || { echo "artifact is not ${ARTIFACT_BYTES} bytes (decompressed?)"; exit 1; }; \ + echo "${ARTIFACT_SHA256} /nym_auth-ssc.bin" | sha256sum -c - + +# Staged with its directories here: a distroless runtime has no shell to chmod with, and `COPY --chmod` gives the +# directories it creates the FILE's mode (0444: no x bit), which makes the artifact unreadable to the non-root user +# ("open /etc/vc/circuits/nym_auth-ssc.bin: permission denied" at startup). +RUN mkdir -p /out/etc/vc/circuits && cp /nym_auth-ssc.bin /out/etc/vc/circuits/nym_auth-ssc.bin \ + && chmod 0755 /out/etc /out/etc/vc /out/etc/vc/circuits && chmod 0444 /out/etc/vc/circuits/nym_auth-ssc.bin + +FROM ${BASE} +COPY --from=check /out/ / diff --git a/environments/gdc.yaml b/environments/gdc.yaml index 7299aba3..6fe71ad9 100644 --- a/environments/gdc.yaml +++ b/environments/gdc.yaml @@ -28,6 +28,74 @@ credential_registries: - https://registry.siros.org images: + # =========================================================================================================== + # PIN PROVENANCE (2026-10-21). Every `local-` image below is a LOCAL BUILD OF UNMERGED PULL REQUESTS: it + # exists only in registry.fly.io/sirosid-gdc-* and must be re-pinned to a RELEASED image when its PRs are merged and + # released. "Heads" are the PR head commits that were built. + # + # vc-verifier registry.fly.io/sirosid-gdc-vc-verifier:local-1792603698 + # digest sha256:96c41f16dd9324da6890c76b9faa7810d4c8259cf142ffd16360e413c8ca8945 + # source sirosfoundation/vc `integration/gdc-nymauth-v5` @ a56c4be9ec42ef394d536f5ea08d80b50f2a95a0 (merge base + # SUNET/vc main 4bba38e614e1; integration of the stack below, NOT for upstream merge), --target + # runtime-verifier, zknative, + the nym_auth circuit artifact overlay (dockerfiles/verifier-nymauth.Dockerfile) + # PRs https://github.com/SUNET/vc/pull/827 @ 80d6ee9ef (nym_auth circuit allowlist; open) + # https://github.com/SUNET/vc/pull/829 @ 55a8c9d7f (validate a zkp_req response against the request; open) + # https://github.com/SUNET/vc/pull/830 @ 770885f31 (dispatch a zkp_req presentation to the nym_auth verifier, + # incl. the allow-all-PDP fix; open) + # https://github.com/SUNET/vc/pull/831 @ deb0c19b6 (bind a redirect-delivered presentation to its handover; open) + # https://github.com/SUNET/vc/pull/832 @ 6649047e4 (operator docs; open) + # plus the rulebook v0.4 port, the verifier UI fixes (req-schema.js `fn_id`, posted `req` vs wire `_req`) and + # the meta.source_format fix, all on that integration branch + # ZK libs https://github.com/sirosfoundation/zk-cred-longfellow v0.1.2 @ 58de20f07487bbb6ebe32148feec3bef4d92677e and + # https://github.com/sirosfoundation/zk-cred-vega v0.0.7 @ 8e7be172e9010e8b47f3192f046d5f990f602790 + # matcher https://github.com/sirosfoundation/siros-dc-matcher v0.11.0 (the wallet side's request matcher; not in this + # image); circuit catalog https://github.com/sirosfoundation/go-zk-circuits release v0.1.0-release.16 is served + # from zk-circuits.fly.dev (values.verifier.zkCircuits) + # previous pins: local-1792603698 (vc 253a17e92), local-1792532233 (094549cc; cannot answer a zkp_req request, never + # deployed), local-1792523015 (ae33687f), local-1792485810 (2a30a609, never deployed), local-1792456320 + # (6c3ac47) + # + # vc-apigw / vc-issuer / vc-registry registry.fly.io/sirosid-gdc-vc-:local-1792473614 + # digests apigw sha256:6d532929f7a5238d16767fe071d928880b68634d8b1c0065510779206c92eb27 + # issuer sha256:c0efa58f3f72bccc73f750be3bf89cfc5f331386e78908520340c4023c758b58 + # registry sha256:458cb70c8b9918fd9841b06fcfbf6954b39030c05500ff0a717c377a6a453268 + # source sirosfoundation/vc `integration/gdc-nymauth-v4` @ 5e546b2d61d4756f04b39ea84d26cf6b18b830ba (NOT for upstream + # merge), on SUNET/vc main as of 2026-10-20, so it also carries upstream #809 (mdoc DeviceAuthentication per ISO + # 18013-5) and #790 (SD-JWT holder binding) + # PRs https://github.com/SUNET/vc/pull/793 @ 3bfa96b6e (seed-backed commitment claims; MERGED upstream since) + # https://github.com/SUNET/vc/pull/821 @ fd078a057 (deliver the secret seed in credential_metadata; open) + # https://github.com/SUNET/vc/pull/813 @ 2e69dd2b4 (identity_attributes / document_claims; closed) + # https://github.com/SUNET/vc/pull/796 @ 62c93fa01 and https://github.com/SUNET/vc/pull/820 @ 6db9dd19b (verifier + # V1 / circuit_zk request; both closed, superseded by the verifier stack above) + # plus startup refusals for a delivering scope without notification_endpoint / credential_encryption.keys and a + # 'Delivered a secret seed' INFO line + # + # wallet-backend (the ENGINE) registry.fly.io/sirosid-gdc-wallet-backend:local-1792564143 + # digest sha256:285a7ca4ee1eaa27cacbe45435965ba08b15e2e98b680636a29ee5073da97749 + # source go-wallet-backend `local/gdc-nymauth-integration-3` @ 9cb6e57d8498f0a1efa024254a6e99e2dbf278ab: a LOCAL branch, + # NEVER pushed (fetched by full SHA from a worktree); VERSION 0.24.0-nymauth.9cb6e57, GIT_REF empty. Base + # origin/main cb42861b6d640ea17166e6491f69761f58a5f172, plus signed --no-ff merges of: + # PRs https://github.com/sirosfoundation/go-wallet-backend/pull/469 @ e4e0bc3bd1fd800a7507a50d33b1703c6224100b + # (OID4VCI credential request encryption) + # https://github.com/sirosfoundation/go-wallet-backend/pull/471 @ f1362658c9891e1cbbbb2642898f9c6413fcc531 + # (client_metadata / verifier_jwk_thumbprint / verifier trust context, incl. the fail-closed verifier-trust fix + # of https://github.com/sirosfoundation/go-wallet-backend/pull/473) + # https://github.com/sirosfoundation/go-wallet-backend/pull/163 @ 71a07f8037c00c0faf139f23707c26a207ab7a8c + # (WMP, go-wmp v0.7.0) + # https://github.com/sirosfoundation/go-wallet-backend/pull/472 @ be69e2e70a7d00e7bdef85ea99792d7651cd2ec5 + # (sign-stage refusal) + # none merged; human review pending. NOT included: https://github.com/sirosfoundation/go-wallet-backend/pull/470. + # rollback pins: local-1792560364 (faa5e63), local-1792473614 (#469 only, e4e0bc3) + # + # pdp (go-trust) ghcr.io/sirosfoundation/go-trust:0.26.0 (RELEASED, tag v0.26.0, commit 621b03f4) + # index digest sha256:73d994eaabcf27d68837c12ef42cbb16072c87562c6abe5cf7e2332a8068cf1e + # contains https://github.com/sirosfoundation/go-trust/pull/218 (merged): verifier_ids from the whitelist, + # system_cert_pool and mdoc_rical registries, carried through all strategies and composites. values.pdp.extraConfig + # below sets policies for the two verifier actions ONLY (x509_san_dns), so a verifier-path evaluation returns + # context.verifier_ids and every other action is evaluated as before. Previous pin: 0.25.0. + # + # Other images are released versions (wallet-frontend, mini-oidc, env-admin). + # =========================================================================================================== # vc: BUILT LOCALLY (2026-10-20). The ISSUER side (registry, issuer, apigw) is sirosfoundation/vc # `integration/gdc-nymauth-v4` @ 5e546b2d61d4756f04b39ea84d26cf6b18b830ba (v3 20ab021 + startup refusals for a delivering # scope without notification_endpoint / credential_encryption.keys + a 'Delivered a secret seed' INFO log line) = SUNET/vc main + #793 (seed derivation @@ -68,7 +136,16 @@ images: # makes the ZK Proofs presets visible on the verifier's landing page - # both of them are categorised and neither is featured, so they still sit # behind "Show more presets" here. - vc-verifier: registry.fly.io/sirosid-gdc-vc-verifier:local-1792456320 + # vc-verifier: see PIN PROVENANCE above. Build recipe (a local build; the circuit artifact overlay is checked for size + # and sha256 at build time): + # docker build -f dockerfiles/worker --target runtime-verifier --build-arg SERVICE_NAME=verifier \ + # --build-arg ZK_CRED_LONGFELLOW_REF= --build-arg ZK_CRED_VEGA_REF= -t . + # docker build -f dockerfiles/verifier-nymauth.Dockerfile --build-arg BASE= \ + # -t registry.fly.io/sirosid-gdc-vc-verifier:local- . # then docker push (flyctl auth docker) + # The verifier config (verifier.nym_auth, zkp_req formats, presets, trust.pdp_url) is under values.verifier below. + # A real wallet presentation is expected to fail proof verification until the verification time T is agreed between + # wallet and verifier (spec decision pending); that is not a config problem. + vc-verifier: registry.fly.io/sirosid-gdc-vc-verifier:local-1792603698 vc-apigw: registry.fly.io/sirosid-gdc-vc-apigw:local-1792473614 vc-issuer: registry.fly.io/sirosid-gdc-vc-issuer:local-1792473614 vc-registry: registry.fly.io/sirosid-gdc-vc-registry:local-1792473614 @@ -118,7 +195,19 @@ images: # (ghcr.io/sirosfoundation/go-wallet-backend:) once it is merged and released. # Build: docker build -f Dockerfile --build-arg VERSION=0.24.0-reqenc.e4e0bc3 --build-arg COMMIT= \ # -t registry.fly.io/sirosid-gdc-wallet-backend:local- . # then docker push (flyctl auth docker) - wallet-backend: registry.fly.io/sirosid-gdc-wallet-backend:local-1792473614 + # 2026-10-21: REPLACED the #469-only build below by a LOCAL INTEGRATION build, go-wallet-backend + # local/gdc-nymauth-integration-3 @ 9cb6e57d8498f0a1efa024254a6e99e2dbf278ab = faa5e63 + #471 @ f1362658 (narrowed progress context, fail-closed verifier trust; #473's fix) (a local branch that is NEVER pushed; + # fetched by full SHA from a local worktree). Signed --no-ff merges on origin/main cb42861b6: #469 @ e4e0bc3b + # (request encryption), #471 @ 28a6fa50 (client_metadata / thumbprint), #163 @ 71a07f80 (WMP, go-wmp v0.7.0), #472 @ + # be69e2e7 (sign-stage refusal). NONE of these are merged upstream; #470 is not included. Build, with GIT_REF left + # EMPTY (a non-empty GIT_REF makes the Dockerfile git-clone that ref over HTTPS, which fails for an unpushed branch): + # docker build -f Dockerfile --build-arg VERSION=0.24.0-nymauth.faa5e63 # --build-arg COMMIT=faa5e63d2777c1cfe3d7c60bf0ade6d4232bea7b -t registry.fly.io/sirosid-gdc-wallet-backend:local- . + # (image id f3b6c030fabe, the same id the go-wallet-backend session got). Config: no key added, renamed or removed. + # NOT configured here: server.external_urls.engine_url, so /.well-known/wmp-configuration returns 503 (startup warns), + # and the wallet-proxy routes only /api/v2/wallet to the engine port, so the discovery path is not routed either. + # Re-pin to the released go-wallet-backend image once those PRs are merged and released. + wallet-backend: registry.fly.io/sirosid-gdc-wallet-backend:local-1792564143 + # previous pins: faa5e63 = local-1792560364; PR 469 only = local-1792473614 # The rest of the SIROS components, at the last RELEASED version as of 2026-10-08. Pinned here, not in # values-fly.yaml (shared by every environment), so gdc moves when someone chooses to move it. @@ -127,7 +216,7 @@ images: # 2026-10-15: 0.24.1 -> 0.25.0. Its image sets GT_HOST=0.0.0.0 GT_PORT=6001 and the environment BEATS the # config file, so the PDP listened on 6001 while everything addresses 8080; fly-up now sets GT_PORT/GT_HOST # itself (deploy.py, singlemachine.py). The LoTE verify_jws change is not used here. - pdp: ghcr.io/sirosfoundation/go-trust:0.25.0 + pdp: ghcr.io/sirosfoundation/go-trust:0.26.0 # wallet-frontend: 0.5.0-1-sirosid.10 -> .11, the newest sirosid-flavoured image that is published # (the v0.5.0-sirosid.12/.13 git tags have no image; v0.6.0 is the main line, not this flavour). wallet-frontend: ghcr.io/sirosfoundation/wallet-frontend:0.5.0-1-sirosid.11 @@ -266,6 +355,27 @@ values: credentialEncryption: enabled: true pdp: + # Needs go-trust >= 0.26.0 (contains sirosfoundation/go-trust PR 218; head a813befc: + # the whitelist, system_cert_pool and mdoc_rical registries emit context.verifier_ids; 0.25.0 emits them only from + # etsi/lote/did/oidfed, which gdc's verifier does not go through). Then: bump images.pdp to that release and + # redeploy the PDP. Policies exist ONLY for the two verifier actions, default_policy and fail_closed_on_unknown_action + # are NOT set, and no registries/allowed_key_types/require_key_binding are set, so every other action (credential- + # issuer, mdoc-issuer, mdoc-issuer-auth) is evaluated exactly as before. Snippet: docs/examples/gdc-verifier-ids- + # policies.yaml in that PR. Expected after the redeploy: a verifier-path replay (subject + # x509_san_dns:sirosid-gdc-vc-verifier.fly.dev, x5c = the gdc chain, action credential-verifier) returns + # context.verifier_ids [{verifier_id_type: x509_san_dns, verifier_id: sirosid-gdc-vc-verifier.fly.dev}] - the bare + # DNS name, lower case. + extraConfig: + policies: + policies: + credential-verifier: + description: "Verifier access: return the verified dNSName of the verifier" + constraints: + return_verifier_ids: [x509_san_dns] + verifier: + description: "Verifier access (legacy action name): return the verified dNSName" + constraints: + return_verifier_ids: [x509_san_dns] # Resolve entities identified by did:web, so a presentation from a wallet # to - or an issuer identified by - a did:web subject can be evaluated at # all. Additive: it registers one more resolver alongside the whitelist and @@ -283,6 +393,45 @@ values: description: "did:web DID document resolution" timeout: 10s verifier: + # nym_auth pseudonym verification (SUNET/vc#827/#829/#830/#832). Needs a vc verifier BUILT WITH the zknative tag + # and the circuit artifact baked in (dockerfiles/verifier-nymauth.Dockerfile); an older verifier ignores these + # keys (non-strict unmarshal) and simply cannot answer a zkp_req request. + # + # verifier_id scopes EVERY pseudonym: SHA-256(verifier_id) is the pseudonym's verifier input. Since rulebook v0.4 + # (vc 2026-10-20, integration/gdc-nymauth-v5 @ fd562f10) it is the verifier's client_id EXACTLY AS PUBLISHED, + # prefix and all: x509_san_dns: (it was the bare host before v0.4, and vc refused the prefix). + # CHANGING IT CHANGES EVERY PSEUDONYM this verifier ever reports. The allowlist values are zk-cred-longfellow's + # src/nymauth/profile.rs (authoritative); do NOT take the digest from docs/CONFIGURATION.md, whose example is a + # placeholder with 52 zeros in the middle. + # + # trust.pdp_url is already set by the chart and MUST stay: nym_auth takes the issuer key from the HOLDER's own + # certificate chain, so the PDP is the only check that key belongs to a real issuer (the verifier refuses to + # start without it). Checked against this environment's PDP 2026-10-20: the gdc issuer's chain is trusted for + # eu.europa.ec.eudi.nym_auth.1 (action mdoc-issuer, the mDOC IACA registry) and a self-signed chain with the same + # SAN is not. + extraConfig: + verifier: + nym_auth: + enable: true + verifier_id: x509_san_dns:sirosid-gdc-vc-verifier.fly.dev + circuits: + allow: + - config: known-offender-matching-ssc + hash_circuit_id: 5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729 + sig_circuit_id: 00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb + block_enc_hash: 6983 + block_enc_sig: 4679 + artifact_path: /etc/vc/circuits/nym_auth-ssc.bin + artifact_sha256: 92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f + artifact_bytes: 1062842 + preferred_vp_formats: + # v0.4 names (circuit_zk / circuit_id_values / circuit_proof_system_values fail startup, naming these) + zkp_req: + system_id_values: ["urn:circuits:longfellow:nym_auth:v1"] + proof_system_values: ["longfellow-libzk-v1"] + # The verifier's client_id is x509_san_dns:sirosid-gdc-vc-verifier.fly.dev; put that host in the SAN of the + # certificate it signs request objects with (re-issued once from the same key and root; see create-pki.sh). + signingCertDnsSan: true # Selectable options in the verifier's own presentation-definition UI. # NOT the same thing as presentation-request templates, which are only # reachable through a real OIDC /authorize?scope=... request. @@ -299,6 +448,46 @@ values: # (category/credentials/zk_system_type) is only understood by the vc build # pinned in environments/gdc.yaml, not by vc main. presets: + # nym_auth pseudonym request (rulebook v0.4 zkp_req; needs preferred_vp_formats.zkp_req and verifier.nym_auth + # above, else the preset is not published). `req` items are {fn_id, return, params}; ver and prf are required. + # The scope key is the verifier's own credential_metadata scope for nym_auth, which supplies meta.source_format + # (mso_mdoc). `validate: nym_auth` makes vc refuse this preset AT STARTUP if it breaks the rulebook. The + # pseudonym is scoped by verifier.nym_auth.verifier_id: changing that changes every pseudonym. + # nym_auth_pseudonym_age adds an age_check; its bounds are vc's example (see there). + "nym_auth_pseudonym": + category: "ZK Proofs" + credentials: + nym_auth: + format: "zkp_req" + validate: "nym_auth" + req: + - fn_id: pseudonym + return: nym + params: + config: known-offender-matching-ssc + ver: 1 + prf: sha-256 + "nym_auth_pseudonym_age": + category: "ZK Proofs" + credentials: + nym_auth: + format: "zkp_req" + validate: "nym_auth" + req: + - fn_id: pseudonym + return: nym + params: + config: known-offender-matching-ssc + ver: 1 + prf: sha-256 + - fn_id: age_check + return: age + # The range must span at least 365 days (a narrower one is an exact birth date) and must suit the + # holder: this window (born 1980-01-01..2000-12-31) is satisfied by alice-001 (born 1990-01-15), so + # it is a positive test; the 2008..2013 example in vc's brief is not. + params: + ge: "1980-01-01" + le: "2000-12-31" "PID (mdoc) - ZK Proof (Vega)": category: "ZK Proofs" credentials: diff --git a/fixtures/create-pki.sh b/fixtures/create-pki.sh index 0dc38358..1803c713 100755 --- a/fixtures/create-pki.sh +++ b/fixtures/create-pki.sh @@ -11,6 +11,7 @@ # ./create-pki.sh # PKI_DIR_OVERRIDE=/path/to/env-pki ./create-pki.sh # e.g. per Fly environment # SIGNING_CERT_ISSUER_URL=https://issuer.example ./create-pki.sh +# SIGNING_CERT_VERIFIER_HOST=verifier.example ./create-pki.sh # # SIGNING_CERT_ISSUER_URL adds a URI SAN naming the credential issuer to the # signing certificate. mdoc verifiers derive an mDL's issuer identity from its @@ -22,6 +23,13 @@ # vc-verifier rejects the presentation with "issuer not trusted". Unset for # the docker-compose stack, where localhost IS the identity. # +# SIGNING_CERT_VERIFIER_HOST adds a DNS SAN naming the vc-verifier's public host. The verifier's OpenID4VP +# client_id is `x509_san_dns:` and it signs its request objects with this same certificate (the key +# is shared with the issuer's DS certificate), so a strict wallet or relying party checking the client_id against +# the request's x5c leaf rejects it when the SAN is missing. Re-issued from the existing key and CA exactly like +# the issuer URI: neither the key nor the root changes, so already-issued credentials keep verifying. A test or +# demo shortcut: a deployment that signs credentials for real should keep separate issuer and verifier keys. +# # Each artifact group is independently guarded by its own file-existence # check, so re-running after adding a new artifact group only generates what's # missing - existing keys already relied upon elsewhere (hardcoded thumbprints, @@ -120,17 +128,28 @@ SIGNING_CERT_URI_SAN_LINE="" if [ -n "${SIGNING_CERT_ISSUER_URL:-}" ]; then SIGNING_CERT_URI_SAN_LINE="URI.1 = ${SIGNING_CERT_ISSUER_URL}" fi +SIGNING_CERT_VERIFIER_SAN_LINE="" +if [ -n "${SIGNING_CERT_VERIFIER_HOST:-}" ]; then + SIGNING_CERT_VERIFIER_SAN_LINE="DNS.4 = ${SIGNING_CERT_VERIFIER_HOST}" +fi signing_cert_current() { [ -f "${PKI_DIR}/signing_ec.crt" ] || return 1 - [ -z "${SIGNING_CERT_ISSUER_URL:-}" ] && return 0 - openssl x509 -in "${PKI_DIR}/signing_ec.crt" -noout -ext subjectAltName 2>/dev/null \ - | grep -qF "URI:${SIGNING_CERT_ISSUER_URL}" + local sans + sans=$(openssl x509 -in "${PKI_DIR}/signing_ec.crt" -noout -ext subjectAltName 2>/dev/null) + if [ -n "${SIGNING_CERT_ISSUER_URL:-}" ]; then + grep -qF "URI:${SIGNING_CERT_ISSUER_URL}" <<<"$sans" || return 1 + fi + if [ -n "${SIGNING_CERT_VERIFIER_HOST:-}" ]; then + # ", " or line start before and ", " or line end after, so `verifier.example` does not match `x.verifier.example`. + grep -qE "(^|[ ,])DNS:${SIGNING_CERT_VERIFIER_HOST//./\\.}(,|$)" <<<"$sans" || return 1 + fi + return 0 } if signing_cert_current; then echo "EC signing certificate already current, skipping." else if [ -f "${PKI_DIR}/signing_ec.crt" ]; then - echo "Re-issuing EC signing certificate (issuer URI SAN ${SIGNING_CERT_ISSUER_URL}) from the existing key..." + echo "Re-issuing EC signing certificate (issuer URI SAN '${SIGNING_CERT_ISSUER_URL:-}', verifier DNS SAN '${SIGNING_CERT_VERIFIER_HOST:-}') from the existing key..." else echo "Issuing EC signing certificate..." fi @@ -163,6 +182,7 @@ subjectAltName = @alt_names DNS.1 = localhost DNS.2 = vc-issuer DNS.3 = vc-verifier +${SIGNING_CERT_VERIFIER_SAN_LINE} ${SIGNING_CERT_URI_SAN_LINE} EOF diff --git a/fixtures/vc-config-golden/accepted-diffs.yaml b/fixtures/vc-config-golden/accepted-diffs.yaml index 3bc5e2a8..8689e5d3 100644 --- a/fixtures/vc-config-golden/accepted-diffs.yaml +++ b/fixtures/vc-config-golden/accepted-diffs.yaml @@ -287,3 +287,6 @@ fly-gdc: - path: verifier.zk_circuits reason: "now driven by environments/.yaml's zk_circuits_sources rather than a CLI-only flag" + - path: verifier.nym_auth + kind: added + reason: "gdc verifies nym_auth pseudonym proofs (SUNET/vc#832; environments/gdc.yaml verifier.extraConfig). Needs a zknative verifier image with the circuit artifact baked in (dockerfiles/verifier-nymauth.Dockerfile)." diff --git a/sirosid_core/deploy.py b/sirosid_core/deploy.py index c222573f..6a184621 100644 --- a/sirosid_core/deploy.py +++ b/sirosid_core/deploy.py @@ -132,6 +132,11 @@ def generate_pki(ctx: DeployContext) -> Path: "PKI_DIR_OVERRIDE": str(pki_dir), "SIGNING_CERT_ISSUER_URL": naming.url("vc-apigw"), } + # Opt-in per environment (values.verifier.signingCertDnsSan): re-issues the signing leaf (same key and root) + # once so the verifier's client_id, x509_san_dns:, appears in the SAN of the certificate it signs + # request objects with. Unset, an environment's certificate is left exactly as it is. + if ((ctx.spec.values or {}).get("verifier") or {}).get("signingCertDnsSan"): + env_vars["SIGNING_CERT_VERIFIER_HOST"] = naming.host("vc-verifier") fly.exec(["bash", "./create-pki.sh"], cwd=resources.fixtures, env=env_vars) return pki_dir diff --git a/sirosid_core/vc_render.py b/sirosid_core/vc_render.py index ecd82c68..77976b87 100644 --- a/sirosid_core/vc_render.py +++ b/sirosid_core/vc_render.py @@ -519,6 +519,18 @@ def write_as_rules(docs: list, out_dir: Path) -> Path: return _write_documents(docs, out_dir, "wallet-backend-as-rules", "as-rules") +def integral_nym_auth_numbers(config: dict) -> dict: + """Helm renders an integer of a million or more as a float (artifact_bytes 1062842 becomes `1.062842e+06`, + which PyYAML then dumps as `1062842.0`), and vc's allowlist wants an integer there. Only the allowlist's + numeric fields are touched, and only when the float is integral.""" + allow = (((config.get("verifier") or {}).get("nym_auth") or {}).get("circuits") or {}).get("allow") or [] + for entry in allow: + for key in ("artifact_bytes", "block_enc_hash", "block_enc_sig"): + if isinstance(entry.get(key), float) and entry[key].is_integer(): + entry[key] = int(entry[key]) + return config + + def render_vc(docs: list, out_dir: Path, target: str, secrets_dir: Path, gen_secret, plain_http_hosts: set = None, secret_overrides: dict = None, env: str = None, mongo_password: str = None, @@ -539,7 +551,7 @@ def render_vc(docs: list, out_dir: Path, target: str, secrets_dir: Path, gen_sec seeds_enabled = bool((config.get("apigw") or {}).get("seeds")) if target == "fly": drop_twin_imports(config) - config = strip_unrenderable(config) + config = integral_nym_auth_numbers(strip_unrenderable(config)) if credential_registries: config = use_credential_registry(config, credential_types or {}, credential_registries) (out_dir / filename).write_text(yaml.dump(config, sort_keys=False)) diff --git a/tests/golden/fly_up.json b/tests/golden/fly_up.json index 585f5b7c..39e3ccb5 100644 --- a/tests/golden/fly_up.json +++ b/tests/golden/fly_up.json @@ -168,7 +168,7 @@ "pdp.yaml": "00d8aee1ad455620", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.generated.yaml": "8d0085e00604c29a", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -188,7 +188,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "9708c0fbe13d1318", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "840b5c8b1432e70d", + "vc-verifier.yaml": "ac07d34f8982b416", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", @@ -347,7 +347,7 @@ "pdp.yaml": "ec43dcc3736a4a3c", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.generated.yaml": "a24b0a8f6522c6fe", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -367,7 +367,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "9708c0fbe13d1318", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "840b5c8b1432e70d", + "vc-verifier.yaml": "ac07d34f8982b416", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", @@ -526,7 +526,7 @@ "pdp.yaml": "941ff7f896171bde", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.environment.yaml": "a68fa91f82976efc", "values.generated.yaml": "96eb4c7dcadd8472", "values.toggles.yaml": "25be7b6793eda8a7", @@ -548,7 +548,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "4ee331e0836ab831", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "c898e01b7727f934", + "vc-verifier.yaml": "6db1fa9a1f1f9b42", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", @@ -684,7 +684,7 @@ "pdp.yaml": "ec43dcc3736a4a3c", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.generated.yaml": "a24b0a8f6522c6fe", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -704,7 +704,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "9708c0fbe13d1318", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "840b5c8b1432e70d", + "vc-verifier.yaml": "ac07d34f8982b416", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", @@ -1025,7 +1025,7 @@ "pdp.yaml": "077060a4843bdbc6", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.generated.yaml": "588f06bf89465652", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "7acf0a61335c1c9c", @@ -1045,7 +1045,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "9708c0fbe13d1318", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "a236b90e7c265efd", + "vc-verifier.yaml": "42e047b392b62d52", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", @@ -1204,7 +1204,7 @@ "pdp.yaml": "b5248c0c2bf703b3", "pres-reqs/pres-reqs.yaml": "b0f8dff4515f79ce", "values.api-auth.yaml": "(volatile)", - "values.base-inlined.yaml": "2616fb5e6883c69c", + "values.base-inlined.yaml": "f8a9f16b225f3572", "values.generated.yaml": "656d3d5ad9b7759c", "vc-apigw.fly.toml": "62a74456aeda6700", "vc-apigw.yaml": "11833e4e0cfe44ee", @@ -1224,7 +1224,7 @@ "vc-registry.fly.toml": "d2312b6ed0cb4bc1", "vc-registry.yaml": "9708c0fbe13d1318", "vc-verifier.fly.toml": "ac42eae3010cf141", - "vc-verifier.yaml": "840b5c8b1432e70d", + "vc-verifier.yaml": "ac07d34f8982b416", "vctms/diploma.json": "348ad3fd59a14f02", "vctms/ebw_oid.json": "048d71ed810bfa8e", "vctms/ehic.json": "d17aab7fc60cdbc7", diff --git a/tests/test_create_pki_verifier_san.py b/tests/test_create_pki_verifier_san.py new file mode 100644 index 00000000..abc12271 --- /dev/null +++ b/tests/test_create_pki_verifier_san.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""fixtures/create-pki.sh: SIGNING_CERT_VERIFIER_HOST re-issues the signing certificate with the verifier's dNSName +from the EXISTING key and root. + +The verifier signs its OpenID4VP request objects with the same certificate the issuer uses as its DS certificate, and +its client_id is `x509_san_dns:`. The re-issue must therefore never touch the key or the root: the PDP's trust +and every credential already issued depend on them.""" +import hashlib +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "fixtures" / "create-pki.sh" +ISSUER = "https://issuer.example" +HOST = "verifier.example" + +NEEDS = unittest.skipUnless(shutil.which("openssl") and shutil.which("bash"), "needs openssl and bash") + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class Pki: + def __init__(self): + self.dir = Path(tempfile.mkdtemp()) + + def run(self, **env): + proc = subprocess.run(["bash", str(SCRIPT)], cwd=SCRIPT.parent, capture_output=True, text=True, + env={**os.environ, "PKI_DIR_OVERRIDE": str(self.dir), **env}) + assert proc.returncode == 0, proc.stdout + proc.stderr + return proc.stdout + + def sans(self) -> str: + out = subprocess.run(["openssl", "x509", "-in", str(self.dir / "signing_ec.crt"), "-noout", "-ext", "subjectAltName"], + capture_output=True, text=True, check=True).stdout + return out.splitlines()[-1].strip() + + def verifies(self) -> bool: + return subprocess.run(["openssl", "verify", "-CAfile", str(self.dir / "rootCA.crt"), str(self.dir / "signing_ec.crt")], + capture_output=True).returncode == 0 + + +@NEEDS +class VerifierSan(unittest.TestCase): + def setUp(self): + self.pki = Pki() + self.addCleanup(shutil.rmtree, self.pki.dir, ignore_errors=True) + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER) # an environment deployed before the verifier SAN existed + self.key, self.root = digest(self.pki.dir / "signing_ec_private.pem"), digest(self.pki.dir / "rootCA.crt") + self.old_cert = digest(self.pki.dir / "signing_ec.crt") + + def test_the_existing_certificate_has_no_verifier_dns_name(self): + self.assertNotIn(f"DNS:{HOST}", self.pki.sans()) + + def test_reissue_adds_the_dns_name_and_keeps_the_key_and_the_root(self): + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("Re-issuing", out) + sans = self.pki.sans() + self.assertIn(f"DNS:{HOST}", sans) + self.assertIn(f"URI:{ISSUER}", sans, "the issuer URI the PDP's mdociaca allowlist matches on must survive") + self.assertIn("DNS:vc-issuer", sans) + self.assertNotEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert, "a new leaf") + self.assertEqual(digest(self.pki.dir / "signing_ec_private.pem"), self.key, "the key must not rotate") + self.assertEqual(digest(self.pki.dir / "rootCA.crt"), self.root, "the root must not change") + self.assertTrue(self.pki.verifies(), "the new leaf chains to the unchanged root") + chain = (self.pki.dir / "signing_ec_chain.pem").read_text() + self.assertEqual(chain, (self.pki.dir / "signing_ec.crt").read_text() + (self.pki.dir / "rootCA.crt").read_text()) + + def test_the_public_key_is_unchanged_so_old_signatures_still_verify(self): + before = (self.pki.dir / "signing_ec_public.pem").read_text() + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + leaf_pub = subprocess.run(["openssl", "x509", "-in", str(self.pki.dir / "signing_ec.crt"), "-noout", "-pubkey"], + capture_output=True, text=True, check=True).stdout + self.assertEqual(leaf_pub, before) + + def test_a_certificate_that_already_has_it_is_left_alone(self): + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + issued = digest(self.pki.dir / "signing_ec.crt") + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("already current", out) + self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), issued) + + def test_unset_leaves_an_existing_certificate_alone(self): + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER) + self.assertIn("already current", out) + self.assertEqual(digest(self.pki.dir / "signing_ec.crt"), self.old_cert) + + def test_a_longer_host_ending_in_the_same_name_is_not_a_match(self): + """`x.verifier.example` must not satisfy `verifier.example`, and `.` must not act as a wildcard.""" + self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="x.verifier.example") + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST=HOST) + self.assertIn("Re-issuing", out) + self.assertIn(f"DNS:{HOST}", self.pki.sans()) + out = self.pki.run(SIGNING_CERT_ISSUER_URL=ISSUER, SIGNING_CERT_VERIFIER_HOST="verifierXexample") + self.assertIn("Re-issuing", out, "an unescaped '.' would have matched verifier.example") + + +class DeployPassThrough(unittest.TestCase): + def generate(self, values): + from sirosid_core import deploy + from sirosid_core.naming import Naming + + seen = {} + + class Fly: + def exec(self, cmd, cwd=None, env=None): + seen["env"] = env + + def app_exists(self, app): + return False + + spec = type("S", (), {"values": values})() + ctx = deploy.DeployContext(fly=Fly(), naming=Naming(env="gdc"), resources=type("R", (), {"fixtures": Path("/x")})(), + spec=spec, out_dir=Path(tempfile.mkdtemp())) + self.addCleanup(shutil.rmtree, ctx.out_dir, ignore_errors=True) + deploy.generate_pki(ctx) + return seen["env"] + + def test_opted_in_environment_hands_the_script_the_verifiers_public_host(self): + env = self.generate({"verifier": {"signingCertDnsSan": True}}) + self.assertEqual(env["SIGNING_CERT_VERIFIER_HOST"], "sirosid-gdc-vc-verifier.fly.dev", + "the host is the client_id's x509_san_dns value") + self.assertEqual(env["SIGNING_CERT_ISSUER_URL"], "https://sirosid-gdc-vc-apigw.fly.dev") + + def test_every_other_environment_is_unchanged(self): + for values in ({}, {"verifier": {}}, {"verifier": {"signingCertDnsSan": False}}, None): + self.assertNotIn("SIGNING_CERT_VERIFIER_HOST", self.generate(values), values) + + def test_gdc_opts_in_and_nothing_else_does(self): + import yaml + for env_file in sorted((ROOT / "environments").glob("*.yaml")): + cfg = yaml.safe_load(env_file.read_text()) or {} + on = (((cfg.get("values") or {}).get("verifier") or {}).get("signingCertDnsSan")) + self.assertEqual(bool(on), env_file.stem == "gdc", env_file.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nymauth_verifier_render.py b/tests/test_nymauth_verifier_render.py new file mode 100644 index 00000000..9b642144 --- /dev/null +++ b/tests/test_nymauth_verifier_render.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""The gdc verifier's nym_auth block (SUNET/vc#832): what the rendered verifier config must say, and the one value +that is easy to get wrong silently. + +`verifier_id` scopes every pseudonym, and a wrong one is not an error anyone sees: it yields a pseudonym no other +implementation reports. The artifact digest has a placeholder lookalike (docs/CONFIGURATION.md's example keeps the +real digest's first 8 and last 4 hex characters and zeroes the middle), so the check below is on the whole value.""" +import re +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +import env_config # noqa: E402 +from sirosid_core.naming import Naming # noqa: E402 +from sirosid_core.render import render # noqa: E402 +from sirosid_core.resources import Resources # noqa: E402 + +NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm") + +# zk-cred-longfellow src/nymauth/profile.rs - the authoritative source (the native loader hashes the decompressed +# artifact and compares against exactly these). +PROFILE = { + "hash_circuit_id": "5f8c75ae5c3893ef6ab6a079628cb385ba85e95a85437d58570c455289d75729", + "sig_circuit_id": "00513797c75b2eab1a9db75004977c15317d4e40c0db1d00674523431baa75cb", + "block_enc_hash": 6983, + "block_enc_sig": 4679, + "artifact_bytes": 1062842, + "artifact_sha256": "92dd42fdf99673249eec33c7d07850bd9da66fbdab01ef26391e04c8341c1b7f", +} + + +def render_gdc_verifier(): + tmp = Path(tempfile.mkdtemp()) + try: + values = env_config.load_environment_config("gdc").get("values") or {} + naming = Naming(env="gdc") + render("fly", ROOT / "chart", env="gdc", resources=Resources(ROOT), out_dir=tmp / "out", secrets_dir=tmp / "secrets", + env_values=values, naming=naming, say=lambda *a, **k: None, mongo_password="x") + text = (tmp / "out" / "fly-gdc" / "vc-verifier.yaml").read_text() + return text, yaml.safe_load(text) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@NEEDS +class GdcNymAuthVerifier(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.text, cfg = render_gdc_verifier() + cls.full = cfg + cls.cfg = cfg["verifier"] + + def test_enabled_with_exactly_the_profile_the_circuit_ships(self): + entry = self.cfg["nym_auth"]["circuits"]["allow"] + self.assertEqual(len(entry), 1) + self.assertEqual({k: entry[0][k] for k in PROFILE}, PROFILE) + self.assertEqual(entry[0]["config"], "known-offender-matching-ssc") + self.assertTrue(self.cfg["nym_auth"]["enable"]) + + def test_numbers_are_integers_in_the_file_vc_reads(self): + """Helm renders 1062842 as 1.062842e+06 and PyYAML dumps that back as 1062842.0; a Go int field refuses it.""" + for k in ("artifact_bytes", "block_enc_hash", "block_enc_sig"): + self.assertIs(type(self.cfg["nym_auth"]["circuits"]["allow"][0][k]), int, k) + self.assertRegex(self.text, r"artifact_bytes: 1062842\n") + + def test_the_digest_is_not_the_documentation_placeholder(self): + digest = self.cfg["nym_auth"]["circuits"]["allow"][0]["artifact_sha256"] + self.assertRegex(digest, r"^[0-9a-f]{64}$") + self.assertNotRegex(digest, r"0{8}", "a run of zeros is the doc_example placeholder, not a SHA-256") + + def test_ids_are_strings_not_numbers_the_yaml_could_reinterpret(self): + entry = self.cfg["nym_auth"]["circuits"]["allow"][0] + for k in ("hash_circuit_id", "sig_circuit_id", "artifact_sha256"): + self.assertIsInstance(entry[k], str, k) + + def test_verifier_id_is_the_published_client_id(self): + """Rulebook v0.4: verifier_id is the client_id exactly as published, x509_san_dns:, prefix + included (the bare host was the rule before v0.4 and is the NOT-a-verifier_id value now: it yields a + pseudonym no other implementation reports). Changing it changes every pseudonym.""" + vid = self.cfg["nym_auth"]["verifier_id"] + self.assertEqual(vid, "x509_san_dns:" + re.sub(r"^https://", "", self.cfg["public_url"])) + + def test_the_artifact_path_is_where_the_overlay_image_puts_it(self): + self.assertEqual(self.cfg["nym_auth"]["circuits"]["allow"][0]["artifact_path"], "/etc/vc/circuits/nym_auth-ssc.bin") + dockerfile = (ROOT / "dockerfiles" / "verifier-nymauth.Dockerfile").read_text() + self.assertIn("/etc/vc/circuits/nym_auth-ssc.bin", dockerfile) + self.assertIn(PROFILE["artifact_sha256"], dockerfile) + self.assertIn(str(PROFILE["artifact_bytes"]), dockerfile) + + def test_a_pdp_is_configured_because_the_verifier_refuses_to_start_without_one(self): + self.assertTrue(self.cfg["trust"]["pdp_url"], "nym_auth takes the issuer key from the holder's chain; no PDP = refuse to start") + + def test_the_request_may_name_only_what_this_verifier_offers(self): + zk = self.cfg["preferred_vp_formats"]["zkp_req"] + self.assertEqual(zk["system_id_values"], ["urn:circuits:longfellow:nym_auth:v1"]) + self.assertEqual(zk["proof_system_values"], ["longfellow-libzk-v1"]) + self.assertIn("dc+sd-jwt", self.cfg["preferred_vp_formats"], "adding zkp_req must not drop the existing formats") + + def test_the_nym_auth_presets_are_shaped_as_vc_reads_them(self): + """vc's brief (SUNET/vc#832): preset key `req` (the WIRE member `_req` was silently accepted and left empty), + item key fn_id, ver and prf required, no source_format / doctype_value (both derive from the scope's + credential_metadata, so a scope key that is not that entry reproduces invalid_meta in every wallet).""" + presets = self.cfg["presets"] + scopes = self.full["common"]["credential_metadata"] + for label in ("nym_auth_pseudonym", "nym_auth_pseudonym_age"): + self.assertEqual(list(presets[label]["credentials"]), ["nym_auth"], label) + self.assertIn("nym_auth", scopes, "the preset's scope key must be a common.credential_metadata scope") + self.assertEqual(scopes["nym_auth"]["format"], "mso_mdoc") + cred = presets[label]["credentials"]["nym_auth"] + self.assertEqual((cred["format"], cred["validate"]), ("zkp_req", "nym_auth")) + self.assertNotIn("_req", cred) + for banned in ("source_format", "doctype_value"): + self.assertNotIn(banned, cred, banned) + first = cred["req"][0] + self.assertEqual((first["fn_id"], first["params"]["ver"], first["params"]["prf"]), ("pseudonym", 1, "sha-256")) + self.assertEqual(first["params"]["config"], "known-offender-matching-ssc") + for item in cred["req"]: + self.assertIn("fn_id", item) + self.assertNotIn("id", item, "`id` was the pre-v0.4 item key") + ages = [i for i in presets["nym_auth_pseudonym_age"]["credentials"]["nym_auth"]["req"] if i["fn_id"] == "age_check"] + self.assertEqual(len(ages), 1) + self.assertEqual(len(presets["nym_auth_pseudonym"]["credentials"]["nym_auth"]["req"]), 1) + + def test_no_pre_v0_4_names_remain(self): + """vc fails startup on circuit_zk / circuit_id_values / circuit_proof_system_values, naming the replacement.""" + text = self.text + for old in ("circuit_zk", "circuit_id_values", "circuit_proof_system_values"): + self.assertNotIn(old, text, old) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pdp_verifier_ids_render.py b/tests/test_pdp_verifier_ids_render.py new file mode 100644 index 00000000..84708af8 --- /dev/null +++ b/tests/test_pdp_verifier_ids_render.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""gdc's PDP policies for context.verifier_ids (sirosfoundation/go-trust PR 218; PREPARED, deployed only with a go-trust +release that contains it). The point of the shape is what it does NOT change: policies exist only for the two verifier +actions, so every other action is evaluated by all registries exactly as before.""" +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +import env_config # noqa: E402 +from sirosid_core.naming import Naming # noqa: E402 +from sirosid_core.render import render # noqa: E402 +from sirosid_core.resources import Resources # noqa: E402 + +NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm") + + +@NEEDS +class GdcVerifierIds(unittest.TestCase): + @classmethod + def setUpClass(cls): + tmp = Path(tempfile.mkdtemp()) + try: + values = env_config.load_environment_config("gdc").get("values") or {} + render("fly", ROOT / "chart", env="gdc", resources=Resources(ROOT), out_dir=tmp / "o", secrets_dir=tmp / "s", + env_values=values, naming=Naming(env="gdc"), say=lambda *a, **k: None, mongo_password="x") + cls.cfg = yaml.safe_load((tmp / "o" / "fly-gdc" / "pdp.yaml").read_text()) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_only_the_two_verifier_actions_have_a_policy_and_each_returns_the_dns_name(self): + policies = self.cfg["policies"]["policies"] + self.assertEqual(set(policies), {"credential-verifier", "verifier"}) + for name, policy in policies.items(): + self.assertEqual(policy["constraints"], {"return_verifier_ids": ["x509_san_dns"]}, name) + + def test_nothing_else_is_constrained(self): + """default_policy would route every unnamed action through a policy and fail_closed_on_unknown_action would deny + every named action not listed; registries / allowed_key_types / require_key_binding would change who is asked.""" + section = self.cfg["policies"] + for banned in ("default_policy", "fail_closed_on_unknown_action"): + self.assertNotIn(banned, self.cfg, banned) + self.assertNotIn(banned, section, banned) + self.assertEqual(set(section), {"policies"}) + for policy in section["policies"].values(): + self.assertEqual(set(policy), {"description", "constraints"}) + self.assertEqual(set(policy["constraints"]), {"return_verifier_ids"}) + + def test_the_whitelist_still_routes_the_verifier_actions(self): + actions = self.cfg["registries"]["whitelist"]["actions"] + self.assertEqual(actions["credential-verifier"], "verifiers") + self.assertEqual(actions["verifier"], "verifiers") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verifier_credential_metadata.py b/tests/test_verifier_credential_metadata.py new file mode 100644 index 00000000..409b04bf --- /dev/null +++ b/tests/test_verifier_credential_metadata.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""The verifier's common.credential_metadata must not list the pre-authorized offer twins (`_offer`): they are +issuance-only scopes, and listing them doubled every entry of the verifier's /ui/metadata and picker. The issuer's +block keeps them (an offer's credential_configuration_id is resolved against credential_configurations_supported), +and the base scopes stay everywhere - nym_auth included, because the zkp_req presets take source_format and +doctype_value from credential_metadata[nym_auth].""" +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +import env_config # noqa: E402 +from sirosid_core.naming import Naming # noqa: E402 +from sirosid_core.render import render # noqa: E402 +from sirosid_core.resources import Resources # noqa: E402 + +NEEDS = unittest.skipUnless(shutil.which("helm"), "needs helm") + + +def render_gdc(): + tmp = Path(tempfile.mkdtemp()) + try: + values = env_config.load_environment_config("gdc").get("values") or {} + render("fly", ROOT / "chart", env="gdc", resources=Resources(ROOT), out_dir=tmp / "o", secrets_dir=tmp / "s", + env_values=values, naming=Naming(env="gdc"), say=lambda *a, **k: None, mongo_password="x") + out = tmp / "o" / "fly-gdc" + return {n: yaml.safe_load((out / f"{n}.yaml").read_text()) for n in ("vc-verifier", "vc-apigw", "vc-issuer")} + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +@NEEDS +class VerifierHidesOfferTwins(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.cfg = render_gdc() + cls.verifier = cls.cfg["vc-verifier"]["common"]["credential_metadata"] + cls.apigw = cls.cfg["vc-apigw"]["common"]["credential_metadata"] + + def test_no_twin_reaches_the_verifier(self): + self.assertEqual([k for k in self.verifier if k.endswith("_offer")], []) + + def test_every_base_scope_stays_and_nym_auth_resolves_for_the_presets(self): + base = {k for k in self.apigw if not k.endswith("_offer")} + self.assertEqual(set(self.verifier), base) + self.assertIn("nym_auth", self.verifier) + self.assertEqual(self.verifier["nym_auth"]["format"], "mso_mdoc") + self.assertEqual(self.verifier["nym_auth"]["mddl_file_path"], "/vctms/nym_auth.mdoc.json") + + def test_the_issuer_keeps_its_twins(self): + twins = {k for k in self.apigw if k.endswith("_offer")} + self.assertIn("nym_auth_offer", twins) + self.assertTrue(all(t[:-len("_offer")] in self.verifier for t in twins), "every twin has its base scope") + self.assertEqual(set(self.cfg["vc-issuer"]["common"]["credential_metadata"]) & twins, twins, + "issuer-core's block is the unfiltered one") + + def test_the_picker_sees_only_base_scopes(self): + """The verifier's picker iterates common.credential_metadata wholesale; this is the count it renders.""" + twins = [k for k in self.apigw if k.endswith("_offer")] + self.assertEqual(len(self.verifier), len(self.apigw) - len(twins)) + self.assertEqual(len(self.verifier), 14, "gdc: 26 entries minus the 12 twins") + + +if __name__ == "__main__": + unittest.main() diff --git a/values-base.yaml b/values-base.yaml index d23fd178..a68bab17 100644 --- a/values-base.yaml +++ b/values-base.yaml @@ -353,7 +353,7 @@ features: claims: [given_name, family_name] # Nym-authentication attestation (sirosfoundation/nym_auth_rulebook v0.3): # birth_date + ssc (SHA-256 of a 16-byte secret seed shared by issuer and - # prover) + optional psc, for circuit_zk pseudonym and age-predicate + # prover) + optional psc, for zkp_req (formerly circuit_zk) pseudonym and age-predicate # presentations. doctype and namespace are both eu.europa.ec.eudi.nym_auth.1. # # fixtures/vc-metadata/nym_auth.mdoc.json is a byte-for-byte COPY of the @@ -653,6 +653,11 @@ issuer: secret_file_path: "/secrets/secrets.yaml" verifier: + # Fly deploys only (read by sirosid_core.deploy.generate_pki, not rendered into the chart): re-issue the signing + # certificate once, from the same key and root, with the verifier's public host as a DNS SAN (the verifier's + # client_id is x509_san_dns: and it signs request objects with this certificate). Off = the + # certificate is left as it is. A test-environment shortcut: the verifier and the issuer share one key. + signingCertDnsSan: false oidc_provider: enabled: true subjectType: pairwise